From ac0ad14224129b7265fbec770d07d8c9b8222731 Mon Sep 17 00:00:00 2001 From: idevlab Date: Mon, 26 Oct 2026 14:51:48 +0800 Subject: [PATCH] feat: add selected-text voice editing on macOS Extract the desktop selected-text voice editing from #124 so it no longer depends on the iOS keyboard stack (#121) and lands directly on main. Selected-text voice edits replace the frozen selection automatically after generation and capture drain, through the existing Session / target lease / output transaction. The HUD shows only progress, cancellation and a confirmed one-use Restore. Esc cancels before delivery; Command-Option-Z restores the confirmed original once. Remote-model disclosure is required before selected text is sent, and selected content stays out of generic history. Source: de895347, 72dc402f, 1eae6726, 27ffc8c1, da43ec84 on research/voicecursor-voice-editing-20261008, rebased onto origin/main. Left out on purpose (still in #124 / #121): UtterMobile, UtterKeyboardBridge, iOS/ targets and the mobile selection scripts; the offline-sandbox build and evidence tooling (build-app.sh options, guarded-offline-build.py, capture-candidate.py). Merge decisions against main: - Secure input now refuses every write, including the clipboard-only copy, on the Chromium web-editor path too. The main-side test that expected a clipboard copy under secure input is updated to match. - Test doubles on main (UpdateFakeExecution) gained the new SessionExecutionService requirements. Co-authored-by: Cursor --- Sources/UtterContracts/DeliveryServices.swift | 28 +- .../UtterContracts/LLMFinalTextOutput.swift | 10 +- .../Resources/en.lproj/Localizable.strings | 26 + .../zh-Hans.lproj/Localizable.strings | 26 + .../SelectionEditProposal.swift | 23 + .../SelectionEditRemoteConsent.swift | 29 + .../SessionExecutionService.swift | 28 +- .../SessionOutputStateService.swift | 24 +- .../UtterContracts/SessionPresentation.swift | 10 +- .../TextProcessingOptions.swift | 2 +- .../UtterIngress/HotkeySessionBinding.swift | 2 +- .../Native/AXTargetState.swift | 14 +- .../Native/NativeDeliveryTarget.swift | 10 +- .../Native/NativeOutputAnchor.swift | 3 +- .../Native/NativeOutputBackend.swift | 7 + .../Native/NativeTargetLease.swift | 12 +- .../ScopedOutputService.swift | 3 +- .../TextDeliveryTransaction.swift | 15 +- .../Native/ConfuciusModelDownloader.swift | 23 +- .../CarbonOverlayShortcuts.swift | 59 + .../NativeDesktopHost+Actions.swift | 38 + .../NativeDesktopHost+Review.swift | 60 + .../NativeDesktopHost+Shortcuts.swift | 33 + .../UtterPresentation/NativeDesktopHost.swift | 42 +- .../UtterPresentation/OverlayShortcuts.swift | 61 + .../PresentationPlugins.swift | 30 +- .../UI/OverlayActionButton.swift | 2 +- .../UtterPresentation/UI/OverlayLayout.swift | 93 + .../UtterPresentation/UI/OverlayPanel.swift | 9 +- .../UI/OverlayPanelContent.swift | 166 +- .../UI/RemoteDisclosureView.swift | 91 + .../UI/SelectionReviewCallbacks.swift | 34 + .../UI/SelectionReviewMetrics.swift | 52 + .../UI/SelectionReviewView.swift | 92 + .../UtterPresentationContracts/AppState.swift | 8 + .../PresentationServices.swift | 16 +- .../GeneratedOutputNormalizer.swift | 42 + .../Native/TextProcessor+Generation.swift | 5 +- .../Native/TextProcessor+Output.swift | 35 +- .../Native/TextProcessor+SelectionEdit.swift | 109 +- ...lectionEditRecipe+AsianInstructions.swift} | 6 +- ...=> SelectionEditRecipe+Instructions.swift} | 12 +- .../SelectionEditRecipe+Options.swift | 55 + ...ft => SelectionEditRecipe+Prompting.swift} | 17 +- .../UtterProcessing/SelectionEditRecipe.swift | 47 + .../RemoteLLMResponseText.swift | 35 +- .../Native/VoiceSessionJob+Capture.swift | 4 +- .../Native/VoiceSessionJob+Editing.swift | 34 +- .../VoiceSessionJob+SelectionEdit.swift | 27 + .../UtterSession/Native/VoiceSessionJob.swift | 30 +- .../Native/VoiceWorkflowFactory.swift | 23 +- .../RestoreSelectionEditJob.swift | 53 + Sources/UtterSession/SessionControl.swift | 47 + .../SessionDriver+Observation.swift | 26 + .../SessionDriver+Settlement.swift | 25 + .../UtterSession/SessionDriver+Waiting.swift | 39 + Sources/UtterSession/SessionDriver.swift | 178 +- Sources/UtterSession/SessionOutputState.swift | 63 +- .../AutoCantonesePromptTests.swift | 12 +- .../ConfuciusModelDownloaderTests.swift | 22 + .../HeldSelectionGeneration.swift | 22 + .../LLMOutputContractTests.swift | 6 +- .../MultilingualPromptTests.swift | 16 +- ...NativeSelectionReviewAppearanceTests.swift | 92 + ...ativeSelectionReviewInteractionTests.swift | 89 + .../NativeSelectionReviewTestSupport.swift | 152 ++ .../OpenTypeTests/OverlayShortcutTests.swift | 75 + .../PromptDelimiterSafetyTests.swift | 4 +- .../RuntimeContextPromptTests.swift | 2 +- .../SelectionEditCustomIntentTests.swift | 14 +- .../SelectionEditPromptTests.swift | 52 +- .../SelectionGenerationPrivacyTests.swift | 60 + .../SelectionRemoteOutputFixture.swift | 107 + .../SelectionRemoteOutputTests.swift | 196 ++ Tests/OpenTypeTests/Support/AppSettings.swift | 2 +- .../OpenTypeTests/Support/InputHistory.swift | 2 +- .../Support/PersonalDictionary.swift | 2 +- .../Support/TestFixtureRoot.swift | 28 + .../TestFixtureIsolationTests.swift | 71 + .../TestOverlayShortcutRegistration.swift | 20 + .../OpenTypeTests/UpdatePluginFixtures.swift | 3 + ...iceEditingNativeE2EPresentationTests.swift | 125 ++ .../VoiceEditingNativeE2ESafetyTests.swift | 103 + ...ceEditingNativeE2ESecureSessionTests.swift | 116 + .../VoiceEditingNativeE2EUIHelpers.swift | 109 + Tests/OpenTypeTests/VoiceEditingTests.swift | 15 +- .../OpenTypeTests/VoiceReplacementTests.swift | 19 + .../VoiceSelectedEditingBoundaryTests.swift | 124 ++ .../VoiceSelectedRetentionTests.swift | 118 ++ Tests/OpenTypeTests/VoiceTextInputTests.swift | 98 +- .../OpenTypeTests/VoiceWorkflowFixture.swift | 69 +- .../VoiceWorkflowTargetFixture.swift | 72 + .../KeyboardDeliveryReceiptTests.swift | 48 + .../HotkeySessionBindingTests.swift | 81 +- .../DeliveryReceiptTests.swift | 64 + .../TextDeliveryTransactionTests.swift | 32 +- .../SelectionEditRecipeTests.swift | 101 + .../RestoreSelectionEditJobTests.swift | 139 ++ .../SelectionReceiptRetentionTests.swift | 104 + .../SessionControlTests.swift | 53 + .../build-preparation-r5.md | 57 + .../build-result-r4.md | 78 + .../context-build-r10.md | 25 + .../desktop-review-prototype.html | 170 ++ .../implementation-result.md | 12 + .../intent.md | 38 + .../manifest-compat-r13.md | 11 + .../offline-registration-r6.md | 54 + .../offline-unit-r9.md | 43 + .../pending-behavior-check.py | 55 + .../2026-10-08-voice-editing-research/plan.md | 121 ++ .../platform-evidence.md | 73 + .../research.md | 72 + .../rule-context-r11.md | 13 + .../sandbox-registration-r8.md | 35 + .../source-evidence.md | 63 + .../2026-10-08-voice-editing-research/spec.md | 60 + .../swiftpm-result-r7.md | 46 + .../ui-design.md | 54 + .../verification.md | 361 ++++ .../workflow.json | 1872 +++++++++++++++++ 121 files changed, 7474 insertions(+), 571 deletions(-) create mode 100644 Sources/UtterContracts/SelectionEditProposal.swift create mode 100644 Sources/UtterContracts/SelectionEditRemoteConsent.swift create mode 100644 Sources/UtterPresentation/CarbonOverlayShortcuts.swift create mode 100644 Sources/UtterPresentation/NativeDesktopHost+Review.swift create mode 100644 Sources/UtterPresentation/NativeDesktopHost+Shortcuts.swift create mode 100644 Sources/UtterPresentation/OverlayShortcuts.swift create mode 100644 Sources/UtterPresentation/UI/OverlayLayout.swift create mode 100644 Sources/UtterPresentation/UI/RemoteDisclosureView.swift create mode 100644 Sources/UtterPresentation/UI/SelectionReviewCallbacks.swift create mode 100644 Sources/UtterPresentation/UI/SelectionReviewMetrics.swift create mode 100644 Sources/UtterPresentation/UI/SelectionReviewView.swift create mode 100644 Sources/UtterProcessing/GeneratedOutputNormalizer.swift rename Sources/UtterProcessing/{Native/TextProcessor+SelectionEditInstructions+Asian.swift => SelectionEditRecipe+AsianInstructions.swift} (98%) rename Sources/UtterProcessing/{Native/TextProcessor+SelectionEditInstructions.swift => SelectionEditRecipe+Instructions.swift} (97%) create mode 100644 Sources/UtterProcessing/SelectionEditRecipe+Options.swift rename Sources/UtterProcessing/{Native/TextProcessor+SelectionEditPrompting.swift => SelectionEditRecipe+Prompting.swift} (93%) create mode 100644 Sources/UtterProcessing/SelectionEditRecipe.swift create mode 100644 Sources/UtterSession/Native/VoiceSessionJob+SelectionEdit.swift create mode 100644 Sources/UtterSession/RestoreSelectionEditJob.swift create mode 100644 Sources/UtterSession/SessionDriver+Observation.swift create mode 100644 Sources/UtterSession/SessionDriver+Settlement.swift create mode 100644 Sources/UtterSession/SessionDriver+Waiting.swift create mode 100644 Tests/OpenTypeTests/HeldSelectionGeneration.swift create mode 100644 Tests/OpenTypeTests/NativeSelectionReviewAppearanceTests.swift create mode 100644 Tests/OpenTypeTests/NativeSelectionReviewInteractionTests.swift create mode 100644 Tests/OpenTypeTests/NativeSelectionReviewTestSupport.swift create mode 100644 Tests/OpenTypeTests/OverlayShortcutTests.swift create mode 100644 Tests/OpenTypeTests/SelectionGenerationPrivacyTests.swift create mode 100644 Tests/OpenTypeTests/SelectionRemoteOutputFixture.swift create mode 100644 Tests/OpenTypeTests/SelectionRemoteOutputTests.swift create mode 100644 Tests/OpenTypeTests/Support/TestFixtureRoot.swift create mode 100644 Tests/OpenTypeTests/TestFixtureIsolationTests.swift create mode 100644 Tests/OpenTypeTests/TestOverlayShortcutRegistration.swift create mode 100644 Tests/OpenTypeTests/VoiceEditingNativeE2EPresentationTests.swift create mode 100644 Tests/OpenTypeTests/VoiceEditingNativeE2ESafetyTests.swift create mode 100644 Tests/OpenTypeTests/VoiceEditingNativeE2ESecureSessionTests.swift create mode 100644 Tests/OpenTypeTests/VoiceEditingNativeE2EUIHelpers.swift create mode 100644 Tests/OpenTypeTests/VoiceSelectedEditingBoundaryTests.swift create mode 100644 Tests/OpenTypeTests/VoiceSelectedRetentionTests.swift create mode 100644 Tests/OpenTypeTests/VoiceWorkflowTargetFixture.swift create mode 100644 Tests/UtterContractsTests/KeyboardDeliveryReceiptTests.swift create mode 100644 Tests/UtterProcessingTests/SelectionEditRecipeTests.swift create mode 100644 Tests/UtterSessionTests/RestoreSelectionEditJobTests.swift create mode 100644 Tests/UtterSessionTests/SelectionReceiptRetentionTests.swift create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/build-preparation-r5.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/build-result-r4.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/context-build-r10.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/desktop-review-prototype.html create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/implementation-result.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/intent.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/manifest-compat-r13.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/offline-registration-r6.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/offline-unit-r9.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/pending-behavior-check.py create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/plan.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/platform-evidence.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/research.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/rule-context-r11.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/sandbox-registration-r8.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/source-evidence.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/spec.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/swiftpm-result-r7.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/ui-design.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/verification.md create mode 100644 docs/sdlc/changes/2026-10-08-voice-editing-research/workflow.json diff --git a/Sources/UtterContracts/DeliveryServices.swift b/Sources/UtterContracts/DeliveryServices.swift index 59f13dcc..86473f31 100644 --- a/Sources/UtterContracts/DeliveryServices.swift +++ b/Sources/UtterContracts/DeliveryServices.swift @@ -2,7 +2,7 @@ import Foundation import UtterRuntime package enum DeliveryDisposition: Equatable { case notCommitted, accepted, uncertain } -package enum DeliveryEffect: Hashable { case none, clipboard, paste, keyPress, accessibility } +package enum DeliveryEffect: Hashable { case none, clipboard, paste, keyPress, accessibility, keyboardInsertion } package enum DeliveryConfirmation: String, Codable, Sendable { case none, clipboardValue, targetValue } package enum DeliveryStatus: String, Codable, Sendable { case inserted, copied, uncertain, notDelivered } @@ -48,17 +48,28 @@ package struct DeliveryRequest { } } +package struct SelectionEditReceipt { + package let operationID: UUID + package let originalSelection: String + package let replacement: String + package let anchor: any OutputAnchor + package init(operationID: UUID, originalSelection: String, replacement: String, anchor: any OutputAnchor) { + self.operationID = operationID; self.originalSelection = originalSelection + self.replacement = replacement; self.anchor = anchor + } +} + package struct DeliveryReceipt { package let operationID: UUID package let disposition: DeliveryDisposition package let effects: Set package let confirmation: DeliveryConfirmation package var effect: DeliveryEffect { - [.accessibility, .paste, .keyPress, .clipboard].first { effects.contains($0) } ?? .none + [.accessibility, .paste, .keyPress, .keyboardInsertion, .clipboard].first { effects.contains($0) } ?? .none } package var isConfirmedInsertion: Bool { disposition == .accepted && confirmation == .targetValue - && !effects.isDisjoint(with: [.accessibility, .paste, .keyPress]) + && !effects.isDisjoint(with: [.accessibility, .paste, .keyPress, .keyboardInsertion]) } package var status: DeliveryStatus { if isConfirmedInsertion { return .inserted } @@ -67,13 +78,15 @@ package struct DeliveryReceipt { } package let reason: String? package let anchor: (any OutputAnchor)? + package let selectionEdit: SelectionEditReceipt? package init(operationID: UUID, disposition: DeliveryDisposition, effect: DeliveryEffect, reason: String? = nil, anchor: (any OutputAnchor)? = nil, - effects: Set? = nil, confirmation: DeliveryConfirmation = .none) { + effects: Set? = nil, confirmation: DeliveryConfirmation = .none, + selectionEdit: SelectionEditReceipt? = nil) { self.operationID = operationID let committed = (effects ?? (effect == .none ? [] : [effect])).subtracting([.none]) - let insertionConfirmed = confirmation == .targetValue && !committed.isDisjoint(with: [.accessibility, .paste, .keyPress]) + let insertionConfirmed = confirmation == .targetValue && !committed.isDisjoint(with: [.accessibility, .paste, .keyPress, .keyboardInsertion]) let copyConfirmed = confirmation == .clipboardValue && committed == [.clipboard] self.disposition = committed.isEmpty ? .notCommitted : disposition == .accepted && (insertionConfirmed || copyConfirmed) ? .accepted : .uncertain @@ -81,6 +94,11 @@ package struct DeliveryReceipt { self.confirmation = self.disposition == .accepted ? confirmation : .none self.reason = reason self.anchor = self.disposition == .accepted && insertionConfirmed ? anchor : nil + self.selectionEdit = self.anchor != nil && selectionEdit?.operationID == operationID + && selectionEdit?.anchor === self.anchor + && selectionEdit?.originalSelection.isEmpty == false + && selectionEdit?.replacement.isEmpty == false + ? selectionEdit : nil } } diff --git a/Sources/UtterContracts/LLMFinalTextOutput.swift b/Sources/UtterContracts/LLMFinalTextOutput.swift index 23540560..401aecbc 100644 --- a/Sources/UtterContracts/LLMFinalTextOutput.swift +++ b/Sources/UtterContracts/LLMFinalTextOutput.swift @@ -1,6 +1,8 @@ import Foundation package enum LLMFinalTextOutput { + // Selection generation scopes every upstream unwrap, including tool/SSE payloads. + @TaskLocal package static var requiresExplicitFinalText = false package static func text(from rawText: String) -> String? { wholeJSONText(from: rawText) } @@ -23,7 +25,7 @@ private extension LLMFinalTextOutput { "output_text", "outputText", "result_text", "resultText", ] static let typedFinalTextValues = [ - "output_text", "final_text", "formatted_text", "cleaned_text", "rewritten_text", + "output_text", "final_text", "formatted_text", "cleaned_text", "rewritten_text", "result_text", ] static let typedFinalTextPayloadKeys = [ "text", "content", "value", "output", "data", @@ -85,7 +87,7 @@ private extension LLMFinalTextOutput { if let text = responseWrapperText(in: object) { return text } - guard allowsAmbiguousKeys || hasMetadata(in: object) else { return nil } + guard !requiresExplicitFinalText && (allowsAmbiguousKeys || hasMetadata(in: object)) else { return nil } for key in ambiguousTextKeys { guard let rawValue = object.value(forCaseInsensitiveKey: key), let text = finalTextValue(from: rawValue, allowsAmbiguousKeys: true) else { @@ -163,6 +165,10 @@ private extension LLMFinalTextOutput { if let text = value as? String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + if requiresExplicitFinalText, + let data = wholeJSONValueData(from: stripWrappingCodeFence(from: trimmed)) { + return finalText(from: data, allowsAmbiguousKeys: false) + } return nestedStructuredFinalText(in: trimmed) ?? trimmed } if let object = value as? [String: Any] { diff --git a/Sources/UtterContracts/Resources/en.lproj/Localizable.strings b/Sources/UtterContracts/Resources/en.lproj/Localizable.strings index 58c415ad..2aee5317 100644 --- a/Sources/UtterContracts/Resources/en.lproj/Localizable.strings +++ b/Sources/UtterContracts/Resources/en.lproj/Localizable.strings @@ -692,3 +692,29 @@ "model.source.help" = "Changes apply to downloads started afterward. Active downloads keep their source."; "model.source.mirror_help" = "Public models download from hf-mirror.com. Saved Hugging Face credentials are not sent to the mirror."; "model.source.confirm" = "Download source: %@"; + +"selection_edit.review" = "Review the selected text before applying"; +"selection_edit.title" = "Edit selection in %@"; +"selection_edit.title_generic" = "Edit selected text"; +"selection_edit.instruction" = "You said"; +"selection_edit.original" = "Original"; +"selection_edit.revised" = "Revised"; +"selection_edit.apply" = "Apply"; +"selection_edit.apply_hint" = "Replaces the selected text. Nothing changes until you press Apply."; +"selection_edit.expired" = "This edit expired or the selection changed. Nothing was changed."; +"selection_edit.applied" = "Selection updated"; +"selection_edit.restore" = "Restore original"; +"selection_edit.restore_failed" = "Couldn't restore. Nothing was changed."; +"selection_edit.remote_disclosure_required" = "The selected text would be sent to a remote model. Nothing was sent. Confirm before it is sent."; +"selection_edit.remote_title" = "Send selected text to %@?"; +"selection_edit.remote_title_generic" = "Send selected text to a remote model?"; +"selection_edit.remote_lead" = "Nothing has been recorded or sent yet. If you continue, the selected text, what you say and nearby context from the app are sent to %@."; +"selection_edit.remote_no_provider" = "The remote provider address is missing. Nothing was recorded or sent."; +"selection_edit.remote_selected" = "Selected text"; +"selection_edit.remote_continue_hint" = "Starts recording. Your selected text is sent to the provider after you finish speaking."; +"selection_edit.remote_failed" = "Couldn't continue. Nothing was recorded or sent."; +"selection_edit.remote_not_confirmed" = "Not confirmed in time, or the selection changed. Nothing was recorded or sent."; + +"selection_edit.restore_shortcut" = "⌘⌥Z Restore original"; +"selection_edit.cancel_shortcut" = "Esc Cancel edit"; +"selection_edit.processing" = "Editing selection…"; diff --git a/Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings b/Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings index d7dcdb2a..44b08459 100644 --- a/Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings @@ -692,3 +692,29 @@ "model.source.help" = "更改仅影响之后开始的下载,进行中的下载继续使用原来源。"; "model.source.mirror_help" = "公开模型将从 hf-mirror.com 下载,不向镜像发送已保存的 Hugging Face 凭证。"; "model.source.confirm" = "下载源:%@"; + +"selection_edit.review" = "请审阅选区修改后再应用"; +"selection_edit.title" = "在 %@ 中修改选中文本"; +"selection_edit.title_generic" = "修改选中文本"; +"selection_edit.instruction" = "你的指令"; +"selection_edit.original" = "原文"; +"selection_edit.revised" = "修改后"; +"selection_edit.apply" = "应用"; +"selection_edit.apply_hint" = "将替换选中的文本。点击“应用”之前不会有任何改动。"; +"selection_edit.expired" = "此修改已过期,或选区已变化。未做任何改动。"; +"selection_edit.applied" = "选区已更新"; +"selection_edit.restore" = "恢复原文"; +"selection_edit.restore_failed" = "无法恢复,未做任何改动。"; +"selection_edit.remote_disclosure_required" = "选中的文本将发送到远程模型。目前尚未发送,需确认后才会发送。"; +"selection_edit.remote_title" = "要将选中的文本发送到 %@ 吗?"; +"selection_edit.remote_title_generic" = "要将选中的文本发送到远程模型吗?"; +"selection_edit.remote_lead" = "目前尚未录音或发送。继续后,选中的文本、你说的话以及应用中的附近上下文会发送到 %@。"; +"selection_edit.remote_no_provider" = "远程服务地址缺失。未录音,也未发送任何内容。"; +"selection_edit.remote_selected" = "选中的文本"; +"selection_edit.remote_continue_hint" = "开始录音。你说完后,选中的文本才会发送给该服务商。"; +"selection_edit.remote_failed" = "无法继续。未录音,也未发送任何内容。"; +"selection_edit.remote_not_confirmed" = "未及时确认,或选区已变化。未录音,也未发送任何内容。"; + +"selection_edit.restore_shortcut" = "⌘⌥Z 恢复原文"; +"selection_edit.cancel_shortcut" = "Esc 取消修改"; +"selection_edit.processing" = "正在修改选区…"; diff --git a/Sources/UtterContracts/SelectionEditProposal.swift b/Sources/UtterContracts/SelectionEditProposal.swift new file mode 100644 index 00000000..36a0a552 --- /dev/null +++ b/Sources/UtterContracts/SelectionEditProposal.swift @@ -0,0 +1,23 @@ +import Foundation + +package struct SelectionEditProposal: Equatable, Sendable { + package let id: UUID + package let sessionID: UUID + package let targetID: UUID + package let processIdentifier: Int32? + package let originalSelection: String + package let recognizedInstruction: String + package let candidate: String + package let expiresAt: Date + package init(id: UUID = UUID(), sessionID: UUID, targetID: UUID, processIdentifier: Int32? = nil, + originalSelection: String, recognizedInstruction: String, candidate: String, + expiresAt: Date = Date().addingTimeInterval(120)) { + self.id = id; self.sessionID = sessionID; self.targetID = targetID + self.processIdentifier = processIdentifier; self.originalSelection = originalSelection + self.recognizedInstruction = recognizedInstruction; self.candidate = candidate; self.expiresAt = expiresAt + } + package var isApplicable: Bool { + Date() < expiresAt && !originalSelection.isEmpty + && !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Sources/UtterContracts/SelectionEditRemoteConsent.swift b/Sources/UtterContracts/SelectionEditRemoteConsent.swift new file mode 100644 index 00000000..436c100d --- /dev/null +++ b/Sources/UtterContracts/SelectionEditRemoteConsent.swift @@ -0,0 +1,29 @@ +import Foundation + +package struct SelectionEditRemoteConsent: Equatable, Sendable { + package let selectedText: String + package let providerID: String + package let providerBaseURL: String + package let expiresAt: Date + package var providerHost: String { URL(string: providerBaseURL)?.host ?? "" } + package init(selectedText: String, providerID: String, providerBaseURL: String, + expiresAt: Date = Date().addingTimeInterval(120)) { + self.selectedText = selectedText; self.providerID = providerID + self.providerBaseURL = providerBaseURL; self.expiresAt = expiresAt + } + package func authorizes(selectedText: String, options: TextProcessingOptions) -> Bool { + Date() < expiresAt && self.selectedText == selectedText + && providerID == (options.textProviderID ?? "generation.remote") + && providerBaseURL == options.remoteBaseURL + } +} + +package struct SelectionEditRemoteDisclosureRequired: LocalizedError { + package let consent: SelectionEditRemoteConsent + package let isTargetCurrent: @MainActor @Sendable () -> Bool + package init(consent: SelectionEditRemoteConsent, + isTargetCurrent: @escaping @MainActor @Sendable () -> Bool) { + self.consent = consent; self.isTargetCurrent = isTargetCurrent + } + package var errorDescription: String? { L("selection_edit.remote_disclosure_required") } +} diff --git a/Sources/UtterContracts/SessionExecutionService.swift b/Sources/UtterContracts/SessionExecutionService.swift index 6f49ceb9..566db417 100644 --- a/Sources/UtterContracts/SessionExecutionService.swift +++ b/Sources/UtterContracts/SessionExecutionService.swift @@ -16,19 +16,24 @@ package struct SessionIntent: Equatable, Sendable { package let request: InputSessionRequest package let mode: TextProcessingMode? package let operation: SessionOperation + package let remoteSelectionConsent: SelectionEditRemoteConsent? package init(id: UUID = UUID(), clientID: String? = nil, input: SessionInput, request: InputSessionRequest = InputSessionRequest(), mode: TextProcessingMode? = nil, - operation: SessionOperation = .input) { + operation: SessionOperation = .input, remoteSelectionConsent: SelectionEditRemoteConsent? = nil) { self.id = id self.clientID = clientID self.input = input self.request = request self.mode = mode self.operation = operation + self.remoteSelectionConsent = remoteSelectionConsent } } -package enum SessionOperation: Equatable, Sendable { case input, applyReplacement(UUID), copy(String) } +package enum SessionOperation: Equatable, Sendable { + case input, applyReplacement(UUID), copy(String) + case restoreSelectionEdit(operationID: UUID) +} package struct SessionHistoryReplacement { package let recordID: UUID @@ -45,6 +50,8 @@ package enum SessionExecutionPhase: Equatable, Sendable { case created, preparing, recording, transcribing, processing, delivering, completed, cancelled, failed } +package enum SessionCaptureFinalizationReason: Equatable, Sendable { case durationLimit } + package struct SessionExecutionSnapshot: Equatable, Sendable { package let id: UUID? package let phase: SessionExecutionPhase? @@ -58,16 +65,24 @@ package struct SessionExecutionSnapshot: Equatable, Sendable { package let mode: TextProcessingMode? package let recoveryAction: SessionRecoveryAction? package let generationOutcome: EspressoGenerationOutcome? + package let selectionEditProposal: SelectionEditProposal? + package let remoteDisclosure: SelectionEditRemoteConsent? + package let isSelectionEditing: Bool package init(id: UUID? = nil, phase: SessionExecutionPhase? = nil, transcript: String = "", text: String = "", error: String? = nil, isBusy: Bool = false, deliveryStatus: DeliveryStatus? = nil, performance: SessionPerformance? = nil, audioLevel: Float = 0, mode: TextProcessingMode? = nil, - recoveryAction: SessionRecoveryAction? = nil, generationOutcome: EspressoGenerationOutcome? = nil) { + recoveryAction: SessionRecoveryAction? = nil, generationOutcome: EspressoGenerationOutcome? = nil, + selectionEditProposal: SelectionEditProposal? = nil, remoteDisclosure: SelectionEditRemoteConsent? = nil, + isSelectionEditing: Bool = false) { self.id = id; self.phase = phase; self.transcript = transcript self.text = text; self.error = error; self.isBusy = isBusy self.deliveryStatus = deliveryStatus self.performance = performance self.audioLevel = audioLevel.isFinite ? min(1, max(0, audioLevel)) : 0 self.mode = mode + self.selectionEditProposal = selectionEditProposal + self.remoteDisclosure = remoteDisclosure + self.isSelectionEditing = isSelectionEditing self.recoveryAction = recoveryAction self.generationOutcome = generationOutcome } @@ -129,15 +144,18 @@ package protocol SessionJobControl: AnyObject { var isStopped: Bool { get } func update(phase: SessionExecutionPhase, transcript: String) func cancel() + func waitForSelectionEditDecision(_ proposal: SelectionEditProposal) async throws func waitForStop() async throws func beginStage(_ stage: SessionStage) -> UUID func endStage(_ id: UUID) func updateAudioLevel(_ level: Float) + @discardableResult func finalizeCapture(reason: SessionCaptureFinalizationReason) -> Bool } @MainActor package protocol SessionJob: AnyObject { var presentationMode: TextProcessingMode? { get } + var containsSelectedText: Bool { get } func promoteToTranslation() -> TextProcessingMode? func bind(input: SessionInput) throws func attach(control: any SessionJobControl) @@ -159,6 +177,9 @@ package protocol SessionWorkflowFactory: AnyObject { package protocol SessionExecutionService: AnyObject { @discardableResult func requestStop(_ id: UUID, at timestamp: Duration?) -> Bool func promoteToTranslation(_ id: UUID, reason: HotkeyPromotion) -> Bool + @discardableResult func applySelectionEdit(sessionID: UUID, proposalID: UUID) -> Bool + func authorizeRemoteDisclosure(_ intentID: UUID) throws + func declineRemoteDisclosure(_ intentID: UUID) var snapshot: SessionExecutionSnapshot { get } func reserve(_ intent: SessionIntent) throws func activate(_ id: UUID, input: SessionInput?) throws @@ -184,6 +205,7 @@ extension SessionServices { extension SessionJob { package var presentationMode: TextProcessingMode? { nil } + package var containsSelectedText: Bool { false } package func promoteToTranslation() -> TextProcessingMode? { nil } package func bind(input: SessionInput) throws { throw IntegrationError.invalidSessionState } package func attach(control: any SessionJobControl) {} diff --git a/Sources/UtterContracts/SessionOutputStateService.swift b/Sources/UtterContracts/SessionOutputStateService.swift index ea320ddb..c4959fa9 100644 --- a/Sources/UtterContracts/SessionOutputStateService.swift +++ b/Sources/UtterContracts/SessionOutputStateService.swift @@ -6,16 +6,33 @@ package struct RecentSessionOutput { package let recordID: UUID package let text: String package let anchor: (any OutputAnchor)? - package init(recordID: UUID, text: String, anchor: (any OutputAnchor)?) { - self.recordID = recordID; self.text = text; self.anchor = anchor + package let selectionEdit: SelectionEditReceipt? + package init(recordID: UUID, text: String, anchor: (any OutputAnchor)?, selectionEdit: SelectionEditReceipt? = nil) { + self.recordID = recordID; self.text = text; self.anchor = anchor; self.selectionEdit = selectionEdit } } package struct SessionOutputSnapshot: Equatable { package let recentText: String package let pending: DeferredReplacement? - package init(recentText: String = "", pending: DeferredReplacement? = nil) { + package let selectionEditRestoration: SelectionEditRestoration? + package init(recentText: String = "", pending: DeferredReplacement? = nil, + selectionEditRestoration: SelectionEditRestoration? = nil) { self.recentText = recentText; self.pending = pending + self.selectionEditRestoration = selectionEditRestoration + } +} + +package struct SelectionEditRestoration: Equatable, Sendable { + package let operationID: UUID + package let originalSelection: String + package let replacement: String + package let expiresAt: Date + package let isAvailable: Bool + package init(operationID: UUID, originalSelection: String, replacement: String, + expiresAt: Date, isAvailable: Bool) { + self.operationID = operationID; self.originalSelection = originalSelection + self.replacement = replacement; self.expiresAt = expiresAt; self.isAvailable = isAvailable } } @@ -25,6 +42,7 @@ package protocol SessionOutputStateService: AnyObject { var recent: RecentSessionOutput? { get } var pendingAnchor: (any OutputAnchor)? { get } func remember(_ completion: SessionCompletion, recordID: UUID) + @discardableResult func consumeSelectionEdit(_ operationID: UUID) -> Bool @discardableResult func installPending(_ replacement: DeferredReplacement, anchor: (any OutputAnchor)?) -> Bool @discardableResult func updatePending(_ id: UUID, mutation: (inout DeferredReplacement) -> Void) -> Bool func clearPending(_ id: UUID?) diff --git a/Sources/UtterContracts/SessionPresentation.swift b/Sources/UtterContracts/SessionPresentation.swift index 73dee9e2..2653d4f7 100644 --- a/Sources/UtterContracts/SessionPresentation.swift +++ b/Sources/UtterContracts/SessionPresentation.swift @@ -7,15 +7,19 @@ package struct SessionPresentation: Equatable, Sendable { package let status: Status package let message: String package let canCopy: Bool - package var keepsVisible: Bool { status == .failed || status == .uncertain } + private let isReviewing: Bool + package var keepsVisible: Bool { isReviewing || status == .failed || status == .uncertain } package init(_ snapshot: SessionExecutionSnapshot) { + isReviewing = snapshot.selectionEditProposal != nil || snapshot.remoteDisclosure != nil canCopy = !snapshot.isBusy && !snapshot.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty switch snapshot.phase { - case .created, .preparing: status = .preparing; message = L("pipeline.preparing_capture") + case .created, .preparing: + status = .preparing + message = L(snapshot.remoteDisclosure == nil ? "pipeline.preparing_capture" : "selection_edit.remote_disclosure_required") case .recording: status = .recording; message = L("pipeline.recording") case .transcribing: status = .transcribing; message = L("pipeline.transcribing") - case .processing: status = .processing; message = L("pipeline.formatting") + case .processing: status = .processing; message = L(snapshot.isSelectionEditing ? "selection_edit.processing" : isReviewing ? "selection_edit.review" : "pipeline.formatting") case .delivering: status = .delivering; message = L("pipeline.inserting") case .completed where snapshot.deliveryStatus == .inserted: status = .inserted; message = L("status.done") case .completed where snapshot.deliveryStatus == .copied: status = .copied; message = L("delivery.copied") diff --git a/Sources/UtterContracts/TextProcessingOptions.swift b/Sources/UtterContracts/TextProcessingOptions.swift index 2608ed69..04889ddf 100644 --- a/Sources/UtterContracts/TextProcessingOptions.swift +++ b/Sources/UtterContracts/TextProcessingOptions.swift @@ -1,6 +1,6 @@ import Foundation -package struct GenerationOptions { +package struct GenerationOptions: Sendable { package let maxTokens: Int package let temperature: Double package init(maxTokens: Int, temperature: Double) { self.maxTokens = maxTokens; self.temperature = temperature } diff --git a/Sources/UtterIngress/HotkeySessionBinding.swift b/Sources/UtterIngress/HotkeySessionBinding.swift index 26cdf246..204d011c 100644 --- a/Sources/UtterIngress/HotkeySessionBinding.swift +++ b/Sources/UtterIngress/HotkeySessionBinding.swift @@ -31,7 +31,7 @@ package final class HotkeySessionBinding { let mode: TextProcessingMode? = action == .translation ? .translation(settings.values.translationTargetLanguage) : nil do { try execution.start(SessionIntent(id: id, input: .local, mode: mode)) - ownedID = id + if execution.snapshot.id == id, execution.snapshot.isBusy { ownedID = id } } catch { log.error("Hotkey session could not start: \(error.localizedDescription)") } } diff --git a/Sources/UtterMacServices/Native/AXTargetState.swift b/Sources/UtterMacServices/Native/AXTargetState.swift index 3b549224..2baa0819 100644 --- a/Sources/UtterMacServices/Native/AXTargetState.swift +++ b/Sources/UtterMacServices/Native/AXTargetState.swift @@ -26,9 +26,11 @@ struct AXTargetState { } static func focusedIdentity() -> (Int32, AXUIElement)? { - guard AXIsProcessTrusted(), let app = NSWorkspace.shared.frontmostApplication else { return nil } + guard !NativeDeliveryKeys.isSecureInput, AXIsProcessTrusted(), + let app = NSWorkspace.shared.frontmostApplication else { return nil } var focused: CFTypeRef? - guard AXUIElementCopyAttributeValue(AXUIElementCreateApplication(app.processIdentifier), + guard !NativeDeliveryKeys.isSecureInput, + AXUIElementCopyAttributeValue(AXUIElementCreateApplication(app.processIdentifier), kAXFocusedUIElementAttribute as CFString, &focused) == .success, let focused, CFGetTypeID(focused) == AXUIElementGetTypeID() else { return nil } return (app.processIdentifier, focused as! AXUIElement) @@ -48,13 +50,14 @@ struct AXTargetState { static func setRange(_ range: NSRange, of element: AXUIElement) -> Bool { var value = CFRange(location: range.location, length: range.length) - guard let encoded = AXValueCreate(.cfRange, &value) else { return false } + guard let encoded = AXValueCreate(.cfRange, &value), !NativeDeliveryKeys.isSecureInput else { return false } return AXUIElementSetAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, encoded) == .success } static func range(of element: AXUIElement) -> NSRange? { var encoded: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &encoded) == .success, + guard !NativeDeliveryKeys.isSecureInput, + AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &encoded) == .success, let encoded, CFGetTypeID(encoded) == AXValueGetTypeID() else { return nil } var range = CFRange() guard AXValueGetValue(encoded as! AXValue, .cfRange, &range), range.location >= 0, range.length >= 0 else { return nil } @@ -69,7 +72,8 @@ struct AXTargetState { static func value(of element: AXUIElement) -> String? { var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &value) == .success else { return nil } + guard !NativeDeliveryKeys.isSecureInput, + AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &value) == .success else { return nil } return value as? String } } diff --git a/Sources/UtterMacServices/Native/NativeDeliveryTarget.swift b/Sources/UtterMacServices/Native/NativeDeliveryTarget.swift index 6b67ce99..3ac7fea6 100644 --- a/Sources/UtterMacServices/Native/NativeDeliveryTarget.swift +++ b/Sources/UtterMacServices/Native/NativeDeliveryTarget.swift @@ -16,7 +16,8 @@ final class NativeDeliveryTarget: DeliveryTextTarget { init?(lease: any OutputTargetLease, anchor: (any OutputAnchor)?, requiresSelection: Bool, classifiesWebContentEditors: Bool = true, clock: (any DeliveryObservationClock)? = nil) { - guard lease.isCurrent, let (pid, element) = AXTargetState.focusedIdentity(), pid == lease.processIdentifier else { return nil } + guard !NativeDeliveryKeys.isSecureInput, lease.isCurrent, + let (pid, element) = AXTargetState.focusedIdentity(), pid == lease.processIdentifier else { return nil } let state = AXTargetState.capture() let value = AXTargetState.value(of: element) if requiresSelection, (state?.selection.length ?? 0) == 0 { return nil } @@ -36,7 +37,7 @@ final class NativeDeliveryTarget: DeliveryTextTarget { } var isCurrent: Bool { - guard lease.isValid, let (pid, element) = AXTargetState.focusedIdentity(), + guard !NativeDeliveryKeys.isSecureInput, lease.isValid, let (pid, element) = AXTargetState.focusedIdentity(), pid == lease.processIdentifier, CFEqual(identity, element) else { return false } if let document, AXTargetState.value(of: identity) != document { return false } if let original { @@ -46,7 +47,7 @@ final class NativeDeliveryTarget: DeliveryTextTarget { } private var canWriteSelectedText: Bool { - guard document != nil, replacementRange != nil else { return false } + guard !NativeDeliveryKeys.isSecureInput, document != nil, replacementRange != nil else { return false } var settable = DarwinBoolean(false) return AXUIElementIsAttributeSettable(identity, kAXSelectedTextAttribute as CFString, &settable) == .success && settable.boolValue } @@ -65,7 +66,8 @@ final class NativeDeliveryTarget: DeliveryTextTarget { } func writeSelection(_ text: String) -> Bool { - AXUIElementSetAttributeValue(identity, kAXSelectedTextAttribute as CFString, text as CFString) == .success + guard !NativeDeliveryKeys.isSecureInput else { return false } + return AXUIElementSetAttributeValue(identity, kAXSelectedTextAttribute as CFString, text as CFString) == .success } func confirm(_ text: String) async -> Bool { diff --git a/Sources/UtterMacServices/Native/NativeOutputAnchor.swift b/Sources/UtterMacServices/Native/NativeOutputAnchor.swift index d744ec88..c7a49477 100644 --- a/Sources/UtterMacServices/Native/NativeOutputAnchor.swift +++ b/Sources/UtterMacServices/Native/NativeOutputAnchor.swift @@ -23,7 +23,8 @@ final class NativeOutputAnchor: OutputAnchor { } var isCurrent: Bool { - guard isOwnerCurrent(), Date() < expiresAt, let current = AXTargetState.capture(), state.matches(current), + guard isOwnerCurrent(), target.isCurrent, Date() < expiresAt, + let current = AXTargetState.capture(), state.matches(current), let document = AXTargetState.value(of: current.element) else { return false } return RecentInsertionGuard.isReplacementSafe(sameTarget: true, currentSelection: current.selection, insertedRange: range, currentText: document, inserted: text) diff --git a/Sources/UtterMacServices/Native/NativeOutputBackend.swift b/Sources/UtterMacServices/Native/NativeOutputBackend.swift index fed0e550..3f7e0c79 100644 --- a/Sources/UtterMacServices/Native/NativeOutputBackend.swift +++ b/Sources/UtterMacServices/Native/NativeOutputBackend.swift @@ -62,11 +62,18 @@ final class NativeOutputBackend { classifiesWebContentEditors: DeliveryKillSwitch.webEditorPasteEnabled(defaults)) else { return DeliveryCompletion(disposition: .notCommitted, reason: L("delivery.target_changed")) } + let originalSelection: String? + if case .replaceSelection = request.command { originalSelection = target.selectedText } + else { originalSelection = nil } var result = await TextDeliveryTransaction.deliver(text, target: destination, pasteboard: pasteboard, allowsClipboardPaste: request.allowsClipboardPaste, isSecureInput: { NativeDeliveryKeys.isSecureInput }, prepareKeys: NativeDeliveryKeys.prepare, canCommit: canCommit, mark: markCommitted) if result.disposition == .accepted, result.confirmation == .targetValue { result.anchor = destination.insertionAnchor(text, isCurrent: isCurrent) + if let originalSelection, !originalSelection.isEmpty, let anchor = result.anchor { + result.selectionEdit = SelectionEditReceipt(operationID: request.id, + originalSelection: originalSelection, replacement: text, anchor: anchor) + } } log.info(result.logSummary) return result diff --git a/Sources/UtterMacServices/Native/NativeTargetLease.swift b/Sources/UtterMacServices/Native/NativeTargetLease.swift index 498065c2..97b65c1e 100644 --- a/Sources/UtterMacServices/Native/NativeTargetLease.swift +++ b/Sources/UtterMacServices/Native/NativeTargetLease.swift @@ -1,5 +1,6 @@ import AppKit import ApplicationServices +import CryptoKit import Foundation import UtterContracts import UtterRuntime @@ -10,6 +11,7 @@ final class NativeTargetLease: OutputTargetLease { let processIdentifier: Int32 let context: InputContext private let state: AXTargetState? + private let documentFingerprint: SHA256.Digest? private let focusedElement: AXUIElement? private let isOwnerCurrent: () -> Bool @@ -18,6 +20,7 @@ final class NativeTargetLease: OutputTargetLease { self.id = id self.processIdentifier = processIdentifier self.state = state + documentFingerprint = state.flatMap { AXTargetState.value(of: $0.element) }.map { SHA256.hash(data: Data($0.utf8)) } focusedElement = state?.element ?? AXTargetState.focusedIdentity()?.1 self.context = context isOwnerCurrent = isCurrent @@ -28,11 +31,12 @@ final class NativeTargetLease: OutputTargetLease { var isValid: Bool { isOwnerCurrent() && NSRunningApplication(processIdentifier: processIdentifier)?.isTerminated == false } var isCurrent: Bool { - guard isValid, let focusedElement, let (pid, element) = AXTargetState.focusedIdentity(), + guard !NativeDeliveryKeys.isSecureInput, isValid, let focusedElement, let (pid, element) = AXTargetState.focusedIdentity(), pid == processIdentifier, CFEqual(focusedElement, element) else { return false } guard let state else { return true } guard let current = AXTargetState.capture() else { return false } - return state.matches(current) + let fingerprint = AXTargetState.value(of: current.element).map { SHA256.hash(data: Data($0.utf8)) } + return state.matches(current) && documentFingerprint == fingerprint } } @@ -51,10 +55,12 @@ private final class NativeTargetCapture: TargetCaptureService { init(isCurrent: @escaping () -> Bool) { self.isCurrent = isCurrent } func capture(_ request: TargetCaptureRequest) throws -> any OutputTargetLease { - guard isCurrent(), !Task.isCancelled, let app = NSWorkspace.shared.frontmostApplication else { throw DeliveryError.invalidTarget } + guard isCurrent(), !Task.isCancelled, !NativeDeliveryKeys.isSecureInput, let app = NSWorkspace.shared.frontmostApplication else { throw DeliveryError.invalidTarget } let state = AXTargetState.capture() + guard !NativeDeliveryKeys.isSecureInput else { throw DeliveryError.invalidTarget } let context = InputContext.capture(targetApp: app, screenContext: request.screenContext, outputMode: request.outputMode, inputLanguage: request.inputLanguage, source: request.source) + guard !NativeDeliveryKeys.isSecureInput else { throw DeliveryError.invalidTarget } return NativeTargetLease(processIdentifier: app.processIdentifier, state: state, context: context, isCurrent: isCurrent) } } diff --git a/Sources/UtterMacServices/ScopedOutputService.swift b/Sources/UtterMacServices/ScopedOutputService.swift index 0216b1df..aab83b04 100644 --- a/Sources/UtterMacServices/ScopedOutputService.swift +++ b/Sources/UtterMacServices/ScopedOutputService.swift @@ -8,6 +8,7 @@ struct DeliveryCompletion { var confirmation: DeliveryConfirmation = .none var path: DeliveryPath? = nil var target: DeliveryTargetKind = .native + var selectionEdit: SelectionEditReceipt? = nil } @MainActor @@ -109,7 +110,7 @@ private final class OwnedDelivery: PreparedDelivery { operationID: id, disposition: disposition, effect: .none, reason: completion.reason, anchor: completion.anchor, - effects: committedEffects, confirmation: completion.confirmation + effects: committedEffects, confirmation: completion.confirmation, selectionEdit: completion.selectionEdit ) receipt = result settle(result) diff --git a/Sources/UtterMacServices/TextDeliveryTransaction.swift b/Sources/UtterMacServices/TextDeliveryTransaction.swift index 065e7d2a..488a5719 100644 --- a/Sources/UtterMacServices/TextDeliveryTransaction.swift +++ b/Sources/UtterMacServices/TextDeliveryTransaction.swift @@ -33,17 +33,20 @@ enum TextDeliveryTransaction { prepareKeys: (_ deleting: Bool) -> (() -> Bool)?, canCommit: () -> Bool, mark: (DeliveryEffect) -> Bool, path: inout DeliveryPath?) async -> DeliveryCompletion { - guard canCommit(), target.isCurrent, target.prepareSelection() else { + func mayWrite() -> Bool { + !isSecureInput() && canCommit() && !isSecureInput() && target.isCurrent && !isSecureInput() + } + guard mayWrite(), target.prepareSelection() else { return DeliveryCompletion(disposition: .notCommitted, reason: L("delivery.target_changed")) } var documentEffect = false - defer { if !documentEffect { target.restoreSelection() } } - func mayWrite() -> Bool { canCommit() && target.isCurrent } + defer { if !documentEffect && !isSecureInput() { target.restoreSelection() } } func markDocument(_ effect: DeliveryEffect) -> Bool { - guard mark(effect) else { return false } + guard mark(effect), mayWrite() else { return false } if effect != .clipboard { documentEffect = true } return true } + guard mayWrite() else { return DeliveryCompletion(disposition: .notCommitted) } // Checked here as well as in the target so no target can reach the ineffective write by mistake. if target.supportsSelectionWrite, !target.isWebContentEditor { path = .accessibility @@ -59,10 +62,10 @@ enum TextDeliveryTransaction { guard allowsClipboardPaste else { return DeliveryCompletion(disposition: .notCommitted, reason: L("delivery.unavailable")) } - guard !isSecureInput(), let post = prepareKeys(text.isEmpty) else { + guard let post = prepareKeys(text.isEmpty) else { guard !text.isEmpty else { return DeliveryCompletion(disposition: .notCommitted, reason: L("delivery.unavailable")) } path = .clipboardOnly - return ClipboardPasteTransaction.copy(text, pasteboard: pasteboard, canCommit: mayWrite, mark: mark) + return ClipboardPasteTransaction.copy(text, pasteboard: pasteboard, canCommit: mayWrite, mark: markDocument) } if text.isEmpty { path = .keyPress diff --git a/Sources/UtterModels/Native/ConfuciusModelDownloader.swift b/Sources/UtterModels/Native/ConfuciusModelDownloader.swift index bffc7b0d..a8b4e32c 100644 --- a/Sources/UtterModels/Native/ConfuciusModelDownloader.swift +++ b/Sources/UtterModels/Native/ConfuciusModelDownloader.swift @@ -95,20 +95,20 @@ package enum ConfuciusModelDownloader { size: size, session: sessions[slot] ) - return (slot, try await writer.write(data, at: offset)) + try await writer.write(data, at: offset) + return (slot, Int64(data.count)) } } for slot in 0.. (Int, Int64)?, + onSlotFree: (Int) -> Void, onProgress: (Int64) -> Void) async throws { + var completedBytes: Int64 = 0 + while let (slot, partBytes) = try await next() { + completedBytes += partBytes + onProgress(completedBytes) + onSlotFree(slot) + } + } + private static func fetchRange( source: URL, start: Int64, @@ -165,7 +175,6 @@ package enum ConfuciusModelDownloader { private actor WeightWriter { private let file: FileHandle - private var completedBytes: Int64 = 0 package init(destination: URL, size: Int64) throws { FileManager.default.createFile(atPath: destination.path, contents: nil) @@ -173,11 +182,9 @@ private actor WeightWriter { try file.truncate(atOffset: UInt64(size)) } - package func write(_ data: Data, at offset: Int64) throws -> Int64 { + package func write(_ data: Data, at offset: Int64) throws { try file.seek(toOffset: UInt64(offset)) try file.write(contentsOf: data) - completedBytes += Int64(data.count) - return completedBytes } package func close() throws { diff --git a/Sources/UtterPresentation/CarbonOverlayShortcuts.swift b/Sources/UtterPresentation/CarbonOverlayShortcuts.swift new file mode 100644 index 00000000..9784cfab --- /dev/null +++ b/Sources/UtterPresentation/CarbonOverlayShortcuts.swift @@ -0,0 +1,59 @@ +import Carbon + +@MainActor +final class CarbonOverlayShortcuts: OverlayShortcutRegistration { + static let signature: OSType = 0x55544844 // UTHD + var onPress: ((UInt32) -> Void)? + private var handler: EventHandlerRef? + private var keys: [UInt32: EventHotKeyRef] = [:] + private var closed = false + + func register(_ key: OverlayShortcuts.Key, id: UInt32) -> Bool { + guard !closed, installHandler() else { return false } + let code = key == .cancel ? kVK_Escape : kVK_ANSI_Z + let modifiers = key == .cancel ? 0 : cmdKey | optionKey + var reference: EventHotKeyRef? + // Do not share a chord with another global registration, or claim a failed shortcut is available. + let status = RegisterEventHotKey(UInt32(code), UInt32(modifiers), + EventHotKeyID(signature: Self.signature, id: id), GetApplicationEventTarget(), + OptionBits(kEventHotKeyExclusive), &reference) + guard status == noErr, let reference else { return false } + keys[id] = reference + return true + } + + private func installHandler() -> Bool { + if handler != nil { return true } + var event = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + return InstallEventHandler(GetApplicationEventTarget(), { _, event, context in + guard let event, let context else { return OSStatus(eventNotHandledErr) } + return MainActor.assumeIsolated { + Unmanaged.fromOpaque(context).takeUnretainedValue().handle(event) + } + }, 1, &event, Unmanaged.passUnretained(self).toOpaque(), &handler) == noErr + } + + /// Decoding is also exercised with a real owned Carbon event, without registering global keys in tests. + func handle(_ event: EventRef) -> OSStatus { + guard !closed, GetEventClass(event) == OSType(kEventClassKeyboard), + GetEventKind(event) == UInt32(kEventHotKeyPressed) else { return OSStatus(eventNotHandledErr) } + var hotkey = EventHotKeyID() + guard GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), + nil, MemoryLayout.size, nil, &hotkey) == noErr, + hotkey.signature == Self.signature else { return OSStatus(eventNotHandledErr) } + onPress?(hotkey.id) + return noErr + } + + func unregister(_ id: UInt32) { + if let reference = keys.removeValue(forKey: id) { UnregisterEventHotKey(reference) } + } + + func close() { + guard !closed else { return } + closed = true + for reference in keys.values { UnregisterEventHotKey(reference) } + keys.removeAll(); onPress = nil + if let handler { RemoveEventHandler(handler); self.handler = nil } + } +} diff --git a/Sources/UtterPresentation/NativeDesktopHost+Actions.swift b/Sources/UtterPresentation/NativeDesktopHost+Actions.swift index 14bef58d..9c39546a 100644 --- a/Sources/UtterPresentation/NativeDesktopHost+Actions.swift +++ b/Sources/UtterPresentation/NativeDesktopHost+Actions.swift @@ -7,6 +7,44 @@ extension NativeDesktopHost { func copy(_ text: String) { start(SessionIntent(input: .unselected, operation: .copy(text))) } + @discardableResult + func applySelectionEdit(sessionID: UUID, proposalID: UUID) -> Bool { + guard !closed, isCurrent(), state.selectionEditProposal?.id == proposalID, + state.snapshot.id == sessionID else { return false } + return execution?.applySelectionEdit(sessionID: sessionID, proposalID: proposalID) ?? false + } + func cancelSelectionEdit(sessionID: UUID, proposalID: UUID) { + guard !closed, isCurrent(), state.selectionEditProposal?.id == proposalID, + state.snapshot.id == sessionID else { return } + execution?.cancel(sessionID) + } + @discardableResult + func restoreSelectionEdit(operationID: UUID) -> Bool { + guard !closed, isCurrent(), let execution else { return false } + if let outputs { state.project(outputs.snapshot) } + guard state.offersRestore, let restoration = state.selectionEditRestoration, + restoration.operationID == operationID, Date() < restoration.expiresAt else { return false } + do { + try execution.start(SessionIntent(input: .text(""), operation: .restoreSelectionEdit(operationID: operationID))) + return true + } catch { + diagnostics.error("Desktop restore could not start: \(error.localizedDescription)") + return false + } + } + /// The owner re-checks the retained intent, live target and expiry; a false result starts nothing. + func authorizeRemoteDisclosure(_ intentID: UUID) -> Bool { + guard !closed, isCurrent(), let execution, state.remoteDisclosure != nil, state.snapshot.id == intentID else { return false } + do { try execution.authorizeRemoteDisclosure(intentID); return true } + catch { + diagnostics.error("Remote selection disclosure could not continue: \(error.localizedDescription)") + return false + } + } + func declineRemoteDisclosure(_ intentID: UUID) { + guard !closed, isCurrent(), state.remoteDisclosure != nil, state.snapshot.id == intentID else { return } + execution?.declineRemoteDisclosure(intentID) + } func applyReplacement() { guard let pending = outputs?.snapshot.pending else { return } start(SessionIntent(input: .unselected, operation: .applyReplacement(pending.id))) diff --git a/Sources/UtterPresentation/NativeDesktopHost+Review.swift b/Sources/UtterPresentation/NativeDesktopHost+Review.swift new file mode 100644 index 00000000..d43df0e2 --- /dev/null +++ b/Sources/UtterPresentation/NativeDesktopHost+Review.swift @@ -0,0 +1,60 @@ +import AppKit +import UtterContracts +import UtterPresentationContracts + +@MainActor +extension NativeDesktopHost { + func showOverlay() { + if state.offersRestore, state.selectionEditRestoration?.operationID == restoreDismissed { hideOverlay(); return } + overlay?.show(context: context, targetApp: NSWorkspace.shared.frontmostApplication) + overlayVisible = overlay != nil + updateShortcuts() + } + + func hideOverlay() { + overlayVisible = false + overlay?.hide() + updateShortcuts() + } + + /// Close only hides the panel; session, receipt and host text are untouched. + func dismissOverlay() { + if state.offersRestore { restoreDismissed = state.selectionEditRestoration?.operationID } + restoreShown = nil + restoreExpiry?.cancel() + hideOverlay() + updateShortcuts() + } + + /// Re-read live availability from the owner on interaction or when another app activates. + /// The owner's `isAvailable` check reads the original target through Accessibility (focused element, selection + /// range and document value, compared against retained state) and only while focus is still on that element. + /// This adds no extra capture, storage or input monitor; it does read host text for the integrity comparison. + func refreshRestoration() { + guard !closed, isCurrent(), let outputs, state.selectionEditRestoration != nil else { return } + state.project(outputs.snapshot) + outputsChanged() + } + + func outputsChanged() { + guard !closed, isCurrent() else { return } + updateShortcuts() + guard state.offersRestore, let restoration = state.selectionEditRestoration, + restoration.operationID != restoreDismissed else { + if restoreShown != nil, !state.snapshot.isBusy, !state.presentation.keepsVisible { hideOverlay() } + restoreShown = nil + restoreExpiry?.cancel() + return + } + guard restoreShown != restoration.operationID else { return } + restoreShown = restoration.operationID + hideTask?.cancel() + showOverlay() + restoreExpiry?.cancel() + restoreExpiry = Task { [weak self] in + do { try await Task.sleep(for: .seconds(max(0, restoration.expiresAt.timeIntervalSinceNow))) } catch { return } + guard let self, !self.closed, self.restoreShown == restoration.operationID else { return } + self.dismissOverlay() + } + } +} diff --git a/Sources/UtterPresentation/NativeDesktopHost+Shortcuts.swift b/Sources/UtterPresentation/NativeDesktopHost+Shortcuts.swift new file mode 100644 index 00000000..01798b3d --- /dev/null +++ b/Sources/UtterPresentation/NativeDesktopHost+Shortcuts.swift @@ -0,0 +1,33 @@ +import Foundation +import UtterContracts + +@MainActor +extension NativeDesktopHost { + func updateShortcuts() { + let snapshot = state.snapshot + let canCancel = overlayVisible && !closed && isCurrent() && snapshot.isSelectionEditing && snapshot.isBusy + && [.preparing, .recording, .transcribing, .processing].contains(snapshot.phase) + let restore = overlayVisible && !closed && isCurrent() && state.offersRestore + && state.selectionEditRestoration?.operationID != restoreDismissed + ? state.selectionEditRestoration?.operationID : nil + shortcuts.update(cancel: canCancel ? snapshot.id : nil, restore: restore) + let cancelAvailable = snapshot.id.map { shortcuts.isRegistered(.cancel($0)) } == true + let restoreAvailable = restore.map { shortcuts.isRegistered(.restore($0)) } == true + if state.selectionEditCancelShortcut != cancelAvailable { state.selectionEditCancelShortcut = cancelAvailable } + if state.selectionEditRestoreShortcut != restoreAvailable { state.selectionEditRestoreShortcut = restoreAvailable } + } + + func handleShortcut(_ action: OverlayShortcuts.Action) { + guard overlayVisible, !closed, isCurrent() else { return } + switch action { + case .cancel(let id): + guard state.snapshot.id == id, state.snapshot.isBusy, state.snapshot.isSelectionEditing, + [.preparing, .recording, .transcribing, .processing].contains(state.snapshot.phase) else { return } + execution?.cancel(id) + case .restore(let id): + guard id != restoreDismissed else { return } + _ = restoreSelectionEdit(operationID: id) + } + updateShortcuts() + } +} diff --git a/Sources/UtterPresentation/NativeDesktopHost.swift b/Sources/UtterPresentation/NativeDesktopHost.swift index a5ca06a7..f25b0343 100644 --- a/Sources/UtterPresentation/NativeDesktopHost.swift +++ b/Sources/UtterPresentation/NativeDesktopHost.swift @@ -15,6 +15,7 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe let icons: (any IconPresentationService)? let diagnostics: any DiagnosticsService let isCurrent: () -> Bool + let shortcuts: OverlayShortcuts var statusItem: NSStatusItem? let popover = NSPopover() let outsideClicks = PopoverOutsideClickMonitor() @@ -24,6 +25,10 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe var settingsCommand: (item: NSMenuItem, target: AnyObject?, action: Selector?)? var subscriptions: Set = [] var hideTask: Task? + var overlayVisible = false + var restoreShown: UUID? + var restoreDismissed: UUID? + var restoreExpiry: Task? var executionObserver: UUID? var outputObserver: UUID? var closed = false @@ -31,11 +36,14 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe init(catalog: any PresentationCatalog, settings: any SettingsService, credentials: any CredentialsService, execution: (any SessionExecutionService)?, outputs: (any SessionOutputStateService)?, overlay: (any OverlayPresentationService)?, icons: (any IconPresentationService)?, - diagnostics: any DiagnosticsService, isCurrent: @escaping () -> Bool) { + diagnostics: any DiagnosticsService, isCurrent: @escaping () -> Bool, + shortcutRegistration: (any OverlayShortcutRegistration)? = nil) { self.catalog = catalog; self.settings = AppSettings(service: settings, credentials: credentials) self.execution = execution; self.outputs = outputs; self.overlay = overlay; self.icons = icons self.diagnostics = diagnostics; self.isCurrent = isCurrent + shortcuts = OverlayShortcuts(registration: shortcutRegistration ?? CarbonOverlayShortcuts()) super.init() + shortcuts.onAction = { [weak self] in self?.handleShortcut($0) } } var context: PresentationContext { @@ -44,10 +52,16 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe applyReplacement: { [weak self] in self?.applyReplacement() }, copy: { [weak self] in self?.copy($0) }, completeOnboarding: { [weak self] in self?.completeOnboarding() }, - dismiss: { [weak self] in self?.overlay?.hide() }, + dismiss: { [weak self] in self?.dismissOverlay() }, cancel: { [weak self] in self?.cancel() }, stop: { [weak self] in self?.stop() }, - recover: { [weak self] in self?.recover() }) + recover: { [weak self] in self?.recover() }, + applySelectionEdit: { [weak self] in self?.applySelectionEdit(sessionID: $0, proposalID: $1) ?? false }, + cancelSelectionEdit: { [weak self] in self?.cancelSelectionEdit(sessionID: $0, proposalID: $1) }, + restoreSelectionEdit: { [weak self] in self?.restoreSelectionEdit(operationID: $0) ?? false }, + refreshRestoration: { [weak self] in self?.refreshRestoration() }, + authorizeRemoteDisclosure: { [weak self] in self?.authorizeRemoteDisclosure($0) ?? false }, + declineRemoteDisclosure: { [weak self] in self?.declineRemoteDisclosure($0) }) } func start() { @@ -64,7 +78,7 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe popover.delegate = self popover.contentViewController = NSHostingController(rootView: contributed(.menu)) executionObserver = execution?.observe { [weak self] in self?.project($0) } - outputObserver = outputs?.observe { [weak self] in self?.state.project($0) } + outputObserver = outputs?.observe { [weak self] in self?.state.project($0); self?.outputsChanged() } settings.objectWillChange.sink { [weak self] _ in Task { @MainActor [weak self] in guard let self, !self.closed, self.isCurrent() else { return } @@ -77,6 +91,8 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe } } }.store(in: &subscriptions) + NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didActivateApplicationNotification) + .sink { [weak self] _ in self?.refreshRestoration() }.store(in: &subscriptions) NSApp.publisher(for: \.effectiveAppearance).sink { [weak self] _ in self?.refreshIcons() }.store(in: &subscriptions) refreshIcons() if !settings.hasCompletedOnboarding { showOnboarding() } @@ -95,15 +111,16 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe state.project(snapshot) refreshIcons() hideTask?.cancel() - guard snapshot.phase != nil, snapshot.phase != .cancelled else { overlay?.hide(); return } - if snapshot.isBusy { closePopover() } - let target = NSWorkspace.shared.frontmostApplication - overlay?.show(context: context, targetApp: target) + guard snapshot.phase != nil, snapshot.phase != .cancelled else { hideOverlay(); return } + if snapshot.isBusy { closePopover(); restoreShown = nil } + showOverlay() if !snapshot.isBusy && !state.presentation.keepsVisible { + if state.offersRestore { outputsChanged(); return } hideTask = Task { [weak self] in do { try await Task.sleep(for: .seconds(1.5)) } catch { return } - guard let self, !self.closed, self.isCurrent(), self.state.snapshot.id == snapshot.id else { return } - self.overlay?.hide() + guard let self, !self.closed, self.isCurrent(), self.state.snapshot.id == snapshot.id, + !self.state.offersRestore else { return } + self.hideOverlay() } } } @@ -117,6 +134,7 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe @objc func togglePopover() { guard !closed, isCurrent(), let button = statusItem?.button else { return } if popover.isShown { closePopover(); return } + if state.phase == .uncertain { showOverlay() } popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) outsideClicks.start { [weak self] in self?.closePopover() } } @@ -127,13 +145,15 @@ final class NativeDesktopHost: NSObject, DesktopPresentationService, NSPopoverDe func close() { guard !closed else { return } closed = true + shortcuts.close() hideTask?.cancel(); hideTask = nil + restoreExpiry?.cancel(); restoreExpiry = nil if let executionObserver { execution?.removeObserver(executionObserver) } if let outputObserver { outputs?.removeObserver(outputObserver) } executionObserver = nil; outputObserver = nil subscriptions.removeAll() restoreSettingsCommand() - overlay?.hide(); closePopover() + hideOverlay(); closePopover() popover.contentViewController = nil settingsWindow?.close(); settingsWindow = nil onboardingWindow?.close(); onboardingWindow = nil diff --git a/Sources/UtterPresentation/OverlayShortcuts.swift b/Sources/UtterPresentation/OverlayShortcuts.swift new file mode 100644 index 00000000..2b14dccd --- /dev/null +++ b/Sources/UtterPresentation/OverlayShortcuts.swift @@ -0,0 +1,61 @@ +import Foundation + +@MainActor +protocol OverlayShortcutRegistration: AnyObject { + var onPress: ((UInt32) -> Void)? { get set } + func register(_ key: OverlayShortcuts.Key, id: UInt32) -> Bool + func unregister(_ id: UInt32) + func close() +} + +/// Each queued key event belongs to the session/receipt that registered it. +@MainActor +final class OverlayShortcuts { + enum Key { case cancel, restore } + enum Action: Hashable { case cancel(UUID), restore(UUID) } + private let registration: any OverlayShortcutRegistration + private var bindings: [Action: UInt32] = [:] + private var nextID: UInt32 = 0 + private var attempted: Set = [] + private var closed = false + var onAction: ((Action) -> Void)? + + init(registration: any OverlayShortcutRegistration) { + self.registration = registration + registration.onPress = { [weak self] in self?.press($0) } + } + + func update(cancel: UUID?, restore: UUID?) { + guard !closed else { return } + let requested = Set([cancel.map(Action.cancel), restore.map(Action.restore)].compactMap { $0 }) + attempted.formIntersection(requested) + for (action, id) in bindings where !requested.contains(action) { + registration.unregister(id); bindings[action] = nil + } + for action in requested where bindings[action] == nil && !attempted.contains(action) { + guard nextID < UInt32.max else { continue } + nextID += 1 + attempted.insert(action) + let key: Key + switch action { case .cancel: key = .cancel; case .restore: key = .restore } + if registration.register(key, id: nextID) { bindings[action] = nextID } + } + } + + func isRegistered(_ action: Action) -> Bool { bindings[action] != nil } + + private func press(_ id: UInt32) { + guard !closed, let action = bindings.first(where: { $0.value == id })?.key else { return } + bindings[action] = nil + registration.unregister(id) + onAction?(action) + } + + func close() { + guard !closed else { return } + closed = true + bindings.removeAll(); attempted.removeAll(); onAction = nil + registration.onPress = nil + registration.close() + } +} diff --git a/Sources/UtterPresentation/PresentationPlugins.swift b/Sources/UtterPresentation/PresentationPlugins.swift index df83233b..b5f51ac7 100644 --- a/Sources/UtterPresentation/PresentationPlugins.swift +++ b/Sources/UtterPresentation/PresentationPlugins.swift @@ -87,14 +87,38 @@ package enum PresentationPlugins { } @MainActor -private final class NativeOverlayPresentation: OverlayPresentationService { +final class NativeOverlayPresentation: OverlayPresentationService { private let panel: OverlayPanel + private let notice = ReviewNotice() + private var context: PresentationContext? + private var targetApp: NSRunningApplication? + private var disclosureID: UUID? + private var userDeclined = false init(screen: (any ScreenCaptureService)?) { panel = OverlayPanel(screen: screen) } + func show(context: PresentationContext, targetApp: NSRunningApplication?) { + self.context = context; self.targetApp = targetApp + let pending = context.state.remoteDisclosure != nil ? context.state.snapshot.id : nil + if pending != disclosureID { userDeclined = false } + disclosureID = pending + if context.state.isBusy { notice.key = nil } + let review = SelectionReviewCallbacks(context: context, notice: notice, onDecline: { [weak self] in self?.userDeclined = true }) panel.show(appState: context.state, targetApp: targetApp, onCancel: context.cancel, onConfirm: context.stop, - onCopy: { context.copy(context.state.snapshot.text) }, onRecover: context.recover, onDismiss: context.dismiss) + onCopy: { context.copy(context.state.snapshot.text) }, onRecover: context.recover, onDismiss: context.dismiss, + review: review) + } + + /// A disclosure that the owner cancelled without the user pressing Cancel (key released, expiry, changed target) + /// leaves a local notice so the panel does not just vanish; nothing was recorded or sent. + func hide() { + let unconfirmed = disclosureID != nil && !userDeclined && context?.state.snapshot.id == disclosureID + && context?.state.snapshot.phase == .cancelled + panel.hide() + notice.key = nil; disclosureID = nil + guard unconfirmed, let context else { return } + notice.key = "selection_edit.remote_not_confirmed" + show(context: context, targetApp: targetApp) } - func hide() { panel.hide() } } @MainActor diff --git a/Sources/UtterPresentation/UI/OverlayActionButton.swift b/Sources/UtterPresentation/UI/OverlayActionButton.swift index 652a4f93..9af95134 100644 --- a/Sources/UtterPresentation/UI/OverlayActionButton.swift +++ b/Sources/UtterPresentation/UI/OverlayActionButton.swift @@ -53,7 +53,7 @@ struct OverlayActionButton: View { } } -private struct OverlayActionButtonStyle: ButtonStyle { +struct OverlayActionButtonStyle: ButtonStyle { let kind: OverlayActionKind let isHovering: Bool diff --git a/Sources/UtterPresentation/UI/OverlayLayout.swift b/Sources/UtterPresentation/UI/OverlayLayout.swift new file mode 100644 index 00000000..7b06d8a9 --- /dev/null +++ b/Sources/UtterPresentation/UI/OverlayLayout.swift @@ -0,0 +1,93 @@ +import UtterContracts +import UtterPresentationContracts +import SwiftUI + +struct OverlayLayout: Equatable { + let width: CGFloat + let height: CGFloat + let outerCornerRadius: CGFloat + let isInteractive: Bool + let horizontalPadding: CGFloat + let topPadding: CGFloat + let bottomPadding: CGFloat + let stackSpacing: CGFloat + var content: Content = .status + + var panelSize: CGSize { + CGSize(width: width, height: height) + } + + @MainActor + init(appState: AppState, notice: String? = nil) { + if let consent = appState.remoteDisclosure { + self = OverlayLayout(content: .disclosure, + size: CGSize(width: SelectionReviewMetrics.width, height: SelectionReviewMetrics.disclosureHeight(for: consent))) + return + } + if appState.offersRestore { + self = OverlayLayout(content: .restore, + size: CGSize(width: SelectionReviewMetrics.restoreWidth, height: SelectionReviewMetrics.restoreHeight)) + return + } + if notice != nil, !appState.isBusy { + self = OverlayLayout(content: .notice, + size: CGSize(width: SelectionReviewMetrics.restoreWidth, height: SelectionReviewMetrics.restoreHeight)) + return + } + let hasPreview = appState.phase == .recording && !appState.rawTranscription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let showsEspressoFallback = appState.phase == .done + && appState.completionKind == .espressoFallback + isInteractive = appState.isRecording || appState.presentation.keepsVisible + + switch appState.phase { + case .recording where hasPreview: + width = 304 + height = 80 + outerCornerRadius = 22 + horizontalPadding = 14 + topPadding = 8 + bottomPadding = 8 + stackSpacing = 6 + case .recording: + width = 148 + height = 40 + outerCornerRadius = 20 + horizontalPadding = 7 + topPadding = 7 + bottomPadding = 7 + stackSpacing = 6 + case .transcribing, .processing, .inserting: + width = 216 + height = 40 + outerCornerRadius = 20 + horizontalPadding = appState.snapshot.isSelectionEditing ? 7 : 12 + topPadding = appState.snapshot.isSelectionEditing ? 7 : 10 + bottomPadding = appState.snapshot.isSelectionEditing ? 7 : 10 + stackSpacing = 6 + case .error, .uncertain: + width = 360 + height = 96 + outerCornerRadius = 18 + horizontalPadding = 12 + topPadding = 8 + bottomPadding = 8 + stackSpacing = 6 + case .done where showsEspressoFallback: + width = 288 + height = 56 + outerCornerRadius = 18 + horizontalPadding = 12 + topPadding = 8 + bottomPadding = 8 + stackSpacing = 6 + default: + width = 192 + height = 40 + outerCornerRadius = 20 + horizontalPadding = 12 + topPadding = 10 + bottomPadding = 10 + stackSpacing = 6 + } + } +} diff --git a/Sources/UtterPresentation/UI/OverlayPanel.swift b/Sources/UtterPresentation/UI/OverlayPanel.swift index 40629398..cbcabbad 100644 --- a/Sources/UtterPresentation/UI/OverlayPanel.swift +++ b/Sources/UtterPresentation/UI/OverlayPanel.swift @@ -22,9 +22,10 @@ final class OverlayPanel { onConfirm: @escaping () -> Void, onCopy: @escaping () -> Void, onRecover: @escaping () -> Void, - onDismiss: @escaping () -> Void + onDismiss: @escaping () -> Void, + review: SelectionReviewCallbacks = .none ) { - let initialLayout = OverlayLayout(appState: appState) + let initialLayout = OverlayLayout(appState: appState, notice: review.notice.key) targetDisplayID = OverlayTargetScreenResolver.displayID(for: targetApp) if window == nil { @@ -53,7 +54,9 @@ final class OverlayPanel { onConfirm: onConfirm, onCopy: onCopy, onRecover: onRecover, - onDismiss: onDismiss + onDismiss: onDismiss, + review: review, + notice: review.notice ) .environmentObject(appState) diff --git a/Sources/UtterPresentation/UI/OverlayPanelContent.swift b/Sources/UtterPresentation/UI/OverlayPanelContent.swift index 5f021631..c65e5c6d 100644 --- a/Sources/UtterPresentation/UI/OverlayPanelContent.swift +++ b/Sources/UtterPresentation/UI/OverlayPanelContent.swift @@ -2,85 +2,9 @@ import UtterContracts import UtterPresentationContracts import SwiftUI -struct OverlayLayout: Equatable { - let width: CGFloat - let height: CGFloat - let outerCornerRadius: CGFloat - let isInteractive: Bool - let horizontalPadding: CGFloat - let topPadding: CGFloat - let bottomPadding: CGFloat - let stackSpacing: CGFloat - - var panelSize: CGSize { - CGSize(width: width, height: height) - } - - @MainActor - init(appState: AppState) { - let hasPreview = appState.phase == .recording && !appState.rawTranscription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let showsEspressoFallback = appState.phase == .done - && appState.completionKind == .espressoFallback - isInteractive = appState.isRecording || appState.presentation.keepsVisible - - switch appState.phase { - case .recording where hasPreview: - width = 304 - height = 80 - outerCornerRadius = 22 - horizontalPadding = 14 - topPadding = 8 - bottomPadding = 8 - stackSpacing = 6 - case .recording: - width = 148 - height = 40 - outerCornerRadius = 20 - horizontalPadding = 7 - topPadding = 7 - bottomPadding = 7 - stackSpacing = 6 - case .transcribing, .processing, .inserting: - width = 216 - height = 40 - outerCornerRadius = 20 - horizontalPadding = 12 - topPadding = 10 - bottomPadding = 10 - stackSpacing = 6 - case .error, .uncertain: - width = 360 - height = 96 - outerCornerRadius = 18 - horizontalPadding = 12 - topPadding = 8 - bottomPadding = 8 - stackSpacing = 6 - case .done where showsEspressoFallback: - width = 288 - height = 56 - outerCornerRadius = 18 - horizontalPadding = 12 - topPadding = 8 - bottomPadding = 8 - stackSpacing = 6 - default: - width = 192 - height = 40 - outerCornerRadius = 20 - horizontalPadding = 12 - topPadding = 10 - bottomPadding = 10 - stackSpacing = 6 - } - } -} - struct OverlayContentView: View { @EnvironmentObject var appState: AppState @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.accessibilityReduceTransparency) private var reduceTransparency - @Environment(\.colorSchemeContrast) private var colorSchemeContrast let onLayoutChange: (OverlayLayout) -> Void let onCancel: () -> Void @@ -88,6 +12,8 @@ struct OverlayContentView: View { let onCopy: () -> Void let onRecover: () -> Void let onDismiss: () -> Void + var review = SelectionReviewCallbacks.none + @ObservedObject var notice = ReviewNotice() private var livePreview: String? { guard appState.isRecording else { return nil } @@ -96,7 +22,7 @@ struct OverlayContentView: View { } private var layout: OverlayLayout { - OverlayLayout(appState: appState) + OverlayLayout(appState: appState, notice: notice.key) } private var isError: Bool { @@ -110,6 +36,35 @@ struct OverlayContentView: View { } var body: some View { + content + .frame(width: layout.width, height: layout.height, alignment: .center) + .background(OverlayPanelSurface(cornerRadius: layout.outerCornerRadius)) + .clipShape(RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous)) + .compositingGroup() + .environment(\.colorScheme, .dark) + .onAppear { + onLayoutChange(layout) + } + .onChange(of: layout) { _, newLayout in + onLayoutChange(newLayout) + } + .animation(reduceMotion ? nil : .easeInOut(duration: 0.18), value: layout) + } + + @ViewBuilder + private var content: some View { + if let consent = appState.remoteDisclosure, let intentID = appState.snapshot.id { + RemoteDisclosureView(consent: consent, intentID: intentID, callbacks: review).id(intentID) + } else if appState.offersRestore, let restoration = appState.selectionEditRestoration { + RestoreOfferView(restoration: restoration, callbacks: review, onClose: onDismiss).id(restoration.operationID) + } else if layout.content == .notice, let key = notice.key { + ReviewNoticeView(key: key, onClose: onDismiss) + } else { + hud + } + } + + private var hud: some View { VStack(spacing: layout.stackSpacing) { if appState.isRecording { recordingControls @@ -126,18 +81,6 @@ struct OverlayContentView: View { } .padding(.horizontal, layout.horizontalPadding) .padding(.bottom, layout.bottomPadding) - .frame(width: layout.width, height: layout.height, alignment: .center) - .background(panelBackground) - .clipShape(RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous)) - .compositingGroup() - .environment(\.colorScheme, .dark) - .onAppear { - onLayoutChange(layout) - } - .onChange(of: layout) { _, newLayout in - onLayoutChange(newLayout) - } - .animation(reduceMotion ? nil : .easeInOut(duration: 0.18), value: layout) } private var statusRow: some View { @@ -154,6 +97,11 @@ struct OverlayContentView: View { .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 4) + if appState.snapshot.isSelectionEditing, appState.snapshot.isBusy, + appState.snapshot.phase != .delivering { + ReviewButton(title: L("common.cancel"), role: .quiet, + hint: appState.selectionEditCancelShortcut ? L("selection_edit.cancel_shortcut") : nil, action: onCancel) + } } .frame(width: layout.width - 2 * layout.horizontalPadding, alignment: .leading) } @@ -200,24 +148,6 @@ struct OverlayContentView: View { .frame(maxWidth: .infinity, alignment: .leading) } - private var panelBackground: some View { - let shape = RoundedRectangle(cornerRadius: layout.outerCornerRadius, style: .continuous) - return ZStack { - if reduceTransparency { - shape.fill(Color.black.opacity(0.92)) - } else { - shape.fill(.ultraThinMaterial) - shape.fill(Color.black.opacity(0.22)) - } - } - .overlay { - shape.strokeBorder( - .white.opacity(colorSchemeContrast == .increased ? 0.28 : 0.12), - lineWidth: colorSchemeContrast == .increased ? 1 : 0.5 - ) - } - } - @ViewBuilder private var statusIcon: some View { switch appState.phase { @@ -258,3 +188,25 @@ struct OverlayContentView: View { } } } + +struct OverlayPanelSurface: View { + let cornerRadius: CGFloat + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.colorSchemeContrast) private var contrast + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ZStack { + if reduceTransparency { + shape.fill(Color.black.opacity(0.92)) + } else { + shape.fill(.ultraThinMaterial) + shape.fill(Color.black.opacity(0.22)) + } + } + .overlay { + shape.strokeBorder(.white.opacity(contrast == .increased ? 0.28 : 0.12), + lineWidth: contrast == .increased ? 1 : 0.5) + } + } +} diff --git a/Sources/UtterPresentation/UI/RemoteDisclosureView.swift b/Sources/UtterPresentation/UI/RemoteDisclosureView.swift new file mode 100644 index 00000000..38aa36da --- /dev/null +++ b/Sources/UtterPresentation/UI/RemoteDisclosureView.swift @@ -0,0 +1,91 @@ +import AppKit +import SwiftUI +import UtterContracts +import UtterPresentationContracts + +/// Shown BEFORE recording and before anything is sent. Continue is an explicit click (no Return default); +/// the owner re-checks the retained intent, target and expiry, and one Continue can start only one session. +struct RemoteDisclosureView: View { + let consent: SelectionEditRemoteConsent + let intentID: UUID + let callbacks: SelectionReviewCallbacks + @EnvironmentObject private var appState: AppState + @State private var submitted = false + @State private var notice: String? // localization key, resolved at render so it follows language changes + + private var host: String { consent.providerHost } + private var canContinue: Bool { !host.isEmpty && !consent.selectedText.isEmpty && Date() < consent.expiresAt } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + controls + ScrollView { + VStack(alignment: .leading, spacing: SelectionReviewMetrics.blockSpacing) { + Text(host.isEmpty ? L("selection_edit.remote_no_provider") : String(format: L("selection_edit.remote_lead"), host)) + .font(.caption).foregroundStyle(.white.opacity(0.7)) + VStack(alignment: .leading, spacing: 4) { + Text(L("selection_edit.remote_selected")).font(.caption2.weight(.semibold)).foregroundStyle(.white.opacity(0.7)) + .frame(height: SelectionReviewMetrics.labelHeight, alignment: .leading) + ReviewTextBlock(text: consent.selectedText) + } + .accessibilityElement(children: .combine) + } + .padding(.horizontal, SelectionReviewMetrics.edgePadding) + .padding(.bottom, SelectionReviewMetrics.edgePadding) + } + .scrollBounceBehavior(.basedOnSize) + if let notice { + Text(L(notice)).font(.caption).foregroundStyle(.white.opacity(0.88)) + .fixedSize(horizontal: false, vertical: true) + .padding([.horizontal, .bottom], SelectionReviewMetrics.edgePadding) + } + } + .onAppear { + NSAccessibility.post(element: NSApp as Any, notification: .announcementRequested, + userInfo: [.announcement: L("selection_edit.remote_disclosure_required"), + .priority: NSAccessibilityPriorityLevel.high.rawValue]) + } + } + + private var controls: some View { + HStack(spacing: 8) { + ReviewButton(title: L("common.cancel"), role: .quiet, shortcut: .cancelAction) { callbacks.decline(intentID) } + Text(host.isEmpty ? L("selection_edit.remote_title_generic") : String(format: L("selection_edit.remote_title"), host)) + .font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.88)) + .lineLimit(1).truncationMode(.middle) + .frame(maxWidth: .infinity) + .accessibilityAddTraits(.isHeader) + ReviewButton(title: L("common.continue"), role: .primary, hint: L("selection_edit.remote_continue_hint"), action: authorize) + .disabled(submitted || !canContinue) + } + .padding(.horizontal, SelectionReviewMetrics.edgePadding) + .frame(height: SelectionReviewMetrics.headerHeight) + } + + private func authorize() { + guard !submitted else { return } + submitted = true + guard canContinue, callbacks.authorize(intentID) else { notice = "selection_edit.remote_failed"; return } + } +} + +/// Local, non-success message for a flow that ended without a session (for example an unconfirmed disclosure). +struct ReviewNoticeView: View { + let key: String + let onClose: () -> Void + @EnvironmentObject private var appState: AppState + + var body: some View { + HStack(spacing: 8) { + ReviewButton(title: L("common.close"), role: .quiet, shortcut: .cancelAction, action: onClose) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle").foregroundStyle(Color(nsColor: .systemOrange)) + .accessibilityHidden(true) + Text(L(key)).font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.88)) + .lineLimit(4).fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(SelectionReviewMetrics.edgePadding) + } +} diff --git a/Sources/UtterPresentation/UI/SelectionReviewCallbacks.swift b/Sources/UtterPresentation/UI/SelectionReviewCallbacks.swift new file mode 100644 index 00000000..0bcb18c7 --- /dev/null +++ b/Sources/UtterPresentation/UI/SelectionReviewCallbacks.swift @@ -0,0 +1,34 @@ +import SwiftUI +import UtterContracts +import UtterPresentationContracts + +/// A localization key (not text) so a notice follows live UI-language changes. +final class ReviewNotice: ObservableObject { + @Published var key: String? +} + +/// Every closure reaches the owner through `PresentationContext`; the owner re-validates IDs, phase and expiry. +struct SelectionReviewCallbacks { + var apply: (SelectionEditProposal) -> Bool = { _ in false } + var cancel: (SelectionEditProposal) -> Void = { _ in } + var restore: (SelectionEditRestoration) -> Bool = { _ in false } + var refresh: () -> Void = {} + var authorize: (UUID) -> Bool = { _ in false } + var decline: (UUID) -> Void = { _ in } + var notice = ReviewNotice() + static var none: SelectionReviewCallbacks { SelectionReviewCallbacks() } +} + +extension SelectionReviewCallbacks { + @MainActor + init(context: PresentationContext, notice: ReviewNotice, onDecline: @escaping () -> Void) { + self.init( + apply: { context.applySelectionEdit($0.sessionID, $0.id) }, + cancel: { context.cancelSelectionEdit($0.sessionID, $0.id) }, + restore: { context.restoreSelectionEdit($0.operationID) }, + refresh: context.refreshRestoration, + authorize: context.authorizeRemoteDisclosure, + decline: { onDecline(); context.declineRemoteDisclosure($0) }, + notice: notice) + } +} diff --git a/Sources/UtterPresentation/UI/SelectionReviewMetrics.swift b/Sources/UtterPresentation/UI/SelectionReviewMetrics.swift new file mode 100644 index 00000000..27c8af08 --- /dev/null +++ b/Sources/UtterPresentation/UI/SelectionReviewMetrics.swift @@ -0,0 +1,52 @@ +import AppKit +import UtterContracts +import UtterPresentationContracts + +extension AppState { + /// Restore is offered only after a confirmed write, and never while another session runs. + var offersRestore: Bool { + selectionEditRestoration.map { $0.isAvailable && Date() < $0.expiresAt } == true && phase == .done && !snapshot.isBusy + } +} + +enum SelectionReviewMetrics { + static let edgePadding: CGFloat = 12 + static let blockSpacing: CGFloat = 8 + static let headerHeight: CGFloat = 40 + static let labelHeight: CGFloat = 14 + static let restoreHeight: CGFloat = 80 + + /// Narrowest/shortest connected display, so the panel fits wherever it is placed. + private static var viewport: CGSize { + let frames = NSScreen.screens.map(\.visibleFrame) + return CGSize(width: frames.map(\.width).min() ?? 800, height: frames.map(\.height).min() ?? 600) + } + + static var width: CGFloat { min(304, max(280, viewport.width - 48)) } + static var restoreWidth: CGFloat { min(288, max(280, viewport.width - 48)) } + + static func disclosureHeight(for consent: SelectionEditRemoteConsent) -> CGFloat { + let textWidth = width - 2 * edgePadding + let lead = textHeight(String(format: L("selection_edit.remote_lead"), consent.providerHost), .caption1, textWidth) + let block = labelHeight + 4 + textHeight(consent.selectedText, .caption1, textWidth) + return min(max(headerHeight + lead + block + blockSpacing + edgePadding, 180), min(360, viewport.height * 0.7)) + } + + private static func textHeight(_ text: String, _ style: NSFont.TextStyle, _ width: CGFloat) -> CGFloat { + let bounds = (text as NSString).boundingRect(with: CGSize(width: width, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: NSFont.preferredFont(forTextStyle: style)]) + return ceil(bounds.height) + } +} + +extension OverlayLayout { + enum Content: Equatable { case status, restore, disclosure, notice } + + init(content: Content, size: CGSize) { + width = size.width; height = size.height + outerCornerRadius = content == .disclosure ? 22 : 18 + isInteractive = true + horizontalPadding = 0; topPadding = 0; bottomPadding = 0; stackSpacing = 0 + self.content = content + } +} diff --git a/Sources/UtterPresentation/UI/SelectionReviewView.swift b/Sources/UtterPresentation/UI/SelectionReviewView.swift new file mode 100644 index 00000000..cf72defc --- /dev/null +++ b/Sources/UtterPresentation/UI/SelectionReviewView.swift @@ -0,0 +1,92 @@ +import AppKit +import SwiftUI +import UtterContracts +import UtterPresentationContracts + +struct ReviewTextBlock: View { + let text: String + var body: some View { + Text(text) + .font(.caption) + .foregroundStyle(.white.opacity(0.88)) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct RestoreOfferView: View { + let restoration: SelectionEditRestoration + let callbacks: SelectionReviewCallbacks + let onClose: () -> Void + @EnvironmentObject private var appState: AppState + @State private var submitted = false + @State private var notice: String? + + private var isRestorable: Bool { restoration.isAvailable && Date() < restoration.expiresAt } + + var body: some View { + HStack(spacing: 8) { + ReviewButton(title: L("common.close"), role: .quiet, shortcut: .cancelAction, action: onClose) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: notice == nil ? "checkmark" : "exclamationmark.triangle") + .foregroundStyle(notice == nil ? Color.white.opacity(0.76) : Color(nsColor: .systemOrange)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(L(notice ?? "selection_edit.applied")) + if notice == nil, appState.selectionEditRestoreShortcut { + Text(L("selection_edit.restore_shortcut")).font(.caption2) + .foregroundStyle(.white.opacity(0.7)) + } + } + .fixedSize(horizontal: false, vertical: true) + } + .font(.caption.weight(.medium)).foregroundStyle(.white.opacity(0.88)) + .frame(maxWidth: .infinity, alignment: .leading) + ReviewButton(title: L("selection_edit.restore"), role: .primary, symbol: "arrow.uturn.backward", + hint: appState.selectionEditRestoreShortcut ? L("selection_edit.restore_shortcut") : nil, action: restore) + .disabled(submitted || !isRestorable) + } + .padding(SelectionReviewMetrics.edgePadding) + .onAppear { callbacks.refresh() } + } + + private func restore() { + guard !submitted else { return } + submitted = true + guard isRestorable, callbacks.restore(restoration) else { notice = "selection_edit.restore_failed"; return } + } +} + +struct ReviewButton: View { + enum Role { case primary, quiet } + let title: String + let role: Role + var symbol: String? + var shortcut: KeyboardShortcut? + var hint: String? + let action: () -> Void + @State private var hovering = false + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Button(action: action) { + Image(systemName: symbol ?? (role == .primary ? "checkmark" : "xmark")) + } + .buttonStyle(OverlayActionButtonStyle(kind: role == .primary ? .confirm : .cancel, isHovering: hovering && isEnabled)) + .modifier(OptionalShortcut(shortcut: shortcut)) + .accessibilityLabel(title) + .accessibilityHint(hint ?? "") + .help(hint ?? title) + .opacity(isEnabled ? 1 : 0.45) + .onHover { hovering = $0 } + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: hovering) + } +} + +private struct OptionalShortcut: ViewModifier { + let shortcut: KeyboardShortcut? + @ViewBuilder func body(content: Content) -> some View { + if let shortcut { content.keyboardShortcut(shortcut) } else { content } + } +} diff --git a/Sources/UtterPresentationContracts/AppState.swift b/Sources/UtterPresentationContracts/AppState.swift index d8835201..d3abd401 100644 --- a/Sources/UtterPresentationContracts/AppState.swift +++ b/Sources/UtterPresentationContracts/AppState.swift @@ -14,12 +14,17 @@ package enum VoiceInputMode: Equatable { @MainActor package final class AppState: ObservableObject { + @Published package var selectionEditCancelShortcut = false + @Published package var selectionEditRestoreShortcut = false @Published package var selectedSettingsID = "presentation.general" @Published package private(set) var phase: AppPhase = .idle @Published package private(set) var rawTranscription = "" @Published package private(set) var processedText = "" @Published package private(set) var audioLevel: Float = 0 @Published package private(set) var statusMessage = L("status.ready") + @Published package private(set) var selectionEditProposal: SelectionEditProposal? + @Published package private(set) var selectionEditRestoration: SelectionEditRestoration? + @Published package private(set) var remoteDisclosure: SelectionEditRemoteConsent? @Published package private(set) var pendingReplacement: DeferredReplacement? @Published package private(set) var lastInsertedText = "" @Published package private(set) var activeInputMode: VoiceInputMode = .dictation @@ -34,6 +39,8 @@ package final class AppState: ObservableObject { package func project(_ snapshot: SessionExecutionSnapshot) { self.snapshot = snapshot + selectionEditProposal = snapshot.selectionEditProposal + remoteDisclosure = snapshot.remoteDisclosure let presentation = SessionPresentation(snapshot) statusMessage = presentation.message rawTranscription = snapshot.transcript @@ -60,6 +67,7 @@ package final class AppState: ObservableObject { } package func project(_ output: SessionOutputSnapshot) { + selectionEditRestoration = output.selectionEditRestoration pendingReplacement = output.pending lastInsertedText = output.recentText } diff --git a/Sources/UtterPresentationContracts/PresentationServices.swift b/Sources/UtterPresentationContracts/PresentationServices.swift index d5e36f7c..7dc79365 100644 --- a/Sources/UtterPresentationContracts/PresentationServices.swift +++ b/Sources/UtterPresentationContracts/PresentationServices.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import Foundation import UtterContracts import UtterRuntime @@ -34,13 +35,26 @@ package struct PresentationContext { package let cancel: () -> Void package let stop: () -> Void package let recover: () -> Void + package let applySelectionEdit: (UUID, UUID) -> Bool + package let cancelSelectionEdit: (UUID, UUID) -> Void + package let restoreSelectionEdit: (UUID) -> Bool + package let refreshRestoration: () -> Void + package let authorizeRemoteDisclosure: (UUID) -> Bool + package let declineRemoteDisclosure: (UUID) -> Void package init(settings: AppSettings, state: AppState, openSettings: @escaping () -> Void, applyReplacement: @escaping () -> Void, copy: @escaping (String) -> Void, completeOnboarding: @escaping () -> Void, dismiss: @escaping () -> Void, - cancel: @escaping () -> Void, stop: @escaping () -> Void, recover: @escaping () -> Void) { + cancel: @escaping () -> Void, stop: @escaping () -> Void, recover: @escaping () -> Void, + applySelectionEdit: @escaping (UUID, UUID) -> Bool, + cancelSelectionEdit: @escaping (UUID, UUID) -> Void, + restoreSelectionEdit: @escaping (UUID) -> Bool, refreshRestoration: @escaping () -> Void, + authorizeRemoteDisclosure: @escaping (UUID) -> Bool, declineRemoteDisclosure: @escaping (UUID) -> Void) { self.settings = settings; self.state = state; self.openSettings = openSettings self.applyReplacement = applyReplacement; self.copy = copy; self.completeOnboarding = completeOnboarding self.dismiss = dismiss; self.cancel = cancel; self.stop = stop; self.recover = recover + self.applySelectionEdit = applySelectionEdit; self.cancelSelectionEdit = cancelSelectionEdit + self.restoreSelectionEdit = restoreSelectionEdit; self.refreshRestoration = refreshRestoration + self.authorizeRemoteDisclosure = authorizeRemoteDisclosure; self.declineRemoteDisclosure = declineRemoteDisclosure } } diff --git a/Sources/UtterProcessing/GeneratedOutputNormalizer.swift b/Sources/UtterProcessing/GeneratedOutputNormalizer.swift new file mode 100644 index 00000000..5ab8cd26 --- /dev/null +++ b/Sources/UtterProcessing/GeneratedOutputNormalizer.swift @@ -0,0 +1,42 @@ +import Foundation +import UtterContracts + +package enum GeneratedOutputNormalizer { + private static let thinkTagNames = [ + "analysis", + "think", "thinking", "thought", + "reason", "reasoning", + "reflect", "reflection", + "inner_monologue", "scratchpad", + ] + + private static let thinkTagPattern: String = { + let names = thinkTagNames.joined(separator: "|") + return "<(?:\(names))(?:\\s+[^>]*)?>" + }() + + package static func stripThinkingTags(_ text: String) -> String { + if let finalText = LLMScaffoldedOutput.finalText(from: text) { + return finalText + } + + var result = text + for tag in Self.thinkTagNames { + result = result.replacingOccurrences( + of: "<\(tag)(?:\\s+[^>]*)?>[\\s\\S]*?", + with: "", + options: [.regularExpression, .caseInsensitive] + ) + } + result = result.replacingOccurrences( + of: "\(Self.thinkTagPattern)[\\s\\S]*$", + with: "", + options: [.regularExpression, .caseInsensitive] + ) + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + + package static func commandText(_ text: String) -> String { + CommandOutputCleaner.clean(stripThinkingTags(text)) + } +} diff --git a/Sources/UtterProcessing/Native/TextProcessor+Generation.swift b/Sources/UtterProcessing/Native/TextProcessor+Generation.swift index 2f68f04e..d1a2df6f 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+Generation.swift +++ b/Sources/UtterProcessing/Native/TextProcessor+Generation.swift @@ -44,7 +44,8 @@ extension TextProcessor { } package func generateText( - prompt: String, systemPrompt: String, options: TextProcessingOptions, maxTokens: Int, temperature: Double + prompt: String, systemPrompt: String, options: TextProcessingOptions, maxTokens: Int, temperature: Double, + allowsFailureDetails: Bool = true ) async throws -> String { let options = await effectiveProviderOptions(options) let providerID = options.textProviderID ?? (options.useRemoteLLM ? "remote" : options.localLLMBackend.rawValue) @@ -98,7 +99,7 @@ extension TextProcessor { throw CancellationError() } catch let error as EspressoMLXFallbackError { await Self.recordEspressoOutcome(.unavailable) - log.sensitive("[TextProcessor] ANE-LM and MLX fallback failed: \(error.details)") + if allowsFailureDetails { log.sensitive("[TextProcessor] ANE-LM and MLX fallback failed: \(error.details)") } throw error } catch { if !options.fallbackToMLXOnEspressoFailure { await Self.recordEspressoOutcome(.failed) } diff --git a/Sources/UtterProcessing/Native/TextProcessor+Output.swift b/Sources/UtterProcessing/Native/TextProcessor+Output.swift index 6dce3d8f..985b9850 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+Output.swift +++ b/Sources/UtterProcessing/Native/TextProcessor+Output.swift @@ -2,38 +2,8 @@ import UtterContracts import Foundation extension TextProcessor { - private static let thinkTagNames = [ - "analysis", - "think", "thinking", "thought", - "reason", "reasoning", - "reflect", "reflection", - "inner_monologue", "scratchpad", - ] - - private static let thinkTagPattern: String = { - let names = thinkTagNames.joined(separator: "|") - return "<(?:\(names))(?:\\s+[^>]*)?>" - }() - package func stripThinkingTags(_ text: String) -> String { - if let finalText = LLMScaffoldedOutput.finalText(from: text) { - return finalText - } - - var result = text - for tag in Self.thinkTagNames { - result = result.replacingOccurrences( - of: "<\(tag)(?:\\s+[^>]*)?>[\\s\\S]*?", - with: "", - options: [.regularExpression, .caseInsensitive] - ) - } - result = result.replacingOccurrences( - of: "\(Self.thinkTagPattern)[\\s\\S]*$", - with: "", - options: [.regularExpression, .caseInsensitive] - ) - return result.trimmingCharacters(in: .whitespacesAndNewlines) + GeneratedOutputNormalizer.stripThinkingTags(text) } package func formattingOptions(for text: String, style: LanguageStyle) -> GenerationOptions { @@ -86,8 +56,7 @@ extension TextProcessor { _ text: String, inputLanguage: InputLanguage ) -> String { - let stripped = stripThinkingTags(text) - return CommandOutputCleaner.clean(stripped) + return GeneratedOutputNormalizer.commandText(text) } package func validatedOutput( diff --git a/Sources/UtterProcessing/Native/TextProcessor+SelectionEdit.swift b/Sources/UtterProcessing/Native/TextProcessor+SelectionEdit.swift index 57f36837..9407e85c 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+SelectionEdit.swift +++ b/Sources/UtterProcessing/Native/TextProcessor+SelectionEdit.swift @@ -1,108 +1,21 @@ -import UtterContracts import Foundation +import UtterContracts extension TextProcessor { - package func processSelectionEdit( - selectedText: String, - intent: SelectionRewriteIntent, - options: TextProcessingOptions, - spokenCommand: String = "", - memoryContext: String = "", - inputContext: InputContext? = nil, - dictionarySnapshot: PersonalDictionarySnapshot? = nil - ) async -> String { + package func processSelectionEdit(selectedText: String, intent: SelectionRewriteIntent, + options: TextProcessingOptions, spokenCommand: String = "", memoryContext: String = "", + inputContext: InputContext? = nil, dictionarySnapshot: PersonalDictionarySnapshot? = nil) async -> String { let options = await effectiveProviderOptions(options) - let trimmedSelection = selectedText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedSelection.isEmpty else { return "" } - let generationOptions = selectionEditOptions(for: trimmedSelection, intent: intent) - do { - let baseUserPrompt = selectionEditPrompt( - selectedText: trimmedSelection, - intent: intent, - inputLanguage: options.inputLanguage, - spokenCommand: spokenCommand, - memoryContext: memoryContext, - inputContext: inputContext - ) - let personal = personalContextSections( - inputLanguage: options.inputLanguage, - dictionarySnapshot: dictionarySnapshot, - transcript: trimmedSelection - ) - let userPrompt = personal.isEmpty - ? baseUserPrompt - : personal.joined(separator: "\n\n") + "\n\n" + baseUserPrompt - let result = try await generateText( - prompt: userPrompt, - systemPrompt: selectionEditSystemPrompt(inputLanguage: options.inputLanguage), - options: options, - maxTokens: generationOptions.maxTokens, - temperature: generationOptions.temperature - ) - return cleanSelectionEditOutput(result, inputLanguage: options.inputLanguage) + return try await SelectionEditRecipe.candidate(selectedText: selectedText, intent: intent, + inputLanguage: options.inputLanguage, spokenCommand: spokenCommand, memoryContext: memoryContext, + inputContext: inputContext, dictionarySnapshot: dictionarySnapshot ?? snapshotDictionary()) { prompt, system, budget in + try await self.generateText(prompt: prompt, systemPrompt: system, options: options, + maxTokens: budget.maxTokens, temperature: budget.temperature, allowsFailureDetails: false) + } } catch { - log.error("[TextProcessor] Selection edit failed: \(error.localizedDescription)") + log.error("[TextProcessor] Selection edit failed") return "" } } - - /// Selection-edit prompts advertise the same final_text JSON contract as - /// command prompts, so the envelope is honored here too. - package func cleanSelectionEditOutput(_ text: String, inputLanguage: InputLanguage) -> String { - cleanCommandGeneratedOutput(text, inputLanguage: inputLanguage) - } -} - -extension TextProcessor { - package func selectionEditOptions(for text: String, intent: SelectionRewriteIntent) -> GenerationOptions { - let characterCount = text.trimmingCharacters(in: .whitespacesAndNewlines).count - let size = selectionEditSize(for: characterCount) - - let maxTokens: Int - switch intent { - case .title: - maxTokens = 96 - case .replyBrief: - maxTokens = 192 - case .expand, .reply, .replyFormal, .replyFriendly, .replyInEnglish, .replyInChinese, - .replyAccept, .replyDecline, .replyClarify: - maxTokens = [384, 768, 1024][size] - case .meetingNotes: - maxTokens = [640, 1024, 1536][size] - case .keyPoints, .decisions, .questions, .risks, .deadlines, .owners, .table, - .bulletList, .numberedList, .actionItems, .checklist: - maxTokens = [384, 640, 1024][size] - case .formal, .casual, .summary, .concise, .proofread, .translateToEnglish, .translateToChinese: - maxTokens = [256, 512, 768][size] - case .custom: - maxTokens = [384, 768, 1280][size] - } - - let temperature: Double - switch intent { - case .keyPoints, .decisions, .questions, .risks, .deadlines, .owners, .proofread, - .table, .bulletList, .numberedList, .actionItems, .checklist: - temperature = 0.10 - case .casual, .replyFriendly: - temperature = 0.18 - case .custom: - temperature = 0.15 - default: - temperature = 0.15 - } - - return GenerationOptions(maxTokens: maxTokens, temperature: temperature) - } - - package func selectionEditSize(for characterCount: Int) -> Int { - switch characterCount { - case 0...120: - return 0 - case 121...360: - return 1 - default: - return 2 - } - } } diff --git a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions+Asian.swift b/Sources/UtterProcessing/SelectionEditRecipe+AsianInstructions.swift similarity index 98% rename from Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions+Asian.swift rename to Sources/UtterProcessing/SelectionEditRecipe+AsianInstructions.swift index 064ffc99..31ac4489 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions+Asian.swift +++ b/Sources/UtterProcessing/SelectionEditRecipe+AsianInstructions.swift @@ -1,8 +1,8 @@ import UtterContracts import Foundation -extension TextProcessor { - package func selectionEditJapaneseInstruction(_ intent: SelectionRewriteIntent) -> String { +extension SelectionEditRecipe { + package static func selectionEditJapaneseInstruction(_ intent: SelectionRewriteIntent) -> String { switch intent { case .formal: return "選択テキストをより正式で明確な文体に書き換え、意味は保ってください。" @@ -69,7 +69,7 @@ extension TextProcessor { } } - package func selectionEditKoreanInstruction(_ intent: SelectionRewriteIntent) -> String { + package static func selectionEditKoreanInstruction(_ intent: SelectionRewriteIntent) -> String { switch intent { case .formal: return "선택 텍스트를 더 공식적이고 명확하게 다시 쓰되 의미를 유지하세요." diff --git a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions.swift b/Sources/UtterProcessing/SelectionEditRecipe+Instructions.swift similarity index 97% rename from Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions.swift rename to Sources/UtterProcessing/SelectionEditRecipe+Instructions.swift index 1afaf87e..2cede757 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditInstructions.swift +++ b/Sources/UtterProcessing/SelectionEditRecipe+Instructions.swift @@ -1,8 +1,8 @@ import UtterContracts import Foundation -extension TextProcessor { - package func selectionEditInstruction(_ intent: SelectionRewriteIntent, inputLanguage: InputLanguage) -> String { +extension SelectionEditRecipe { + package static func selectionEditInstruction(_ intent: SelectionRewriteIntent, inputLanguage: InputLanguage) -> String { switch inputLanguage { case .auto: return selectionEditAutoInstruction(intent) @@ -19,21 +19,21 @@ extension TextProcessor { } } - package func selectionEditAutoInstruction(_ intent: SelectionRewriteIntent) -> String { + package static func selectionEditAutoInstruction(_ intent: SelectionRewriteIntent) -> String { """ 先判断选中文本主要语言。除非本指令要求翻译或指定回复语言,否则保持选中文本原语言或自然混排,不要无故翻译。 \(selectionEditChineseInstruction(intent)) """ } - package func selectionEditCantoneseInstruction(_ intent: SelectionRewriteIntent) -> String { + package static func selectionEditCantoneseInstruction(_ intent: SelectionRewriteIntent) -> String { """ 除非本指令要求翻译或指定回复语言,否则用自然粤语书面表达和必要中英混排输出,不要默认改成普通话书面中文。 \(selectionEditChineseInstruction(intent)) """ } - package func selectionEditChineseInstruction(_ intent: SelectionRewriteIntent) -> String { + package static func selectionEditChineseInstruction(_ intent: SelectionRewriteIntent) -> String { switch intent { case .formal: return "把选中文本改写得更正式、清晰,保留原意。" @@ -100,7 +100,7 @@ extension TextProcessor { } } - package func selectionEditEnglishInstruction(_ intent: SelectionRewriteIntent) -> String { + package static func selectionEditEnglishInstruction(_ intent: SelectionRewriteIntent) -> String { switch intent { case .formal: return "Rewrite the selected text in a more formal and clear style while preserving meaning." diff --git a/Sources/UtterProcessing/SelectionEditRecipe+Options.swift b/Sources/UtterProcessing/SelectionEditRecipe+Options.swift new file mode 100644 index 00000000..e90affdb --- /dev/null +++ b/Sources/UtterProcessing/SelectionEditRecipe+Options.swift @@ -0,0 +1,55 @@ +import Foundation +import UtterContracts + +extension SelectionEditRecipe { + package static func selectionEditOptions(for text: String, intent: SelectionRewriteIntent) -> GenerationOptions { + let characterCount = text.trimmingCharacters(in: .whitespacesAndNewlines).count + let size = selectionEditSize(for: characterCount) + + let maxTokens: Int + switch intent { + case .title: + maxTokens = 96 + case .replyBrief: + maxTokens = 192 + case .expand, .reply, .replyFormal, .replyFriendly, .replyInEnglish, .replyInChinese, + .replyAccept, .replyDecline, .replyClarify: + maxTokens = [384, 768, 1024][size] + case .meetingNotes: + maxTokens = [640, 1024, 1536][size] + case .keyPoints, .decisions, .questions, .risks, .deadlines, .owners, .table, + .bulletList, .numberedList, .actionItems, .checklist: + maxTokens = [384, 640, 1024][size] + case .formal, .casual, .summary, .concise, .proofread, .translateToEnglish, .translateToChinese: + maxTokens = [256, 512, 768][size] + case .custom: + maxTokens = [384, 768, 1280][size] + } + + let temperature: Double + switch intent { + case .keyPoints, .decisions, .questions, .risks, .deadlines, .owners, .proofread, + .table, .bulletList, .numberedList, .actionItems, .checklist: + temperature = 0.10 + case .casual, .replyFriendly: + temperature = 0.18 + case .custom: + temperature = 0.15 + default: + temperature = 0.15 + } + + return GenerationOptions(maxTokens: maxTokens, temperature: temperature) + } + + package static func selectionEditSize(for characterCount: Int) -> Int { + switch characterCount { + case 0...120: + return 0 + case 121...360: + return 1 + default: + return 2 + } + } +} diff --git a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditPrompting.swift b/Sources/UtterProcessing/SelectionEditRecipe+Prompting.swift similarity index 93% rename from Sources/UtterProcessing/Native/TextProcessor+SelectionEditPrompting.swift rename to Sources/UtterProcessing/SelectionEditRecipe+Prompting.swift index 9de0f0b0..8a2264d2 100644 --- a/Sources/UtterProcessing/Native/TextProcessor+SelectionEditPrompting.swift +++ b/Sources/UtterProcessing/SelectionEditRecipe+Prompting.swift @@ -1,8 +1,8 @@ import UtterContracts import Foundation -extension TextProcessor { - package func selectionEditPrompt( +extension SelectionEditRecipe { + package static func selectionEditPrompt( selectedText: String, intent: SelectionRewriteIntent, inputLanguage: InputLanguage, @@ -31,7 +31,7 @@ extension TextProcessor { """ } - package func selectionEditSpokenCommandSection(_ spokenCommand: String, inputLanguage: InputLanguage) -> String { + package static func selectionEditSpokenCommandSection(_ spokenCommand: String, inputLanguage: InputLanguage) -> String { guard let preview = SpokenEditCommandResolutionContext.preview(spokenCommand, limit: 600) else { return "" } switch inputLanguage { case .auto, .chinese, .cantonese: @@ -57,14 +57,7 @@ extension TextProcessor { } } - package func selectionEditSystemPromptWithPersonalContext(inputLanguage: InputLanguage) -> String { - systemPromptWithPersonalContext( - selectionEditSystemPrompt(inputLanguage: inputLanguage), - inputLanguage: inputLanguage - ) - } - - package func selectionEditSystemPrompt(inputLanguage: InputLanguage) -> String { + package static func selectionEditSystemPrompt(inputLanguage: InputLanguage) -> String { switch inputLanguage { case .auto: return """ @@ -119,7 +112,7 @@ extension TextProcessor { } } - package func selectionEditMemorySection(_ memoryContext: String, inputLanguage: InputLanguage) -> String { + package static func selectionEditMemorySection(_ memoryContext: String, inputLanguage: InputLanguage) -> String { guard !memoryContext.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return "" } let label: String switch inputLanguage { diff --git a/Sources/UtterProcessing/SelectionEditRecipe.swift b/Sources/UtterProcessing/SelectionEditRecipe.swift new file mode 100644 index 00000000..a1472d8c --- /dev/null +++ b/Sources/UtterProcessing/SelectionEditRecipe.swift @@ -0,0 +1,47 @@ +import Foundation +import UtterContracts + +package enum SelectionEditRecipe { + package enum Failure: Error, Equatable { case emptySelection, emptyInstruction, emptyCandidate } + + package static func candidate(selectedText: String, intent: SelectionRewriteIntent, + inputLanguage: InputLanguage, spokenCommand: String = "", memoryContext: String = "", + inputContext: InputContext? = nil, dictionarySnapshot: PersonalDictionarySnapshot? = nil, + generate: (String, String, GenerationOptions) async throws -> String) async throws -> String { + try Task.checkCancellation() + guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw Failure.emptySelection } + if case .custom(let instruction) = intent, instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw Failure.emptyInstruction + } + let base = selectionEditPrompt(selectedText: selectedText, intent: intent, inputLanguage: inputLanguage, + spokenCommand: spokenCommand, memoryContext: memoryContext, inputContext: inputContext) + let personal = dictionarySnapshot.map { snapshot in + [PromptCatalog.activeIndustryLexiconSection(snapshot.industryLexicon.promptDescription(matching: selectedText), + industry: snapshot.industryLexicon.pack?.id, inputLanguage: inputLanguage), + PromptCatalog.activePersonalDictionarySection(snapshot.activeEntriesDescription, inputLanguage: inputLanguage), + PromptCatalog.activeEditRulesSection(snapshot.activeRulesDescription, inputLanguage: inputLanguage)].compactMap { $0 } + } ?? [] + let prompt = personal.isEmpty ? base : personal.joined(separator: "\n\n") + "\n\n" + base + let output = try await LLMFinalTextOutput.$requiresExplicitFinalText.withValue(true) { + try await generate(prompt, selectionEditSystemPrompt(inputLanguage: inputLanguage), + selectionEditOptions(for: selectedText, intent: intent)) + } + try Task.checkCancellation() + let candidate = normalize(output) + guard !candidate.isEmpty else { throw Failure.emptyCandidate } + return candidate + } + + package static func normalize(_ output: String) -> String { + LLMFinalTextOutput.$requiresExplicitFinalText.withValue(true) { + let text = GeneratedOutputNormalizer.stripThinkingTags(output) + let structured = FormattedOutputCleaner.clean(text) + if let data = structured.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: data)) != nil { + guard let final = LLMFinalTextOutput.text(from: structured) else { return "" } + return CommandOutputCleaner.clean(final) + } + return CommandOutputCleaner.clean(text) + } + } +} diff --git a/Sources/UtterRemoteInference/RemoteLLMResponseText.swift b/Sources/UtterRemoteInference/RemoteLLMResponseText.swift index 40bb2dde..5f8cb9ee 100644 --- a/Sources/UtterRemoteInference/RemoteLLMResponseText.swift +++ b/Sources/UtterRemoteInference/RemoteLLMResponseText.swift @@ -124,11 +124,7 @@ private extension RemoteLLMResponseText { return RemoteLLMPayload.nonEmpty(text) } if let blocks = value as? [Any] { - let text = blocks - .compactMap(contentBlockText) - .joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? nil : text + return joinedText(blocks.compactMap(contentBlockText)) } if let object = value as? [String: Any] { return contentBlockText(object) @@ -143,6 +139,11 @@ private extension RemoteLLMResponseText { if let type = object.value(forCaseInsensitiveKey: "type") as? String { if RemoteLLMPayload.matchesBlockType(type, in: textBlockTypes) { + if LLMFinalTextOutput.requiresExplicitFinalText, + !RemoteLLMPayload.matchesBlockType(type, in: ["text"]), + let json = RemoteLLMPayload.jsonString(from: object) { + return json + } return contentText(from: object.value(forCaseInsensitiveKey: "text")) ?? contentText(from: object.value(forCaseInsensitiveKey: "content")) ?? contentText(from: object.value(forCaseInsensitiveKey: "value")) @@ -165,6 +166,9 @@ private extension RemoteLLMResponseText { return nil } + if LLMFinalTextOutput.requiresExplicitFinalText { + return actionableJSONText(from: object) + } if let text = contentText(from: object.value(forCaseInsensitiveKey: "text")) { return text } @@ -179,11 +183,7 @@ private extension RemoteLLMResponseText { static func toolCallText(from value: Any?) -> String? { if let blocks = value as? [Any] { - let text = blocks - .compactMap(toolCallText) - .joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - return text.isEmpty ? nil : text + return joinedText(blocks.compactMap(toolCallText)) } guard let object = value as? [String: Any] else { return contentText(from: value) @@ -233,6 +233,7 @@ private extension RemoteLLMResponseText { /// resolved to their text; command payloads keep their JSON shape so the /// spoken-edit resolver can parse them later. static func actionableText(from text: String) -> String? { + if LLMFinalTextOutput.requiresExplicitFinalText { return text } if let finalText = LLMFinalTextOutput.text(from: text) { return finalText } @@ -242,7 +243,21 @@ private extension RemoteLLMResponseText { return nil } + static func joinedText(_ parts: [String]) -> String? { + if LLMFinalTextOutput.requiresExplicitFinalText { + let values = parts.map { (try? JSONSerialization.jsonObject(with: Data($0.utf8))) ?? $0 } + if values.contains(where: { $0 is [String: Any] || $0 is [Any] }) { + return RemoteLLMPayload.jsonString(from: values) + } + } + return RemoteLLMPayload.nonEmpty(parts.joined(separator: "\n")) + } + static func structuredPayloadText(from value: Any?) -> String? { + if LLMFinalTextOutput.requiresExplicitFinalText, + let structured = RemoteLLMPayload.jsonString(from: value) { + return structured + } if let text = contentText(from: value) { return text } diff --git a/Sources/UtterSession/Native/VoiceSessionJob+Capture.swift b/Sources/UtterSession/Native/VoiceSessionJob+Capture.swift index b8958113..091408d8 100644 --- a/Sources/UtterSession/Native/VoiceSessionJob+Capture.swift +++ b/Sources/UtterSession/Native/VoiceSessionJob+Capture.swift @@ -97,8 +97,8 @@ extension VoiceSessionJob { let shouldCapture: Bool switch mode { case .formatting: shouldCapture = settings.useScreenContext - case .command, .selectionEdit: shouldCapture = true - case .direct, .translation: shouldCapture = false + case .command: shouldCapture = target?.selectedText?.isEmpty != false + case .direct, .translation, .selectionEdit: shouldCapture = false } guard shouldCapture else { return } guard let screen = dependencies.screen else { diff --git a/Sources/UtterSession/Native/VoiceSessionJob+Editing.swift b/Sources/UtterSession/Native/VoiceSessionJob+Editing.swift index e84dc58b..9dde6fea 100644 --- a/Sources/UtterSession/Native/VoiceSessionJob+Editing.swift +++ b/Sources/UtterSession/Native/VoiceSessionJob+Editing.swift @@ -3,6 +3,16 @@ import UtterContracts import UtterMediaContracts extension VoiceSessionJob { + func performSelectedRewrite(_ request: ProcessingRequest, control: any SessionJobControl) async throws -> SessionCompletion { + let selectedTarget = try selectionTarget() + let (text, outcome) = try await rewrite(selectedTarget.selectedText ?? "", instruction: .custom(request.text), + request: request, control: control) + try await prepareSelectionEdit(text, instruction: request.text, target: selectedTarget, control: control) + let acceptance = try await deliver(.replaceSelection(text), target: selectedTarget, control: control) + return SessionCompletion(transcript: request.text, text: text, acceptance: acceptance, + outputMutation: selectedOutputMutation(acceptance), generationOutcome: outcome) + } + func performSpokenEdit(_ request: ProcessingRequest, recipe: any ModeRecipeService, control: any SessionJobControl) async throws -> SessionCompletion? { let selected = target?.selectedText @@ -39,13 +49,13 @@ extension VoiceSessionJob { outputTarget = try selectionTarget() text = try replacementText(recipe.cleanReplacement(replacement, language: settings.inputLanguage)) outputCommand = .replaceSelection(text) - recordsHistory = true + recordsHistory = false case .rewriteSelection(let instruction): let selectedTarget = try selectionTarget() (text, generationOutcome) = try await rewrite(selectedTarget.selectedText ?? "", instruction: instruction, request: request, control: control) outputCommand = .replaceSelection(text) outputTarget = selectedTarget - recordsHistory = true + recordsHistory = false case .deleteSelection: outputTarget = try selectionTarget() text = "" @@ -54,15 +64,29 @@ extension VoiceSessionJob { case .undoLastInsertion: let anchor = try recentAnchor() text = "" - outputCommand = .undoAnchor(anchor) + if let edit = recentOutput?.selectionEdit { + guard edit.anchor === anchor, edit.replacement == anchor.text, + dependencies.outputs?.consumeSelectionEdit(edit.operationID) == true else { + throw DeliveryError.invalidTarget + } + outputCommand = .replaceAnchor(edit.originalSelection, anchor) + } else { + outputCommand = .undoAnchor(anchor) + } outputTarget = anchor.target recordsHistory = false } + if case .replaceSelection = outputCommand { + try await prepareSelectionEdit(text, instruction: request.text, target: outputTarget, control: control) + } control.update(phase: .delivering, transcript: request.text) let acceptance = try await deliver(outputCommand, target: outputTarget, control: control) + let isSelection: Bool + if case .replaceSelection = outputCommand { isSelection = true } else { isSelection = false } return SessionCompletion(transcript: request.text, text: text, acceptance: acceptance, - record: recordsHistory || !acceptance.isAccepted ? InputRecord(id: intent.id, date: Date(), rawText: request.text, - processedText: text, wasProcessed: true, context: request.inputContext) : nil, generationOutcome: generationOutcome) + record: !isSelection && (recordsHistory || !acceptance.isAccepted) ? InputRecord(id: intent.id, date: Date(), rawText: request.text, + processedText: text, wasProcessed: true, context: request.inputContext) : nil, + outputMutation: isSelection ? selectedOutputMutation(acceptance) : .remember, generationOutcome: generationOutcome) } private func recentAnchor() throws -> any OutputAnchor { diff --git a/Sources/UtterSession/Native/VoiceSessionJob+SelectionEdit.swift b/Sources/UtterSession/Native/VoiceSessionJob+SelectionEdit.swift new file mode 100644 index 00000000..7cd6b604 --- /dev/null +++ b/Sources/UtterSession/Native/VoiceSessionJob+SelectionEdit.swift @@ -0,0 +1,27 @@ +import Foundation +import UtterContracts +import UtterMediaContracts + +extension VoiceSessionJob { + func selectedOutputMutation(_ acceptance: SessionAcceptance) -> SessionOutputMutation { + guard case .delivery(let receipt) = acceptance, receipt.selectionEdit != nil else { return .preserve } + return .remember + } + + func prepareSelectionEdit(_ candidate: String, instruction: String, target: (any OutputTargetLease)?, + control: any SessionJobControl) async throws { + try check(control) + guard let target, target.isCurrent, let original = target.selectedText, !original.isEmpty, + !candidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DeliveryError.invalidTarget } + await recording?.stopCapture() + await callbacks.drain() + engine?.cancelListening() + await engine?.drainRecognition() + await recording?.close() + recording = nil; engine = nil + try check(control) + guard target.isCurrent else { throw DeliveryError.invalidTarget } + guard target.selectedText == original, original == selectedTextAtCapture else { throw DeliveryError.invalidTarget } + control.update(phase: .delivering, transcript: instruction) + } +} diff --git a/Sources/UtterSession/Native/VoiceSessionJob.swift b/Sources/UtterSession/Native/VoiceSessionJob.swift index 72e44825..36a43924 100644 --- a/Sources/UtterSession/Native/VoiceSessionJob.swift +++ b/Sources/UtterSession/Native/VoiceSessionJob.swift @@ -6,6 +6,8 @@ import UtterRuntime @MainActor final class VoiceSessionJob: SessionJob { let intent: SessionIntent + let containsSelectedText: Bool + let selectedTextAtCapture: String? var input: SessionInput let settings: VoiceInputSettings let options: TextProcessingOptions @@ -45,6 +47,12 @@ final class VoiceSessionJob: SessionJob { target: (any OutputTargetLease)?, recentOutput: RecentSessionOutput?, memoryContext: String, dependencies: VoiceWorkflowDependencies, authorize: @escaping () throws -> Void) { self.intent = intent + selectedTextAtCapture = target?.selectedText + switch mode { + case .selectionEdit: containsSelectedText = intent.clientID == nil + case .command: containsSelectedText = intent.clientID == nil && target?.selectedText?.isEmpty == false + default: containsSelectedText = false + } self.input = intent.input self.settings = settings self.options = options @@ -119,11 +127,14 @@ final class VoiceSessionJob: SessionJob { try check(control) let inputContext = contextWithScreen(screen.text) let formatKind = mode == .formatting ? dependencies.preparation.format(text: transcript, context: inputContext).kind : nil - let recipe = try await dependencies.recipes.create(id: recipeID, request: ()) - try check(control) let request = ProcessingRequest(mode: mode, text: processingText(transcript), options: options, dictionary: settings.dictionary, screenContext: screen.text, screenImage: screen.image, memoryContext: memoryContext, inputContext: inputContext, formatKind: formatKind) + if intent.clientID == nil, mode == .command, target?.selectedText?.isEmpty == false { + return try await performSelectedRewrite(request, control: control) + } + let recipe = try await dependencies.recipes.create(id: recipeID, request: ()) + try check(control) if intent.clientID == nil, mode == .command, let edited = try await performSpokenEdit(request, recipe: recipe, control: control) { return edited } if mode == .command, [.permissionDenied, .captureFailed, .recognitionFailed].contains(screen.status) { @@ -142,17 +153,24 @@ final class VoiceSessionJob: SessionJob { if intent.clientID != nil { acceptance = .returnedText } else { - control.update(phase: .delivering, transcript: transcript) let command: DeliveryCommand if case .selectionEdit = mode { guard target?.selectedText?.isEmpty == false else { throw DeliveryError.invalidTarget } + try await prepareSelectionEdit(text, instruction: transcript, target: target, control: control) command = .replaceSelection(text) - } else { command = .insert(text) } + } else { + control.update(phase: .delivering, transcript: transcript) + command = .insert(text) + } acceptance = try await deliver(command, target: target, control: control) } + let record: InputRecord? + if case .selectionEdit = mode { record = nil } + else { record = InputRecord(id: intent.id, date: Date(), rawText: transcript, processedText: text, + wasProcessed: mode != .direct, context: inputContext, formatKind: formatKind) } return SessionCompletion(transcript: transcript, text: text, acceptance: acceptance, - record: InputRecord(id: intent.id, date: Date(), rawText: transcript, processedText: text, wasProcessed: mode != .direct, - context: inputContext, formatKind: formatKind), generationOutcome: output.generationOutcome) + record: record, outputMutation: record == nil ? selectedOutputMutation(acceptance) : .remember, + generationOutcome: output.generationOutcome) } func check(_ control: any SessionJobControl) throws { diff --git a/Sources/UtterSession/Native/VoiceWorkflowFactory.swift b/Sources/UtterSession/Native/VoiceWorkflowFactory.swift index 4d1abb4e..fe1d6a6d 100644 --- a/Sources/UtterSession/Native/VoiceWorkflowFactory.swift +++ b/Sources/UtterSession/Native/VoiceWorkflowFactory.swift @@ -19,10 +19,16 @@ final class VoiceWorkflowFactory: SessionWorkflowFactory { guard intent.clientID == nil else { throw IntegrationError.invalidSessionState } return try CopySessionJob(id: intent.id, text: text, output: dependencies.output) } + if case .restoreSelectionEdit(let operationID) = intent.operation { + guard intent.clientID == nil, let outputs = dependencies.outputs else { throw IntegrationError.invalidSessionState } + return try RestoreSelectionEditJob(operationID: operationID, sessionID: intent.id, + outputs: outputs, output: dependencies.output, + authorize: { [self] in try authorize(intent) }) + } if case .applyReplacement(let id) = intent.operation { guard intent.clientID == nil, let outputs = dependencies.outputs else { throw IntegrationError.invalidSessionState } let values = dependencies.settings.values - let target = try? dependencies.targets.capture(TargetCaptureRequest(outputMode: values.outputMode, + let target = try dependencies.targets.capture(TargetCaptureRequest(outputMode: values.outputMode, inputLanguage: values.inputLanguage, source: .menuBar)) return try DeferredReplacementJob(id: id, outputs: outputs, output: dependencies.output, access: dependencies.access, target: target, operationID: intent.id, @@ -32,6 +38,8 @@ final class VoiceWorkflowFactory: SessionWorkflowFactory { if let language = intent.request.language { values.inputLanguage = language } if let mode = intent.request.mode { values.outputMode = mode } if let screen = intent.request.useScreenContext { values.useScreenContext = screen } + let target = try intent.clientID == nil ? dependencies.targets.capture(TargetCaptureRequest( + outputMode: values.outputMode, inputLanguage: values.inputLanguage, source: .menuBar)) : nil let bindings = try dependencies.configuration?.sessionSnapshot.bindings ?? [:] let speech: ProviderDescriptor? if case .text = intent.input { speech = nil } @@ -58,8 +66,14 @@ final class VoiceWorkflowFactory: SessionWorkflowFactory { _ = try descriptor(options.fallbackProviderID, in: dependencies.generation.descriptors) } let client = intent.clientID.flatMap { dependencies.clients.client(id: $0) } - let target = intent.clientID == nil ? try? dependencies.targets.capture(TargetCaptureRequest( - outputMode: values.outputMode, inputLanguage: values.inputLanguage, source: .menuBar)) : nil + let editsSelection: Bool + switch mode { case .selectionEdit, .command: editsSelection = true; default: editsSelection = false } + if editsSelection, options.useRemoteLLM, let selected = target?.selectedText, !selected.isEmpty, + intent.remoteSelectionConsent?.authorizes(selectedText: selected, options: options) != true { + throw SelectionEditRemoteDisclosureRequired(consent: SelectionEditRemoteConsent( + selectedText: selected, providerID: options.textProviderID ?? "generation.remote", + providerBaseURL: options.remoteBaseURL), isTargetCurrent: { target?.isCurrent == true }) + } let context = target?.context ?? InputContext(appName: client?.displayName, bundleIdentifier: client?.bundleIdentifier, outputMode: values.outputMode, inputLanguage: values.inputLanguage, source: .integration) let dictionary = dependencies.dictionary.snapshot(industryLexicon: dependencies.lexicons.snapshot(for: values.industryLexicon), @@ -93,7 +107,8 @@ final class VoiceWorkflowFactory: SessionWorkflowFactory { } followups[intent.id] = (work, task) } - guard case .delivery(let receipt) = completion.acceptance, let context = completion.record?.context, + guard case .delivery(let receipt) = completion.acceptance, receipt.selectionEdit == nil, + let context = completion.record?.context, let seed = receipt.anchor?.correctionSeed(context: context) else { return } dependencies.correction?.start(seed: seed, recordID: recordID) } diff --git a/Sources/UtterSession/RestoreSelectionEditJob.swift b/Sources/UtterSession/RestoreSelectionEditJob.swift new file mode 100644 index 00000000..508fe9ad --- /dev/null +++ b/Sources/UtterSession/RestoreSelectionEditJob.swift @@ -0,0 +1,53 @@ +import Foundation +import UtterContracts + +@MainActor +package final class RestoreSelectionEditJob: SessionJob { + private let edit: SelectionEditReceipt + private let sessionID: UUID + private let outputs: any SessionOutputStateService + private let output: any OutputService + private let authorize: () throws -> Void + private var revoked = false + private var delivery: (any PreparedDelivery)? + + package init(operationID: UUID, sessionID: UUID, outputs: any SessionOutputStateService, + output: any OutputService, authorize: @escaping () throws -> Void) throws { + guard let recent = outputs.recent, let edit = recent.selectionEdit, + edit.operationID == operationID, edit.anchor === recent.anchor, + edit.replacement == recent.text, edit.replacement == edit.anchor.text, + !edit.originalSelection.isEmpty, edit.anchor.isCurrent, edit.anchor.target.isCurrent else { + throw DeliveryError.invalidTarget + } + self.edit = edit; self.sessionID = sessionID + self.outputs = outputs; self.output = output; self.authorize = authorize + } + + package func run(control: any SessionJobControl) async throws -> SessionCompletion { + try check(control) + guard edit.anchor.isCurrent, edit.anchor.target.isCurrent, + outputs.consumeSelectionEdit(edit.operationID) else { throw DeliveryError.invalidTarget } + // Consume before preparing any effect. An uncertain restoration cannot be retried. + control.update(phase: .delivering, transcript: "") + let prepared = try output.prepare(DeliveryRequest(id: sessionID, + command: .replaceAnchor(edit.originalSelection, edit.anchor), target: edit.anchor.target, + allowsClipboardPaste: false), isSessionCurrent: { [weak self, weak control] in + guard let self, let control else { return false } + return (try? self.check(control)) != nil + }) + delivery = prepared + let receipt = await prepared.commit() + await prepared.close() + delivery = nil + return SessionCompletion(transcript: "", text: edit.originalSelection, acceptance: .delivery(receipt), outputMutation: .preserve) + } + + private func check(_ control: any SessionJobControl) throws { + try Task.checkCancellation() + guard !revoked, control.isCurrent else { throw CancellationError() } + try authorize() + } + + package func revoke() { revoked = true } + package func close() async { revoke(); await delivery?.close(); delivery = nil } +} diff --git a/Sources/UtterSession/SessionControl.swift b/Sources/UtterSession/SessionControl.swift index d652cb78..a76810fa 100644 --- a/Sources/UtterSession/SessionControl.swift +++ b/Sources/UtterSession/SessionControl.swift @@ -11,9 +11,13 @@ final class SessionControl: SessionJobControl { private var cancelled = false private var stopped = false private var activated = false + private var phase: SessionExecutionPhase = .preparing private var activationWaiter: CheckedContinuation? private var waiter: CheckedContinuation? private var stopWaiterID: UUID? + private var reviewWaiter: CheckedContinuation? + private(set) var selectionEditProposal: SelectionEditProposal? + private(set) var hasSelectionEditReview = false init(isCurrent: @escaping () -> Bool, cancel: @escaping () -> Void, update: @escaping (SessionExecutionPhase, String) -> Void, timing: SessionTiming? = nil, audioLevel: @escaping (Float) -> Void = { _ in }) { @@ -29,6 +33,7 @@ final class SessionControl: SessionJobControl { func updateAudioLevel(_ level: Float) { if isCurrent { audioLevel(level) } } func update(phase: SessionExecutionPhase, transcript: String) { guard isCurrent else { return } + self.phase = phase updateSnapshot(phase, transcript) } func cancel() { if isCurrent { cancelSession() } } @@ -64,8 +69,50 @@ final class SessionControl: SessionJobControl { guard isCurrent else { throw CancellationError() } } func stop() { stopped = true; waiter?.resume(); waiter = nil; stopWaiterID = nil } + @discardableResult + func finalizeCapture(reason: SessionCaptureFinalizationReason) -> Bool { + guard isCurrent, !stopped, phase == .recording else { return false } + timing?.stop(at: nil) + stop() + return true + } + func waitForSelectionEditDecision(_ proposal: SelectionEditProposal) async throws { + try Task.checkCancellation() + guard isCurrent, proposal.isApplicable, reviewWaiter == nil else { throw CancellationError() } + let timeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(max(0, proposal.expiresAt.timeIntervalSinceNow))) } + catch { return } + guard self?.selectionEditProposal?.id == proposal.id else { return } + self?.cancel() + } + defer { timeout.cancel(); selectionEditProposal = nil; reviewWaiter = nil } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard isCurrent, !Task.isCancelled else { continuation.resume(throwing: CancellationError()); return } + reviewWaiter = continuation + hasSelectionEditReview = true + selectionEditProposal = proposal + update(phase: .processing, transcript: proposal.recognizedInstruction) + } + } onCancel: { + Task { @MainActor [weak self] in self?.revoke() } + } + try Task.checkCancellation() + guard isCurrent, proposal.isApplicable else { throw CancellationError() } + } + func applySelectionEdit(_ id: UUID) -> Bool { + guard isCurrent, let proposal = selectionEditProposal, proposal.id == id, + proposal.isApplicable, let continuation = reviewWaiter else { return false } + reviewWaiter = nil + selectionEditProposal = nil + update(phase: .delivering, transcript: proposal.recognizedInstruction) + continuation.resume() + return true + } func revoke() { cancelled = true + selectionEditProposal = nil + reviewWaiter?.resume(throwing: CancellationError()); reviewWaiter = nil waiter?.resume(throwing: CancellationError()); waiter = nil stopWaiterID = nil activationWaiter?.resume(throwing: CancellationError()); activationWaiter = nil diff --git a/Sources/UtterSession/SessionDriver+Observation.swift b/Sources/UtterSession/SessionDriver+Observation.swift new file mode 100644 index 00000000..9b1c81f2 --- /dev/null +++ b/Sources/UtterSession/SessionDriver+Observation.swift @@ -0,0 +1,26 @@ +import Foundation +import UtterContracts + +extension SessionDriver { + package func observe(_ callback: @escaping (SessionExecutionSnapshot) -> Void) -> UUID { + let id = UUID() + if !closed { observers[id] = callback } + return id + } + package func removeObserver(_ id: UUID) { observers.removeValue(forKey: id) } + + package func observeProgress(_ callback: @escaping (SessionIntent, SessionExecutionSnapshot) -> Void) -> UUID { + let id = UUID() + if !closed { progressObservers[id] = callback } + return id + } + package func removeProgressObserver(_ id: UUID) { progressObservers.removeValue(forKey: id) } + + package func observeSettlement(_ callback: @escaping (SessionIntent, Result) -> Void) -> UUID { + let id = UUID() + if !closed { settlementObservers[id] = callback } + return id + } + package func removeSettlementObserver(_ id: UUID) { settlementObservers.removeValue(forKey: id) } + +} diff --git a/Sources/UtterSession/SessionDriver+Settlement.swift b/Sources/UtterSession/SessionDriver+Settlement.swift new file mode 100644 index 00000000..8e27a28a --- /dev/null +++ b/Sources/UtterSession/SessionDriver+Settlement.swift @@ -0,0 +1,25 @@ +import Foundation +import UtterContracts + +extension SessionDriver { + func hasTemporarySelectionContent(_ intent: SessionIntent) -> Bool { + if active?.job.containsSelectedText == true || active?.control.hasSelectionEditReview == true { return true } + if case .selectionEdit = intent.mode { return true } + if case .restoreSelectionEdit = intent.operation { return true } + return false + } + + func terminal(for result: Result) -> SessionPerformance.Terminal { + switch result { + case .failure(let error): return error is CancellationError ? .cancelled : .failed + case .success(let completion): + switch completion.acceptance.deliveryStatus { + case .inserted: return .inserted + case .copied: return .copied + case .uncertain: return .uncertain + case .notDelivered: return .notDelivered + case .none: return completion.accepted ? .returnedText : .failed + } + } + } +} diff --git a/Sources/UtterSession/SessionDriver+Waiting.swift b/Sources/UtterSession/SessionDriver+Waiting.swift new file mode 100644 index 00000000..833e8d06 --- /dev/null +++ b/Sources/UtterSession/SessionDriver+Waiting.swift @@ -0,0 +1,39 @@ +import Foundation +import UtterContracts + +extension SessionDriver { + package func waitForRecording(_ id: UUID) async throws { + _ = try await waitForPhase(id, recording: true) + } + package func waitForCompletion(_ id: UUID) async throws -> SessionExecutionSnapshot { + try await waitForPhase(id, recording: false) + } + + func waitForPhase(_ id: UUID, recording: Bool) async throws -> SessionExecutionSnapshot { + guard let initial = terminalSnapshots[id] ?? (snapshot.id == id ? snapshot : nil) else { + throw IntegrationError.invalidSessionState + } + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let observation = observe { value in + if value.id == id { continuation.yield(value) } + } + defer { removeObserver(observation); continuation.finish() } + continuation.yield(initial) + for await value in stream { + if Task.isCancelled { break } + switch value.phase { + case .completed: return value + case .recording, .transcribing, .processing, .delivering: if recording { return value } + case .failed: throw terminalFailures[id] ?? IntegrationError.operationFailed + case .cancelled: throw CancellationError() + default: continue + } + } + if active?.intent.id == id { + cancel() + await stop() + } + throw CancellationError() + } + +} diff --git a/Sources/UtterSession/SessionDriver.swift b/Sources/UtterSession/SessionDriver.swift index 39da879a..86a73bff 100644 --- a/Sources/UtterSession/SessionDriver.swift +++ b/Sources/UtterSession/SessionDriver.swift @@ -1,30 +1,36 @@ import Foundation import UtterContracts - @MainActor package final class SessionDriver: SessionExecutionService { - private struct Active { + struct Active { var intent: SessionIntent let job: any SessionJob let control: SessionControl let task: Task let timing: SessionTiming } + private struct Disclosure { + let intent: SessionIntent + let activate: Bool + let rejection: SelectionEditRemoteDisclosureRequired + let timeout: Task + } private let workflows: any SessionWorkflowFactory private let history: any HistoryService private let notifications: StateNotifications private let isReady: () -> Bool private let now: () -> Duration - private var active: Active? - private var closed = false + private(set) var active: Active? + private var disclosure: Disclosure? + private(set) var closed = false private var reserving = false private var settling = false - private var terminalFailures: [UUID: Error] = [:] - private var terminalSnapshots: [UUID: SessionExecutionSnapshot] = [:] + private(set) var terminalFailures: [UUID: Error] = [:] + private(set) var terminalSnapshots: [UUID: SessionExecutionSnapshot] = [:] private var admittedIDs: Set = [] - private var progressObservers: [UUID: (SessionIntent, SessionExecutionSnapshot) -> Void] = [:] - private var settlementObservers: [UUID: (SessionIntent, Result) -> Void] = [:] - private var observers: [UUID: (SessionExecutionSnapshot) -> Void] = [:] + var progressObservers: [UUID: (SessionIntent, SessionExecutionSnapshot) -> Void] = [:] + var settlementObservers: [UUID: (SessionIntent, Result) -> Void] = [:] + var observers: [UUID: (SessionExecutionSnapshot) -> Void] = [:] package private(set) var snapshot = SessionExecutionSnapshot() package init(workflows: any SessionWorkflowFactory, history: any HistoryService, notifications: StateNotifications, @@ -37,16 +43,14 @@ package final class SessionDriver: SessionExecutionService { } package func reserve(_ intent: SessionIntent) throws { try admit(intent, activate: false) } - package func start(_ intent: SessionIntent) throws { try admit(intent, activate: true) } - package func promoteToTranslation(_ id: UUID, reason: HotkeyPromotion) -> Bool { guard var current = active, current.intent.id == id, current.control.isCurrent, !current.control.isStopped, current.intent.clientID == nil, snapshot.phase == .recording || (reason == .chordClassification && snapshot.phase == .preparing), let mode = current.job.promoteToTranslation() else { return false } current.intent = SessionIntent(id: id, clientID: nil, input: current.intent.input, request: current.intent.request, - mode: mode, operation: current.intent.operation) + mode: mode, operation: current.intent.operation, remoteSelectionConsent: current.intent.remoteSelectionConsent) active = current update(id, phase: snapshot.phase ?? .preparing, transcript: snapshot.transcript) return true @@ -60,58 +64,37 @@ package final class SessionDriver: SessionExecutionService { if active.intent.input == .unselected { try active.job.bind(input: source) active.intent = SessionIntent(id: id, clientID: active.intent.clientID, input: source, - request: active.intent.request, mode: active.intent.mode, operation: active.intent.operation) + request: active.intent.request, mode: active.intent.mode, operation: active.intent.operation, + remoteSelectionConsent: active.intent.remoteSelectionConsent) self.active = active } else if source != active.intent.input { throw IntegrationError.invalidSessionState } workflows.willActivate(active.intent) active.control.activate() - publish(SessionExecutionSnapshot(id: id, phase: .preparing, isBusy: true)) - } - - package func waitForRecording(_ id: UUID) async throws { - _ = try await waitForPhase(id, recording: true) - } - - package func waitForCompletion(_ id: UUID) async throws -> SessionExecutionSnapshot { - try await waitForPhase(id, recording: false) - } - - private func waitForPhase(_ id: UUID, recording: Bool) async throws -> SessionExecutionSnapshot { - guard let initial = terminalSnapshots[id] ?? (snapshot.id == id ? snapshot : nil) else { - throw IntegrationError.invalidSessionState - } - let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) - let observation = observe { value in - if value.id == id { continuation.yield(value) } - } - defer { removeObserver(observation); continuation.finish() } - continuation.yield(initial) - for await value in stream { - if Task.isCancelled { break } - switch value.phase { - case .completed: return value - case .recording, .transcribing, .processing, .delivering: if recording { return value } - case .failed: throw terminalFailures[id] ?? IntegrationError.operationFailed - case .cancelled: throw CancellationError() - default: continue - } - } - if active?.intent.id == id { - cancel() - await stop() - } - throw CancellationError() + publish(SessionExecutionSnapshot(id: id, phase: .preparing, isBusy: true, + isSelectionEditing: active.job.containsSelectedText)) } - - private func admit(_ intent: SessionIntent, activate: Bool) throws { + private func admit(_ intent: SessionIntent, activate: Bool, allowsDisclosure: Bool = true) throws { guard !closed, isReady() else { throw IntegrationError.developerInterfaceDisabled } - guard active == nil, !reserving, !settling else { throw IntegrationError.busy } + guard active == nil, disclosure == nil, !reserving, !settling else { throw IntegrationError.busy } guard !admittedIDs.contains(intent.id) else { throw IntegrationError.invalidSessionState } reserving = true defer { reserving = false } - let job = try workflows.make(intent) + let job: any SessionJob + do { job = try workflows.make(intent) } + catch let rejection as SelectionEditRemoteDisclosureRequired { + guard allowsDisclosure else { throw DeliveryError.invalidTarget } + let timeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(max(0, rejection.consent.expiresAt.timeIntervalSinceNow))) } + catch { return } + self?.declineRemoteDisclosure(intent.id) + } + disclosure = Disclosure(intent: intent, activate: activate, rejection: rejection, timeout: timeout) + publish(SessionExecutionSnapshot(id: intent.id, phase: .created, isBusy: true, mode: intent.mode, + remoteDisclosure: rejection.consent)) + return + } if activate, intent.input == .unselected { try job.bind(input: .local) } let timing = SessionTiming(now: now) let control = SessionControl(isCurrent: { [weak self] in @@ -132,15 +115,17 @@ package final class SessionDriver: SessionExecutionService { } admittedIDs.insert(intent.id) let frozen = SessionIntent(id: intent.id, clientID: intent.clientID, input: intent.input, request: intent.request, - mode: intent.mode ?? job.presentationMode, operation: intent.operation) + mode: intent.mode ?? job.presentationMode, operation: intent.operation, + remoteSelectionConsent: intent.remoteSelectionConsent) active = Active(intent: frozen, job: job, control: control, task: task, timing: timing) job.attach(control: control) if activate { workflows.willActivate(intent); control.activate() } - publish(SessionExecutionSnapshot(id: intent.id, phase: activate ? .preparing : .created, isBusy: true, mode: frozen.mode)) + publish(SessionExecutionSnapshot(id: intent.id, phase: activate ? .preparing : .created, isBusy: true, mode: frozen.mode, isSelectionEditing: job.containsSelectedText)) } @discardableResult package func requestStop(_ id: UUID, at timestamp: Duration? = nil) -> Bool { + if disclosure?.intent.id == id { declineRemoteDisclosure(id); return true } guard let active, active.intent.id == id, active.control.isCurrent, !active.control.isStopped, [.preparing, .recording].contains(snapshot.phase) else { return false } active.timing.stop(at: timestamp) @@ -148,7 +133,15 @@ package final class SessionDriver: SessionExecutionService { return true } + @discardableResult + package func applySelectionEdit(sessionID: UUID, proposalID: UUID) -> Bool { + guard let active, active.intent.id == sessionID, snapshot.phase == .processing, + snapshot.selectionEditProposal?.id == proposalID else { return false } + return active.control.applySelectionEdit(proposalID) + } + package func stop() async { + if let id = disclosure?.intent.id { declineRemoteDisclosure(id); return } guard let active else { return } if snapshot.phase == .created { cancel() } else { requestStop(active.intent.id) } @@ -158,16 +151,19 @@ package final class SessionDriver: SessionExecutionService { } package func stop(_ id: UUID) async { + if disclosure?.intent.id == id { declineRemoteDisclosure(id); return } guard active?.intent.id == id else { return } await stop() } package func cancel(_ id: UUID) { + if disclosure?.intent.id == id { declineRemoteDisclosure(id); return } guard active?.intent.id == id else { return } cancel() } package func cancel() { + if let id = disclosure?.intent.id { declineRemoteDisclosure(id) } guard let active else { return } active.control.revoke() active.job.revoke() @@ -180,46 +176,59 @@ package final class SessionDriver: SessionExecutionService { cancel() } package func close() async { + let timeout = disclosure?.timeout revoke() + await timeout?.value if let active { await active.task.value } } - package func observe(_ callback: @escaping (SessionExecutionSnapshot) -> Void) -> UUID { - let id = UUID() - if !closed { observers[id] = callback } - return id - } - package func removeObserver(_ id: UUID) { observers.removeValue(forKey: id) } - - package func observeProgress(_ callback: @escaping (SessionIntent, SessionExecutionSnapshot) -> Void) -> UUID { - let id = UUID() - if !closed { progressObservers[id] = callback } - return id + package func authorizeRemoteDisclosure(_ intentID: UUID) throws { + guard let pending = disclosure, pending.intent.id == intentID else { throw IntegrationError.invalidSessionState } + guard !closed, isReady(), Date() < pending.rejection.consent.expiresAt, + pending.rejection.isTargetCurrent() else { + declineRemoteDisclosure(intentID) + throw DeliveryError.invalidTarget + } + pending.timeout.cancel(); disclosure = nil + let original = pending.intent + let authorized = SessionIntent(id: original.id, clientID: original.clientID, input: original.input, + request: original.request, mode: original.mode, operation: original.operation, + remoteSelectionConsent: pending.rejection.consent) + do { try admit(authorized, activate: pending.activate, allowsDisclosure: false) } + catch { + admittedIDs.insert(intentID); terminalFailures[intentID] = error + let value = SessionExecutionSnapshot(id: intentID, phase: .failed, + error: SessionFailurePresentation(error: error).message, mode: original.mode) + terminalSnapshots[intentID] = value; publish(value) + throw error + } } - package func removeProgressObserver(_ id: UUID) { progressObservers.removeValue(forKey: id) } - package func observeSettlement(_ callback: @escaping (SessionIntent, Result) -> Void) -> UUID { - let id = UUID() - if !closed { settlementObservers[id] = callback } - return id + package func declineRemoteDisclosure(_ intentID: UUID) { + guard let pending = disclosure, pending.intent.id == intentID else { return } + pending.timeout.cancel(); disclosure = nil; admittedIDs.insert(intentID) + let value = SessionExecutionSnapshot(id: intentID, phase: .cancelled, mode: pending.intent.mode) + terminalSnapshots[intentID] = value; publish(value) } - package func removeSettlementObserver(_ id: UUID) { settlementObservers.removeValue(forKey: id) } private func update(_ id: UUID, phase: SessionExecutionPhase, transcript: String) { guard let active, active.intent.id == id, active.control.isCurrent else { return } guard [.recording, .transcribing, .processing, .delivering].contains(phase) else { return } publish(SessionExecutionSnapshot(id: id, phase: phase, transcript: transcript, isBusy: true, - audioLevel: phase == .recording ? snapshot.audioLevel : 0, mode: active.intent.mode)) + audioLevel: phase == .recording ? snapshot.audioLevel : 0, mode: active.intent.mode, + selectionEditProposal: active.control.selectionEditProposal, isSelectionEditing: active.job.containsSelectedText)) } private func updateLevel(_ id: UUID, level: Float) { guard active?.intent.id == id, snapshot.phase == .recording else { return } publish(SessionExecutionSnapshot(id: id, phase: .recording, transcript: snapshot.transcript, - isBusy: true, audioLevel: level, mode: active?.intent.mode)) + isBusy: true, audioLevel: level, mode: active?.intent.mode, + isSelectionEditing: active?.job.containsSelectedText == true)) } private func finish(_ original: SessionIntent, result: Result) { guard let intent = active?.intent, intent.id == original.id else { return } + let privateText = hasTemporarySelectionContent(intent) let performance = active?.timing.finish(terminal(for: result)) settling = true notifications.settle { @@ -227,11 +236,11 @@ package final class SessionDriver: SessionExecutionService { switch result { case .success(let completion) where completion.accepted: snapshot = SessionExecutionSnapshot(id: intent.id, phase: .completed, - transcript: completion.transcript, text: completion.text, deliveryStatus: completion.acceptance.deliveryStatus, + transcript: privateText ? "" : completion.transcript, text: privateText ? "" : completion.text, deliveryStatus: completion.acceptance.deliveryStatus, performance: performance, mode: intent.mode, generationOutcome: completion.generationOutcome) case .success(let completion): snapshot = SessionExecutionSnapshot(id: intent.id, phase: .failed, - transcript: completion.transcript, text: completion.text, + transcript: privateText ? "" : completion.transcript, text: privateText ? "" : completion.text, error: completion.acceptance.deliveryReason, deliveryStatus: completion.acceptance.deliveryStatus, performance: performance, mode: intent.mode, generationOutcome: completion.generationOutcome) @@ -239,12 +248,12 @@ package final class SessionDriver: SessionExecutionService { terminalFailures[intent.id] = error let failure = SessionFailurePresentation(error: error) snapshot = SessionExecutionSnapshot(id: intent.id, - phase: error is CancellationError ? .cancelled : .failed, transcript: snapshot.transcript, error: failure.message, + phase: error is CancellationError ? .cancelled : .failed, transcript: privateText ? "" : snapshot.transcript, error: failure.message, performance: performance, mode: intent.mode, recoveryAction: failure.recovery) } terminalSnapshots[intent.id] = snapshot for (id, callback) in Array(settlementObservers) where settlementObservers[id] != nil { callback(intent, result) } - if case .success(let completion) = result { + if !privateText, case .success(let completion) = result { if completion.acceptance.deliveryStatus == .inserted, let replacement = completion.historyReplacement { history.replaceRecord(recordID: replacement.recordID, processedText: completion.text, context: replacement.context, formatKind: replacement.formatKind) @@ -271,19 +280,6 @@ package final class SessionDriver: SessionExecutionService { } } - private func terminal(for result: Result) -> SessionPerformance.Terminal { - switch result { - case .failure(let error): return error is CancellationError ? .cancelled : .failed - case .success(let completion): - switch completion.acceptance.deliveryStatus { - case .inserted: return .inserted - case .copied: return .copied - case .uncertain: return .uncertain - case .notDelivered: return .notDelivered - case .none: return completion.accepted ? .returnedText : .failed - } - } - } private func enqueueSnapshot() { let value = snapshot for id in observers.keys.sorted(by: { $0.uuidString < $1.uuidString }) { diff --git a/Sources/UtterSession/SessionOutputState.swift b/Sources/UtterSession/SessionOutputState.swift index a9f62eef..ebb062f8 100644 --- a/Sources/UtterSession/SessionOutputState.swift +++ b/Sources/UtterSession/SessionOutputState.swift @@ -5,17 +5,71 @@ import UtterContracts package final class SessionOutputState: SessionOutputStateService { private let notifications: StateNotifications private var closed = false + private var consumedSelectionEdits: Set = [] + private var selectionExpiry: Task? private var pending: DeferredReplacement? private var observers: [UUID: (SessionOutputSnapshot) -> Void] = [:] package private(set) var recent: RecentSessionOutput? package private(set) var pendingAnchor: (any OutputAnchor)? - package var snapshot: SessionOutputSnapshot { SessionOutputSnapshot(recentText: recent?.text ?? "", pending: pending) } + package var snapshot: SessionOutputSnapshot { + let restoration = recent?.selectionEdit.map { edit in + SelectionEditRestoration(operationID: edit.operationID, originalSelection: edit.originalSelection, + replacement: edit.replacement, expiresAt: edit.anchor.expiresAt, + isAvailable: edit.anchor.isCurrent && edit.anchor.target.isCurrent + && !consumedSelectionEdits.contains(edit.operationID)) + } + return SessionOutputSnapshot(recentText: recent?.text ?? "", pending: pending, + selectionEditRestoration: restoration) + } package init(notifications: StateNotifications) { self.notifications = notifications } package func remember(_ completion: SessionCompletion, recordID: UUID) { guard !closed, completion.accepted, case .delivery(let receipt) = completion.acceptance else { return } - recent = completion.text.isEmpty ? nil : RecentSessionOutput(recordID: recordID, text: completion.text, anchor: receipt.anchor) + guard !consumedSelectionEdits.contains(receipt.operationID) else { return } + if let edit = receipt.selectionEdit { + guard edit.operationID == receipt.operationID, edit.replacement == completion.text, edit.replacement == edit.anchor.text, + edit.anchor === receipt.anchor, !edit.originalSelection.isEmpty, + edit.anchor.expiresAt > Date() else { return } + } + selectionExpiry?.cancel(); selectionExpiry = nil + recent = completion.text.isEmpty ? nil : RecentSessionOutput(recordID: recordID, text: completion.text, + anchor: receipt.anchor, selectionEdit: receipt.selectionEdit) + pending = nil + pendingAnchor = nil + if let edit = receipt.selectionEdit { + let operationID = edit.operationID + let delay = max(0, edit.anchor.expiresAt.timeIntervalSinceNow) + selectionExpiry = Task { [weak self] in + do { try await Task.sleep(for: .seconds(delay)) } + catch { return } + self?.expireSelectionEdit(operationID) + } + } + notify() + } + + @discardableResult + package func consumeSelectionEdit(_ operationID: UUID) -> Bool { + guard !closed, let current = recent, let edit = current.selectionEdit, edit.operationID == operationID, + edit.anchor === current.anchor, edit.replacement == current.text, + edit.replacement == edit.anchor.text, edit.anchor.isCurrent, edit.anchor.target.isCurrent, + !consumedSelectionEdits.contains(operationID) else { return false } + consumedSelectionEdits.insert(operationID) + clearSelectionEdit(operationID) + return true + } + + func expireSelectionEdit(_ operationID: UUID) { + guard !closed, recent?.selectionEdit?.operationID == operationID else { return } + consumedSelectionEdits.insert(operationID) + clearSelectionEdit(operationID) + } + + private func clearSelectionEdit(_ operationID: UUID) { + guard recent?.selectionEdit?.operationID == operationID else { return } + selectionExpiry?.cancel(); selectionExpiry = nil + recent = nil pending = nil pendingAnchor = nil notify() @@ -57,7 +111,10 @@ package final class SessionOutputState: SessionOutputStateService { return id } package func removeObserver(_ id: UUID) { observers[id] = nil } - package func close() { closed = true; observers.removeAll(); recent = nil; pending = nil; pendingAnchor = nil } + package func close() { + closed = true; selectionExpiry?.cancel(); selectionExpiry = nil + consumedSelectionEdits.removeAll(); observers.removeAll(); recent = nil; pending = nil; pendingAnchor = nil + } private func notify() { let value = snapshot diff --git a/Tests/OpenTypeTests/AutoCantonesePromptTests.swift b/Tests/OpenTypeTests/AutoCantonesePromptTests.swift index bef69aba..80537044 100644 --- a/Tests/OpenTypeTests/AutoCantonesePromptTests.swift +++ b/Tests/OpenTypeTests/AutoCantonesePromptTests.swift @@ -172,24 +172,24 @@ final class AutoCantonesePromptTests: XCTestCase { func testAutoAndCantoneseSelectionEditPromptsKeepLanguageIntent() { let processor = TextProcessor() - let automatic = processor.selectionEditPrompt( + let automatic = SelectionEditRecipe.selectionEditPrompt( selectedText: "Ship Friday, 金曜に出す", intent: .concise, inputLanguage: .auto ) - let cantonese = processor.selectionEditPrompt( + let cantonese = SelectionEditRecipe.selectionEditPrompt( selectedText: "啱啱講錯咗,唔係星期四,係星期五下晝開會。", intent: .formal, inputLanguage: .cantonese ) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .auto).contains("多语言选中文本处理器")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .auto).contains("保持选中文本原语言或自然混排方式")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .auto).contains("多语言选中文本处理器")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .auto).contains("保持选中文本原语言或自然混排方式")) XCTAssertTrue(automatic.contains("先判断选中文本主要语言")) XCTAssertTrue(automatic.contains("不要无故翻译")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("粤语选中文本处理器")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("不要默认改成普通话书面中文")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("粤语选中文本处理器")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("不要默认改成普通话书面中文")) XCTAssertTrue(cantonese.contains("自然粤语书面表达")) XCTAssertTrue(cantonese.contains("不要默认改成普通话书面中文")) } diff --git a/Tests/OpenTypeTests/ConfuciusModelDownloaderTests.swift b/Tests/OpenTypeTests/ConfuciusModelDownloaderTests.swift index b6cd9de5..a12c7c54 100644 --- a/Tests/OpenTypeTests/ConfuciusModelDownloaderTests.swift +++ b/Tests/OpenTypeTests/ConfuciusModelDownloaderTests.swift @@ -20,6 +20,17 @@ import XCTest @testable import UtterPresentation final class ConfuciusModelDownloaderTests: XCTestCase { + func testTailFirstPartResultsEmitMonotonicTotal() async throws { + var parts: [(Int, Int64)] = [(6, 7), (5, 16), (4, 16), (3, 16), (2, 16), (1, 16), (0, 16)] + var progress: [Int64] = [] + var freedSlots: [Int] = [] + try await ConfuciusModelDownloader.consumePartResults(next: { + parts.isEmpty ? nil : parts.removeFirst() + }, onSlotFree: { freedSlots.append($0) }, onProgress: { progress.append($0) }) + XCTAssertEqual(progress, [7, 23, 39, 55, 71, 87, 103]) + XCTAssertEqual(freedSlots, [6, 5, 4, 3, 2, 1, 0]) + } + func testParallelRangesReassembleExactWeightAndReportProgress() async throws { let bytes = Data((0..<103).map { UInt8($0) }) let session = rangeSession(bytes: bytes) @@ -41,6 +52,11 @@ final class ConfuciusModelDownloaderTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: output), bytes) XCTAssertEqual(progress.last, Int64(bytes.count)) + let values = progress.snapshot + XCTAssertEqual(values.count, 7) + XCTAssertTrue(zip(values, values.dropFirst()).allSatisfy { $0 < $1 }) + let increments = zip([0] + values, values).map { $1 - $0 }.sorted() + XCTAssertEqual(increments, [7, 16, 16, 16, 16, 16, 16]) } func testCorruptRangeCannotPublishAWeight() async throws { @@ -113,6 +129,12 @@ private final class ProgressValues: @unchecked Sendable { defer { lock.unlock() } return values.last } + + var snapshot: [Int64] { + lock.lock() + defer { lock.unlock() } + return values + } } private final class RangeFixtureProtocol: URLProtocol { diff --git a/Tests/OpenTypeTests/HeldSelectionGeneration.swift b/Tests/OpenTypeTests/HeldSelectionGeneration.swift new file mode 100644 index 00000000..3cb665b0 --- /dev/null +++ b/Tests/OpenTypeTests/HeldSelectionGeneration.swift @@ -0,0 +1,22 @@ +import Foundation +import UtterContracts +import UtterMediaContracts + +@MainActor +final class HeldSelectionGeneration { + private(set) var entered = false + private var continuation: CheckedContinuation? + let text: String + init(_ text: String = "请您今天同步进展。 👩🏽‍💻") { self.text = text } + func process(_ request: ProcessingRequest) async -> ProcessingResult { + entered = true + await withCheckedContinuation { continuation = $0 } + return ProcessingResult(text: text) + } + func waitUntilEntered() async throws { + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while !entered, ContinuousClock.now < deadline { await Task.yield() } + guard entered else { throw IntegrationError.operationFailed } + } + func release() { continuation?.resume(); continuation = nil } +} diff --git a/Tests/OpenTypeTests/LLMOutputContractTests.swift b/Tests/OpenTypeTests/LLMOutputContractTests.swift index bf2a6888..b3766df5 100644 --- a/Tests/OpenTypeTests/LLMOutputContractTests.swift +++ b/Tests/OpenTypeTests/LLMOutputContractTests.swift @@ -32,7 +32,7 @@ final class LLMOutputContractTests: XCTestCase { func testSelectionEditPromptsAdvertiseFinalTextJSONContract() { let processor = TextProcessor() for language in InputLanguage.allCases { - let prompt = processor.selectionEditSystemPrompt(inputLanguage: language) + let prompt = SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: language) XCTAssertTrue(prompt.contains("final_text"), "\(language)") } @@ -58,7 +58,7 @@ final class LLMOutputContractTests: XCTestCase { """ XCTAssertEqual( - processor.cleanSelectionEditOutput(output, inputLanguage: .english), + SelectionEditRecipe.normalize(output), "Please send the release notes today." ) } @@ -68,7 +68,7 @@ final class LLMOutputContractTests: XCTestCase { let output = #"Document this example: {"final_text":"hello"}."# XCTAssertEqual( - processor.cleanSelectionEditOutput(output, inputLanguage: .english), + SelectionEditRecipe.normalize(output), output ) } diff --git a/Tests/OpenTypeTests/MultilingualPromptTests.swift b/Tests/OpenTypeTests/MultilingualPromptTests.swift index bec33dc9..87b316b0 100644 --- a/Tests/OpenTypeTests/MultilingualPromptTests.swift +++ b/Tests/OpenTypeTests/MultilingualPromptTests.swift @@ -182,24 +182,24 @@ final class MultilingualPromptTests: XCTestCase { func testSelectionEditPromptsUseTargetLanguageLabelsAndInstructions() { let processor = TextProcessor() - let japanese = processor.selectionEditPrompt( + let japanese = SelectionEditRecipe.selectionEditPrompt( selectedText: "金曜までにリリースノートを整える", intent: .casual, inputLanguage: .japanese, memoryContext: "前回 OpenType と言った" ) - let korean = processor.selectionEditPrompt( + let korean = SelectionEditRecipe.selectionEditPrompt( selectedText: "금요일까지 릴리스 노트를 정리한다", intent: .meetingNotes, inputLanguage: .korean, memoryContext: "이전에 OpenType를 말함" ) - let cantonese = processor.selectionEditPrompt( + let cantonese = SelectionEditRecipe.selectionEditPrompt( selectedText: "今日發版", intent: .casual, inputLanguage: .cantonese ) - let automatic = processor.selectionEditPrompt( + let automatic = SelectionEditRecipe.selectionEditPrompt( selectedText: "今天发版", intent: .concise, inputLanguage: .auto @@ -231,10 +231,10 @@ final class MultilingualPromptTests: XCTestCase { func testSelectionEditSystemPromptsUseTargetLanguageRules() { let processor = TextProcessor() - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .japanese).contains("選択テキスト処理エンジン")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .korean).contains("선택 텍스트 처리기")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("选中文本处理器")) - XCTAssertTrue(processor.selectionEditSystemPrompt(inputLanguage: .auto).contains("选中文本处理器")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .japanese).contains("選択テキスト処理エンジン")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .korean).contains("선택 텍스트 처리기")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .cantonese).contains("选中文本处理器")) + XCTAssertTrue(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .auto).contains("选中文本处理器")) } func testCustomSystemPromptOutputContractUsesTargetLanguage() { diff --git a/Tests/OpenTypeTests/NativeSelectionReviewAppearanceTests.swift b/Tests/OpenTypeTests/NativeSelectionReviewAppearanceTests.swift new file mode 100644 index 00000000..a674cde6 --- /dev/null +++ b/Tests/OpenTypeTests/NativeSelectionReviewAppearanceTests.swift @@ -0,0 +1,92 @@ +import AppKit +import SwiftUI +import XCTest +import UtterContracts +import UtterPresentationContracts +@testable import UtterPresentation + +@MainActor +final class NativeSelectionReviewAppearanceTests: XCTestCase { + private typealias UI = NativeSelectionReviewTestSupport + + func testAutomaticEditHUDUsesOriginalControlsAndNeverShowsOriginalCandidateOrApply() async throws { + let language: UILanguage = L("common.cancel") == "Cancel" ? .english : .chinese + defer { Loc.use(language) } + for locale in [UILanguage.english, .chinese] { + Loc.use(locale) + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + let owner = OverlayPanel() + defer { owner.hide() } + let state = AppState() + state.project(SessionExecutionSnapshot(phase: .recording, transcript: "今天确认三件事。", isBusy: true)) + let recording = try await show(owner, state: state) + recording.appearance = NSAppearance(named: appearance) + try await UI.settle(recording) + let cancelSize = try UI.button(L("overlay.cancel_recording"), in: recording).accessibilityFrame().size + try await NativeE2EUI.capture("original-recording-\(locale)-\(appearance.rawValue)", window: recording) + owner.hide() + let secret = "SECRET-ORIGINAL-" + String(repeating: "长文本 👩🏽‍💻\n", count: 500) + let result = "SECRET-CANDIDATE-" + String(repeating: "修改结果 🇸🇬\n", count: 500) + state.project(SessionExecutionSnapshot(id: UUID(), phase: .processing, + transcript: secret, text: result, isBusy: true, isSelectionEditing: true)) + state.selectionEditCancelShortcut = true + let layout = OverlayLayout(appState: state) + XCTAssertEqual(layout.width, 216) + XCTAssertEqual(layout.height, 40) + let panel = try await show(owner, state: state) + panel.appearance = NSAppearance(named: appearance) + try await UI.settle(panel) + XCTAssertEqual(panel.frame.size, layout.panelSize) + let cancel = try UI.button(L("common.cancel"), in: panel) + XCTAssertEqual(cancel.accessibilityFrame().size, cancelSize) + XCTAssertEqual(cancelSize.width, 26, accuracy: 1) + UI.assertVisible(cancel, in: panel) + let labels = UI.nodes(panel).map { ($0.accessibilityLabel() ?? "") + $0.textValue } + XCTAssertFalse(labels.contains { $0.contains("SECRET-ORIGINAL-") || $0.contains("SECRET-CANDIDATE-") }) + XCTAssertFalse(UI.nodes(panel).contains { $0.accessibilityRole() == .button && $0.accessibilityLabel() == L("selection_edit.apply") }) + XCTAssertFalse(panel.isKeyWindow) + try await NativeE2EUI.capture("automatic-processing-\(locale)-\(appearance.rawValue)", window: panel) + owner.hide() + } + } + } + + func testRemoteDisclosureKeepsExplicitOneUseConsentWithCompactControls() async throws { + let language: UILanguage = L("common.cancel") == "Cancel" ? .english : .chinese + defer { Loc.use(language) } + for locale in [UILanguage.english, .chinese] { + Loc.use(locale) + let consent = SelectionEditRemoteConsent(selectedText: "这是将发送给所选服务的完整原文。", + providerID: "generation.remote", providerBaseURL: "https://example.invalid/v1") + let intentID = UUID() + var authorized = 0 + var declined = 0 + var callbacks = SelectionReviewCallbacks.none + callbacks.authorize = { id in XCTAssertEqual(id, intentID); authorized += 1; return false } + callbacks.decline = { id in XCTAssertEqual(id, intentID); declined += 1 } + let panel = try await UI.panel(RemoteDisclosureView(consent: consent, intentID: intentID, + callbacks: callbacks), width: 280, height: 240) + defer { panel.close() } + UI.assertVisible(try UI.text(consent.selectedText, in: panel), in: panel) + XCTAssertTrue(try UI.button(L("common.continue"), in: panel).accessibilityPerformPress()) + try await UI.settle(panel) + XCTAssertEqual(panel.frame.size, NSSize(width: 280, height: 240)) + _ = try UI.button(L("common.continue"), in: panel).accessibilityPerformPress() + XCTAssertEqual(authorized, 1) + XCTAssertFalse(try UI.button(L("common.continue"), in: panel).isAccessibilityEnabled()) + UI.assertVisible(try UI.text(L("selection_edit.remote_failed"), in: panel), in: panel) + try await NativeE2EUI.capture("corrected-disclosure-\(locale)", window: panel) + XCTAssertTrue(try UI.button(L("common.cancel"), in: panel).accessibilityPerformPress()) + XCTAssertEqual(declined, 1) + panel.close() + } + } + + private func show(_ owner: OverlayPanel, state: AppState) async throws -> NSWindow { + owner.show(appState: state, targetApp: nil, onCancel: {}, onConfirm: {}, + onCopy: {}, onRecover: {}, onDismiss: {}) + let panel = try NativeE2EUI.panel() + try await UI.activateAccessibility(in: panel) + return panel + } +} diff --git a/Tests/OpenTypeTests/NativeSelectionReviewInteractionTests.swift b/Tests/OpenTypeTests/NativeSelectionReviewInteractionTests.swift new file mode 100644 index 00000000..6c13d65e --- /dev/null +++ b/Tests/OpenTypeTests/NativeSelectionReviewInteractionTests.swift @@ -0,0 +1,89 @@ +import AppKit +import SwiftUI +import XCTest +import UtterContracts +import UtterPresentationContracts +@testable import UtterPresentation + +@MainActor +final class NativeSelectionReviewInteractionTests: XCTestCase { + private typealias UI = NativeSelectionReviewTestSupport + private var previousLanguage: UILanguage = .chinese + + override func setUp() { + super.setUp() + previousLanguage = L("common.cancel") == "Cancel" ? .english : .chinese + } + + override func tearDown() { + Loc.use(previousLanguage) + super.tearDown() + } + + func testLongRemoteDisclosureScrollsAtNarrowSizesWithoutAnyCandidateOrApply() async throws { + let selected = "待披露开头 e\u{301} 👩🏽‍💻 🇸🇬\n" + String(repeating: "请保留每一条完整选区与空白。\n", count: 60) + "待披露末尾-END" + for language in [UILanguage.english, .chinese] { + Loc.use(language) + for width in [CGFloat(280), 320, 420] { + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + let consent = SelectionEditRemoteConsent(selectedText: selected, providerID: "generation.remote", + providerBaseURL: "https://example.invalid/v1") + let panel = try await UI.panel(RemoteDisclosureView(consent: consent, intentID: UUID(), callbacks: .none), + width: width, height: 360) + defer { panel.close() } + panel.appearance = NSAppearance(named: appearance) + try await UI.settle(panel) + XCTAssertEqual(panel.frame.size, NSSize(width: width, height: 360)) + let scroll = try XCTUnwrap(UI.scrollView(in: try XCTUnwrap(panel.contentView))) + let start = scroll.contentView.bounds.origin + try await NativeE2EUI.capture("disclosure-start-\(language)-\(width)-\(appearance.rawValue)", window: panel) + var reachedEnd = false + for _ in 0..<240 { + if UI.endIsVisible(try UI.text("待披露末尾-END", in: panel), in: scroll) { reachedEnd = true; break } + try UI.scroll(by: 80, in: scroll) + await Task.yield() + } + XCTAssertTrue(reachedEnd) + XCTAssertNotEqual(start, scroll.contentView.bounds.origin) + UI.assertVisible(try UI.button(L("common.cancel"), in: panel), in: panel) + UI.assertVisible(try UI.button(L("common.continue"), in: panel), in: panel) + XCTAssertFalse(UI.nodes(panel).contains { $0.accessibilityRole() == .button && $0.accessibilityLabel() == L("selection_edit.apply") }) + try await NativeE2EUI.capture("disclosure-end-\(language)-\(width)-\(appearance.rawValue)", window: panel) + for _ in 0..<240 { try UI.scroll(by: -80, in: scroll) } + XCTAssertEqual(scroll.contentView.bounds.origin.y, start.y, accuracy: 1) + panel.close() + } + } + } + } + + func testRestoreAccessibilityIsOneUseAndFailureRemainsReadable() async throws { + for succeeded in [false, true] { + Loc.use(.english) + var calls = 0 + var closed = 0 + var callbacks = SelectionReviewCallbacks.none + callbacks.restore = { _ in calls += 1; return succeeded } + let receipt = SelectionEditRestoration(operationID: UUID(), originalSelection: "原文 👩🏽‍💻", + replacement: "候选 🇸🇬", expiresAt: Date().addingTimeInterval(15), isAvailable: true) + let panel = try await UI.panel(RestoreOfferView(restoration: receipt, callbacks: callbacks, + onClose: { closed += 1 }), width: 280, height: SelectionReviewMetrics.restoreHeight) + defer { panel.close() } + let restore = try UI.button(L("selection_edit.restore"), in: panel) + XCTAssertTrue(try restore.accessibilityPerformPress()) + try await UI.settle(panel) + XCTAssertEqual(panel.frame.size, NSSize(width: 280, height: 80)) + _ = try UI.button(L("selection_edit.restore"), in: panel).accessibilityPerformPress() + XCTAssertEqual(calls, 1) + XCTAssertFalse(try UI.button(L("selection_edit.restore"), in: panel).isAccessibilityEnabled()) + UI.assertVisible(try UI.button(L("common.close"), in: panel), in: panel) + UI.assertVisible(try UI.button(L("selection_edit.restore"), in: panel), in: panel) + if !succeeded { UI.assertVisible(try UI.text(L("selection_edit.restore_failed"), in: panel), in: panel) } + try await NativeE2EUI.capture("restore-result-\(succeeded)", window: panel) + XCTAssertTrue(try UI.button(L("common.close"), in: panel).accessibilityPerformPress()) + XCTAssertEqual(closed, 1) + panel.close() + } + } + +} diff --git a/Tests/OpenTypeTests/NativeSelectionReviewTestSupport.swift b/Tests/OpenTypeTests/NativeSelectionReviewTestSupport.swift new file mode 100644 index 00000000..625b7bae --- /dev/null +++ b/Tests/OpenTypeTests/NativeSelectionReviewTestSupport.swift @@ -0,0 +1,152 @@ +import AppKit +import ApplicationServices +import SwiftUI +import XCTest +import UtterContracts +import UtterPresentationContracts +@testable import UtterPresentation + +@MainActor +enum NativeSelectionReviewTestSupport { + private static let finishApplicationLaunch: Void = NSApplication.shared.finishLaunching() + + // SwiftUI's virtual children need not conform to the complete NSAccessibility protocol. + struct Node { + let object: NSObject + func accessibilityRole() -> NSAccessibility.Role? { + (object as AnyObject).accessibilityRole?() + } + func accessibilityLabel() -> String? { + (object as AnyObject).accessibilityLabel?() + } + func accessibilityValue() -> Any? { (object as AnyObject).accessibilityValue?() } + var textValue: String { + let value = accessibilityValue() + return (value as? String) ?? (value as? NSAttributedString)?.string ?? "" + } + func isAccessibilityEnabled() throws -> Bool { + try XCTUnwrap((object as AnyObject).isAccessibilityEnabled?()) + } + func accessibilityFrame() -> NSRect { + (object as AnyObject).accessibilityFrame?() ?? .zero + } + func accessibilityPerformPress() throws -> Bool { + try XCTUnwrap((object as AnyObject).accessibilityPerformPress?()) + } + } + + static func panel(_ content: Content, width: CGFloat, height: CGFloat, + accessibility: Bool = true) async throws -> NSPanel { + _ = NSApplication.shared + _ = finishApplicationLaunch + let panel = NSPanel(contentRect: NSRect(x: 100, y: 100, width: width, height: height), + styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) + do { + panel.becomesKeyOnlyIfNeeded = true + let hosting = NSHostingView(rootView: content.environmentObject(AppState()) + .frame(width: width, height: height) + .background(OverlayPanelSurface(cornerRadius: 22)) + .environment(\.colorScheme, .dark)) + hosting.sizingOptions = [] + panel.contentView = hosting + panel.setContentSize(NSSize(width: width, height: height)) + panel.orderFrontRegardless() + try await settle(panel) + if accessibility { + try await activateAccessibility(in: panel) + } + try NativeE2EUI.record("native-tree-\(panel.windowNumber)", ["nodes": nodes(panel).map { + ["class": NSStringFromClass(type(of: $0.object)), "role": $0.accessibilityRole()?.rawValue ?? "", + "label": $0.accessibilityLabel() ?? "", "value": String(describing: $0.accessibilityValue() ?? ""), + "navigationChildren": navigationChildren($0.object)?.count ?? -1, + "structuralChildren": ($0.object as AnyObject).accessibilityChildren?()?.count ?? -1, + "frame": NSStringFromRect($0.accessibilityFrame())] + }]) + return panel + } catch { + panel.close() + throw error + } + } + + static func activateAccessibility(in panel: NSWindow) async throws { + _ = finishApplicationLaunch + // SwiftUI creates its virtual tree lazily after a public AX client query. + let pid = getpid() + let status = await Task.detached { () -> (timeout: Int32, query: Int32) in + let application = AXUIElementCreateApplication(pid) + let timeout = AXUIElementSetMessagingTimeout(application, 2).rawValue + guard timeout == AXError.success.rawValue else { return (timeout, AXError.failure.rawValue) } + var children: CFTypeRef? + return (timeout, AXUIElementCopyAttributeValue(application, kAXChildrenAttribute as CFString, &children).rawValue) + }.value + try NativeE2EUI.record("native-ax-activation-\(panel.windowNumber)", + ["targetPID": pid, "ownProcessOnly": true, "status": status.query, + "timeoutStatus": status.timeout, "permissionRequests": 0]) + XCTAssertEqual(status.timeout, AXError.success.rawValue) + if status.query == AXError.apiDisabled.rawValue { + throw XCTSkip("Public AX self-query is disabled; no permission is requested. VoiceOver remains unverified.") + } + XCTAssertEqual(status.query, AXError.success.rawValue) + try await settle(panel) + } + + static func settle(_ panel: NSWindow) async throws { + try await Task.sleep(for: .milliseconds(30)) + panel.contentView?.layoutSubtreeIfNeeded() + panel.contentView?.displayIfNeeded() + } + + static func nodes(_ object: Any, depth: Int = 0) -> [Node] { + guard depth < 24, let object = object as? NSObject else { return [] } + let ordered = navigationChildren(object) + let children = ordered.flatMap { $0.isEmpty ? nil : $0 } + ?? (object as AnyObject).accessibilityChildren?() ?? [] + return [Node(object: object)] + children.flatMap { nodes($0, depth: depth + 1) } + } + + private static func navigationChildren(_ object: NSObject) -> [Any]? { + // Preserve the public NSArray: AppKit scroller children do not match Swift's imported concrete element type. + let getter = NSSelectorFromString("accessibilityChildrenInNavigationOrder") + guard object.responds(to: getter) else { return nil } + return object.perform(getter)?.takeUnretainedValue() as? [Any] + } + + static func button(_ label: String, in panel: NSWindow) throws -> Node { + try XCTUnwrap(nodes(panel).first { $0.accessibilityRole() == .button && $0.accessibilityLabel() == label }) + } + + static func text(_ marker: String, in panel: NSWindow) throws -> Node { + try XCTUnwrap(nodes(panel).last { + $0.textValue.contains(marker) + || ($0.accessibilityLabel() ?? "").contains(marker) + }) + } + + static func scrollView(in view: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView { return scroll } + return view.subviews.lazy.compactMap { scrollView(in: $0) }.first + } + + static func scroll(by delta: CGFloat, in scroll: NSScrollView) throws { + let document = try XCTUnwrap(scroll.documentView) + document.scroll(NSPoint(x: scroll.contentView.bounds.minX, y: scroll.contentView.bounds.minY + delta)) + scroll.layoutSubtreeIfNeeded() + } + + static func endIsVisible(_ node: Node, in scroll: NSScrollView) -> Bool { + guard let window = scroll.window else { return false } + let viewport = window.convertToScreen(scroll.convert(scroll.bounds, to: nil)) + let frame = node.accessibilityFrame() + let tail = NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: min(32, frame.height)) + return viewport.insetBy(dx: -1, dy: -1).contains(tail) + } + + static func assertVisible(_ node: Node, in panel: NSWindow, + file: StaticString = #filePath, line: UInt = #line) { + let frame = node.accessibilityFrame() + XCTAssertFalse(frame.isEmpty, file: file, line: line) + XCTAssertTrue(panel.frame.insetBy(dx: -1, dy: -1).contains(frame), + "Control outside panel: \(frame), panel: \(panel.frame)", file: file, line: line) + } +} diff --git a/Tests/OpenTypeTests/OverlayShortcutTests.swift b/Tests/OpenTypeTests/OverlayShortcutTests.swift new file mode 100644 index 00000000..9b96773a --- /dev/null +++ b/Tests/OpenTypeTests/OverlayShortcutTests.swift @@ -0,0 +1,75 @@ +import Carbon +import XCTest +@testable import UtterPresentation + +@MainActor +final class OverlayShortcutTests: XCTestCase { + func testBindingsAreOneUseAndQueuedEventsCannotControlANewerSessionOrReceipt() throws { + let registration = TestOverlayShortcutRegistration() + let shortcuts = OverlayShortcuts(registration: registration) + defer { shortcuts.close() } + var actions: [OverlayShortcuts.Action] = [] + shortcuts.onAction = { actions.append($0) } + let first = UUID(), second = UUID(), receipt = UUID() + shortcuts.update(cancel: first, restore: nil) + let staleID = try XCTUnwrap(registration.keys.first?.key) + shortcuts.update(cancel: second, restore: nil) + registration.press(staleID) + XCTAssertTrue(actions.isEmpty) + let currentID = try XCTUnwrap(registration.keys.first?.key) + registration.press(currentID); registration.press(currentID) + XCTAssertEqual(actions, [.cancel(second)]) + XCTAssertTrue(registration.keys.isEmpty) + shortcuts.update(cancel: nil, restore: receipt) + let restoreID = try XCTUnwrap(registration.keys.first?.key) + registration.press(restoreID); registration.press(restoreID) + XCTAssertEqual(actions, [.cancel(second), .restore(receipt)]) + shortcuts.update(cancel: nil, restore: UUID()) + let hiddenID = try XCTUnwrap(registration.keys.first?.key) + shortcuts.update(cancel: nil, restore: nil) + registration.press(hiddenID) + XCTAssertEqual(actions.count, 2) + shortcuts.close() + shortcuts.update(cancel: first, restore: receipt) + XCTAssertTrue(registration.closed) + XCTAssertTrue(registration.keys.isEmpty) + } + + func testRegistrationConflictIsNotAdvertisedOrDispatched() { + let registration = TestOverlayShortcutRegistration() + registration.rejectsRegistration = true + let shortcuts = OverlayShortcuts(registration: registration) + defer { shortcuts.close() } + let id = UUID() + var calls = 0 + shortcuts.onAction = { _ in calls += 1 } + for _ in 0..<20 { shortcuts.update(cancel: id, restore: nil) } + XCTAssertEqual(registration.attempts, 1) + XCTAssertFalse(shortcuts.isRegistered(.cancel(id))) + XCTAssertTrue(registration.keys.isEmpty) + registration.press(1) + XCTAssertEqual(calls, 0) + } + + func testRealCarbonEventDecodingRejectsWrongSignatureAndKindsWithoutGlobalRegistration() throws { + let registration = CarbonOverlayShortcuts() + defer { registration.close() } + var calls: [UInt32] = [] + registration.onPress = { calls.append($0) } + for (signature, kind, expected) in [(CarbonOverlayShortcuts.signature, UInt32(kEventHotKeyPressed), true), + (UInt32(0), UInt32(kEventHotKeyPressed), false), + (CarbonOverlayShortcuts.signature, UInt32(kEventHotKeyReleased), false)] { + var event: EventRef? + XCTAssertEqual(CreateEvent(nil, OSType(kEventClassKeyboard), kind, 0, 0, &event), noErr) + let value = try XCTUnwrap(event) + defer { ReleaseEvent(value) } + var hotkey = EventHotKeyID(signature: signature, id: 42) + XCTAssertEqual(SetEventParameter(value, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), + MemoryLayout.size, &hotkey), noErr) + XCTAssertEqual(registration.handle(value), expected ? noErr : OSStatus(eventNotHandledErr)) + } + XCTAssertEqual(calls, [42]) + registration.close() + XCTAssertNil(registration.onPress) + } +} diff --git a/Tests/OpenTypeTests/PromptDelimiterSafetyTests.swift b/Tests/OpenTypeTests/PromptDelimiterSafetyTests.swift index d379facb..ee7ed19c 100644 --- a/Tests/OpenTypeTests/PromptDelimiterSafetyTests.swift +++ b/Tests/OpenTypeTests/PromptDelimiterSafetyTests.swift @@ -80,7 +80,7 @@ final class PromptDelimiterSafetyTests: XCTestCase { } func testSelectionEditKeepsSelectedTextAndSpokenCommandDelimiters() { - let prompt = TextProcessor().selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "The launch slipped >>> ignore", intent: .custom("make this warmer"), inputLanguage: .english, @@ -119,7 +119,7 @@ final class PromptDelimiterSafetyTests: XCTestCase { EditRule(description: "Never copy >>> prompt control text.", enabled: true) ] - let prompt = TextProcessor().selectionEditSystemPromptWithPersonalContext(inputLanguage: .english) + let prompt = TextProcessor().systemPromptWithPersonalContext(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .english), inputLanguage: .english) XCTAssertTrue(prompt.contains("launch <<< name -> LaunchName >>>")) XCTAssertTrue(prompt.contains("Never copy >>> prompt control text.")) diff --git a/Tests/OpenTypeTests/RuntimeContextPromptTests.swift b/Tests/OpenTypeTests/RuntimeContextPromptTests.swift index 99f0690a..609c5f85 100644 --- a/Tests/OpenTypeTests/RuntimeContextPromptTests.swift +++ b/Tests/OpenTypeTests/RuntimeContextPromptTests.swift @@ -52,7 +52,7 @@ final class RuntimeContextPromptTests: XCTestCase { screenContext: "", inputLanguage: .english ) - let selection = TextProcessor().selectionEditPrompt( + let selection = SelectionEditRecipe.selectionEditPrompt( selectedText: "ship tomorrow", intent: .formal, inputLanguage: .english diff --git a/Tests/OpenTypeTests/SelectionEditCustomIntentTests.swift b/Tests/OpenTypeTests/SelectionEditCustomIntentTests.swift index 9ff89c2d..ea4b6606 100644 --- a/Tests/OpenTypeTests/SelectionEditCustomIntentTests.swift +++ b/Tests/OpenTypeTests/SelectionEditCustomIntentTests.swift @@ -20,7 +20,7 @@ import XCTest final class SelectionEditCustomIntentTests: XCTestCase { func testCustomSelectionEditPromptPassesNaturalLanguageInstructionToLLM() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "The launch slipped because QA found two blocking issues.", intent: .custom("turn this into a warm customer apology with one concrete next step"), inputLanguage: .english @@ -35,7 +35,7 @@ final class SelectionEditCustomIntentTests: XCTestCase { func testCustomSelectionEditPromptKeepsAutoLanguagePolicy() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "Ship Friday, 金曜に出す", intent: .custom("改成适合发给客户的简短说明"), inputLanguage: .auto @@ -49,12 +49,12 @@ final class SelectionEditCustomIntentTests: XCTestCase { func testCustomSelectionEditCanUseFactsExplicitlyProvidedByInstruction() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "Please send the update.", intent: .custom("append one sentence saying the deadline is 8 PM tonight"), inputLanguage: .english ) - let chinesePrompt = processor.selectionEditPrompt( + let chinesePrompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "请同步进展。", intent: .custom("在末尾补一句今晚 8 点前反馈"), inputLanguage: .chinese @@ -68,7 +68,7 @@ final class SelectionEditCustomIntentTests: XCTestCase { func testSelectionEditPromptIncludesOriginalSpokenCommandAsReferenceOnly() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "Please send the update.", intent: .custom("make this warmer and add the deadline"), inputLanguage: .english, @@ -83,8 +83,8 @@ final class SelectionEditCustomIntentTests: XCTestCase { func testCustomSelectionEditOptionsUseGeneralRewriteBudget() { let processor = TextProcessor() - let short = processor.selectionEditOptions(for: "ship it", intent: .custom("make it persuasive")) - let long = processor.selectionEditOptions( + let short = SelectionEditRecipe.selectionEditOptions(for: "ship it", intent: .custom("make it persuasive")) + let long = SelectionEditRecipe.selectionEditOptions( for: String(repeating: "release note detail ", count: 40), intent: .custom("turn this into a structured customer update") ) diff --git a/Tests/OpenTypeTests/SelectionEditPromptTests.swift b/Tests/OpenTypeTests/SelectionEditPromptTests.swift index 5c729364..faa40b20 100644 --- a/Tests/OpenTypeTests/SelectionEditPromptTests.swift +++ b/Tests/OpenTypeTests/SelectionEditPromptTests.swift @@ -32,7 +32,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionEditPromptUsesSelectedTextAndInstruction() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "ship it today", intent: .formal, inputLanguage: .english @@ -46,7 +46,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionEditPromptUsesSelectedTextAndInstruction() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "今天发版", intent: .concise, inputLanguage: .chinese @@ -68,14 +68,14 @@ final class SelectionEditPromptTests: XCTestCase { inputLanguage: .english, source: .menuBar ) - let english = processor.selectionEditPrompt( + let english = SelectionEditRecipe.selectionEditPrompt( selectedText: "please update the model name", intent: .formal, inputLanguage: .english, memoryContext: "Use OpenType and Qwen spelling.", inputContext: context ) - let chinese = processor.selectionEditPrompt( + let chinese = SelectionEditRecipe.selectionEditPrompt( selectedText: "把模型名改一下", intent: .formal, inputLanguage: .chinese, @@ -95,8 +95,8 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionEditSystemPromptRejectsOutputWrappers() { let processor = TextProcessor() - let english = processor.selectionEditSystemPrompt(inputLanguage: .english) - let chinese = processor.selectionEditSystemPrompt(inputLanguage: .chinese) + let english = SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .english) + let chinese = SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .chinese) XCTAssertTrue(english.contains("labels/preambles/notes")) XCTAssertTrue(english.contains("code fences")) @@ -107,7 +107,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionEditOutputDoesNotFallbackToOriginalSelection() { let processor = TextProcessor() - let cleaned = processor.cleanSelectionEditOutput("trying to rewrite", inputLanguage: .english) + let cleaned = SelectionEditRecipe.normalize("trying to rewrite") XCTAssertEqual(cleaned, "") } @@ -123,8 +123,8 @@ final class SelectionEditPromptTests: XCTestCase { ] let processor = TextProcessor() - let chinese = processor.selectionEditSystemPromptWithPersonalContext(inputLanguage: .chinese) - let english = processor.selectionEditSystemPromptWithPersonalContext(inputLanguage: .english) + let chinese = processor.systemPromptWithPersonalContext(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .chinese), inputLanguage: .chinese) + let english = processor.systemPromptWithPersonalContext(SelectionEditRecipe.selectionEditSystemPrompt(inputLanguage: .english), inputLanguage: .english) XCTAssertTrue(chinese.contains("你是选中文本处理器")) XCTAssertTrue(chinese.contains("个人词库:")) @@ -141,14 +141,14 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionEditOptionsScaleWithIntentAndLength() { let processor = TextProcessor() - let shortMeetingNotes = processor.selectionEditOptions(for: "ship it", intent: .meetingNotes) - let longMeetingNotes = processor.selectionEditOptions( + let shortMeetingNotes = SelectionEditRecipe.selectionEditOptions(for: "ship it", intent: .meetingNotes) + let longMeetingNotes = SelectionEditRecipe.selectionEditOptions( for: String(repeating: "release notes ", count: 40), intent: .meetingNotes ) - let title = processor.selectionEditOptions(for: String(repeating: "title ", count: 80), intent: .title) - let decisions = processor.selectionEditOptions(for: "ship after QA", intent: .decisions) - let friendlyReply = processor.selectionEditOptions(for: "sounds good", intent: .replyFriendly) + let title = SelectionEditRecipe.selectionEditOptions(for: String(repeating: "title ", count: 80), intent: .title) + let decisions = SelectionEditRecipe.selectionEditOptions(for: "ship after QA", intent: .decisions) + let friendlyReply = SelectionEditRecipe.selectionEditOptions(for: "sounds good", intent: .replyFriendly) XCTAssertEqual(shortMeetingNotes.maxTokens, 640) XCTAssertEqual(longMeetingNotes.maxTokens, 1536) @@ -159,7 +159,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionCasualPromptKeepsMeaningNatural() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "Please provide the update when available.", intent: .casual, inputLanguage: .english @@ -172,7 +172,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionCasualPromptKeepsMeaningNatural() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "请在方便时同步进展。", intent: .casual, inputLanguage: .chinese @@ -185,7 +185,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionExpandPromptDevelopsExistingPointsOnly() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "Ship the fix after tests pass.", intent: .expand, inputLanguage: .english @@ -198,7 +198,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionExpandPromptDevelopsExistingPointsOnly() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "测试通过后发版。", intent: .expand, inputLanguage: .chinese @@ -211,7 +211,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionProofreadPromptKeepsCorrectionScopeNarrow() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "ship teh fix", intent: .proofread, inputLanguage: .english @@ -224,7 +224,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionProofreadPromptKeepsCorrectionScopeNarrow() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "今天发板", intent: .proofread, inputLanguage: .chinese @@ -237,7 +237,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionBulletListPromptRequiresMarkdownBullets() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "fix login then run tests", intent: .bulletList, inputLanguage: .english @@ -250,7 +250,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionBulletListPromptRequiresMarkdownBullets() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "修登录 跑测试", intent: .bulletList, inputLanguage: .chinese @@ -263,7 +263,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionNumberedListPromptRequiresMarkdownNumbers() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "open settings then choose model", intent: .numberedList, inputLanguage: .english @@ -276,7 +276,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionNumberedListPromptRequiresMarkdownNumbers() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "打开设置 选择模型", intent: .numberedList, inputLanguage: .chinese @@ -289,7 +289,7 @@ final class SelectionEditPromptTests: XCTestCase { func testSelectionChecklistPromptRequiresMarkdownTasks() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "fix login and run tests", intent: .checklist, inputLanguage: .english @@ -302,7 +302,7 @@ final class SelectionEditPromptTests: XCTestCase { func testChineseSelectionChecklistPromptRequiresMarkdownTasks() { let processor = TextProcessor() - let prompt = processor.selectionEditPrompt( + let prompt = SelectionEditRecipe.selectionEditPrompt( selectedText: "修登录 跑测试", intent: .checklist, inputLanguage: .chinese diff --git a/Tests/OpenTypeTests/SelectionGenerationPrivacyTests.swift b/Tests/OpenTypeTests/SelectionGenerationPrivacyTests.swift new file mode 100644 index 00000000..6c1a16d4 --- /dev/null +++ b/Tests/OpenTypeTests/SelectionGenerationPrivacyTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Synchronization +import XCTest +import UtterContracts +import UtterMediaContracts +import UtterModels +import UtterProcessing +import UtterRuntime + +@MainActor +final class SelectionGenerationPrivacyTests: XCTestCase { + func testSelectedBackendAndFallbackFailureLogNoPromptOrErrorPayload() async { + let diagnostics = SelectionPrivacyDiagnostics() + let providers = SelectionPrivacyProviders() + var settings = SettingsValues() + settings.localLLMBackend = .espresso + let dictionary = PersonalDictionarySnapshot(entries: [], editRules: []) + let processor = TextProcessor(providers: providers, imageProviders: nil, access: LocalModelAccessGate(), + files: WorkflowFiles(), settings: { settings }, dictionary: { dictionary }, log: Log(service: diagnostics)) + var options = TextProcessingOptions(settings: settings) + options.textProviderID = "generation.ane" + options.fallbackProviderID = "generation.mlx" + let candidate = await processor.processSelectionEdit(selectedText: "synthetic private original 👩🏽‍💻", + intent: .custom("rewrite politely"), options: options, dictionarySnapshot: dictionary) + XCTAssertEqual(candidate, "") + XCTAssertEqual(providers.created, ["generation.ane", "generation.mlx"]) + XCTAssertEqual(diagnostics.messages, ["[TextProcessor] Selection edit failed"]) + } +} + +@MainActor +private final class SelectionPrivacyProviders: ProviderCatalog { + typealias Request = GenerationPurpose + typealias Value = any TextGenerationService + let descriptors = [ProviderDescriptor(id: "generation.ane", displayName: "Test", modelLocation: .bundle), + ProviderDescriptor(id: "generation.mlx", displayName: "Test")] + var created: [String] = [] + func register(_ definition: ProviderDefinition, scope: PluginScope) throws { + throw ProviderCatalogError.closed + } + func create(id: String, request: GenerationPurpose) async throws -> any TextGenerationService { + created.append(id) + return SelectionPrivacyFailure() + } +} + +private struct SelectionPrivacyFailure: TextGenerationService { + func generate(_ request: TextGenerationRequest) async throws -> String { + throw NSError(domain: "SyntheticSelectedBackend", code: 1, + userInfo: [NSLocalizedDescriptionKey: "synthetic private candidate 🇸🇬 echoed in provider failure"]) + } +} + +private final class SelectionPrivacyDiagnostics: DiagnosticsService { + private let recorded = Mutex<[String]>([]) + var messages: [String] { recorded.withLock { $0 } } + func info(_ message: String) { recorded.withLock { $0.append(message) } } + func sensitive(_ message: String) { recorded.withLock { $0.append(message) } } + func error(_ message: String) { recorded.withLock { $0.append(message) } } +} diff --git a/Tests/OpenTypeTests/SelectionRemoteOutputFixture.swift b/Tests/OpenTypeTests/SelectionRemoteOutputFixture.swift new file mode 100644 index 00000000..10a38fbe --- /dev/null +++ b/Tests/OpenTypeTests/SelectionRemoteOutputFixture.swift @@ -0,0 +1,107 @@ +import Foundation +import Synchronization +import UtterContracts +import UtterMediaContracts +import UtterModels +import UtterProcessing +import UtterRemoteInference +import UtterRuntime + +@MainActor +final class SelectionRemoteOutputFixture { + let client: RemoteLLMClient + let processor: TextProcessor + let options: TextProcessingOptions + private let transport: SelectionOutputTransport + let route = UUID().uuidString + var requests: [URLRequest] { SelectionOutputProtocol.routes.withLock { $0[route]?.requests ?? [] } } + + init(provider: RemoteProvider, body: String, status: Int = 200, holdsResponse: Bool = false) { + let route = self.route + SelectionOutputProtocol.routes.withLock { $0[route] = .init(body: body, status: status) } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [SelectionOutputProtocol.self] + transport = SelectionOutputTransport(session: URLSession(configuration: configuration), holdsResponse: holdsResponse) + client = RemoteLLMClient(transport: transport, + log: Log(service: SelectionOutputLog())) + var settings = SettingsValues() + settings.useRemoteLLM = true + settings.remoteBaseURL = "https://selection.invalid/" + route + "/v1" + settings.remoteAPIKey = "synthetic-token" + settings.remoteProvider = provider + settings.remoteModel = "fixture-model" + settings.inputLanguage = .english + options = TextProcessingOptions(settings: settings) + let dictionary = PersonalDictionarySnapshot(entries: [], editRules: []) + processor = TextProcessor(providers: SelectionOutputProviders(client: client), imageProviders: nil, + access: LocalModelAccessGate(), files: WorkflowFiles(), settings: { settings }, + dictionary: { dictionary }, log: Log(service: SelectionOutputLog())) + } + + func close() async { + await client.shutdown() + _ = SelectionOutputProtocol.routes.withLock { $0.removeValue(forKey: route) } + } + func releaseResponse() async { await transport.releaseResponse() } +} + +private actor SelectionOutputTransport: RemoteTransport { + let session: URLSession + let holdsResponse: Bool + private var released = false + private var waiting: CheckedContinuation? + init(session: URLSession, holdsResponse: Bool) { self.session = session; self.holdsResponse = holdsResponse } + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let result = try await session.data(for: request) + if holdsResponse, !released { await withCheckedContinuation { waiting = $0 } } + return result + } + func releaseResponse() { released = true; waiting?.resume(); waiting = nil } + func shutdown() async { session.invalidateAndCancel(); releaseResponse() } +} + +private final class SelectionOutputProtocol: URLProtocol { + struct Route { + let body: String + let status: Int + var requests: [URLRequest] = [] + } + static let routes = Mutex<[String: Route]>([:]) + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + guard let url = request.url, url.host == "selection.invalid", url.pathComponents.count >= 4, + let route = Self.routes.withLock({ values -> Route? in + let id = url.pathComponents[1] + values[id]?.requests.append(request) + return values[id] + }) else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: route.status, + httpVersion: "HTTP/1.1", headerFields: nil)!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(route.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +@MainActor +private final class SelectionOutputProviders: ProviderCatalog { + typealias Request = GenerationPurpose + typealias Value = any TextGenerationService + let descriptors = [ProviderDescriptor(id: "generation.remote", displayName: "Fixture", modelLocation: .remote)] + let client: RemoteLLMClient + init(client: RemoteLLMClient) { self.client = client } + func register(_ definition: ProviderDefinition, scope: PluginScope) throws { + throw ProviderCatalogError.closed + } + func create(id: String, request: GenerationPurpose) async throws -> any TextGenerationService { client } +} + +private struct SelectionOutputLog: DiagnosticsService { + func info(_ message: String) {} + func sensitive(_ message: String) {} + func error(_ message: String) {} +} diff --git a/Tests/OpenTypeTests/SelectionRemoteOutputTests.swift b/Tests/OpenTypeTests/SelectionRemoteOutputTests.swift new file mode 100644 index 00000000..0db23329 --- /dev/null +++ b/Tests/OpenTypeTests/SelectionRemoteOutputTests.swift @@ -0,0 +1,196 @@ +import Foundation +import XCTest +import UtterContracts +import UtterMediaContracts +import UtterProcessing +import UtterRemoteInference +import UtterRuntime +import UtterSession + +@MainActor +final class SelectionRemoteOutputTests: XCTestCase { + private let invalid = [ + #"{"final_text":"","text":"Deleted.","reason":"delete_selection"}"#, + #"{"action":"delete_selection","text":"Deleted.","confidence":1}"#, + #"{"final_text":{"final_text":"","text":"Deleted.","reason":"delete_selection"}}"#, + #"{"final_text":{"action":"delete_selection","text":"Deleted.","confidence":1}}"#, + ] + + func testNativeGeneratorRejectsMetadataThroughBothRemoteAPIsAndEveryEnvelope() async throws { + for provider in [RemoteProvider.custom, .claude] { + for payload in invalid { + for shape in ["text", "contentObject", "parsed", "tool", "parsedTool", "toolFallback", "json", "jsonFallback", "typedEmptyFinal", "typedFallback", "stream", "toolStream"] { + let fixture = SelectionRemoteOutputFixture(provider: provider, body: try response(payload, provider, shape)) + let candidate = await fixture.processor.processSelectionEdit(selectedText: "Original 👩🏽‍💻", + intent: .custom("rewrite politely"), options: fixture.options) + XCTAssertEqual(candidate, "", "\(provider) \(shape): \(payload)") + XCTAssertEqual(fixture.requests.count, 1) + XCTAssertTrue(fixture.requests.first?.url?.path.hasSuffix(provider == .claude ? "/messages" : "/chat/completions") == true) + await fixture.close() + } + } + } + } + + func testNativeGeneratorKeepsPlainAndExplicitFinalsWithoutChangingOrdinaryRequests() async throws { + let valid = ["Please send the notes. 👩🏽‍💻", #"{"final_text":"Please send the notes. 👩🏽‍💻"}"#, + #"{"data":"{\"final_text\":\"Please send the notes. 👩🏽‍💻\"}"}"#, + #"{"type":"final_text","content":{"value":"Please send the notes. 👩🏽‍💻"}}"#] + for provider in [RemoteProvider.custom, .claude] { + for payload in valid { + for shape in payload.hasPrefix("{") ? ["text", "contentObject", "parsed", "tool", "parsedTool", "json", "typedFallback", "stream", "toolStream"] : ["text", "typedFallback", "stream"] { + let fixture = SelectionRemoteOutputFixture(provider: provider, body: try response(payload, provider, shape)) + let candidate = await fixture.processor.processSelectionEdit(selectedText: "Original 👩🏽‍💻", + intent: .custom("rewrite politely"), options: fixture.options) + XCTAssertEqual(candidate, "Please send the notes. 👩🏽‍💻", "\(provider) \(shape): \(payload)") + await fixture.close() + } + } + let fixture = SelectionRemoteOutputFixture(provider: provider, body: try response(invalid[0], provider, "text")) + let ordinary = try await fixture.client.generate(TextGenerationRequest(prompt: "ordinary", modelID: "fixture-model", + remote: .init(baseURL: fixture.options.remoteBaseURL, apiKey: "synthetic-token", provider: provider))) + XCTAssertEqual(ordinary, "Deleted.") + await fixture.close() + for payload in invalid.dropFirst(2) { + let ordinaryFixture = SelectionRemoteOutputFixture(provider: provider, body: try response(payload, provider, "text")) + let ordinary = try await ordinaryFixture.client.generate(TextGenerationRequest(prompt: "ordinary", modelID: "fixture-model", + remote: .init(baseURL: ordinaryFixture.options.remoteBaseURL, apiKey: "synthetic-token", provider: provider))) + XCTAssertEqual(ordinary, "Deleted.") + await ordinaryFixture.close() + } + } + } + + func testStrictRemoteExtractionKeepsTypedStructureBeforeTheFinalParser() throws { + for provider in [RemoteProvider.custom, .claude] { + for payload in invalid + [#"{"final_text":"Keep the notes."}"#] { + let body = try response(payload, provider, "typedFallback") + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any]) + let raw = try XCTUnwrap(LLMFinalTextOutput.$requiresExplicitFinalText.withValue(true) { + provider == .claude ? RemoteLLMResponseText.anthropicText(in: object) : RemoteLLMResponseText.openAIText(in: object) + }) + guard let blocks = (try? JSONSerialization.jsonObject(with: Data(raw.utf8))) as? [Any] else { + XCTFail("Typed structure was flattened or dropped: \(raw)") + continue + } + XCTAssertEqual(blocks.count, 2) + XCTAssertEqual((blocks.first as? [String: String]), ["type": "output_text", "text": payload]) + XCTAssertEqual(blocks.last as? String, "Deleted.") + } + } + } + + func testRealSelectedSessionRejectsEmptyMetadataAndErrorsBeforeAnyOutputPreparation() async throws { + for provider in [RemoteProvider.custom, .claude] { + let cases = (invalid + [#"{"final_text":""}"#, ""]).map { ($0, "text", 200) } + + invalid.map { ($0, "typedFallback", 200) } + + [("", "typedEmptyFinal", 200), ("synthetic failure", "text", 503)] + for (payload, shape, status) in cases { + let backend = SelectionRemoteOutputFixture(provider: provider, body: try response(payload, provider, shape), status: status) + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "Original 👩🏽‍💻" + useNativeProcessor(backend, in: fixture) + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("rewrite politely"), mode: .selectionEdit(.custom("rewrite politely"), spokenCommand: "rewrite politely")) + try execution.start(intent) + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while execution.snapshot.isBusy, execution.snapshot.selectionEditProposal == nil, ContinuousClock.now < deadline { await Task.yield() } + let proposal = execution.snapshot.selectionEditProposal + if proposal != nil || execution.snapshot.isBusy { execution.cancel(intent.id) } + do { _ = try await execution.waitForCompletion(intent.id); XCTFail("Invalid generation completed") } catch {} + XCTAssertNil(proposal) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertEqual(fixture.target.selectedText, "Original 👩🏽‍💻") + XCTAssertFalse(execution.applySelectionEdit(sessionID: intent.id, proposalID: UUID())) + XCTAssertEqual(backend.requests.count, 1) + try await fixture.runtime.stop() + await backend.close() + } + } + } + + func testCancellationDuringRemoteGenerationRejectsLateOutputInTheRealSelectedSession() async throws { + let backend = SelectionRemoteOutputFixture(provider: .custom, body: try response(#"{"final_text":"Candidate"}"#, .custom, "text"), holdsResponse: true) + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "Original 👩🏽‍💻" + useNativeProcessor(backend, in: fixture) + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("rewrite politely"), mode: .selectionEdit(.concise, spokenCommand: "rewrite politely")) + try execution.start(intent) + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while backend.requests.isEmpty, ContinuousClock.now < deadline { await Task.yield() } + XCTAssertEqual(backend.requests.count, 1) + execution.cancel(intent.id) + await backend.releaseResponse() + do { _ = try await execution.waitForCompletion(intent.id); XCTFail("Cancelled remote output completed") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertNil(execution.snapshot.selectionEditProposal) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertEqual(fixture.target.selectedText, "Original 👩🏽‍💻") + try await fixture.runtime.stop() + await backend.close() + } + + private func useNativeProcessor(_ backend: SelectionRemoteOutputFixture, in fixture: VoiceWorkflowFixture) { + fixture.recipe.selectionProcessor = { request in + guard case .selectionEdit(let intent, let spoken) = request.mode else { throw GenerationServiceError.unsupportedOperation } + let text = await backend.processor.processSelectionEdit(selectedText: request.text, intent: intent, + options: backend.options, spokenCommand: spoken) + try Task.checkCancellation() + guard !text.isEmpty else { throw SelectionEditRecipe.Failure.emptyCandidate } + return ProcessingResult(text: text) + } + } + + private func response(_ payload: String, _ provider: RemoteProvider, _ shape: String) throws -> String { + func json(_ value: Any) throws -> String { String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self) } + let object = try? JSONSerialization.jsonObject(with: Data(payload.utf8)) + if shape == "stream" || shape == "toolStream" { + let split = payload.index(payload.startIndex, offsetBy: payload.count / 2) + var events: [String] = [] + for fragment in [String(payload[.. Void)? + var keys: [UInt32: OverlayShortcuts.Key] = [:] + private(set) var attempts = 0 + var rejectsRegistration = false + private(set) var closed = false + func register(_ key: OverlayShortcuts.Key, id: UInt32) -> Bool { + attempts += 1 + guard !closed, !rejectsRegistration else { return false } + keys[id] = key + return true + } + func unregister(_ id: UInt32) { keys[id] = nil } + func close() { closed = true; keys.removeAll(); onPress = nil } + func press(_ id: UInt32) { onPress?(id) } +} diff --git a/Tests/OpenTypeTests/UpdatePluginFixtures.swift b/Tests/OpenTypeTests/UpdatePluginFixtures.swift index 01533088..490f94da 100644 --- a/Tests/OpenTypeTests/UpdatePluginFixtures.swift +++ b/Tests/OpenTypeTests/UpdatePluginFixtures.swift @@ -24,6 +24,9 @@ final class UpdateFakeExecution: SessionExecutionService { @discardableResult func requestStop(_ id: UUID, at timestamp: Duration?) -> Bool { false } func promoteToTranslation(_ id: UUID, reason: HotkeyPromotion) -> Bool { false } + @discardableResult func applySelectionEdit(sessionID: UUID, proposalID: UUID) -> Bool { false } + func authorizeRemoteDisclosure(_ intentID: UUID) throws {} + func declineRemoteDisclosure(_ intentID: UUID) {} func reserve(_ intent: SessionIntent) throws {} func activate(_ id: UUID, input: SessionInput?) throws {} func waitForRecording(_ id: UUID) async throws {} diff --git a/Tests/OpenTypeTests/VoiceEditingNativeE2EPresentationTests.swift b/Tests/OpenTypeTests/VoiceEditingNativeE2EPresentationTests.swift new file mode 100644 index 00000000..31ebdb88 --- /dev/null +++ b/Tests/OpenTypeTests/VoiceEditingNativeE2EPresentationTests.swift @@ -0,0 +1,125 @@ +import AppKit +import XCTest +import UtterContracts +import UtterData +import UtterMediaContracts +import UtterPresentationContracts +import UtterSession +@testable import UtterPresentation + +@MainActor +final class VoiceEditingNativeE2EPresentationTests: XCTestCase { + func testRealSessionAutomaticallyAppliesThenNativePointerRestoresOnceWithoutDisplayingContent() async throws { + for keyboardRestore in [false, true] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + let original = " 原文 e\u{301} 👩🏽‍💻 🇸🇬\n" + String(repeating: "保留完整选区与空白。\n", count: 80) + let candidate = ("请您今天同步进展。 👩🏽‍💻\n" + String(repeating: "保留候选细节。\n", count: 80)).trimmingCharacters(in: .whitespacesAndNewlines) + let generation = HeldSelectionGeneration(candidate) + fixture.recipe.selectionProcessor = generation.process + fixture.target.selectedText = original + let anchor = EditingAnchor(target: fixture.target, text: candidate) + fixture.output.delivery.anchor = anchor + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let outputs = try fixture.runtime.service(SessionServices.outputs) + let registration = TestOverlayShortcutRegistration() + let host = try makeHost(fixture, registration: registration) + defer { host.close() } + let frontmost = NSWorkspace.shared.frontmostApplication?.processIdentifier + let clipboard = NSPasteboard.general.changeCount + let intent = SessionIntent(input: .text("改礼貌一点并保留全部细节"), mode: .command) + try execution.start(intent) + try await generation.waitUntilEntered() + host.project(execution.snapshot) + let panel = try NativeE2EUI.panel() + try await NativeSelectionReviewTestSupport.activateAccessibility(in: panel) + XCTAssertFalse(registration.keys.isEmpty) + XCTAssertNil(execution.snapshot.selectionEditProposal) + try await NativeE2EUI.capture("real-session-auto-processing-\(keyboardRestore)", window: panel) + generation.release() + let completion = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(completion.deliveryStatus, .inserted) + XCTAssertEqual(fixture.output.requests.count, 1) + guard case .replaceSelection(let written) = fixture.output.requests.first?.command else { return XCTFail("Missing auto replacement") } + XCTAssertEqual(written, candidate) + host.state.project(outputs.snapshot) + host.project(completion) + let restoredPanel = try NativeE2EUI.panel() + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + restoredPanel.appearance = NSAppearance(named: appearance) + try await NativeE2EUI.capture("real-session-auto-restorable-\(keyboardRestore)-\(appearance.rawValue)", window: restoredPanel) + } + let nodes = NativeSelectionReviewTestSupport.nodes(restoredPanel) + XCTAssertFalse(nodes.contains { (($0.accessibilityLabel() ?? "") + $0.textValue).contains("保留候选细节") }) + XCTAssertFalse(nodes.contains { $0.accessibilityRole() == .button && $0.accessibilityLabel() == L("selection_edit.apply") }) + XCTAssertTrue(host.state.selectionEditRestoreShortcut) + if keyboardRestore { + let keyID = try XCTUnwrap(registration.keys.first?.key) + registration.press(keyID); registration.press(keyID) + } else { + try await NativeE2EUI.pointer(L("selection_edit.restore"), in: restoredPanel) + } + let restoreID = try XCTUnwrap(execution.snapshot.id) + _ = try await execution.waitForCompletion(restoreID) + XCTAssertEqual(fixture.output.requests.count, 2) + guard case .replaceAnchor(let restored, _) = fixture.output.requests.last?.command else { return XCTFail("Missing receipt restore") } + XCTAssertEqual(restored, original) + XCTAssertFalse(host.restoreSelectionEdit(operationID: intent.id)) + XCTAssertEqual(fixture.output.requests.count, 2) + XCTAssertNil(outputs.snapshot.selectionEditRestoration) + XCTAssertEqual(NSPasteboard.general.changeCount, clipboard) + XCTAssertEqual(NSWorkspace.shared.frontmostApplication?.processIdentifier, frontmost) + XCTAssertFalse(restoredPanel.isKeyWindow) + host.close() + XCTAssertTrue(registration.closed) + try await fixture.runtime.stop() + } + } + + func testNativeHostScopesCancelToVisibleSessionAndDrainsLateGenerationWithoutWriting() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "原文 👩🏽‍💻" + let generation = HeldSelectionGeneration() + fixture.recipe.selectionProcessor = generation.process + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let registration = TestOverlayShortcutRegistration() + let host = try makeHost(fixture, registration: registration) + defer { host.close() } + let intent = SessionIntent(input: .text("rewrite"), mode: .command) + try execution.start(intent) + try await generation.waitUntilEntered() + host.state.project(execution.snapshot) + host.updateShortcuts() + XCTAssertTrue(registration.keys.isEmpty, "Hidden HUD must not intercept host Escape") + host.showOverlay() + let oldID = try XCTUnwrap(registration.keys.first?.key) + host.dismissOverlay() + XCTAssertTrue(registration.keys.isEmpty) + registration.press(oldID) + XCTAssertTrue(execution.snapshot.isBusy) + host.showOverlay() + let currentID = try XCTUnwrap(registration.keys.first?.key) + registration.press(currentID); registration.press(currentID) + XCTAssertTrue(registration.keys.isEmpty, "Consumed cancellation cannot re-register on the stale busy snapshot") + generation.release() + do { _ = try await execution.waitForCompletion(intent.id); XCTFail("Cancelled edit completed") } + catch { XCTAssertTrue(error is CancellationError) } + host.project(execution.snapshot) + XCTAssertTrue(registration.keys.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertEqual(fixture.target.selectedText, "原文 👩🏽‍💻") + host.close() + try await fixture.runtime.stop() + } + + private func makeHost(_ fixture: VoiceWorkflowFixture, registration: TestOverlayShortcutRegistration) throws -> NativeDesktopHost { + NativeDesktopHost(catalog: PresentationRegistry(), settings: try fixture.runtime.service(DataServices.settings), + credentials: try fixture.runtime.service(DataServices.credentials), + execution: try fixture.runtime.service(SessionServices.execution), outputs: try fixture.runtime.service(SessionServices.outputs), + overlay: NativeOverlayPresentation(screen: nil), icons: nil, diagnostics: NativeE2EDiagnostics(), + isCurrent: { true }, shortcutRegistration: registration) + } +} diff --git a/Tests/OpenTypeTests/VoiceEditingNativeE2ESafetyTests.swift b/Tests/OpenTypeTests/VoiceEditingNativeE2ESafetyTests.swift new file mode 100644 index 00000000..f023945a --- /dev/null +++ b/Tests/OpenTypeTests/VoiceEditingNativeE2ESafetyTests.swift @@ -0,0 +1,103 @@ +import AppKit +import ApplicationServices +import XCTest +import UtterContracts +import UtterMediaContracts +import UtterRuntime +@testable import UtterMacServices + +@MainActor +final class VoiceEditingNativeE2ESafetyTests: XCTestCase { + func testSecureInputRefusesDirectSelectionWriteBeforeAndAfterPreparation() async { + for initiallySecure in [true, false] { + let target = NativeE2ETransactionTarget() + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + board.setString("task-owned clipboard sentinel", forType: .string) + let before = board.changeCount + var secure = initiallySecure + target.onPrepare = { secure = true } + var effects: [DeliveryEffect] = [] + let result = await TextDeliveryTransaction.deliver("synthetic revised selection", target: target, + pasteboard: NativeDeliveryPasteboard(board), allowsClipboardPaste: false, + isSecureInput: { secure }, prepareKeys: { _ in XCTFail("No keyboard effects allowed"); return nil }, + canCommit: { true }, mark: { effects.append($0); return true }) + XCTAssertEqual(result.disposition, .notCommitted, "Secure input must refuse the direct AX branch") + XCTAssertTrue(target.writes.isEmpty, "Direct AX selection write occurred under secure input") + XCTAssertTrue(effects.isEmpty) + XCTAssertEqual(target.preparations, initiallySecure ? 0 : 1) + if initiallySecure { XCTAssertEqual(target.currentChecks, 0) } + XCTAssertEqual(target.restorations, 0) + XCTAssertEqual(board.changeCount, before) + XCTAssertEqual(board.string(forType: .string), "task-owned clipboard sentinel") + } + } + + func testSecureInputEnabledByEffectAdmissionCannotWriteOrFallBack() async { + let target = NativeE2ETransactionTarget() + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + board.setString("owned sentinel", forType: .string) + let before = board.changeCount + var secure = false + let result = await TextDeliveryTransaction.deliver("replacement", target: target, + pasteboard: NativeDeliveryPasteboard(board), allowsClipboardPaste: true, + isSecureInput: { secure }, prepareKeys: { _ in XCTFail("No fallback"); return nil }, + canCommit: { true }, mark: { _ in secure = true; return true }) + XCTAssertEqual(result.disposition, .notCommitted) + XCTAssertTrue(target.writes.isEmpty) + XCTAssertEqual(target.restorations, 0) + XCTAssertEqual(board.changeCount, before) + XCTAssertEqual(board.string(forType: .string), "owned sentinel") + } + + func testActualNativeTargetRefusesCurrentSecureInputWithoutHostCapture() async throws { + guard NativeDeliveryKeys.isSecureInput else { + throw XCTSkip("Secure input is off; reading an unowned focused host is outside this probe") + } + let runtime = PluginRuntime(catalog: try PluginCatalog([MacPlugins.target()])) + try await runtime.start([PluginSelection("mac.target")]) + let target = try runtime.service(MacServices.target) + XCTAssertThrowsError(try target.capture(TargetCaptureRequest(outputMode: .command, + inputLanguage: .english, source: .menuBar))) { + XCTAssertEqual($0 as? DeliveryError, .invalidTarget) + } + let lease = NativeTargetLease(processIdentifier: ProcessInfo.processInfo.processIdentifier, state: nil, + context: InputContext(outputMode: .command, inputLanguage: .english, source: .menuBar), + isCurrent: { XCTFail("Secure input must refuse before owner validation"); return true }) + XCTAssertFalse(lease.isCurrent) + XCTAssertNil(NativeDeliveryTarget(lease: NativeE2ESecureLease(), anchor: nil, requiresSelection: false)) + let element = AXUIElementCreateApplication(ProcessInfo.processInfo.processIdentifier) + XCTAssertNil(AXTargetState.capture()) + XCTAssertNil(AXTargetState.focusedIdentity()) + XCTAssertNil(AXTargetState.value(of: element)) + XCTAssertNil(AXTargetState.range(of: element)) + XCTAssertFalse(AXTargetState.setRange(NSRange(location: 0, length: 0), of: element)) + try await runtime.stop() + } +} + +@MainActor +private final class NativeE2ESecureLease: OutputTargetLease { + let id = UUID() + let processIdentifier = ProcessInfo.processInfo.processIdentifier + let context = InputContext(outputMode: .command, inputLanguage: .english, source: .menuBar) + let isValid = true + var isCurrent: Bool { XCTFail("Secure input must refuse before lease validation"); return true } +} + +@MainActor +private final class NativeE2ETransactionTarget: DeliveryTextTarget { + var currentChecks = 0 + var isCurrent: Bool { currentChecks += 1; return true } + let supportsSelectionWrite = true + let isWebContentEditor = false + var preparations = 0 + var restorations = 0 + var onPrepare: () -> Void = {} + var writes: [String] = [] + func prepareSelection() -> Bool { preparations += 1; onPrepare(); return true } + func writeSelection(_ text: String) -> Bool { writes.append(text); return true } + func confirm(_ text: String) async -> Bool { true } + func restoreSelection() { restorations += 1 } +} diff --git a/Tests/OpenTypeTests/VoiceEditingNativeE2ESecureSessionTests.swift b/Tests/OpenTypeTests/VoiceEditingNativeE2ESecureSessionTests.swift new file mode 100644 index 00000000..3963a5b1 --- /dev/null +++ b/Tests/OpenTypeTests/VoiceEditingNativeE2ESecureSessionTests.swift @@ -0,0 +1,116 @@ +import XCTest +import UtterContracts +import UtterData +import UtterMediaContracts +import UtterModels +import UtterProcessing +import UtterRuntime +@testable import UtterMacServices +@testable import UtterSession + +@MainActor +final class VoiceEditingNativeE2ESecureSessionTests: XCTestCase { + func testRefusedLocalTargetCannotBecomeUnselectedCommandDictationOrOutput() async throws { + for mode in [TextProcessingMode.command, .formatting, .direct] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.targetAvailable = false + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("Synthetic dictation"), mode: mode) + do { + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + XCTFail("Refused local capture was admitted") + } catch { XCTAssertEqual(error as? DeliveryError, .invalidTarget) } + XCTAssertTrue(fixture.capture.requests.isEmpty) + XCTAssertTrue(fixture.screen.requests.isEmpty) + XCTAssertTrue(fixture.recipeCreations.isEmpty) + XCTAssertTrue(fixture.recipe.requests.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertTrue(try fixture.runtime.service(DataServices.history).records.isEmpty) + try await fixture.runtime.stop() + } + } + + func testValidUnselectedCommandStillUsesScreenContext() async throws { + for selection in [nil, ""] as [String?] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = selection + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("Synthetic command"), mode: .command) + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(fixture.screen.requests.count, 1) + XCTAssertEqual(fixture.recipe.requests.count, 1) + XCTAssertEqual(fixture.output.requests.count, 1) + XCTAssertEqual(fixture.output.requests.first?.target?.id, fixture.target.id) + try await fixture.runtime.stop() + } + } + + func testExplicitCopyDoesNotRequireLocalTargetAdmission() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.targetAvailable = false + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .local, operation: .copy("Explicit owned copy")) + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + guard case .clipboard(let text) = fixture.output.requests.first?.command else { + return XCTFail("Explicit copy was refused") + } + XCTAssertEqual(text, "Explicit owned copy") + XCTAssertEqual(fixture.output.requests.count, 1) + XCTAssertTrue(fixture.screen.requests.isEmpty) + XCTAssertTrue(fixture.recipe.requests.isEmpty) + XCTAssertTrue(try fixture.runtime.service(DataServices.history).records.isEmpty) + try await fixture.runtime.stop() + } + + func testNativeSecureTargetFailureCannotBecomeScreenCommandAndOutput() async throws { + guard NativeDeliveryKeys.isSecureInput else { + throw XCTSkip("Secure input off; no unowned focused host capture permitted") + } + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.defaults.set(false, forKey: "useScreenContext") + try await fixture.start() + let native = PluginRuntime(catalog: try PluginCatalog([MacPlugins.target()])) + try await native.start([PluginSelection("mac.target")]) + let runtime = fixture.runtime! + let dependencies = VoiceWorkflowDependencies( + settings: try runtime.service(DataServices.settings), credentials: try runtime.service(DataServices.credentials), + configuration: nil, dictionary: try runtime.service(DataServices.dictionary), + lexicons: try runtime.service(DataServices.lexicons), clients: try runtime.service(IntegrationServices.clients), + files: fixture.files, access: try runtime.service(ModelServices.resourceAccess), + speech: try runtime.service(SpeechServices.providers), generation: try runtime.service(GenerationServices.providers), + recipes: try runtime.service(ModeServices.recipes), outputs: try runtime.service(SessionServices.outputs), + preparation: try runtime.service(ProcessingServices.preparation), capture: fixture.capture, + audioFiles: try runtime.service(AudioServices.files), evidence: fixture.evidence, output: fixture.output, + targets: try native.service(MacServices.target), memory: nil, screen: fixture.screen, + sounds: nil, correction: nil, log: NativeE2EDiagnostics()) + let driver = SessionDriver(workflows: VoiceWorkflowFactory(dependencies: dependencies, isReady: { true }), + history: try runtime.service(DataServices.history), notifications: StateNotifications()) + let intent = SessionIntent(input: .text("Rewrite the selected synthetic text politely"), mode: .command) + do { + try driver.start(intent) + _ = try await driver.waitForCompletion(intent.id) + } catch {} + XCTAssertTrue(fixture.screen.requests.isEmpty, "Secure native target refusal became an OCR/screenshot request") + XCTAssertTrue(fixture.recipe.requests.isEmpty, "Secure native target refusal became generation") + XCTAssertTrue(fixture.output.requests.isEmpty, "Secure native target refusal became an output effect") + XCTAssertTrue(try runtime.service(DataServices.history).records.isEmpty) + try NativeE2EUI.record("secure-native-session", ["screenRequests": fixture.screen.requests.count, + "recipeRequests": fixture.recipe.requests.count, "outputRequests": fixture.output.requests.count, + "nativeTarget": "production MacPlugins.target with current secure event input", + "session": "production VoiceWorkflowFactory and SessionDriver", + "screenGenerationOutput": "controlled fixtures; no OCR/network/host write performed"]) + await driver.close() + try await native.stop() + try await runtime.stop() + } +} diff --git a/Tests/OpenTypeTests/VoiceEditingNativeE2EUIHelpers.swift b/Tests/OpenTypeTests/VoiceEditingNativeE2EUIHelpers.swift new file mode 100644 index 00000000..c8fcf217 --- /dev/null +++ b/Tests/OpenTypeTests/VoiceEditingNativeE2EUIHelpers.swift @@ -0,0 +1,109 @@ +import AppKit +import Foundation +import XCTest +import UtterContracts +import UtterPresentationContracts +@testable import UtterPresentation + +@MainActor +enum NativeE2EUI { + static func directory(override: String? = ProcessInfo.processInfo.environment["UTTER_NATIVE_EVIDENCE"]) throws -> URL { + let directory = override.map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? TestFixtureRoot.root.appendingPathComponent("NativeEvidence", isDirectory: true) + for name in ["logs", "screenshots"] { + try FileManager.default.createDirectory( + at: directory.appendingPathComponent(name, isDirectory: true), withIntermediateDirectories: true + ) + } + return directory + } + + static func panel() throws -> NSWindow { + try XCTUnwrap(NSApp.windows.first { $0.isVisible && $0.styleMask.contains(.nonactivatingPanel) }) + } + + static func capture(_ name: String, window: NSWindow) async throws { + try await Task.sleep(for: .milliseconds(250)) + let view = try XCTUnwrap(window.contentView) + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: directory().appendingPathComponent("screenshots/" + name + ".png")) + try record(name, ["windowNumber": window.windowNumber, "frame": NSStringFromRect(window.frame), + "key": window.isKeyWindow, "active": NSApp.isActive, "accessibility": labels(view)]) + } + + static func labels(_ object: Any, depth: Int = 0) -> [[String: String]] { + guard depth < 18, let node = object as? NSAccessibilityProtocol else { return [] } + var result: [[String: String]] = [] + if let role = node.accessibilityRole() { + result.append(["role": role.rawValue, "label": node.accessibilityLabel() ?? "", + "value": String(describing: node.accessibilityValue() ?? "")]) + } + for child in node.accessibilityChildren() ?? [] { result += labels(child, depth: depth + 1) } + return result + } + + static func button(_ object: Any, label: String, depth: Int = 0) -> (any NSAccessibilityProtocol)? { + guard depth < 18, let node = object as? NSAccessibilityProtocol else { return nil } + if node.accessibilityRole() == .button, node.accessibilityLabel() == label { return node } + for child in node.accessibilityChildren() ?? [] { + if let found = button(child, label: label, depth: depth + 1) { return found } + } + return nil + } + + static func pointer(_ label: String, in window: NSWindow) async throws { + try await NativeSelectionReviewTestSupport.activateAccessibility(in: window) + let button = try NativeSelectionReviewTestSupport.button(label, in: window) + NativeSelectionReviewTestSupport.assertVisible(button, in: window) + let frame = button.accessibilityFrame() + let location = window.convertPoint(fromScreen: NSPoint(x: frame.midX, y: frame.midY)) + try record("local-pointer-" + label, ["windowNumber": window.windowNumber, + "x": location.x, "y": location.y, "locator": "current localized control AX frame converted to owned window", + "systemPointerPosted": false]) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try XCTUnwrap(NSEvent.mouseEvent(with: type, location: location, + modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + window.sendEvent(event) + } + } + + static func ax(_ action: String, label: String) async throws -> [String: Any] { + let task = Process() + task.executableURL = try directory().appendingPathComponent("fixtures/owned-ax-client") + task.arguments = [String(ProcessInfo.processInfo.processIdentifier), action, label] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + try task.run() + let deadline = ContinuousClock.now.advanced(by: .seconds(8)) + while task.isRunning, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(20)) } + if task.isRunning { task.terminate(); throw NSError(domain: "OwnedAXClientTimeout", code: 1) } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + try data.write(to: directory().appendingPathComponent("logs/ax-\(action)-\(label).json")) + XCTAssertEqual(task.terminationStatus, 0, String(decoding: data, as: UTF8.self)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + static func localKey(_ code: UInt16, character: String, in window: NSWindow) throws { + let event = try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, characters: character, charactersIgnoringModifiers: character, isARepeat: false, keyCode: code)) + window.sendEvent(event) + } + + static func record(_ name: String, _ value: [String: Any]) throws { + let data = try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: directory().appendingPathComponent("logs/" + name + ".json")) + } +} + +struct NativeE2EDiagnostics: DiagnosticsService { + func info(_ message: String) {} + func sensitive(_ message: String) {} + func error(_ message: String) {} +} diff --git a/Tests/OpenTypeTests/VoiceEditingTests.swift b/Tests/OpenTypeTests/VoiceEditingTests.swift index 43214382..ed3cbe5a 100644 --- a/Tests/OpenTypeTests/VoiceEditingTests.swift +++ b/Tests/OpenTypeTests/VoiceEditingTests.swift @@ -26,16 +26,19 @@ final class VoiceEditingTests: XCTestCase { fixture.recipe.resolution = .command(.rewriteSelection(.formal)) try await fixture.start() let driver = try fixture.runtime.service(SessionServices.execution) - try driver.start(SessionIntent(input: .text("Make the selection formal"), mode: .command)) - let result = try await driver.waitForCompletion(try XCTUnwrap(driver.snapshot.id)) + let intent = SessionIntent(input: .text("Make the selection formal"), mode: .command) + try driver.start(intent) + let result = try await driver.waitForCompletion(intent.id) XCTAssertEqual(fixture.recipe.requests.first?.text, fixture.target.selectedText) - XCTAssertEqual(fixture.recipe.requests.first?.mode, .selectionEdit(.formal, spokenCommand: "Make the selection formal")) - XCTAssertLessThan(fixture.recipe.resolutionContexts.first?.selectedTextPreview?.count ?? 0, 330) + XCTAssertEqual(fixture.recipe.requests.first?.mode, .selectionEdit(.custom("Make the selection formal"), spokenCommand: "Make the selection formal")) + XCTAssertTrue(fixture.recipe.resolutionContexts.isEmpty) + XCTAssertTrue(fixture.screen.requests.isEmpty) guard case .replaceSelection(let text) = fixture.output.requests.first?.command else { return XCTFail("Selection was inserted instead of replaced") } - XCTAssertEqual(text, result.text) + XCTAssertEqual(text, String(repeating: "Entire selected sentence. ", count: 100) + " Formatted.") + XCTAssertEqual(result.text, "") XCTAssertTrue(fixture.speechRequests.isEmpty) XCTAssertTrue(fixture.capture.requests.isEmpty) - XCTAssertEqual(try fixture.runtime.service(DataServices.history).records.count, 1) + XCTAssertTrue(try fixture.runtime.service(DataServices.history).records.isEmpty) try await fixture.runtime.stop() } diff --git a/Tests/OpenTypeTests/VoiceReplacementTests.swift b/Tests/OpenTypeTests/VoiceReplacementTests.swift index 1970c879..b4712c5d 100644 --- a/Tests/OpenTypeTests/VoiceReplacementTests.swift +++ b/Tests/OpenTypeTests/VoiceReplacementTests.swift @@ -19,6 +19,25 @@ import UtterSession @MainActor final class VoiceReplacementTests: XCTestCase { + func testRefusedLocalTargetCannotCopyPendingReplacement() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + try await fixture.start() + let (pending, original) = try installPending(fixture) + fixture.targetAvailable = false + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .local, operation: .applyReplacement(pending.id)) + do { + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + XCTFail("Refused capture became a pending replacement copy") + } catch { XCTAssertEqual(error as? DeliveryError, .invalidTarget) } + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertEqual(try fixture.runtime.service(DataServices.history).records.first?.processedText, original.processedText) + XCTAssertEqual(try fixture.runtime.service(SessionServices.outputs).snapshot.pending?.state, .ready) + try await fixture.runtime.stop() + } + func testAcceptedReplacementAfterCancellationUpdatesOnlyOriginalHistoryRecord() async throws { let fixture = try VoiceWorkflowFixture() defer { fixture.remove() } diff --git a/Tests/OpenTypeTests/VoiceSelectedEditingBoundaryTests.swift b/Tests/OpenTypeTests/VoiceSelectedEditingBoundaryTests.swift new file mode 100644 index 00000000..51888c76 --- /dev/null +++ b/Tests/OpenTypeTests/VoiceSelectedEditingBoundaryTests.swift @@ -0,0 +1,124 @@ +import Foundation +import XCTest +import UtterContracts +import UtterMediaContracts +import UtterSession + +@MainActor +final class VoiceSelectedEditingBoundaryTests: XCTestCase { + func testNaturalSelectedInstructionAutomaticallyWritesOnceAfterOneRecordingWithoutScreenOrAction() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.defaults.set(true, forKey: "useScreenContext") + fixture.target.selectedText = "请同步进展。 e\u{301} 👩🏽‍💻" + fixture.engine.transcript = "改礼貌一点" + fixture.recipe.resolution = .command(.deleteSelection) + fixture.recipe.generatedText = "请您方便时同步进展。" + fixture.screen.snapshot = ScreenContextSnapshot(text: "", image: nil, status: .permissionDenied) + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + var sawProposal = false + let observer = execution.observe { sawProposal = sawProposal || $0.selectionEditProposal != nil } + defer { execution.removeObserver(observer) } + let intent = SessionIntent(input: .local, mode: .command) + try execution.start(intent) + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + while execution.snapshot.phase != .recording, execution.snapshot.isBusy, ContinuousClock.now < deadline { await Task.yield() } + XCTAssertTrue(execution.snapshot.isSelectionEditing) + XCTAssertTrue(execution.requestStop(intent.id, at: nil)) + let result = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(result.deliveryStatus, .inserted) + XCTAssertFalse(sawProposal) + XCTAssertEqual(fixture.capture.requests.count, 1) + XCTAssertEqual(fixture.speechRequests.count, 1) + XCTAssertTrue(fixture.capture.recording.closed) + XCTAssertEqual(fixture.recipeCreations, ["mode.edit"]) + XCTAssertEqual(fixture.recipe.requests.first?.mode, .selectionEdit(.custom("改礼貌一点"), spokenCommand: "改礼貌一点")) + XCTAssertTrue(fixture.recipe.resolutionContexts.isEmpty) + XCTAssertTrue(fixture.screen.requests.isEmpty) + guard case .replaceSelection("请您方便时同步进展。") = fixture.output.requests.first?.command else { + return XCTFail("Selected instruction did not use automatic replacement") + } + XCTAssertEqual(fixture.output.requests.count, 1) + XCTAssertFalse(execution.applySelectionEdit(sessionID: intent.id, proposalID: UUID())) + XCTAssertEqual(fixture.output.requests.count, 1) + try await fixture.runtime.stop() + } + + func testAutomaticSelectedCommandKeepsFullInstructionAndExplicitSelectionEditNeverCapturesScreen() async throws { + let instruction = "改礼貌一点," + String(repeating: "保留原来的细节。", count: 120) + "结尾保留 🇸🇬。" + for mode in [TextProcessingMode.command, .selectionEdit(.custom(instruction), spokenCommand: instruction)] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.defaults.set(true, forKey: "useScreenContext") + let original = "完整原文 👩🏽‍💻\n" + String(repeating: "保留细节。", count: 200) + fixture.target.selectedText = original + fixture.recipe.generatedText = "完整候选 🇸🇬" + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text(instruction), mode: mode) + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(fixture.recipe.requests.first?.text, original) + XCTAssertEqual(fixture.recipe.requests.first?.mode, .selectionEdit(.custom(instruction), spokenCommand: instruction)) + XCTAssertEqual(fixture.recipeCreations, ["mode.edit"]) + XCTAssertEqual(fixture.recipe.requests.first?.screenContext, "") + XCTAssertNil(fixture.recipe.requests.first?.screenImage) + XCTAssertTrue(fixture.screen.requests.isEmpty) + XCTAssertNil(execution.snapshot.selectionEditProposal) + guard case .replaceSelection("完整候选 🇸🇬") = fixture.output.requests.first?.command else { return XCTFail("Missing automatic replacement") } + XCTAssertEqual(fixture.output.requests.count, 1) + try await fixture.runtime.stop() + } + } + + func testEmptyDeleteActionAndFailedGenerationNeverOfferApplyOrWriteSelection() async throws { + let outputs: [String?] = [" \n ", "{\"final_text\":\"\"}", "{\"action\":\"delete_selection\",\"confidence\":1}", nil] + for output in outputs { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "原文 👩🏽‍💻" + fixture.recipe.resolution = .command(.deleteSelection) + fixture.recipe.generatedText = output + if output == nil { fixture.recipe.failure = GenerationServiceError.modelUnavailable } + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("删除选区"), mode: .command) + try execution.start(intent) + do { _ = try await execution.waitForCompletion(intent.id); XCTFail("Invalid selected result was accepted") } + catch {} + XCTAssertEqual(execution.snapshot.phase, .failed) + XCTAssertNil(execution.snapshot.selectionEditProposal) + XCTAssertFalse(execution.applySelectionEdit(sessionID: intent.id, proposalID: UUID())) + XCTAssertEqual(fixture.target.selectedText, "原文 👩🏽‍💻") + XCTAssertEqual(fixture.recipeCreations, ["mode.edit"]) + XCTAssertTrue(fixture.recipe.resolutionContexts.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertTrue(fixture.screen.requests.isEmpty) + try await fixture.runtime.stop() + } + } + + func testUnselectedLegacyReplyAndOrdinaryDictationKeepTheirExistingRoutes() async throws { + for mode in [TextProcessingMode.command, .direct, .formatting, .translation(.english)] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.defaults.set(false, forKey: "useScreenContext") + fixture.defaults.set(false, forKey: "enableInstantInsert") + if mode != .command { fixture.target.selectedText = "Existing selection" } + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("帮我回复这封邮件"), mode: mode) + try execution.start(intent) + _ = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(fixture.recipe.requests.first?.mode, mode) + XCTAssertNil(execution.snapshot.selectionEditProposal) + XCTAssertEqual(fixture.screen.requests.count, mode == .command ? 1 : 0) + guard case .insert = fixture.output.requests.first?.command else { return XCTFail("Ordinary mode changed output route") } + XCTAssertEqual(fixture.output.requests.count, 1) + try await fixture.runtime.stop() + } + } + + +} diff --git a/Tests/OpenTypeTests/VoiceSelectedRetentionTests.swift b/Tests/OpenTypeTests/VoiceSelectedRetentionTests.swift new file mode 100644 index 00000000..0f1d5a6c --- /dev/null +++ b/Tests/OpenTypeTests/VoiceSelectedRetentionTests.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest +import UtterContracts +import UtterData +import UtterSession + +@MainActor +final class VoiceSelectedRetentionTests: XCTestCase { + func testAutomaticSelectedEditAndRestoreKeepFullVolatileReceiptWithoutHistoryOrLearning() async throws { + let original = " 原文 e\u{301} 👩🏽‍💻\n" + String(repeating: "完整内容 🇸🇬。", count: 120) + "\n " + for mode in [TextProcessingMode.command, .selectionEdit(.custom("改礼貌一点"), spokenCommand: "改礼貌一点")] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = original + fixture.recipe.generatedText = "请您方便时同步进展。" + let anchor = RetentionAnchor(target: fixture.target, text: "请您方便时同步进展。") + fixture.output.delivery.anchor = anchor + try await fixture.start() + let history = try fixture.runtime.service(DataServices.history) + let ordinary = InputRecord(rawText: "ordinary", processedText: "ordinary", wasProcessed: false) + history.addRecord(ordinary) + let historyURL = fixture.directory.appendingPathComponent("input_history.json") + let persistedBefore = try Data(contentsOf: historyURL) + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("改礼貌一点"), mode: mode) + try execution.start(intent) + XCTAssertLessThan(fixture.target.context.selectedText?.count ?? 0, original.count) + let applied = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(applied.deliveryStatus, .inserted) + XCTAssertEqual(applied.text, "") + XCTAssertEqual(applied.transcript, "") + XCTAssertEqual(history.records.map(\.id), [ordinary.id]) + XCTAssertEqual(try Data(contentsOf: historyURL), persistedBefore) + XCTAssertEqual(anchor.correctionReads, 0) + XCTAssertTrue(fixture.correction.started.isEmpty) + XCTAssertFalse(fixture.recipe.requests.first?.collectsDiagnostics ?? true) + let outputs = try fixture.runtime.service(SessionServices.outputs) + XCTAssertEqual(outputs.snapshot.selectionEditRestoration?.originalSelection, original) + let restore = SessionIntent(input: .local, operation: .restoreSelectionEdit(operationID: intent.id)) + try execution.start(restore) + let restored = try await execution.waitForCompletion(restore.id) + XCTAssertEqual(restored.deliveryStatus, .inserted) + XCTAssertEqual(restored.text, "") + guard case .replaceAnchor(let written, _) = fixture.output.requests.last?.command else { return XCTFail("Restore did not replace its receipt") } + XCTAssertEqual(written, original) + XCTAssertNil(outputs.recent) + XCTAssertEqual(outputs.snapshot, SessionOutputSnapshot()) + XCTAssertEqual(try Data(contentsOf: historyURL), persistedBefore) + XCTAssertTrue(fixture.correction.started.isEmpty) + XCTAssertEqual(anchor.correctionReads, 0) + let cachedAfterRestore = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(cachedAfterRestore.text, "") + + fixture.target.selectedText = nil + fixture.recipe.generatedText = nil + let ordinaryAnchor = RetentionAnchor(target: fixture.target, text: "normal dictation") + fixture.output.delivery.anchor = ordinaryAnchor + let normal = SessionIntent(input: .text("normal dictation"), mode: .direct) + try execution.start(normal) + let completed = try await execution.waitForCompletion(normal.id) + XCTAssertEqual(completed.text, "normal dictation") + XCTAssertEqual(history.records.count, 2) + XCTAssertEqual(history.records.first?.processedText, "normal dictation") + XCTAssertEqual(fixture.correction.started, [normal.id]) + XCTAssertEqual(ordinaryAnchor.correctionReads, 1) + let cachedAfterOrdinary = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(cachedAfterOrdinary.text, "") + try await fixture.runtime.stop() + } + } + + func testCopiedSelectedCandidateHasNoPersistentHistoryOrGenericRecentText() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "private original" + fixture.recipe.generatedText = "private candidate" + fixture.output.delivery.effect = .clipboard + fixture.output.delivery.confirmation = .clipboardValue + try await fixture.start() + let execution = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("rewrite"), mode: .command) + try execution.start(intent) + let completion = try await execution.waitForCompletion(intent.id) + XCTAssertEqual(completion.deliveryStatus, .copied) + XCTAssertEqual(completion.text, "") + XCTAssertTrue(try fixture.runtime.service(DataServices.history).records.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: fixture.directory.appendingPathComponent("input_history.json").path)) + XCTAssertEqual(try fixture.runtime.service(SessionServices.outputs).snapshot, SessionOutputSnapshot()) + XCTAssertTrue(fixture.correction.started.isEmpty) + try await fixture.runtime.stop() + } + + +} + +@MainActor +private final class RetentionAnchor: OutputAnchor { + let target: any OutputTargetLease + let text: String + var range: NSRange { NSRange(location: 0, length: text.utf16.count) } + let expiresAt = Date().addingTimeInterval(15) + let isCurrent = true + var correctionReads = 0 + init(target: any OutputTargetLease, text: String) { self.target = target; self.text = text } + func correctionSeed(context: InputContext) -> CorrectionCaptureSeed? { + correctionReads += 1 + return CorrectionCaptureSeed(observation: RetentionObservation(), insertedText: text, context: context) + } +} + +@MainActor +private final class RetentionObservation: CorrectionObservationSource { + let isEligible = true + let isFocused = true + func readEditedText() -> String? { nil } + func observe(_ callback: @escaping () -> Void) -> UUID? { nil } + func removeObserver(_ id: UUID) {} +} diff --git a/Tests/OpenTypeTests/VoiceTextInputTests.swift b/Tests/OpenTypeTests/VoiceTextInputTests.swift index 208701a0..fe0dddcf 100644 --- a/Tests/OpenTypeTests/VoiceTextInputTests.swift +++ b/Tests/OpenTypeTests/VoiceTextInputTests.swift @@ -19,7 +19,7 @@ import UtterSession @MainActor final class VoiceTextInputTests: XCTestCase { - func testMissingTargetKeepsAutomaticOutputUnderTheProviderClipboardPolicy() async throws { + func testAuthorizedClientWithoutDesktopTargetReturnsTextWithoutDesktopEffects() async throws { for instant in [false, true] { let fixture = try VoiceWorkflowFixture() defer { fixture.remove() } @@ -27,15 +27,17 @@ final class VoiceTextInputTests: XCTestCase { fixture.defaults.set(false, forKey: "allowClipboardPaste") fixture.defaults.set(instant, forKey: "enableInstantInsert") try await fixture.start() + let client = IntegrationClient.localCLI(executablePath: "/fixture/cli") + try fixture.runtime.service(IntegrationServices.clients).approve(client) let driver = try fixture.runtime.service(SessionServices.execution) - let intent = SessionIntent(input: .text("Synthetic dictation"), mode: .formatting) + let intent = SessionIntent(clientID: client.id, input: .text("Synthetic dictation"), mode: .formatting) try driver.start(intent) - _ = try await driver.waitForCompletion(intent.id) - let request = try XCTUnwrap(fixture.output.requests.first) - guard case .insert = request.command else { return XCTFail("Automatic output bypassed provider policy with a copy command") } - XCTAssertNil(request.target) - XCTAssertFalse(request.allowsClipboardPaste) - XCTAssertEqual(fixture.output.requests.count, 1) + let result = try await driver.waitForCompletion(intent.id) + XCTAssertEqual(result.phase, .completed) + XCTAssertEqual(result.text, "Synthetic dictation Formatted.") + XCTAssertEqual(fixture.recipe.requests.count, 1) + XCTAssertTrue(fixture.screen.requests.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) try await fixture.runtime.stop() } } @@ -91,6 +93,86 @@ final class VoiceTextInputTests: XCTestCase { XCTAssertEqual(fixture.recipe.requests.first?.mode, mode) guard case .replaceSelection = fixture.output.requests.first?.command else { return XCTFail("Selection edit did not replace selection") } XCTAssertTrue(fixture.speechRequests.isEmpty) + XCTAssertEqual(fixture.output.requests.count, 1) try await fixture.runtime.stop() } + + func testAutomaticSelectionCancellationAndStaleSelectionNeverPrepareHostOrClipboardWrites() async throws { + for change in 0..<3 { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + let generation = HeldSelectionGeneration() + fixture.recipe.selectionProcessor = generation.process + fixture.target.selectedText = "原文 e\u{301} 👩🏽‍💻\n第二行" + try await fixture.start() + let driver = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("Make concise"), mode: .selectionEdit(.concise, spokenCommand: "Make concise")) + try driver.start(intent) + try await generation.waitUntilEntered() + XCTAssertTrue(driver.snapshot.isSelectionEditing) + XCTAssertNil(driver.snapshot.selectionEditProposal) + XCTAssertTrue(fixture.output.requests.isEmpty) + if change == 0 { driver.cancel(intent.id) } + else if change == 1 { fixture.target.selectedText = "用户后续输入 🇸🇬" } + else { fixture.target.isCurrent = false } + generation.release() + do { _ = try await driver.waitForCompletion(intent.id); XCTFail("Cancelled or stale edit delivered") } + catch { + if change == 0 { XCTAssertTrue(error is CancellationError) } + else { XCTAssertEqual(error as? DeliveryError, .invalidTarget) } + } + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertNil(driver.snapshot.selectionEditProposal) + XCTAssertEqual(driver.snapshot.transcript, "") + XCTAssertTrue(fixture.capture.requests.isEmpty) + try await fixture.runtime.stop() + } + } + + func testFailedAndEmptySelectionGenerationHasNoReviewAndLeavesTheOriginalIntact() async throws { + for fails in [false, true] { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.target.selectedText = "original 👩🏽‍💻" + if fails { fixture.recipe.failure = GenerationServiceError.modelUnavailable } + else { fixture.recipe.generatedText = " \n " } + try await fixture.start() + let driver = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .text("rewrite"), mode: .selectionEdit(.concise, spokenCommand: "rewrite")) + try driver.start(intent) + do { _ = try await driver.waitForCompletion(intent.id); XCTFail("Failed model was accepted") } + catch {} + XCTAssertEqual(driver.snapshot.phase, .failed) + XCTAssertNil(driver.snapshot.selectionEditProposal) + XCTAssertEqual(fixture.target.selectedText, "original 👩🏽‍💻") + XCTAssertTrue(fixture.output.requests.isEmpty) + try await fixture.runtime.stop() + } + } + + func testRemoteSelectionPreflightDoesNotStartCaptureOrProcessingAndRechecksTheProvider() async throws { + let fixture = try VoiceWorkflowFixture() + defer { fixture.remove() } + fixture.defaults.set(true, forKey: "useRemoteLLM") + fixture.defaults.set("https://model.example.test/v1", forKey: "remoteBaseURL") + fixture.target.selectedText = "private selection 👩🏽‍💻" + try await fixture.start() + let driver = try fixture.runtime.service(SessionServices.execution) + let intent = SessionIntent(input: .local, mode: .selectionEdit(.concise, spokenCommand: "rewrite")) + try driver.start(intent) + XCTAssertEqual(driver.snapshot.remoteDisclosure?.providerHost, "model.example.test") + XCTAssertEqual(driver.snapshot.remoteDisclosure?.selectedText, "private selection 👩🏽‍💻") + XCTAssertTrue(fixture.recipe.requests.isEmpty) + XCTAssertTrue(fixture.capture.requests.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) + try fixture.runtime.service(DataServices.settings).update { $0.remoteBaseURL = "https://changed.example.test/v1" } + XCTAssertThrowsError(try driver.authorizeRemoteDisclosure(intent.id)) + XCTAssertTrue(fixture.recipe.requests.isEmpty) + XCTAssertTrue(fixture.capture.requests.isEmpty) + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertFalse(driver.snapshot.isBusy) + try await fixture.runtime.stop() + } + + } diff --git a/Tests/OpenTypeTests/VoiceWorkflowFixture.swift b/Tests/OpenTypeTests/VoiceWorkflowFixture.swift index 329b5c2a..3b62eb6c 100644 --- a/Tests/OpenTypeTests/VoiceWorkflowFixture.swift +++ b/Tests/OpenTypeTests/VoiceWorkflowFixture.swift @@ -21,17 +21,19 @@ import UtterRuntime @MainActor final class VoiceWorkflowFixture { let suite = "VoiceWorkflow-" + UUID().uuidString - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = TestFixtureRoot.root.appendingPathComponent(UUID().uuidString, isDirectory: true) let defaults: UserDefaults let engine = WorkflowSpeech() let capture = WorkflowCapture() let output = WorkflowOutput() let files = WorkflowFiles() let recipe = WorkflowRecipe() + var recipeCreations: [String] = [] let target = WorkflowTarget() var targetAvailable = true let screen = WorkflowScreen() let evidence = WorkflowEvidence() + let correction = WorkflowCorrection() var speechRequests: [SpeechProviderRequest] = [] private(set) var runtime: PluginRuntime! private var plugins: [PluginRegistration] = [] @@ -46,7 +48,8 @@ final class VoiceWorkflowFixture { let capabilities = PluginRegistration(descriptor: PluginDescriptor(id: "fixture.voice-capabilities", provides: [ ModelServices.files.reference, SpeechServices.providers.reference, GenerationServices.providers.reference, ModeServices.recipes.reference, AudioServices.capture.reference, AudioServices.files.reference, - AudioServices.evidence.reference, MacServices.output.reference, MacServices.target.reference, MacServices.screen.reference + AudioServices.evidence.reference, MacServices.output.reference, MacServices.target.reference, MacServices.screen.reference, + MacServices.correction.reference ])) { [self] context, _ in let speech = ProviderRegistry() try speech.register(ProviderDefinition(descriptor: ProviderDescriptor(id: "speech.fixture", legacyIDs: ["apple"], displayName: "Fixture")) { [self] request in @@ -55,9 +58,13 @@ final class VoiceWorkflowFixture { }, scope: context.scope) let generation = ProviderRegistry() try generation.register(ProviderDefinition(descriptor: ProviderDescriptor(id: "generation.mlx", displayName: "Fixture")) { _ in WorkflowGeneration() }, scope: context.scope) + try generation.register(ProviderDefinition(descriptor: ProviderDescriptor(id: "generation.remote", displayName: "Remote fixture", modelLocation: .remote)) { _ in WorkflowGeneration() }, scope: context.scope) let modes = ProviderRegistry() for id in ["mode.direct", "mode.formatting", "mode.command", "mode.translation", "mode.edit"] { - try modes.register(ProviderDefinition(descriptor: ProviderDescriptor(id: id, displayName: id)) { [recipe] _ in recipe }, scope: context.scope) + try modes.register(ProviderDefinition(descriptor: ProviderDescriptor(id: id, displayName: id)) { [self] _ in + recipeCreations.append(id) + return recipe + }, scope: context.scope) } try context.provide(ModelServices.files, value: files) try context.provide(SpeechServices.providers, value: speech) @@ -69,6 +76,7 @@ final class VoiceWorkflowFixture { try context.provide(MacServices.output, value: output) try context.provide(MacServices.target, value: WorkflowTargets(target: target, isAvailable: { [self] in targetAvailable })) try context.provide(MacServices.screen, value: screen) + try context.provide(MacServices.correction, value: correction) } plugins = [DataPlugins.settings(defaults: defaults), DataPlugins.credentials(), DataPlugins.notifications(), DataPlugins.integrationClients(defaults: defaults), DataPlugins.dictionary(directoryURL: directory), @@ -167,6 +175,11 @@ final class WorkflowOutput: OutputService { func prepare(_ request: DeliveryRequest, isSessionCurrent: @escaping () -> Bool) throws -> any PreparedDelivery { requests.append(request) guard isSessionCurrent() else { throw CancellationError() } + delivery.operationID = request.id + if case .replaceSelection(let text) = request.command, let anchor = delivery.anchor, + let original = request.target?.selectedText { + delivery.selectionEdit = SelectionEditReceipt(operationID: request.id, originalSelection: original, replacement: text, anchor: anchor) + } else { delivery.selectionEdit = nil } return delivery } } @@ -175,6 +188,10 @@ final class WorkflowOutput: OutputService { final class WorkflowDelivery: PreparedDelivery { var anchor: (any OutputAnchor)? var receipt: DeliveryReceipt? + var operationID = UUID() + var selectionEdit: SelectionEditReceipt? + var effect = DeliveryEffect.paste + var confirmation = DeliveryConfirmation.targetValue var committing = false var closing = false var heldCommit = false @@ -184,7 +201,8 @@ final class WorkflowDelivery: PreparedDelivery { func commit() async -> DeliveryReceipt { committing = true if heldCommit { await withCheckedContinuation { commitWaiter = $0 } } - let value = DeliveryReceipt(operationID: UUID(), disposition: .accepted, effect: .paste, anchor: anchor, confirmation: .targetValue) + let value = DeliveryReceipt(operationID: operationID, disposition: .accepted, effect: effect, anchor: anchor, + confirmation: confirmation, selectionEdit: selectionEdit) receipt = value return value } @@ -196,40 +214,6 @@ final class WorkflowDelivery: PreparedDelivery { func releaseClose() { closeWaiter?.resume(); closeWaiter = nil } } -@MainActor -final class WorkflowRecipe: ModeRecipeService { - var generationOutcome: EspressoGenerationOutcome? - var requests: [ProcessingRequest] = [] - var resolution: SpokenEditCommandLLMResolution? - var resolutionContexts: [SpokenEditCommandResolutionContext] = [] - var holdFormatting = false - var confirmsTranslation = true - var translationProofTarget: TranslationLanguage? - var formatting = false - private var formattingWaiter: CheckedContinuation? - func resolveEditCommand(_ request: ProcessingRequest, context: SpokenEditCommandResolutionContext) async throws -> SpokenEditCommandLLMResolution? { - resolutionContexts.append(context) - return resolution - } - func process(_ request: ProcessingRequest) async throws -> ProcessingResult { - requests.append(request) - if request.mode == .direct { return ProcessingResult(text: request.text) } - if request.mode == .formatting { - formatting = true - if holdFormatting { await withCheckedContinuation { formattingWaiter = $0 } } - } - if case .translation(let target) = request.mode, confirmsTranslation { - let language = translationProofTarget ?? target - let assessment = TranslationAssessment.assess(target: language, - hasLinguisticContent: true, scores: [language.rawValue: 1]) - return ProcessingResult(text: request.text + " Formatted.", - decision: ProcessingDecision(.accepted, translation: assessment)) - } - return ProcessingResult(text: request.text + " Formatted.", generationOutcome: generationOutcome) - } - func releaseFormatting() { formattingWaiter?.resume(); formattingWaiter = nil } -} - final class WorkflowGeneration: TextGenerationService, @unchecked Sendable { func generate(_ request: TextGenerationRequest) async throws -> String { "Unused" } } @@ -264,15 +248,6 @@ private final class WorkflowTargets: TargetCaptureService { return target } } -@MainActor -final class WorkflowTarget: OutputTargetLease { - let id = UUID() - let processIdentifier: Int32 = 123 - let context = InputContext(appName: "Editor", bundleIdentifier: "fixture.editor", outputMode: .processed, inputLanguage: .english, source: .menuBar) - var selectedText: String? - var isValid = true - var isCurrent = true -} private struct WorkflowLog: DiagnosticsService { func info(_ message: String) {} func sensitive(_ message: String) {} diff --git a/Tests/OpenTypeTests/VoiceWorkflowTargetFixture.swift b/Tests/OpenTypeTests/VoiceWorkflowTargetFixture.swift new file mode 100644 index 00000000..b590aa38 --- /dev/null +++ b/Tests/OpenTypeTests/VoiceWorkflowTargetFixture.swift @@ -0,0 +1,72 @@ +import Foundation +import UtterContracts +import UtterMediaContracts +import UtterProcessing + +@MainActor +final class WorkflowTarget: OutputTargetLease { + let id = UUID() + let processIdentifier: Int32 = 123 + var context: InputContext { + InputContext(appName: "Editor", bundleIdentifier: "fixture.editor", selectedText: selectedText.map { String($0.prefix(500)) }, + outputMode: .processed, inputLanguage: .english, source: .menuBar) + } + var selectedText: String? + var isValid = true + var isCurrent = true +} + +@MainActor +final class WorkflowCorrection: CorrectionControlService { + var started: [UUID] = [] + func start(seed: CorrectionCaptureSeed, recordID: UUID) { started.append(recordID) } + func finishCurrentSession() {} + func cancelCurrentSession() {} +} + +@MainActor +final class WorkflowRecipe: ModeRecipeService { + var selectionProcessor: ((ProcessingRequest) async throws -> ProcessingResult)? + var failure: Error? + var generatedText: String? + var generationOutcome: EspressoGenerationOutcome? + var requests: [ProcessingRequest] = [] + var resolution: SpokenEditCommandLLMResolution? + var resolutionContexts: [SpokenEditCommandResolutionContext] = [] + var holdFormatting = false + var confirmsTranslation = true + var translationProofTarget: TranslationLanguage? + var formatting = false + private var formattingWaiter: CheckedContinuation? + func resolveEditCommand(_ request: ProcessingRequest, context: SpokenEditCommandResolutionContext) async throws -> SpokenEditCommandLLMResolution? { + resolutionContexts.append(context) + return resolution + } + func process(_ request: ProcessingRequest) async throws -> ProcessingResult { + requests.append(request) + if let failure { throw failure } + if case .selectionEdit(let intent, let command) = request.mode { + if let selectionProcessor { return try await selectionProcessor(request) } + let candidate = try await SelectionEditRecipe.candidate(selectedText: request.text, intent: intent, + inputLanguage: request.options.inputLanguage, spokenCommand: command, + memoryContext: request.memoryContext, inputContext: request.inputContext, + dictionarySnapshot: request.dictionary) { [self] _, _, _ in generatedText ?? request.text + " Formatted." } + return ProcessingResult(text: candidate, generationOutcome: generationOutcome) + } + if let generatedText { return ProcessingResult(text: generatedText) } + if request.mode == .direct { return ProcessingResult(text: request.text) } + if request.mode == .formatting { + formatting = true + if holdFormatting { await withCheckedContinuation { formattingWaiter = $0 } } + } + if case .translation(let target) = request.mode, confirmsTranslation { + let language = translationProofTarget ?? target + let assessment = TranslationAssessment.assess(target: language, + hasLinguisticContent: true, scores: [language.rawValue: 1]) + return ProcessingResult(text: request.text + " Formatted.", + decision: ProcessingDecision(.accepted, translation: assessment)) + } + return ProcessingResult(text: request.text + " Formatted.", generationOutcome: generationOutcome) + } + func releaseFormatting() { formattingWaiter?.resume(); formattingWaiter = nil } +} diff --git a/Tests/UtterContractsTests/KeyboardDeliveryReceiptTests.swift b/Tests/UtterContractsTests/KeyboardDeliveryReceiptTests.swift new file mode 100644 index 00000000..a1ac9ab1 --- /dev/null +++ b/Tests/UtterContractsTests/KeyboardDeliveryReceiptTests.swift @@ -0,0 +1,48 @@ +import XCTest +import UtterContracts + +final class KeyboardDeliveryReceiptTests: XCTestCase { + func testConfirmedKeyboardInsertionNeedsNoDesktopAnchor() { + let operationID = UUID() + let receipt = DeliveryReceipt(operationID: operationID, disposition: .accepted, + effect: .keyboardInsertion, confirmation: .targetValue) + XCTAssertEqual(receipt.operationID, operationID) + XCTAssertEqual(receipt.disposition, .accepted) + XCTAssertEqual(receipt.effect, .keyboardInsertion) + XCTAssertEqual(receipt.effects, [.keyboardInsertion]) + XCTAssertEqual(receipt.confirmation, .targetValue) + XCTAssertEqual(receipt.status, .inserted) + XCTAssertTrue(receipt.isConfirmedInsertion) + XCTAssertNil(receipt.anchor) + XCTAssertNil(receipt.selectionEdit) + } + + func testKeyboardEffectWithoutConfirmedReadbackRemainsUncertain() { + for disposition in [DeliveryDisposition.accepted, .uncertain, .notCommitted] { + let receipt = DeliveryReceipt(operationID: UUID(), disposition: disposition, + effect: .keyboardInsertion) + XCTAssertEqual(receipt.disposition, .uncertain) + XCTAssertEqual(receipt.status, .uncertain) + XCTAssertEqual(receipt.confirmation, .none) + XCTAssertFalse(receipt.isConfirmedInsertion) + } + let partial = DeliveryReceipt(operationID: UUID(), disposition: .uncertain, + effect: .keyboardInsertion, confirmation: .targetValue) + XCTAssertEqual(partial.status, .uncertain) + XCTAssertEqual(partial.confirmation, .none) + let wrong = DeliveryReceipt(operationID: UUID(), disposition: .accepted, + effect: .keyboardInsertion, confirmation: .clipboardValue) + XCTAssertEqual(wrong.status, .uncertain) + } + + func testNoEffectCannotBecomeConfirmedAndKeyboardPrecedesClipboard() { + let absent = DeliveryReceipt(operationID: UUID(), disposition: .accepted, + effect: .none, confirmation: .targetValue) + XCTAssertEqual(absent.disposition, .notCommitted) + XCTAssertEqual(absent.status, .notDelivered) + let combined = DeliveryReceipt(operationID: UUID(), disposition: .accepted, + effect: .clipboard, effects: [.clipboard, .keyboardInsertion], confirmation: .targetValue) + XCTAssertEqual(combined.effect, .keyboardInsertion) + XCTAssertEqual(combined.status, .inserted) + } +} diff --git a/Tests/UtterIngressTests/HotkeySessionBindingTests.swift b/Tests/UtterIngressTests/HotkeySessionBindingTests.swift index 524c6f5b..9a37cb5a 100644 --- a/Tests/UtterIngressTests/HotkeySessionBindingTests.swift +++ b/Tests/UtterIngressTests/HotkeySessionBindingTests.swift @@ -7,6 +7,75 @@ import UtterSession @MainActor final class HotkeySessionBindingTests: XCTestCase { + func testReleaseBeforeDisclosureConsentRetiresTheSameIntentWithoutStartingCapture() async throws { + let fixture = try BindingFixture() + defer { fixture.remove() } + fixture.factory.requiredConsent = consent() + fixture.binding.start() + let id = UUID() + fixture.hotkeys.captureID = id + fixture.hotkeys.start?(.dictation) + XCTAssertEqual(fixture.execution.snapshot.id, id) + XCTAssertEqual(fixture.execution.snapshot.remoteDisclosure?.selectedText, "private selection 👩🏽‍💻") + XCTAssertTrue(fixture.execution.snapshot.isBusy) + XCTAssertEqual(fixture.factory.job.runs, 0) + fixture.hotkeys.stop?(.dictation) + XCTAssertEqual(fixture.execution.snapshot.phase, .cancelled) + XCTAssertNil(fixture.execution.snapshot.remoteDisclosure) + XCTAssertThrowsError(try fixture.execution.authorizeRemoteDisclosure(id)) + XCTAssertEqual(fixture.factory.job.runs, 0) + XCTAssertEqual(fixture.factory.intents.count, 1) + await fixture.binding.close(); await fixture.execution.close() + } + + func testContinueWhileHeldStartsOneSameIntentAndNormalReleaseFinalizesIt() async throws { + let fixture = try BindingFixture() + defer { fixture.remove() } + let approved = consent() + fixture.factory.requiredConsent = approved + fixture.binding.start() + let id = UUID() + fixture.hotkeys.captureID = id + fixture.hotkeys.start?(.dictation) + XCTAssertEqual(fixture.factory.job.runs, 0) + try fixture.execution.authorizeRemoteDisclosure(id) + XCTAssertThrowsError(try fixture.execution.authorizeRemoteDisclosure(id)) + try await fixture.execution.waitForRecording(id) + XCTAssertEqual(fixture.factory.job.runs, 1) + XCTAssertEqual(fixture.factory.intents.map(\.id), [id, id]) + XCTAssertEqual(fixture.factory.intents.last?.input, .local) + XCTAssertEqual(fixture.factory.intents.last?.remoteSelectionConsent, approved) + fixture.hotkeys.stop?(.dictation) + let result = try await fixture.execution.waitForCompletion(id) + XCTAssertEqual(result.phase, .completed) + XCTAssertEqual(fixture.factory.job.runs, 1) + await fixture.binding.close(); await fixture.execution.close() + } + + func testDisclosureCancellationExpiryAndChangedTargetOrProviderHaveNoCapture() async throws { + for change in 0..<4 { + let fixture = try BindingFixture() + defer { fixture.remove() } + fixture.factory.requiredConsent = consent(expiresAt: Date().addingTimeInterval(change == 1 ? 0.02 : 120)) + let id = UUID() + try fixture.execution.start(SessionIntent(id: id, input: .local)) + if change == 0 { fixture.execution.declineRemoteDisclosure(id) } + else if change == 1 { try await Task.sleep(for: .milliseconds(60)) } + else if change == 2 { fixture.factory.targetCurrent = false } + else { fixture.factory.requiredConsent = consent(baseURL: "https://changed.example.test/v1") } + XCTAssertThrowsError(try fixture.execution.authorizeRemoteDisclosure(id)) + XCTAssertEqual(fixture.factory.job.runs, 0) + XCTAssertNil(fixture.execution.snapshot.remoteDisclosure) + XCTAssertFalse(fixture.execution.snapshot.isBusy) + await fixture.binding.close(); await fixture.execution.close() + } + } + + private func consent(baseURL: String = "https://model.example.test/v1", expiresAt: Date = Date().addingTimeInterval(120)) -> SelectionEditRemoteConsent { + SelectionEditRemoteConsent(selectedText: "private selection 👩🏽‍💻", providerID: "generation.remote", + providerBaseURL: baseURL, expiresAt: expiresAt) + } + func testCallbacksFreezeCaptureIdentityAndStopTimeBeforeDraining() async throws { let fixture = try BindingFixture() defer { fixture.remove() } @@ -95,14 +164,24 @@ private final class BindingHotkeys: HotkeyControlService { private final class BindingWorkflows: SessionWorkflowFactory { var intents: [SessionIntent] = [] let job = BindingJob() - func make(_ intent: SessionIntent) throws -> any SessionJob { intents.append(intent); return job } + var requiredConsent: SelectionEditRemoteConsent? + var targetCurrent = true + func make(_ intent: SessionIntent) throws -> any SessionJob { + intents.append(intent) + if let requiredConsent, intent.remoteSelectionConsent != requiredConsent { + throw SelectionEditRemoteDisclosureRequired(consent: requiredConsent, isTargetCurrent: { self.targetCurrent }) + } + return job + } func settle(_ intent: SessionIntent, result: Result) {} } @MainActor private final class BindingJob: SessionJob { weak var control: (any SessionJobControl)? + var runs = 0 func promoteToTranslation() -> TextProcessingMode? { .translation(.english) } func run(control: any SessionJobControl) async throws -> SessionCompletion { + runs += 1 self.control = control control.update(phase: .recording, transcript: "") try await control.waitForStop() diff --git a/Tests/UtterMacServicesTests/DeliveryReceiptTests.swift b/Tests/UtterMacServicesTests/DeliveryReceiptTests.swift index f43194de..93577924 100644 --- a/Tests/UtterMacServicesTests/DeliveryReceiptTests.swift +++ b/Tests/UtterMacServicesTests/DeliveryReceiptTests.swift @@ -6,6 +6,51 @@ import UtterContracts final class DeliveryReceiptTests: XCTestCase { private func request() -> DeliveryRequest { DeliveryRequest(id: UUID(), command: .clipboard("fixture")) } + func testUnacknowledgedKeyboardInsertionIsConsumedWithoutRetry() async throws { + var insertions = 0 + let service = ScopedOutputService(isCurrent: { true }) { _, _, mark in + if mark(.keyboardInsertion) { insertions += 1 } + return DeliveryCompletion(disposition: .accepted) + } + let request = DeliveryRequest(id: UUID(), command: .insert("候选 👩🏽‍💻")) + let delivery = try service.prepare(request, isSessionCurrent: { true }) + let receipt = await delivery.commit() + XCTAssertEqual(receipt.effects, [.keyboardInsertion]) + XCTAssertEqual(receipt.status, .uncertain) + XCTAssertFalse(receipt.isConfirmedInsertion) + await delivery.close() + let replay = try service.prepare(request, isSessionCurrent: { true }) + let replayed = await replay.commit() + XCTAssertEqual(replayed.status, .uncertain) + XCTAssertEqual(insertions, 1) + await service.close() + } + + func testReplayedSelectionReceiptRetainsStatusWithoutRestorationAuthorityOrOriginalText() async throws { + let anchor = SelectionReceiptAnchor() + var commits = 0 + let service = ScopedOutputService(isCurrent: { true }) { request, _, mark in + commits += 1 + XCTAssertTrue(mark(.accessibility)) + return DeliveryCompletion(disposition: .accepted, anchor: anchor, confirmation: .targetValue, + selectionEdit: SelectionEditReceipt(operationID: request.id, originalSelection: "private original 👩🏽‍💻", + replacement: anchor.text, anchor: anchor)) + } + let request = DeliveryRequest(id: UUID(), command: .replaceSelection("candidate"), target: anchor.target) + let first = try service.prepare(request, isSessionCurrent: { true }) + let original = await first.commit() + XCTAssertEqual(original.selectionEdit?.originalSelection, "private original 👩🏽‍💻") + XCTAssertTrue(original.anchor === anchor) + await first.close() + let replay = try service.prepare(request, isSessionCurrent: { true }) + let receipt = await replay.commit() + XCTAssertEqual(receipt.status, .inserted) + XCTAssertNil(receipt.anchor) + XCTAssertNil(receipt.selectionEdit) + XCTAssertEqual(commits, 1) + await service.close() + } + func testCancelledPreparationNeverCallsTheDriverOrCommits() async throws { var calls = 0 let service = ScopedOutputService(isCurrent: { true }) { _, _, mark in @@ -115,6 +160,25 @@ final class DeliveryReceiptTests: XCTestCase { } } +@MainActor +private final class SelectionReceiptAnchor: OutputAnchor { + let target: any OutputTargetLease = SelectionReceiptTarget() + let range = NSRange(location: 0, length: 9) + let text = "candidate" + let expiresAt = Date().addingTimeInterval(15) + let isCurrent = true + func correctionSeed(context: InputContext) -> CorrectionCaptureSeed? { nil } +} + +@MainActor +private final class SelectionReceiptTarget: OutputTargetLease { + let id = UUID() + let processIdentifier: Int32 = 1 + let context = InputContext(outputMode: .processed, inputLanguage: .english, source: .menuBar) + let isValid = true + let isCurrent = true +} + @MainActor private final class DeliverySignal { private var sent = false diff --git a/Tests/UtterMacServicesTests/TextDeliveryTransactionTests.swift b/Tests/UtterMacServicesTests/TextDeliveryTransactionTests.swift index c04b9307..016b67af 100644 --- a/Tests/UtterMacServicesTests/TextDeliveryTransactionTests.swift +++ b/Tests/UtterMacServicesTests/TextDeliveryTransactionTests.swift @@ -50,11 +50,11 @@ final class TextDeliveryTransactionTests: XCTestCase { XCTAssertTrue(effects.isEmpty) XCTAssertEqual(clipboard.text, "old") XCTAssertEqual(clipboard.changeCount, 0) - XCTAssertEqual(target.restorations, 1) + XCTAssertEqual(target.restorations, secure ? 0 : 1) } } - func testEnabledClipboardFallbackCanCopyUnderSecureInputWithoutPostingKeys() async { + func testSecureTargetRefusalCannotAutomaticallyCopyEvenWhenPasteIsEnabled() async { let target = TransactionTarget() target.supportsSelectionWrite = false let clipboard = TransactionPasteboard() @@ -62,10 +62,27 @@ final class TextDeliveryTransactionTests: XCTestCase { let result = await TextDeliveryTransaction.deliver("new", target: target, pasteboard: clipboard, allowsClipboardPaste: true, isSecureInput: { true }, prepareKeys: { _ in XCTFail("Secure input blocks keys"); return nil }, canCommit: { true }, mark: { effects.insert($0).inserted }) + XCTAssertEqual(result.disposition, .notCommitted) + XCTAssertEqual(result.confirmation, .none) + XCTAssertTrue(effects.isEmpty) + XCTAssertEqual(clipboard.text, "old") + XCTAssertEqual(clipboard.changeCount, 0) + XCTAssertEqual(target.restorations, 0) + } + + func testUnsecureKeyPreparationFailureStillCopiesWhenPasteIsEnabled() async { + let target = TransactionTarget() + target.supportsSelectionWrite = false + let clipboard = TransactionPasteboard() + var effects: Set = [] + let result = await TextDeliveryTransaction.deliver("new", target: target, pasteboard: clipboard, + allowsClipboardPaste: true, isSecureInput: { false }, prepareKeys: { _ in nil }, + canCommit: { true }, mark: { effects.insert($0).inserted }) XCTAssertEqual(result.confirmation, .clipboardValue) XCTAssertEqual(result.path, .clipboardOnly) XCTAssertEqual(effects, [.clipboard]) XCTAssertEqual(clipboard.text, "new") + XCTAssertTrue(target.writes.isEmpty) } func testTargetChangeDuringSelectionPreparationCommitsNothing() async { @@ -150,7 +167,7 @@ final class TextDeliveryTransactionTests: XCTestCase { XCTAssertEqual(target.restorations, 1) } - func testWebEditorUnderSecureInputOnlyCopiesWithoutPostingKeys() async { + func testWebEditorUnderSecureInputCommitsNothingAndDoesNotCopy() async { let target = TransactionTarget() target.isWebContentEditor = true let clipboard = TransactionPasteboard() @@ -158,11 +175,14 @@ final class TextDeliveryTransactionTests: XCTestCase { let result = await TextDeliveryTransaction.deliver("new", target: target, pasteboard: clipboard, allowsClipboardPaste: true, isSecureInput: { true }, prepareKeys: { _ in XCTFail("Secure input blocks keys"); return nil }, canCommit: { true }, mark: { effects.insert($0).inserted }) - XCTAssertEqual(result.confirmation, .clipboardValue) - XCTAssertEqual(result.path, .clipboardOnly) + XCTAssertEqual(result.disposition, .notCommitted) + XCTAssertEqual(result.confirmation, .none) + XCTAssertNil(result.path, "no route was taken") XCTAssertEqual(result.target, .web) - XCTAssertEqual(effects, [.clipboard]) + XCTAssertTrue(effects.isEmpty) XCTAssertTrue(target.writes.isEmpty) + XCTAssertEqual(clipboard.changeCount, 0) + XCTAssertEqual(target.restorations, 0) } func testWebEditorTargetChangeBeforeThePasteCommitsNothing() async { diff --git a/Tests/UtterProcessingTests/SelectionEditRecipeTests.swift b/Tests/UtterProcessingTests/SelectionEditRecipeTests.swift new file mode 100644 index 00000000..d18f6a38 --- /dev/null +++ b/Tests/UtterProcessingTests/SelectionEditRecipeTests.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTest +import UtterContracts +import UtterProcessing + +final class SelectionEditRecipeTests: XCTestCase { + func testPortableRecipeUsesTheFullSelectionAndNormalizesOneGeneration() async throws { + let original = " 原文 e\u{301} 👩🏽‍💻\n" + String(repeating: "完整内容。", count: 200) + var calls = 0 + let candidate = try await SelectionEditRecipe.candidate(selectedText: original, + intent: .custom("Make a concise English reply"), inputLanguage: .auto, + spokenCommand: "Make a concise English reply") { prompt, system, budget in + calls += 1 + XCTAssertTrue(prompt.contains(original)) + XCTAssertTrue(prompt.contains("Make a concise English reply")) + XCTAssertTrue(system.contains("final_text")) + XCTAssertEqual(budget.maxTokens, 1280) + XCTAssertEqual(budget.temperature, 0.15) + return "internal reasoning{\"final_text\":\"Rewritten 👩🏽‍💻\"}" + } + XCTAssertEqual(candidate, "Rewritten 👩🏽‍💻") + XCTAssertEqual(calls, 1) + XCTAssertTrue(original.hasPrefix(" 原文 e\u{301} 👩🏽‍💻\n")) + } + + func testEmptyInputsNeverInvokeTheExistingBackend() async { + for (selection, instruction, expected) in [(" \n ", "rewrite", SelectionEditRecipe.Failure.emptySelection), + ("original", " \n ", .emptyInstruction)] { + do { + _ = try await SelectionEditRecipe.candidate(selectedText: selection, intent: .custom(instruction), + inputLanguage: .english) { _, _, _ in XCTFail("Invalid input invoked generation"); return "candidate" } + XCTFail("Invalid input produced a candidate") + } catch { XCTAssertEqual(error as? SelectionEditRecipe.Failure, expected) } + } + } + + func testFailedOrReasoningOnlyGenerationNeverFallsBackToTheOriginal() async { + do { + _ = try await SelectionEditRecipe.candidate(selectedText: "original", intent: .concise, + inputLanguage: .english) { _, _, _ in throw GenerationServiceError.modelUnavailable } + XCTFail("Model failure invented a candidate") + } catch { XCTAssertEqual(error as? GenerationServiceError, .modelUnavailable) } + for output in [" \n ", "still trying", "unfinished reasoning", + "{\"final_text\":\"\"}", "{\"action\":\"delete_selection\",\"confidence\":1}", + "{\"final_text\":\"\",\"text\":\"Deleted.\",\"reason\":\"delete_selection\"}", + "{\"action\":\"delete_selection\",\"text\":\"Deleted.\",\"confidence\":1}", + #"{"final_text":{"final_text":"","text":"Deleted.","reason":"delete_selection"}}"#, + #"{"final_text":{"action":"delete_selection","text":"Deleted.","confidence":1}}"#, + "```json\n{\"action\":\"delete_selection\",\"confidence\":1}\n```"] { + do { + _ = try await SelectionEditRecipe.candidate(selectedText: "original", intent: .concise, + inputLanguage: .english) { _, _, _ in output } + XCTFail("Empty result fell back to original") + } catch { XCTAssertEqual(error as? SelectionEditRecipe.Failure, .emptyCandidate) } + } + } + + func testCancelledGenerationCannotProduceAReviewableCandidate() async { + let task = Task { + try await SelectionEditRecipe.candidate(selectedText: "original", intent: .concise, + inputLanguage: .english) { _, _, _ in + withUnsafeCurrentTask { $0?.cancel() } + return "candidate" + } + } + do { _ = try await task.value; XCTFail("Cancelled model result was accepted") } + catch { XCTAssertTrue(error is CancellationError) } + } + + func testStrictSelectionHandlesNestedStringifiedAndFencedEnvelopes() { + for output in [ + #"{"data":"{\"action\":\"delete_selection\",\"text\":\"Deleted.\",\"confidence\":1}"}"#, + #"{"final_text":"{\"final_text\":\"\",\"text\":\"Deleted.\",\"reason\":\"delete_selection\"}"}"#, + #"[{"text":"Deleted.","reason":"delete_selection"}]"#, + "```json\n{\"final_text\":\"\",\"text\":\"Deleted.\",\"reason\":\"delete_selection\"}\n```", + ] { XCTAssertEqual(SelectionEditRecipe.normalize(output), "") } + for output in [#"{"response":{"rewritten_text":"Keep the notes. 👩🏽‍💻"}}"#, + #"[{"type":"final_text","text":"Keep the notes. 👩🏽‍💻"}]"#, + #"{"tool_calls":[{"function":{"arguments":"{\"final_text\":\"Keep the notes. 👩🏽‍💻\"}"}}]}"#, + "```json\n{\"final_text\":\"Keep the notes. 👩🏽‍💻\"}\n```"] { + XCTAssertEqual(SelectionEditRecipe.normalize(output), "Keep the notes. 👩🏽‍💻") + } + } + + func testStrictRecursiveObjectsRejectAmbiguousTextWhileOrdinaryParsingKeepsIt() { + for output in [ + #"{"final_text":{"final_text":"","text":"Deleted.","reason":"delete_selection"}}"#, + #"{"final_text":{"action":"delete_selection","text":"Deleted.","confidence":1}}"#, + #"{"type":"final_text","value":{"action":"delete_selection","text":"Deleted.","confidence":1}}"#, + ] { + XCTAssertEqual(SelectionEditRecipe.normalize(output), "") + XCTAssertEqual(LLMFinalTextOutput.text(from: output), "Deleted.") + } + for output in [#"{"final_text":{"value":"Keep the notes."}}"#, + #"{"type":"final_text","content":{"value":"Keep the notes."}}"#, + #"{"final_text":{"final_text":"Keep the notes.","text":"Deleted."}}"#] { + XCTAssertEqual(SelectionEditRecipe.normalize(output), "Keep the notes.") + } + XCTAssertFalse(LLMFinalTextOutput.requiresExplicitFinalText) + } +} diff --git a/Tests/UtterSessionTests/RestoreSelectionEditJobTests.swift b/Tests/UtterSessionTests/RestoreSelectionEditJobTests.swift new file mode 100644 index 00000000..c212408e --- /dev/null +++ b/Tests/UtterSessionTests/RestoreSelectionEditJobTests.swift @@ -0,0 +1,139 @@ +import Foundation +import XCTest +import UtterContracts +@testable import UtterSession + +@MainActor +final class RestoreSelectionEditJobTests: XCTestCase { + func testOriginalUnicodeSelectionUsesOneBoundTransactionAndCannotReplay() async throws { + let original = " 原文 e\u{301} 👩🏽‍💻\n" + String(repeating: "第二行 🇸🇬 细节。", count: 100) + "\n " + let fixture = RestoreFixture(original: original) + let sessionID = UUID() + let job = try fixture.job(sessionID: sessionID) + let completion = try await job.run(control: fixture.control) + XCTAssertEqual(completion.text, original) + XCTAssertNil(completion.record) + XCTAssertNil(completion.historyReplacement) + XCTAssertNil(completion.followup) + XCTAssertEqual(completion.acceptance.deliveryStatus, .inserted) + XCTAssertEqual(fixture.output.requests.count, 1) + let request = try XCTUnwrap(fixture.output.requests.first) + XCTAssertEqual(request.id, sessionID) + XCTAssertFalse(request.allowsClipboardPaste) + guard case .replaceAnchor(let text, let anchor) = request.command else { return XCTFail("Restore deleted the insertion") } + XCTAssertEqual(text, original) + XCTAssertTrue(anchor === fixture.anchor) + XCTAssertEqual(anchor.range, NSRange(location: 9, length: "改写 🇸🇬".utf16.count)) + XCTAssertNil(fixture.outputs.snapshot.selectionEditRestoration) + XCTAssertNil(fixture.outputs.recent) + XCTAssertEqual(fixture.outputs.snapshot.recentText, "") + XCTAssertThrowsError(try fixture.job()) + do { _ = try await job.run(control: fixture.control); XCTFail("Consumed restoration replayed") } + catch { XCTAssertEqual(error as? DeliveryError, .invalidTarget) } + XCTAssertEqual(fixture.output.requests.count, 1) + await job.close() + } + + func testUncertainRestoreRetiresTheReceiptWithoutClaimingInsertion() async throws { + let fixture = RestoreFixture() + fixture.output.disposition = .uncertain + let job = try fixture.job() + let completion = try await job.run(control: fixture.control) + XCTAssertEqual(completion.acceptance.deliveryStatus, .uncertain) + XCTAssertFalse(completion.accepted) + XCTAssertThrowsError(try fixture.job()) + XCTAssertNil(fixture.outputs.snapshot.selectionEditRestoration) + XCTAssertEqual(fixture.output.requests.count, 1) + } + + func testLaterInputFocusAndNewerReceiptBlockReservedRestoreWithoutEffects() async throws { + for invalidation in 0..<3 { + let fixture = RestoreFixture() + let job = try fixture.job() + if invalidation == 0 { fixture.anchor.current = false } + else if invalidation == 1 { fixture.target.isCurrent = false } + else { fixture.remember(operationID: UUID()) } + do { _ = try await job.run(control: fixture.control); XCTFail("Stale restoration committed") } + catch { XCTAssertEqual(error as? DeliveryError, .invalidTarget) } + XCTAssertTrue(fixture.output.requests.isEmpty) + } + } + + func testWrongReceiptAndRevokedJobCannotPrepareAnOutput() async throws { + let fixture = RestoreFixture() + XCTAssertThrowsError(try RestoreSelectionEditJob(operationID: UUID(), sessionID: UUID(), + outputs: fixture.outputs, output: fixture.output, authorize: {})) + let job = try fixture.job() + job.revoke() + do { _ = try await job.run(control: fixture.control); XCTFail("Cancelled restore committed") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertTrue(fixture.output.requests.isEmpty) + XCTAssertEqual(fixture.outputs.snapshot.selectionEditRestoration?.operationID, fixture.operationID) + } +} + +@MainActor +private final class RestoreFixture { + let operationID = UUID() + let recordID = UUID() + let target = RestoreTarget() + let output = RestoreOutput() + let outputs = SessionOutputState(notifications: StateNotifications()) + let control = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + let anchor: RestoreAnchor + let original: String + init(original: String = "原文 e\u{301} 👩🏽‍💻\n第二行") { + self.original = original; anchor = RestoreAnchor(target: target); remember(operationID: operationID) + } + func remember(operationID: UUID) { + let edit = SelectionEditReceipt(operationID: operationID, originalSelection: original, + replacement: anchor.text, anchor: anchor) + let receipt = DeliveryReceipt(operationID: operationID, disposition: .accepted, effect: .accessibility, + anchor: anchor, confirmation: .targetValue, selectionEdit: edit) + outputs.remember(SessionCompletion(transcript: "改写", text: anchor.text, acceptance: .delivery(receipt)), recordID: recordID) + } + func job(sessionID: UUID = UUID()) throws -> RestoreSelectionEditJob { + try RestoreSelectionEditJob(operationID: operationID, sessionID: sessionID, + outputs: outputs, output: output, authorize: {}) + } +} + +@MainActor +private final class RestoreTarget: OutputTargetLease { + let id = UUID() + let processIdentifier: Int32 = 3 + let context = InputContext(outputMode: .processed, inputLanguage: .chinese, source: .menuBar) + let isValid = true + var isCurrent = true +} + +@MainActor +private final class RestoreAnchor: OutputAnchor { + let target: any OutputTargetLease + let text = "改写 🇸🇬" + var range: NSRange { NSRange(location: 9, length: text.utf16.count) } + let expiresAt = Date().addingTimeInterval(15) + var current = true + var isCurrent: Bool { current && Date() < expiresAt } + init(target: any OutputTargetLease) { self.target = target } + func correctionSeed(context: InputContext) -> CorrectionCaptureSeed? { nil } +} + +@MainActor +private final class RestoreOutput: OutputService, PreparedDelivery { + var disposition = DeliveryDisposition.accepted + var requests: [DeliveryRequest] = [] + var receipt: DeliveryReceipt? + func prepare(_ request: DeliveryRequest, isSessionCurrent: @escaping () -> Bool) throws -> any PreparedDelivery { + guard isSessionCurrent() else { throw CancellationError() } + requests.append(request) + return self + } + func commit() async -> DeliveryReceipt { + let receipt = DeliveryReceipt(operationID: requests.last!.id, disposition: disposition, + effect: .accessibility, confirmation: .targetValue) + self.receipt = receipt + return receipt + } + func close() async {} +} diff --git a/Tests/UtterSessionTests/SelectionReceiptRetentionTests.swift b/Tests/UtterSessionTests/SelectionReceiptRetentionTests.swift new file mode 100644 index 00000000..02794c81 --- /dev/null +++ b/Tests/UtterSessionTests/SelectionReceiptRetentionTests.swift @@ -0,0 +1,104 @@ +import Foundation +import XCTest +import UtterContracts +@testable import UtterSession + +@MainActor +final class SelectionReceiptRetentionTests: XCTestCase { + func testConsumeClearsOriginalCandidateAnchorAndProjectionOnce() { + let state = SessionOutputState(notifications: StateNotifications()) + let operationID = UUID() + var anchor: ExpiringSelectionAnchor? = ExpiringSelectionAnchor(expiresAt: Date().addingTimeInterval(15)) + weak var retained = anchor + remember(state, operationID: operationID, anchor: anchor!) + anchor = nil + XCTAssertEqual(state.snapshot.selectionEditRestoration?.originalSelection, " 原文 👩🏽‍💻\n ") + XCTAssertNotNil(retained) + XCTAssertTrue(state.consumeSelectionEdit(operationID)) + XCTAssertFalse(state.consumeSelectionEdit(operationID)) + XCTAssertNil(state.recent) + XCTAssertEqual(state.snapshot, SessionOutputSnapshot()) + XCTAssertNil(retained) + state.close() + } + + func testAutomaticExpiryClearsTextAndNotifiesWithoutAnotherUserAction() async throws { + let state = SessionOutputState(notifications: StateNotifications()) + var observations: [SessionOutputSnapshot] = [] + _ = state.observe { observations.append($0) } + var anchor: ExpiringSelectionAnchor? = ExpiringSelectionAnchor(expiresAt: Date().addingTimeInterval(0.05)) + weak var retained = anchor + let id = UUID() + remember(state, operationID: id, anchor: anchor!) + anchor = nil + let deadline = ContinuousClock.now.advanced(by: .seconds(2)) + while state.recent != nil, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } + XCTAssertNil(state.recent) + XCTAssertEqual(state.snapshot, SessionOutputSnapshot()) + XCTAssertEqual(observations.last, SessionOutputSnapshot()) + XCTAssertNil(retained) + XCTAssertFalse(state.consumeSelectionEdit(id)) + state.close() + } + + func testStaleExpiryCannotClearOrdinaryOrNewerSelectedOutput() { + let state = SessionOutputState(notifications: StateNotifications()) + let oldID = UUID() + remember(state, operationID: oldID, anchor: ExpiringSelectionAnchor(expiresAt: Date().addingTimeInterval(15))) + state.remember(SessionCompletion(transcript: "normal", text: "normal", acceptance: .delivery( + DeliveryReceipt(operationID: UUID(), disposition: .accepted, effect: .paste, confirmation: .targetValue))), recordID: UUID()) + state.expireSelectionEdit(oldID) + XCTAssertEqual(state.snapshot.recentText, "normal") + let newID = UUID() + remember(state, operationID: newID, anchor: ExpiringSelectionAnchor(expiresAt: Date().addingTimeInterval(15))) + state.expireSelectionEdit(oldID) + XCTAssertEqual(state.snapshot.selectionEditRestoration?.operationID, newID) + state.expireSelectionEdit(newID) + XCTAssertEqual(state.snapshot, SessionOutputSnapshot()) + state.close() + } + + func testCloseCancelsExpiryAndReleasesReceiptWithoutLateObservers() async throws { + let state = SessionOutputState(notifications: StateNotifications()) + var calls = 0 + _ = state.observe { _ in calls += 1 } + var anchor: ExpiringSelectionAnchor? = ExpiringSelectionAnchor(expiresAt: Date().addingTimeInterval(0.05)) + weak var retained = anchor + remember(state, operationID: UUID(), anchor: anchor!) + anchor = nil + state.close() + let before = calls + try await Task.sleep(for: .milliseconds(100)) + XCTAssertEqual(calls, before) + XCTAssertNil(retained) + XCTAssertEqual(state.snapshot, SessionOutputSnapshot()) + } + + private func remember(_ state: SessionOutputState, operationID: UUID, anchor: ExpiringSelectionAnchor) { + let edit = SelectionEditReceipt(operationID: operationID, originalSelection: " 原文 👩🏽‍💻\n ", + replacement: anchor.text, anchor: anchor) + let receipt = DeliveryReceipt(operationID: operationID, disposition: .accepted, effect: .accessibility, + anchor: anchor, confirmation: .targetValue, selectionEdit: edit) + state.remember(SessionCompletion(transcript: "rewrite", text: anchor.text, acceptance: .delivery(receipt)), recordID: UUID()) + } +} + +@MainActor +private final class ExpiringSelectionAnchor: OutputAnchor { + let target: any OutputTargetLease = ExpiringSelectionTarget() + let text = "候选 🇸🇬" + var range: NSRange { NSRange(location: 0, length: text.utf16.count) } + let expiresAt: Date + var isCurrent: Bool { Date() < expiresAt } + init(expiresAt: Date) { self.expiresAt = expiresAt } + func correctionSeed(context: InputContext) -> CorrectionCaptureSeed? { nil } +} + +@MainActor +private final class ExpiringSelectionTarget: OutputTargetLease { + let id = UUID() + let processIdentifier: Int32 = 2 + let context = InputContext(outputMode: .processed, inputLanguage: .chinese, source: .menuBar) + let isValid = true + let isCurrent = true +} diff --git a/Tests/UtterSessionTests/SessionControlTests.swift b/Tests/UtterSessionTests/SessionControlTests.swift index 1954f6aa..9bc0b6a8 100644 --- a/Tests/UtterSessionTests/SessionControlTests.swift +++ b/Tests/UtterSessionTests/SessionControlTests.swift @@ -4,6 +4,59 @@ import UtterContracts @MainActor final class SessionControlTests: XCTestCase { + func testApplyIsBoundToTheDisplayedProposalAndResumesExactlyOnce() async throws { + let control = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + let proposal = SelectionEditProposal(sessionID: UUID(), targetID: UUID(), + originalSelection: "原文 👩🏽‍💻", recognizedInstruction: "改写", candidate: "候选 🧑‍🚀") + let waiting = Task { try await control.waitForSelectionEditDecision(proposal) } + for _ in 0..<100 where control.selectionEditProposal == nil { await Task.yield() } + XCTAssertEqual(control.selectionEditProposal, proposal) + XCTAssertFalse(control.applySelectionEdit(UUID())) + XCTAssertTrue(control.applySelectionEdit(proposal.id)) + XCTAssertFalse(control.applySelectionEdit(proposal.id)) + try await waiting.value + XCTAssertNil(control.selectionEditProposal) + } + + func testCancellationAndEmptyCandidateNeverAuthorizeDelivery() async { + let control = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + let proposal = SelectionEditProposal(sessionID: UUID(), targetID: UUID(), + originalSelection: "original", recognizedInstruction: "rewrite", candidate: "candidate") + let waiting = Task { () -> Bool in + do { try await control.waitForSelectionEditDecision(proposal); return true } + catch { return false } + } + for _ in 0..<100 where control.selectionEditProposal == nil { await Task.yield() } + control.revoke() + XCTAssertFalse(control.applySelectionEdit(proposal.id)) + let allowed = await waiting.value + XCTAssertFalse(allowed) + XCTAssertNil(control.selectionEditProposal) + let empty = SelectionEditProposal(sessionID: UUID(), targetID: UUID(), + originalSelection: "original", recognizedInstruction: "rewrite", candidate: " \n ") + let fresh = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + do { try await fresh.waitForSelectionEditDecision(empty); XCTFail("Empty candidate entered review") } + catch { XCTAssertTrue(error is CancellationError) } + XCTAssertNil(fresh.selectionEditProposal) + } + + func testDurationLimitFinalizesOnlyTheCurrentRecording() async throws { + let control = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + XCTAssertFalse(control.finalizeCapture(reason: .durationLimit)) + control.update(phase: .recording, transcript: "") + let waiting = Task { try await control.waitForStop() } + XCTAssertTrue(control.finalizeCapture(reason: .durationLimit)) + XCTAssertFalse(control.finalizeCapture(reason: .durationLimit)) + try await waiting.value + let processing = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) + processing.update(phase: .processing, transcript: "done") + XCTAssertFalse(processing.finalizeCapture(reason: .durationLimit)) + XCTAssertFalse(processing.isStopped) + let retired = SessionControl(isCurrent: { false }, cancel: {}, update: { _, _ in }) + retired.update(phase: .recording, transcript: "") + XCTAssertFalse(retired.finalizeCapture(reason: .durationLimit)) + } + func testCancelledCaptureWaiterDrainsWithoutInventingAStop() async { let control = SessionControl(isCurrent: { true }, cancel: {}, update: { _, _ in }) var entered = false diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/build-preparation-r5.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/build-preparation-r5.md new file mode 100644 index 00000000..21044141 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/build-preparation-r5.md @@ -0,0 +1,57 @@ +# Run5 外盘隔离与离线依赖准备 + +**Status:** blocked +**Authorization:** 继承用户整体授权,仅表示本轮准备工作已获授权,未代表实际验收通过。 + +实测准备完成;SwiftPM 离线初始化、compiler 外盘落点、完整 build 和 swift test 均为 **not-run**。不会把源码复制或 help 输出报为工程通过。Run4 是磁盘保护 exit -2,未发现可据此修改的代码错误。本轮没有产品源码修改。 + +## 已实测 + +- 重新核验既有外盘 identity、APFS、挂载、可写和空间,仅创建任务独占 `EXTERNAL_TASK_ROOT`。拒绝旧目录及 symlink;专属 scratch/modulecache/clangcache/temp/packagecache/config/security 的写入、fsync、读回和 SHA 校验通过。 +- 已安装 Swift 6.2.3 的 build/package help 确认 scratch/cache/config/security、禁止自动更新解析/预取/prebuilts 和 jobs 选项;swiftc/clang help 确认 module-cache 选项。仅执行 help/version,没有编译探针。未选择或执行 Xcode,其既有 developer directory 只核验存在。 +- Package.resolved 的全部 19 pins 对应本 worktree checkout HEAD,clean 且本地 bare repositories 包含确切 commit。包含递归 Git 子模块的精确源码快照已放入任务 scratch/checkouts,共 8254 文件、145334442 bytes,逐文件读回 SHA 一致。没有复制 `.git`、认证、整个全局缓存、原生产线源码或 workspace-state。 +- HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`。tracked binary diff SHA256 `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`。完整 846 文件源码/script/resource manifest SHA256 `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`,与 Run4 逐文件一致。git diff --check 通过。 + +## 未执行与准确阻塞 + +最初 Data 可用 10,649,055,232 bytes,低于固定保护线 10,737,418,240 bytes,所以只做非编译准备。22:39:09Z 读到恢复后的 11,201,437,696 bytes,最终准备读数 11179929600 bytes;此时距命令收尾及 native 目标已不足三分钟,没有启动 build 或最后启动测试。没有等待空间恢复、降低阈值、删除 Data 文件或再次重试磁盘保护。 + +快照是源码快照,尚无 SwiftPM 注册所需 Git 元数据。packagecache 只有写读探针,没有宣称已缓存可用依赖;没有复制绝对 workspace-state。SwiftPM describe/resolve/build 和本地 source override 均未执行。因此离线初始化还未成立,不能直接执行配置草案期待离线成功。 + +`configuration-only.json` 保存精确 command/env 和落点,全部本机绝对路径及 volume identity 只在 ignored raw 中。配置草案使用本进程 TMPDIR/SWIFTPM_MODULECACHE_OVERRIDE/CLANG_MODULE_CACHE_PATH,SwiftPM scratch/cache/config/security 及显式 Swift/Clang modulecache。后续需先从已验证本地 repos 补建 Git working copies 或任务专属 mirrors,并让真实 SwiftPM 离线初始化验证;不改 Package.swift/lock/global config/HOME,不允许联网 fetch。再核验 Data >=10GiB、保留至少三分钟自身 PGID 监督窗口,运行一次无 target/product 限制的完整 swift build,jobs2、关闭 prebuilts/解析更新/预取,以日志证明实际外盘 compiler 落点。 + +swift test、basic checks、release-style build、UI/宿主/焦点/隐私/设备均 not-run。basic 脚本含 Swift 编译执行路径,不在不足监督窗口中启动。源码未改,未写父独占 plan/spec/verification/workflow/Run5-state。 + +## 证据与命令 + +raw 根 `.local/voicecursor-research/run5-preparation/`。`command-records.json` 保存每条实际 command/env/start/end/exit/PID/PGID/logSHA256;全部原始日志、19份 source manifests、`dependencies.json`、`full-source-manifest-after.json`、`configuration-only.json` 和 `external-mapping.json` 留在该根。日志哈希总索引 `evidence-hashes.json`。启动命令 `python3 .local/voicecursor-research/run5-preparation/prepare.py`,两次 `--resume` 只修准备脚本的仓库名歧义和 Git 子模块目录处理;失败原始结果仍保留。不是重试 build 或重新扩预算。 + +最终准备监督开始 2026-10-07T22:39:58.234891+00:00,结束 2026-10-07T22:40:15.352968+00:00,exit0。每条工具命令自身 PGID 监督、最多15秒,并受22:40:40Z累计截止约束。自身命令进程组清空;终态快照见 `process-terminal.json`。不留 watcher、daemon 或子 Agent。native completedAt 和 deadline 达标由父宿主核验,报告写入时间不等于 native 终态。 + +## 全部精确依赖 + +| identity | revision | source manifest SHA256 | +| --- | --- | --- | +| ane-lm | `033472ec12ea796fc7ea4f8cefd7ed456f69900b` | `bd3f271ba88baae32f0de626e07268a6c465ee37c0356fa2691336b92f93d3d1` | +| argmax-oss-swift | `25c62997041c134b03ca82731ce2f6fd2cae1eb9` | `e247e884f6c6ea9aee2ccbc04f6f68640aa88513b0dbed0c5a67bb4d24a7b662` | +| eventsource | `a3a85a85214caf642abaa96ae664e4c772a59f6e` | `9bb356649cd43dff5c7bf95d23fec6cdefffa720a63779d36e8c5ebec9dcb75d` | +| mlx-audio-swift | `d302a5c6080d2bb97bae38c7418f82abb76013b6` | `bcd73c7f49ad49f730f9042dd10c6392de112995c0798ed2951883b2766042e0` | +| mlx-swift | `dc43e62d7055353c7f99fa071a4e71d29dfddc44` | `fbd7842d5394688250f2b81dd74a33f7a2e262d0ddc2b2834314ab25224d1e6c` | +| mlx-swift-lm | `bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57` | `bc20c6c0d0dc6b7915f0c1465f2c5e91744e36d31b1b588fc2f0c38148f86422` | +| swift-argument-parser | `6a52f3251125d74daf04fcbd5e6f08a75d074382` | `b9c48d9a5b5596acd5544c82709d89567a865775054a0ed9b4c56104f633e1f0` | +| swift-asn1 | `eb50cbd14606a9161cbc5d452f18797c90ef0bab` | `5b589d3fc5706da7e4c7f43765cd8119e223736996b9ae4aee9d3ef271098b09` | +| swift-atomics | `b601256eab081c0f92f059e12818ac1d4f178ff7` | `e3608a3ac996f4185b9baedfe5e64d5a4a925189a1de53f5c515dd9f8b6f9483` | +| swift-collections | `fea17c02d767f46b23070fdfdacc28a03a39232a` | `9804a3402b0b36f82d2d1780ba2eb58af70bfb8c6f9d0771569259c22af24943` | +| swift-crypto | `1b6b2e274e85105bfa155183145a1dcfd63331f1` | `640cd1d63d33b96c22114d886c3c354e3487a742274ee650ec2c84ad035824b7` | +| swift-huggingface | `b721959445b617d0bf03910b2b4aced345fd93bf` | `b5ad7b072e4411bda49b1ce74cb846e54babec7a4a59817c72c2cccfb3c15adf` | +| swift-jinja | `0b67ecb79139f6addef8699eff3622808aa6c7dc` | `897bcb0d3a80266762acdacbbd053a95b225c87e809eeede97b46da4314a27c4` | +| swift-nio | `57c0a08a331aaea9f5d7a932ad94ef43be942a95` | `7d0b785605a6ddd4b97c57e5094683f99785fa127f6071c2b995d6abb8355d02` | +| swift-numerics | `0c0290ff6b24942dadb83a929ffaaa1481df04a2` | `1a34e387c2741b9f2c1ce5600de009b8523691a30ee37645f94381aff3c94151` | +| swift-syntax | `9de99a78f099e59caf2b2beec65a4c45d54b2081` | `dc68737684c6d5450752db7099946df63d06da6398e04106bb93cdca55c870f7` | +| swift-system | `7c6ad0fc39d0763e0b699210e4124afd5041c5df` | `8420cc1e7c14d1862d4e5fb43edfb377cc402fdf5c10ddb10371f1fd4a295515` | +| swift-transformers | `2fa33e1f5e7131a7fc64c28e6d161dcec0d24820` | `a28a68ab2af7a99be0f8c5f2373ce38727f2c1515eb7e9abeb3f65cdd6adedb0` | +| yyjson | `8b4a38dc994a110abaec8a400615567bd996105f` | `48a4074d1c0286bbcb42bf5b7d89ee54b6db1f5d9b92220b0ccdf807d6d29d44` | + +下一具体动作是补证真实 SwiftPM 离线初始化并完成完整 build。成功后交 Cursor 接原生审阅和 focus/privacy 检查,再做真实宿主验证。移动由原 owner 闭合 T0 并预约设备,manual return 不达标。本轮无 commit/push/PR/release。 + +SDLC checks pass,exit 0,2026-10-07T22:41:18.284319+00:00 至 2026-10-07T22:41:19.771720+00:00,日志 SHA256 `5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0`。已知自身旧命令 PID/PGID 残留 0。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/build-result-r4.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/build-result-r4.md new file mode 100644 index 00000000..56f45e14 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/build-result-r4.md @@ -0,0 +1,78 @@ +# Run4 真实 SwiftPM 构建证据 + +**Status:** draft +**Authorization:** 本轮整体授权持续;仅完整编译与确证错误修复,不代表产品验收。 +**Result:** interrupted,完整构建未通过。磁盘触发既有 10 GiB 保护后停止自身构建。没有编译错误证据,因此本轮没有源码或测试 fixture 修改。 + +工作目录 `RESEARCH_WORKTREE`,环境 Mac mini `DEVICE_BINDING_IN_IGNORED_LOCAL`。HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`。未委派、未下载工具/模型、未改 dependency/lockfile/权限/签名,未使用 production 未提交源码或缓存,未停止维护 daemon 或其他 Agent,未删除证据。无 commit/push/PR/release。 + +## 前置阅读与边界 + +已读仓库 AGENTS、SDLC README、istack AGENTS/agent-routing/t3-delegation、Ponytail FULL、spec/plan/verification/workflow、Run3 状态/清单与 b-r3 日志。使用 Unslop 与 explain-clearly 整理证据;用户禁止委派,直接完成报告。无 UI 制作或视觉 QA,本轮不启动 ANTI/原型审计。 + +Run3 的真实错误是 `DeliveryServices.swift:101:59` 在非隔离 autoclosure 引用 MainActor `OutputAnchor.text`。Run3 已将该校验移入 MainActor `SessionOutputState.remember`;其最终 emit-module 日志为空且通过,但使用 synthetic Bundle.module。这里保留该历史分类,不将它当作本轮真实 SwiftPM 通过。 + +已查相关 git 历史和实际契约/调用路径。单一 SessionDriver/SessionControl/VoiceSessionJob 持有同一 Session;selectionEdit 与 spoken selection replacement 复用 recipe 和 review,再走同一 deliver。review 保持 busy,停止/释放录音,Apply 绑定 session/proposal 身份并只唤醒一次,随后再核对 PID/target/lease/原文;Cancel/expiry 结束候选。lease 依赖 focus、AX identity、UTF-16 范围/选区和全文快照;现有 confirmed targetValue receipt 才保留原文/replacement/anchor;restore 消费一次 receipt 并 replaceAnchor 原文。编译不能证明这些不变量在真实宿主、普通听写、权限/隐私或焦点返回成立。本轮没有据此修改架构。 + +## 工具链、缓存与命令 + +Apple Swift 6.2.3,swift-driver 1.127.14.1,swiftlang-6.2.3.3.21,clang-1700.6.3.2,工具报告 target `arm64-apple-macosx28.0`。macOS SDK 26.2,路径 `/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk`。`xcodebuild -version` exit 1,活动 developer directory 为 `/Library/Developer/CommandLineTools`,需要完整 Xcode;没有修改 developer directory。`DEVELOPER_DIR/SDKROOT/SWIFT_EXEC/CLANG_MODULE_CACHE_PATH/SWIFTPM_MODULECACHE_OVERRIDE` 均未覆盖。 + +读了 Package.swift、ci-basic-checks.sh、test-industry-lexicons.sh、build-and-run.sh 与 build-app.sh。禁止的 build-and-run 未执行,其 pkill/签名/启动行为仍未触发;release 打包未执行。19 个依赖 checkout 及锁定 revision 均已在本地。无 binary target 下载声明,artifacts/prebuilts cache 初始为空;关闭 SwiftSyntax experimental prebuilts,避免其默认下载。mlx-swift 0.31.4 checkout/repository 已存在,但 workspace-state 尚未登记,真实 SwiftPM 由本地 repository 创建工作副本并登记。没有观察到 fetch/download 日志。依赖 checkout revision/状态、锁定 pins、工具 exit、资源 SHA 在 raw 环境证据中。 + +原 ci-basic-checks.sh 使用无离线参数的 `swift package describe`,行业词典脚本使用 `swift run UtterLexiconCheck`。不能对其默认解析/预编译下载行为提供所需约束,因此原脚本 not-run;没有替换 swift、改脚本或制造通过。Swift test not-run,完整 build 因空间保护中止,不能继续重型检查。 + +完整构建命令没有 target/product 限制,不用 parse、emit-module 或 Bundle.module shim 替代: + +```sh +swift build --skip-update --disable-automatic-resolution --disable-prefetching --disable-experimental-prebuilts --disable-index-store --jobs 2 +``` + +使用默认 debug、arm64 与 .build 缓存,关闭 index store 并限制 jobs 为 2。真实构建编译了 GenerateManual/GenerateDoccReference plugin、_NumericsShims、yyjson、_NIOBase64、_AtomicsShims,并复制 tokenizer JSON/PrivacyInfo.xcprivacy。停止时最后日志为 `[17/308] Write sources`。没有完成全部模块/产品链接或全部 Utter resource bundles,未生成可验收 OpenType 包。 + +构建开始 `2026-10-07T22:24:35.256062+00:00`,结束 `2026-10-07T22:25:22.997666+00:00`,子进程 PID/PGID `7166/7166`,supervisor PID `7163`。构建 exit `-2`,status `interrupted`;native tool session `49408` supervisor exit 0,仅表示监督/回收完成。 + +实际 preflight df 可用 `10832539648` bytes,约 `10.088589` GiB。每约 0.5 秒在自身普通命令内读取 df。最低 `10734473216` bytes,约 `9.997257` GiB,低于 `10737418240` bytes 保护线。`2026-10-07T22:25:22.901850+00:00` 对自身 PGID 发 SIGINT,返回 -2,remainingGroupProcesses 空。未重试同一空间阻塞;未删除或迁移缓存来扩预算。 + +构建日志 SHA256 `28023756db0943baae95ad3937460936d3851f3763695b3e74083ec86c7d837c`。 + +- `bash scripts/sdlc-checks.sh` pass,`2026-10-07T22:25:20.214662+00:00` 到 `2026-10-07T22:25:22.327098+00:00`,exit 0,PID/PGID `10037/10037`,日志 SHA256 `5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0`。 +- 首次受同一空间保护的 diff 命令 not-run;轻量只读 `git diff --check` 后续 pass,`2026-10-07T22:26:07.943952+00:00` 到 `2026-10-07T22:26:07.975197+00:00`,exit 0,空日志 SHA256 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。 +- `bash scripts/ci-basic-checks.sh` not-run,原命令缺少离线约束;行业词典未执行,不能声称 basic pass。 +- `swift test` not-run,空间不足且完整构建未完成。测试 conformance 没有真实 compiler 证据,不预修。 +- release、签名、native UI、权限/隐私、真实宿主/设备、普通听写/焦点、安全恢复、独立验收均 not-run。 + +## 精确源码与产物 + +Tracked binary diff SHA256 保持 `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`。18 文件 sorted compact JSON 清单 SHA256 保持 `f64965163a8c900c3a233dcd9ff6dd3f8133189378c27d4c813afa6f0c36c831`,包括两个未跟踪新 Swift 文件。before/after 内容逐文件相同。 + +完整 root source/resource/test manifest 覆盖 `Sources/`、`SourcesCLI/`、`Tests/`、Package 中行业词典/eval target 的脚本源码、`Package.swift`、`Package.resolved` 与 `scripts/resource-bundles.json`,共 787 文件,before/after 均为 SHA256 `4c5cf71ff0508d0d11a40e0aa5e3456239c81f31afb5960bc2e481115cb8f9f2`。完整逐文件哈希在 raw `full-source-manifest-before.json` 与 `full-source-manifest-after.json`,并非仅 tracked diff。19 个依赖 checkout 的 revision、Package manifest SHA 和工作树状态在 environment/status JSON;不将未完成依赖编译当通过。 + +18 文件本体 SHA256: + +- `Sources/UtterContracts/DeliveryServices.swift`: `e1d03e2882914048c6fef51538d4a029825c2e9e5618abac85cdb0e904657f33` +- `Sources/UtterContracts/Resources/en.lproj/Localizable.strings`: `c60b316ca09ec908196a2f265f02de290894a743bcf179ff71e0f97cb1244954` +- `Sources/UtterContracts/Resources/zh-Hans.lproj/Localizable.strings`: `52776c17de4a52d1f84d9cd2eb83dfcdf803d0fe9c806c3597b2b471df625410` +- `Sources/UtterContracts/SelectionEditProposal.swift`: `c742db8328f3df4b0a68fa4567fc8c8f606e46e2727cf545f10299be86fa9d76` +- `Sources/UtterContracts/SessionExecutionService.swift`: `ef3d80f03d6e86cc03aadda77bf25e6355abce81975eefc9b4726c6ade96c95b` +- `Sources/UtterContracts/SessionOutputStateService.swift`: `48582261ab50beadb8f5f356205992991bb117a10158d4509a4aed2b21b14fd1` +- `Sources/UtterContracts/SessionPresentation.swift`: `60fb70c1376733df9a3ed54c745c43a705881b1e4155007024852f36f01e23fc` +- `Sources/UtterMacServices/Native/NativeOutputBackend.swift`: `c658a8053b162b06fd4e82315f3bdb68b94807780950bcb4ddcbff3ce20698c2` +- `Sources/UtterMacServices/Native/NativeTargetLease.swift`: `7d1b9ce1c84e114cdd7f9fa4a6907ff43b3f03c332b04b997a3880f1e35654f1` +- `Sources/UtterMacServices/ScopedOutputService.swift`: `d80a1248368671b0c8478308527a4a1acffb7722793c0a27520eb7d761b2400d` +- `Sources/UtterPresentation/NativeDesktopHost+Actions.swift`: `09c8e7fa6b77a9f54b5b9656356f7f03875829b74196ebb6e618d5caf8c68012` +- `Sources/UtterPresentationContracts/AppState.swift`: `f7a1315564f04fbf2df3dbd55d894b52869b614ceed2fc80674b5176e932e86f` +- `Sources/UtterSession/Native/VoiceSessionJob+Editing.swift`: `5d4b835cf3e49b32a3cc153e098a9525bae0fa23a2db0d673414bad0c78f5ced` +- `Sources/UtterSession/Native/VoiceSessionJob+Review.swift`: `e15b4251e7d32c8f29e540797f0fbf122727ddae85b2f91f41b6ef76ecf1b118` +- `Sources/UtterSession/Native/VoiceSessionJob.swift`: `6a79230b9f8049989ea1b2e0bd4abe98bdbe1ba4d7567b2d69dc0eddff6ba9ab` +- `Sources/UtterSession/SessionControl.swift`: `6f952e846d6922af0e87e66908daa015fd240af0eda0bfaf4e091e816dc213c9` +- `Sources/UtterSession/SessionDriver.swift`: `5cbd15ee425e6dd15bc4fd27b03932226b8e7a93c39751843644d53536da64a1` +- `Sources/UtterSession/SessionOutputState.swift`: `21c5a6b1f0a4a5cccefe34ca7c99bcf13aeedaeb9c361e0f37dd71f37a265776` + +Raw ignored 证据统一在 `.local/voicecursor-research/run4-build/`:`build.log`、`build-result.json`、`sdlc.log`、`sdlc-result.json`、`diff-final.log`、`diff-final-result.json`、两套 before/after manifests、`environment-cache-resources.json`、`dependency-checkout-status.json`、`partial-build-artifacts.json`、`process-terminal.json` 与普通监督脚本 `supervise.py`。部分 build artifacts 保留于本 worktree `.build/arm64-apple-macosx/debug/`,不是 app/package 候选。未复制 production artifacts。 + +## 终态与后续 + +已核验已知 supervisor/build/SDLC PID 和进程组均不存在,同 worktree 无 swift/clang/linker 构建进程残留。单次 native 调用均 <=30 秒,没有 watcher/daemon/timer 或新增子 Agent。command 监督截止设为 22:27:40Z,早于本执行者目标 22:28:20Z、负责人预取消 22:28:30Z 与 hard native 截止 22:28:50Z。本报告不是 agent native completedAt,后者由负责人通过宿主核验;未写其独占 Run4-state/plan/workflow/verification。 + +下一轮在负责人解决真实可用空间后,从保留的真实 .build 缓存继续同一完整 build,只修真实 compiler 错误,再完整重编译及 swift test。原 basic 脚本须有明确的离线执行约束,不能以替代检查冒充。release 风格仍需完整 Xcode、既有签名 owner 和独立验证,不能据本轮 CLI SDK 证据放行。原生 UI 与 focus/privacy/普通听写、安全取消/恢复宿主用例及既有 HTML recapture/Close/Restore 缺口保留给后续负责人,本轮没有修原型。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/context-build-r10.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/context-build-r10.md new file mode 100644 index 00000000..34b7b9f5 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/context-build-r10.md @@ -0,0 +1,25 @@ +# Run10 — sandbox context diagnosis and changed cache precondition + +**Status:** partial retained; registration/build not accepted +**Authorization:** User explicitly continued the existing overall authorization. This is not product acceptance. + +The finite2×2 nested `/usr/bin/true` census retained the same exact inner profile throughout: + +| Outer profile | Baseline environment | Task environment | +|---|---:|---:| +| narrow | exit0 | exit0 | +| registration | exit71 | exit71 | + +Both failures report `sandbox_apply: Operation not permitted`. For this tested command, the result follows the outer profile rather than the changed environment. The specific failing rule and installed SwiftPM manifest context are still unproven. No profile change, inner-disable workaround, variable-isolation retry, resolve or compile was attempted. + +Fresh Data samples are about85.94–85.97GiB, above the unchanged10GiB floor. Existing external volume identity/APFS/writability matches, but BOTH historical Run5/Run6 task roots are absent. Cause/actor unknown; this run performed no cleanup. Therefore historical19 external mirrors/mappings are not currently usable. Original worktree `.build` still contains19 checkouts: all19 HEADs equal Package.resolved pins and git status is clean. This checks revision/cleanliness, not a new8254-file/submodule-content verification or real offline registration. + +Owner independently rehashed all846 source/script/resource files, with unchanged HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`, binarydiff SHA256 `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`, canonical manifest SHA256 `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`. All6 supervised command logs match recorded hashes; no known own process groups remain. Production worktree read-only: same HEAD,38tracked modifications/17untracked; nativeordinal27completed, activeRun null/pending0. Device availability was not checked or reserved; no production files copied. + +One Standard Codex child native31:46.726→35:24.619Z, interrupted217.893s, activeRun null/pending0/hasPendingfalse. Hard35:25 met by0.381s; target35:10 missed14.619s. Planned35:15 cancellation was issued approximately35:24, about9s late; do not report timely cancellation. Parent native starts00:27:30.405Z; actual completion is for root durable read, never artifact time. Delegation began after256s of parent preparation/compaction and left a small diagnosis window; no full build was started late. + +Initial preparation incorrectly required the absent historical cache root for a read-only probe and returned an empty assertion error. One precise guard correction allowed the finite read-only census, without creating directories or changing sandbox rules. Initial failure checkpoint is retained separately, but reconstructed from saved ledger/observations and labeled as such; it is not an untouched initial result file. A final child metadata command failed during closure; owner evidence/hash/native checks are authoritative for accepted scope. Same-cause generic sandbox workaround count remains2/2; no third workaround was performed. + +Next executable unit: use the current exact19 original checkouts; safely recreate only task-owned isolated cache/mirror scaffolding with freshly checked submodules, then isolate the actual outer-profile rule difference using retained inner protection and explicit file/network canaries. Capture installed SwiftPM manifest arguments/environment; perform one real offline registration only after the complete context works, then a separately budgeted full compile/basic/tests. No global config/auth/permission/billing changes or downloads. Native review/focus/privacy, independent real-host safety, signed Release and mobile original-owner real T0/device window remain authorized and unaccepted. No UI/device/commit/push/PR/release work occurred. + +Raw paths, volume UUID, task/clientRequest IDs, commands/environment, matrix, capacities and native snapshots are only in ignored `.local/voicecursor-research/run10-context/` and `Run10-state.json`. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/desktop-review-prototype.html b/docs/sdlc/changes/2026-10-08-voice-editing-research/desktop-review-prototype.html new file mode 100644 index 00000000..6603c26f --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/desktop-review-prototype.html @@ -0,0 +1,170 @@ + + + + + +编辑选区 · 审阅原型(模拟) + + + +
+

编辑选区:审阅候选

+

模拟原型:不使用麦克风、不连接宿主或模型。“录音”和“识别”只是按钮触发的固定演示数据。

+ +
+

+

+
+
原文(捕获的选区,确认前不变)
+
候选结果
+
+
识别到的口令
+
+
+ + + + + + + +
+
+

文本区按 Enter 无效果,不会提交;焦点在按钮上时 Enter 等同点击该按钮。Esc 取消并保留原文。

+
+ +
+

模拟宿主与夹具(仅供评审,非产品界面;无真实宿主、麦克风、网络、剪贴板)

+
模拟宿主文本(应用/恢复真实改写这里,[ ]为选区)

+
候选生成结果 + + +
+
选区内容 + +
+
点击应用时的宿主 + +
+
+
+
+ + + diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/implementation-result.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/implementation-result.md new file mode 100644 index 00000000..5dea4606 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/implementation-result.md @@ -0,0 +1,12 @@ +# Run 2 retained increment + +**Status:** draft +**Owner:** lead recovery after bounded Codex cancellation + +Base ed4234b41764be8346ae58cdc63011c8f26a0f1e; uncommitted diff SHA256 06424f638677956353ff65fcfef6745fdfa08fa9df7ce93f44be4f242df39d8b. No package candidate/hash. Six owner files changed, +55/-7: DeliveryServices, SessionOutputStateService, NativeOutputBackend, ScopedOutputService, VoiceSessionJob+Editing, SessionOutputState. + +SelectionEditReceipt carries operationID/originalSelection/replacement/anchor; DeliveryReceipt retains only accepted targetValue and matching operation/anchor. Native backend captures selection before existing transaction and attaches only after readback. Session remembers confirmed receipt; consume removes anchor before restore attempt. Selection undo uses replaceAnchor(originalSelection); ordinary insertion keeps existing empty undo. Replay ScopedOutput receipt strips anchors and new selectionEdit by default. + +Lead inspected exact diff and Swift frontend parse passed. Not typechecked/built, no real behavioral acceptance. SDLC passed. basic and swift-test skip-build each bounded8s, SIGTERM exit-15, terminal confirmed; lexicon check/dependency working copy incomplete. No release-style build or signed package; no device/private host/clipboard access. UI preview not wired into product; existing automatic spoken edit path still exists. Need pending-proposal integration before complete behavior can ship. + +Remaining code review: receipt validation original/replacement integrity, consume-on-failed-delivery semantics, lifetime of consumed operation set, focus-return/readback/secure fields, independent behavior E2E. No claim of product pass. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/intent.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/intent.md new file mode 100644 index 00000000..e6e67f3d --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/intent.md @@ -0,0 +1,38 @@ +# Intent: VoiceCursor 调研与 Utter 语音编辑 + +**Status:** draft +**Approved-by:** — +**Approved-date:** — +**Upstream:** 用户整体授权,根线程转达“都允许”;普通阶段可推进,验收未代签 +**Risk:** 后续选区写入、上下文隐私和签名发布按 High lane 审查 + +## Problem + +用户希望选中文字,用语音描述修改,并在原宿主改善原文。桌面已有相关处理能力,但当前选区编辑的预览和原文恢复边界尚未闭合;移动端的原位录音与返回仍受原生产线 T0 闸门约束。 + +## Outcome and scope + +本轮完成官方证据、当前实现和 Apple 平台边界核对,提供一个可审阅的行为建议和后续 workflow。研究结论见 [research](research.md),来源见 [source evidence](source-evidence.md)。本轮不创建产品 UI,不修改 Swift,不安装/注册/付费,不操作共享 iPad,不提交或发布。 + +下一候选范围建议先验证 macOS 普通可编辑纯文本选区。移动跨应用原位语音编辑须依赖生产负责人关闭录音、焦点返回和生命周期闸门,不以手动返回替代。 + +## Acceptance criteria + +- 每项竞品事实对应官方来源、获取日期和证据等级;明确未实测。 +- 核对新 HEAD、原工作区差异与最近 durable 游标,旧成功证据不能证明新方案。 +- 对照桌面和移动能力,并明确失败保护、确认和恢复原文的要求。 +- workflow 每阶段有负责人、依赖、产物、可检查验收、状态和 human gate。 +- 本轮到原生 startedAt 加 10 分钟前结束,子任务终态核对,无后台遗留。 + +## Current authorization (supersedes first-round pending decision) + +用户整体授权已到达。桌面首闭环与移动目标按 spec/plan/workflow 推进;未声称用户已经审阅尚未生成的设计,实际验证仍为独立闸门。上文“本轮仅研究”属于首轮历史范围。 + +## Current completion authorization — 2026-10-14 + +用户直接要求取消旧时间限制、将整体桌面与移动语音编辑完成并提交PR。当前任务范围是实际原生审阅/明确Apply/Cancel/一次原文恢复及各端安全流程,不止首轮研究。原始研究阶段限定保留为历史,不限制已授权本地实现与验证。用户随后确认iPad暂不可用,设备查询/预约/安装/启动暂停,真机验收延后;不将延期标为通过。 + + +## Combined Draft delivery authorization + +The user now explicitly requests publication after the exact mobile import and current writer settlement, with failed and pending checks disclosed. Continue fixes on this same Draft PR. This changes delivery order and does not approve verification, merge, release, or legal material. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/manifest-compat-r13.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/manifest-compat-r13.md new file mode 100644 index 00000000..090ec425 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/manifest-compat-r13.md @@ -0,0 +1,11 @@ +# Run13 protected root manifest verification + +Status: bounded manifest context PASS; no product/build pass. + +统一写入范围与目录布局后,保留explicit deny-network及SwiftPM实际inner,真实dump-package于13:32:14.064→13:32:27.941 exit0。Owner从真实日志重新解析JSON,OpenType/41targets与childJSON一致。2directnestedcontrols exit0;批准写入通过、taskdeniedwrite/IPv4loopback/POSIXshm均EPERM。仅测试这些操作;不宣称整个网络/内核边界已穷尽验证。 + +临时、scratch、clang、config/security/packagecache均位于taskmodulecache子树,outer只有与实际inner相同2写入subpaths,额外保留networkdeny。没有关闭inner、扩caller/OS权限、下载或改产品。Data诊断目录不冒充外盘构建验证。离线resolve/19依赖注册/fullbuild/basic/swifttest/UI/真实宿主/移动T0/签名Release均NOTRUN。 + +Owner10logs hashes全一致;846source/script/resource全部未变,准确SHA/hash见result.json/raw。没有已知ownPGID残留。Childnativecompleted13:29:16.579→13:34:38.466321.887s,active/pending0/hasPendingfalse;targetmiss28.466s,hard34:40met。planned34:20取消未执行,不宣称准时取消。Parentnativeordinal17 starts13:27:26.053、hard13:37:26.053,实际terminal由rootdurable读,artifact不代替native。Run12 actualparent618.001s/hardmiss18.001已纠正记录。 + +Next:沿此已验证布局,fresh检查真正复用的19pinned checkout/submodule内容和每个manifest策略,建立task-only本地映射,做一次真实offline registration;成功后另有充分预算的fullbuild/basic/tests。不能将根manifest通过推定全部依赖、完整产品、UI/宿主/移动/Release通过。整体授权持续,无普通新许可闸门,不自主下一run。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-registration-r6.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-registration-r6.md new file mode 100644 index 00000000..d710c5c6 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-registration-r6.md @@ -0,0 +1,54 @@ +# Run6 local Git preparation and offline-registration blocker + +**Status:** blocked +**Authorization:** Existing overall authorization covers this bounded preparation. This report does not represent product acceptance. + +All 19 exact local Git mirrors and working copies pass revision, Git metadata, clean state, recursive submodule and tracked-source checks. The complete 8254-file manifests match Run5, including symlink contents, totaling 145334442 bytes. SwiftPM offline registration, compiler placement, full build, basic checks, swift test and release-style build are **not-run**. Fresh Data stayed below the fixed 10737418240-byte floor. No SwiftPM or compiler executable was invoked, no recovery wait or disk/build retry occurred. + +The new `EXTERNAL_TASK_ROOT` passed fresh UUID, external APFS, mounted and writable checks, collision/symlink rejection and nine directory write/fsync/read/hash/device probes. Absolute paths, device identity and detailed mapping remain only in ignored raw. Task-private mirrors, scratch/checkouts, packagecache, Swift/Clang modulecache, temp, config, security and empty-template directories exist. File probes do not prove compiler placement or a populated SwiftPM cache. + +## Actual Git evidence + +Installed Git version is 2.50.1 Apple Git-155, with local/mirror/no-hardlinks clone options confirmed from installed help. Commands used `git clone --local --no-hardlinks --dissociate --template=EXTERNAL_TASK_ROOT/empty-template --mirror LOCAL_REPOSITORY TASK_MIRROR`, then local no-checkout cloning and `checkout --detach PIN`. MLX submodules were cloned from the already local source submodule Git objects to task mirrors, checked out at their pins and registered under their actual `.gitmodules` names. Recursive status confirms mlx ce45c52505c8158ea48d2a54e8caae05efd86bfe and mlx-c 0726ca922fc902c4c61ef9c27d94132be418e945. No alternates remain in task clones. Task mirror origins are local source repositories; working-copy and submodule origins point to task-local mirrors. + +Every managed Git command had `GIT_ALLOW_PROTOCOL=file`, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, `GIT_CONFIG_NOSYSTEM=1`, task-private `GIT_CONFIG_GLOBAL`, external `TMPDIR`, disabled fsmonitor and task-private hooks path. All clone arguments were absolute local repository paths. No fetch, remote submodule update, dependency/model download, global config, HOME or credentials change occurred. This is command/protocol evidence; there was no packet capture and no assertion about unrelated processes' networking. No workspace-state was copied or fabricated; no SwiftPM registration is claimed. + +| Identity | Exact revision | Revision/source/Git/clean/submodules | +| --- | --- | --- | +| ane-lm | `033472ec12ea796fc7ea4f8cefd7ed456f69900b` | pass | +| argmax-oss-swift | `25c62997041c134b03ca82731ce2f6fd2cae1eb9` | pass | +| eventsource | `a3a85a85214caf642abaa96ae664e4c772a59f6e` | pass | +| mlx-audio-swift | `d302a5c6080d2bb97bae38c7418f82abb76013b6` | pass | +| mlx-swift | `dc43e62d7055353c7f99fa071a4e71d29dfddc44` | pass | +| mlx-swift-lm | `bd4b7434e6bdb588c7ef55706ff8904cb7fd4c57` | pass | +| swift-argument-parser | `6a52f3251125d74daf04fcbd5e6f08a75d074382` | pass | +| swift-asn1 | `eb50cbd14606a9161cbc5d452f18797c90ef0bab` | pass | +| swift-atomics | `b601256eab081c0f92f059e12818ac1d4f178ff7` | pass | +| swift-collections | `fea17c02d767f46b23070fdfdacc28a03a39232a` | pass | +| swift-crypto | `1b6b2e274e85105bfa155183145a1dcfd63331f1` | pass | +| swift-huggingface | `b721959445b617d0bf03910b2b4aced345fd93bf` | pass | +| swift-jinja | `0b67ecb79139f6addef8699eff3622808aa6c7dc` | pass | +| swift-nio | `57c0a08a331aaea9f5d7a932ad94ef43be942a95` | pass | +| swift-numerics | `0c0290ff6b24942dadb83a929ffaaa1481df04a2` | pass | +| swift-syntax | `9de99a78f099e59caf2b2beec65a4c45d54b2081` | pass | +| swift-system | `7c6ad0fc39d0763e0b699210e4124afd5041c5df` | pass | +| swift-transformers | `2fa33e1f5e7131a7fc64c28e6d161dcec0d24820` | pass | +| yyjson | `8b4a38dc994a110abaec8a400615567bd996105f` | pass | + +Original dependency Git metadata was hashed before and after, including bare repositories, checkout Git directories and submodule metadata. 1426 metadata files matched, canonical SHA256 `bf5967b0393dd394c7c0b61a14d316ad47281695e25ce5ee15b7e8a0a02c0185`. The root HEAD remains `ed4234b41764be8346ae58cdc63011c8f26a0f1e`, tracked binary diff SHA256 `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`, and the complete 846-file source/script/resource manifest SHA256 `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`. Package.swift and Package.resolved are unchanged. Run5 snapshots and evidence remain untouched. + +## Data and terminal evidence + +- 2026-10-07T22:54:06.465926+00:00: 10287767552 bytes, 9.581230 GiB. +- 2026-10-07T22:54:46.786570+00:00: 10281578496 bytes, 9.575466 GiB. +- 2026-10-07T22:55:18.471362+00:00: 10283343872 bytes, 9.577110 GiB. + +Preparation supervisor attempt 1 ran 2026-10-07T22:54:06.398316Z to22:54:18.191360Z, exit1. The actual error was task submodule registration under a directory path rather than its `.gitmodules` name; recursive status correctly reported unregistered submodules. Failure JSON/logs remain preserved. One same-run recovery fixed the mapping and reused only the validated task root, without deleting files. Attempt 2 ran 2026-10-07T22:54:46.716986+00:00 to 2026-10-07T22:55:18.471387+00:00, exit0. All managed command PGIDs were bounded to the unchanged22:56:40Z internal deadline and reaped, with zero remaining processes. The owner's command deadline remains22:56:50Z. Native agent completion must be read by the parent after return; artifact timestamps are not native terminal evidence. + +Raw `.local/voicecursor-research/run6-offline/command-records.json` records every actual invocation/environment/start/end/exit/PID/PGID/log SHA256. `dependencies.json`, 19 source manifests, full-source-manifest-after.json, original-git-metadata-before/after.json, metadata-preservation-summary.json, failure JSON, external-mapping.json, process-terminal.json and evidence-hashes.json retain the evidence. No new Swift/help/compiler probe was executed below the floor; Run5 Swift/tool help is historical evidence only, not a fresh tool-version binding or registration result. + +Next: fresh Data>=10GiB plus enough supervised time, then real offline SwiftPM registration using these local repositories with isolated config/cache/security/scratch/temp. Verify SwiftPM reads and accepts the exact identities and revisions before a single complete build, with at least three minutes remaining and continuous10GiB PGID protection. Compiler arguments and actual written cache/temp artifacts still need proof. Complete build precedes Cursor native review/focus/privacy and independent real-host checks. Mobile remains the original owner's T0 and device reservation; no manual-return acceptance. No child delegation, UI, host, device, production-workspace, signing, commit, push, PR or release work occurred. + +sdlc passed, exit0, 2026-10-07T22:56:16.440173+00:00 to 2026-10-07T22:56:18.068540+00:00, log SHA256 `5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0`. + +diff-check passed, exit0, 2026-10-07T22:56:18.069206+00:00 to 2026-10-07T22:56:18.109209+00:00, log SHA256 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-unit-r9.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-unit-r9.md new file mode 100644 index 00000000..196f1569 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/offline-unit-r9.md @@ -0,0 +1,43 @@ +# Offline unit Run9 + +Status: blocked + +The bounded protection unit passed. Actual offline dependency registration remains blocked. No resolve or full build ran, and no SwiftPM manifest protection was disabled. The child created no subagents. + +## Current binding and capacity + +HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`. Parent binary diff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83` is the entry binding, not an independently recalculated final diff. The child rehashed all 846 parent-listed source files at 2026-10-08T01:31:39.255103+00:00; no file changed. The parent source manifest is `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`. Package and lock hashes are in the ignored source-after record. + +The child entry measured 11374936064 free Data bytes at 01:27:22.813846Z. Every guarded operation persisted its timestamp and byte count before testing the 10737418240-byte floor. Continuous samples accompanied supervised commands. Minimum retained sample was 11344375808 bytes; no floor failure occurred. Fresh external volume UUID and mount point matched before any external canary write. Absolute paths and UUID evidence stay in ignored files. + +## Verified protection and failure + +The profile reproduces the public SwiftPM default deny policy, imports system.sb, allows file reads and process execution, and grants the two documented Mach services. The manifest canary grants only its two cache directories. A separate outer registration profile grants only the existing task scratch, packagecache, modulecache, clangcache and temp directories. Both profiles explicitly deny network access. + +The unsandboxed control could write task files and create task POSIX shared memory. Its loopback TCP attempt returned connection refused, errno 61; this confirms the request reached the network stack, not that a service accepted it. Under each candidate profile, approved cache writes succeeded; task writes outside those approved directories, loopback networking and POSIX shared memory returned EPERM, errno 1. All canary assertions passed. These tests cover those operations, not an exhaustive sandbox audit. + +Nested sandbox execution under the narrow manifest profile succeeded at 01:29:49Z. Under the actual registration profile and task cache/TMPDIR environment, the native nested probe exited 71 at 01:31:05Z with `sandbox-exec: sandbox_apply: Operation not permitted`. This establishes a context-specific compatibility failure. It does not establish general OS refusal or a need to increase permissions. The profile and environment differences were not isolated further within the deadline. + +The outer registration protection would supplement the original SwiftPM inner manifest sandbox. No --disable-sandbox option was used. Because the exact registration-context nested probe failed, the conditional resolve never started. Actual correction resolve attempts remain zero. + +## Installed help and primary evidence + +Live xcrun reported Apple Swift 6.2.3, swiftlang-6.2.3.3.21, clang-1700.6.3.2, target arm64-apple-macosx28.0. Installed package and resolve help were retained. Presence of --disable-sandbox in help provides no authorization to remove protection. + +Public [SwiftPM Sandbox source](https://raw.githubusercontent.com/swiftlang/swift-package-manager/swift-6.2.3-RELEASE/Sources/Basics/Sandbox.swift) describes the default policy and writable directory rules. [ManifestLoader](https://raw.githubusercontent.com/swiftlang/swift-package-manager/swift-6.2.3-RELEASE/Sources/PackageLoading/ManifestLoader.swift) selects strictness by tools version and grants ManifestLoading/module-cache writes. Exact deployed Apple patch equivalence remains unproven. Retaining its native protection avoids claiming that a replacement profile is equivalent to an unseen Apple patch. + +## Retained artifacts and process recovery + +Ignored evidence is under `.local/voicecursor-research/run9-offline/`: `child-commands.json`, `child-disk.jsonl`, `child-canary-result.json`, `child-registration.json`, `child-result.json`, both sandbox profiles, both canary programs, command stdout/stderr logs, live catalog/primary evidence, volume evidence and `child-source-after.json`. All 10 supervised command logs match their retained SHA-256 values. They record argv, task environment overrides, start/end, exit and PID/PGID. Initial instruction/setup reads were tool-recorded, but do not have this complete supervisor ledger; no claim of complete initial stdout capture is made. + +Every owned supervised process group was reaped. Final process inspection found 0 remaining owned processes. There are no child-owned delegated runs. The owner must record the real T3 native task final state after this turn returns. The child cannot independently certify its own host completed timestamp. + +Only two external task cache canary files were written. Prior 19 Git copies and mirror mappings were reused; no original dependency, product source, lock, global config, auth, device, billing, release or owner-exclusive result file changed. The actual workspace registration of all 19 identities/revisions is not proven. + +Full build was excluded after its three-minute window closed at 01:28:50Z. SDLC/basic checks, swift test, release-style build, native UI, real host and mobile T0 were not run by this child. Existing approval fields were not changed. Overall execution authorization does not constitute acceptance. + +## Minimum remaining requirement + +Capture and review the installed SwiftPM manifest sandbox argv and environment in the exact registration context. Isolate why the nested default probe succeeds with the narrow profile but fails with the registration profile and task environment. A supported execution context must retain original inner protection and prove the complete outer boundary before one real offline resolve. Then inspect actual workspace-state for all 19 pinned identities/revisions. No permission, proxy, transport or sandbox bypass is proposed. No new run was created. + +Owner feedback 1, 2 and 3 was read. By the final checkpoint there was insufficient supervised time to validate a further conditional path and resolve before 01:31:50Z. The source-bound canary evidence and failed context probe remain reviewable. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/pending-behavior-check.py b/docs/sdlc/changes/2026-10-08-voice-editing-research/pending-behavior-check.py new file mode 100644 index 00000000..fbb96038 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/pending-behavior-check.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Run actual review/control sources with dependency-free synthetic boundaries.""" +import subprocess,tempfile +from pathlib import Path +root=Path(__file__).resolve().parents[4] +stubs='''import Foundation + enum SessionExecutionPhase { case processing, delivering } + enum SessionStage { case processing } + enum IntegrationError: Error { case invalidSessionState } + enum DeliveryError: Error { case invalidTarget } + @MainActor protocol SessionJobControl: AnyObject { var isCurrent: Bool { get }; func waitForSelectionEditDecision(_ p: SelectionEditProposal) async throws } + @MainActor class SessionTiming { func begin(_ s: SessionStage)->UUID { UUID() }; func end(_ id: UUID) {} } + @MainActor protocol OutputTargetLease: AnyObject { var id: UUID { get }; var processIdentifier: Int32 { get }; var selectedText: String? { get }; var isCurrent: Bool { get } } + @MainActor final class Target: OutputTargetLease { let id=UUID();let processIdentifier:Int32=12;var selectedText:String?="家👨‍👩‍👧‍👦é\\n原文";var isCurrent=true } + @MainActor final class Recording { var stopped=false;var closed=false;func stopCapture() async { stopped=true };func close() async { closed=true } } + @MainActor final class Engine { func cancelListening() {};func drainRecognition() async {} } + @MainActor final class Callbacks { func drain() async {} } + struct Intent { let id=UUID() } + @MainActor final class VoiceSessionJob { let intent=Intent();var recording:Recording?;var engine:Engine?;let callbacks=Callbacks();func check(_ c:any SessionJobControl) throws { try Task.checkCancellation();if !c.isCurrent { throw CancellationError() } } } +''' +runner=''' +@main struct Check { + @MainActor static func main() async throws { + for action in ["apply","cancel","stale","empty","expired","replaced"] { + let job=VoiceSessionJob();let target=Target();let original=target.selectedText!;let recording=Recording();job.recording=recording + var control:SessionControl!;var current=true;var writes=0 + control=SessionControl(isCurrent:{ current },cancel:{ control.revoke() },update:{ _,_ in }) + let task=Task { @MainActor in try await job.reviewSelectionEdit(action=="empty" ? " " : "新👩🏽‍💻文",instruction:"改简短",target:target,control:control);writes += 1 } + if action=="empty" { do { try await task.value;fatalError("empty accepted") } catch {};precondition(writes==0);continue } + for _ in 0..<1000 { if control.selectionEditProposal != nil { break };await Task.yield() } + let proposal=control.selectionEditProposal!;precondition(proposal.originalSelection==original && proposal.recognizedInstruction=="改简短" && writes==0 && recording.stopped && recording.closed) + precondition(!control.applySelectionEdit(UUID())) + if action=="cancel" { control.revoke() } + else if action=="replaced" { current=false;precondition(!control.applySelectionEdit(proposal.id));control.revoke() } + else { if action=="stale" { target.isCurrent=false };precondition(control.applySelectionEdit(proposal.id));precondition(!control.applySelectionEdit(proposal.id)) } + do { try await task.value;precondition(action=="apply" || action=="expired") } catch { precondition(action=="cancel" || action=="stale" || action=="replaced") } + precondition(writes == ((action=="apply" || action=="expired") ? 1 : 0));precondition(target.selectedText==original) + } + var control:SessionControl!;control=SessionControl(isCurrent:{true},cancel:{control.revoke()},update:{_,_ in}) + let expired=SelectionEditProposal(sessionID:UUID(),targetID:UUID(),processIdentifier:12,originalSelection:"a",recognizedInstruction:"b",candidate:"c",expiresAt:Date().addingTimeInterval(-1)) + do { try await control.waitForSelectionEditDecision(expired);fatalError("expired accepted") } catch {} + let expiring=SelectionEditProposal(sessionID:UUID(),targetID:UUID(),processIdentifier:12,originalSelection:"a",recognizedInstruction:"b",candidate:"c",expiresAt:Date().addingTimeInterval(0.02)) + do { try await control.waitForSelectionEditDecision(expiring);fatalError("timeout accepted") } catch {} + precondition(control.selectionEditProposal==nil) + print("PASS: actual review/control pending, exact Unicode, microphone drain, apply once, cancel, stale, replaced, empty, expiry") + } +} +''' +sources=[] +for f in ['Sources/UtterContracts/SelectionEditProposal.swift','Sources/UtterSession/SessionControl.swift','Sources/UtterSession/Native/VoiceSessionJob+Review.swift']: + s=(root/f).read_text();s='\n'.join(l for l in s.splitlines() if not l.startswith('import Utter'));s=s.replace('package ','');sources.append(s) +with tempfile.TemporaryDirectory(prefix='utter-review-') as d: + p=Path(d);(p/'check.swift').write_text(stubs+'\n'.join(sources)+runner) + subprocess.run(['swiftc','-parse-as-library','-swift-version','5',str(p/'check.swift'),'-o',str(p/'check')],check=True,timeout=15) + subprocess.run([str(p/'check')],check=True,timeout=5) diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/plan.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/plan.md new file mode 100644 index 00000000..ddec8d55 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/plan.md @@ -0,0 +1,121 @@ +# Plan: 后续 build and ship 候选工作流 + +**Status:** draft +**Approved-by:** — +**Approved-date:** — +**Upstream:** [spec](spec.md) + +**Authorization:** 用户整体授权,2026-10-08;普通阶段可继续,未声称用户已审阅本方案。实际验收仍待完成。 + +## Current direct-edit contract, 2026-10-17 + +The user explicitly removes the candidate preview and Apply step, requests keyboard operation, and retains the original HUD appearance. This supersedes earlier explicit-Apply desktop requirements below. It is user authorization for this change, not human acceptance of an unseen result. + +Desktop selected voice commands generate and validate inside the existing Session, drain capture, revalidate the original target/selection/document, then commit once through the existing output transaction. The popup shows recording/processing/result status and one-use Restore only; it never displays selected original, generated candidate or an Apply control. No simulated button press or second session/output pipeline is used. Invalid/empty/model-error/stale/uncertain paths retain existing protections and never blind retry. Selected content stays ephemeral even without a review suspension. + +Use the existing voice trigger/release to finish capture. Register Esc only during an eligible local selected session and Command-Option-Z only for its current confirmed restoration receipt. Bind each registration to its exact session/operation, reject queued/stale events, unregister when hidden/ineligible/closed, and revalidate before action. Preserve host Command-Z. Registration conflicts fail closed and cannot be advertised as available. Native keyboard registrations use the already-installed macOS API without prompts or global configuration changes. Remote-provider disclosure before sending selected text remains distinct from Apply and retains explicit one-use consent. + +Verify real Session automatic single delivery, cancel while generation drains, stale selection/focus, Unicode, empty/error output, uncertainty, temporary-content retention, and one-use restoration. Render the actual compact production HUD in English/Chinese and both appearances; prove long original/candidate text is absent and controls fit narrow width. Verify native event decoding/registration lifecycle with owned local event fixtures; locked-host global dispatch, actual VoiceOver and device acceptance remain open. Independent review and rollback remain required. Roll back this increment with a normal revert, without resetting other changes or user data. Mobile's existing proposal protocol stays supported pending its separately verified platform adapter; no device or original-owner workspace actions. + +## Current delivery plan — 2026-10-14 + +用户最新明确要求取消时间上限、完成整体功能后提交 PR。旧轮次截止和原型计划以下作为历史证据保留,不是当前执行限制;普通阶段已整体授权,实际通过仍须证据。 + +1. Codex runtime/build owner独占Contracts/Session/MacServices/AppState与行为测试,闭合一次候选确认/取消、精确receipt恢复、失效/误识别/网络/uncertain及普通听写隐私保护。真实19pinned本地依赖注册、完整SwiftPM/basic/tests;不削弱sandbox或网络/10GiB保护。 +2. Cursor独占原生Presentation与双语文案,消费同一Session和typedrestoreAPI;先紧凑交互契约,再真实原生渲染/点击/完整ANTI审计,HTML历史原型不作为产品验收。 +3. 原移动owner独占平台glue和per-sceneT0,审阅精确共享API/hash后受控集成。用户确认iPad暂不可用:目前仅本地开发,不查询/预约/安装/启动设备;真机验收延后。根协调者维护设备窗口。 +4. 当前HEAD/main分叉6/10,研究侧六提交引入移动代码;两端快照删除形状不代表main删除已集成移动代码。共享API存在SessionJobControl.stop移除冲突。各writer结束后由负责人普通merge并逐项协调冲突,不reset/rebase/force、不复制原工作树。所有完整检查重新绑定整合后的源码与包哈希。 +5. 完整构建后独立验收TextEdit和浏览器选区语音编辑、Cancel零写入、一次Apply/恢复、stale/Unicode/剪贴板/失焦/uncertain/模型失败、真实录音与既有权限/隐私。签名与MetalRelease风格构建单独验收,不能以compile通过替代。 +6. 合格候选正常commit/push/PR,立即link_pull_request,核对remoteSHA与linkedPR。CI以watch_pull_request原生事件监控;交回用户前unwatch。不以当前整体授权声称未验证版本可发布。 + +当前父执行无旧10分钟cap,所有自有命令仍有独立timeout/PGID/floor保护,任务持续监督到真实终态。具体taskId与命令证据见ignored completion-state及workflow。 + + +1. 本线程刷新 HEAD/原线 durable 状态,纠正实际 native 时间;只读原工作区,协调移动 T0/设备与单一集成 owner。 +2. Cursor Sonnet 5.5 / 300K / High 独占 HTML 交互原型与设计说明,本线程渲染、点击并验收;无 Fast/1M/层层委派。 +3. Codex GPT-6.1 Sol / Xhigh / Standard 在既有 owner 内先补明确选区 receipt:originalSelection/replacement/resulting anchor,确认后保留,既有 anchored output 一次恢复。此增量不冒充完整审阅流程已可用。 +4. 后续将 pending proposal 接入现有 Session 生命周期与 PresentationContracts;Cursor 实现 native 审阅 UI。精确原宿主焦点返回需证明,不绕过 lease。独立验收者与实现者分离。 +5. 对实际 diff 执行 SDLC/basic/swift 检查及 Release 风格构建;真实 TextEdit/浏览器验证 spec 各失败保护。留 UI 前后截图、失败、权限隐私和确切源码/包哈希;有界命令,到时 native 取消并核验终态。 +6. 原生产负责人归属核对后受控整合增量,再按集成 SHA/包哈希重验;不复制/提交其未提交工作。 +7. 移动先闭合 T0 自动返回、焦点、完整上屏与生命周期,然后预约 iPad 并独立验证 iPhone/键盘选区边界;移动不删除 scope。 +8. Release owner 准备配置签名、适用 notarization、权限/隐私、Archive/export/checksum/提交材料;独立验收确切候选后正常 commit/push/PR/既有发布,不 force/reset/rebase/代签法务。PR 立即 link_pull_request,watch_pull_request 后结束轮次。 + +依赖、负责人、产物、验收与状态见 workflow.json。尚未通过的 native UI、焦点返回、真实宿主、完整测试/签名、独立验收及移动真机都是真实闸门,不以整体授权替代。 + +Run 2 native start 2026-10-07T21:55:56.728Z;22:03:56.728Z 停止扩展;22:05:56.728Z 前终止本轮。子任务截止22:02:45Z,未完成则 native取消/核验/保留现场;无无人监管后台工作。 + +## Run 3 bounded implementation and next verification unit + +用户整体授权持续有效,普通阶段无需再待同一许可。本轮A仅修既有HTML模拟原型事务防护,Cursor独占原型/ui-design/ui-audit;B由Codex独占Session/Contracts/lease与动作桥,复用同一录音和输出链。负责人只审查与记录,不写产品UI。 + +候选在当前Session保持busy,snapshot公开originalSelection、recognizedInstruction、candidate及精确session/proposal/target身份;当前实现用processing + selectionEditProposal表示待审,不以returnedText结算成功。候选前释放麦克风,120秒过期或取消结束同一Session;Apply只能一次唤醒原Job,随后重复核对target lease才进入原事务。Cancel不准备输出或动剪贴板。NativeDesktopHost动作桥需要由后续Cursor原生审阅视图真实调用,未接UI时不能作为可用候选发布。 + +下一单元为有充分预算的完整编译/必要修复,先复核Data可用>=10GiB,运行basic/Swift tests与release-style构建,记录准确源码manifest与包哈希。编译通过后由Cursor接现有presentation原生视图,审阅焦点不能使目标失效或偷换目标;使用非激活面板或经验证的焦点契约,不能绕过lease。随后独立验收真实Mac原生输入框与浏览器可编辑区域、Unicode、无写入Cancel、重复Apply、stale/焦点、剪贴板所有权、uncertain无重试和原文恢复。移动仍由原owner先闭合T0并预约设备,不能以手动返回代替自动返回。签名Release、隐私、独立验收及发布前置仍分别记录。 + +## Run 4 executable compile unit + +唯一目标是真实完整SwiftPM工程编译与确证集成错误修复。一个T3-owned Codex Sol/Xhigh/Standard执行者,禁止再委派;负责人独占编排/验收文档,执行者独占必要源码/失败协议fixture和build-result-r4.md。继承整体授权,未代签任何实际验收。 + +先核对>=10GiB Data可用和本地依赖;不新下载、不删除证据、不停止其他Agent。真实swift build覆盖产品/依赖/资源;basic脚本作用先读再实际执行,不能用parse或synthetic资源shim替代。明确日志起止/exit/源码manifest,错误修复后重编译。足够预算和空间才运行swift test,否则写未运行。命令与child在22:28:50Z前native结束,22:28:30Z主动取消窗口;父22:29:05.508Z停止扩展,目标22:30:35.508Z前native收尾,硬截止22:31:05.508Z。 + +不做第三次HTML审计、不改UI/移动T0/设备/原线。现存原型丢输入/范围缺口转为原生review验收用例;后续Cursor原生UI消费和独立真实宿主单元均在授权队列中,不是新human许可闸门。完整编译不证明安全替换、Cancel/恢复、隐私、焦点或生产就绪。 + + +### Run4 next concrete compile unit + +Full build remains interrupted by Data disk protection, not a confirmed code defect. Use existing `EXTERNAL_VOLUME` isolated scratch/module caches only after checking safe placement and exact locally pinned dependency reuse; no new download, deletion, daemon/permission change. Run full SwiftPM compilation, then basic and complete tests with sufficient budget. Native review UI and real-host proof stay behind compile completion; mobile original-owner T0/device coordination remains in scope. This queue is authorized, not a new human approval gate. + + +### Run5 bounded external-cache preparation + +Owner: one T3-owned Codex executor; root coordinates acceptance only. Prepare isolated external scratch/Swift and Clang module caches/temp and exact locally pinned dependencies. Verify mounted volume identity and write/read hashes; record device mapping only in ignored raw. Recheck source/dependency hashes. Data >=10 GiB remains mandatory before and during any complete build. Below threshold: retain preparation and precise blocker, no cleanup, no compile probe or wait loop. Only one complete build if threshold recovers and sufficient budget remains. No UI/devices/release. Parent native starts22:34:50.880Z; expansion stops22:42:50.880Z; native target22:44:20.880Z/hard22:44:50.880Z. Child native hard22:42:15Z, proactive stop22:41:55Z. + + +### After Run5: exact offline registration and full compile + +Accepted: seven external file probes and19 exact source snapshots with submodules. Not ready as SwiftPM Git working copies. Next owner is one bounded Codex executor: verify fresh Data floor and source hashes; reconstruct task-owned local Git metadata/mirrors from already verified local repositories, preserving all original files; prove real offline SwiftPM dependency registration without changing Package/lock/global config or downloading. Then run one complete build using configured external scratch/modulecache/temp and10GiB Data supervision. Record real compiler arguments/files and exact logs; do not claim configuration-only drafts suffice. Build success precedes basic/tests and Cursor native review, real-host safety, mobile T0, signed release acceptance. + + +### Run6 bounded exact Git metadata unit + +One T3-owned Codex executor exclusively prepares a new EXTERNAL_TASK_ROOT and real local Git working copies/mirrors with exact pinned and submodule revisions, preserving Run5 snapshots and original dependencies. Source/hash equivalence and Git cleanliness are separate from actual SwiftPM registration. Every SwiftPM/compiler/build action requires fresh Data>=10GiB and continuous floor protection; old recovered sample is stale. Only one complete build after registration and adequate supervision window. No Data cleanup, global settings, network dependency download, UI/devices/production edits/release. Parent starts22:50:25.816Z; stop expansion22:58:25.816Z; native target22:59:55.816Z/hard23:00:25.816Z. Child target22:57:15/hard22:57:45; proactive stop22:57:25; commands stop22:56:50. + + +### After Run6: space-gated real offline registration + +19 task-owned real Git mirrors/working copies and exact submodules now accepted; preserve and reuse them. Actual SwiftPM registration remains not run. Only after a fresh Data>=10GiB window, bind current installed Swift/help evidence and isolated paths, configure task-local package mirrors or carefully validated seed state, prove real SwiftPM acceptance of all exact dependencies without download, then one complete build under live10GiB guard and adequate time. Do not recreate metadata or repeat blocked build runs while space remains below the floor. This is an execution dependency, not a new ordinary approval gate. + + +### Run10 explicit continuation: exact profile/environment diagnosis + +User said 继续 on2026-10-11; overall authorization persists and does not constitute acceptance. Fresh Data is now above10GiB, with no cleanup or settings change by this thread. Preserve source and exact local dependencies. One bounded T3-owned Codex performs a finite2×2 census using the two actual Run9 outer profiles and baseline/task environments, keeping the same inner sandbox. Persist premise and rerunnable matrix before any correction; isolate a single variable only with evidence. No third blind workaround, disabling inner protection, UI/device work or rushed complete build. Actual registration/full compile still follows proven safe context with a separate adequate supervision window; native review/focus/privacy, real-host safety and mobile original-owner T0 remain authorized later units. + +Parent native starts00:27:30.405Z; target00:37:00.405Z/hard00:37:30.405Z. Child command stop00:34:55Z, proactive owner cancellation00:35:15Z if active; hard native00:35:25Z. All known command groups and actual child terminal must be collected; artifact closure never stands in for native terminal. + + +### Run11 specific named-rule unit + +Root accepted only Run10 finite diagnosis; user explicitly authorizes this subsequent bounded unit. One supervised T3-owned Standard Codex isolates named file-write/context differences under retained actual inner protection. No permissive profile, global permissions/config, blind third workaround, product/UI/device edits or dependency copies before necessity. Finite rerunnable rule census and file/network canaries precede actual installed manifest argument evidence and at mostone offline resolve. Full compilation/basic/tests is a separate later budgeted unit. Historical external roots are absent, exact original19 checkouts are available; newly reused dependencies need fresh submodule/content proof. + +Parent native00:40:17.030Z, stop expansion00:48:17.030Z; native target00:49:47.030Z/hard00:50:17.030Z. Child commandsstop00:47:20Z, native target00:47:50Z, cancellation checkpoint00:48:00Z/hard00:48:20Z. Owner records and verifies actual task/native/process terminal, noartifact timestamp substitution. Scope, dependencies, owner, outputs and acceptance are in workflow.json. + + +## Current Draft delivery order + +Publish the frozen combined source as a Draft after the controlled mobile whitelist import and real writer settlement. Disclose fixture and loader failures, earlier source-specific checks, locked-host and unavailable-device limits. Continue the confirmed fixture repair, SDK loader evidence, concrete native UI follow-up, complete final checks, and independent acceptance on the same PR. Normal commit and push only; link the PR immediately and use native check events. Do not update historical PR121. Human acceptance and protected production gates stay pending. + + +## Native UX completion in current Codex, 2026-10-16 + +The user explicitly removes the T3 execution dependency and authorizes safe deletion of proven regenerable caches, retaining source, models, user data and all verification evidence. Current Codex owns this finite completion; no new T3 thread, watcher, timer, credentials or permissions. Existing overall implementation/PR authorization remains, with no fabricated human acceptance. Preserve the existing native UX, localization, materials, action order and nonactivating target policy. + +Verify the real production SelectionReviewView in native NSPanel at280/320/420-point widths, English/Chinese and light/dark. Use public native document scrolling to prove both original and candidate ends can be reached, reverse scroll returns, and footer controls remain visible. Preserve failed synthetic-wheel diagnostics separately: a windowNumber0 event is not physical-wheel proof. Verify supplied accessibility order, localized labels, disabled/stale and once-only actions; label structural fallback explicitly and keep all of this separate from spoken VoiceOver navigation. Extend native integrated Apply/Cancel coverage and repair only reproduced defects. No model, microphone, external host, device or global VoiceOver/auth/settings operation. + +Use the existing19 pinned local dependencies and native SwiftPM sandbox; fresh mutable build epochs and module/temp caches on the task-owned external volume, with the unchanged10GiB Data floor. Seal complete products before guarded runtime use; do not mutate old sealed bundles or retry the exhausted SwiftTesting-loader workaround. Run relevant native/owner cases, basic/SDLC and meaningful full checks in the supported context, independently inspect actual screenshots and final diff, then commit/push to PR124 and verify remote SHA/CI. Record scoped passes separately from real host/device/signing/production acceptance. + +## Current Codex HUD correction, 2026-10-17 + +1. Compare pre-feature native HUD/source with review. Reuse its surface, circular button renderer and typography; remove the separate review button style and large form treatment. +2. Rebuild the real SwiftPM test bundle with the retained native build sandbox, exact19localpins, external scratch and continuous10GiBDatafloor. Preserve all earlier frozen artifacts. +3. Exercise native Apply/Cancel/expiry/repeat/Restore/remote consent, and compare actual recording/review button geometry and short/long native renders in both languages and appearances. Retain source, binary and image hashes. +4. Complete the applicable ANTI_SLOP audit, independent review, SDLC/basic/diff checks; update the existing DraftPR124. Actual external host, physical wheel, spoken VoiceOver and mobiledevice gates remain open. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/platform-evidence.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/platform-evidence.md new file mode 100644 index 00000000..fe8866e7 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/platform-evidence.md @@ -0,0 +1,73 @@ +# Apple 平台边界与 Utter 现有所有者 + +Status: pending approval +Date: 2026-10-08 Asia/Singapore +Evidence-acquired: 2026-10-07 UTC, 21:11–21:16 +Baseline: ed4234b41764be8346ae58cdc63011c8f26a0f1e +Scope: 独立只读调研;父线程验收;没有实现或批准后续阶段。 + +## 结论 + +macOS 已有可复用的目标租约与交付事务,但 AX 属性存在不等于宿主支持原位替换。特别是本机 Apple SDK 的公共头文件将 `AXSelectedText` 标为不可写,选区范围标为可写。现有直接写选中文本路径依赖运行时探测,仍需逐宿主验证。iOS/iPadOS 提供当前输入对象的代理编辑能力,不能据此承诺任意全文范围、完整上下文、原子替换或系统撤销。全访问不会授予键盘扩展麦克风能力。 + +## 证据分类与获取 + +- **D-current**:本次获取的 Apple 当前在线 API/文章。公开浏览器实际打开文本交互文章、AX 头文件集合和可写性探测文档,并读取渲染正文。原始 DOM 文本在 `.local/voicecursor-research/platform/browser-*.json`。 +- **D-archive**:Apple 明示不再更新的归档资料,仅作补充,不能代表当前版本全部行为。 +- **D-sdk**:本机 Xcode 随附公共 SDK 头文件,证明头文件定义,不证明宿主表现。摘录在 `sdk-ax-attributes.txt`。 +- **S**:上述 HEAD 的源码静态追踪。**R**:真实宿主/设备运行证据,本次没有。 +- 公开文档副本与 HTTP 获取清单在 ignored `.local/voicecursor-research/platform/`。获取日期均为 **2026-10-08 Asia/Singapore / 2026-10-07 UTC**;`fetch-manifest.json` 含首批精确 UTC 时间。以下链接是原始 Apple 来源。 + +## 平台事实 + +### macOS + +- **D-current**:AX 是客户端与宿主间的消息 API;属性可缺失、元素可失效,宿主可不响应或未实现。可信辅助功能客户端并不等于每个文本控件可读写。[AXUIElement.h](https://developer.apple.com/documentation/applicationservices/axuielement_h)、[AXUIElementCopyAttributeValue](https://developer.apple.com/documentation/applicationservices/1462085-axuielementcopyattributevalue)。 +- **D-sdk**:`AXSelectedText` 是选中的字符串,头文件注明不可写;`AXSelectedTextRange` 是字符范围而非字节范围,值是承载 CFRange 的 AXValue,注明可写。还存在多段选区属性,Utter 当前只处理单段。公共头文件 `AXAttributeConstants.h:684–723`,完整绝对来源路径见本地摘录。线上符号入口为 [kAXSelectedTextAttribute](https://developer.apple.com/documentation/applicationservices/kaxselectedtextattribute)、[kAXSelectedTextRangeAttribute](https://developer.apple.com/documentation/applicationservices/kaxselectedtextrangeattribute)。线上符号仅有声明,详细可写性证据来自 SDK,不能混称网页实测。 +- **D-current**:`AXUIElementIsAttributeSettable` 返回具体元素/属性是否可改;写入还可能返回 unsupported、invalid、cannotComplete 等错误。探测成功与写后结果是不同证据。[可写性探测](https://developer.apple.com/documentation/applicationservices/1459972-axuielementisattributesettable)、[属性写入](https://developer.apple.com/documentation/applicationservices/1460434-axuielementsetattributevalue)。没有在已读文档中找到跨宿主原子性或撤销事务保证。 +- **D-current + S**:Utter 以 frontmost PID、focused AX element、选区与上下文绑定目标,而不是仅保存 app 名称。焦点变化必须重验,AX 引用存在不能保证用户还在同一个字段。`AXTargetState.swift:14–40`、`NativeTargetLease.swift:28–35`。 +- **D-current**:`NSPasteboard.changeCount` 随所有权变化递增,可比较是否仍拥有剪贴板;它不是目标已粘贴的回执。[changeCount](https://developer.apple.com/documentation/appkit/nspasteboard/changecount)。**推断**:剪贴板临时暴露文本,保存/恢复不能当成保密通道或无损事务;写入后有其他所有者时,不应覆盖其内容。 +- **D-archive**:Secure Event Input 防止键盘事件被其他进程截获;Apple 给出 `IsSecureEventInputEnabled` 检查,但该文档不承诺 AX 值、合成按键或剪贴板的一切行为。[TN2150,2007,归档](https://developer.apple.com/library/archive/technotes/tn2150/_index.html)。因此不能把 secure-event 状态当成完整的敏感字段识别。 + +### iOS / iPadOS + +- **D-current**:键盘在独立进程中,没有宿主编辑器直接引用。`selectedText` 是只读可选字符串;before/after 是插入点附近的可选上下文。已读 API 没给完整全文、固定长度、可读全部选区或稳定位置范围的保证。不能把文章示例的 `fullText` 变量理解为全文读取保证。[文本交互](https://developer.apple.com/documentation/uikit/handling-text-interactions-in-custom-keyboards)、[selectedText](https://developer.apple.com/documentation/uikit/uitextdocumentproxy/selectedtext)、[before](https://developer.apple.com/documentation/uikit/uitextdocumentproxy/documentcontextbeforeinput)、[after](https://developer.apple.com/documentation/uikit/uitextdocumentproxy/documentcontextafterinput)。 +- **D-current**:`insertText` 向当前输入对象插入字符串;`deleteBackward` 删除插入点前的字符;`adjustTextPosition` 按正负字符偏移移动插入点,正方向随当前语言的文本存储方向。这些方法返回 Void,不带目标版本、成功回执或批量事务。[insertText](https://developer.apple.com/documentation/uikit/uikeyinput/inserttext(_:))、[deleteBackward](https://developer.apple.com/documentation/uikit/uikeyinput/deletebackward())、[adjustTextPosition](https://developer.apple.com/documentation/uikit/uitextdocumentproxy/adjusttextposition(bycharacteroffset:))。 +- **D-current**:文章明确描述 `setMarkedText` 给出的 selectedRange 后续插入会替换该段。它不能证明任意宿主里的用户选区替换、跨段编辑及多次 delete/insert 是原子的;这些仍需 R。已读代理文档没有任意文档绝对选区 setter,也没有系统 undo 栈或一次撤销保证。**D-archive** 还说明一般文本选择由宿主管理,但其旧限制不能覆盖当前 marked-text API。[当前文本交互](https://developer.apple.com/documentation/uikit/handling-text-interactions-in-custom-keyboards)、[旧键盘指南](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/CustomKeyboard.html)。 +- **D-current**:默认沙箱禁止网络与共享容器写入,但当前文档允许共享容器只读。`RequestsOpenAccess=true` 还须用户在设置允许全访问,之后能写共享容器、使用网络;当前全访问能力列表继承前述限制,没有麦克风与扬声器访问能力。全访问不是录音授权。[配置全访问](https://developer.apple.com/documentation/uikit/configuring-open-access-for-a-custom-keyboard)。旧指南说无共享容器,与当前只读能力不同,采用当前文档。 +- **D-current**:安全文本输入暂时换回系统键盘;phonePad/namePhonePad 也用系统键盘。宿主可通过 delegate 拒绝 `com.apple.keyboard-service`,全访问不能覆盖这些限制。[配置键盘界面](https://developer.apple.com/documentation/uikit/configuring-a-custom-keyboard-interface)。Apple 安全文档也确认安全输入排除键盘、宿主可禁用、MDM 可限制托管 app 键盘。[平台安全:扩展](https://support.apple.com/guide/security/supporting-extensions-secabd3504cd/web)。 + +## 当前调用链与可复用所有者,均为 S + +路径以下均相对仓库根目录。 + +- **macOS 捕获**:`Sources/UtterSession/Native/VoiceWorkflowFactory.swift:61` 捕获租约与 InputContext;`Sources/UtterMacServices/Native/NativeTargetLease.swift:53` 实现 TargetCaptureService;`Native/AXTargetState.swift:14` 从 AX value 与单段范围截取上下各 500 个 NSString 单位。另一路 `Native/InputContext+Capture.swift:63` 在 value 不可读时可仅读 selectedText,不能据此推断可交付替换。 +- **macOS 编辑**:`Sources/UtterSession/Native/VoiceSessionJob.swift:128` 进入 `VoiceSessionJob+Editing.swift:6`,先判断选区/最近插入可用性,解析命令,再构造 replaceSelection/deleteSelection/replaceAnchor/undoAnchor;`VoiceSessionJob+Editing.swift:76` 重新检查当前租约。共享 `UtterContracts` 的 DeliveryCommand/OutputTargetLease/OutputAnchor 与 `UtterSession` 应继续承担命令和会话,不把它们移入 UI。 +- **macOS 交付**:`Sources/UtterMacServices/Native/NativeOutputBackend.swift:41` 分派命令;`Native/NativeDeliveryTarget.swift:35` 核对身份、原 value 与范围,`:45` 探测 AXSelectedText 是否可写,`:60` 直接写。`TextDeliveryTransaction.swift:20` 先准备选区,`:31` AX 优先,`:44` 后才检查 secure-event 并选择按键/剪贴板。**静态风险**:AX 读取/直接写分支并没有同一 secure-event 门;这不是本次发现的可利用漏洞,需另做敏感字段策略和宿主验证。 +- **macOS 确认与剪贴板**:`Native/NativeDeliveryTarget.swift:64` 预计算完整目标 value 并回读;`DeliveryTextObservation.swift:19` 最长 3 秒、间隔最多 25ms,这是代码预算而非已测延迟。`ClipboardPasteTransaction.swift:23` 保存内容,比较 changeCount 与 payload,发起粘贴后必须确认目标消费且仍拥有剪贴板才恢复。未确认可能留下临时 payload;恢复 API 没有返回错误回执。`Native/NativeDeliveryPasteboard.swift:10–28` 物化所有 data 表示,不能推定保留所有延迟提供者语义。 +- **macOS 撤销**:`Native/NativeOutputBackend.swift:48` 将 undoAnchor 实现为空字符串替换已验证的 Utter 插入范围;这不是调用宿主系统 undo,不能承诺恢复原先被替换的任意选区或格式。 +- **iOS 控制与录音**:`iOS/Keyboard/KeyboardController.swift:127–145` 要求 hasFullAccess、桥接状态、documentIdentifier 与租约;`:172–193` 发共享桥命令。`iOS/App/VoiceHost.swift:30–47` 由包含 app 执行 MobileController;`Sources/UtterMobile/MobileController.swift:79` 请求麦克风权限,`MobileCapture.swift:42–62` 在 app 侧设置 AVAudioSession/AVAudioEngine。`iOS/Configuration/Keyboard-Info.plist:39` 声明 RequestsOpenAccess,声明不等于用户已授予。 +- **iOS 写入与撤销**:`iOS/Keyboard/KeyboardController+Dictation.swift:61–80` 经 `Sources/UtterKeyboardBridge/StreamingInsertion.swift:26–48` 保存上下文、计算差异,用多次 deleteBackward 加 insertText 写本次听写;`:143–151` 先恢复 raw,再删除自有片段。没有调用 adjustTextPosition,也没有保存原用户选区以支持任意选区回滚。StreamingInsertion 将 nil 上下文视为空,并接受截短 suffix;**推断**:这不是可靠的全文身份/位置证明,需要 nil、重复文本、长文、emoji/组合字符及宿主规范化验证。 +- **iOS 返回宿主**:`iOS/Keyboard/KeyboardView.swift:125` 是打开 `utter://standby?dictate=1` 的 Link;`KeyboardController+Dictation.swift:158–169` 用 documentID 匹配返回后的意图。源码注释声称返回后开始听写,不是 R;本次没有切换宿主,不能宣称顺畅原位编辑。包含 app 的 standby/PiP 实现也不能由“代码存在”证明可持续后台唤醒或 App Store 接受。 + +## 隐私、推断与成本 + +- **S**:macOS `VoiceWorkflowFactory.swift:49` 可选远端 LLM,`:63–74` 把捕获上下文传入会话;`VoiceSessionJob+Editing.swift:93` 在选区改写请求中传入 InputContext、memory、screenContext。`Sources/UtterProcessing/PromptBuilder.swift:11` 接收 InputContext;`Sources/UtterRemoteInference/RemoteLLMClient+HTTP.swift:24–44` 把 prompt 放入远端 HTTP 请求。应审阅完整 prompt 路径和用户选择,不能将本地语音识别称为整个流程都本地。 +- **S**:移动端本次看到 Apple/下载模型识别所有者与 system/LocalPolisher 两种润色路径,`Sources/UtterMobile/MobileController+Runtime.swift:100–113`。没有验证所有分支的数据流、模型下载网络、缓存/日志/历史保留及远端 fallback,不能据静态局部追踪作零外传承诺。 +- **D-current + 建议**:Apple 全访问文档要求保护共享数据、安全传输并只为清楚说明的输入用途处理数据。新增上下文应最小化,远端处理应明确告知选区及邻文发送范围。 +- **未测**:ASR/命令解析/改写/交付端到端延迟,冷热启动、内存、电耗、网络与 token 费用。远端 HTTP 的 60 秒 timeout、macOS 3 秒确认、iOS 7 秒润色等待与 250ms 轮询都是预算或实现参数,不能写成测得性能。后两项见 `KeyboardController+Dictation.swift:119–124`。 + +## 建议,与平台事实分开 + +1. 先限定已选文本及最近 Utter 插入,复用现有租约、anchor、事务与桥接所有者;不以反复移动 iOS 光标读取全文作为默认方案。 +2. macOS 按控件探测读范围、写范围、写选中文本,保存确认等级;拒绝失焦、不可识别范围及未确认重试。将敏感字段策略覆盖捕获、AX 写、按键和剪贴板全部路径。 +3. iOS 对缺失上下文采取保守策略;把一次替换和撤销结果按宿主实测记录。系统撤销、自有片段删除、恢复 raw、恢复被替换原文分别定义,不能合并宣传。 +4. 下一阶段使用自建公开测试文本,覆盖原生文本控件、浏览器字段、富文本、只读和安全字段、焦点/选区变化、长文与 Unicode。分别记录无切宿主与需切回路径。本次没有执行这些实验。 + +## 固定交付记录 + +- **产物位置**:`docs/sdlc/changes/2026-10-08-voice-editing-research/platform-evidence.md`;原始资料 `.local/voicecursor-research/platform/`,由目录内 `.gitignore` 排除。 +- **验证证据**:启动及交付时 HEAD 与指定值一致;本次 Apple 页面渲染正文、官方 Markdown HTTP 200 与公共 SDK 摘录。源码 path:line 是静态事实,R=0。最终 `git diff --check` 通过,`git check-ignore` 确认原始资料被忽略。未运行构建或行为测试。 +- **假设**:运行环境提供文档版本与 Apple 当前线上页面一致;没有锁定某一 macOS/iPadOS 小版本。未来宿主允许的实现差异需实测,已读资料中的无保证不等于证明永远不可能。 +- **未做的事**:不改代码,不运行 app/测试/构建,不用 iPad,不读取私人宿主文本、剪贴板内容或认证,不安装/注册,不改权限,不调用付费推断,不派子任务,不建线程/定时器/watcher/后台服务,不手动切宿主。不把过去设备记忆当成当前 R;未进入实现 SDLC 阶段。 +- **错误**:网页读取器最初只给 JS 占位,Markdown 抓取器不支持 text/markdown,部分 AX Markdown 返回 404;改用官方渲染正文及公开 HTTP 获取,停止重复同因尝试。一次旧 QA URL 实际为 debugger 页面,已排除,采用正确 TN2150。一次 zsh 未匹配源码 glob 导致命令 exit 1,随后用已发现的具体文件恢复;未产生改动。一次 browser snapshot 包含大图片导致输出截断,改用正文提取并保存。没有因此推定平台 API 成功。产物写入于 21:17:03Z,晚于子任务 21:16:50Z 截止;立即交付,不继续扩展研究。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/research.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/research.md new file mode 100644 index 00000000..a83919a5 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/research.md @@ -0,0 +1,72 @@ +# VoiceCursor 与 Utter 语音编辑首轮研究 + +本轮结论是先把 macOS 现有选区编辑补成“有审阅、只替换当前原选区、能安全恢复原文”的候选流程。移动端先关闭现有原位录音与焦点返回 T0 闸门,不能照搬桌面或以手动切回宿主达标。此为待批准的范围建议,未制作 UI、未修改产品。 + +## 产品证据与值得借鉴的内容 + +VoiceCursor 官网把语音用于修改已写文本,原始视频实际可见 Mail/Slack 选择态和编辑状态。这支持研究该交互方向,不能证明通用可靠性、原文恢复或跨平台同等体验。官网公开 [视频](https://voicecursor.ai/videos/homepage/intelligent-editing.mp4)、[官网](https://voicecursor.ai/)和[更新记录](https://apps.apple.com/us/app/voice-cursor-ai/id6762721421)详见 source-evidence。 + +只保留四项有依据的借鉴: +1. 按应用选择默认语气,减少每次口令的重复。Utter 需先核对既有 style/inputContext,选区编辑默认保留原文语种和事实,语气只能显式变更。 +2. 自定义词典和 snippets,保护人名/产品术语。Utter 已有字典 owner,应优先复用;snippet 展开要由明确指令触发,避免改写新增事实。 +3. 先形成清晰文本、再按明确要求转为邮件或要点。纯文本/显式 Markdown 可作为有限范围,不承诺 Notion 表格或任意富文本无损。 +4. 可恢复的处理状态。商店更新有 Retry/Recover 和掉线录音恢复宣称。Utter 建议仅重算候选文本,不在 outcome uncertain 时重复写宿主;本轮不照搬其存音频政策。 + +Action Mode 的跨服务执行超出本轮最小范围。只借鉴“先看再执行”的理念,不新增邮件/日历/文件自动化。 + +## 桌面与移动的能力对照 + +| 能力 | macOS 当前证据 | iPhone/iPad 当前证据与限制 | +|---|---|---| +| 获取选择 | AX选区、目标lease代码已存在;宿主必须暴露属性并授权辅助功能 | UITextDocumentProxy selectedText与有限前后文,不是全篇或任意应用读取权 | +| 语音指令改写 | shared Session/Processing已有 selected edit recipe 与 command resolver | 键盘现有App Group语音bridge不提供任意选区编辑闭环;clientID返回文本,不经过desktop spoken-edit输出 | +| 原位录音 | 当前Mac录音owner可复用,具体宿主新流程未测 | 键盘扩展不能直接使用麦克风;全访问不能解除此限制。原生产线app capture/返回/生命周期闸门未闭合 | +| 写回 | AX可写性、精确选区/文档复核;剪贴板降级有 uncertain边界 | insertText/deleteBackward受宿主焦点与proxy行为约束,无任意范围事务保证;不可盲删context或扫全文 | +| 撤销 | current undoAnchor将新锚点改为空,不保存原选区全文恢复契约 | current undo仅负责自身近期听写,不是宿主全局undo,也不证明选择替换恢复 | +| 格式 | 当前文本字符串输出,不能推断富文本保持 | proxy字符串和插入接口不提供跨宿主富文本事务;第一版限纯文本 | +| 隐私/模型 | 现有本地Whisper/MLX与可选remote provider,具体路由要用户已有设置决定 | main app可本地模型,键盘/桥接留存和remote范围必须审查,不能新增静默云fallback | + +平台事实依据见 platform-evidence。源码存在、API文档、真实宿主通过是三个不同等级。 + +## 唯一候选行为契约 + +第一版只接受非空、可编辑纯文本选区。用户明确进入“编辑选区”,从原宿主捕获 selection text、精确范围和 target lease。只把选区与语音指令交给现有 Processing;默认不加 OCR、全文、屏幕图或历史记忆。按用户已有模型配置运行,远端前需明确知道选区和指令会发送到何处。 + +候选文本先在 Utter 的审阅区域显示原文、识别口令与结果。用户可重新说口令或取消;宿主原文在确认前保持不变。具体面板形态和是否可编辑口令由下一阶段 UI负责人制作可审阅方案后决定。绝不把低置信口令自动当听写追加。 + +点击应用前重新核对原PID、AX对象、文本、选择范围和文档状态。任一变化则停止写回,保留候选,要求重新选择并发起新事务。拒绝空结果、失败、超时、取消和失效lease;网络错误允许重算候选,不能自动写入或切云。一次确认对应一个operationID和一个receipt,uncertain需读取可验证结果或由用户处理,不重放按键。 + +Apple公共SDK把AXSelectedText标为不可写,现有实现仍做逐元素settable探测。先以自建非私密fixture验证能力,不能先承诺直接AX替换普遍可用。若目标拒绝该属性而只有paste可行,则该宿主在另行确认clipboard安全证据前不支持首版替换。取消不得改剪贴板;用paste时必须保留当前owner校验/恢复机制,不覆盖用户期间复制的新内容。 + +撤销不是删除新文字。回执需在内存保存 originalSelection和replacement,并绑定提交后精确目标/范围/周边状态。只有目标仍一致且该范围仍等于replacement时,才把范围恢复成originalSelection;用户之后输入、换焦点、移动选择或应用关闭就禁用直接恢复。首版只承诺本次事务的一次恢复,不承诺任意宿主全局undo或跨会话多级undo。现有undoLastInsertion/undoAnchor不能直接冒充该契约。 + +多轮通过重新选择当前结果、重新捕获新lease发起下一笔编辑。先不做隐藏连续session。富文本、表格、全文编辑、无选区“智能猜目标”、自动发送和语音命令删除全部不在首版范围。 + +## 可复现中文验收例子 + +以下是固定测试材料与目标,非本轮模型运行结果。每例选择完整给定文本,语音口令在审阅中应可看到;确认前宿主字节不变,确认后仅选择范围变。 + +| 类别 | 原文 | 口令 | 可检查结果 | +|---|---|---|---| +| 纠错 | 下周三下午三点和张珊开会。 | 只把张珊改成张山,其他不变。 | 下周三下午三点和张山开会。 | +| 语气 | 你今天必须把报告给我。 | 改得礼貌一点,仍然要求今天给报告。 | 保留今天/报告/请求,无新增期限或原因;允许不同礼貌表述 | +| 缩短 | 我们计划在本周五下午两点召开会议,主要讨论新版本发布前的测试结果。 | 缩短到一句,保留星期五、两点和讨论测试结果。 | 保留时间和主题,无额外事实 | +| 结构 | 小王负责界面,周三完成。小李负责测试,周五完成。 | 按负责人、任务、截止时间,整理成两条纯文本要点。 | 两人/两任务/两日期匹配,范围外文本不变 | + +保护性验收至少包含:处理期间选择变化/同值另位置/宿主切换/文档变化、中文与emoji范围、取消、没有语音、误识别口令、模型空答/超时、断网、剪贴板被用户修改、提交结果不确定、重复确认和修改后撤销。没有安全恢复原文证据就不启用替换发布。 + +## 延迟、离线与成本 + +没有本轮延迟或推理成本测量。将capture、ASR、edit generation、确认、write/observe分别计时,不把官网4x或near-instant当Utter目标已达成。给每阶段明确超时与取消,先测短中文选区再确定产品预算。本地路径取决于已可用模型与质量;不新增下载或承诺移动端全部离线。远端费用跟语音时长、文本token、所选provider有关,VoiceCursor订阅价格无法推出Utter成本。 + +隐私建议为选区/口令仅存当前内存事务,失败日志记状态与operationID,不记私密原文;崩溃日志、History、App Group文件留存、字典/记忆拼接都要做显式审计。VoiceCursor不训练承诺并不消除其上传与留存,Utter不能直接宣称同等隐私。 + +## 剩余风险与下一步 + +未完成VoiceCursor客户端安装实测;未确认专用hotkey、连续替换、编辑undo、网络故障或iPad原位行为。未测试Utter新流程,未操作iPad/原工作区。独立Apple文档核对完成后由负责人验收来源和边界。 + +下一真实决定是 intent 范围批准;之后才进入Claude/Cursor交互设计。未来实现回到当前shared Session/Processing/Mac output所有者,与原生产负责人协调移动bridge与设备资源。不会建立平行编辑或录音逻辑。阶段与验收见workflow.json。 + +## 2026-10-08 continuation + +用户整体授权已取代首轮待批推进限制,见spec/plan/workflow。桌面先补可审阅候选和恢复原文receipt;移动目标保留,原生产负责人ordinal24/cursor2617明确T0未通过,负责移动/设备/整合。实际原型与6文件增量均未通过最终验收,见verification与implementation-result;无版本生产就绪结论。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/rule-context-r11.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/rule-context-r11.md new file mode 100644 index 00000000..9bf01fde --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/rule-context-r11.md @@ -0,0 +1,13 @@ +# Run11 named-rule and installed manifest context + +Status: pending approval + +The original outer policies differ only in `allow file-write*` subpath predicates. A finite census shows both policies can reapply themselves, and a comment-only variant also succeeds. Applying a different policy fails, including an inner policy with all write grants removed. The missing-write-permission hypothesis is therefore rejected for the measured nested probes. This is bounded context evidence, not full kernel implementation proof or a safe permission fix. + +Real Apple Swift 6.2.3 dump-package compiled and linked the package manifest, then failed at sandbox_apply. Its first failure log remains unchanged. One supported very-verbose instrumentation attempt, with the same restrictive outer and the installed inner sandbox enabled, captured the actual inner profile/argv and JSON context. The actual inner grants only ManifestLoading and module-cache writes. It still failed. Safe SwiftPM invocation environment variables were measured; direct manifest child environment was not observed. + +Current canaries allowed one task-owned cache write and denied one task-owned write, a loopback connection, and POSIX shared-memory creation with EPERM. Those operations alone are proved. Same-policy nesting is not actual-manifest protection acceptance. No offline resolve was attempted because the real manifest context did not pass. + +Fresh HEAD, scoped diff and package/lock hashes match entry. No dependencies were reused, no external scaffold was created, and no product source or original checkout metadata changed. Detailed commands, exact hashes, actual argv/profile, capacity samples and process-group closure are in ignored Run11 artifacts. Native completion remains for owner verification. + +Full product build/basic/tests, UI/host/mobile acceptance and Release were not run in this unit. No policy widening, sandbox disabling, permission change, cleanup, network download, recursive delegation or new round occurred. The remaining boundary is different-policy application inside an already sandboxed process while keeping both protection layers. No safe correction is established. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/sandbox-registration-r8.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/sandbox-registration-r8.md new file mode 100644 index 00000000..6ec8d492 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/sandbox-registration-r8.md @@ -0,0 +1,35 @@ +# Run8 sandbox compatibility result + +Status: blocked + +Fresh Data capacity was 10500861952 bytes, below the required 10737418240 bytes by 236556288 bytes. The child stopped the unit. It did not create a compensating profile, run canaries, use `--disable-sandbox`, or retry resolve. No actual workspace registration or full build was established. + +Existing authorization covers this bounded investigation. This report records a capacity block, not a request for another ordinary stage approval. The original network denial and runtime permissions were not changed. + +## Protection determination + +Run7's outer profile denies network only. It does not restrict filesystem writes. `GIT_ALLOW_PROTOCOL=file` restricts Git transport only. The installed option's presence would not prove that disabling the inner manifest sandbox preserves protection. The previous `sandbox_apply: Operation not permitted` is an OS error; no native T3 approval rejection was established by the inspected Run7 record. A safe compatibility determination remains unproved. + +The upstream [SwiftPM sandbox source](https://raw.githubusercontent.com/swiftlang/swift-package-manager/swift-6.2-RELEASE/Sources/Basics/Sandbox.swift) shows that manifest protection includes filesystem restrictions. This is source evidence, not proof that a replacement profile passes on the installed toolchain. The matching release [manifest loader](https://raw.githubusercontent.com/swiftlang/swift-package-manager/swift-6.2.3-RELEASE/Sources/PackageLoading/ManifestLoader.swift) and [sandbox source](https://raw.githubusercontent.com/swiftlang/swift-package-manager/swift-6.2.3-RELEASE/Sources/Basics/Sandbox.swift) were opened, but the capacity stop prevented further study, raw-source persistence and real protection tests. The Run7 installed version record says Apple Swift 6.2.3; the child did not rerun the live version/help commands. + +The owner's supplemental primary-evidence file arrived in this same run after the capacity block. It remains an input for a future capacity-qualified unit. The child did not open it after the stop. + +## Verification and limits + +- Live T3 environment and configuration matched the required machine, full-access runtime, and requested model options. No settings changed. +- The external APFS identity and writable state matched before the low sample. No external configuration or cache was written. +- The previous child sample passed at 10978177024 bytes. The subsequent stage-entry assertion failed at 10500861952 bytes. The low sample's exact timestamp was not persisted; it preceded the 23:38:34 UTC closure clock reading. +- Resolve correction count is zero. This is sandbox correction unit ordinal 1, with a maximum of two across rounds. Existing disk cleanup recovery remains exhausted at two of two. +- Canary protection, actual 19-dependency workspace registration, full build, basic checks, tests, release, UI, host and mobile T0 were not run. +- The child made no product, dependency, resolved-file, authentication or global configuration edits. Source entry evidence belongs to the owner's verification. The child did not rescan the source after the stop. +- All child tool commands returned synchronously. No yielded process session, custom supervisor process group or recursive delegated task exists. No cleanup or capacity retry occurred. + +## Retained errors + +Two broad metadata reads produced truncated outputs. This did not rehash historical snapshots, but it was unnecessary and reduced command-log completeness. After the capacity assertion, the already-prepared tool group continued one instruction read and two public source opens. That exceeded the immediate no-new-metadata-study rule within that call. No configuration, download of dependencies or models, compiler, canary or resolve work followed. The child then stopped and saved this report. + +## Handoff + +Preserve the existing mirrors, working copies and mappings. Do not wait for capacity recovery, clean Data, lower the threshold, or retry this run. A later bounded unit must first pass a fresh capacity gate, use the owner's primary evidence, and prove all relevant sandbox restrictions using task-owned canaries before one resolve correction. Registration and build readiness remain unaccepted. + +Full machine-local evidence, native timing placeholders, paths, command limitations and retained errors are in the ignored child-result JSON. The owner must read actual native terminal timing after the child returns. This report does not represent native completion or owner acceptance. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/source-evidence.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/source-evidence.md new file mode 100644 index 00000000..a10f5874 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/source-evidence.md @@ -0,0 +1,63 @@ +# Source evidence + +获取日期为 2026-10-08 Asia/Singapore,UTC 2026-10-07 21:09:56 起。负责人为本线程。证据分为现场网页/源码观察、官方文档或演示宣称、推断、未证实。没有安装 VoiceCursor 或对其原生客户端做实测。 + +## 官方产品材料 + +| ID | 官方链接 | 当前观察与证据等级 | 本机原始证据 | +|---|---|---|---| +| V1 | [官网](https://voicecursor.ai/) | 官网宣称选区语音改写、缩短、翻译、改细节;不能证明所有宿主或移动支持相同契约。 | homepage-dom.json | +| V2 | [原始视频](https://voicecursor.ai/videos/homepage/intelligent-editing.mp4) | 官网实际加载,duration 126.548 秒;现场检查多个时间点。85秒为 Mail 蓝色选区与 Editing 状态;93秒为 Slack 蓝色选区;100秒为移动画面和 Speak to edit、Tap again to finish 面板。剪辑/合成演示,非本轮原生实测。 | demo-085s.png、demo-093s.png、demo-100s.png,全部截图和哈希见 screenshot-map.json | +| V3 | [公开 demo](https://voicecursor.ai/demo) | 实际导航,出现四种写作场景按钮和 Connecting...;未授权麦克风、未登录、未完成语音输入或选区替换。 | public-demo-dom.json | +| V4 | [下载](https://voicecursor.ai/download) | 官方提供 Mac/Windows 与 App Store/Android 分发链接;分发存在不代表平台功能完全一致。 | official-web-results.json | +| V5 | [App Store 开发者页](https://apps.apple.com/us/app/voice-cursor-ai/id6762721421) | 开发者更新记录提及 Voice Edit、PiP/键盘交接、Retry/Recover、应用语气。页面最新 1.0.21,相对日期 5d ago;1.0.18 提及新打字键盘。iPhone iOS17 / iPadOS17 起。描述却有桌面 hotkey/menu bar 文案,不能据此推断 iOS 触发方式。 | 本轮 web 获取,来源链接保留 | +| V6 | [隐私政策](https://voicecursor.ai/privacy-policy) | 生效 2026-01-22。收集输入/输出;未经单独 opt-in 不用于模型训练;可能分享给云托管等服务商;保留期限随账号/服务需要。无本轮证据可确认全本地、零留存、具体推理模型或机房。 | privacy-dom.json、privacy.png | +| V7 | [Action Mode](https://voicecursor.ai/action-mode) | 官方 Beta,示例为查文件、发邮件、约会议,可先审阅步骤。此审阅是动作执行机制,不能说编辑功能也有同一确认或 undo。公开 Notion guide 本轮抓取 Internal Error,未重试绕过。 | official-web-results.json | +| V8 | [官方博客](https://voicecursor.ai/blogs)及[控制权文章](https://voicecursor.ai/blogs/hands-free-writing-with-ai-dictation) | 博客文章强调用户审阅与精确编辑;这是产品理念,不是具体事务保证。未找到可引用的桌面版本 changelog 或完整编辑 API/手册。 | 本轮 web 获取 | + +官网当前显示 Free 每周跨设备 2,000 words,14天 Pro 试用,Pro 月付 $20;美区 App Store 内购月 $19.99、年 $143.99。价格来自不同渠道,未结算核实,不用于估算 Utter 成本。App Store 隐私标签列出关联身份的 User Content,含 Audio Data。未将“不训练”解释为“不上传”。 + +## 重点链路的证据缺口 + +- 触发:桌面演示 Editing 状态;商店文字提 custom hotkey/menu bar,但本轮未确认编辑专用键。移动演示 Tap again to finish,不清楚入口和系统交接。 +- 选区/上下文:官网声称使用选区及周边上下文;没有公开获取长度/裁剪规则。视频显示可见选区,不证明未读全文。 +- 跨应用:官网与视频列/显示多个宿主;未实测覆盖率、只读区域、富文本和焦点竞争。 +- 替换/追加:宣传目标为修改选择内容;本轮抽帧没有形成连续提交前后链,不能确认每个平台实际替换语义。 +- 多轮:无连续演示能证明保留编辑 session 或重新选区规则。 +- 审阅/撤销:未确认原生编辑预览、确认、撤销/恢复原文。Action Mode 的审批不可借用为编辑证据。 +- 格式、语言、语气:官网有相关宣称;未证实富文本/表格结构保持、中文事实保真、长文本边界。 +- 失败:商店更新提录音掉线保存/重试和 Recover。不能证明编辑写入 exactly-once 或失效选区保护。 + +## 工作区与原执行线 + +新 branch research/voicecursor-voice-editing-20261008,实际 HEAD ed4234b41764be8346ae58cdc63011c8f26a0f1e,起始 git status 干净。原工作区相同 HEAD,但现场为38个 tracked modified、17个 untracked entries,diff stat 760 insertion/231 deletion。原始状态清单保存于 ignored .local。只读核对未提交内容,没有复制源码或工件。 + +原线程 0e62fe12-4c97-497b-a4c7-985eea9aa1cd 最新 run ordinal23 completed,started 20:23:49Z、completed 21:01:36Z。读取 afterPosition2500,最后 assistant 游标2601。具体键盘 A 布局 pending approval;04:15设备失败止于开发证书不信任,后续用户可启动不证明 T0成功。自动返回、原焦点/PiP、生产独立验收仍未闭合。原 checkpoint-capture.md 只读用于当前状态,没有取旧成功证明新方案。 + +原线未提交 BridgeMessages 增加 attach/captureRequestID/captureFromApp,键盘在 captureFromApp 为真时不做 live streaming。这是 Debug 录音附着试验,不是 arbitrary selection voice editing。原线还有隐私清单、MobileController+KeyboardCapture、Standby/VoiceHost、图标与键盘布局变更。未来必须向原负责人整合单一逻辑。 + +## Utter 本 HEAD 的现场源码 + +- Sources/UtterSession/Native/VoiceSessionJob.swift:120-150,仅 desktop clientID nil 的 command 路由 performSpokenEdit;remote/client 路径返回文本。 +- Sources/UtterSession/Native/VoiceSessionJob+Editing.swift:6-98,有 selection/last insertion 目标判断、rewriteSelection、replaceSelection、deleteSelection 和 undoLastInsertion;直接 deliver,没有用户审阅闸门。 +- Sources/UtterProcessing/Native/TextProcessor+SelectionEdit.swift:6,有选区模型处理;失败返回空文本。Prompting 同目录限制新事实与输出结构。提示约束不等于模型质量通过。 +- Sources/UtterMacServices/Native/NativeTargetLease.swift:27,有宿主 PID、AX 元素、选区/文档状态校验。 +- Sources/UtterMacServices/Native/NativeDeliveryTarget.swift:35-58,复核焦点、文档及 selection,探测 AX selectedText 是否可写。 +- Sources/UtterMacServices/Native/NativeOutputBackend.swift:48,undoAnchor 写入空文本;anchor 记录新 text。不能把该路径称为恢复“替换前原文”,需要补回执保存原文及安全恢复。 +- Sources/UtterMacServices/ClipboardPasteTransaction.swift:31-62,剪贴板 changeCount 所有权及消费确认后恢复;paste outcome uncertain 不安全重试。存在安全保护不等于每个宿主实测支持。 +- iOS/Keyboard/KeyboardController+Dictation.swift:61-80、143,有自写听写 streaming 与 undo;selectedText 用于一致性保护。本轮没找到任意选区语音指令传递/改写/原文恢复闭环。 +- iOS/README.md 当前已提交文本仍标记 keyboard recording feasibility gate blocked。原未提交 T0 工作不得通过本分支旧 README 或旧测试翻转为通过。 + +Apple 官方依据和更精确源码范围由独立任务记录于 platform-evidence.md,负责人核对后引用。 + +## 路径、预算和错误 + +environmentId DEVICE_BINDING_IN_IGNORED_LOCAL,darwin Mac mini;所有绝对路径仅本机有效。工作区 .local/voicecursor-research 被 .local/.gitignore 的 * 忽略;源 screenshot 位于 T3 browser-artifacts,副本、对应URL、时间点和 SHA256 留在 .local。未读取原私人设备截图,不披露系统私人区域。 + +native startedAt 2026-10-07T21:09:56.301Z,hard deadline21:19:56.301Z。账户额度未知;live catalog核对 Codex Sol Xhigh Standard;Claude disabled,Cursor available,未来 UI 从允许路由选。没有启用 Fast/MAX、付费或提高权限。 + +错误记录:初次混合输出超过预算而截断,按文件分段重读;git check-ignore 当时目录未忽略返回1,中断其后命令,已单独补读;一个源码猜测路径不存在,改用 rg --files 找实际 owner;Notion guide Internal Error 未访问到。公开demo连接中,无原生产品实测。未启动 watcher、scheduler、其他普通线程。 + +## Native timing erratum and current cursor + +首轮父实际completedAt21:20:51.594Z,startedAt21:09:56.301Z,超55.293秒;旧“父deadline intact”等表述撤回。Apple子任务native超56.6秒,不是产物+13秒。Run2原线ordinal24 completed21:59:36.954Z、cursor2617,HEAD ed4234b41764be8346ae58cdc63011c8f26a0f1e,38个tracked改动;读取t0-return并未修改原工作区。本线子任务原生终态/任务ID在workflow与ignored run2-state.json。 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/spec.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/spec.md new file mode 100644 index 00000000..fa73697e --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/spec.md @@ -0,0 +1,60 @@ +# Spec: 桌面纯文本选区语音修改 + +**Status:** draft +**Approved-by:** — +**Approved-date:** — +**Upstream:** [intent](intent.md) + +**Authorization:** 用户整体授权。2026-10-08 根协调线程转达用户“都允许”,覆盖本次桌面与移动设计、计划、实现、验证和既有交付目标跟进;未声称用户已审阅本方案。 +**Risk:** high +**Owner:** 本线程负责契约/验收;原生产负责人负责集成与发布。 +**Base:** ed4234b41764be8346ae58cdc63011c8f26a0f1e + +## Current direct-edit contract, 2026-10-17 + +The user explicitly removes the candidate preview and Apply step, requests keyboard operation, and retains the original HUD appearance. This supersedes earlier explicit-Apply desktop requirements below. It is user authorization for this change, not human acceptance of an unseen result. + +Desktop selected voice commands generate and validate inside the existing Session, drain capture, revalidate the original target/selection/document, then commit once through the existing output transaction. The popup shows recording/processing/result status and one-use Restore only; it never displays selected original, generated candidate or an Apply control. No simulated button press or second session/output pipeline is used. Invalid/empty/model-error/stale/uncertain paths retain existing protections and never blind retry. Selected content stays ephemeral even without a review suspension. + +Use the existing voice trigger/release to finish capture. Register Esc only during an eligible local selected session and Command-Option-Z only for its current confirmed restoration receipt. Bind each registration to its exact session/operation, reject queued/stale events, unregister when hidden/ineligible/closed, and revalidate before action. Preserve host Command-Z. Registration conflicts fail closed and cannot be advertised as available. Native keyboard registrations use the already-installed macOS API without prompts or global configuration changes. Remote-provider disclosure before sending selected text remains distinct from Apply and retains explicit one-use consent. + +Verify real Session automatic single delivery, cancel while generation drains, stale selection/focus, Unicode, empty/error output, uncertainty, temporary-content retention, and one-use restoration. Render the actual compact production HUD in English/Chinese and both appearances; prove long original/candidate text is absent and controls fit narrow width. Verify native event decoding/registration lifecycle with owned local event fixtures; locked-host global dispatch, actual VoiceOver and device acceptance remain open. Independent review and rollback remain required. Roll back this increment with a normal revert, without resetting other changes or user data. Mobile's existing proposal protocol stays supported pending its separately verified platform adapter; no device or original-owner workspace actions. + +## 唯一行为契约 + +捕获单一可编辑宿主的非空纯文本选区,复用既有 Session 录音和 selectionEdit Processing recipe。语音用于修改指令。任何宿主写入前,展示识别指令、精确原文和候选;只有明确点击“应用修改”才能提交。Enter 不隐式提交;Escape/取消保留原文。 + +应用前重新核对捕获的 PID、AX 元素、完整文档快照、UTF-16 选区和选中文字。审阅面板暂时获取焦点后,必须安全返回原宿主并证明原 lease 一致;不能放松 lease 检查或猜测邻近匹配。焦点、选区或文档改变使候选失效。Unicode、组合字符、emoji 和换行保持精确边界。 + +目标 readback 确认后,明确 receipt 保存 operationID、originalSelection、replacement 和 resulting anchor。恢复走现有 replaceAnchor(originalSelection, anchor),不能使用 undoAnchor 的空字符串删除。仅当目标、文档、范围和替换文字仍一致且 anchor 未过期时提供一次恢复;继续输入、换目标、重复确认或 uncertain 都不能再次覆盖。恢复不生成新的恢复机会;不宣称跨宿主全局撤销或富文本保真。 + +## 状态与失败保护 + +captured → recording → processing → review → applying → confirmed / uncertain / notCommitted;review 可 expired。取消在写入前丢弃候选;写入已经发出时先按现有事务结算再展示状态。空答、模型/网络错误、超时不产生可应用候选。uncertain 显示“尚未确认是否已应用,请检查原文”,禁止盲重试/恢复,可明确复制候选。 + +剪贴板继续使用现有 snapshot/changeCount/消费确认;不覆盖用户后来复制的内容。敏感/安全输入在读取选区前排除,包含 AX 直写分支。原文与候选仅临时保留,不新增私密全文上传、截图或历史持久化。尊重现有本地/远程模型选择,远程选区文本披露需在使用前可见。 + +## 审阅与验收 + +Cursor 制作 desktop-review-prototype.html 和 ui-design.md,以合成中文样例真实响应点击;本线程逐项点击、真实渲染和 ANTI_SLOP 前后审计。面板清晰显示宿主、指令、原文、候选,长文本可滚动无裁切,状态和按钮支持键盘/可访问性。 + +- Native 文本框与浏览器 textarea/contenteditable:选择→语音→审阅→取消原文完全不变;应用精确改选区并保留前后文本;一次恢复精确原文。 +- 中文、组合音标、家庭 emoji、换行、surrogate pairs;选区/焦点/文档失效、继续输入、重复应用/恢复不能写错或写两次。 +- 空答/断网/取消、剪贴板所有权变化、AX/粘贴 uncertain:不盲重试、不破坏原文。 +- 准确 SHA/diff/包哈希、完整构建、真实权限/隐私/宿主、独立验收分别记录;SDLC 脚本通过不等于产品通过。 + +## 移动目标与范围 + +iPhone/iPad 保留在授权范围。先依赖原负责人 T0 录音→自动返回原宿主/焦点→完整上屏→生命周期;手动返回是未达标。键盘代理 selectedText/context 可选且无全局原子 undo receipt,不能照搬 AX。共享 Session/Processing,单独证明选区一致和替换观察后才能恢复;不支持宿主保留原文。共用 iPad 必须预约,不改变原线无关键盘布局。 + +首版排除 Action Mode、全篇、富文本、邮件/日历自动执行、新付费注册/安装、认证权限修改。回滚关闭入口,无破坏性迁移。来源与未证实事项见 research.md/platform-evidence.md/source-evidence.md。 + +## Confirmed selected-entry privacy contract + +专用selectionEdit及已有command模式中冻结了非空选区的本次指令,都走选区改写与审阅;识别的自然指令不必重复说“选区”。不启动截图/OCR,不落到任意屏幕Action或自动删除;复用同一Session与已有rewrite helper。未选区command及普通听写原行为保留。此为整体授权内的实现设计收敛,未声称用户已审阅或实际验收通过。 + +## Original HUD styling correction, 2026-10-17 + +The user rejected the review screen's visual divergence. Preserve the existing HUD language from the pre-feature `f0ef2aa` source and retained native recording renders: dark ultra-thin material in both appearances, caption typography, 26-point circular x/check controls, and the 304-point expanded recording width. Review, remote disclosure and restoration extend that same HUD. Short selections show instruction/original/candidate together; long selections scroll within a panel capped at360points, with the action row always visible. The check control keeps the localized Apply or Continue accessibility label and help; it receives no Return shortcut. Restore uses an undo glyph and the same existing control style. Recording controls and ordinary dictation retain their behavior. + +The earlier statement that the UX was preserved meant only that the last test-only increment changed no production source. It did not establish visual consistency with the pre-feature product, and the user has rejected that broader claim. This correction requires fresh native screenshots and interaction checks on the new source; old44-PNG evidence is historical. No actual host/device/VoiceOver/signing acceptance is implied. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/swiftpm-result-r7.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/swiftpm-result-r7.md new file mode 100644 index 00000000..c38165c0 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/swiftpm-result-r7.md @@ -0,0 +1,46 @@ +# Run7 SwiftPM offline registration result + +Status: pending approval +Date: 2026-10-08 + +The actual offline dependency registration failed before registering dependency checkouts. All 19 mirrors were configured through the installed SwiftPM CLI. This configuration result does not establish dependency registration or a complete build. + +The installed tool is Apple Swift 6.2.3, swiftlang-6.2.3.3.21 and clang-1700.6.3.2. Actual `swift package --help`, mirror help, resolve help, build help and verbose compiler output are retained. The existing external APFS volume identity matched the supplied mapping. Hardware output is retained for owner readback. + +## Execution and failure + +One initial mirror configuration command failed because the selected task configuration directory did not contain Package.swift. The same run corrected the package context, retained the failed attempt, and successfully configured all 19 URL mappings through `swift package config set-mirror`. The installed CLI actually wrote the task-owned mirror override file. No workspace-state.json was fabricated. + +One real `swift package resolve` ran with an outer deny-network sandbox, file-only Git protocol, isolated cache/config/security/scratch paths, disabled credential lookup, skipped updates, disabled prefetching and disabled experimental prebuilts, with forced resolved versions. SwiftPM compiled and linked the real candidate Package.swift manifest. It then failed when its manifest execution attempted a nested sandbox: + +`sandbox-exec: sandbox_apply: Operation not permitted` + +The process exited 1. This is an execution configuration blocker, not a product-source compiler error. No sandbox or permission settings were changed to bypass it. The command and complete unredacted diagnostic are in ignored task logs. + +## Evidence and checks + +- Offline SwiftPM dependency registration: failed. No accepted 19-dependency workspace registration. +- Installed mirror CLI and task-owned override: passed, 19 mappings. +- Actual manifest compiler placement: partial. Compiler object, linker executable and overlay paths in verbose output point to the external task temp directory; 37 generated cache/temp files have hashes and device evidence. Product compile placement remains not-run. +- Full candidate build: not-run. Entry at 23:25:30Z left less than three supervised minutes before the 23:28:15Z command deadline. +- Swift tests: not-run. +- Signed release build, native review UI, focus/privacy, real-host apply/cancel/safeRestore and mobile acceptance: not-run; retained as later acceptance work. +- sdlc: interrupted. command deadline +- Source manifest verification: passed, 846 actual files matched the supplied hashes. +- Source HEAD from current readback: `ed4234b41764be8346ae58cdc63011c8f26a0f1e`. +- Source manifest SHA256: `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`. +- Source-scope tracked diff SHA256: `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`. +- Continuous Data capacity guard: 128 recorded samples; minimum 10767134720 bytes against 10737418240 bytes. No cleanup, threshold reduction or waiting for recovery. +- Every owned command process group: terminal, no remaining members. Command start/end, PID, PGID, exit code and log hashes are retained. Provider native completion remains parent-verified after this response. +- Account quota: unknown. Existing Standard/default route stayed unchanged; no delegates or new rounds. + +## Retained files and next action + +Detailed result: `.local/voicecursor-research/run7-offline/child-result.json`. +Commands and SHA256 evidence: `commands.json`, `evidence-hashes.json`, `compiler-output.json`, `disk.jsonl`, `source-before.json`, `source-after.json`, `registration-failure-1.json` and `031-offline-resolve.log` in that same ignored directory. Device mappings, UUIDs and absolute compiler paths remain there. + +Resolve nested sandbox restriction within an authorized isolated offline execution configuration, verify all 19 SwiftPM identities/revisions, then reserve at least three supervised minutes for one full build. Preserve generated module cache and logs; do not recreate mirrors or metadata. + +The supervisor enforces fresh capacity before each owned command and polls it during execution. Each process owns a dedicated group; termination escalates to SIGKILL and confirms the group is empty. Existing dependency files and prior evidence were reused. Product source, package lock, original dependency Git metadata and global configuration received no intentional writes. The 846-file hash check verifies product/source/lock content preservation in this run; the prior 1426-file dependency metadata acceptance was reused, not re-audited. Parent-owned workflow, verification, run state and handoff result files were not edited. + +Closing audit: the report was retained after the child deadline. The supervisor rejected new check commands at the command deadline; no further SwiftPM or permission recovery command ran. Exact closing timestamps and the unmet child deadline are recorded in ignored late-close.json. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/ui-design.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/ui-design.md new file mode 100644 index 00000000..eeafa801 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/ui-design.md @@ -0,0 +1,54 @@ +# 桌面“编辑选区”审阅界面设计(原型说明) + +状态:原型草案,待负责人评审。`desktop-review-prototype.html` 是纯静态、无网络依赖的**模拟**页面:不使用麦克风,不连接真实宿主或模型,不能证明 AX 写回、剪贴板、恢复或延迟。 + +## 范围 +仅纯文本选区。流程:捕获宿主选区 -> 复用现有录音 -> 显示识别到的口令 -> 并排预览原文与候选 -> 显式“应用”或“取消” -> 确认结果 -> 仅在目标与文本未变时一次性“恢复原文”。 + +固定演示数据:原文 `妈妈说周末一起吃饭,记得带上蛋糕🎂。`,口令 `改得更礼貌,保留时间和蛋糕`,候选 `妈妈说周末想一起吃饭,麻烦记得带上蛋糕🎂,谢谢。`(演示用,非模型输出)。 + +## 布局 +单个审阅面板,无仿窗口外壳:流程步骤 -> 目标信息 -> 原文 / 候选(宽屏左右,窄屏上下) -> 识别口令 -> 状态消息 -> 按钮行。其下的虚线区“模拟夹具”只服务评审,不属于产品界面。 + +## 状态与按钮 +| 状态 | 可用 | 禁用或隐藏 | +|---|---|---| +| 已捕获 | 开始录音(模拟)、取消 | 应用 | +| 录音中 | 停止并识别(模拟)、取消 | 应用 | +| 预览(正常) | 应用、重新录音、取消 | - | +| 预览(空结果/失败) | 重新生成候选、重新录音、取消 | 应用禁用 | +| 失效(stale) | 重新捕获选区、取消 | 应用禁用,录音隐藏 | +| 已应用 | 恢复原文(仅一次)、关闭 | 应用隐藏 | +| 已恢复 | 关闭 | 恢复禁用 | +| 结果不确定 | 关闭 | 应用、重试、录音全部不可用;不自动重试 | +| 已取消 | 重新捕获选区、关闭 | 原文未改动 | + +候选失败只允许重算候选,绝不写宿主。恢复时若夹具设为“用户已改动/换焦点”,则拒绝恢复并消耗该次机会,不写入。 + +## 键盘与无障碍 +- Esc 在应用前取消;Enter 只会激活当前聚焦按钮,文本区域按 Enter 无效果,不存在默认提交。 +- 状态变化后焦点移到面板标题;原文与候选为可聚焦的滚动区域(`tabindex=0`、`role=region`),长文本 `max-height` 内滚动,不裁切。 +- 状态消息写入 `role=status` 的 `aria-live=polite` 区域;步骤用 `aria-current="step"`;全部为原生 `button`/`input`。 +- 状态不只靠颜色:有文字、禁用态与“(已完成)”标记。浅色/深色跟随系统。 + +## 字体与色彩 +系统字体栈(`-apple-system`、PingFang SC),不加载外部字体;正文 15px,选区文本 17px,行高 1.6 至 1.7。中性暖灰底,单一深青绿强调色用于“应用”,红/琥珀仅用于错误/警示。无渐变、阴影、徽章、装饰动画。 + +## 夹具控制 +候选:正常/空结果/失败;选区:示例句/超长文本;应用:成功/不确定;确认后宿主:未变/已改动;按钮“模拟:宿主选区已改变”触发失效;“重置原型”。 + +## 未知项 +- 口令是否可编辑,仍待决定(原型为只读,仅可重新录音)。 +- 真实面板形态(浮层/独立窗口)、焦点是否离开宿主,需真实宿主测试。 +- 未做 VoiceOver、真实窗口浅/深色、窄宽度截图;仅做脚本语法检查。 +- 恢复条件(同目标且文本等于候选)在此只是夹具开关,真实判定见 spec。 + +## Lead rendered audit (2026-10-08) + +Actual T3 browser clicks/screenshots done. Final acceptance: NOT PASSED. Original author did not fully read ANTI; correction native-cancelled at budget. Leader reread full1599lines before/after. Issues: +- uncertain关闭会reset到captured,丢失未解决警告 +- Apply handler只检查stale,缺少phase/empty/replayguard +- Enter文案声称只有点击,实际焦点按钮可Enter激活 +- 缺少真实host mutation fixture,应用/恢复只切状态 +- filled/outline按钮对、beige、hairline boxes 尚待Cursor修正 +- dark/narrow/VoiceOver/完整真实宿主未验收 diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/verification.md b/docs/sdlc/changes/2026-10-08-voice-editing-research/verification.md new file mode 100644 index 00000000..858cd09d --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/verification.md @@ -0,0 +1,361 @@ +# Verification: 首轮研究检查 + +**Status:** draft +**Approved-by:** — +**Approved-date:** — +**Upstream:** [plan](plan.md) + +本轮仅研究与文档,未实现候选功能。官方网页实际浏览、视频抽帧、源码核对与 T3 durable 状态属于研究证据,不是产品 E2E 通过。详细分类见 [source evidence](source-evidence.md)。 + +检查和子任务回收结果在本文件收尾追加。任何未来实现必须绑定准确源码/包哈希重新执行 SDLC/basic/Swift tests、真实宿主与设备验证。原生产线已有失败与旧包通过范围保留,不平移。 + +## 首轮收尾结果 + +- SDLC checks passed。workflow JSON解析、git diff --check通过;git status仅新研究目录未跟踪,产品源码未改。 +- basic检查触发swift-run依赖解析,预算内未完成,已用native write_stdin Ctrl-C取消并返回exit130。日志保留,不报通过。 +- swift test --skip-build --disable-automatic-resolution仍开始创建依赖working copy,12秒timeout后终止,未执行测试。无缓存测试通过可借用,完整检查留在后续实现验收。产生的ignored .build缓存未删,避免破坏其它证据。 +- 唯一T3 Apple子任务completed、hasPendingChildRuns=false;产物和源分类已核对。子任务截止超过13秒,已记录;未再派返工,父预算仍按最初startedAt收尾。 +- 完整复读ANTI_SLOP并逐标题审计,详细结果ignored .local/voicecursor-research/anti-slop-audit.json。本轮无自制UI,视觉风格条目不适用;真实交互范围只有官网导航和视频seek,没有把宣传面板作为可操作Utter原型。没有产品界面可修或可验收。 +- 14张官方视频/隐私页截图有SHA256映射;没有读取/发布私密宿主内容。未用iPad,不改原工作区,不commit/push/PR/deploy,无watcher或定时器。 +- 结论停在intent pending approval。仍缺VoiceCursor客户端实测、Utter新契约宿主E2E、签名Release/移动T0闭合和发布批准。 + +## Run 2 authorization, budget correction and coordination + +用户整体授权已记录,首轮 pending approval 已被取代。上轮父 native startedAt21:09:56.301Z、completedAt21:20:51.594Z,实际超55.293秒;Apple任务 native 超56.6秒,产物+13秒不能证明原生准时。已纠正 workflow,不重复误判。 +原线 ordinal24 completed21:59:36.954Z,cursor2617,HEAD ed4234b4,38个tracked改动未复制。iPad空闲但T0未通过;研究不使用设备。 +UI首稿 admitted only first400/1599 ANTI_SLOP lines and no render,未完全验收,派一次有界Cursor修正;负责人已完整前读,实际浏览器点击模拟应用/一次恢复/空答/失效。模拟不是产品宿主通过。 +apply_patch重复delete/add同路径报invalid patch,未写入;改用一次Update修正,无产品损坏。 + +## Run 2 retained candidate and final audit + +Base ed4234b41764be8346ae58cdc63011c8f26a0f1e; diff SHA256 06424f638677956353ff65fcfef6745fdfa08fa9df7ce93f44be4f242df39d8b. Product increment +55/-7 in6files uncommitted. Swift frontend parse and diff check passed; SDLC passed. basic/swift-test each8s timeout→SIGTERM -15; process-group terminal verified, no pass. No package candidate, Release or real host/device tests. + +Browser click checks: recording/recognized instruction, apply state, one-use restore state, empty/error apply disabled, stale blocked, long800-code-unit content scroll without horizontal overflow, cancel clearscandidate, uncertain hides apply/restore. These are simulated state checks only. Before/after screenshots copied ignored ui-r2-before.png/ui-r2-after.png. + +Lead full ANTI_SLOP read before and after UI review. Final audit NOT PASSED; concrete pending issues: +- uncertain关闭会reset到captured,丢失未解决警告 +- Apply handler只检查stale,缺少phase/empty/replayguard +- Enter文案声称只有点击,实际焦点按钮可Enter激活 +- 缺少真实host mutation fixture,应用/恢复只切状态 +- filled/outline按钮对、beige、hairline boxes 尚待Cursor修正 +- dark/narrow/VoiceOver/完整真实宿主未验收 + +UI task completed22:00:22.610Z; receipt interrupted22:02:46.245Z (+1.245s child cutoff); Cursor correction interrupted22:02:46.306Z (+1.306s). All hasPendingChildRuns=false, no task active. Native completedAt used, not artifact writes. Task IDs and owners in workflow/raw state. Parent future native terminal time deliberately null until root checks; close artifact time not terminal. + +## Run 3 source review and bounded scope + +Root复核Run2 native开始21:55:56.728Z、结束22:05:40.154Z,累计583.426s,未超600s。6文件+55/-7与diff哈希已现场确认;只按partial retained接受。Run3 native开始22:08:15.147Z,22:16:15.147Z停止扩展,22:18:15.147Z硬截止。 + +已重新完整读AGENTS/SDLC/istack路由与委派规则、编排技能、Ponytail full、spec/plan/workflow/verification、Run2原型及失败日志。负责人完整前读并完整后读ANTI_SLOP 1-1599行;原型仍需真实交互和逐项审计,未把阅读当验收。UI纠错为同因第2次,不再派泛泛重审。 + +Data只读df测10689784KiB(约10.19GiB),本轮不运行重型构建。原production durable仍ordinal24/cursor2617,无新增活动,HEAD相同,38个tracked改动与多项untracked保留,只读未复制。旧iPad空闲不是当前预约保证,本轮无设备操作。 + +负责人源码核对观察:direct selectionEdit与spoken replace/rewriteSelection均改为等待当前Session提案;Apply检查session/proposal与单次continuation,原Job再核对lease。目标lease增加整篇AX值比较,需下一轮审查是否不必要影响普通听写、长文性能与全篇内存隐私;既有AX state只保存选区和各500 UTF16周边。真实失焦/焦点返回尚未证明。原生UI仅有状态和动作桥,调用搜索未发现实际审阅按钮消费,不能宣称桌面流程闭合。新增protocol requirement的所有生产与测试conformance需完整typecheck验证。 + +读取原线程时一次ID拼写错误thread_not_found,已用正确ID重读成功,未推断线程消失;一次猜测VoiceSessionJob+Output.swift路径不存在,未编辑该路径。未重启provider、未注册付费/认证/权限修改。 + +## Run 3 retained result and final audit + +A/B both native interrupted before their deadline: B22:09:50.382Z→22:15:06.263Z (315.881s), A22:12:32.599Z→22:15:06.314Z (153.715s). activeRunId null, pendingRequestCount0, hasPendingChildRuns false. Proactive native stop; no new tasks after expansion cutoff. All task/clientRequest IDs and native state saved run3-state/workflow. A delegated late in parent budget, only153.715s actual; budget did not reset, complete correction/audit was not achieved. + +B actual-source synthetic-boundary behavior check passed for pending/no write before Apply, one-use/wrong-ID, Cancel, stale lease, replaced session, empty result, expiry and Unicode preservation. This harness imports production control/review sources but stubs target/mic/callbacks and counts continuation completion; does NOT perform actual host mutation, driver settlement, receipt restoration or model failures. Runtime/Contracts/MediaContracts/Session/PresentationContracts swiftc emit-module probes passed with synthetic Bundle.module shim; initial MainActor anchor.text compiler error was corrected at SessionOutputState owner. Native actions/output/lease parse, localization lint/module boundaries and SDLC passed; full SwiftPM build/basic/Swift test and native UI/host/Release still not run or passed. Raw child commands and compile logs preserved. + +A lead pointer checks: fixed-data generation→Apply truly mutates simulated host; Restore recovers exact original once; post-edit disables Restore; uncertain Close/Reopen persists warning and blocks Apply. Source handler checks phase/nonempty/current target/text/range/focus/transaction. NOT ACCEPTED: recapture and close after applied reset/newHost and erase subsequent simulated input; closing changes host without incrementing writes. Restore lacks range check. Cursor final audit not delivered. Lead complete ANTI reread and per-point audit saved anti-slop-audit-r3.json, passed=false; dark/narrow/contrast/VoiceOver and remaining invalid-handler scenarios not measured. No third same-cause generic audit spawned. + +HEAD ed4234b41764be8346ae58cdc63011c8f26a0f1e; tracked binary diff SHA256 11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83; complete changed-source manifest SHA256 f64965163a8c900c3a233dcd9ff6dd3f8133189378c27d4c813afa6f0c36c831. Two new Swift files included in manifest, not in tracked diff. No binary package candidate. + +No actual host/device use, no permission/auth/paid/install or production-worktree mutations, no commit/push/PR/release. Next: bounded full compile+necessary fixes, concrete native presentation wiring and its privacy/focus review, independent real-host safety E2E; mobile stays dependent on originalowner real T0/device reservation. Native parent completedAt remains null until externally observed; artifact write time never used as terminal time. + + +## Run4 — real full compile evidence (2026-10-07T22:30:32.386605+00:00) + +Overall user authorization continues; this is not actual acceptance. Result: partial retained, full compile NOT PASSED. The unshimmed complete SwiftPM command and logs are in [build-result-r4.md](build-result-r4.md). Build ran 22:24:35.256062Z–22:25:22.997666Z; minimum Data free 9.997257 GiB triggered the existing 10 GiB guard, own PGID was interrupted/reaped, build exit -2. No compiler error confirmed and no source/test changes made. SDLC and git diff --check passed; basic and swift test not run; no UI/host/device/Release acceptance. + +HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`; tracked binary diff SHA256 `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`. Full repository source/script/resource manifest: 846 files, canonical SHA256 `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`, unchanged=True. Child's narrower 787 compile-source manifest is separately labeled in its report. Build log SHA256 `28023756db0943baae95ad3937460936d3851f3763695b3e74083ec86c7d837c`. Raw: `.local/voicecursor-research/run4-build/` and `Run4-state.json`. + +Only child task `node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-full-build-20261008-r4` native started22:22:35.633Z, completed22:28:42.038Z (366.405s); activeRunId null/pendingRequestCount0/hasPendingChildRuns false. It met hard22:28:50, missed target22:28:20 by22.038s. Parent context compaction delayed scheduled22:28:30 proactive cancellation; no cancellation was actually issued and this deviation is retained. Known own command PIDs remaining: []. Parent started22:21:05.508Z; actual parent native completion must be read by root after termination, never inferred from this artifact timestamp. + +Next compile unit: verify existing writable APFS `EXTERNAL_VOLUME` (read-only inventory showed about434 GiB free) for isolated scratch/module caches and exact local dependency reuse without new download. No write/build on that volume was performed. Current active developer directory is CommandLineTools; `INSTALLED_XCODE_DEVELOPER_DIR` exists, but Xcode selection/build has not been tested. Complete full compilation/basic/tests first, then authorized native review UI via Cursor and independent real-host verification. Mobile remains original production owner's T0/device-window dependency. No commit/push/PR/release, no other agent/device/production changes. + + +## Run5 external preparation accepted only + +[Executor report](build-preparation-r5.md) SHA256 `3cde7ff845c813e96ec9eb74a0d910f2c02c0e6bba4b01bfeaa3071c5ffce46a`. Seven task-owned directory write/fsync/read/hash probes and volume identity checks passed; local19pins/clean Git checkouts/submodule revisions/local commits verified. All8254 snapshot files,145334442bytes, independently read and compared by owner.164 command-log SHA checks matched. No product source changes: HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`, diff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`, full846 manifest `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50` unchanged. Device UUID/absolute directory mapping and all raw evidence remain ignored `.local/voicecursor-research/`. + +Data was below10GiB initially; daemon blocked and latest cleanup0 at22:37. Later fresh22:39 read showed daemon ok and Data above10GiB; this thread did not clean or change daemon. Final22:42:28 df free 11206709248bytes (10.437061GiB). No build started because remaining supervised expansion window after preparation was below3minutes. Source snapshots lack Git metadata/SwiftPM registration. Real offline initialization, compiler cache/temp/scratch placement, complete build/basic/tests/UI/host/Release remain NOT RUN. Help/file-copy success does not prove these. + +Child `node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-external-cache-20261008-r5` native22:36:11.439Z→22:41:54.220Z completed342.781s, activeRunId null/pending0/hasPendingChildRuns false. Target22:41:45 missed9.220s, hard22:42:15 met. Proactive cancellation requested around22:41:54 in completion race; durable completed retained, not falsely recorded interrupted. All known own command processes absent; owner audits returned exit0. Parentnative started22:34:50.880Z, final time left for root durable read. + +Retained errors: wrong rule/workflow paths corrected; exact repository name match fixed; Git submodule directory handling fixed; wrong preliminary count corrected to8254. Two prepare-script resumes were distinct fixes, not build/disk cleanup retries. No Data deletion, global configuration, user-data/model/auth/permissions/paid changes; no UI/device/production-worktree edits or commit/push/PR/release. + +Next executable unit: Fresh bounded Codex run: revalidate source and Data>=10GiB; create exact local Git working copies or task-specific mirrors from verified repositories with no network/global changes; prove real SwiftPM offline registration; then one complete build with external scratch/module caches/temp and unchanged live10GiB guard. Do not run current configuration draft before registration proof. Complete compile precedes Cursor native review/focus/privacy, then independent actual-host safety; mobile remains original-owner T0 and device reservation, manual return never passes T0. User overall authorization remains active; no new ordinary approval gate. + +Owner final SDLC and git diff --check after documentation updates both exit0 at22:43:30Z. Logs and exact hashes in ignored run5-owner-checks.json. Parent native terminal remains externally observable only after this turn ends. + + +## Run6 精确本地 Git 依赖验收与空间阻塞 + +[执行报告](offline-registration-r6.md) SHA256 `58af8e3fd7d88ccd6679c24e6deddaee9a3ceb624c54ad398970012c08d6aea2`。19个实际 Git mirrors/working copies 含精确子模块通过 revision、metadata、clean 和源码比较。负责人独立读取19个 HEAD 与全部8254文件,并重新读取1426个原依赖 Git metadata,均一致;9项外盘探针及volume绑定复核通过,362条命令日志哈希无差异。原Git metadata canonical SHA256 `bf5967b0393dd394c7c0b61a14d316ad47281695e25ce5ee15b7e8a0a02c0185`。原依赖、Run5快照和产品源码没有修改。 + +HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`;tracked diff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`;846个源/script/resource manifest `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`前后未变。Package和lock未改。所有设备绝对路径/卷UUID、命令环境、原始日志、失败及哈希仅在ignored `.local/voicecursor-research/run6-offline/`及Run6-state.json。 + +最新22:57:46.895Z Data 10269753344bytes (9.564453GiB),低于10737418240bytes;daemon blocked、last_cleanup_freed0、state age23.569s。没有用Run5旧恢复样本启动执行;未清理Data、改daemon/白名单、等待恢复或重试build。SwiftPM离线注册、compiler落点、完整build/basic/tests/Release均NOT RUN,不能把Git准备成功称SwiftPM ready。所有Git clone源是本机绝对路径,process GIT_ALLOW_PROTOCOL=file约束;未调用fetch/远程子模块update/download,无全机网络流量断言。 + +唯一child `node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-registration-20261008-r6` native22:51:56.717Z→22:56:54.385Z completed297.668s,activeRunId null/pending0/hasPendingfalse,目标57:15与hard57:45均达标,无需取消。同一ordinal1收到一次预算检查点steer,没有新轮。已知命令PID全部终止,owner审查命令已exit0。父native started22:50:25.816Z,终态只由根durable读,artifact写入时间不当terminal。 + +真实错误:首稿把MLX目录当作submodule名称,使用实际.gitmodules修复一次,原失败保留。早期大输出截断/JSON字段误读纠正,无凭猜测产品代码修改。Run5父native22:34:50.880→22:44:13.345Z562.465s已按根durable纠正;cancel/completion竞态仍记真实completed。 + +下一执行单元:Do not repeat registration/build while Data is below10GiB. After a fresh adequate-space window, reuse the existing exact task-owned Git mirrors/working copies; bind installed Swift tool version, configure isolated SwiftPM paths and task-local mirrors or validated local state, prove actual offline registration, then one full build with continuous10GiB guard and at least3 supervised minutes. No metadata rebuild, cleanup or threshold reduction. 完整build后才进入Cursor原生review/focus/privacy,再独立真实宿主安全验收;移动仍归原owner T0/预约,不以手动返回通过。整体授权继续,无普通阶段新待批;本轮无UI/宿主/设备/production edits/commit/push/PR/release。 + + +## Run7 实际离线注册失败,外盘manifest产物部分验证 + +用户整体授权继续有效;无需重复普通阶段许可,实际验收仍未通过。[执行报告](swiftpm-result-r7.md)保留具体命令和错误。真实安装Swift6.2.3配置19个task-local映射通过;一次真实离线resolve于23:27:28.604691→23:27:43.800126Z exit1,实际Package.swift编译/链接输出后嵌套sandbox报`Operation not permitted`,不是产品源码错误。未改权限/transport或重试该错误。manifest输出路径和37个外盘产物由负责人独立读取哈希确认;产品scratch、Swift/Clang完整编译落点未通过。完整build/basic/swift test/UI/真实宿主/设备/Release均NOT RUN。 + +源码HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`、tracked binary diff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`、846文件manifest `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`均前后真实一致。负责人核对全部32日志SHA;32 command exit/ended完整且无已知自有PGID残留。原19Git与1426metadata沿用Run6验收,未重复8254扫描;不将无intentional writes当新全量metadata证明。 + +128个child磁盘样本最低10767134720bytes,未低于10GiB;owner23:29:27新样本10762665984bytes(10.023514GiB)。无清理、等待恢复、阈值降低、全局配置或原线/设备变化。quota unknown,Standard/default。 + +唯一child实际native23:25:22.406→23:29:06.424Z interrupted224.018s;比23:28:45截止超21.424s。负责人planned23:28:55取消未准点执行,23:29:06实际cancel后核验activeRunId null/pending0/hasPendingfalse。产物23:28:45不当native终态。父native started23:21:46.161;completedAt只留根durable读。所有详细派工和两次同ordinal监督反馈均在ignored.local文件。 + +下一步:下一有足够预算且fresh Data>=10GiB的单元,先诊断SwiftPM嵌套sandbox限制;本机help列有--disable-sandbox,可评估仅关闭内层SwiftPM sandbox且保留现有外层deny-network/file-only和相同权限的任务配置,不能声明已验证或绕过权限拒绝。成功后读取真实workspace全部19身份/revision,再保留至少3分钟做一次完整构建;不重建镜像/元数据。原生review/focus/privacy、真实宿主、签名Release与移动原owner T0/预约仍待实际验收。 + +Owner final SDLC/diff checks: sdlc exit0, diff exit0. Not product acceptance. Exact logs/hashes retained ignored run7-offline. + + +## Run8 容量阻塞,sandbox安全兼容性未证明 + +用户整体授权继续,无普通阶段重复待批。[子执行报告](sandbox-registration-r8.md) SHA256 `c3198dd874e5d15937ec6dd4bc4ad40c4b7d8e56008b9eedab12698a1462f1df`。child真实Data assertion10500861952bytes(9.779689789GiB),低于10GiB236556288bytes;durable失败事件updated23:38:19.944Z,实际measurement时间未保存,不能把事件/产物时间写成采样时间。owner稍后fresh样本高于floor不用于本轮重启。无清理/阈值降低/额外依赖下载/权限或transport修改。 + +Owner独立读匹配Swift6.2.3 Sandbox/ManifestLoader primary:原inner默认deny规则含文件写入/网络/其他操作限制;CLI flag不是安全证明。当前外层仅networkdeny。部署Applepatch与upstreamtagexact equivalence未证明;补偿profile/canary/livehelp/resolve/实际19workspace/fullbuild/basic/tests/UI/宿主/移动T0/Release均NOT RUN/unaccepted。原19Git及19CLI映射沿已验收事实保留,不重建8254/1426/362证据。 + +846 actualsourcehashes前后全部一致;HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`,trackedbinarydiff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`,manifest `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`。child没有自建PGID/longcommand/yieldedexecsession或递归任务;native已completed23:37:11.877→23:40:01.532(169.655s),activeRunId null/pending0/hasPendingfalse,未需cancel且截止通过。父native started23:35:59.034,终态留根durable读,artifact时间不当终态。 + +偏差保留:guard assert后的预备工具组仍读一次instruction及打开两primary源,违反立即停止后不做新metadata要求;没有config/canary/compile/resolve或外盘写入。子初始两个大输出截断,未完整保存逐命令stdout,不能称完整日志验收。owner一次runLimit0参数错误已改1;初始catalog显示过大;dispatch70.127s未达60saim。所有实际失败和来源文件留ignored.local。 + +下一可执行单元:本轮关闭,不因稍后空间回升重试。下个独立有界已授权单元先fresh Data>=10GiB;guard先持久化时间/bytes并立即退出,禁止assert后的同一shell/并行工具组继续metadata/source请求。复用19Git和19mirror mappings;从匹配6.2.3 primary和installedhelp核对innerdefault/cache权限,证明任务自有补偿profile保留网络/写入及其他限制,task canary全部通过才一次resolve;读取实际19workspace revision后再安排至少3分钟完整构建。未证明安全则blocked,不切权限/proxy/transport。 + +Owner final checks: sdlc exit0, diff exit0. Only documentation/diff validation, no product acceptance. Exact logs/hashes retained ignored run8-sandbox. + + +## Run9 可验证边界单元通过,注册context阻塞 + +整体授权有效,与实际验收分开。[执行报告](offline-unit-r9.md) SHA256 `47e30aff8db68d8ee6c1cbe0caf50edb80d1a673a4e0bc091f4d7d3e2ecaff4b`。实际Swift6.2.3/help/卷匹配通过;29child容量样本最低11344375808bytes均>=10GiB,未清理/等待/降阈值。默认profile及注册outerprofile批准写入通过,目录外写入/loopbacknetwork/POSIXsharedmemory均EPERM;这是这些操作的实测,不是exhaustiveprofile审核。narrowprofile nested true exit0;registrationprofile+taskenvironment nested true exit71,实际sandbox_apply Operationnotpermitted。inner未禁用;实际resolve0次,19workspace/fullbuild/basic/tests/UI/宿主/移动T0/Release均NOT RUN。无一般OS拒绝、T3approval rejection或需要扩权的结论。 + +Owner独立核对10日志SHA、两个profile/脚本及已知PGIDs终态无残留;846源码前后重新读取一致,HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`、binarydiff `11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83`、manifest `de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50`。外盘只写2个taskcachecanary文件,19Git/mirror与原source/lock/production/device/globalauthconfig未改。 + +Child native01:26:52.827→01:32:42.215 completed349.388s;activeRunId null/pending0/hasPendingfalse,hard33:00通过,target32:10超32.215s。planned32:20取消未执行;负责人较晚状态核对发现已completed,不能记准时取消/interrupted。父native started01:24:59.291,结束仅根durable读取,不拿产物时间充当terminal。初始规则/路径读取耗时和旧helper路径猜错保留;不重扫8254/1426/362旧证据。 + +精确剩余:本次同因纠错单元2/2已用,不自动Run10或重复泛泛审计。精确剩余条件:隔离同一机器上narrowprofile nested exit0与registrationprofile+taskenvironment nested exit71的具体差异,捕获installedSwiftPM实际manifest sandbox argv/环境;保持原inner保护与网络/文件限制,不改权限/proxy/transport。证明支持的完整context后才一个真实offline resolve、读实际19pins,再足够监督预算fullbuild;本轮不重试。现有移动原owner T0/预约、原生review/focus/privacy/真实宿主/Release目标保留,未验收。 + +Owner finalSDLC/diff: sdlc exit0, diff exit0. These checks do not prove build/registration/product acceptance. Exactlogs retained ignored run9-offline. + + +## Run10 finite diagnosis and fresh missing cache precondition + +[Owner report](context-build-r10.md). Four real cells: narrow baseline/task both0; registration baseline/task both71. Profile correlation only; exact rule/SwiftPM context unproven. Fresh Data~85.94GiB; external volume valid but Run5/Run6 task roots absent. Original19 checkout revisions and clean status pass, not fresh complete submodule/content registration proof. All846 source hashes unchanged and6loghashes match; known own PGIDs absent. No resolve/build/basic/tests/UI/host/Release/mobile validation. One child interrupted31:46.726→35:24.619217.893s; activeRun null/pending0/hasPendingfalse, hardmet but targetmiss14.619s and plannedcancel about9s late. Parent starts00:27:30.405; nativecompletion leftnull untilrootdurable. Initial read-only guard correction and final failed metadata command retained. No source/production/device/globalconfig changes. Overall authorization remains, no ordinary permission gate. + +Owner final SDLC and diff checks both exit0 at00:36:43Z; documentation/diff checks only. Logs and hashes retained in run10-context/owner-final-checks.json; all commands reaped. + + +## Run11 exact rule/context evidence, execution boundary unresolved + +[Executor report](rule-context-r11.md). Owner accepted20loghashes/846unchangedsource,9namedprobes and installeddebug-generatedinnerargv/profile. Different-policy reapplication fails evenwithallinnerwritegrantsremoved; actualkernel/individualpathcauseunproven. Currentouter file/network/shm canary passes testedops only;2actualmanifestdiagnostics exit1, resolve0, fullbuild/basic/tests/UI/host/mobile/Release NOTRUN. Onechildcompleted41:28.651→47:26.860358.209s, active/pending0/hasPendingfalse; target/hardmet/no cancellation needed.485capacities min92222947328bytes. No depsreused/copies/external scaffolds or source/permission/globalconfig changes. Parentnative40:17.030, actualterminalrootdurableonly. Raw/state and rootpersistentresult preserve chronology/errors. Next bounded samepolicy+directory/protection compatibility must pass beforeoffline registration/fullcompile; noautonomousRun12. + + +## Run12 incomplete, no new technical execution + +Owner source846 hashes unchanged. Recovery/reads delayed child dispatch383.394s. Child interrupted13:16:55.529→13:18:37.779102.250s; active/pending0/hasPendingfalse, hardmiss7.779s, plannedcancel approximately22s late. No compatibility/canary/manifest/resolve/build/test/UI/device/Release result; failed final preflight retained in durable/raw. Old coordinator checkout/handoff absent; current ignored worktree result retained. Parent nativeordinal16 started13:10:32.135, terminal root durable only. No product changes or autonomous following run. + + +## Run13 bounded protected rootmanifest PASS + +[Report](manifest-compat-r13.md). Realexit0/parsedOpenType41targets owner independently matched native log. Network/file/shm canaries pass testedops; inner retained;10loghashes/846source unchanged. Fullresolve/build/tests/UI/host/mobile/Release NOTRUN. Childcompleted34:38.466; targetmiss28.466s/hardmet, plannedcancel34:20missed. Actualparentterminalrootdurable only. + +## Completion execution checkpoint — 2026-10-14 + +旧轮次证据保留,当前取消10分钟cap不追溯改写历史超时。父started2026-10-14T01:40:10.032Z;实际terminal仍待durable观察。开始HEAD ed4234b41764be8346ae58cdc63011c8f26a0f1e、846源manifest de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50;当前runtime源码继续变化,旧hash不证明新候选。 + +真实offline resolve第一次exit1来自MLX子模块URL前缀映射;task-only映射纠正后本地子模块注册通过,第二次exit1来自yyjson tools5.0旧native sandbox与外层two-write-path不同,sandbox_apply EPERM。原依赖/manifest/lock未改,未关闭保护或扩大写入。完整build/basic/tests尚未通过。具体日志/hash/raw命令在ignored completion目录,尚非产品闭环。 + +Cursor首任务native01:49:52.803Z→01:51:38.237Z failed,公开错误Provider turn failed,activeRun null/pending0,无UI产物。原配置重试1已经开始01:59:41.586Z并实际读源码;仍待真实终态/原生交互验收。typedrestoreAPI已经写入同一Session/output链,源审查不是行为测试。 + +根/移动owner确认iPad不可用:不查询/预约/安装/启动设备。原生产Debug Info.plist和旧build目录缺失,旧包hash无效;T0与真机结果仍未验证。origin/main实测f0ef2aa88296da017e29ced2b7bc9271af96e4fb,比研究HEAD多10commits,最终普通merge后重新验证。未commit/push/PR/release。 + +### History interpretation correction + +移动owner指出并现场复核:HEAD/main实际分叉6/10,merge-base c383b25d27d652e8da8fe58f594578b1bb603e5a。早先“只落后10commits/main删除移动代码”的判断错误,已撤回;三点main diff不含Mobile/Bridge/iOS/Package修改。实际共享冲突是SessionJobControl.stop移除与移动duration timer调用,交共享runtime owner给精确支持API。保留历史源代码,无merge/reset/rebase/force操作。 + +### Explicit build-boundary decision + +用户对具体提案回答“允许此构建隔离调整”。仅本任务构建移除自建外层sandbox,保留SwiftPM原生sandbox、准确本地固定依赖、禁止下载和10GiB保护;不改系统权限/认证/全局配置。旧受保护resolve失败事实不改写,后续真实完整build结果另记。 + +### Native UI child partial retained + +Cursor retry taskcompleted,hasPendingfalse、activeRunnull/pending0;最新durable startedAt02:15:00.021Z、completedAt02:22:08.271Z,同ordinal首次观察started01:59:41.586Z,steer后started字段变化,不虚构单一elapsed或旧10分钟合规。源码审阅/恢复面板与双语文案已落盘,isolated swiftc render fixture25pass/0fail/1unverified(VoiceOver),imports stripped且非full product/hostproof。 + +未验收:handoff前未完整重读ANTI、无真实TextEdit焦点/非keypanelEscape、VoiceOver/增强对比/减少透明、完整产品构建。远程before-upload披露视图与PresentationContext正式回调仍待runtimeAPI,临时downcast未移除。后续只完成这些具体集成和真实native/host审计,不派第三次泛泛HTML审计。当前接受范围仅partial source/render retained,不是功能完成。 + +### Actual local dependency registration milestone + +用户允许边界调整后,真实SwiftPM resolve02:29:38.181Z→02:29:50.386Z exit0、processGroupEndedtrue、无stopReason。logSHA256 e1c9755106ee6e7b0e203913591c6ddc461c135cc0c7c0737febe72efb2b3244。自建外层未启用,原生manifest sandbox仍启用;使用原19pins/localfilemirrors,禁netrc/keychain/update/prefetch/prebuilts,禁止下载。完整编译/basic/test仍待真实结果,不以resolve代替。 + +PortableSelectionEditRecipe已实际移动共享prompt/instruction/options/normalization,Native和mobile消费同一逻辑;旧ModeServices.edit forwarding未注册建议撤回。当前sharedAPIhashpacket仅SessionControl一处与正在修订源码不一致,尚未向mobile导入旧packet;source review不是build验收。新Cursor具体nativeintegration task仅接remote披露/正式Context回调/live语言和完整审计,不泛泛HTML审计。 + + +### Current 2026-10-14 candidate gates + +Actual whole SwiftPM build passed under the user-authorized outer-wrapper adjustment with native sandboxes retained. The intermediate selected-retention build and root suite passed1174XCTest/18skipped/0failure plus1SwiftTesting. These unmerged results do not validate later Models/UI/mobile/main changes or packaged runtime. Build artifacts are now preserved immediately by the execution wrapper; the earlier delayed raw binary copy failed source/binary binding and is explicitly rejected. + +Cursor native-integration task completed03:31:02.170Z, no active run/pending child work. Required6PresentationContext callbacks and before-upload disclosure are wired, downcast removed, live-language update implemented.49isolated native-fixture checks passed,3unverified: real non-key Escape, VoiceOver, and contrast/transparency/material over actual content. Compiled-bundle, hold-mode Session/host, TextEdit/browser and actual delivery/restoration remain pending. Source manifest and audits are retained in ignored UI integration evidence. + +Original mobile owner's precise checkpoint retains39realBridgeXCTest/16offlineT0/16proxy-stub passes and the latest unsignediOS build-for-testing success; formal and compiled probe sources differ only the temporary Plist, which was restored. These are local compile/logic evidence only. Shared import is held until exact API packet review; MobileJob/UI are not wired. User device constraint remains no query/reservation/install/launch and physical T0 is deferred. + +The mobile current complete suite failed:1150XCTest,18skipped,1failure plus1SwiftTesting pass. ConfuciusModelDownloader emitted finalprogress96instead103 while the103byte URLProtocol fixture assembled correctly. Earlier mobile1148green and desktop1174green do not override this current failure. A bounded two-file Models owner correction and deterministic progress check are assigned to the current runtime/build executor, with final candidate suite rerun required. No real model download, device actions, production acceptance, commit or PR has occurred. + + +### Settled owned build and independent API review + +Codex owned build task native completed04:00:27.967Z after01:45:34.534Z start; no active run/pending work. Actual fullbuild/basic/SDLC passed on retained unmerged HEAD; full root suite1175XCTest cases,18skipped,zero failures plus1SwiftTesting pass after Models correction. Parent checked raw log hashes and immediate preserved artifact files. This does not resolve originalmobile redcandidate without import/retest or validate finalmerged/package/host. Task terminal summary included an incomplete trailing sentence; the prior complete result and persistent build report/command records supply actual evidence, not that fragment. + +Independent API reviewer native completed03:59:32.947Z, binding stable, no source changes. Complete mobile import was refused. API-01: empty final_text or Action-only JSON can reach a nonempty candidate through metadata text fallback; explicitApply remains required, so this is not observed automatic deletion. API-02: the3hash-bound small patches are building blocks and omit requiredreview/control/typedrestore interfaces; keyboard effect patch also assumes an intermediate receipt baseline. Current import remains held. One scoped corrective task covers strict selection behavior through actualgenerator and upstreamremote response normalization, local controlled regressions, and a complete exact portable base-to-result patch. Ordinarydictation/command behavior is preserved and no live download/remote request is authorized. + + +### Exact typed-output correction and complete r3 import acceptance + +The independent R2 review accepted the complete 38-path shared scope but rejected typed-block fallback. The final scoped correction changes exactly two production lines, retaining typed JSON before final parsing and preventing strict ambiguous fallback at every recursive object. Expanded existing fixtures ran red first, 11 tests/93 assertion failures. After correction actual 125 focused tests passed; whole real SwiftPM build/basic/SDLC/diff passed; whole root suite1182 XCTest,18 skipped,0 failures plus1 Swift Testing passed. Exact source aggregate `75db8c7886a508bfd951356e2c1f19f7263ba375dcdd92f81a6163e53dae7516`. Native child completed04:47:42.724–05:00:27.504Z, no active/pending child. Old ten-minute cap remains superseded. Same-cause correction2/2 ends here. + +Parent independently verified869 current/preserved source files,31 preserved binaries/resources, eight command/log hash pairs, real index integrity and isolated base-to-result apply for shared38/Models2. Complete r3 packet SHA256 `35be2da32bf4baca1277ac30fa0ed5bd86cb81bec667439cfb0d99e25b7d0627`; shared patch `a38f07e1100b7e6b5c57949b404392f3246f79ef4c7c375c6c20a27af3088071`. Models patch bytes are unchanged. Parent technical acknowledgement authorizes exact mechanical import to the existing mobile owner under user overall authorization. It is not human spec/UI approval or product acceptance. Prior r1/r2 shared packets are superseded. + +Original owner ordinal29 is active in its own workspace. Same MobileJob/real bridge/keyboard review, explicitApply/Cancel, full native readback acknowledgement and typed once-restore remain its owned integration/export. Formal T0 source/config binding and mobile current full-suite rerun remain required. Device query/reservation/install/launch remain forbidden; physical acceptance deferred. Final main/mobile candidate, Release Metal/signature and real host/privacy/accessibility are not yet passed. Full raw witness, failures and exact device paths stay in ignored completion evidence. + + +### Normal main merge and preliminary signed Release candidate + +Normal merge remains pending commit, HEAD `ed4234b41764be8346ae58cdc63011c8f26a0f1e`, MERGE_HEAD/current origin-main `f0ef2aa88296da017e29ced2b7bc9271af96e4fb`; no unmerged index entries. Scoped reversible checkpoint retains original index,100 owned dirty paths and ignored evidence. Three actual overlaps, bilingual localization and VoiceSessionJob+Capture, were reconciled with three-way comparison. No reset/rebase/force or original mobile worktree copy. + +Real xcodebuild Release/OpenType/arm64 compiled and assembled existing pipeline, Metal and41-file app. Explicit existing Apple Development signature, hardened runtime, ownedresources/helper and verify-release-artifact passed. Canonical app file-map hash `a734ceb8e3a70866c4d29dc5adbae7b8a4621be2b613566a46856bc7b02b6eee`; Metal hash `0214ad23cc2fa1df961eb7e2118881bd4b2bdb0183546ef537fff1a2a61e2ada`. Not DeveloperID/notarized/distribution or DMG proof. Only executor-owned tracked edit extends existing build-app.sh with optional task cache/derived-data/dist paths; no second pipeline. + +Source aggregate `6f4040fd8a58ced9b0747b239291237d2aab11d6d384a8b229057878e82d9558`,880 code paths. Complete SwiftPM build and root1216 XCTest/18skips/0failure+1SwiftTesting, basic/SDLC/bothdiffchecks passed. Parent verified1349 source-state entries including3 deliberate deletions, actual source archive/currentfile hashes,41 appfile hashes,24 command/log hashes and precise pendingmerge. Parent checker metadata assumptions failed on deleted entries, canonical-versus-file JSON digest and empty missing-list type; these validation errors are retained and corrected without product changes. + +Xcode emitted an error-like UtterModels diagnostic whose referenced command exited0 with no further output; actual pipeline exit0 and preserved app/resources/signature/fullnativechecks passed. Cause unresolved and raw bytes retained, no speculative sourcefix/buildretry. First executor closure audit failed on raw nonUTF8trace; decode-for-display correction passed, original log/hash retained. Native task05:10:24.011–05:33:06.578Z completed, no active/pending child, all owned commands ended. Lowest Data29.365GiB. Local19pins/native sandbox/no dependency downloads retained. + +This is preliminary compile/package proof, not final mobile candidate or real host/UI/capture/privacy/accessibility acceptance. Original mobile owner has now imported accurate sharedr3; its complete owneddelta remains in progress. Devices remain no query/reservation/install/launch. Current SDLC prose updated after the preserved preliminary manifest; product code/package unchanged. Latest user directs results to this thread only; no further root notifications. + + +### Independent native verifier refuses acceptance with concrete regressions + +Verifier native05:43:06.110–06:14:37.688Z completed, no active/pending;13 command/log hash pairs checked. GenuineSwiftPM owner modules/resources compiled; only4 new scoped regression/test-helper files added. Finalfocused4cases have2failures/10assertions; complete current1220 XCTest/18skips/2failedcases plus1SwiftTesting. Prior1216cases have no failedcase, but oldergreen cannotclearnewsecurity failures. Source880 preliminarypaths unchanged byverifier. + +NH01: realTextDeliveryTransaction withcontrolledtarget/directwriteadapter accepts when secureinputtruebeforeorafterprepare. AXdirect branch andintegrityre-read lackfreshguard. Noactualexternal/privatewriteperformed. NH02: actualnativeMac target refusal underactualsecureinput is suppressed byfactorytry?; actualDriver/Factory sendsone controlledscreen request/generation/output andhistory. NoactualOCR/network/hostwrite. Parentreviewconfirms bothrootlocations; scopedNativeMac/NativeSessionownerfix assigned, keeprealredtests untilpass. Ordinaryvalid/unselected/integration/explicitcopy policies muststillbechecked. Sharedportable38/API/Models/UI remain separate. + +NH03: whole test emitted Contacts persistenceXPC/CoreData connection4097exception. No successfulContactscontentread/mutationproven; injectionalone doesn'tprovehardprivate-store isolation. NoContactsdatabaseinspected orpermissionrequested. Separate task-onlytest-runtime restriction diagnosis usesfrozencompiledartifacts/read-onlyproduct, avoidingincompatibleouterSwiftPMBUILDwrapper. Complete native suitepaused until actualruntimeboundarycanaries andnarrowcompatibilityverified; originalnativeBUILDsandbox retained. + +RealSwiftPM nativecomponent reviewrender/Apply/Cancel processlocalNSEventdispatch passed on controlledtarget/output, actualexternaldeliveryblocked. Fourlight/darkownedreviewframes,904UTF16fulloriginal, unchangedgeneralclipboardchangeCount. ExternalAX/systempointer/Escape/VO/contrast/long-scrollend/narrow/actualnativeRestore remainnotrun/blocked. Parentviewedlight/darkframes; revisedcandidateisbelowlongoriginalinouterScrollView initially, notprovenunreachable. Concrete follow-upafterruntimefreeze required; no third genericHTMLaudit. + +Userreportedordinaryinput; nonpromptfreshprobe stillsecureInputtrue, AXtrusted, mic/SpeechnotDetermined. Read-onlyOSmetadataidentifies loginwindowascurrentSecureInputowner. UserMacminidesktopunlockclarificationpending; noagentSecureInput/TCC/OS/authchanges. Devicesstillnoquery/reserve/install/launch. Userresultsdirectonly, no rootreports. Both fix/isolationtasksboundedStandardSolXhigh, distinctfileowners/nochildrecursion; no commit/PR orproductionacceptance. + +User explicitly confirmed temporarily unable to unlock this Mac mini. Actualexternalhost acceptance is deferred/notpassed; no further unlock request or authentication/SecureInput/TCC change. Authorizedlocalfix/integration/build/review andPR continue, with exact remaininghost/device gates in the review record. + + +## Combined Draft PR checkpoint, 2026-10-14 + +The user explicitly changed delivery ordering: publish the combined Draft after exact mobile import and writer settlement, disclose current failures, and continue fixes on the same PR. This is PR creation authorization, not human verification, merge, signing, or production approval. Historical round limits and old green results remain historical. + +The runtime child completed natively at 07:35:29.300Z after starting at 06:58:28.476Z. It has no active run, pending request, or pending child run. Parent reread 888 source hashes, 8,940 frozen product files, 23 build-context files, all 34 command records and logs, and all 33 ended PGIDs. The complete real SwiftPM bundle and its resource context match. The retained private-runtime profile passed nine named-boundary canaries. One actual Configuration XCTest and one fixture isolation XCTest passed. Of 70 actual focused XCTest cases, 37 passed and 33 failed before behavior assertions because VoiceWorkflowFixture used the system temporary directory. Swift Testing failed to load twice, executed zero cases, and exhausted this unit's loader retry limit. Full suite readiness is false. No private store contents, real host text, authentication, or devices were inspected. + +NH01 and NH02 corrections preserve secure-input admission before AX reads and effects, and propagate local target capture refusal before command processing. Their earlier 70 focused passes are a different execution context; they do not replace the current guarded failures. Remaining work includes the confirmed fixture path, a proven SDK loader context, complete guarded tests, and concrete native review scroll, narrow-height, accessibility, and interaction checks. + +Parent imported exactly the independently reviewed mobile owned r3 whitelist: 38 frozen paths, packet SHA256 `6c8b195d4621534639929591d0dcfaa196bf4941ba7d234225b97c2ed4d11841`, patch SHA256 `30059d485a53a50b49a6aa8052af1bba9ebeb9fb9752273b68519d3181b459b0`. All current base, frozen source, isolated applied, and resulting hashes match; 1,340 other repository files and the real index stayed unchanged. Shared r3 and Models2 were not reapplied. Original dirty PiP, layout, scene-return experiments, privacy, icons, Plist, and other unrelated increments were not copied. + +The mobile owner's 51 bridge XCTest, 24 selected-method, 15 lifecycle, and 16 native writer checks, independent source review, and unsigned formal-Plist Debug compiler result apply to the pre-integration owned increment. The prior desktop 1,216 XCTest with 18 skips and zero failures, one Swift Testing case, and Apple Development signed Release-style package apply to an earlier source. None validates this combined Draft source. Final combined basic, whole tests, Release-style packaging, and iOS compiler checks are pending. Local SDLC and diff checks are recorded separately and do not certify product behavior. + +Actual external macOS host, microphone, recognition, focus, and restoration remain deferred because this Mac mini cannot be unlocked. Physical iPhone/iPad actions remain prohibited by the current device constraint. Mobile formal cold activation still requires manual host return; automatic return and T0 are not passed. Real native UI, VoiceOver, privacy broker coverage, distribution signing, notarization, human verification, protected merge, and production acceptance remain open. The PR stays Draft. Rollback disables selected editing and retires pending proposals and output authority without changing ordinary dictation or stored user data. + +Detailed local evidence is retained in ignored completion-20261014/complete-test-runtime and mobile-owned-controlled-import. The public review record intentionally omits device paths and credentials. PR creation will record its URL and exact pushed head in the local combined-pr-result files; PR121 remains untouched. + +Draft source binding covers 949 tracked source, test, resource, iOS, and build-script paths, plus Package.swift and Package.resolved. Sorted compact JSON of path-to-SHA256 values is `15551622b259cef2077ae2b39c4d1949f96cb332c966fbe28850b18c670c32f0`. Device paths and the full manifest remain ignored. SDLC passed; staged diff check initially found two whitespace issues, which were corrected without changing behavior, then passed. This draft candidate has no new combined full build or full suite result. + + +## PR124 fixture and CI crash correction + +Draft PR [124](https://github.com/IchenDEV/utter/pull/124) was published at `de895347d5fe4671db90f6c7aa4d3bbe9cb9b1d2`. That exact CI head passed SDLC, basic checks, compilation, the Release-style App Build job, and one Swift Testing case. XCTest aborted after 1,149 passing and 18 skipped cases because the native evidence helper force-unwrapped an absent environment variable. No completed full XCTest suite result exists for that run; SDLC Gate correctly failed its dependency aggregation. Its enforcement remains unchanged. + +The next child corrected four test-only files. VoiceWorkflowFixture and the shared PersonalDictionary now use the existing task fixture root. NativeE2EUI uses an optional explicit evidence path, otherwise a unique owned directory, creates logs/screenshots, and throws real IO failures. The regression exercises actual constructors, persistence, missing and explicit overrides, and failed directory IO. It adds no skip or expected failure and changes no production UI, Session, MacServices, package, dependency, mobile source, or workflow. + +A fresh complete real SwiftPM test bundle compiled with native build protection and the same private-runtime profile. Final exact canaries passed 9/9, the isolation case passed 1/1 without the evidence override, and the original focused set passed 70/70 with zero skips, including all four SecureSession cases. The first epoch retained a 69/70 result caused by missing evidence/logs; the original 33 system-temporary-directory failures and CI crash remain preserved. Parent independently matched 906 source paths, 8,976 complete frozen product files, 23 build-context files, and all 27 command log hashes. All 27 own PGIDs and the native child ended. Native start was 07:58:41.047Z, completion 08:21:54.864Z; the user had removed the old native time cap. Minimum Data free space was 14.29 GiB, above the unchanged 10 GiB floor. + +The final local source witness is `5e112f73ceb81242cf6f6217fbd77458c8cc4fcc0a91a6b75caf7c17f4dbbec9`. Complete frozen product tree is `e507f9870e5ad2afad7be8aeb41b0cfa53299b2ecd5f86e28c5b795fb881bfaa`. Full manifest, delta, literal behavior checks, logs, loader census, and parent acceptance are retained in ignored pr-fixture-fix evidence. The requested delta and terminal records were embedded in result.json; parent read them and extracted a separate review copy. + +Full local suite and native window events were not run. Local Swift Testing was not retried: its two-attempt budget remains exhausted. Read-only SDK closure evidence and GitHub's one actual Swift Testing pass are distinct from local dynamic-loader proof. Other temporary-directory consumers, complete privacy broker coverage, native UI scroll/narrow/accessibility, actual external host, physical mobile T0, final iOS compilation and distribution acceptance remain open. This correction requires a new CI head; earlier green checks are not transferred to it. + + +## Current Codex native UX verification, 2026-10-16 + +The user authorizes current Codex execution, safe deletion of proven regenerable caches, and updating PR124 while preserving the existing UX. This supersedes the old T3/capacity follow-up blocker; it grants no human acceptance. No product source, Session/output owner, mobile source, dependency, permission, workflow enforcement or signing configuration changed in this increment. + +Only two ignored, unopened SwiftPM-generated cache directories were removed after tag/ignore/open-file/frozen-root checks. Their prior allocated size was about175MiB. Data was already above10GiB at this turn's first sample; this cleanup did not recover10GiB. Current available space is about11.2GiB. Source,19 dependency checkouts, models, user data, Agent storage, archives and all verification evidence remain. Every complete build and test used the retained10GiB capacity guard and local-only dependencies; mutable products/module/temp caches stayed on the verified task-owned external volume. + +| Check | Exact local result | Scope | +|---|---|---| +| Whole real SwiftPM build-tests | Passed | Native SwiftPM sandbox, real resources/modules,19 pinned local dependencies, no resource shim | +| Basic and SDLC | Passed | Actual repository scripts; isolated SwiftPM paths | +| Runtime canaries |9/9 passed | Same retained private-runtime policy; only the named tested boundaries | +| New native review tests |6/6 passed,0 skipped | Real production views, AX labels/content order/actions, expired/repeated Apply, one-use Restore, failure notice/Close, process-local Escape and no Return default | +| Original output/security/session set |71/71 passed,0 skipped |70 real owner tests plus the compiled Session/native pointer Apply/Cancel test; controlled target/output adapters | +| Native visual inspection |44 native PNGs inspected |36 scroll captures across280/320/420points × English/Chinese × light/dark;4 result/notice captures;4 actual composed-panel captures | + +Working-tree source manifest: `a763c64af43c95c62dda84ecc4cb4b6b944fc9b7fddd0217c7f39151be22ae66`. Complete8,984-file frozen product manifest aggregate: `29092913b3777787385ea23859a65f494eb8c42532a3fdde71707ad2861c4267`. Full file maps, exact commands/environments, timestamps, log hashes and volume mappings remain ignored. The final source bytes include the two new native test files; the product source bytes match the prior72dc402 head. + +First failures remain retained. The test host needed the public AppKit finishLaunching lifecycle and a bounded own-PID AX query before SwiftUI exposed virtual descendants. Public navigation children require an untyped NSArray bridge because a scroller part does not match Swift's imported concrete array type. Independent review found missing-selector false negatives, stale proxy presses, factory cleanup and process-policy leakage; the final test boundary throws for missing state/action methods, reacquires controls, closes failed factories and performs no activation-policy write. It records navigation provenance and checks the messaging-timeout setter. No permission prompt or external AX target is involved. Only an actual apiDisabled query may skip an AX-dependent test; the accepted local run had no skips. + +Long-text acceptance uses the public NSView.scroll method on the actual native scroll document, observes real clip-position changes/return-to-start, and pairs end geometry with inspected original/candidate marker glyphs. Failed CGEvent wheel injection remained windowNumber0 and produced no movement. Native scroller diagnostic calls also did not establish scrolling. These are not accepted physical-wheel or VoiceOver-scroll proof. Component captures include the native window background so a transparent view-cache image is not misread as black-on-black product UI. The real composed-panel light/dark captures were separately inspected. + +The complete1,599-line ANTI_SLOP file was reread before handoff; all157 headings have an explicit scoped audit. No new visual design was introduced. Content, gutters, footer containment, full endpoint glyphs, localized controls and notice readability passed within the recorded native render/action scope. Actual desktop-backdrop translucency, physical pointer/wheel input, real VoiceOver speech/cursor/scrolling, external-host focus/voice/delivery, physical mobile T0, full privacy-broker coverage and distribution acceptance remain unverified. No unlocked-host or device operation was attempted. + +All46 pre-push command records/log hashes were independently matched; every executed command group ended. One existing native Codex reviewer completed two read-only boundary reviews without T3; findings were closed by the parent against the final source/results. This is technical evidence, not independent human or protected production approval. The exhausted local SwiftTesting-loader attempt limit was unchanged; no local full suite or new Release package was run. New-head GitHub full tests/packaging are a separate gate after push. Earlier72dc402 CI green is historical and is not transferred to a new head. PR124 remains Draft. + +Detailed evidence: ignored `.local/voicecursor-research/codex-native-ux-20261016/` contains cleanup, final-result, command-integrity, image-manifest, the157-point audit, reviews, raw first failures, and the sealed iteration7 product/runtime evidence. The existing rollback and real-host/mobile/signing gates remain in force. Current Codex reports directly to the user; no T3 watcher, timer or coordinator notification was created. + + +## Original HUD correction after user visual review, 2026-10-17 + +The user rejected the review screen's difference from the original HUD. The earlier test-only increment preserved the already-added UI bytes, but did not establish consistency with the pre-feature product. That broader UX-preservation claim is withdrawn. Current source reuses the original circular26-point button renderer and dark ultra-thin HUD surface, returns to caption typography and304-point normal width, removes separate rectangular review controls/text cards, and caps long review/disclosure panels at360points. Recording behavior and Session/output/lease/receipt owners are unchanged. Apply/Continue keep localized accessibility labels/help and no Return default; Cancel/Escape and one-use Restore retain their existing guards. + +| Current local check | Result and scope | +|---|---| +| Complete real SwiftPM `build --build-tests` |Passed with19localpins, native build sandbox, external scratch/module/temp, no dependency/model download | +| SDLC/basic/diff |Passed | +| Native review/appearance tests |8passed,0failed,0skipped; production recording/review button geometry and real OverlayPanel captures, remote consent, short/long content,280/320/420point fixtures, disabled/repeated Apply, Escape/no Return, and80-point Restore | +| Owner/security/Session/native pointer set |71passed,0failed,0skipped; current localized AX control frame drives process-local NSEvent through the real panel, with controlled output/target adapters | +| Same private-runtime policy |9named canaries passed; no permission/policy widening | +| Visual review |58raw native PNGs inspected, including36long-scroll captures across12variants, actual production short/rejected/recording frames, and restoration/disclosure notices | + +Final909-file source manifest SHA256: `a5e6a33c9013bc552541a8cbb6aad410c7ec2a587b0e7edabd9bc4a54abab2df`. Complete8,988-file frozen native test product aggregate: `f4008ec1e89c91487f418f04dfa80a5e01435384663f9630aedaf2472c22f943`. Every current test command binds this exact source, resources and module context.27command log records across the retained corrective epochs were hash-checked; their command groups ended and no input changed during a command. + +The independent reviewer found stale footer pointer coordinates and104-point Restore tests against80-point production; both were fixed. The parent then rejected helper renders that auto-resized after failure. Disabling NSHostingView automatic sizing alone fixed AppKit dimensions but did not fully bind the SwiftUI root; the final helper also supplies the production-equivalent fixed frame and asserts actual dimensions/disabled-action containment after transitions. Those earlier images and passing test logs are retained as non-final fixture evidence. The final source passes the actual size and action checks, and the original/candidate endpoint glyphs were visually read. + +The complete1,599-line ANTI_SLOP file was read before editing and fully reread before handoff; all157headings have a scoped audit. The user's explicit request to retain existing native controls takes precedence over website-branding defaults. No new fonts, dependencies, decorative assets, global accessibility settings, TCC, authentication, devices or source in the mobile owner's workspace were changed. Raw reports, source/image manifests, audits, reviewer findings, first failures and exact commands remain ignored under `.local/voicecursor-research/codex-ux-correction-20261017/`, with sealed execution evidence in sibling `codex-native-ux-20261016/iteration10/`. + +Full local `swift test` was not rerun: the prior SwiftTesting-loader retry limit remains2/2. Fresh full-suite and Release-style GitHub checks belong to the new pushed head and must be read separately. Actual host voice/focus/write/restore, physical wheel, spoken VoiceOver/navigation, live desktop-backdrop material, mobile T0, complete private-broker coverage, distribution signing/notarization and protected production acceptance remain open. The PR remains Draft. Existing rollback retires selected-edit authority without changing ordinary dictation or stored user data. + + +## Automatic desktop edit and scoped shortcuts, 2026-10-17 + +The user explicitly removes candidate preview/Apply and requests keyboard use while retaining the original HUD. This supersedes prior desktop explicit-Apply records, without changing remote pre-send consent or granting human acceptance. The existing Session generates a candidate, drains capture, checks the original frozen selection and current target, then commits once through the same output transaction. Desktop no longer publishes a pending review. Original/candidate content is not rendered in the popup; confirmed output exposes only status and one-use original restoration. Selected-content retention is now tied to the frozen job before generation, so failed/automatic sessions remain redacted from generic terminal snapshots/history/follow-up. + +Esc is scoped to a shown, cancellable selected session. Command-Option-Z is scoped to the shown current restoration receipt and rechecks owner availability; host Command-Z stays untouched. Native Carbon registrations are exclusive, fail closed on conflict, remember failed attempts for the same identity, reject queued/stale/repeated IDs, and unregister when hidden/ineligible/closed. No global configuration, permission prompt, credentials, user data or device was changed. The mobile adapter retains its existing typed proposal protocol and is not silently switched to the desktop shortcut implementation. + +| Current exact-source check | Result and limits | +|---|---| +| Complete real SwiftPM build-tests and basic checks |Passed;19exact local pins, native build sandbox, external scratch/module/temp, no dependency/model downloads, continuous10GiBDatafloor | +| Owner/security/Session tests |81passed,0failed,0skipped; automatic single output, long/Unicode selection, cancellation while generation drains, stale selection/focus, empty/error/remote503, privacy retention, unchanged dictation, receipt/clipboard/uncertainty/restore guards | +| Native interaction and keyboard boundary tests |9passed,0failed,0skipped; actual production HUD, no original/candidate/Apply, original26point controls, local pointer and scoped shortcut callback to the real Session restore, hide/close/stale-ID/conflict lifecycle; real owned Carbon event decode with injected OS registration | +| Retained private-runtime policy |9named canaries passed | +| Visual and accessibility structure |42native PNGs inspected;24long remote-disclosure start/end frames across280/320/420points,2languages,2appearances;18recording/status/restore/notice frames. Localized native AX controls/actions and end glyphs verified; actual spoken VoiceOver is not implied | + +915-file source manifest: `b9b45674c3ff4e0e2afdc76b62fea190a02a3f3ada97849b91b94788be91f836`. Complete9,012-file frozen native test product aggregate: `a605be004e1dfa496e2a4b89182b4f8c214ec80c02fd99208552706fd5e67e86`. Exact commands/environments,19pin revisions, capacity samples, source/product maps, logs and screenshots remain ignored under `.local/voicecursor-research/codex-native-ux-20261016/iteration11/`; current result, image/hash/command checks,157-heading audit and independent review are in `.local/voicecursor-research/codex-auto-edit-20261017/`. All own process groups ended, input hashes stayed stable within each command, and log hashes were rechecked. + +The first complete build exposed a missing UtterMediaContracts import in the new held-generation test helper; corrected, then the entire real build succeeded. An old contact-sheet utility expected removed review screenshot names and failed; a corrected task-only AppKit utility was compiled in a separate isolated cache outside the frozen product, then all raw screenshots were reviewed. Both failures are retained. Independent source review found hidden-HUD shortcuts and premature delivering phase during capture drain; both were fixed and native regression checks pass. Final bounded source review reported no findings. This is technical evidence, not human or real-host acceptance. + +Full local swift test remains not rerun because the retained SwiftTesting-loader retry budget is2/2. Previous27ffc8c GitHub full tests, Release-style App Build and SDLC Gate are now confirmed green, but belong to that previous head. Fresh new-head full-suite/packaging checks must be read after push. Actual global-key registration/dispatch under the locked loginwindow, external-host voice/focus/write/restore, physical wheel, spoken VoiceOver/navigation, live backdrop, mobile T0/final iOS validation, complete private-broker coverage and distribution/human/protected acceptance remain open. Valid but misrecognized speech may still produce an unintended rewrite; the user-requested direct flow relies on the same recognition/generation checks and guarded original restoration, not an unproven semantic guarantee. PR124 remains Draft. Rollback is a normal revert of this increment, preserving stored user data and ordinary dictation. diff --git a/docs/sdlc/changes/2026-10-08-voice-editing-research/workflow.json b/docs/sdlc/changes/2026-10-08-voice-editing-research/workflow.json new file mode 100644 index 00000000..cf0aede9 --- /dev/null +++ b/docs/sdlc/changes/2026-10-08-voice-editing-research/workflow.json @@ -0,0 +1,1872 @@ +{ + "schemaVersion": 1, + "owner": "mcp:6aef24eb-40c7-4074-8d14-6d17c03f9f30", + "environmentId": "DEVICE_BINDING_IN_IGNORED_LOCAL", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "startedAt": "2026-10-07T21:09:56.301Z", + "deadline": null, + "researchAuthorizationOnly": false, + "quota": "unknown; Standard; user removed old round duration cap", + "delegatedTasks": [ + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-platform-20261008-r1", + "owner": "codex/gpt-6.1-sol/xhigh/default", + "scope": "Apple official boundaries and baseline source only", + "status": "completed", + "hasPendingChildRuns": false, + "artifact": "platform-evidence.md", + "acceptance": "research source review accepted; runtime not verified; artifact +13s was NOT native terminal; native deadline overrun56.6s", + "nativeDeadlineOverrunSeconds": 56.6 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-20261008-r2", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-20261008-r2", + "providerInstanceId": "cursor", + "model": "claude-sonnet-5-5", + "startedAt": "2026-10-07T21:58:42.933Z", + "nativeCompletedAt": "2026-10-07T22:00:22.610Z", + "deadline": "2026-10-07T22:02:45Z", + "status": "completed", + "hasPendingChildRuns": false, + "acceptance": "not accepted: writer incomplete ANTI read; simulated controls only", + "nativeOverrunSeconds": 0 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-receipt-20261008-r2", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-receipt-20261008-r2", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "startedAt": "2026-10-07T22:00:23.946Z", + "nativeCompletedAt": "2026-10-07T22:02:46.245Z", + "deadline": "2026-10-07T22:02:45Z", + "status": "interrupted", + "hasPendingChildRuns": false, + "acceptance": "partial diff retained; parse only; product not accepted", + "nativeOverrunSeconds": 1.245 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-audit-20261008-r2b", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-audit-20261008-r2b", + "providerInstanceId": "cursor", + "model": "claude-sonnet-5-5", + "startedAt": "2026-10-07T22:01:13.122Z", + "nativeCompletedAt": "2026-10-07T22:02:46.306Z", + "deadline": "2026-10-07T22:02:45Z", + "status": "interrupted", + "hasPendingChildRuns": false, + "acceptance": "no final audit artifact; original prototype still draft", + "nativeOverrunSeconds": 1.306 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-pending-20261008-r3", + "owner": "codex/gpt-6.1-sol/xhigh/default", + "scope": "Session contracts/control/job and narrow output logic; no UI views", + "status": "interrupted", + "deadline": "2026-10-07T22:15:20Z", + "acceptance": "partial retained only; no product acceptance", + "hasPendingChildRuns": false, + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-pending-20261008-r3", + "startedAt": "2026-10-07T22:09:50.382Z", + "nativeCompletedAt": "2026-10-07T22:15:06.263Z", + "nativeElapsedSeconds": 315.881, + "nativeOverrunSeconds": 0, + "activeRunId": null, + "pendingRequestCount": 0 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-fix-20261008-r3", + "owner": "cursor/claude-sonnet-5-5/300k/high/Standard", + "scope": "HTML prototype and UI audit only; second final same-cause correction", + "status": "interrupted", + "deadline": "2026-10-07T22:15:20Z", + "acceptance": "partial retained only; no product acceptance", + "hasPendingChildRuns": false, + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-ui-fix-20261008-r3", + "startedAt": "2026-10-07T22:12:32.599Z", + "nativeCompletedAt": "2026-10-07T22:15:06.314Z", + "nativeElapsedSeconds": 153.715, + "nativeOverrunSeconds": 0, + "activeRunId": null, + "pendingRequestCount": 0 + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-full-build-20261008-r4", + "clientRequestId": "voice-editing-full-build-20261008-r4", + "owner": "codex/gpt-6.1-sol/xhigh/default", + "scope": "complete real SwiftPM/resource/module compilation and confirmed necessary errors only", + "status": "completed", + "deadline": "2026-10-07T22:28:50Z", + "nestedDelegation": false, + "acceptance": "build not passed; logs accepted; zero code changes", + "startedAt": "2026-10-07T22:22:35.633Z", + "native": { + "startedAt": "2026-10-07T22:22:35.633Z", + "completedAt": "2026-10-07T22:28:42.038Z", + "status": "completed", + "elapsedSeconds": 366.405, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "deadlineMet": true, + "proactiveCancelNotIssuedAtScheduledTime": true, + "supervisionDeviation": "Child was still running at 22:28:02; parent context compaction delayed scheduled 22:28:30 proactive cancellation. Child completed natively at 22:28:42.038, before hard child deadline22:28:50. Do not claim scheduled cancellation occurred." + } + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-external-cache-20261008-r5", + "clientRequestId": "voice-editing-external-cache-20261008-r5", + "owner": "codex/gpt-6.1-sol/xhigh/default", + "scope": "task-owned external cache and exact local dependencies; no source changes unless confirmed complete build error", + "status": "completed", + "deadline": "2026-10-07T22:42:15Z", + "nestedDelegation": false, + "acceptance": "seven file probes and all19 source snapshots accepted; offline registration/compiler/full build not passed", + "native": { + "startedAt": "2026-10-07T22:36:11.439Z", + "completedAt": "2026-10-07T22:41:54.220Z", + "status": "completed", + "elapsedSeconds": 342.781, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "targetMissedSeconds": 9.22, + "hardDeadlineMet": true, + "cancelRequestedAtClockSecond": "2026-10-07T22:41:54Z", + "cancelResult": "cancel_requested", + "reconciliation": "Cancellation requested in terminal race; durable completed is authoritative, not interrupted. No new child run." + }, + "hasPendingChildRuns": false, + "artifact": "build-preparation-r5.md" + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-registration-20261008-r6", + "clientRequestId": "voice-editing-offline-registration-20261008-r6", + "owner": "codex/gpt-6.1-sol/xhigh/default", + "scope": "task-owned real Git metadata/local mirrors; conditional offline registration/compiler/full build", + "status": "completed", + "startedAt": "2026-10-07T22:51:56.717Z", + "deadline": "2026-10-07T22:57:45Z", + "nestedDelegation": false, + "acceptance": "19 exact local Git mirrors/working copies accepted; offline registration/compiler/full build not run", + "native": { + "startedAt": "2026-10-07T22:51:56.717Z", + "completedAt": "2026-10-07T22:56:54.385Z", + "status": "completed", + "elapsedSeconds": 297.668, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "targetMet": true, + "hardDeadlineMet": true, + "cancelRequested": false, + "cancelNotNeeded": "native completed before proactive cancel window" + }, + "hasPendingChildRuns": false, + "artifact": "offline-registration-r6.md" + }, + { + "run": 7, + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-swiftpm-20261008-r7", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-swiftpm-20261008-r7", + "childRunId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-swiftpm-20261008-r7:ordinal:1", + "owner": "Codex gpt-6.1-sol xhigh/default", + "status": "interrupted", + "files": "task-owned ignored run7-offline plus swiftpm-result-r7.md; no product changes", + "nativeStartedAt": "2026-10-07T23:25:22.406Z", + "deadline": "2026-10-07T23:28:45Z", + "nativeCompletedAt": "2026-10-07T23:29:06.424Z", + "hasPendingChildRuns": false + }, + { + "run": 8, + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-swiftpm-sandbox-20261008-r8", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-swiftpm-sandbox-20261008-r8", + "childRunId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-swiftpm-sandbox-20261008-r8:ordinal:1", + "clientRequestId": "voice-editing-swiftpm-sandbox-20261008-r8", + "owner": "Codex gpt-6.1-sol xhigh/default", + "status": "completed", + "files": "task-owned ignored run8-sandbox plus sandbox-registration-r8.md; no productsource", + "nativeStartedAt": "2026-10-07T23:37:11.877Z", + "deadline": "2026-10-07T23:43:30Z", + "nativeCompletedAt": "2026-10-07T23:40:01.532Z", + "hasPendingChildRuns": false + }, + { + "run": 9, + "taskId": "node:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-unit-20261008-r9", + "childThreadId": "thread:delegated-task:command%3Amcp%3A67541bea-8eb1-4d26-8f01-933833e33315%3Adelegate-task%3Avoice-editing-offline-unit-20261008-r9", + "childRunId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-unit-20261008-r9:ordinal:1", + "clientRequestId": "voice-editing-offline-unit-20261008-r9", + "owner": "Codex gpt-6.1-sol xhigh/default", + "status": "completed", + "files": "task-owned ignored run9-offline plus offline-unit-r9.md", + "nativeStartedAt": "2026-10-08T01:26:52.827Z", + "deadline": "2026-10-08T01:33:00Z", + "nativeCompletedAt": "2026-10-08T01:32:42.215Z", + "hasPendingChildRuns": false + }, + { + "clientRequestId": "voice-editing-complete-build-20261014", + "taskId": "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-complete-build-20261014", + "status": "running", + "owner": "codex logic build; excludes Presentation/localization" + }, + { + "clientRequestId": "voice-editing-native-ui-20261014", + "taskId": "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-native-ui-20261014", + "status": "failed", + "startedAt": "2026-10-14T01:49:52.803Z", + "completedAt": "2026-10-14T01:51:38.237Z", + "activeRunId": null, + "pendingRequestCount": 0, + "error": "Provider turn failed.", + "sourceChanged": false + }, + { + "clientRequestId": "voice-editing-native-ui-20261014-r2", + "taskId": "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-native-ui-20261014-r2", + "status": "completed", + "owner": "Cursor native Presentation/localization", + "sameCauseRetry": 1, + "hasPendingChildRuns": false, + "activeRunId": null, + "pendingRequestCount": 0, + "firstObservedStartedAt": "2026-10-14T01:59:41.586Z", + "latestDurableStartedAt": "2026-10-14T02:15:00.021Z", + "completedAt": "2026-10-14T02:22:08.271Z", + "startTimestampChangedDuringSteers": true, + "acceptance": "partial source/render retained; no final full ANTI reread, no product build/host/VoiceOver/Escape/remote UI; not accepted as feature" + }, + { + "taskId": "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-native-integration-20261014", + "clientRequestId": "voice-editing-native-integration-20261014", + "status": "running", + "owner": "Cursor native Presentation/localization", + "scope": "specific remote/context/language native integration + full audit; not generic HTML" + } + ], + "stages": [ + { + "id": "research", + "owner": "this research thread", + "dependsOn": [], + "artifacts": [ + "source-evidence.md", + "research.md", + "platform-evidence.md", + "intent.md" + ], + "acceptance": [ + "official URLs and acquisition date", + "native cursor and SHA checked", + "bounded child result accepted", + "no product or device changes" + ], + "status": "completed; authorization refreshed", + "gate": "整体授权覆盖普通阶段推进;证据与范围真实" + }, + { + "id": "interaction", + "owner": "Claude/Cursor UI executor selected from current live catalog; this thread accepts", + "dependsOn": [ + "authorized scope" + ], + "artifacts": [ + "spec.md", + "reviewable interaction proposal; prototype only if needed", + "ANTI_SLOP audit" + ], + "acceptance": [ + "selection->instruction->preview->confirm->replace->restore visible", + "error/selection stale/cancel states tested", + "Mac native text and limited browser-host fixture capability evidence", + "no claim that AXSelectedText universally writable" + ], + "status": "native source retained; real compiled process-local Apply/Cancel evidence on earlier source only; concrete scroll/narrow/VO/system-key cases pending", + "gate": "整体授权覆盖普通阶段推进;实际渲染与交互审计" + }, + { + "id": "implementation_plan", + "owner": "research lead + original production owner", + "dependsOn": [ + "reviewed concrete spec" + ], + "artifacts": [ + "plan.md", + "integration owner and source contract" + ], + "acceptance": [ + "one shared Session/Processing behavior", + "macOS lease/receipt original-text restore plan", + "mobile T0 dependence and device reservation", + "rollback disable editing entry without destructive migration" + ], + "status": "current single-chain desktop and portable/mobile design implemented locally; production acceptance pending", + "gate": "整体授权覆盖普通阶段推进;单一契约与原负责人集成" + }, + { + "id": "implement", + "owner": "Codex implementation executor; original production owner integrates", + "dependsOn": [ + "reviewed implementation plan" + ], + "artifacts": [ + "minimal code diff", + "behavioral fixture/eval cases" + ], + "acceptance": [ + "Ponytail full before coding", + "reuse current owner targets", + "no parallel recording/edit implementation", + "no blind retry or destructive undo", + "privacy minimal selected text; existing provider only" + ], + "status": "shared r3, native secure admission fixes, reviewed mobile owned r3 38-path increment integrated; no copied unrelated dirty increments", + "gate": "整体授权覆盖普通阶段推进;源码归属、安全行为与真实验证" + }, + { + "id": "verify", + "owner": "independent verifier for High lane + original production device owner", + "dependsOn": [ + "implementation" + ], + "artifacts": [ + "verification.md", + "exact source/package hashes", + "host recordings/screenshots", + "failure evidence" + ], + "acceptance": [ + "sdlc/basic/swift test", + "real Mac native field and browser editable field", + "stale selection/focus/network/clipboard/duplicate apply/Unicode", + "one-step restore original; edit after commit disables restore", + "iPhone/iPad only after T0 capture+auto-return+focus+cancel pass; no manual switch counted", + "light/dark/accessibility and final ANTI_SLOP audit" + ], + "status": "partial scoped acceptance:fourTestSupport fixes,9canaries+1regression+70focused passed;full new CI/nativeUI/host/mobileT0 pending", + "gate": "整体授权覆盖普通阶段推进;独立验收与共享设备窗口" + }, + { + "id": "release_candidate", + "owner": "original production release owner + independent verifier", + "dependsOn": [ + "actual verification accepted" + ], + "artifacts": [ + "release.md", + "signed release-style app/DMG or iOS Archive/export/validation", + "privacy policy/labels/manifests", + "release notes and checksum" + ], + "acceptance": [ + "configured identity; never ad-hoc fallback", + "Developer ID notarization when applicable", + "clean-machine launch/permission/offline paths", + "store submission states accurately recorded", + "rollback and residual risk explicit" + ], + "status": "PR124 Draft open and linked;normal corrective commit/push pending;human/production signing/device acceptance unverified", + "gate": "整体授权覆盖普通阶段推进;确切候选签名/隐私/权限/提交验证" + }, + { + "id": "publish", + "owner": "user + original production release owner", + "dependsOn": [ + "exact release candidate accepted", + "existing delivery scope" + ], + "artifacts": [ + "commit/PR/release/store record" + ], + "acceptance": [ + "publish only exact accepted candidate", + "future PR link_pull_request and checks against actual HEAD", + "monitor via watch_pull_request only", + "ship outcome and observation recorded" + ], + "status": "authorized within existing goals; acceptance prerequisites not passed", + "gate": "整体授权覆盖普通阶段推进;已有生产目标与保护闸门,无新法务代签" + } + ], + "authorization": { + "kind": "用户整体授权", + "quote": "都允许", + "sourceThreadId": "3e0f26f4-c326-441f-9996-209568817a17", + "relayedDate": "2026-10-08", + "covers": [ + "desktop", + "iPhone", + "iPad", + "design", + "plan", + "implementation", + "verification", + "existing delivery goals" + ], + "doesNotProve": "user review of unseen spec/UI; actual acceptance; production readiness", + "exclusions": [ + "new paid/register", + "destructive data", + "auth or permission changes", + "legal signatures" + ], + "latest": { + "date": "2026-10-14", + "quote": "不要有时间映射那个上限限制了,直接把整个任务完全地完成,完成后提交一个 PR。", + "timeLimitRemoved": true, + "completeTaskAndCreatePR": true, + "unchangedExclusions": [ + "newpayment/register", + "auth/permissionchanges", + "destructivedata", + "legal signatures" + ], + "notAcceptance": true + }, + "buildIsolationAdjustment": { + "userAnswer": "允许此构建隔离调整", + "date": "2026-10-14", + "scope": "remove custom outer only for task build; keep native sandboxes/exact local pins/no download/10GiB", + "notAcceptance": true + }, + "draftDeliveryOrdering": { + "date": "2026-10-14", + "source": "explicit user steering in existing owner collaboration", + "action": "publish combined Draft after precise import and writer settlement; continue failures on same PR", + "notAcceptance": true + } + }, + "rounds": [ + { + "ordinal": 1, + "startedAt": "2026-10-07T21:09:56.301Z", + "deadline": "2026-10-07T21:19:56.301Z", + "nativeCompletedAt": "2026-10-07T21:20:51.594Z", + "nativeOverrunSeconds": 55.293, + "artifactClosedAt": "2026-10-07T21:20:19.048581+00:00" + }, + { + "ordinal": 2, + "startedAt": "2026-10-07T21:55:56.728Z", + "stopExpansionAt": "2026-10-07T22:03:56.728Z", + "deadline": "2026-10-07T22:05:56.728Z", + "nativeCompletedAt": "2026-10-07T22:05:40.154Z", + "note": "Root checked native terminal; within600s. Partial retained only, no product acceptance.", + "selfCloseEvidenceAt": "2026-10-07T22:04:28.245358+00:00", + "nativeElapsedSeconds": 583.426, + "nativeOverrunSeconds": 0 + }, + { + "ordinal": 3, + "startedAt": "2026-10-07T22:08:15.147Z", + "stopExpansionAt": "2026-10-07T22:16:15.147Z", + "deadline": "2026-10-07T22:18:15.147Z", + "nativeCompletedAt": "2026-10-07T22:18:15.097Z", + "childTargetFinish": "2026-10-07T22:14:45Z", + "childHardDeadline": "2026-10-07T22:15:20Z", + "quota": "unknown; bounded Standard; no Fast/1M", + "uiSameCauseCorrectionCount": 2, + "selfCloseEvidenceAt": "2026-10-07T22:17:30.774037+00:00", + "nativeElapsedSeconds": 599.95, + "nativeOverrunSeconds": 0, + "note": "Root durable rechecked actual native terminal; partial retained only" + }, + { + "ordinal": 4, + "startedAt": "2026-10-07T22:21:05.508Z", + "stopExpansionAt": "2026-10-07T22:29:05.508Z", + "targetNativeCloseAt": "2026-10-07T22:30:35.508Z", + "deadline": "2026-10-07T22:31:05.508Z", + "nativeCompletedAt": "2026-10-07T22:30:55.115Z", + "childDeadline": "2026-10-07T22:28:50Z", + "proactiveChildCancelAt": "2026-10-07T22:28:30Z", + "quota": "unknown; Standard", + "scope": "complete real SwiftPM/resource/module compilation and confirmed necessary errors only", + "nativeElapsedSeconds": 589.607, + "nativeOverrunSeconds": 0, + "targetCloseMissedSeconds": 19.607, + "note": "Root durable checked; actual completion, no artifact inference; scheduled child cancellation was not performed." + }, + { + "ordinal": 5, + "startedAt": "2026-10-07T22:34:50.880Z", + "stopExpansionAt": "2026-10-07T22:42:50.880Z", + "targetNativeCloseAt": "2026-10-07T22:44:20.880Z", + "deadline": "2026-10-07T22:44:50.880Z", + "nativeCompletedAt": "2026-10-07T22:44:13.345Z", + "childTargetFinish": "2026-10-07T22:41:45Z", + "proactiveChildCancelAt": "2026-10-07T22:41:55Z", + "childHardDeadline": "2026-10-07T22:42:15Z", + "quota": "unknown; Standard", + "nativeElapsedSeconds": 562.465, + "nativeOverrunSeconds": 0, + "note": "Root durable checked; source snapshots accepted only" + }, + { + "ordinal": 6, + "startedAt": "2026-10-07T22:50:25.816Z", + "stopExpansionAt": "2026-10-07T22:58:25.816Z", + "targetNativeCloseAt": "2026-10-07T22:59:55.816Z", + "deadline": "2026-10-07T23:00:25.816Z", + "nativeCompletedAt": "2026-10-07T22:59:42.962Z", + "childTargetFinish": "2026-10-07T22:57:15Z", + "proactiveChildCancelAt": "2026-10-07T22:57:25Z", + "childHardDeadline": "2026-10-07T22:57:45Z", + "commandsDeadline": "2026-10-07T22:56:50Z", + "quota": "unknown; Standard", + "nativeElapsedSeconds": 557.146, + "nativeOverrunSeconds": 0, + "note": "Root durable rechecked; Git preparation only; SwiftPM/compiler/full build not run." + }, + { + "ordinal": 7, + "startedAt": "2026-10-07T23:21:46.161Z", + "completedAt": null, + "stopExpansionAt": "2026-10-07T23:29:46.161Z", + "targetCloseAt": "2026-10-07T23:31:16.161Z", + "hardDeadline": "2026-10-07T23:31:46.161Z", + "nativeCompletedAt": "2026-10-07T23:31:18.292Z", + "childDeadline": "2026-10-07T23:28:45Z", + "proactiveChildCancelAt": "2026-10-07T23:28:55Z", + "commandsDeadline": "2026-10-07T23:28:15Z", + "quota": "unknown; Standard", + "scope": "actual offline SwiftPM registration and compiler external placement; one full build only if protected space and sufficient supervision", + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-swiftpm-20261008-r7:ordinal:1", + "ordinal": 1, + "status": "interrupted", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-07T23:25:20.087Z", + "startedAt": "2026-10-07T23:25:22.406Z", + "completedAt": "2026-10-07T23:29:06.424Z", + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "elapsedSeconds": 224.018, + "deadlineOverrunSeconds": 21.424, + "cancelRequested": true, + "cancelPlannedAt": "2026-10-07T23:28:55Z", + "cancelActualAt": "2026-10-07T23:29:06.424Z", + "cancelActualTimeNote": "cancel call and native interruption observed23:29:06; exact native terminal retained; missed planned23:28:55 checkpoint" + }, + "artifactClosedAt": "2026-10-07T23:30:43.771395+00:00", + "nativeElapsedSeconds": 572.131, + "nativeOverrunSeconds": 0, + "targetCloseMissedSeconds": 2.131, + "note": "Root durable verified actual completed, not interrupted; no artifact inference. Child deadline miss retained." + }, + { + "ordinal": 8, + "runId": "run:thread:mcp%3A6aef24eb-40c7-4074-8d14-6d17c03f9f30:ordinal:8", + "status": "running", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-07T23:35:58.918Z", + "startedAt": "2026-10-07T23:35:59.034Z", + "completedAt": null, + "stopExpansionAt": "2026-10-07T23:42:59.034Z", + "targetCloseAt": "2026-10-07T23:45:29.034Z", + "hardDeadline": "2026-10-07T23:45:59.034Z", + "nativeCompletedAt": "2026-10-07T23:43:46.701Z", + "childHardDeadline": "2026-10-07T23:43:30Z", + "proactiveChildCancelAt": "2026-10-07T23:42:55Z", + "commandsDeadline": "2026-10-07T23:42:30Z", + "quota": "unknown; Standard", + "scope": "prove sandbox boundary compatibility then one exact offline resolve correction; no productsource", + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-swiftpm-sandbox-20261008-r8:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-07T23:37:09.161Z", + "startedAt": "2026-10-07T23:37:11.877Z", + "completedAt": "2026-10-07T23:40:01.532Z", + "elapsedSeconds": 169.655, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "hardDeadlineMet": true, + "targetMet": true, + "cancelRequested": false, + "cancelNotNeeded": "completed well before proactive window" + }, + "dispatchDelaySeconds": 70.127, + "artifactClosedAt": "2026-10-07T23:42:28.439978+00:00", + "nativeElapsedSeconds": 467.667, + "nativeOverrunSeconds": 0, + "note": "Live native read now verifies Run8 completed; no artifact terminal inference. Capacity block retained." + }, + { + "ordinal": 9, + "runId": "run:thread:mcp%3A6aef24eb-40c7-4074-8d14-6d17c03f9f30:ordinal:9", + "status": "running", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-08T01:24:59.250Z", + "startedAt": "2026-10-08T01:24:59.291Z", + "completedAt": null, + "stopExpansionAt": "2026-10-08T01:32:59.291Z", + "targetCloseAt": "2026-10-08T01:34:29.291Z", + "hardDeadline": "2026-10-08T01:34:59.291Z", + "nativeCompletedAt": null, + "childHardDeadline": "2026-10-08T01:33:00Z", + "proactiveChildCancelAt": "2026-10-08T01:32:20Z", + "commandsDeadline": "2026-10-08T01:31:50Z", + "quota": "unknown; Standard", + "scope": "one guarded offline SwiftPM boundary/registration/build unit; no productsource", + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-unit-20261008-r9:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-08T01:26:48.762Z", + "startedAt": "2026-10-08T01:26:52.827Z", + "completedAt": "2026-10-08T01:32:42.215Z", + "elapsedSeconds": 349.388, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "hardDeadlineMet": true, + "targetMissedSeconds": 32.215, + "cancelRequested": false, + "cancelCheckpointMissed": true, + "cancelNote": "planned32:20 cancellation not performed; terminal read found completed32:42.215 beforehard33:00, never record interrupted or timely proactive cancellation" + }, + "artifactClosedAt": "2026-10-08T01:34:06.344619+00:00" + } + ], + "productionCoordination": { + "runOrdinal": 24, + "completedAt": "2026-10-07T21:59:36.954Z", + "cursor": 2617, + "HEAD": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "iPad": "No new durable updates after2617; old idle evidence not current reservation; no device operations this run", + "mobileT0": "not passed: auto-return + keyboard focus + real capture + full insertion + cancel lifecycle still pending", + "integrationOwner": "0e62fe12-4c97-497b-a4c7-985eea9aa1cd", + "originalChanges": "38 tracked files modified; not copied or changed by research thread", + "readAgainAt": "2026-10-07T22:13:49.809404+00:00" + }, + "run4Acceptance": { + "status": "partial retained / full compilation interrupted", + "childNative": { + "startedAt": "2026-10-07T22:22:35.633Z", + "completedAt": "2026-10-07T22:28:42.038Z", + "status": "completed", + "elapsedSeconds": 366.405, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "deadlineMet": true, + "proactiveCancelNotIssuedAtScheduledTime": true, + "supervisionDeviation": "Child was still running at 22:28:02; parent context compaction delayed scheduled 22:28:30 proactive cancellation. Child completed natively at 22:28:42.038, before hard child deadline22:28:50. Do not claim scheduled cancellation occurred." + }, + "sourceUnchanged": true, + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "fullSource846SHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "nextAction": "Verify EXTERNAL_TASK_ROOT isolated scratch/module caches and local pinned reuse; device mapping only in ignored raw; then full build under unchanged Data guard.", + "parentNativeCompletedAt": "2026-10-07T22:30:55.115Z", + "artifactClosedAt": "2026-10-07T22:30:32.386605+00:00" + }, + "run5Acceptance": { + "status": "partial retained: external file probes and exact source snapshots accepted; offline initialization/compiler placement/full build not verified", + "sourceUnchanged": true, + "full846SourceSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "childNative": { + "startedAt": "2026-10-07T22:36:11.439Z", + "completedAt": "2026-10-07T22:41:54.220Z", + "status": "completed", + "elapsedSeconds": 342.781, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "targetMissedSeconds": 9.22, + "hardDeadlineMet": true, + "cancelRequestedAtClockSecond": "2026-10-07T22:41:54Z", + "cancelResult": "cancel_requested", + "reconciliation": "Cancellation requested in terminal race; durable completed is authoritative, not interrupted. No new child run." + }, + "accepted": { + "volumeBound": true, + "sevenFileProbesPass": true, + "all19ExactRevisionsAndClean": true, + "owner8254FilesReadbackPass": true, + "bytes": 145334442, + "actualToolHelpPass": true + }, + "notVerified": [ + "SwiftPM Git metadata registration/local mirrors", + "real offline SwiftPM initialization", + "actual compiler scratch/module cache/temp placement", + "full build", + "basic", + "swift test", + "native review/focus/privacy/real hosts", + "mobile original owner T0", + "Release" + ], + "DataFreeAtCloseBytes": 11206709248, + "nextAction": "Fresh bounded Codex run: revalidate source and Data>=10GiB; create exact local Git working copies or task-specific mirrors from verified repositories with no network/global changes; prove real SwiftPM offline registration; then one complete build with external scratch/module caches/temp and unchanged live10GiB guard. Do not run current configuration draft before registration proof.", + "artifactClosedAt": "2026-10-07T22:43:28.428388+00:00", + "parentNativeCompletedAt": "2026-10-07T22:44:13.345Z", + "spaceSampleCorrection": "Run5 earlier recovered sample stale: root later observed9.538/9.434/9.414GiB. New samples required before SwiftPM/build." + }, + "run6Acceptance": { + "status": "partial retained: exact local Git dependency metadata/source accepted; offline registration and full build blocked by fresh Data", + "sourceSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "full846SourceSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "childNative": { + "startedAt": "2026-10-07T22:51:56.717Z", + "completedAt": "2026-10-07T22:56:54.385Z", + "status": "completed", + "elapsedSeconds": 297.668, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "targetMet": true, + "hardDeadlineMet": true, + "cancelRequested": false, + "cancelNotNeeded": "native completed before proactive cancel window" + }, + "accepted": { + "all19GitMetadataAndRevisions": true, + "recursiveSubmodules": true, + "all8254ContentsOwnerReadback": true, + "all1426OriginalMetadataOwnerReadback": true, + "nineExternalProbesOwnerReadback": true, + "all362CommandLogHashesMatch": true, + "full846SourceUnchanged": true + }, + "checks": { + "GitMetadata": "pass", + "SwiftPMRegistration": "not run; fresh Data below10GiB", + "CompilerPlacement": "not run", + "FullBuild": "not run", + "basic": "not run", + "Tests": "not run", + "Release": "not run", + "sdlc": "owner pass", + "diff": "owner pass" + }, + "currentDataFreeBytes": 10269753344, + "guardStatus": "blocked", + "lastCleanupFreed": 0, + "nextAction": "Do not repeat registration/build while Data is below10GiB. After a fresh adequate-space window, reuse the existing exact task-owned Git mirrors/working copies; bind installed Swift tool version, configure isolated SwiftPM paths and task-local mirrors or validated local state, prove actual offline registration, then one full build with continuous10GiB guard and at least3 supervised minutes. No metadata rebuild, cleanup or threshold reduction.", + "artifactClosedAt": "2026-10-07T22:58:45.442232+00:00", + "parentNativeCompletedAt": null, + "ownerFinalChecks": [ + { + "command": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-07T22:58:45.549679+00:00", + "endedAt": "2026-10-07T22:58:47.274115+00:00", + "exitCode": 0, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0" + }, + { + "command": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-07T22:58:47.274174+00:00", + "endedAt": "2026-10-07T22:58:47.302723+00:00", + "exitCode": 0, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + }, + "run7Acceptance": { + "status": "partial retained; real registration failed", + "checks": { + "mirrorCLI": "pass:19 task mappings", + "SwiftPMRegistration": "failed: nested sandbox_apply Operation not permitted; no accepted workspace", + "compilerPlacement": "partial: actual manifest paths and 37 external artifacts; product scratch/compile not proven", + "fullBuild": "not run: less than3 supervised minutes at child entry", + "basic": "not run", + "swiftTest": "not run", + "Release": "not run", + "UIHostDevice": "not run", + "sourcePreservation": "pass:846 actual hashes, HEAD, binary diff unchanged", + "commandEvidence": "pass:32 log hashes; all32 commands terminal; no owned groups remain", + "sdlc": "owner pass", + "diff": "owner pass" + }, + "accepted": { + "mirrorCLI19": true, + "actualSwiftPMRegistration": false, + "fullBuild": false, + "full846SourceUnchanged": true, + "all32CommandLogHashes": true, + "all37ExternalArtifactHashes": true, + "allOwnCommandGroupsTerminal": true + }, + "source": { + "HEAD": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "sourceFiles": 846, + "fullSourceManifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "sourceUnchanged": true + }, + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-swiftpm-20261008-r7:ordinal:1", + "ordinal": 1, + "status": "interrupted", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-07T23:25:20.087Z", + "startedAt": "2026-10-07T23:25:22.406Z", + "completedAt": "2026-10-07T23:29:06.424Z", + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "elapsedSeconds": 224.018, + "deadlineOverrunSeconds": 21.424, + "cancelRequested": true, + "cancelPlannedAt": "2026-10-07T23:28:55Z", + "cancelActualAt": "2026-10-07T23:29:06.424Z", + "cancelActualTimeNote": "cancel call and native interruption observed23:29:06; exact native terminal retained; missed planned23:28:55 checkpoint" + }, + "nextAction": "下一有足够预算且fresh Data>=10GiB的单元,先诊断SwiftPM嵌套sandbox限制;本机help列有--disable-sandbox,可评估仅关闭内层SwiftPM sandbox且保留现有外层deny-network/file-only和相同权限的任务配置,不能声明已验证或绕过权限拒绝。成功后读取真实workspace全部19身份/revision,再保留至少3分钟做一次完整构建;不重建镜像/元数据。原生review/focus/privacy、真实宿主、签名Release与移动原owner T0/预约仍待实际验收。", + "artifactClosedAt": "2026-10-07T23:30:43.771395+00:00", + "parentNativeCompletedAt": "2026-10-07T23:31:18.292Z", + "ownerFinalChecks": [ + { + "label": "sdlc", + "command": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-07T23:30:59.440388+00:00", + "endedAt": "2026-10-07T23:31:00.952044+00:00", + "exitCode": 0, + "PID": 20348, + "dataFreeBytes": 10802532352, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0" + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-07T23:31:00.952255+00:00", + "endedAt": "2026-10-07T23:31:00.995867+00:00", + "exitCode": 0, + "PID": 21723, + "dataFreeBytes": 10802372608, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + }, + "run8Acceptance": { + "status": "blocked: capacity guard stopped compatibility unit; no resolve retry", + "checks": { + "boundaryCompatibility": "not proven", + "compensatingSandboxProfile": "not created", + "taskCanaries": "not run", + "freshInstalledHelp": "not run", + "liveSwiftVersion": "not run", + "resolve": "not run", + "realWorkspace19": "not inspected or registered", + "fullBuild": "not run", + "sdlc": "owner pass", + "ciBasic": "not run", + "swiftTest": "not run", + "release": "not run", + "UIHostMobileT0": "not run", + "ownerSource": "pass:846 actual hashes/HEAD/diff unchanged", + "diff": "owner pass", + "externalWrites": "not performed by child", + "sandboxCorrectionResolveAttempts": 0, + "ownerPrimary": "matching6.2.3 tagged sources inspected; deployedApple patch equivalence and safeexecution not proven" + }, + "accepted": { + "full846SourceUnchanged": true, + "safeSandboxCompatibility": false, + "canaries": false, + "SwiftPMRegistration": false, + "fullBuild": false, + "childNativeTerminal": true + }, + "source": { + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true, + "at": "2026-10-07T23:41:20.457004+00:00" + }, + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-swiftpm-sandbox-20261008-r8:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-07T23:37:09.161Z", + "startedAt": "2026-10-07T23:37:11.877Z", + "completedAt": "2026-10-07T23:40:01.532Z", + "elapsedSeconds": 169.655, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "hardDeadlineMet": true, + "targetMet": true, + "cancelRequested": false, + "cancelNotNeeded": "completed well before proactive window" + }, + "nextAction": "本轮关闭,不因稍后空间回升重试。下个独立有界已授权单元先fresh Data>=10GiB;guard先持久化时间/bytes并立即退出,禁止assert后的同一shell/并行工具组继续metadata/source请求。复用19Git和19mirror mappings;从匹配6.2.3 primary和installedhelp核对innerdefault/cache权限,证明任务自有补偿profile保留网络/写入及其他限制,task canary全部通过才一次resolve;读取实际19workspace revision后再安排至少3分钟完整构建。未证明安全则blocked,不切权限/proxy/transport。", + "artifactClosedAt": "2026-10-07T23:42:28.439978+00:00", + "lowDataBytes": 10500861952, + "parentNativeCompletedAt": "2026-10-07T23:43:46.701Z", + "ownerFinalChecks": [ + { + "label": "sdlc", + "command": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-07T23:43:14.223528+00:00", + "endedAt": "2026-10-07T23:43:15.731815+00:00", + "exitCode": 0, + "PID": 37434, + "dataFreeBytes": 10942525440, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0" + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-07T23:43:15.732037+00:00", + "endedAt": "2026-10-07T23:43:15.772143+00:00", + "exitCode": 0, + "PID": 38805, + "dataFreeBytes": 10942464000, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + }, + "run9Acceptance": { + "status": "partial retained: measured boundary canaries; registration context blocked", + "checks": { + "freshCapacity": "pass:29 child samples minimum11344375808bytes", + "installedHelpVersion": "pass:actualSwift6.2.3/help retained", + "defaultCanary": "pass:twoapprovedwrites; outsidewrite/network/sharedmemory denied", + "narrowNestedCanary": "pass", + "registrationOuterCanary": "pass testedoperations only", + "registrationNestedCanary": "failed:exit71 sandbox_apply Operation not permitted", + "completeSafeRegistrationBoundary": "not proven", + "resolve": "not run;0attempts", + "realWorkspace19": "not run/unregistered", + "fullBuild": "not run", + "ciBasic": "not run", + "swiftTest": "not run", + "ReleaseUIHostMobile": "not run", + "source": "owner pass:846actualhashes/HEAD/binarydiff unchanged", + "commandEvidence": "owner pass:10logs+knownPGIDs terminal", + "sdlc": "owner pass", + "diff": "owner pass" + }, + "source": { + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true, + "at": "2026-10-08T01:32:27.604318+00:00" + }, + "accepted": { + "boundedCanaryEvidence": true, + "full846SourceUnchanged": true, + "all10CommandHashes": true, + "knownOwnProcessGroupsTerminal": true, + "actualSwiftPMRegistration": false, + "fullBuild": false + }, + "childNative": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253A67541bea-8eb1-4d26-8f01-933833e33315%253Adelegate-task%253Avoice-editing-offline-unit-20261008-r9:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-08T01:26:48.762Z", + "startedAt": "2026-10-08T01:26:52.827Z", + "completedAt": "2026-10-08T01:32:42.215Z", + "elapsedSeconds": 349.388, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "hardDeadlineMet": true, + "targetMissedSeconds": 32.215, + "cancelRequested": false, + "cancelCheckpointMissed": true, + "cancelNote": "planned32:20 cancellation not performed; terminal read found completed32:42.215 beforehard33:00, never record interrupted or timely proactive cancellation" + }, + "nextAction": "本次同因纠错单元2/2已用,不自动Run10或重复泛泛审计。精确剩余条件:隔离同一机器上narrowprofile nested exit0与registrationprofile+taskenvironment nested exit71的具体差异,捕获installedSwiftPM实际manifest sandbox argv/环境;保持原inner保护与网络/文件限制,不改权限/proxy/transport。证明支持的完整context后才一个真实offline resolve、读实际19pins,再足够监督预算fullbuild;本轮不重试。现有移动原owner T0/预约、原生review/focus/privacy/真实宿主/Release目标保留,未验收。", + "artifactClosedAt": "2026-10-08T01:34:06.344619+00:00", + "parentNativeCompletedAt": "2026-10-08T01:34:48.753Z", + "ownerFinalChecks": [ + { + "label": "sdlc", + "command": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-08T01:34:34.362552+00:00", + "endedAt": "2026-10-08T01:34:35.495561+00:00", + "exitCode": 0, + "PID": 88629, + "dataFreeBytes": 11340193792, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0" + }, + { + "label": "diff", + "command": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-08T01:34:35.495671+00:00", + "endedAt": "2026-10-08T01:34:35.532996+00:00", + "exitCode": 0, + "PID": 90001, + "dataFreeBytes": 11340050432, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ], + "parentNativeElapsedSeconds": 589.462, + "parentNativeTargetMissedSeconds": 19.462, + "parentNativeHardDeadlineMet": true + }, + "run10Execution": { + "authorization": "user explicit continue, overall 都允许; not acceptance", + "parentNativeStartedAt": "2026-10-11T00:27:30.405Z", + "parentNativeCompletedAt": null, + "parentTarget": "2026-10-11T00:37:00.405Z", + "parentHardDeadline": "2026-10-11T00:37:30.405Z", + "childTaskId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-context-20261011-r10", + "childNativeStartedAt": "2026-10-11T00:31:46.726Z", + "childNativeCommandStopAt": "2026-10-11T00:34:55Z", + "childNativeCancelAt": "2026-10-11T00:35:15Z", + "childNativeHardDeadline": "2026-10-11T00:35:25Z", + "owner": "existing independent voice-editing thread", + "executor": "one T3-owned Codex gpt-6.1-sol xhigh/default; quota unknown", + "scope": "finite exact profile/environment census; no blind third retry", + "dependencies": [ + "fresh Data>=10GiB", + "existing local Git mirrors, exact source/lock unchanged", + "retain actual inner sandbox and network/file restrictions" + ], + "artifacts": [ + "context-build-r10.md", + "ignored run10-context/" + ], + "acceptance": [ + "rerunnable finite matrix with real exits/log hashes", + "source unchanged", + "all own process groups and child native terminal" + ], + "status": "closed partial retained", + "nextAfterAcceptance": "actual offline registration/full compile; then native review/focus/privacy and independent real-host safety; mobile original-owner T0+device window" + }, + "run10Acceptance": { + "status": "partial retained: finite profile correlation; missing external cache precondition", + "checks": { + "matrix": "pass4realcells; specificrule/rootcause NOT proven", + "externalVolume": "pass identity/APFS/writable", + "historicalExternalRoots": "missing; actor unknown", + "original19Checkouts": "pass exactHEAD+clean only", + "source846": "pass unchanged", + "commandLogs": "pass6hashes; noownPGIDs", + "registration": "not run", + "fullBuild": "not run", + "ciBasic": "not run", + "swiftTest": "not run", + "UIHostReleaseMobile": "not run", + "sdlc": "owner pass, documentation only", + "diff": "owner pass" + }, + "source": { + "at": "2026-10-11T00:35:47.877752+00:00", + "count": 846, + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true + }, + "childNative": { + "native": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-context-20261011-r10:ordinal:1", + "ordinal": 1, + "status": "interrupted", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-11T00:31:46.551Z", + "startedAt": "2026-10-11T00:31:46.726Z", + "completedAt": "2026-10-11T00:35:24.619Z" + }, + "activeRunId": null, + "pendingRequestCount": 0, + "taskStatus": { + "taskId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-context-20261011-r10", + "childThreadId": "thread:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-context-20261011-r10", + "childRunId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-context-20261011-r10:ordinal:1", + "childNodeId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-context-20261011-r10", + "status": "interrupted", + "workState": "result_available", + "hasPendingChildRuns": false, + "latestTerminalRunId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-context-20261011-r10:ordinal:1", + "latestTerminalStatus": "interrupted", + "latestTerminalSummary": "Run10 证据已保存,缺失前置条件已注明。[报告](RESEARCH_WORKTREE/.local/voicecursor-research/run10-context/child-report.md)\n\n", + "latestTerminalResultContextTransferId": "context-transfer:type:subagent_result:source-thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-context-20261011-r10:target-thread:mcp%3A6aef24eb-40c7-4074-8d14-6d17c03f9f30:4ef865d6-b245-4b45-b9bf-59196bc9d00e", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "summary": "Run10 证据已保存,缺失前置条件已注明。[报告](RESEARCH_WORKTREE/.local/voicecursor-research/run10-context/child-report.md)\n\n", + "resultContextTransferId": "context-transfer:type:subagent_result:source-thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-context-20261011-r10:target-thread:mcp%3A6aef24eb-40c7-4074-8d14-6d17c03f9f30:4ef865d6-b245-4b45-b9bf-59196bc9d00e", + "waitTimedOut": false + }, + "plannedCancelAt": "2026-10-11T00:35:15Z", + "cancelToolObservedAt": "2026-10-11T00:35:24Z", + "cancelCheckpointMissedApproximatelySeconds": 9, + "elapsedSeconds": 217.893, + "targetMissedSeconds": 14.619, + "hardDeadlineMet": true + }, + "taskId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-context-20261011-r10", + "nextAction": "restore task-owned scaffolding from fresh verified local19; isolate outerprofile rule with actual inner retained; prove installedmanifest context before oneoffline resolve/full compile", + "parentNativeStartedAt": "2026-10-11T00:27:30.405Z", + "parentNativeCompletedAt": "2026-10-11T00:37:07.376Z", + "artifactClosedAt": "2026-10-11T00:36:41.947685+00:00", + "ordinaryApprovalRequired": false, + "actualAcceptanceRequired": true, + "ownerFinalChecks": [ + { + "label": "sdlc", + "argv": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-11T00:36:41.952617+00:00", + "endedAt": "2026-10-11T00:36:43.006397+00:00", + "exitCode": 0, + "pid": 37763, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0", + "dataFreeBytes": 92271632384 + }, + { + "label": "diff", + "argv": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-11T00:36:43.006510+00:00", + "endedAt": "2026-10-11T00:36:43.030780+00:00", + "exitCode": 0, + "pid": 39133, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dataFreeBytes": 92271583232 + } + ], + "parentNativeElapsedSeconds": 576.971, + "parentNativeTargetMissedSeconds": 6.971 + }, + "run11Execution": { + "authorization": "overall 都允许 plus explicit Run11 continuation; not acceptance", + "owner": "existingvoiceEditingOwner", + "executor": "one supervised T3-owned Codex gpt-6.1-sol xhigh/default; quotaunknown", + "scope": "specific named-rule/realmanifest context, preserved protection", + "files": "raw run11-rules + portable rule-context-r11.md; parent exclusive workflow/verification/state/rootresult", + "taskId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-rules-20261011-r11", + "childThreadId": "thread:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-rules-20261011-r11", + "parentNativeStartedAt": "2026-10-11T00:40:17.030Z", + "parentNativeCompletedAt": null, + "parentTarget": "2026-10-11T00:49:47.030Z", + "parentHardDeadline": "2026-10-11T00:50:17.030Z", + "childNativeStartedAt": "2026-10-11T00:41:28.651Z", + "childCommandStopAt": "2026-10-11T00:47:20Z", + "childNativeTarget": "2026-10-11T00:47:50Z", + "childCancelCheckpoint": "2026-10-11T00:48:00Z", + "childHardDeadline": "2026-10-11T00:48:20Z", + "dependencies": [ + "fresh Data>=10GiB", + "current externalUUID/APFS writable", + "preserve actual inner and approved-directory/network protections", + "verify current dependencies actually reused" + ], + "acceptance": [ + "exact namedrule/context and rerunnable ledger", + "source unchanged", + "protectioncanaries before atmostoneoffline resolve", + "native terminal noownedprocesses" + ], + "status": "closed partial retained" + }, + "run11Acceptance": { + "status": "partial retained; concrete different-policy reapplication boundary; no product pass", + "authorization": "用户整体授权 + explicit Run11; actual acceptance not substituted", + "checks": { + "namedRuleCensus": "pass9probes: sole differing file-write subpath rule; stricter second policy also fails", + "installedInnerArgv": "pass actualSwiftPMdebug-generated profile/executable/fileno/context; ps missed inner process", + "directInnerEnvironment": "not observed; safe parent task environment recorded", + "currentProtectionCanary": "pass approvedwrite; taskdeniedwrite/IPv4loopback/POSIXshm EPERM, only these operations", + "actualManifest": "failed2diagnosticdump-package attempts exit1; original inner retained", + "offlineResolve": "not run;0attempts", + "fullBuild": "not run", + "ciBasic": "not run", + "swiftTest": "not run", + "nativeUIHostMobileRelease": "not run", + "source846": "pass unchanged", + "commandLogs": "pass20hashes; noownedPGIDs", + "freshCapacity": "pass485samples min92222947328bytes", + "dependencyReuse": "none; nofreshcontent/submodule proof claimed" + }, + "ownerThreadId": "mcp:6aef24eb-40c7-4074-8d14-6d17c03f9f30", + "taskId": "node:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-rules-20261011-r11", + "childThreadId": "thread:delegated-task:command%3Amcp%3Aee4b8700-5fa9-4845-b27c-f183b77e47a0%3Adelegate-task%3Avoice-editing-rules-20261011-r11", + "childNative": { + "native": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253Aee4b8700-5fa9-4845-b27c-f183b77e47a0%253Adelegate-task%253Avoice-editing-rules-20261011-r11:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-11T00:41:28.478Z", + "startedAt": "2026-10-11T00:41:28.651Z", + "completedAt": "2026-10-11T00:47:26.860Z" + }, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "status": "completed", + "elapsedSeconds": 358.209, + "targetMet": true, + "hardDeadlineMet": true, + "cancelRequested": false, + "cancelReason": "completed before planned checkpoint; no cancellation needed" + }, + "parentNativeStartedAt": "2026-10-11T00:40:17.030Z", + "parentNativeCompletedAt": null, + "parentTarget": "2026-10-11T00:49:47.030Z", + "parentHardDeadline": "2026-10-11T00:50:17.030Z", + "artifactClosedAt": "2026-10-11T00:49:55.060108+00:00", + "source": { + "at": "2026-10-11T00:48:31.169857+00:00", + "count": 846, + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true + }, + "installedInnerProfileSHA256": "976f0e833ac3d80e3ace3c8e34bc981f66db55efde4443d64d554d77ff25d6e8", + "installedHeaderSHA256": "5baa98c212110d6323f7d73f4b42b0072a4c66a826a46c8bfd8fb850305f9416", + "remainingOwnPGIDs": [], + "genericRecoveryCount": "2/2 exhausted; no reset or third generic workaround", + "nextAction": "仅安排一个有充分预算的实际策略兼容性单元:以已捕获的installed inner为基准,核对相同策略/任务目录布置与network denial能否共同保持,先真实file/network保护和rootmanifest执行;未证明即停止。禁止关闭inner、放宽caller/OS权限或猜测加write grants。通过后才精确验证实际复用的本地19依赖/submodule内容、一次offline resolve,再独立fullbuild/basic/tests。不得自主Run12。", + "clientRequestIds": [ + "voice-editing-rules-20261011-r11", + "voicecursor-r11-checkpoint-1-20261011", + "voicecursor-r11-checkpoint-2-20261011", + "voicecursor-r11-result-20261011" + ], + "errors": [ + "initial catalog/rule output truncated; targeted full reread corrected", + "child guessed two historical result paths; actual persisted root path later read", + "supporteddebug argument capture distinguishes ps-missed inner from debug-generatedargv", + "recorded installedhelp before instrumentation; freshidenticalhelp after", + "Apple online primary lookup inaccessible; no claim from that page", + "childreport oldartifact timestamp differs from final JSON close; neither is native terminal" + ], + "ownerFinalChecks": [ + { + "label": "sdlc", + "argv": [ + "bash", + "scripts/sdlc-checks.sh" + ], + "startedAt": "2026-10-11T00:49:55.062032+00:00", + "endedAt": "2026-10-11T00:49:56.133702+00:00", + "exitCode": 0, + "pid": 49576, + "logSHA256": "5ed5fb27bb8a18b455bb5c10b900d0e0b602a1b7dc598eecbd736cacdc07bfc0", + "dataFreeBytes": 92235943936 + }, + { + "label": "diff", + "argv": [ + "git", + "diff", + "--check" + ], + "startedAt": "2026-10-11T00:49:56.133807+00:00", + "endedAt": "2026-10-11T00:49:56.154542+00:00", + "exitCode": 0, + "pid": 50966, + "logSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dataFreeBytes": 92235866112 + } + ] + }, + "run12Execution": { + "researchRun": 12, + "nativeOrdinal": 16, + "authorization": "user继续onOct13 plus overall都允许; not acceptance", + "owner": "existingVoiceEditingOwner", + "executor": "oneT3-ownedCodexSolXhighStandard/quotaunknown", + "taskId": "node:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r12", + "childThreadId": "thread:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r12", + "parentNativeStartedAt": "2026-10-13T13:10:32.135Z", + "parentNativeCompletedAt": null, + "parentHardDeadline": "2026-10-13T13:20:32.135Z", + "parentTarget": "2026-10-13T13:20:02.135Z", + "childCommandStopAt": "2026-10-13T13:17:50Z", + "childCancelAt": "2026-10-13T13:18:15Z", + "childHardDeadline": "2026-10-13T13:18:30Z", + "status": "closed incomplete", + "scope": "one samewrite-policy layout with explicit networkdeny retained; atmostoneprotectedrootmanifest, noresolve/build", + "dependencies": [ + "fresh10GiBfloor", + "actualcurrentSwiftinner", + "onlytaskowneddirectories", + "protectionsnotreduced" + ], + "acceptance": [ + "exactfinitecompatibility+protectioncanary", + "actualexit0parsedmanifest if attempted", + "sourcepreservation", + "native/ownedPGIDs ended" + ], + "outputs": [ + "ignoredrun12-compat/", + "manifest-compat-r12.md(optional)" + ], + "errors": [ + "old root handoff directory missing, not recreated", + "native14cancelled/15failed transport; durable shows no execution/delegation in those runs" + ], + "nextAction": "only after trueprotectedmanifestpass verifyactual reused19/submodulecontent; offline registration thenfullbuild/nativeUI/host/mobileT0/Release" + }, + "run12Acceptance": { + "status": "incomplete; no new technical probe result", + "researchRun": 12, + "nativeOrdinal": 16, + "parentNativeStartedAt": "2026-10-13T13:10:32.135Z", + "parentNativeCompletedAt": "2026-10-13T13:20:50.136Z", + "childNative": { + "startedAt": "2026-10-13T13:16:55.529Z", + "completedAt": "2026-10-13T13:18:37.779Z", + "status": "interrupted", + "elapsedSeconds": 102.25, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "hardDeadlineMissSeconds": 7.779, + "targetMissSeconds": 27.779, + "cancelCheckpointDelayApproximatelySeconds": 22 + }, + "source": { + "at": "2026-10-13T13:20:24.879312+00:00", + "count": 846, + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true + }, + "taskId": "node:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r12", + "checks": { + "source": "pass846unchanged", + "freshCapacity": "above10GiB", + "compatibility": "not run", + "protectionCanary": "not run", + "manifest": "not run", + "resolveBuildTestsUIHostMobileRelease": "not run", + "childTerminal": "interrupted; noactive/pending; missedhard7.779s" + }, + "nextAction": "earlydelegate>=5min; exactnarrowwritepolicy/layout withnetworkdeny, currenttoolchain/realcanary beforemanifest; noautonomousRun13", + "artifactClosedAt": "2026-10-13T13:20:24.915028+00:00", + "clientRequestIds": [ + "voice-editing-compat-20261013-r12", + "voicecursor-r12-cancel-20261013", + "voicecursor-r12-result-20261013" + ], + "parentNativeElapsedSeconds": 618.001, + "parentNativeHardDeadlineMissSeconds": 18.001, + "parentNativeHardDeadlineMet": false + }, + "run13Execution": { + "researchRun": 13, + "nativeOrdinal": 17, + "parentNativeStartedAt": "2026-10-13T13:27:26.053Z", + "parentNativeCompletedAt": null, + "parentTarget": "2026-10-13T13:36:30Z", + "parentHardDeadline": "2026-10-13T13:37:26.053Z", + "childTaskId": "node:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r13", + "childThreadId": "thread:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r13", + "childCommandStopAt": "2026-10-13T13:33:50Z", + "childCancelAt": "2026-10-13T13:34:20Z", + "childHardDeadline": "2026-10-13T13:34:40Z", + "owner": "existingVoiceEditingOwner", + "executor": "oneCodexSolXhighStandard;quotaunknown", + "scope": "samewrites/sharedlayout with explicitdeny-network and actualinner preserved; finiteprobe/canary/oneconditionalmanifest", + "dependencies": [ + "freshData10GiB", + "currentinstalledinner", + "nooriginalsource/lock/checkoutmetadata changes" + ], + "outputs": [ + "run13-compat raw/result", + "manifest-compat-r13.md optional" + ], + "acceptance": [ + "realcompatibility/protection", + "ifattempted realexit0parsedrootmanifest", + "sourcepreservation", + "native/ownedPGID terminal" + ], + "authorization": "explicit继续验证 andoverall都允许; not acceptance", + "status": "closed bounded manifestpass" + }, + "run13Acceptance": { + "status": "PASS bounded protected rootmanifest; notproduct", + "researchRun": 13, + "nativeOrdinal": 17, + "parentNativeStartedAt": "2026-10-13T13:27:26.053Z", + "parentNativeCompletedAt": "2026-10-13T13:38:28.782Z", + "taskId": "node:delegated-task:command%3Amcp%3Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%3Adelegate-task%3Avoice-editing-compat-20261013-r13", + "childNative": { + "native": { + "runId": "run:thread:thread%3Adelegated-task%3Acommand%253Amcp%253Addcf2823-fd2a-4879-b4d8-fc1ba551b34f%253Adelegate-task%253Avoice-editing-compat-20261013-r13:ordinal:1", + "ordinal": 1, + "status": "completed", + "providerInstanceId": "codex", + "model": "gpt-6.1-sol", + "requestedAt": "2026-10-13T13:29:11.427Z", + "startedAt": "2026-10-13T13:29:16.579Z", + "completedAt": "2026-10-13T13:34:38.466Z" + }, + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "elapsedSeconds": 321.887, + "targetMissedSeconds": 28.466, + "hardDeadlineMet": true, + "cancelRequested": false, + "cancelCheckpointMissed": true, + "cancelNote": "planned34:20 cancellation not issued; nativecompleted34:38.466 beforehard34:40; do not claim timely cancellation" + }, + "evidence": { + "at": "2026-10-13T13:36:14.336864+00:00", + "dataFreeBytes": 90605998080, + "source": { + "at": "2026-10-13T13:36:14.308571+00:00", + "count": 846, + "manifestSHA256": "de6b2fe41b205b259748fc91df8dea7189b1a2dc2c8fba4f0e8d232d875efc50", + "baseSHA": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "trackedDiffSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "unchanged": true + }, + "commandCount": 10, + "logChecks": [ + { + "label": "source-before-head", + "exitCode": 0, + "recordedSHA256": "7a79a8cb92b5c55a6a412c9b71960e9e3189c41395c141a5006ac737e650f9af", + "actualSHA256": "7a79a8cb92b5c55a6a412c9b71960e9e3189c41395c141a5006ac737e650f9af", + "matches": true, + "pid": 33800, + "pgid": 33800, + "remaining": false + }, + { + "label": "source-before-diff", + "exitCode": 0, + "recordedSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "actualSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "matches": true, + "pid": 33801, + "pgid": 33801, + "remaining": false + }, + { + "label": "installed-swift-version", + "exitCode": 0, + "recordedSHA256": "b5e4e616abcf690417aa3ee11230ab5a53ff3efecd7a317a84b9cb99f17a5927", + "actualSHA256": "b5e4e616abcf690417aa3ee11230ab5a53ff3efecd7a317a84b9cb99f17a5927", + "matches": true, + "pid": 33802, + "pgid": 33802, + "remaining": false + }, + { + "label": "installed-package-help", + "exitCode": 0, + "recordedSHA256": "e5865ac3b511c8256ca6dad77452f36bdfd6b0da0390da308b82940265893722", + "actualSHA256": "e5865ac3b511c8256ca6dad77452f36bdfd6b0da0390da308b82940265893722", + "matches": true, + "pid": 33806, + "pgid": 33806, + "remaining": false + }, + { + "label": "same-policy-self-control", + "exitCode": 0, + "recordedSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "actualSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "matches": true, + "pid": 33807, + "pgid": 33807, + "remaining": false + }, + { + "label": "retained-network-to-native-inner", + "exitCode": 0, + "recordedSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "actualSHA256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "matches": true, + "pid": 33808, + "pgid": 33808, + "remaining": false + }, + { + "label": "protection-canary", + "exitCode": 0, + "recordedSHA256": "7a389c79820282671aa78bf580999ebfdd226a881651cdbf32f7485e29f6d231", + "actualSHA256": "7a389c79820282671aa78bf580999ebfdd226a881651cdbf32f7485e29f6d231", + "matches": true, + "pid": 33809, + "pgid": 33809, + "remaining": false + }, + { + "label": "protected-real-root-dump-package", + "exitCode": 0, + "recordedSHA256": "291180e6a4a2c8f2b9a9e09b5f24e532b4bf65ad30e915bac7ee75d4b47d8999", + "actualSHA256": "291180e6a4a2c8f2b9a9e09b5f24e532b4bf65ad30e915bac7ee75d4b47d8999", + "matches": true, + "pid": 33810, + "pgid": 33810, + "remaining": false + }, + { + "label": "source-after-head", + "exitCode": 0, + "recordedSHA256": "7a79a8cb92b5c55a6a412c9b71960e9e3189c41395c141a5006ac737e650f9af", + "actualSHA256": "7a79a8cb92b5c55a6a412c9b71960e9e3189c41395c141a5006ac737e650f9af", + "matches": true, + "pid": 33885, + "pgid": 33885, + "remaining": false + }, + { + "label": "source-after-diff", + "exitCode": 0, + "recordedSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "actualSHA256": "11b3c4932766a8fdd6cfda1e243193c06afb1ce6b38c686baf94698ec27e8a83", + "matches": true, + "pid": 33886, + "pgid": 33886, + "remaining": false + } + ], + "allLogsMatch": true, + "remainingOwnPGIDs": [], + "actualJSONFoundInNativeLog": true, + "parsedJSONMatchesNativeLog": true, + "realPackageName": "OpenType", + "targetCount": 41, + "outerIsExactInnerPlusNetworkDeny": true, + "outerSHA256": "00b9de61ea4ca14b604d1ba6998e90dd4decf57e328b889a7dae9813b3690dcc", + "innerSHA256": "edf799a1cfdd336bc79f05fc7d88602bd3d36af229f0101c6a66c3dc6dc7ea34", + "realPackageSHA256": "55d1d234775c275fd91182dfddb0feec0b75723a381430c11348146832495f6d", + "explicitInnerDisableAbsent": true + }, + "manifestCommand": { + "label": "protected-real-root-dump-package", + "argv": [ + "/usr/bin/sandbox-exec", + "-f", + "RESEARCH_WORKTREE/.local/voicecursor-research/run13-compat/retained-network-outer.sb", + "/usr/bin/xcrun", + "swift", + "package", + "--package-path", + "RESEARCH_WORKTREE", + "--scratch-path", + "RESEARCH_WORKTREE/.local/voicecursor-research/run13-compat/modulecache/scratch", + "--cache-path", + "RESEARCH_WORKTREE/.local/voicecursor-research/run13-compat/modulecache/packagecache", + "--config-path", + "RESEARCH_WORKTREE/.local/voicecursor-research/run13-compat/modulecache/config", + "--security-path", + "RESEARCH_WORKTREE/.local/voicecursor-research/run13-compat/modulecache/security", + "--disable-netrc", + "--manifest-cache", + "shared", + "--very-verbose", + "dump-package" + ], + "startedAt": "2026-10-13T13:32:14.063945+00:00", + "capacityBefore": { + "at": "2026-10-13T13:32:14.063946+00:00", + "label": "protected-real-root-dump-package", + "dataFreeBytes": 90687107072 + }, + "pid": 33810, + "pgid": 33810, + "exitCode": 0, + "endedAt": "2026-10-13T13:32:27.941142+00:00", + "logSHA256": "291180e6a4a2c8f2b9a9e09b5f24e532b4bf65ad30e915bac7ee75d4b47d8999", + "processGroupEnded": true + }, + "canaries": { + "approvedWrite": { + "allowed": true + }, + "deniedWrite": { + "allowed": false, + "errno": 1 + }, + "networkConnect": { + "allowed": false, + "errno": 1 + }, + "sharedMemory": { + "allowed": false, + "errno": 1 + } + }, + "notRun": [ + "offlineResolve", + "19dependencyregistration", + "fullbuild", + "basic", + "swifttest", + "UI", + "host", + "mobileT0", + "Release" + ], + "artifactClosedAt": "2026-10-13T13:37:47.484450+00:00", + "nextAction": "freshlocal19/submodule+allmanifestpolicy proof then actualoffline registration in sameprotectedlayout; separatefullbuild", + "authorization": "整体都允许+继续验证; notactualproductacceptance", + "parentNativeElapsedSeconds": 662.729, + "parentNativeHardDeadlineMissSeconds": 62.729 + }, + "timeLimitSupersededByUser": true, + "completionExecution": { + "owner": "existing voice editing owner", + "status": "in progress", + "goal": "complete desktop/mobile voice editing and verified PR", + "stages": [ + "fullbuild", + "native review/safety", + "mobileowner realT0 and voiceediting", + "independent candidate checks", + "commit/push/linkPR/watchCI" + ], + "oldResearchRounds": "historical evidence, not product acceptance", + "parentStartedAt": "2026-10-14T01:40:10.032Z", + "timeLimit": null, + "nativeTerminal": null, + "mainHistory": { + "head": "ed4234b41764be8346ae58cdc63011c8f26a0f1e", + "originMain": "f0ef2aa88296da017e29ced2b7bc9271af96e4fb", + "mergeBase": "c383b25d27d652e8da8fe58f594578b1bb603e5a", + "leftRight": "6/10", + "status": "normal merge pending commit, no unresolved entries" + }, + "mobileDevice": "deferred by user; no query/reservation/install/launch; root coordinator", + "nativeUI": "settled native source compiles; 49 fixture passes/3 unverified; packaged runtime/host pending", + "offlineResolve": "actual pass; native sandboxes retained; whole unmerged build/tests passed", + "currentCheckpoint": { + "updatedAt": "2026-10-14T05:41:23.442615+00:00", + "status": "in progress; independent software acceptance refused until confirmed fixes", + "compile": "Merged preliminary whole build/basic/SDLC/diff passed", + "sourceSHA256": "6f4040fd8a58ced9b0747b239291237d2aab11d6d384a8b229057878e82d9558", + "tests": "Current1220 XCTest/18skip/2failedcases10assertions+1SwiftTesting. Earlier1216green cannotclear newfailures.", + "Models": "Two-file owner correction accepted for mobile mechanical import; mobile own rerun pending", + "APIReview": "API02 complete38 scope independently accepted; API01R2 exact two-line correction parent reviewed with actual125focused/1182whole checks", + "packet": "r3 parent isolated apply/current/base/source/result and869source/31product/8command log hash checks pass; import acknowledged", + "mobile": "Original owner imported exact r3 shared38, actual MobileJob/native ack/UI export in progress; devices prohibited", + "nativeUI": "RealSwiftPM componentframes/pointerdispatch; externalhost/Escape/VO/contrast/scrollend not passed", + "next": "Native-onlysecureboundary fix +runtime-onlyContacts isolation; freshhoststate; complete mobileexport; finalrebuild/independentacceptance/PR", + "taskIds": [ + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-strict-output-packet-20261014", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-portable-api-review-20261014-r2", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-typed-final-fix-20261014-r2", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-main-release-20261014", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-native-host-verifier-20261014", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-security-boundary-fix-20261014", + "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-test-runtime-isolation-20261014" + ], + "release": "Actual xcodebuild Release/Metal/resources/helper/AppleDevelopment codesign and artifact verifier pass; not notarized/distribution", + "independentVerifier": "completed05:43:06.110–06:14:37.688Z, no pending; NH01/NH02 confirmed red, NH03 runtime isolation issue" + }, + "hostEnvironment": "User confirmed this Mac mini cannot currently be unlocked. Actualexternalhost acceptance deferred/notpassed; no furtherunlockrequest, loginwindow/SecureInput/TCC changes prohibited. Continue localsoftwarecompletion+PR with explicitgates.", + "draftCheckpoint": { + "date": "2026-10-14T07:45:16.826511+00:00", + "status": "preparing authorized combined Draft", + "runtimeTaskId": "node:delegated-task:command%3Amcp%3A44fadcb4-394f-444f-87ac-e4ea9575c52d%3Adelegate-task%3Avoice-editing-complete-test-runtime-20261014", + "nativeStartedAt": "2026-10-14T06:58:28.476Z", + "nativeCompletedAt": "2026-10-14T07:35:29.300Z", + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "sourceReadback": 888, + "frozenProductReadback": 8940, + "buildContextReadback": 23, + "commandRecords": 34, + "ownedGroupsEnded": 33, + "canaries": "9/9 named-store boundary only", + "selected": "1pass", + "isolation": "1pass", + "focused": "70executed37pass33fixture-setupfail", + "swiftTesting": "0executed;2loaderfail;unitretrybudgetexhausted", + "mobileOwnedImport": "exact38 result hashes +1340otherfiles/indexunchanged;shared/Modelsnotreapplied", + "candidateGates": "combined build/tests/basic/nativeUI/host/mobileT0/Release/human acceptance pending", + "next": "normal commit/push/create/link combined Draft; continue exact fixes on same PR" + }, + "pullRequest": { + "number": 124, + "url": "https://github.com/IchenDEV/utter/pull/124", + "state": "Draft;ongoing", + "creationHead": "de895347d5fe4671db90f6c7aa4d3bbe9cb9b1d2", + "creationCI": "basic/compile/Release-styleAppBuild/1SwiftTesting pass;XCTestaborted;aggregateGatefailed", + "fixtureFix": "four TestSupport-only files accepted;9canaries+1regression+70focused pass;zero skip;new CI head pending", + "localSwiftTesting": "unrun;2/2 exhausted;static census does not grant dynamic pass", + "hostDevices": "deferred,not passed", + "next": "normal commit/push same PR,exact new CI;then native review and remaining local/platform gates" + }, + "fixtureFixAcceptance": { + "nativeStartedAt": "2026-10-14T07:58:41.047Z", + "nativeCompletedAt": "2026-10-14T08:21:54.864Z", + "activeRunId": null, + "pendingRequestCount": 0, + "hasPendingChildRuns": false, + "sourceMatched": 906, + "productFilesMatched": 8976, + "contextFilesMatched": 23, + "all27OwnedPGIDsAbsent": true, + "sourceHash": "5e112f73ceb81242cf6f6217fbd77458c8cc4fcc0a91a6b75caf7c17f4dbbec9", + "reportSHA256": "332c32fd40d3fbb218136b72e5eadcae66e5f5026ee345f55866498b1c9f660b" + } + }, + "reportingRoute": "Latest user: report directly in this thread, no root progress/terminal notifications", + "codexNativeUX20261016": { + "owner": "current Codex; user direct reporting", + "authorization": "Explicit user: no T3 dependency; safe regenerable cache cleanup; preserve UX; finish native/scroll/narrow/accessibility validation and update PR124", + "humanAcceptance": false, + "dependencies": [ + "existing19localpins", + "Data>=10GiB", + "unchanged native build and private-runtime boundaries" + ], + "outputs": [ + "two native XCTest source files", + "plan.md", + "verification.md", + "ignored native render/log/hash/audit records" + ], + "acceptance": { + "wholeBuild": "pass", + "basicSDLC": "pass", + "canaries": "9/9", + "nativeTests": "6/6,0skip,0fail", + "sameSourceOwnerSessionTests": "71/71,0skip,0fail", + "visual": "44nativePNGs/12variants inspected", + "UX": "production source unchanged", + "scope": "programmatic native scroll layout; AX actions/content order; local key/pointer routing" + }, + "independentTask": "/root/native_ax_boundary_review; completed two bounded read-only reports; no T3", + "status": "local scope passed; PR update pending; product acceptance remains partial", + "open": [ + "physical wheel", + "actual VoiceOver speech/navigation/scroll", + "unlocked-host voice/focus/delivery", + "physical mobile T0", + "full private-broker coverage", + "distribution signing/notarization", + "human/protected approval" + ], + "next": "Commit/push tested increment to existing Draft PR124, verify remote SHA and exact new-head CI; no protected merge or release." + }, + "hudStyleCorrection": { + "date": "2026-10-17", + "owner": "current Codex", + "authorization": "User rejects visual divergence; preserve original UX within existing overall implementation/PR authorization", + "baseline": "pre-feature f0ef2aa native HUD and retained original screenshots", + "status": "local verification passed; Draft PR124 revision", + "acceptance": [ + "same dark HUD surface and caption hierarchy", + "existing26pt x/check button renderer", + "304pt normal width and bounded long text", + "unchanged explicit consent and one-use write/restore controls" + ], + "evidence": ".local/voicecursor-research/codex-ux-correction-20261017/", + "humanAcceptance": "not claimed", + "nativeTests": "8/8 +71/71, zero skips/failures", + "runtimeCanaries": "9/9", + "imagesInspected": 58, + "auditHeadings": 157, + "sourceSHA256": "a5e6a33c9013bc552541a8cbb6aad410c7ec2a587b0e7edabd9bc4a54abab2df", + "artifactSHA256": "f4008ec1e89c91487f418f04dfa80a5e01435384663f9630aedaf2472c22f943", + "independentReview": "completed; concrete pointer/fixture findings closed; final execution reviewed by parent", + "remaining": "fresh GitHub checks; actual host/VoiceOver/device/distribution acceptance" + }, + "currentOwner": "current Codex", + "automaticDesktopEdit": { + "owner": "current Codex", + "authorization": "2026-10-17 explicit user: remove candidate/Apply; support shortcuts; preserve original UX", + "status": "local verification passed; Draft PR124 update", + "dependencies": [ + "same Session/target/output/receipt owners", + "19 local pins", + "Data>=10GiB", + "retained native sandbox/private test runtime" + ], + "deliverables": [ + "direct-edit Session flow", + "compact HUD and scoped shortcuts", + "behavior/native tests", + "same-source evidence", + "PR124 update" + ], + "acceptance": [ + "one automatic guarded write", + "cancel/stale/error no write", + "one-use exact restoration", + "no candidate/Apply in popup", + "keyboard lifecycle and no host Command-Z capture" + ], + "humanAcceptance": false, + "open": [ + "locked real host", + "actual VoiceOver", + "physical mobile T0", + "distribution acceptance" + ], + "sourceSHA256": "b9b45674c3ff4e0e2afdc76b62fea190a02a3f3ada97849b91b94788be91f836", + "productSHA256": "a605be004e1dfa496e2a4b89182b4f8c214ec80c02fd99208552706fd5e67e86", + "checks": { + "wholeBuild": "pass", + "basic": "pass", + "ownerSecurity": "81/81", + "nativeShortcuts": "9/9", + "canaries": "9/9", + "rawPNGs": 42 + }, + "independentReview": "completed; visibility/drain findings fixed; final source review no findings", + "evidence": ".local/voicecursor-research/codex-auto-edit-20261017/", + "next": "Push exact verified source to existing Draft PR124 and read fresh remote head/CI; keep actual host/VoiceOver/mobile/distribution gates open" + } +}