From 7b566885a7a86443b0161904236f9640e687ab55 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:30:20 -0700 Subject: [PATCH] refactor: extract coordinator state mechanisms --- Cotabby.xcodeproj/project.pbxproj | 20 ++++++ .../SuggestionCoordinator+Acceptance.swift | 29 +++----- .../SuggestionCoordinator+Prediction.swift | 29 +++----- .../Coordinators/SuggestionCoordinator.swift | 36 ++-------- .../PostExhaustionAcceptanceState.swift | 59 +++++++++++++++ .../Suggestion/SuggestionStreamingState.swift | 69 ++++++++++++++++++ ...SuggestionCoordinatorAcceptanceTests.swift | 18 ++--- .../PostExhaustionAcceptanceStateTests.swift | 57 +++++++++++++++ .../SuggestionStreamingStateTests.swift | 72 +++++++++++++++++++ 9 files changed, 312 insertions(+), 77 deletions(-) create mode 100644 Cotabby/Support/Suggestion/PostExhaustionAcceptanceState.swift create mode 100644 Cotabby/Support/Suggestion/SuggestionStreamingState.swift create mode 100644 CotabbyTests/Support/Suggestion/PostExhaustionAcceptanceStateTests.swift create mode 100644 CotabbyTests/Support/Suggestion/SuggestionStreamingStateTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 5a030d86..2a52e744 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 03FBB478CF7C51BDFB9AEB6D /* ScreenTextExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445BDC778CCC14AC909D659D /* ScreenTextExtractorTests.swift */; }; 0479478CBE8D5387119C3E35 /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */; }; 050750A0343235F426D8B47B /* ScreenshotContextGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023BA8AA7DF3F8A45F9CE8FA /* ScreenshotContextGeneratorTests.swift */; }; + 05A339764492452827B3431F /* SuggestionStreamingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BDB0BD6D25DE85E01C8321 /* SuggestionStreamingState.swift */; }; 05CC25E51682528CE2E73446 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 0609E4F29F537530A49C6A50 /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; 06358FF43FF44F773CCC6881 /* StreamedGhostTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2572996D6A44E20E2C0F9060 /* StreamedGhostTextPolicy.swift */; }; @@ -71,6 +72,7 @@ 1681C0F22323FB1156579D99 /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; 16CCE176C5C31CB180D131AD /* TextLayoutCaretEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5125EBB70F6E32CBD46506C0 /* TextLayoutCaretEstimatorTests.swift */; }; 16E1A22AA7F4827E69C7F2CE /* SuggestionSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB17696CE79982039061F29 /* SuggestionSettingsStore.swift */; }; + 17A2FC800BCEE6D3B3B12C24 /* SuggestionStreamingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BDB0BD6D25DE85E01C8321 /* SuggestionStreamingState.swift */; }; 1816D3D625F561209590D45D /* TagChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04DFC5BD7821D73DA21D7DB /* TagChip.swift */; }; 188DE63AA5C77C740781A1AF /* RuntimeBootstrapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF8AA184C5F9CFFDF97A070 /* RuntimeBootstrapModel.swift */; }; 1899BC5A35DC96B4D04B18A5 /* es.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0B6816DF5D33863F966240B4 /* es.txt */; }; @@ -137,6 +139,7 @@ 2C44C5142ED914BF99A70EC6 /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76BC705A82669AEFBC704F59 /* InlinePreviewView.swift */; }; 2CDF880348D439708BD4F792 /* ru.txt in Resources */ = {isa = PBXBuildFile; fileRef = D8C2663427BC5219EA415D00 /* ru.txt */; }; 2D83CECB0FF076323226449D /* SuggestionSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */; }; + 2DEA0512A4BB2ADD6BF06245 /* PostExhaustionAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E13F429518CBF0E51B7FCD3 /* PostExhaustionAcceptanceState.swift */; }; 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; 2E79F18B84807EFEAE20FA35 /* WelcomeKeybindStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D1F76146AC25F3B077B1F6D /* WelcomeKeybindStepView.swift */; }; @@ -179,6 +182,7 @@ 39B31DBE07288712A1ACA202 /* OpenAICompatibleAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */; }; 3A35344542DD0AB857C56115 /* MirrorOverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11589537F19EDE247697056 /* MirrorOverlayLayout.swift */; }; 3A3B49975BCF1F648F8C1782 /* SuggestionEngineModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B494C76056D9B01D6A3ECABD /* SuggestionEngineModelsTests.swift */; }; + 3A597568FCC4E6D1817037C2 /* PostExhaustionAcceptanceStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728F06EE19A43FB1780344EC /* PostExhaustionAcceptanceStateTests.swift */; }; 3AB45217DFC86AFC98C374D6 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; 3AC2F7F221F7C59242B06DFC /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 3AD39D992243A7AA541A43C8 /* SecureFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE99C84483D3A58D561C61D /* SecureFieldDetector.swift */; }; @@ -629,6 +633,7 @@ D397D3FF36E9AF8AD8421275 /* SuggestionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2574E89992AA55C237DE995 /* SuggestionRequest.swift */; }; D42BBBD704B21898047615E0 /* LlamaRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16967F782C95CEB09B9C8D49 /* LlamaRuntimeModels.swift */; }; D4460806E35A0FABE6CBD339 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC31A792E63C132BE8ABDFD2 /* EmojiMatcher.swift */; }; + D49AB056A3D414982099EED4 /* SuggestionStreamingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17E0B06A8C9E09961F39B7 /* SuggestionStreamingStateTests.swift */; }; D4DD6B6D22598BB3B98792DA /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; D58B73ED080E5D253A838FAC /* SpellingDictionaryResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */; }; @@ -683,6 +688,7 @@ E83E989C5132F2A8C8FED8EB /* SuggestionSettingsDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */; }; E846FC1E29526242117F126F /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DE86A49339BB507D197629D /* MenuBarPresentationObserver.swift */; }; E8768B22E683F35098DF4CCF /* InlinePreviewPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 924CAA5E25C596A9FAB7602B /* InlinePreviewPanelController.swift */; }; + E95B5CB0F37E447FE18D996F /* PostExhaustionAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E13F429518CBF0E51B7FCD3 /* PostExhaustionAcceptanceState.swift */; }; E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; E9B34664449F7451581C6908 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D93E3819F3BB0957FF3DF6 /* CustomRulesCatalog.swift */; }; E9B7E353B233F842AC859542 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; @@ -777,6 +783,7 @@ 0B6816DF5D33863F966240B4 /* es.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = es.txt; sourceTree = ""; }; 0C90C9EBCB70327D215EAE07 /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; 0DA66559D50874865032EE8C /* PromptContextSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptContextSanitizerTests.swift; sourceTree = ""; }; + 0E13F429518CBF0E51B7FCD3 /* PostExhaustionAcceptanceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExhaustionAcceptanceState.swift; sourceTree = ""; }; 114AC8D1D904F49DEFDAC0C6 /* SuggestionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionConfiguration.swift; sourceTree = ""; }; 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrector.swift; sourceTree = ""; }; 12F6FDB6D09851CC5C6649EF /* MenuBarSections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarSections.swift; sourceTree = ""; }; @@ -937,6 +944,7 @@ 66D6D153C80A5BDE4905B81E /* MacroEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngineTests.swift; sourceTree = ""; }; 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepGeometryWalkThrottle.swift; sourceTree = ""; }; 68ADAFFD5031EBFA70CD62BB /* EmojiPickerModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModelsTests.swift; sourceTree = ""; }; + 68BDB0BD6D25DE85E01C8321 /* SuggestionStreamingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStreamingState.swift; sourceTree = ""; }; 68C4773B9316036F7E594EE1 /* EmojiUsageModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageModels.swift; sourceTree = ""; }; 6A5B87C4C13EAE32813286C7 /* TextLayoutCaretEstimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimator.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; @@ -957,6 +965,7 @@ 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyAppEnvironment.swift; sourceTree = ""; }; 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsPaneView.swift; sourceTree = ""; }; 7174EF88BA41ACC0A74CA582 /* OnboardingTemplateFeatureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureList.swift; sourceTree = ""; }; + 728F06EE19A43FB1780344EC /* PostExhaustionAcceptanceStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostExhaustionAcceptanceStateTests.swift; sourceTree = ""; }; 72B13136DF7318F3E96DF0D3 /* SuggestionCoordinator+Acceptance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Acceptance.swift"; sourceTree = ""; }; 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordSpellChecker.swift; sourceTree = ""; }; 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LGPL-3.0.txt"; sourceTree = ""; }; @@ -1207,6 +1216,7 @@ FA93AF731DE8931F72C3C65F /* PerformanceMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStore.swift; sourceTree = ""; }; FAED667FCDDF3DE1A7EBDDB9 /* SuggestionPauseModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionPauseModelsTests.swift; sourceTree = ""; }; FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngineAndModelPaneView.swift; sourceTree = ""; }; + FD17E0B06A8C9E09961F39B7 /* SuggestionStreamingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStreamingStateTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1855,12 +1865,14 @@ AB1A91DE0EA12F499D2129D0 /* InsertionSafetyGate.swift */, DFEBE38C24E36D1FA3106FE5 /* InsertionStrategySelector.swift */, 310F3498A3E498729CC8433E /* MidWordContinuationPolicy.swift */, + 0E13F429518CBF0E51B7FCD3 /* PostExhaustionAcceptanceState.swift */, C231B65BAF20AD33A3C0130C /* SentenceBoundaryClassifier.swift */, 4275FD6213DCDA953B4800EA /* SpeculativeAcceptanceContext.swift */, 2572996D6A44E20E2C0F9060 /* StreamedGhostTextPolicy.swift */, AADD165EBA2FE4C66F5BA885 /* SuggestionAvailabilityEvaluator.swift */, 330D425B2BF2245FD7BBE4FA /* SuggestionRequestFactory.swift */, 022C91940E688482C3F80423 /* SuggestionSessionReconciler.swift */, + 68BDB0BD6D25DE85E01C8321 /* SuggestionStreamingState.swift */, 9DFA4093ADA92D5E33F1F833 /* SuggestionTextNormalizer.swift */, 4FCBADDF1A64F0813E094F2D /* TrailingDuplicationFilter.swift */, ); @@ -2195,6 +2207,7 @@ 45E30CEB74593771E5F62E5E /* InsertionSafetyGateTests.swift */, 6EA8ECB565875395AA51E328 /* InsertionStrategySelectorTests.swift */, 786EDA0AB3DACFDA1399D6A3 /* MidWordContinuationPolicyTests.swift */, + 728F06EE19A43FB1780344EC /* PostExhaustionAcceptanceStateTests.swift */, A8104A4A22860581743CCC81 /* SentenceBoundaryClassifierTests.swift */, 1480CC831D97E0C55267E0EC /* SpeculativeAcceptanceContextTests.swift */, FA6A718EDDF3473A762705BE /* StreamedGhostTextPolicyTests.swift */, @@ -2207,6 +2220,7 @@ 356C8E766609ED3CC1501F24 /* SuggestionSessionTypingTests.swift */, 34784852DCFDF959F76C7F78 /* SuggestionSettingsModelDisabledAppsTests.swift */, 45DC0822E12D5700AE705E76 /* SuggestionStaleAcceptanceEchoTests.swift */, + FD17E0B06A8C9E09961F39B7 /* SuggestionStreamingStateTests.swift */, 5A520C43E88BA7D3CCF0E961 /* SuggestionTextNormalizerTests.swift */, E0523515C5DAAED9475BB8A3 /* SuggestionWordAcceptanceTests.swift */, 7CC541A0FCCE9172C24C1350 /* SyntheticReplacePlannerTests.swift */, @@ -2841,6 +2855,7 @@ 513066CA00F0E9A5E7358A4E /* PermissionReminderView.swift in Sources */, D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */, D0BA4677F47C6943310B8A28 /* PopupChrome.swift in Sources */, + E95B5CB0F37E447FE18D996F /* PostExhaustionAcceptanceState.swift in Sources */, C607A624A0FB697486C56B8E /* PowerSourceMonitor.swift in Sources */, 37BAD9C6AFAAF99E818D734D /* PromptContextSanitizer.swift in Sources */, 47EFFC4AEBA2848C933BB77C /* PromptSectionBudget.swift in Sources */, @@ -2901,6 +2916,7 @@ FB31D4954D031D97EDBB4C73 /* SuggestionSettingsData.swift in Sources */, BCD54829D3DD33D8B034AA73 /* SuggestionSettingsModel.swift in Sources */, 16E1A22AA7F4827E69C7F2CE /* SuggestionSettingsStore.swift in Sources */, + 17A2FC800BCEE6D3B3B12C24 /* SuggestionStreamingState.swift in Sources */, 3DC75D1C5EF3AB83E3BC5750 /* SuggestionSubsystemContracts.swift in Sources */, 7523DE160DC22FE5C26E866E /* SuggestionTextColorCodec.swift in Sources */, A08A409B3D209BFDB7DF46EB /* SuggestionTextNormalizer.swift in Sources */, @@ -3109,6 +3125,7 @@ 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */, 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */, 986C1339A8FBA3569FC129D6 /* PopupChrome.swift in Sources */, + 2DEA0512A4BB2ADD6BF06245 /* PostExhaustionAcceptanceState.swift in Sources */, 50E1247BE6A952AB1B12C444 /* PowerSourceMonitor.swift in Sources */, 32A5095C89B598C9A959F435 /* PromptContextSanitizer.swift in Sources */, 21187878C7390AD639717B8F /* PromptSectionBudget.swift in Sources */, @@ -3169,6 +3186,7 @@ E3CF6ADB3DFAE5AB2F23935B /* SuggestionSettingsData.swift in Sources */, 5E43AD2C53EA1813A6A138E3 /* SuggestionSettingsModel.swift in Sources */, 96CC39E6D6DF3F27ABA8019B /* SuggestionSettingsStore.swift in Sources */, + 05A339764492452827B3431F /* SuggestionStreamingState.swift in Sources */, 6CCFAA1D86C4E4DC7E3DB8A3 /* SuggestionSubsystemContracts.swift in Sources */, 2EA1350B599695F472A8F06E /* SuggestionTextColorCodec.swift in Sources */, 285B3026E0D911FFFC306696 /* SuggestionTextNormalizer.swift in Sources */, @@ -3308,6 +3326,7 @@ 01CDBE7607A9F9F85F87AFF0 /* PerformanceMetricsStoreTests.swift in Sources */, 73B7C4D5143F7AEC321BC873 /* PermissionAndContextModelTests.swift in Sources */, 41F36D158005A69CE87921FA /* PermissionOverlayTrackerTests.swift in Sources */, + 3A597568FCC4E6D1817037C2 /* PostExhaustionAcceptanceStateTests.swift in Sources */, EEC06CE37957B44E2B258DEE /* PromptContextSanitizerTests.swift in Sources */, 7008657841ECD4F06A46A9DE /* PromptPolicyTests.swift in Sources */, EC4975F707689CEA77A636BB /* PromptSectionBudgetTests.swift in Sources */, @@ -3356,6 +3375,7 @@ 19449988F10AD289E2682F04 /* SuggestionSettingsStoreTests.swift in Sources */, 0224B3567B9DC45BD731A7DC /* SuggestionStaleAcceptanceEchoTests.swift in Sources */, DA92312A2DE3518DAF0C7819 /* SuggestionStateHelperTests.swift in Sources */, + D49AB056A3D414982099EED4 /* SuggestionStreamingStateTests.swift in Sources */, 420C41A28EB788D077719978 /* SuggestionTextColorCodecTests.swift in Sources */, 021084DE49BDB797C67EA100 /* SuggestionTextNormalizerTests.swift in Sources */, 6573CD78E36EF0C297CF291A /* SuggestionWordAcceptanceTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index d192448b..b1a06fdd 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -335,8 +335,7 @@ extension SuggestionCoordinator { // A final-chunk accept tears the session down and regenerates the continuation // asynchronously (see the `.exhausted` branch). While that regen is in flight we keep owning // Tab instead of leaking it into the host app as a real Tab. - if isPostExhaustionAcceptanceArmed { - hasQueuedPostExhaustionAccept = true + if postExhaustionAcceptanceState.queueAcceptIfArmed() { logStage( "\(keyName)-held-for-regen", workID: currentWorkID, @@ -467,20 +466,18 @@ extension SuggestionCoordinator { /// guard, so the accept tap forwarded the original Tab to the host and focus jumped out of the /// field. Re-asserting interception here keeps the tail tap installed and owning Tab across the /// regen window (its mach port otherwise lingers only ~50ms), and `shouldConsumeAcceptKeyProvider` - /// also consults `isPostExhaustionAcceptanceArmed` so the key is still routed in while the overlay - /// is hidden. A token-keyed backstop guarantees the window can never trap Tab. + /// also consults the extracted state so the key is still routed in while the overlay is hidden. + /// A token-keyed backstop guarantees the window can never trap Tab. func armPostExhaustionAcceptance() { - isPostExhaustionAcceptanceArmed = true - hasQueuedPostExhaustionAccept = false + let generation = postExhaustionAcceptanceState.arm() inputMonitor.setAcceptInterceptionActive(true) - postExhaustionAcceptanceGeneration &+= 1 - let generation = postExhaustionAcceptanceGeneration DispatchQueue.main.asyncAfter( deadline: .now() + Self.postExhaustionAcceptanceWindowSeconds ) { [weak self] in // Only the generation that scheduled this timer may act on it; a newer accept (or an // already-released window) bumped the token, so this fires as a no-op. - guard let self, self.postExhaustionAcceptanceGeneration == generation else { return } + guard let self, + self.postExhaustionAcceptanceState.ownsBackstop(generation: generation) else { return } self.releasePostExhaustionAcceptanceWindow() } } @@ -490,10 +487,7 @@ extension SuggestionCoordinator { /// to the caller: whether Tab ownership should drop depends on why the window ended (a fresh /// suggestion keeps owning it; a teardown or the backstop drops it). func clearPostExhaustionAcceptanceWindow() { - isPostExhaustionAcceptanceArmed = false - hasQueuedPostExhaustionAccept = false - // Cancel any pending backstop, which is keyed to the generation captured at arm time. - postExhaustionAcceptanceGeneration &+= 1 + postExhaustionAcceptanceState.clear() } /// Ends the post-exhaustion window and returns the accept key to the host unless a suggestion is @@ -501,7 +495,7 @@ extension SuggestionCoordinator { /// backstop release; the common, prompt release is `onStateChange(.hidden)` ending the window as /// soon as any teardown hides the overlay. func releasePostExhaustionAcceptanceWindow() { - guard isPostExhaustionAcceptanceArmed || hasQueuedPostExhaustionAccept else { return } + guard postExhaustionAcceptanceState.needsRelease else { return } if !overlayState.isVisible { inputMonitor.setAcceptInterceptionActive(false) } @@ -513,10 +507,9 @@ extension SuggestionCoordinator { /// instead of stalling. Bounded to one queued accept so mashing Tab cannot run away. Called at the /// end of `apply`'s success path, after the new session and overlay exist. func flushQueuedPostExhaustionAcceptIfNeeded() { - let shouldAccept = isPostExhaustionAcceptanceArmed && hasQueuedPostExhaustionAccept - // Normal acceptance has resumed now that a fresh suggestion is visible, so end the window - // regardless of whether a press was queued (this also cancels the now-redundant backstop). - clearPostExhaustionAcceptanceWindow() + // Consuming also ends the window when no press was queued, invalidating the backstop now that + // normal acceptance has resumed with a fresh visible suggestion. + let shouldAccept = postExhaustionAcceptanceState.consumeQueuedAccept() guard shouldAccept else { return } // A queued accept can still legitimately fail (the new continuation no longer reconciles with // live AX, or insertion fails). `acceptSuggestion` cleans up its own state on failure, so log diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 83ac9133..0a3e2112 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -265,14 +265,9 @@ extension SuggestionCoordinator { /// result (or failure) only while it is still the current work. Extracted from /// `generateFromCurrentFocus` so that function stays within the project's complexity budget. private func dispatchGeneration(request: SuggestionRequest, workID: UInt64) { - // A new generation starts a new stream; the previous request's rendered-partial state - // must not gate the new partials' monotonic checks. `isStreamDrainScheduled` is left - // alone on purpose: an already-enqueued drain block cannot be unscheduled, and it - // self-heals either way — it finds nil and clears the flag, or it finds a partial the - // new generation queued in the meantime and renders it under the same work-id guards. - // Resetting the flag here would instead double-schedule a drain for one partial. - streamRenderedText = nil - pendingStreamPartial = nil + // A new generation starts a new stream. The state value deliberately preserves an already + // scheduled drain callback while dropping the old request's partial and rendered text. + suggestionStreamingState.beginGeneration() // Streaming the ghost text token-by-token is opt-in. Read the flag here on the main actor so // the work closure captures a plain Bool. When off, the closure passes no `onPartial`, so the // engine skips its per-token main-actor hops entirely and the suggestion appears once, fully @@ -361,22 +356,18 @@ extension SuggestionCoordinator { guard workController.isCurrent(workID) else { return } - pendingStreamPartial = PendingStreamPartial(result: partial, workID: workID) - guard !isStreamDrainScheduled else { + guard suggestionStreamingState.enqueue(partial, workID: workID) else { return } - isStreamDrainScheduled = true DispatchQueue.main.async { [weak self] in self?.drainStreamedPartial() } } private func drainStreamedPartial() { - isStreamDrainScheduled = false - guard let pending = pendingStreamPartial else { + guard let pending = suggestionStreamingState.drain() else { return } - pendingStreamPartial = nil applyStreamedPartial(pending.result, workID: pending.workID) } @@ -393,10 +384,7 @@ extension SuggestionCoordinator { guard workController.isCurrent(workID) else { return } - guard StreamedGhostTextPolicy.isRenderableExtension( - candidate: partial.text, - currentlyRendered: streamRenderedText - ) else { + guard suggestionStreamingState.canRender(partial.text) else { return } guard let rawContext = focusModel.snapshot.context else { @@ -423,7 +411,7 @@ extension SuggestionCoordinator { liveContext: liveContext, latency: partial.latency ) - streamRenderedText = partial.text + suggestionStreamingState.recordRendered(partial.text) presentOverlay( text: partial.text, at: liveContext.caretRect, @@ -1085,8 +1073,7 @@ extension SuggestionCoordinator { // typed, focus changed, predictions disabled). The final-chunk accept re-sets it afterward. lastAcceptedTail = nil // Stream bookkeeping follows the session it was rendering for. - streamRenderedText = nil - pendingStreamPartial = nil + suggestionStreamingState.clearSession() latestSuggestionPreview = nil latestFullSuggestionPreview = nil latestRemainingSuggestionPreview = nil diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index a2fe82dc..e7499066 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -101,19 +101,9 @@ final class SuggestionCoordinator: ObservableObject { } var clipboardPrefaceMemo: ClipboardPrefaceMemo? - /// Streamed-render bookkeeping. Partial results hop in from the engine while a decode is - /// still running; they are coalesced (latest wins, drained once per runloop turn) so - /// token-rate deliveries cannot stack session and overlay layout work on the main actor, and - /// `streamRenderedText` carries the monotonic-extension state for `StreamedGhostTextPolicy`. - /// All of it is scoped to the current work id and reset when a new generation dispatches. - struct PendingStreamPartial { - let result: SuggestionResult - let workID: UInt64 - } - - var pendingStreamPartial: PendingStreamPartial? - var isStreamDrainScheduled = false - var streamRenderedText: String? + /// Coalescing and monotonic-render state for engine partials. The coordinator owns scheduling + /// and presentation; the value owns the stream's pure state transitions. + var suggestionStreamingState = SuggestionStreamingState() /// Monotonic cancellation token for the "wait until the host publishes typed text to AX" loop. /// @@ -155,21 +145,9 @@ final class SuggestionCoordinator: ObservableObject { /// stands down instead of scheduling a duplicate regeneration. var pendingSpeculativeSignature: String? - /// Monotonic token for the post-exhaustion "keep owning Tab" window. Bumped on every arm so a - /// stale backstop timer (or a window superseded by a newer accept) no-ops instead of releasing a - /// window it no longer owns. See `armPostExhaustionAcceptance`. - var postExhaustionAcceptanceGeneration: UInt64 = 0 - /// True while Cotabby keeps the accept tap owning Tab in the gap between a final-chunk accept and - /// the regenerated continuation appearing. Accepting the last buffered word hides the overlay and - /// reschedules generation asynchronously; without this the fail-open accept-tap preflight (which - /// keys on overlay visibility) would forward a fast follow-up Tab to the host as a real Tab and - /// focus would jump out of the field. The window self-releases when the next suggestion shows, - /// when any teardown hides the overlay, or via a backstop timer. See `armPostExhaustionAcceptance`. - var isPostExhaustionAcceptanceArmed = false - /// Set when a Tab is swallowed during that window. The next continuation that lands accepts its - /// first word, so rapid Tabbing keeps inserting words across the exhaustion boundary instead of - /// stalling. Bounded to a single queued accept so mashing Tab cannot run away. - var hasQueuedPostExhaustionAccept = false + /// Pure state for the bounded "keep owning Tab" window after a final-chunk acceptance. The + /// coordinator continues to own the timer and input-monitor effects around these transitions. + var postExhaustionAcceptanceState = PostExhaustionAcceptanceState() init( permissionManager: any SuggestionPermissionProviding, @@ -263,7 +241,7 @@ final class SuggestionCoordinator: ObservableObject { // even though the overlay is hidden then. Otherwise a fast follow-up Tab in that gap // falls through to the host app as a real Tab and focus jumps out of the field — the // "rapid Tab breaks, slow Tab is fine" report. See `armPostExhaustionAcceptance`. - guard self.overlayState.isVisible || self.isPostExhaustionAcceptanceArmed else { return false } + guard self.overlayState.isVisible || self.postExhaustionAcceptanceState.isArmed else { return false } return true } diff --git a/Cotabby/Support/Suggestion/PostExhaustionAcceptanceState.swift b/Cotabby/Support/Suggestion/PostExhaustionAcceptanceState.swift new file mode 100644 index 00000000..4f7ee14f --- /dev/null +++ b/Cotabby/Support/Suggestion/PostExhaustionAcceptanceState.swift @@ -0,0 +1,59 @@ +/// Pure state machine for the short Tab-ownership window after a suggestion is exhausted. +/// +/// The coordinator owns one instance for its lifetime and supplies the side effects: installing the +/// input interception, scheduling the timeout, and accepting a regenerated continuation. Keeping +/// those effects outside this value makes the hard rules explicit: only one accept may be queued, +/// every arm invalidates older timeout callbacks, and consuming or clearing the window is atomic. +struct PostExhaustionAcceptanceState: Equatable { + private(set) var backstopGeneration: UInt64 = 0 + private(set) var isArmed = false + private(set) var hasQueuedAccept = false + + /// Whether a release has any state to clear. Kept separate from `isArmed` so an inconsistent + /// queued flag still fails safe by returning Tab ownership to the host. + var needsRelease: Bool { + isArmed || hasQueuedAccept + } + + /// Opens a fresh window and returns the identity its timeout callback must capture. + @discardableResult + mutating func arm() -> UInt64 { + isArmed = true + hasQueuedAccept = false + backstopGeneration &+= 1 + return backstopGeneration + } + + /// Queues one rapid accept while the continuation is unavailable. + /// + /// Repeated presses deliberately collapse into one Boolean so a user mashing Tab cannot cause a + /// regenerated suggestion to accept multiple unseen chunks. + @discardableResult + mutating func queueAcceptIfArmed() -> Bool { + guard isArmed else { + return false + } + + hasQueuedAccept = true + return true + } + + /// Returns true only for the timeout belonging to the current arm operation. + func ownsBackstop(generation: UInt64) -> Bool { + backstopGeneration == generation + } + + /// Closes the window and invalidates every timeout callback already in flight. + mutating func clear() { + isArmed = false + hasQueuedAccept = false + backstopGeneration &+= 1 + } + + /// Atomically closes the window and reports whether the regenerated continuation owes one accept. + mutating func consumeQueuedAccept() -> Bool { + let shouldAccept = isArmed && hasQueuedAccept + clear() + return shouldAccept + } +} diff --git a/Cotabby/Support/Suggestion/SuggestionStreamingState.swift b/Cotabby/Support/Suggestion/SuggestionStreamingState.swift new file mode 100644 index 00000000..940b4477 --- /dev/null +++ b/Cotabby/Support/Suggestion/SuggestionStreamingState.swift @@ -0,0 +1,69 @@ +/// Main-actor bookkeeping for one streamed suggestion delivery. +/// +/// `SuggestionCoordinator` owns this value for its lifetime. The value keeps token-rate engine +/// callbacks from leaking three related invariants into the coordinator: pending partials are +/// latest-wins, at most one drain is scheduled per runloop turn, and visible text may only grow +/// monotonically. It does not schedule work or render UI; the coordinator remains responsible for +/// those side effects. +struct SuggestionStreamingState { + /// One partial paired with the replaceable-work identity that produced it. + struct PendingPartial { + let result: SuggestionResult + let workID: UInt64 + } + + private(set) var pendingPartial: PendingPartial? + private(set) var isDrainScheduled = false + private(set) var renderedText: String? + + /// Starts a new stream without clearing an already-enqueued drain callback. + /// + /// A scheduled callback cannot be cancelled. Preserving `isDrainScheduled` lets the existing + /// callback drain a replacement partial from the new generation; clearing it here could enqueue + /// two drains for the same runloop turn. + mutating func beginGeneration() { + renderedText = nil + pendingPartial = nil + } + + /// Stores the newest partial and returns whether the coordinator must schedule a drain. + @discardableResult + mutating func enqueue(_ result: SuggestionResult, workID: UInt64) -> Bool { + pendingPartial = PendingPartial(result: result, workID: workID) + guard !isDrainScheduled else { + return false + } + + isDrainScheduled = true + return true + } + + /// Ends the scheduled-drain window and returns the newest partial, if one survived teardown. + mutating func drain() -> PendingPartial? { + isDrainScheduled = false + defer { pendingPartial = nil } + return pendingPartial + } + + /// Applies the backend-independent monotonic rendering rule to the current stream. + func canRender(_ candidate: String) -> Bool { + StreamedGhostTextPolicy.isRenderableExtension( + candidate: candidate, + currentlyRendered: renderedText + ) + } + + /// Records text only after all coordinator freshness and seam guards have accepted it. + mutating func recordRendered(_ text: String) { + renderedText = text + } + + /// Drops state associated with a torn-down suggestion session. + /// + /// As with `beginGeneration`, a scheduled callback remains responsible for clearing the drain + /// flag when it eventually runs. + mutating func clearSession() { + renderedText = nil + pendingPartial = nil + } +} diff --git a/CotabbyTests/App/Suggestion/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/App/Suggestion/SuggestionCoordinatorAcceptanceTests.swift index 61b02751..0c40f4af 100644 --- a/CotabbyTests/App/Suggestion/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/App/Suggestion/SuggestionCoordinatorAcceptanceTests.swift @@ -284,7 +284,7 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { // First Tab accepts the only remaining chunk, exhausts the session, and arms the window. XCTAssertTrue(coordinator.acceptCurrentSuggestion()) XCTAssertEqual(inserter.insertedChunks, [" today"]) - XCTAssertTrue(coordinator.isPostExhaustionAcceptanceArmed) + XCTAssertTrue(coordinator.postExhaustionAcceptanceState.isArmed) XCTAssertFalse(coordinator.overlayState.isVisible) // Ownership of Tab was re-asserted even though the overlay is now hidden. XCTAssertEqual(inputMonitor.acceptInterceptionRequests.last, true) @@ -304,7 +304,7 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { [" today"], "The second Tab has nothing to insert yet; it is queued, not inserted." ) - XCTAssertTrue(coordinator.hasQueuedPostExhaustionAccept) + XCTAssertTrue(coordinator.postExhaustionAcceptanceState.hasQueuedAccept) } } @@ -334,14 +334,14 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { ) XCTAssertTrue(coordinator.acceptCurrentSuggestion()) - XCTAssertTrue(coordinator.isPostExhaustionAcceptanceArmed) + XCTAssertTrue(coordinator.postExhaustionAcceptanceState.isArmed) // Any teardown that hides the overlay (focus change, typing, dismissal, an empty // regeneration) must end the window so the user can Tab out of the field normally again. coordinator.invalidateActiveSuggestion(reason: "Focus moved to another field.") - XCTAssertFalse(coordinator.isPostExhaustionAcceptanceArmed) - XCTAssertFalse(coordinator.hasQueuedPostExhaustionAccept) + XCTAssertFalse(coordinator.postExhaustionAcceptanceState.isArmed) + XCTAssertFalse(coordinator.postExhaustionAcceptanceState.hasQueuedAccept) XCTAssertFalse( inputMonitor.shouldConsumeAcceptKeyProvider(), "Once the window is released the accept tap should stop owning Tab." @@ -379,8 +379,8 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { ) // Simulate a Tab that was swallowed and queued while this continuation was still loading; // `apply` calls `flushQueuedPostExhaustionAcceptIfNeeded` once the suggestion is on screen. - coordinator.isPostExhaustionAcceptanceArmed = true - coordinator.hasQueuedPostExhaustionAccept = true + coordinator.postExhaustionAcceptanceState.arm() + coordinator.postExhaustionAcceptanceState.queueAcceptIfArmed() coordinator.flushQueuedPostExhaustionAcceptIfNeeded() @@ -389,8 +389,8 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { [" world"], "The queued Tab should accept the continuation's first word." ) - XCTAssertFalse(coordinator.isPostExhaustionAcceptanceArmed) - XCTAssertFalse(coordinator.hasQueuedPostExhaustionAccept) + XCTAssertFalse(coordinator.postExhaustionAcceptanceState.isArmed) + XCTAssertFalse(coordinator.postExhaustionAcceptanceState.hasQueuedAccept) } } diff --git a/CotabbyTests/Support/Suggestion/PostExhaustionAcceptanceStateTests.swift b/CotabbyTests/Support/Suggestion/PostExhaustionAcceptanceStateTests.swift new file mode 100644 index 00000000..16216e11 --- /dev/null +++ b/CotabbyTests/Support/Suggestion/PostExhaustionAcceptanceStateTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import Cotabby + +/// Verifies the pure transition rules behind the coordinator's short post-acceptance Tab window. +/// Coordinator acceptance tests continue to cover the surrounding input-monitor and insertion effects. +@MainActor +final class PostExhaustionAcceptanceStateTests: XCTestCase { + func test_armOpensFreshWindowAndInvalidatesPreviousBackstop() { + var state = PostExhaustionAcceptanceState() + let firstGeneration = state.arm() + state.queueAcceptIfArmed() + + let secondGeneration = state.arm() + + XCTAssertTrue(state.isArmed) + XCTAssertFalse(state.hasQueuedAccept) + XCTAssertFalse(state.ownsBackstop(generation: firstGeneration)) + XCTAssertTrue(state.ownsBackstop(generation: secondGeneration)) + } + + func test_acceptQueuesOnlyWhileWindowIsArmed() { + var state = PostExhaustionAcceptanceState() + + XCTAssertFalse(state.queueAcceptIfArmed()) + state.arm() + XCTAssertTrue(state.queueAcceptIfArmed()) + XCTAssertTrue(state.queueAcceptIfArmed()) + XCTAssertTrue(state.hasQueuedAccept) + } + + func test_clearClosesWindowAndInvalidatesCapturedBackstop() { + var state = PostExhaustionAcceptanceState() + let generation = state.arm() + state.queueAcceptIfArmed() + + state.clear() + + XCTAssertFalse(state.isArmed) + XCTAssertFalse(state.hasQueuedAccept) + XCTAssertFalse(state.needsRelease) + XCTAssertFalse(state.ownsBackstop(generation: generation)) + } + + func test_consumeQueuedAcceptAtomicallyClosesWindow() { + var queuedState = PostExhaustionAcceptanceState() + queuedState.arm() + queuedState.queueAcceptIfArmed() + + XCTAssertTrue(queuedState.consumeQueuedAccept()) + XCTAssertFalse(queuedState.needsRelease) + + var emptyState = PostExhaustionAcceptanceState() + emptyState.arm() + XCTAssertFalse(emptyState.consumeQueuedAccept()) + XCTAssertFalse(emptyState.needsRelease) + } +} diff --git a/CotabbyTests/Support/Suggestion/SuggestionStreamingStateTests.swift b/CotabbyTests/Support/Suggestion/SuggestionStreamingStateTests.swift new file mode 100644 index 00000000..b970aa8f --- /dev/null +++ b/CotabbyTests/Support/Suggestion/SuggestionStreamingStateTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import Cotabby + +/// Locks down the coordinator's extracted token-stream bookkeeping without involving an engine, +/// runloop timing, Accessibility, or overlay presentation. +@MainActor +final class SuggestionStreamingStateTests: XCTestCase { + func test_enqueueCoalescesToNewestPartialAndSchedulesOnlyOneDrain() throws { + var state = SuggestionStreamingState() + let first = result(text: " wor") + let newest = result(text: " world") + + XCTAssertTrue(state.enqueue(first, workID: 10)) + XCTAssertFalse(state.enqueue(newest, workID: 10)) + + let pending = try XCTUnwrap(state.drain()) + XCTAssertEqual(pending.result, newest) + XCTAssertEqual(pending.workID, 10) + XCTAssertFalse(state.isDrainScheduled) + XCTAssertNil(state.pendingPartial) + } + + func test_beginGenerationPreservesScheduledDrainForReplacementPartial() throws { + var state = SuggestionStreamingState() + XCTAssertTrue(state.enqueue(result(text: " old"), workID: 1)) + + state.recordRendered(" old") + state.beginGeneration() + + XCTAssertNil(state.renderedText) + XCTAssertNil(state.pendingPartial) + XCTAssertTrue(state.isDrainScheduled) + XCTAssertFalse(state.enqueue(result(text: " new"), workID: 2)) + + let pending = try XCTUnwrap(state.drain()) + XCTAssertEqual(pending.result.text, " new") + XCTAssertEqual(pending.workID, 2) + } + + func test_clearSessionLetsAlreadyScheduledEmptyDrainSelfHeal() { + var state = SuggestionStreamingState() + state.enqueue(result(text: " pending"), workID: 4) + state.recordRendered(" pending") + + state.clearSession() + + XCTAssertNil(state.renderedText) + XCTAssertNil(state.pendingPartial) + XCTAssertTrue(state.isDrainScheduled) + XCTAssertNil(state.drain()) + XCTAssertFalse(state.isDrainScheduled) + } + + func test_renderedTextOnlyAdmitsStrictMonotonicExtensions() { + var state = SuggestionStreamingState() + + XCTAssertTrue(state.canRender(" wor")) + state.recordRendered(" wor") + XCTAssertTrue(state.canRender(" world")) + XCTAssertFalse(state.canRender(" wor")) + XCTAssertFalse(state.canRender(" wild")) + } + + private func result(text: String) -> SuggestionResult { + SuggestionResult( + generation: 7, + rawText: text, + text: text, + latency: 0.01 + ) + } +}