From a092825e86377d5b310b1ab0457703b4420a412b Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 15 Oct 2026 19:00:20 +0800 Subject: [PATCH] Release v1.158.0 --- .gitignore | 7 + RELEASE_NOTES.en.md | 35 +- RELEASE_NOTES.md | 35 +- electron/ipcChannels.ts | 2 + electron/main.ts | 18 +- electron/persistedAtomFile.test.ts | 352 ++++++ electron/persistedAtomFile.ts | 230 ++++ electron/persistedAtomHost.test.ts | 257 ++++ electron/persistedAtomHost.ts | 72 ++ electron/persistedAtomIndex.test.ts | 378 ++++++ electron/persistedAtomRecords.test.ts | 266 ++++ electron/persistedAtomRecords.ts | 389 ++++++ .../persistedAtomStore.integration.test.ts | 354 ++++++ electron/persistedAtomStore.test-support.ts | 123 ++ electron/persistedAtomStore.test.ts | 453 +++++++ electron/persistedAtomStore.ts | 487 ++++++++ electron/preload.test.ts | 92 ++ electron/preload.ts | 16 +- electron/rendererEventPolicy.test.ts | 37 + electron/rendererEventPolicy.ts | 21 + eslint.config.js | 11 + internal/aiprompts/architecture.md | 102 +- internal/aiprompts/commands.md | 19 + internal/exec-plans/README.md | 2 +- .../codex-gui-alignment-current-plan.md | 677 ++-------- .../codex-gui-alignment-history-20261015.md | 799 ++++++++++++ internal/exec-plans/release-v1.157.0-plan.md | 22 + internal/exec-plans/release-v1.158.0-plan.md | 69 ++ .../composer-code-highlight-audit.md | 61 + .../composer-code-nodeview-audit.md | 68 ++ .../composer-draft-persistence-audit.md | 122 +- .../composer-marks-css-audit.md | 15 + .../codex-desktop-rea/evidence-index.json | 1087 +++++++++++++++++ .../new-conversation-navigation-audit.md | 71 ++ internal/research/codex-desktop-rea/report.md | 33 +- .../settings-navigation-audit.md | 77 ++ .../structure-alignment-ledger.md | 43 + lime-rs/Cargo.lock | 72 +- lime-rs/Cargo.toml | 2 +- package.json | 5 +- packages/cli/package.json | 2 +- pnpm-lock.yaml | 35 + scripts/agent-qc/project-gate-settings-a.mjs | 2 +- scripts/agent-runtime/async-tabs-smoke.mjs | 19 +- ...current-fixture-composer-code-dom.test.mjs | 108 ++ ...t-current-fixture-composer-code-picker.mjs | 423 +++++++ ...rent-fixture-composer-code-picker.test.mjs | 360 ++++++ ...-current-fixture-composer-code-pm.test.mjs | 101 ++ ...-chat-current-fixture-composer-history.mjs | 115 +- ...-current-fixture-composer-history.test.mjs | 11 +- ...t-fixture-composer-marks-renderer.test.mjs | 6 +- ...aw-chat-current-fixture-composer-marks.mjs | 157 ++- ...at-current-fixture-composer-marks.test.mjs | 102 +- .../claw-chat-current-fixture-constants.mjs | 10 +- ...chat-current-fixture-draft-persistence.mjs | 739 +++++++++++ .../claw-chat-current-fixture-first-send.mjs | 685 +++++++++++ ...-current-fixture-inputbar-rich-restore.mjs | 52 +- ...rrent-fixture-rich-input-evidence.test.mjs | 29 +- .../claw-chat-current-fixture-smoke.mjs | 72 +- .../claw-chat-current-fixture-teardown.mjs | 97 ++ ...law-chat-current-fixture-teardown.test.mjs | 129 ++ .../current-fixture-regression-smoke.mjs | 1 + .../fixtures/composer-reference.md | 5 + .../agent-runtime/tool-surface-page-smoke.mjs | 2 +- scripts/check-command-contracts.mjs | 6 +- .../electron/composer-draft-fixture-smoke.mjs | 35 + scripts/electron/current-entrypoints.test.mjs | 106 +- .../lib/composer-draft-fixture-driver.mjs | 658 ++++++++++ .../lib/composer-draft-fixture-evidence.mjs | 255 ++++ .../composer-draft-fixture-evidence.test.mjs | 512 ++++++++ .../lib/composer-first-send-evidence.mjs | 473 +++++++ .../lib/composer-first-send-evidence.test.mjs | 608 +++++++++ ...oser-first-send-resource-evidence.test.mjs | 333 +++++ .../composer-home-draft-fixture-evidence.mjs | 280 +++++ ...poser-home-draft-fixture-evidence.test.mjs | 497 ++++++++ .../composer-home-draft-fixture-scenario.mjs | 288 +++++ .../lib/persisted-atom-fixture-driver.mjs | 385 ++++++ .../persisted-atom-fixture-driver.test.mjs | 414 +++++++ .../lib/persisted-atom-fixture-standalone.mjs | 309 +++++ ...persisted-atom-fixture-standalone.test.mjs | 248 ++++ .../electron/lib/persisted-atom-fixture.mjs | 736 +++++++++++ .../lib/persisted-atom-fixture.test.mjs | 775 ++++++++++++ .../persisted-atom-index-consumer.test.mjs | 231 ++++ .../electron/settings-about-fixture-smoke.mjs | 2 +- scripts/lib/composer-editor-dom.mjs | 2 + scripts/lib/composer-editor-dom.test.mjs | 15 +- scripts/lib/electron-fixture-build.mjs | 6 +- scripts/lib/electron-fixture-build.test.mjs | 24 + scripts/lib/invoke-trace-collector.mjs | 40 +- scripts/lib/persisted-atom-contract.mjs | 152 +++ scripts/lib/persisted-atom-contract.test.mjs | 240 ++++ scripts/lib/project-gate-settings-a-core.mjs | 3 +- .../lib/project-gate-settings-a-core.test.mjs | 6 + scripts/settings-all-pages-current-smoke.mjs | 145 +-- src/App.tsx | 27 +- .../AppPageContent.newConversation.test.tsx | 88 ++ src/components/AppPageContent.test.tsx | 48 +- src/components/AppPageContent.tsx | 53 +- .../AppSidebar.conversationProjects.test.tsx | 41 +- .../AppSidebar.conversations.test.tsx | 31 +- src/components/AppSidebar.testFixtures.tsx | 26 +- src/components/AppSidebar.tsx | 32 +- ...ntChatWorkspace.homePendingPreview.test.ts | 6 +- ...entChatWorkspace.inputRestoreGuard.test.ts | 25 +- ...taskCenterInteractionBoundaryGuard.test.ts | 6 +- ...atWorkspace.workspaceBoundaryGuard.test.ts | 17 +- .../agent/chat/agentChatWorkspaceContract.ts | 6 +- .../agent/chat/commands/executor.ts | 59 +- src/components/agent/chat/commands/types.ts | 3 +- .../agent/chat/components/EmptyState.test.tsx | 368 +++++- .../agent/chat/components/EmptyState.tsx | 263 ++-- .../agent/chat/components/EmptyState.types.ts | 14 +- ...EmptyStateComposerPanel.inputFlow.test.tsx | 74 +- .../EmptyStateComposerPanel.testFixtures.tsx | 26 +- .../components/EmptyStateComposerPanel.tsx | 80 +- ...ontroller.stopRestore.integration.test.tsx | 671 ++++++++++ .../Inputbar/hooks/useInputbarController.ts | 161 ++- .../Inputbar/hooks/useInputbarSend.test.tsx | 357 +++++- .../Inputbar/hooks/useInputbarSend.ts | 258 ++-- .../chat/components/Inputbar/index.test.tsx | 22 +- .../agent/chat/components/Inputbar/index.tsx | 29 +- .../Inputbar/inputbarSendPayload.test.ts | 47 + .../Inputbar/inputbarSendPayload.ts | 35 +- .../ShellBackgroundTerminalList.test.tsx | 28 +- .../components/TaskCenterTabStrip.test.tsx | 3 +- .../chat/components/TaskCenterTabStrip.tsx | 2 +- .../composerSubmissionSettlement.test.ts | 399 ++++++ .../composerSubmissionSettlement.ts | 295 +++++ .../agent/chat/homePendingPreview.ts | 23 +- .../agentChatSendMessage.acceptance.test.ts | 94 ++ .../chat/hooks/agentChatSendMessage.test.ts | 71 +- .../agent/chat/hooks/agentChatSendMessage.ts | 170 +-- .../agent/chat/hooks/agentChatShared.ts | 15 +- .../chat/hooks/agentRuntimeAdapter.test.ts | 29 +- .../agent/chat/hooks/agentRuntimeAdapter.ts | 18 +- .../agent/chat/hooks/agentSendResult.test.ts | 123 ++ .../hooks/agentSendResult.testFixtures.ts | 18 + .../agent/chat/hooks/agentSendResult.ts | 205 ++++ .../agentStreamAcceptance.testFixtures.ts | 124 ++ .../agentStreamComposerRestoreToken.test.ts | 208 ++++ .../chat/hooks/agentStreamFlowControl.test.ts | 6 +- .../chat/hooks/agentStreamFlowControl.ts | 56 +- .../hooks/agentStreamInputRestoreTypes.ts | 2 + .../agentStreamPreparedSendDispatch.test.ts | 10 +- .../hooks/agentStreamPreparedSendDispatch.ts | 2 +- .../agentStreamRestoreToken.testFixtures.ts | 119 ++ .../agent/chat/hooks/agentStreamSend.test.ts | 10 +- .../agent/chat/hooks/agentStreamSend.ts | 2 +- .../hooks/agentStreamSubmissionLifecycle.ts | 5 + ...ntStreamSubmitExecution.acceptance.test.ts | 143 +++ ...eamSubmitExecution.structured.unit.test.ts | 4 +- .../hooks/agentStreamSubmitExecution.test.ts | 59 +- .../chat/hooks/agentStreamSubmitExecution.ts | 77 +- ...entStreamSubmitLifecycleController.test.ts | 104 +- .../agentStreamSubmitLifecycleController.ts | 139 ++- .../agentStreamUserInputSendPreparation.ts | 5 + ...serInputSubmission.structured.unit.test.ts | 14 +- .../agentStreamUserInputSubmission.test.ts | 5 +- .../hooks/agentStreamUserInputSubmission.ts | 4 +- .../agent/chat/hooks/handleSendTypes.ts | 8 +- .../preferencesStorage.case.tsx | 1 + .../useAgentChat.test/runtimeRouting.case.tsx | 1 + .../slashSkillExecution.case.tsx | 15 +- .../chat/hooks/useAgentChat.testUtils.tsx | 38 +- .../agent/chat/hooks/useAgentChat.ts | 2 +- .../hooks/useAgentRuntimeSyncEffects.test.tsx | 16 +- .../useAgentStream.restoreToken.test.tsx | 149 +++ .../agent/chat/hooks/useAgentStream.ts | 9 +- .../chat/index.serviceSkillA2ui.test.tsx | 4 +- .../agent/chat/index.taskTabs02.test.tsx | 42 +- src/components/agent/chat/index.test.tsx | 197 ++- .../agent/chat/index.testFixtures.tsx | 25 +- src/components/agent/chat/index.tsx | 3 - .../agent/chat/index.workbench01.test.tsx | 5 +- .../chat/sessionTypesCurrentBoundary.test.ts | 22 +- .../CharacterMention.testFixtures.tsx | 6 +- .../inputCapabilitySelection.ts | 15 + .../skill-selection/skillSelectionBindings.ts | 3 + .../agent/chat/taskCenterDraftTaskEvents.ts | 49 - .../agent/chat/utils/agentChatCopy.test.ts | 35 +- .../agent/chat/utils/taskCenterTabs.test.ts | 26 +- .../agent/chat/utils/taskCenterTabs.ts | 10 - .../workspace/CodingWorkbenchOutputPanel.tsx | 18 +- .../CodingWorkbenchRecoveryPanel.tsx | 3 +- .../workspace/ComposerDraftStatus.test.tsx | 90 ++ .../chat/workspace/ComposerDraftStatus.tsx | 56 + .../workspace/WorkspaceConversationScene.tsx | 5 +- .../workspace/agentChatWorkspaceHelpers.ts | 49 +- .../agentChatWorkspaceHelpers.unit.test.ts | 4 - .../agent/chat/workspace/chatSurfaceProps.ts | 14 + ...mposerDraftNavigation.integration.test.tsx | 410 +++++++ .../chat/workspace/composerDraftNavigation.ts | 16 + .../workspace/inputCapabilityRouting.test.ts | 47 + .../chat/workspace/inputCapabilityRouting.ts | 13 +- .../knowledge/useWorkspaceKnowledgeRuntime.ts | 15 +- ...ationPublicNavigation.integration.test.tsx | 442 +++++++ .../workspace/stopSendChain.testFixtures.tsx | 218 ++++ ...nterComposerDraftSend.integration.test.tsx | 352 ++++++ .../chat/workspace/taskCenterDraftTabs.ts | 185 --- .../taskCenterDraftTabs.unit.test.ts | 224 ---- .../taskCenterSubmissionSettlement.test.ts | 58 + .../taskCenterSubmissionSettlement.ts | 65 + .../chat/workspace/taskCenterSurfaceState.ts | 57 +- .../taskCenterSurfaceState.unit.test.ts | 109 +- .../workspace/taskCenterTabProjection.test.ts | 115 +- .../chat/workspace/taskCenterTabProjection.ts | 41 +- .../useAgentChatWorkspaceCommandRuntime.ts | 26 +- ...mandWiring.navigation.integration.test.tsx | 314 +++++ .../useAgentChatWorkspaceCommandWiring.ts | 91 +- .../useAgentChatWorkspaceSceneRuntime.tsx | 40 +- .../useAgentChatWorkspaceSetupRuntime.ts | 4 +- ...entChatWorkspaceShellInteractionRuntime.ts | 86 +- ...alWorkbenchInitialDispatchRuntime.test.tsx | 7 +- ...eGeneralWorkbenchInitialDispatchRuntime.ts | 5 +- .../useTaskCenterChromeNavigationRuntime.ts | 53 +- ...CenterDraftMaterializationRuntime.test.tsx | 552 --------- ...seTaskCenterDraftMaterializationRuntime.ts | 471 ------- .../useTaskCenterDraftSendDispatchRuntime.ts | 315 ++--- .../useTaskCenterDraftSendRuntime.ts | 666 +++++----- ...useTaskCenterDraftSendRuntime.unit.test.ts | 727 ++--------- ...askCenterDraftSendStructured.unit.test.tsx | 295 +++-- .../useTaskCenterFallbackRestoreRuntime.ts | 4 - .../useTaskCenterHomeChromeRuntime.ts | 6 +- ...eTaskCenterHomeChromeRuntime.unit.test.tsx | 1 - .../useTaskCenterNewTaskPageRuntime.ts | 178 --- ...eTaskCenterNewTaskPageRuntime.unit.test.ts | 99 -- .../workspace/useTaskCenterTabChrome.test.tsx | 104 ++ .../chat/workspace/useTaskCenterTabChrome.tsx | 53 +- .../useTaskCenterTabSessionRuntime.test.tsx | 57 +- .../useTaskCenterTabSessionRuntime.ts | 39 +- ...eTaskCenterTopicNavigationRuntime.test.tsx | 230 +++- .../useTaskCenterTopicNavigationRuntime.ts | 212 +--- .../useWorkspaceCanvasWorkflowActions.ts | 3 +- ...mposerDraftNavigation.integration.test.tsx | 412 +++++++ .../useWorkspaceComposerDraftNavigation.ts | 142 +++ .../useWorkspaceComposerDraftRuntime.test.tsx | 319 +++++ .../useWorkspaceComposerDraftRuntime.ts | 236 ++++ ...tRuntime.visibleReady.integration.test.tsx | 368 ++++++ .../useWorkspaceContextSurfaceRuntime.ts | 15 +- ...ConversationLandingSurfaceRuntime.test.tsx | 8 + ...spaceConversationLandingSurfaceRuntime.tsx | 33 + .../useWorkspaceHomeRecoveryRuntime.ts | 2 +- ...kspaceImageWorkbenchActionRuntime.test.tsx | 33 +- ...ageWorkbenchActionRuntime.testFixtures.tsx | 14 +- ...spaceImageWorkbenchCommandActionRuntime.ts | 17 +- .../useWorkspaceImageWorkbenchEventRuntime.ts | 3 +- ...eImageWorkbenchSendCommandRuntime.test.tsx | 8 +- ...rkspaceImageWorkbenchSendCommandRuntime.ts | 2 +- .../useWorkspaceInputbarSceneRuntime.test.tsx | 194 ++- .../useWorkspaceInputbarSceneRuntime.tsx | 84 +- .../useWorkspacePlanDecisionRuntime.tsx | 3 +- .../workspace/useWorkspaceResetRuntime.ts | 60 +- .../useWorkspaceResetRuntime.unit.test.tsx | 37 +- .../useWorkspaceSendActions.test.tsx | 375 +++--- .../chat/workspace/useWorkspaceSendActions.ts | 471 +++---- ...dMetadata.stopRestore.integration.test.tsx | 492 ++++++++ .../useWorkspaceSendStructured.unit.test.tsx | 253 ++-- .../useWorkspaceSendSurfaceRuntime.ts | 25 +- ...kspaceTaskCenterDraftStateRuntime.test.tsx | 34 +- ...useWorkspaceTaskCenterDraftStateRuntime.ts | 69 +- ...seWorkspaceTaskCenterInteractionRuntime.ts | 89 +- ...useWorkspaceTaskCenterNavigationRuntime.ts | 21 +- .../useWorkspaceTaskCenterSendRuntime.ts | 205 ++-- ...orkspaceTaskCenterSendRuntime.unit.test.ts | 118 +- .../useWorkspaceTaskCenterSurfaceRuntime.ts | 34 +- ...spaceTopicSwitch.navigationSource.test.tsx | 255 ++++ .../chat/workspace/useWorkspaceTopicSwitch.ts | 195 ++- .../workspaceArticleEditorActionDispatch.ts | 12 +- ...ceArticleEditorActionDispatch.unit.test.ts | 59 +- .../workspaceConversationCodingViews.tsx | 3 +- .../workspaceQueueSubmission.test.ts | 103 ++ .../workspace/workspaceQueueSubmission.ts | 44 + .../workspace/workspaceSendSubmission.test.ts | 111 ++ .../chat/workspace/workspaceSendSubmission.ts | 65 + .../useAppSidebarConversationActions.ts | 70 +- .../input-kit/CodeBlockNodeView.test.ts | 270 ++++ src/components/input-kit/CodeBlockNodeView.ts | 188 +++ .../CodeBlockNodeViewRegistry.test.ts | 105 ++ .../input-kit/CodeBlockNodeViewRegistry.ts | 89 ++ src/components/input-kit/CodeBlockToolbar.css | 107 ++ ...odeBlockToolbar.locales.component.test.tsx | 81 ++ .../input-kit/CodeBlockToolbar.test.tsx | 330 +++++ src/components/input-kit/CodeBlockToolbar.tsx | 215 ++++ .../input-kit/ComposerController.ts | 136 +-- .../ComposerDraftCoordinator.binding.test.ts | 340 ++++++ ...DraftCoordinator.fresh.integration.test.ts | 183 +++ .../ComposerDraftCoordinator.test.ts | 510 ++++++++ .../ComposerDraftCoordinator.testFixtures.ts | 206 ++++ .../input-kit/ComposerDraftCoordinator.ts | 766 ++++++++++++ ...omposerEditor.codeBlock.component.test.tsx | 229 ++++ src/components/input-kit/ComposerEditor.tsx | 32 +- .../input-kit/ComposerIntegration.test.tsx | 333 +++++ .../composerCodeBlockAutoDetect.test.ts | 398 ++++++ .../input-kit/composerCodeBlockAutoDetect.ts | 187 +++ .../composerCodeBlockHighlight.test.ts | 573 +++++++++ .../input-kit/composerCodeBlockHighlight.ts | 508 ++++++++ .../composerCodeBlockLanguage.test.ts | 155 +++ .../input-kit/composerCodeBlockLanguage.ts | 283 +++++ ...mposerCodeBlockReference.testFixtures.json | 256 ++++ .../input-kit/composerCodeBlockTestUtils.ts | 64 + .../input-kit/composerDraftCodec.test.ts | 508 ++++++++ .../input-kit/composerDraftCodec.ts | 280 +++++ src/components/input-kit/composerState.ts | 89 ++ src/components/input-kit/index.ts | 13 +- src/components/input-kit/types.ts | 19 + .../input-kit/useComposerController.ts | 90 +- .../_layout/SettingsNavigation.tsx | 16 +- .../settings-v2/_layout/index.test.tsx | 235 +++- src/components/settings-v2/_layout/index.tsx | 410 +------ .../agent/image-gen/index.test.tsx | 62 + .../settings-v2/agent/image-gen/index.tsx | 11 +- .../agent/media-services/index.tsx | 11 +- .../useAutoDismissMessage.ts | 12 +- .../settings-v2/agent/video-gen/index.tsx | 18 +- .../settings-v2/agent/voice/index.tsx | 2 +- .../hooks/useSettingsCategory.test.tsx | 25 + .../settings-v2/hooks/useSettingsCategory.ts | 176 +-- src/components/settings-v2/index.ts | 7 +- src/components/settings-v2/sections.tsx | 305 +++++ src/hooks/useAppNavigation.test.tsx | 173 +++ src/hooks/useAppNavigation.ts | 247 +++- src/i18n/__tests__/loadNamespace.test.ts | 4 +- src/i18n/__tests__/types.test.ts | 4 +- src/i18n/agentChatCopy.ts | 16 +- src/i18n/resources/en-US/agentInputbar.json | 20 +- src/i18n/resources/en-US/settings.json | 3 +- src/i18n/resources/ja-JP/agentInputbar.json | 20 +- src/i18n/resources/ja-JP/settings.json | 3 +- src/i18n/resources/ko-KR/agentInputbar.json | 20 +- src/i18n/resources/ko-KR/settings.json | 3 +- src/i18n/resources/zh-CN/agentInputbar.json | 20 +- src/i18n/resources/zh-CN/settings.json | 3 +- src/i18n/resources/zh-TW/agentInputbar.json | 20 +- src/i18n/resources/zh-TW/settings.json | 3 +- src/lib/api/agentRuntime/clientFactory.d.ts | 11 +- .../api/agentRuntime/clientFactory.test.ts | 39 +- .../agentRuntime/runtimeAcceptance.test.ts | 94 ++ src/lib/api/agentRuntime/runtimeAcceptance.ts | 136 +++ .../threadClient.acceptance.test.ts | 142 +++ src/lib/api/agentRuntime/threadClient.d.ts | 6 +- src/lib/api/agentRuntime/threadClient.test.ts | 225 ++-- src/lib/api/agentRuntime/threadClient.ts | 48 +- .../api/persistedAtomIndexProtocol.test.ts | 361 ++++++ src/lib/api/persistedAtomProtocol.test.ts | 364 ++++++ src/lib/api/persistedAtomProtocol.ts | 527 ++++++++ src/lib/api/persistedAtomTypes.ts | 165 +++ src/lib/api/persistedAtoms.test.ts | 484 ++++++++ src/lib/api/persistedAtoms.ts | 105 ++ .../promptHistory.current-boundary.test.ts | 12 +- ...http-client.persistedAtom.contract.test.ts | 157 +++ src/lib/dev-bridge/http-client.ts | 27 +- src/lib/dev-bridge/invokeDiagnostics.ts | 108 ++ .../safeInvoke.persistedAtom.contract.test.ts | 210 ++++ src/lib/dev-bridge/safeInvoke.ts | 85 +- src/lib/governance/agentCommandCatalog.json | 1 + .../appServerRuntimeBoundary.testSupport.ts | 1 + src/lib/workspace/navigation.ts | 13 +- src/types/page.ts | 7 + src/types/settings.ts | 27 - 359 files changed, 41354 insertions(+), 7902 deletions(-) create mode 100644 electron/persistedAtomFile.test.ts create mode 100644 electron/persistedAtomFile.ts create mode 100644 electron/persistedAtomHost.test.ts create mode 100644 electron/persistedAtomHost.ts create mode 100644 electron/persistedAtomIndex.test.ts create mode 100644 electron/persistedAtomRecords.test.ts create mode 100644 electron/persistedAtomRecords.ts create mode 100644 electron/persistedAtomStore.integration.test.ts create mode 100644 electron/persistedAtomStore.test-support.ts create mode 100644 electron/persistedAtomStore.test.ts create mode 100644 electron/persistedAtomStore.ts create mode 100644 electron/rendererEventPolicy.test.ts create mode 100644 electron/rendererEventPolicy.ts create mode 100644 internal/exec-plans/codex-gui-alignment-history-20261015.md create mode 100644 internal/exec-plans/release-v1.158.0-plan.md create mode 100644 internal/research/codex-desktop-rea/composer-code-highlight-audit.md create mode 100644 internal/research/codex-desktop-rea/composer-code-nodeview-audit.md create mode 100644 internal/research/codex-desktop-rea/composer-marks-css-audit.md create mode 100644 internal/research/codex-desktop-rea/new-conversation-navigation-audit.md create mode 100644 internal/research/codex-desktop-rea/settings-navigation-audit.md create mode 100644 internal/research/codex-desktop-rea/structure-alignment-ledger.md create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-composer-code-dom.test.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.test.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-composer-code-pm.test.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-teardown.mjs create mode 100644 scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs create mode 100644 scripts/agent-runtime/fixtures/composer-reference.md create mode 100644 scripts/electron/composer-draft-fixture-smoke.mjs create mode 100644 scripts/electron/lib/composer-draft-fixture-driver.mjs create mode 100644 scripts/electron/lib/composer-draft-fixture-evidence.mjs create mode 100644 scripts/electron/lib/composer-draft-fixture-evidence.test.mjs create mode 100644 scripts/electron/lib/composer-first-send-evidence.mjs create mode 100644 scripts/electron/lib/composer-first-send-evidence.test.mjs create mode 100644 scripts/electron/lib/composer-first-send-resource-evidence.test.mjs create mode 100644 scripts/electron/lib/composer-home-draft-fixture-evidence.mjs create mode 100644 scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs create mode 100644 scripts/electron/lib/composer-home-draft-fixture-scenario.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture-driver.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture-driver.test.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture-standalone.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture-standalone.test.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture.mjs create mode 100644 scripts/electron/lib/persisted-atom-fixture.test.mjs create mode 100644 scripts/electron/lib/persisted-atom-index-consumer.test.mjs create mode 100644 scripts/lib/persisted-atom-contract.mjs create mode 100644 scripts/lib/persisted-atom-contract.test.mjs create mode 100644 src/components/AppPageContent.newConversation.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/hooks/useInputbarController.stopRestore.integration.test.tsx create mode 100644 src/components/agent/chat/components/Inputbar/inputbarSendPayload.test.ts create mode 100644 src/components/agent/chat/components/composerSubmissionSettlement.test.ts create mode 100644 src/components/agent/chat/components/composerSubmissionSettlement.ts create mode 100644 src/components/agent/chat/hooks/agentChatSendMessage.acceptance.test.ts create mode 100644 src/components/agent/chat/hooks/agentSendResult.test.ts create mode 100644 src/components/agent/chat/hooks/agentSendResult.testFixtures.ts create mode 100644 src/components/agent/chat/hooks/agentSendResult.ts create mode 100644 src/components/agent/chat/hooks/agentStreamAcceptance.testFixtures.ts create mode 100644 src/components/agent/chat/hooks/agentStreamComposerRestoreToken.test.ts create mode 100644 src/components/agent/chat/hooks/agentStreamRestoreToken.testFixtures.ts create mode 100644 src/components/agent/chat/hooks/agentStreamSubmitExecution.acceptance.test.ts create mode 100644 src/components/agent/chat/hooks/useAgentStream.restoreToken.test.tsx delete mode 100644 src/components/agent/chat/taskCenterDraftTaskEvents.ts create mode 100644 src/components/agent/chat/workspace/ComposerDraftStatus.test.tsx create mode 100644 src/components/agent/chat/workspace/ComposerDraftStatus.tsx create mode 100644 src/components/agent/chat/workspace/composerDraftNavigation.integration.test.tsx create mode 100644 src/components/agent/chat/workspace/composerDraftNavigation.ts create mode 100644 src/components/agent/chat/workspace/newConversationPublicNavigation.integration.test.tsx create mode 100644 src/components/agent/chat/workspace/stopSendChain.testFixtures.tsx create mode 100644 src/components/agent/chat/workspace/taskCenterComposerDraftSend.integration.test.tsx delete mode 100644 src/components/agent/chat/workspace/taskCenterDraftTabs.ts delete mode 100644 src/components/agent/chat/workspace/taskCenterDraftTabs.unit.test.ts create mode 100644 src/components/agent/chat/workspace/taskCenterSubmissionSettlement.test.ts create mode 100644 src/components/agent/chat/workspace/taskCenterSubmissionSettlement.ts create mode 100644 src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.navigation.integration.test.tsx delete mode 100644 src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.test.tsx delete mode 100644 src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.ts delete mode 100644 src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.unit.test.ts create mode 100644 src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.integration.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts create mode 100644 src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.visibleReady.integration.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceSendMetadata.stopRestore.integration.test.tsx create mode 100644 src/components/agent/chat/workspace/useWorkspaceTopicSwitch.navigationSource.test.tsx create mode 100644 src/components/agent/chat/workspace/workspaceQueueSubmission.test.ts create mode 100644 src/components/agent/chat/workspace/workspaceQueueSubmission.ts create mode 100644 src/components/agent/chat/workspace/workspaceSendSubmission.test.ts create mode 100644 src/components/agent/chat/workspace/workspaceSendSubmission.ts create mode 100644 src/components/input-kit/CodeBlockNodeView.test.ts create mode 100644 src/components/input-kit/CodeBlockNodeView.ts create mode 100644 src/components/input-kit/CodeBlockNodeViewRegistry.test.ts create mode 100644 src/components/input-kit/CodeBlockNodeViewRegistry.ts create mode 100644 src/components/input-kit/CodeBlockToolbar.css create mode 100644 src/components/input-kit/CodeBlockToolbar.locales.component.test.tsx create mode 100644 src/components/input-kit/CodeBlockToolbar.test.tsx create mode 100644 src/components/input-kit/CodeBlockToolbar.tsx create mode 100644 src/components/input-kit/ComposerDraftCoordinator.binding.test.ts create mode 100644 src/components/input-kit/ComposerDraftCoordinator.fresh.integration.test.ts create mode 100644 src/components/input-kit/ComposerDraftCoordinator.test.ts create mode 100644 src/components/input-kit/ComposerDraftCoordinator.testFixtures.ts create mode 100644 src/components/input-kit/ComposerDraftCoordinator.ts create mode 100644 src/components/input-kit/ComposerEditor.codeBlock.component.test.tsx create mode 100644 src/components/input-kit/ComposerIntegration.test.tsx create mode 100644 src/components/input-kit/composerCodeBlockAutoDetect.test.ts create mode 100644 src/components/input-kit/composerCodeBlockAutoDetect.ts create mode 100644 src/components/input-kit/composerCodeBlockHighlight.test.ts create mode 100644 src/components/input-kit/composerCodeBlockHighlight.ts create mode 100644 src/components/input-kit/composerCodeBlockLanguage.test.ts create mode 100644 src/components/input-kit/composerCodeBlockLanguage.ts create mode 100644 src/components/input-kit/composerCodeBlockReference.testFixtures.json create mode 100644 src/components/input-kit/composerCodeBlockTestUtils.ts create mode 100644 src/components/input-kit/composerDraftCodec.test.ts create mode 100644 src/components/input-kit/composerDraftCodec.ts create mode 100644 src/components/input-kit/composerState.ts rename src/components/settings-v2/agent/{voice => shared}/useAutoDismissMessage.ts (77%) create mode 100644 src/components/settings-v2/sections.tsx create mode 100644 src/lib/api/agentRuntime/runtimeAcceptance.test.ts create mode 100644 src/lib/api/agentRuntime/runtimeAcceptance.ts create mode 100644 src/lib/api/agentRuntime/threadClient.acceptance.test.ts create mode 100644 src/lib/api/persistedAtomIndexProtocol.test.ts create mode 100644 src/lib/api/persistedAtomProtocol.test.ts create mode 100644 src/lib/api/persistedAtomProtocol.ts create mode 100644 src/lib/api/persistedAtomTypes.ts create mode 100644 src/lib/api/persistedAtoms.test.ts create mode 100644 src/lib/api/persistedAtoms.ts create mode 100644 src/lib/dev-bridge/http-client.persistedAtom.contract.test.ts create mode 100644 src/lib/dev-bridge/invokeDiagnostics.ts create mode 100644 src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts diff --git a/.gitignore b/.gitignore index fe5edbcd9..c26240a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ internal/exec-plans/* !internal/exec-plans/README.md !internal/exec-plans/rea-codex-desktop-analysis-plan.md !internal/exec-plans/codex-gui-alignment-current-plan.md +!internal/exec-plans/codex-gui-alignment-history-20261015.md !internal/exec-plans/codex-desktop-selective-goose-reference-plan.md !internal/exec-plans/codex-desktop-selective-goose-reference-evidence.json !internal/exec-plans/tui-codex-snapshot-inventory.json @@ -272,11 +273,17 @@ internal/research/** !internal/research/codex-desktop-rea/ !internal/research/codex-desktop-rea/report.md !internal/research/codex-desktop-rea/evidence-index.json +!internal/research/codex-desktop-rea/structure-alignment-ledger.md !internal/research/codex-desktop-rea/navigation-composer-audit.md !internal/research/codex-desktop-rea/queue-steer-audit.md !internal/research/codex-desktop-rea/main-composer-audit.md !internal/research/codex-desktop-rea/composer-draft-persistence-audit.md +!internal/research/codex-desktop-rea/new-conversation-navigation-audit.md +!internal/research/codex-desktop-rea/settings-navigation-audit.md !internal/research/codex-desktop-rea/marks-voice-audit.md +!internal/research/codex-desktop-rea/composer-marks-css-audit.md +!internal/research/codex-desktop-rea/composer-code-nodeview-audit.md +!internal/research/codex-desktop-rea/composer-code-highlight-audit.md !internal/research/codex-desktop-rea/professional-files-audit.md !internal/research/codex-desktop-rea/user-message-collapse-audit.md !internal/research/agent/ diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md index bf4397910..cbb8c8885 100644 --- a/RELEASE_NOTES.en.md +++ b/RELEASE_NOTES.en.md @@ -1,41 +1,36 @@ -## Lime v1.157.0 +## Lime v1.158.0 Simplified Chinese release notes are the primary version; this English companion summarizes the same release. ### Features -- Use one structured composer for skill, character, and capability mentions, preserving image, path, and rich input data through submission and restoration. -- Support links and code blocks with Markdown submission and preserved reference ranges; add dictation cancellation and isolate late results after conversation switches or IME composition. -- Preview, edit, delete, reorder, and start queued messages while idle; keep pending input separate from the public conversation history. -- Show pending approvals in the conversation with actions and status tied to each request. -- Reuse conversation changes and diff views in the Files panel, with file selection and navigation to the corresponding change. +- Persist composer drafts across restarts, keeping home, workspace, and conversation scopes isolated while restoring rich text and resource references. +- Add code block language selection, search, automatic detection, and syntax highlighting while preserving structured editing and Markdown submission. ### Fixes -- Prevent late notifications and conversation switches from applying queue state, title edits, model settings, or working directories to the wrong conversation. -- Correct browser tab and right panel state during restoration, closure, and switching. -- Preserve scroll position when expanding, collapsing, or copying long user messages, and stop copy feedback updates after unmount. -- Correct header toolbar overflow and message/composer placement in compact windows. +- Prevent incorrect draft bindings, stale content restoration, and accidental clearing of new edits during new conversation navigation, first sends, and conversation switches. +- Settle input using runtime acceptance: preserve rejected drafts, restore interrupted input, and prevent late receipts from overwriting newer drafts. +- Navigate skill trial submissions to the actual conversation and restore the editor to an editable state after sending. ### Improvements and Refactoring -- Mount conversation Turns by viewport, retaining independent history pagination, dynamic height measurement, and reading position. -- Consolidate project, branch, and run location controls, structured submission, and input restoration around existing owners. -- Keep queue, approval, file, and settings behavior on the shared App Server and remove replaced components and duplicate projections. -- Consolidate desktop media and clipboard permissions while preserving main window and browser tab boundaries. +- Consolidate draft coordination, desktop persistence, and sidebar draft projections; remove replaced draft events, duplicate state, and navigation implementations. +- Use one settings section definition for menus, loading, and categorization; return to the conversation that opened settings while keeping Home navigation separate, and share notification lifecycle handling across media settings. +- Keep conversation history on the shared App Server and draft persistence on the existing Electron Host. ### Tests and Quality -- Extend regressions for queue history, search and cold recovery, approval responses, structured input, scrolling, copy lifecycle, and browser tab restoration. -- Update real Electron current fixtures, interaction selectors, and build asset checks, separating source, artifact, and platform evidence. -- Update UI text and regression coverage across five languages. +- Extend coverage for draft saving, cold recovery, first sends, stop restoration, navigation, and input acceptance. +- Strengthen real Electron fixture teardown, build freshness, and resource byte verification. +- Update code block and settings text and regressions across five languages. ### Documentation -- Update GUI design, architecture, quality workflows, Codex Desktop research evidence, and alignment execution records. +- Update GUI architecture, draft and code block research, settings navigation audits, and structural alignment records, distinguishing delivered behavior from remaining work. ### Other -- Align the desktop app, CLI npm package, and Rust workspace at `1.157.0`. +- Align the desktop app, CLI npm package, and Rust workspace at `1.158.0`. -**Full Changelog**: `v1.156.0` -> `v1.157.0` +**Full Changelog**: `v1.157.0` -> `v1.158.0` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 15248127c..2b837f700 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,39 +1,34 @@ -## Lime v1.157.0 +## Lime v1.158.0 ### 新功能 -- 输入框采用统一结构化编辑器,支持技能、角色和能力引用,并保留图片、路径与富文本输入的提交和恢复信息。 -- 支持链接和代码块输入,按 Markdown 提交并保留引用位置;听写增加取消操作,隔离会话切换和输入法组字后的迟到结果。 -- 排队消息支持展开预览、编辑、删除、重排与空闲时启动;待执行输入与公开对话历史分开呈现。 -- 待处理审批集中展示在消息区,提供对应请求的操作与状态反馈。 -- Files 面板复用会话变更与 diff 视图,支持选择文件并定位到对应变更。 +- 输入框草稿持久化保存,支持首页及不同工作区、会话间隔离,并在重启后恢复富文本和资源引用。 +- 代码块增加语言选择、搜索、自动识别与语法高亮,保留结构化编辑和 Markdown 提交。 ### 修复 -- 修复会话切换和迟到通知导致队列、标题编辑、模型与工作目录设置落入错误会话的问题。 -- 修复浏览器标签与右侧面板恢复、关闭及切换时的状态串扰。 -- 修复长用户消息展开、折叠与复制时的滚动跳动,以及复制反馈在卸载后继续更新的问题。 -- 修复紧凑窗口中页头工具栏越界和消息、输入框布局错位。 +- 修复新建会话、首次发送和会话切换时草稿误绑定、旧内容回流或新编辑被清空的问题。 +- 按运行时实际接受结果处理输入:提交被拒绝时保留草稿,停止后恢复原输入,迟到回执不覆盖新草稿。 +- 修复技能试用等入口提交后未切换到实际会话,以及发送后编辑器恢复可编辑状态的问题。 ### 优化与重构 -- 长对话使用按视口挂载和卸载的 Turn 列表,保留独立历史分页、动态高度测量和阅读位置。 -- 统一输入框项目、分支与运行位置工具栏,收敛结构化提交和输入恢复链路。 -- GUI 队列、审批、文件与设置继续复用 App Server 和现有业务 owner,移除被替代的旧组件与重复投影。 -- 收敛桌面媒体与剪贴板权限处理,保持主窗口和浏览器标签的权限边界。 +- 统一草稿协调、桌面持久化和侧栏草稿投影,移除旧草稿事件、重复状态与被替代的导航实现。 +- 设置页面使用统一章节定义,收敛菜单、加载和分类逻辑;页头返回恢复进入设置前的会话,首页入口保持独立,并复用媒体设置提示生命周期。 +- 保持 GUI 与共享 App Server 的业务边界,草稿持久化复用 Electron Host,不建立第二套会话历史。 ### 测试与质量 -- 扩充队列公共历史、搜索与冷恢复、审批响应、结构化输入、列表滚动、复制生命周期及浏览器标签恢复回归。 -- 更新真实 Electron current fixture、交互定位和构建资源检查,区分源码、产物及平台验证证据。 -- 同步五语言界面文案与回归覆盖。 +- 扩充草稿保存、冷恢复、首次发送、停止恢复、导航和输入接受结果的回归覆盖。 +- 增强真实 Electron fixture 的退出检查、构建新鲜度和资源字节验证。 +- 同步代码块与设置的五语言文案及回归覆盖。 ### 文档 -- 更新 GUI 设计、架构与质量事实源,以及 Codex Desktop 分析证据和对齐执行账本。 +- 更新 GUI 架构、草稿与代码块分析、设置导航审计和结构对齐账本,区分已交付能力与后续范围。 ### 其他 -- 桌面应用、CLI npm 包与 Rust workspace 版本统一为 `1.157.0`。 +- 桌面应用、CLI npm 包与 Rust workspace 版本统一为 `1.158.0`。 -**完整变更**: `v1.156.0` -> `v1.157.0` +**完整变更**: `v1.157.0` -> `v1.158.0` diff --git a/electron/ipcChannels.ts b/electron/ipcChannels.ts index 2df95f16f..07f058a41 100644 --- a/electron/ipcChannels.ts +++ b/electron/ipcChannels.ts @@ -13,6 +13,8 @@ export const ELECTRON_HOST_COMMANDS = [ "app_server_handle_json_lines", "app_server_drain_events", "app_server_host_diagnostics", + "persisted_atom_read", + "persisted_atom_update", "cloud_session_credential_set", "cloud_session_credential_status", "cloud_session_credential_delete", diff --git a/electron/main.ts b/electron/main.ts index 5a840d6c4..860630734 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,6 @@ import { IPC_DEEP_LINK_GET_URLS_CHANNEL, IPC_DIALOG_OPEN_CHANNEL, IPC_DIALOG_SAVE_CHANNEL, - IPC_EMIT_CHANNEL, IPC_GLOBAL_SHORTCUT_COMMAND_CHANNEL, IPC_INVOKE_CHANNEL, IPC_SHELL_OPEN_CHANNEL, @@ -27,6 +26,8 @@ import { ElectronBrowserTabHost, isBrowserTabCommand } from "./browserTabHost"; import { resolveBrowserTabOwnerWindow } from "./browserTabSnapshot"; import { ElectronDevHttpBridge } from "./devHttpBridge"; import { ElectronHostCommands } from "./hostCommands"; +import { PersistedAtomHost } from "./persistedAtomHost"; +import { registerRendererEventHandler } from "./rendererEventPolicy"; import { ElectronSecureCredentialStore } from "./secureCredentialStore"; import { buildMainWindowChromeOptions, @@ -152,6 +153,10 @@ const hostCommands = new ElectronHostCommands( safeStorage, ), ); +const persistedAtomHost = new PersistedAtomHost( + desktopStorageRoots.appDataRoot, + broadcast, +); const updateHost = new ElectronUpdateHost(broadcast, { open: openUpdateNotificationWindow, close: closeUpdateNotificationWindow, @@ -1064,9 +1069,7 @@ function registerIpcHandlers(): void { }, ); - ipcMain.on(IPC_EMIT_CHANNEL, (_event, event: string, payload?: unknown) => { - broadcast(event, payload); - }); + registerRendererEventHandler(ipcMain, broadcast); ipcMain.handle(IPC_DIALOG_OPEN_CHANNEL, async (_event, options) => { const normalizedOptions = normalizeOpenDialogOptions(options); @@ -1142,6 +1145,12 @@ async function handleHostInvoke( : {}, ); } + if (command === "persisted_atom_read") { + return persistedAtomHost.sendPersistedAtomState(args); + } + if (command === "persisted_atom_update") { + return persistedAtomHost.updatePersistedAtomState(args); + } if (command === "sync_tray_model_shortcuts") { syncTrayModelShortcuts(args); return null; @@ -1543,6 +1552,7 @@ app.on("before-quit", () => { devHttpBridge?.stop(); devHttpBridge = null; hostCommands.dispose(); + persistedAtomHost.dispose(); void appServerHost.stop(); }); diff --git a/electron/persistedAtomFile.test.ts b/electron/persistedAtomFile.test.ts new file mode 100644 index 000000000..251757ff4 --- /dev/null +++ b/electron/persistedAtomFile.test.ts @@ -0,0 +1,352 @@ +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PersistedAtomEnvelope } from "../src/lib/api/persistedAtomTypes"; +import { + PersistedAtomFile, + persistedAtomFileIO, + type PersistedAtomFileIO, +} from "./persistedAtomFile"; +import { scopeKey } from "./persistedAtomRecords"; + +const roots: string[] = []; +const limits = { + maxDraftBytes: 2_048, + maxFileBytes: 8_192, + maxEntries: 10, + maxBindings: 10, +}; +async function createRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "atom-file-")); + roots.push(root); + return root; +} +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); +function data(text = "original", revision = 1): PersistedAtomEnvelope { + const originScopeKey = scopeKey({ + workspaceId: null, + kind: "home", + id: "new-conversation", + }); + return { + format: "electron-persisted-atom-state", + version: 1, + storeRevision: revision, + atoms: { + "composer-drafts-v1": { + bindings: { [originScopeKey]: "d" }, + entries: { + d: { + draftId: "d", + scopeVersion: `v${revision}`, + originScopeKey, + updatedAt: revision, + state: "draft", + draft: { text }, + }, + }, + }, + }, + }; +} +function failure(code: string): Error { + return Object.assign( + new Error("secret user content and attachment data must never escape"), + { code }, + ); +} + +describe("persisted atom fixed-file atomic IO", () => { + it("uses fixed names, private file mode, valid backup and no retained temporary file", async () => { + const root = await createRoot(), + file = new PersistedAtomFile(root, limits); + await file.save(data()); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")), + ).toEqual(data()); + expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe( + await readFile(path.join(root, "host-state.json"), "utf8"), + ); + expect((await file.load()).envelope).toEqual(data()); + expect((await readdir(root)).sort()).toEqual([ + "host-state.json", + "host-state.json.bak", + ]); + if (process.platform !== "win32") + expect( + (await stat(path.join(root, "host-state.json"))).mode & 0o777, + ).toBe(0o600); + }); + + it.each(["corrupt", "missing"])( + "recovers backup when primary is %s", + async (mode) => { + const root = await createRoot(), + file = new PersistedAtomFile(root, limits); + await file.save(data()); + if (mode === "corrupt") + await writeFile(path.join(root, "host-state.json"), "{broken"); + else await rm(path.join(root, "host-state.json")); + expect(await file.load()).toEqual({ envelope: data(), recovered: true }); + }, + ); + + it("treats only two missing files as an empty initial profile", async () => { + const root = await createRoot(), + file = new PersistedAtomFile(root, limits); + expect(await file.load()).toEqual({ envelope: null, recovered: false }); + await writeFile(path.join(root, "host-state.json"), "bad"); + await expect(file.load()).rejects.toMatchObject({ + diagnostic: { phase: "load", code: "INVALID_STATE" }, + }); + expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe( + "bad", + ); + expect(await readdir(root)).toEqual(["host-state.json"]); + }); + + it("both corrupt files remain unchanged after a rejected load", async () => { + const root = await createRoot(); + await writeFile(path.join(root, "host-state.json"), "bad-main"); + await writeFile(path.join(root, "host-state.json.bak"), "bad-backup"); + await expect( + new PersistedAtomFile(root, limits).load(), + ).rejects.toMatchObject({ + diagnostic: { phase: "load", code: "INVALID_STATE" }, + }); + expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe( + "bad-main", + ); + expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe( + "bad-backup", + ); + }); + + it.each(["write", "rename"])( + "preserves old primary/backup on %s failure and cleans only the new temporary", + async (stage) => { + const root = await createRoot(); + await new PersistedAtomFile(root, limits).save(data()); + const removeTemporary = vi.fn(persistedAtomFileIO.removeTemporary); + const io: PersistedAtomFileIO = { + ...persistedAtomFileIO, + removeTemporary, + ...(stage === "write" + ? { + writeFile: async () => { + throw failure("ENOSPC"); + }, + } + : { + rename: async () => { + throw failure("EIO"); + }, + }), + }; + await expect( + new PersistedAtomFile(root, limits, { io }).save(data("new", 2)), + ).rejects.toMatchObject({ + diagnostic: { + phase: "primary", + code: stage === "write" ? "ENOSPC" : "EIO", + }, + }); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")), + ).toEqual(data()); + expect( + JSON.parse( + await readFile(path.join(root, "host-state.json.bak"), "utf8"), + ), + ).toEqual(data()); + expect(removeTemporary).toHaveBeenCalledTimes(1); + expect( + removeTemporary.mock.calls.every(([file]) => file.endsWith(".tmp")), + ).toBe(true); + expect((await readdir(root)).sort()).toEqual([ + "host-state.json", + "host-state.json.bak", + ]); + }, + ); + + it.each(["EPERM", "EACCES", "EBUSY"])( + "retries Windows %s without deleting the destination", + async (code) => { + const root = await createRoot(); + await new PersistedAtomFile(root, limits).save(data()); + let attempts = 0; + const rename = vi.fn(async (from: string, to: string) => { + attempts += 1; + if (attempts <= 3) throw failure(code); + await persistedAtomFileIO.rename(from, to); + }); + const removeTemporary = vi.fn(persistedAtomFileIO.removeTemporary), + wait = vi.fn(async () => undefined); + await new PersistedAtomFile(root, limits, { + platform: "win32", + wait, + io: { ...persistedAtomFileIO, rename, removeTemporary }, + }).save(data("new", 2)); + expect(wait.mock.calls).toEqual([[100], [200], [400]]); + expect(rename).toHaveBeenCalledTimes(5); + expect(removeTemporary).not.toHaveBeenCalled(); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")), + ).toEqual(data("new", 2)); + }, + ); + + it("limits Windows retries and leaves the old durable snapshot intact", async () => { + const root = await createRoot(); + await new PersistedAtomFile(root, limits).save(data()); + const rename = vi.fn(async () => { + throw failure("EPERM"); + }), + wait = vi.fn(async () => undefined); + await expect( + new PersistedAtomFile(root, limits, { + platform: "win32", + wait, + io: { ...persistedAtomFileIO, rename }, + }).save(data("new", 2)), + ).rejects.toMatchObject({ + diagnostic: { phase: "primary", code: "EPERM" }, + }); + expect(rename).toHaveBeenCalledTimes(4); + expect(wait).toHaveBeenCalledTimes(3); + expect((await new PersistedAtomFile(root, limits).load()).envelope).toEqual( + data(), + ); + }); + + it.each([ + { platform: "darwin" as const, code: "EPERM" }, + { platform: "win32" as const, code: "EEXIST" }, + ])( + "does not retry an unsupported replacement error", + async ({ platform, code }) => { + const root = await createRoot(), + rename = vi.fn(async () => { + throw failure(code); + }), + wait = vi.fn(async () => undefined); + await expect( + new PersistedAtomFile(root, limits, { + platform, + wait, + io: { ...persistedAtomFileIO, rename }, + }).save(data()), + ).rejects.toMatchObject({ diagnostic: { code } }); + expect(rename).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + }, + ); + + it("backup replacement failure preserves old backup and reports partial persistence", async () => { + const root = await createRoot(); + await new PersistedAtomFile(root, limits).save(data()); + const io = { + ...persistedAtomFileIO, + rename: async (from: string, to: string) => { + if (to.endsWith(".bak")) throw failure("EIO"); + await persistedAtomFileIO.rename(from, to); + }, + }; + await expect( + new PersistedAtomFile(root, limits, { io }).save(data("new", 2)), + ).rejects.toMatchObject({ diagnostic: { phase: "backup", code: "EIO" } }); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")), + ).toEqual(data("new", 2)); + expect( + JSON.parse( + await readFile(path.join(root, "host-state.json.bak"), "utf8"), + ), + ).toEqual(data()); + }); + + it("rejects oversize UTF8 file without truncation or any write", async () => { + const root = await createRoot(); + const bytes = Buffer.byteLength(JSON.stringify(data("中文")), "utf8"); + const exact = new PersistedAtomFile(root, { + ...limits, + maxFileBytes: bytes, + }); + await exact.save(data("中文")); + await expect( + new PersistedAtomFile(root, { ...limits, maxFileBytes: bytes - 1 }).save( + data("中文"), + ), + ).rejects.toMatchObject({ + diagnostic: { phase: "serialize", code: "CAPACITY_EXCEEDED" }, + }); + await expect( + new PersistedAtomFile(root, { + ...limits, + maxFileBytes: bytes - 1, + }).load(), + ).rejects.toMatchObject({ + diagnostic: { phase: "load", code: "CAPACITY_EXCEEDED" }, + }); + expect((await exact.load()).envelope).toEqual(data("中文")); + }); + + it("filters unknown IO codes and never exposes exception messages", async () => { + const root = await createRoot(); + const file = new PersistedAtomFile(root, limits, { + io: { + ...persistedAtomFileIO, + readFile: async () => { + throw failure("private payload"); + }, + }, + }); + const error = await file.load().catch((caught: unknown) => caught); + expect(error).toMatchObject({ + message: "persisted atom file operation failed", + diagnostic: { phase: "load", code: "IO_ERROR" }, + }); + expect(JSON.stringify(error)).not.toContain("private payload"); + expect(JSON.stringify(error)).not.toContain("secret user content"); + }); + + it("bounds native file reads and accepts exactly the declared byte budget", async () => { + const root = await createRoot(), + file = path.join(root, "large.json"); + const bytes = Buffer.alloc(1_024, 0x61); + await writeFile(file, bytes); + await expect(persistedAtomFileIO.readFile(file, 1_024)).resolves.toEqual( + bytes, + ); + await expect( + persistedAtomFileIO.readFile(file, 1_023), + ).rejects.toMatchObject({ + diagnostic: { phase: "load", code: "CAPACITY_EXCEEDED" }, + }); + expect(await readFile(file)).toEqual(bytes); + }); + + it("rejects relative/empty roots rather than choosing an arbitrary transport path", () => { + expect(() => new PersistedAtomFile("relative", limits)).toThrow( + "resolved AppDataRoot", + ); + expect(() => new PersistedAtomFile(" ", limits)).toThrow( + "resolved AppDataRoot", + ); + }); +}); diff --git a/electron/persistedAtomFile.ts b/electron/persistedAtomFile.ts new file mode 100644 index 000000000..773f254d3 --- /dev/null +++ b/electron/persistedAtomFile.ts @@ -0,0 +1,230 @@ +import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { TextDecoder } from "node:util"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import type { + PersistedAtomDiagnostic, + PersistedAtomEnvelope, +} from "../src/lib/api/persistedAtomTypes"; +import { parseEnvelope } from "./persistedAtomRecords"; +import type { PersistedAtomLimits } from "../src/lib/api/persistedAtomProtocol"; + +export interface PersistedAtomFileIO { + readFile(file: string, maxBytes: number): Promise; + mkdir(directory: string): Promise; + writeFile(file: string, data: string): Promise; + rename(from: string, to: string): Promise; + removeTemporary(file: string): Promise; +} + +export const persistedAtomFileIO: PersistedAtomFileIO = { + readFile: async (file, maxBytes) => { + const handle = await fs.open(file, "r"); + try { + if ((await handle.stat()).size > maxBytes) + throw new PersistedAtomFileError("load", "CAPACITY_EXCEEDED"); + const chunks: Buffer[] = []; + let total = 0; + for (;;) { + const chunk = Buffer.allocUnsafe( + Math.min(64 * 1024, maxBytes - total + 1), + ); + const { bytesRead } = await handle.read(chunk, 0, chunk.length, null); + if (!bytesRead) break; + total += bytesRead; + if (total > maxBytes) + throw new PersistedAtomFileError("load", "CAPACITY_EXCEEDED"); + chunks.push(chunk.subarray(0, bytesRead)); + } + return Buffer.concat(chunks, total); + } finally { + await handle.close(); + } + }, + mkdir: async (directory) => { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + }, + writeFile: (file, data) => + fs.writeFile(file, data, { encoding: "utf8", mode: 0o600, flag: "wx" }), + rename: (from, to) => fs.rename(from, to), + removeTemporary: (file) => fs.rm(file, { force: true }), +}; + +const SAFE_IO_CODES = new Set([ + "ENOENT", + "EACCES", + "EPERM", + "EBUSY", + "ENOSPC", + "EIO", + "EEXIST", + "ETIMEDOUT", +]); + +export class PersistedAtomFileError extends Error { + readonly diagnostic: PersistedAtomDiagnostic; + constructor(phase: PersistedAtomDiagnostic["phase"], code: string) { + super("persisted atom file operation failed"); + this.diagnostic = { phase, code }; + } +} + +function ioCode(error: unknown): string { + const code = + error && typeof error === "object" && "code" in error + ? error.code + : undefined; + return typeof code === "string" && SAFE_IO_CODES.has(code) + ? code + : "IO_ERROR"; +} + +export interface PersistedAtomFileOptions { + readonly io?: PersistedAtomFileIO; + readonly platform?: typeof process.platform; + readonly wait?: (milliseconds: number) => Promise; +} + +type FileRead = + | { state: "valid"; envelope: PersistedAtomEnvelope } + | { state: "missing" } + | { state: "invalid"; code: string }; + +export class PersistedAtomFile { + readonly #root: string; + readonly #io: PersistedAtomFileIO; + readonly #platform: typeof process.platform; + readonly #wait: (milliseconds: number) => Promise; + readonly #limits: PersistedAtomLimits; + + constructor( + appDataRoot: string, + limits: PersistedAtomLimits, + options: PersistedAtomFileOptions = {}, + ) { + if (!appDataRoot.trim() || !path.isAbsolute(appDataRoot)) + throw new Error("persisted atom requires the resolved AppDataRoot"); + this.#root = path.resolve(appDataRoot); + this.#io = options.io ?? persistedAtomFileIO; + this.#platform = options.platform ?? process.platform; + this.#wait = + options.wait ?? + ((milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds))); + this.#limits = limits; + } + + async load(): Promise<{ + envelope: PersistedAtomEnvelope | null; + recovered: boolean; + }> { + const primary = await this.#read(path.join(this.#root, "host-state.json")); + if (primary.state === "valid") + return { envelope: primary.envelope, recovered: false }; + const backup = await this.#read( + path.join(this.#root, "host-state.json.bak"), + ); + if (backup.state === "valid") + return { envelope: backup.envelope, recovered: true }; + if (primary.state === "missing" && backup.state === "missing") + return { envelope: null, recovered: false }; + throw new PersistedAtomFileError( + "load", + primary.state === "invalid" + ? primary.code + : backup.state === "invalid" + ? backup.code + : "INVALID_STATE", + ); + } + + async save(envelope: PersistedAtomEnvelope): Promise { + let data: string; + try { + data = JSON.stringify(envelope); + if (Buffer.byteLength(data, "utf8") > this.#limits.maxFileBytes) + throw new PersistedAtomFileError("serialize", "CAPACITY_EXCEEDED"); + } catch (error) { + if (error instanceof PersistedAtomFileError) throw error; + throw new PersistedAtomFileError("serialize", "INVALID_JSON"); + } + try { + await this.#io.mkdir(this.#root); + } catch (error) { + throw new PersistedAtomFileError("primary", ioCode(error)); + } + await this.#replace( + path.join(this.#root, "host-state.json"), + data, + "primary", + ); + await this.#replace( + path.join(this.#root, "host-state.json.bak"), + data, + "backup", + ); + } + + async #read(file: string): Promise { + let buffer: Buffer; + try { + buffer = await this.#io.readFile(file, this.#limits.maxFileBytes); + } catch (error) { + const code = + error instanceof PersistedAtomFileError && + error.diagnostic.code === "CAPACITY_EXCEEDED" + ? "CAPACITY_EXCEEDED" + : ioCode(error); + return code === "ENOENT" + ? { state: "missing" } + : { state: "invalid", code }; + } + if (buffer.byteLength > this.#limits.maxFileBytes) + return { state: "invalid", code: "CAPACITY_EXCEEDED" }; + try { + return { + state: "valid", + envelope: parseEnvelope( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)), + this.#limits, + ), + }; + } catch { + return { state: "invalid", code: "INVALID_STATE" }; + } + } + + async #replace( + file: string, + data: string, + phase: "primary" | "backup", + ): Promise { + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + let renamed = false; + try { + await this.#io.writeFile(temporary, data); + for (let attempt = 0; ; attempt += 1) { + try { + await this.#io.rename(temporary, file); + break; + } catch (error) { + if ( + this.#platform !== "win32" || + !["EPERM", "EACCES", "EBUSY"].includes(ioCode(error)) || + attempt >= 3 + ) + throw error; + await this.#wait(100 * 2 ** attempt); + } + } + renamed = true; + } catch (error) { + throw new PersistedAtomFileError(phase, ioCode(error)); + } finally { + if (!renamed) + await this.#io.removeTemporary(temporary).catch(() => undefined); + } + } +} diff --git a/electron/persistedAtomHost.test.ts b/electron/persistedAtomHost.test.ts new file mode 100644 index 000000000..b0b3bad19 --- /dev/null +++ b/electron/persistedAtomHost.test.ts @@ -0,0 +1,257 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PersistedAtomUpdateEvent } from "../src/lib/api/persistedAtomTypes"; +import { PersistedAtomHost } from "./persistedAtomHost"; + +const roots: string[] = [], + hosts: { host: PersistedAtomHost; root: string }[] = []; +const scope = { + workspaceId: null, + kind: "home", + id: "new-conversation", +} as const; +const request = { + key: "composer-drafts-v1", + mutation: { + op: "put", + scope, + draftId: "d1", + expectedVersion: null, + nextVersion: "v1", + draft: { + text: "fixture private content", + attachments: [{ data: "c2VjcmV0", mediaType: "image/png" }], + }, + }, +} as const; +async function createHost() { + const root = await mkdtemp(path.join(tmpdir(), "atom-host-")); + roots.push(root); + const events: { name: string; payload: unknown }[] = []; + const host = new PersistedAtomHost(root, (name, payload) => + events.push({ name, payload }), + ); + hosts.push({ host, root }); + await host.ready; + return { root, host, events }; +} +afterEach(async () => { + for (const { host, root } of hosts.splice(0)) { + const revision = host.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: scope }, + }).storeRevision; + host.dispose(); + if (revision > 0) + await vi.waitFor(async () => { + const backup = JSON.parse( + await readFile(path.join(root, "host-state.json.bak"), "utf8"), + ); + expect(backup.storeRevision).toBeGreaterThanOrEqual(revision); + }); + } + // Disposal is intentionally not an acknowledged Quit protocol; ensure test IO settled before cleanup. + await Promise.all( + roots.splice(0).map(async (root) => { + await vi.waitFor(async () => { + await rm(root, { recursive: true, force: true }); + }); + }), + ); +}); + +describe("persisted atom isolated Host command boundary", () => { + it("accepts only the known atom and reports complete scope projection", async () => { + const { host } = await createHost(); + expect(host.updatePersistedAtomState(request)).toMatchObject({ + outcome: "applied", + storeRevision: 1, + }); + expect( + host.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: scope }, + }), + ).toMatchObject({ + outcome: "ready", + data: { + queryKind: "scope", + view: { + scope, + entry: { + draftId: "d1", + scopeVersion: "v1", + draft: request.mutation.draft, + }, + }, + }, + }); + }); + + it.each([ + { key: "other-atom", mutation: request.mutation }, + { ...request, path: "/arbitrary/host-state.json" }, + { ...request, budget: Number.MAX_SAFE_INTEGER }, + { key: "composer-drafts-v1", mutation: { op: "replace", value: {} } }, + { mutation: request.mutation }, + ])( + "refuses unknown keys, paths, caller budgets and full replacements", + async (input) => { + const { host, events } = await createHost(); + const count = events.length; + expect(host.updatePersistedAtomState(input)).toMatchObject({ + outcome: "invalid", + changed: false, + storeRevision: 0, + }); + expect(events).toHaveLength(count); + expect( + host.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: scope }, + }), + ).toMatchObject({ + data: { queryKind: "scope", view: { entry: null } }, + }); + }, + ); + + it("validates read fields and scope with explicit errors", async () => { + const { host } = await createHost(); + expect( + host.sendPersistedAtomState({ + key: "other", + query: { kind: "scope", scope: scope }, + }), + ).toMatchObject({ + outcome: "invalid", + reason: "invalid_request", + }); + expect( + host.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: { ...scope, kind: "cloud" } }, + }), + ).toMatchObject({ outcome: "invalid", reason: "invalid_request" }); + expect( + host.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: scope }, + path: "x", + }), + ).toMatchObject({ outcome: "invalid", data: null }); + }); + + it("serves workspace index through the same read command without another owner or event", async () => { + const { host, events } = await createHost(); + host.updatePersistedAtomState(request); + const count = events.length; + expect( + host.sendPersistedAtomState({ + key: request.key, + query: { kind: "workspace-index", workspaceId: null }, + }), + ).toMatchObject({ + outcome: "ready", + storeRevision: 1, + data: { + queryKind: "workspace-index", + workspaceId: null, + items: [{ scope, identity: { draftId: "d1", scopeVersion: "v1" } }], + }, + }); + expect( + host.sendPersistedAtomState({ + key: request.key, + query: { kind: "workspace-index", workspaceId: "other" }, + }), + ).toMatchObject({ data: { items: [] } }); + expect(events).toHaveLength(count); + }); + + it("rejects removed scope-only requests and nested query getters without invoking them", async () => { + const { host } = await createHost(); + expect( + host.sendPersistedAtomState({ key: request.key, scope }), + ).toMatchObject({ + outcome: "invalid", + reason: "invalid_request", + data: null, + }); + let called = false; + const query = Object.defineProperty( + { kind: "workspace-index" }, + "workspaceId", + { + enumerable: true, + get() { + called = true; + return null; + }, + }, + ); + expect( + host.sendPersistedAtomState({ key: request.key, query }), + ).toMatchObject({ + outcome: "invalid", + reason: "invalid_request", + data: null, + }); + expect(called).toBe(false); + }); + + it("emits safe revision/persistence notifications without content or runtime fields", async () => { + const { host, events } = await createHost(); + host.updatePersistedAtomState(request); + const event = events.at(-1); + expect(event?.name).toBe("persisted-atom-update"); + expect(event?.payload).toMatchObject({ + key: "composer-drafts-v1", + event: "persisted-atom-update", + storeRevision: 1, + persistence: { state: "pending", savedRevision: 0 }, + } satisfies Partial); + expect(JSON.stringify(events)).not.toContain("fixture private content"); + expect(JSON.stringify(events)).not.toContain("c2VjcmV0"); + expect(event?.payload).not.toHaveProperty("threadId"); + expect(event?.payload).not.toHaveProperty("entry"); + }); + + it("normal lifecycle disposal initiates best-effort save without a promise or renderer ACK", async () => { + const { host, root, events } = await createHost(); + host.updatePersistedAtomState(request); + const count = events.length; + expect(host.dispose()).toBeUndefined(); + expect(host.updatePersistedAtomState(request)).toMatchObject({ + outcome: "unavailable", + reason: "closed", + }); + await vi.waitFor(async () => { + const primary = JSON.parse( + await readFile(path.join(root, "host-state.json"), "utf8"), + ); + const backup = JSON.parse( + await readFile(path.join(root, "host-state.json.bak"), "utf8"), + ); + expect(primary.storeRevision).toBe(1); + expect(backup).toEqual(primary); + }); + expect(events).toHaveLength(count); + const restored = new PersistedAtomHost(root); + hosts.push({ host: restored, root }); + await restored.ready; + expect( + restored.sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope: scope }, + }), + ).toMatchObject({ + data: { + queryKind: "scope", + view: { entry: { draft: request.mutation.draft } }, + }, + }); + }); +}); diff --git a/electron/persistedAtomHost.ts b/electron/persistedAtomHost.ts new file mode 100644 index 000000000..c06f10df1 --- /dev/null +++ b/electron/persistedAtomHost.ts @@ -0,0 +1,72 @@ +import { parsePersistedAtomReadRequest } from "../src/lib/api/persistedAtomProtocol"; +import type { + PersistedAtomMetadata, + PersistedAtomReadResult, + PersistedAtomUpdateEvent, + PersistedAtomUpdateResult, +} from "../src/lib/api/persistedAtomTypes"; +import { COMPOSER_ATOM_KEY, exactRecord } from "./persistedAtomRecords"; +import { PersistedAtomStore } from "./persistedAtomStore"; + +type HostEventEmitter = (event: string, payload?: unknown) => void; + +/** Host UI persistence only. It owns no runtime, session or send readiness. */ +export class PersistedAtomHost { + readonly #store: PersistedAtomStore; + readonly #unsubscribe: () => void; + readonly #emit: HostEventEmitter; + readonly ready: Promise; + + constructor(appDataRoot: string, emit: HostEventEmitter = () => undefined) { + this.#store = new PersistedAtomStore(appDataRoot); + this.#emit = emit; + this.ready = this.#store.ready; + this.#unsubscribe = this.#store.subscribe((metadata) => + this.#broadcastPersistedAtomUpdate(metadata), + ); + } + + sendPersistedAtomState(args: unknown): PersistedAtomReadResult { + try { + const request = parsePersistedAtomReadRequest(args); + return this.#store.sendPersistedAtomState(request.query); + } catch { + return { + ...this.#store.metadata(), + outcome: "invalid", + reason: "invalid_request", + data: null, + }; + } + } + + updatePersistedAtomState(args: unknown): PersistedAtomUpdateResult { + try { + const request = exactRecord(args, ["key", "mutation"]); + if (request.key !== COMPOSER_ATOM_KEY) + throw new Error("invalid atom key"); + return this.#store.updatePersistedAtomState(request.mutation); + } catch { + return { + ...this.#store.metadata(), + outcome: "invalid", + reason: "invalid_request", + changed: false, + entry: null, + }; + } + } + + dispose(): void { + this.#unsubscribe(); + this.#store.dispose(); + } + + #broadcastPersistedAtomUpdate(metadata: PersistedAtomMetadata): void { + const event: PersistedAtomUpdateEvent = { + ...metadata, + event: "persisted-atom-update", + }; + this.#emit(event.event, event); + } +} diff --git a/electron/persistedAtomIndex.test.ts b/electron/persistedAtomIndex.test.ts new file mode 100644 index 000000000..c205543e0 --- /dev/null +++ b/electron/persistedAtomIndex.test.ts @@ -0,0 +1,378 @@ +import { Buffer } from "node:buffer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + PERSISTED_ATOM_LIMITS, + parsePersistedAtomReadResult, +} from "../src/lib/api/persistedAtomProtocol"; +import type { + DraftScope, + PersistedAtomEnvelope, + PersistedDraftEntry, +} from "../src/lib/api/persistedAtomTypes"; +import { projectWorkspaceDraftIndex, scopeKey } from "./persistedAtomRecords"; +import { + cleanup, + createStore, + entry, + home, + identity, + local, + put, +} from "./persistedAtomStore.test-support"; +import { PersistedAtomStore } from "./persistedAtomStore"; + +afterEach(cleanup); +const scope = (id: string, workspaceId: string | null = "w"): DraftScope => ({ + workspaceId, + kind: "draft", + id, +}); +function index(store: PersistedAtomStore, workspaceId: string | null = "w") { + const query = { kind: "workspace-index", workspaceId } as const; + const result = parsePersistedAtomReadResult( + store.sendPersistedAtomState(query), + { + key: "composer-drafts-v1", + query, + }, + ); + if (result.outcome !== "ready" || result.data.queryKind !== "workspace-index") + throw new Error("expected ready workspace index"); + return result; +} +function items(store: PersistedAtomStore, workspaceId: string | null = "w") { + const result = index(store, workspaceId); + if (result.data.queryKind !== "workspace-index") + throw new Error("wrong query"); + return result.data.items; +} + +describe("same-owner workspace draft index", () => { + it("isolates exact workspaces including null and returns empty metadata discovery", async () => { + const { store } = await createStore(); + const scopes = [ + home, + scope("same"), + scope("same", "other"), + scope("same", null), + local, + ]; + for (const [i, value] of scopes.entries()) + expect(store.updatePersistedAtomState(put(value, `d${i}`)).outcome).toBe( + "applied", + ); + expect(items(store).map((item) => item.scope)).toEqual([ + scope("same"), + local, + ]); + expect(items(store, null).map((item) => item.scope)).toEqual([ + scope("same", null), + home, + ]); + expect(items(store, "other").map((item) => item.scope)).toEqual([ + scope("same", "other"), + ]); + expect(items(store, "missing")).toEqual([]); + expect( + store.sendPersistedAtomState({ kind: "scope", scope: scope("missing") }), + ).toMatchObject({ + outcome: "ready", + data: { queryKind: "scope", view: { entry: null } }, + }); + }); + + it("uses current promoted binding and removes consumed records without exposing payload", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState({ + op: "promote", + source: home, + target: local, + identity, + expectedTarget: null, + sessionId: local.id, + threadId: "canonical-thread", + }); + expect(items(store, null)).toEqual([]); + expect(items(store)).toEqual([ + { + scope: local, + identity, + updatedAt: 1_000, + sessionId: local.id, + threadId: "canonical-thread", + }, + ]); + expect(entry(store, local)?.originScopeKey).toBe(scopeKey(home)); + expect(JSON.stringify(items(store))).not.toMatch( + /rich 中文|attachments|mediaType|editorDocument|inputCapabilityRoute|originScopeKey/, + ); + expect( + store.updatePersistedAtomState({ op: "consume", identity }).outcome, + ).toBe("applied"); + expect(items(store)).toEqual([]); + expect(items(store, null)).toEqual([]); + expect(store.updatePersistedAtomState(put()).reason).toBe("retired_draft"); + }); + + it("preserves the single binding invariant across rejected aliases and repeated promotions", async () => { + const { store } = await createStore(); + const a = scope("a"), + b = scope("b"), + c = scope("c"); + store.updatePersistedAtomState(put(a)); + expect(store.updatePersistedAtomState(put(b)).outcome).toBe("conflict"); + expect( + store.updatePersistedAtomState(put(b, "d1", "v2", "v1")).outcome, + ).toBe("conflict"); + const promote = (source: DraftScope, target: DraftScope) => + store.updatePersistedAtomState({ + op: "promote", + source, + target, + identity, + expectedTarget: null, + }); + expect(promote(a, b).outcome).toBe("applied"); + expect(promote(a, b).changed).toBe(false); + expect(promote(b, c).outcome).toBe("applied"); + expect(items(store).map((item) => item.scope)).toEqual([c]); + expect(entry(store, a)).toBeNull(); + expect(entry(store, b)).toBeNull(); + }); + + it("orders by newest time then canonical scope key without environment-sensitive collation", async () => { + let time = 10; + const { store } = await createStore({ now: () => time }); + for (const id of ["z", "ä", "Z", "a", "😀", "\ue000"]) + store.updatePersistedAtomState(put(scope(id), `d-${id}`)); + time = 11; + store.updatePersistedAtomState(put(scope("newest"), "d-newest")); + expect(items(store).map((item) => item.scope.id)).toEqual([ + "newest", + "Z", + "a", + "z", + "ä", + "\ue000", + "😀", + ]); + expect(items(store)).toEqual(items(store)); + }); + + it("does not read/write disk, broadcast, change metadata or update timestamps while reading", async () => { + const now = vi.fn(() => 1_000); + const calls: string[] = []; + const { store } = await createStore({ + now, + io: { + readFile: async () => { + calls.push("read"); + throw { code: "ENOENT" }; + }, + mkdir: async () => { + calls.push("mkdir"); + }, + writeFile: async () => { + calls.push("write"); + }, + rename: async () => { + calls.push("rename"); + }, + removeTemporary: async () => { + calls.push("remove"); + }, + }, + }); + store.updatePersistedAtomState(put(scope("a"))); + await store.flush(); + const metadata = store.metadata(), + body = entry(store, scope("a")); + const listener = vi.fn(); + store.subscribe(listener); + const baselineCalls = [...calls], + baselineTimeCalls = now.mock.calls.length; + for (let i = 0; i < 3; i += 1) { + expect(index(store).storeRevision).toBe(metadata.storeRevision); + expect(items(store)[0].updatedAt).toBe(1_000); + expect(store.metadata()).toEqual(metadata); + expect(entry(store, scope("a"))).toEqual(body); + } + expect(calls).toEqual(baselineCalls); + expect(now).toHaveBeenCalledTimes(baselineTimeCalls); + expect(listener).not.toHaveBeenCalled(); + // Projection objects cannot mutate the store owner. + const projection = items(store)[0]; + Object.assign(projection.scope, { id: "mutated" }); + Object.assign(projection.identity, { scopeVersion: "mutated" }); + expect(items(store)[0].scope.id).toBe("a"); + expect(items(store)[0].identity.scopeVersion).toBe("v1"); + }); + + it("returns the complete small-budget index without read capacity truncation", async () => { + const { store } = await createStore({ + limits: { ...PERSISTED_ATOM_LIMITS, maxEntries: 3, maxBindings: 3 }, + }); + for (let i = 0; i < 3; i += 1) + store.updatePersistedAtomState(put(scope(`s${i}`), `d${i}`)); + expect( + store.updatePersistedAtomState(put(scope("over"), "over")).reason, + ).toBe("capacity_exceeded"); + expect(items(store)).toHaveLength(3); + expect(index(store).storeRevision).toBe(3); + }); + + it("returns all 10,000 validated bindings from one synchronous loaded-store snapshot", async () => { + const entries: Record = {}, + bindings: Record = {}; + for (let i = 0; i < 10_000; i += 1) { + const current = scope(`s${i}`), + draftId = `d${i}`; + const key = scopeKey(current); + entries[draftId] = { + draftId, + scopeVersion: "v1", + originScopeKey: key, + updatedAt: i, + state: "draft", + draft: {}, + }; + bindings[key] = draftId; + } + const envelope: PersistedAtomEnvelope = { + format: "electron-persisted-atom-state", + version: 1, + storeRevision: 10_000, + atoms: { "composer-drafts-v1": { entries, bindings } }, + }; + const buffer = Buffer.from(JSON.stringify(envelope)); + const { store } = await createStore({ + limits: PERSISTED_ATOM_LIMITS, + io: { + readFile: async () => buffer, + mkdir: async () => { + throw new Error("unexpected write"); + }, + writeFile: async () => { + throw new Error("unexpected write"); + }, + rename: async () => { + throw new Error("unexpected write"); + }, + removeTemporary: async () => { + throw new Error("unexpected write"); + }, + }, + }); + const result = index(store); + expect(result.storeRevision).toBe(10_000); + if (result.data.queryKind !== "workspace-index") + throw new Error("wrong query"); + expect(result.data.items).toHaveLength(10_000); + expect(result.data.items[0].identity.draftId).toBe("d9999"); + expect(result.data.items.at(-1)?.identity.draftId).toBe("d0"); + expect(store.metadata()).toMatchObject({ + storeRevision: 10_000, + persistence: { state: "saved", savedRevision: 10_000 }, + }); + }, 20_000); + + it("rejects old bare scopes and malformed queries and distinguishes closed from empty", async () => { + const { store } = await createStore(); + for (const query of [ + home, + { kind: "workspace-index", workspaceId: undefined }, + { kind: "workspace-index", workspaceId: null, path: "/tmp/private" }, + { kind: "scope", scope: home, extra: true }, + ]) + expect(store.sendPersistedAtomState(query)).toMatchObject({ + outcome: "invalid", + reason: "invalid_request", + data: null, + }); + store.dispose(); + expect( + store.sendPersistedAtomState({ + kind: "workspace-index", + workspaceId: null, + }), + ).toMatchObject({ outcome: "unavailable", reason: "closed", data: null }); + }); + + it("reports failed load as unavailable instead of an empty index that could discard saved state", async () => { + const { store } = await createStore({ + io: { + readFile: async () => { + throw { code: "EACCES" }; + }, + mkdir: async () => { + throw new Error("unexpected write"); + }, + writeFile: async () => { + throw new Error("unexpected write"); + }, + rename: async () => { + throw new Error("unexpected write"); + }, + removeTemporary: async () => { + throw new Error("unexpected write"); + }, + }, + }); + expect( + store.sendPersistedAtomState({ + kind: "workspace-index", + workspaceId: null, + }), + ).toMatchObject({ + outcome: "unavailable", + data: null, + persistence: { + state: "unavailable", + diagnostic: { phase: "load", code: "EACCES" }, + }, + }); + }); + + it("projects only valid live current bindings and excludes orphans, tombstones and malformed bindings", () => { + const current = scope("current"), + old = scope("origin", null); + const live: PersistedDraftEntry = { + ...identity, + originScopeKey: scopeKey(old), + updatedAt: 2, + state: "draft", + draft: { text: "private" }, + }; + const { draft: _draft, ...base } = live; + const consumed: PersistedDraftEntry = { + ...base, + draftId: "consumed", + state: "consumed", + consumption: "clear", + consumedAt: 3, + }; + const entries = new Map([ + ["d1", live], + ["orphan", { ...live, draftId: "orphan" }], + ["consumed", consumed], + ["bad-local", { ...live, draftId: "bad-local", sessionId: "wrong" }], + ["mismatch", { ...live, draftId: "different" }], + ]); + const bindings = new Map([ + [scopeKey(current), "d1"], + [scopeKey(scope("dangling")), "missing"], + [scopeKey(scope("consumed")), "consumed"], + [scopeKey(local), "bad-local"], + [scopeKey(scope("mismatch")), "mismatch"], + ["malformed", "d1"], + [JSON.stringify(["w", "draft", "current"]) + " ", "d1"], + ]); + expect(projectWorkspaceDraftIndex(bindings, entries, "w")).toEqual([ + { scope: current, identity, updatedAt: 2 }, + ]); + expect(projectWorkspaceDraftIndex(bindings, entries, null)).toEqual([]); + expect(entries.get("d1")).toBe(live); + expect(bindings.size).toBe(7); + }); +}); diff --git a/electron/persistedAtomRecords.test.ts b/electron/persistedAtomRecords.test.ts new file mode 100644 index 000000000..c804fcad8 --- /dev/null +++ b/electron/persistedAtomRecords.test.ts @@ -0,0 +1,266 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; +import { + cloneDraft, + parseEnvelope, + parseMutation, + parseScope, + scopeKey, +} from "./persistedAtomRecords"; +import type { PersistedAtomEnvelope } from "../src/lib/api/persistedAtomTypes"; + +const limits = { + maxDraftBytes: 2_048, + maxFileBytes: 8_192, + maxEntries: 10, + maxBindings: 10, +}; +const home = { + workspaceId: null, + kind: "home", + id: "new-conversation", +} as const; +function envelope(): PersistedAtomEnvelope { + return { + format: "electron-persisted-atom-state", + version: 1, + storeRevision: 1, + atoms: { + "composer-drafts-v1": { + bindings: { [scopeKey(home)]: "draft-1" }, + entries: { + "draft-1": { + state: "draft", + draftId: "draft-1", + scopeVersion: "v1", + originScopeKey: scopeKey(home), + updatedAt: 1, + draft: { + text: "中文😀", + editorDocument: { type: "doc", content: [] }, + }, + }, + }, + }, + }, + }; +} + +describe("persisted atom JSON/identity boundary", () => { + it("keeps structured namespaces distinct and preserves the local session id", () => { + expect( + parseScope({ workspaceId: "w", kind: "local", id: "session-1" }), + ).toEqual({ workspaceId: "w", kind: "local", id: "session-1" }); + expect( + scopeKey({ workspaceId: "a|b", kind: "draft", id: "c" }), + ).not.toEqual(scopeKey({ workspaceId: "a", kind: "draft", id: "b|c" })); + expect(scopeKey(home)).not.toEqual( + scopeKey({ ...home, workspaceId: "null" }), + ); + }); + + it.each([ + { ...home, id: "other-home" }, + { ...home, kind: "remote" }, + { ...home, workspaceId: undefined }, + { ...home, extra: "ignored" }, + { ...home, id: "" }, + { ...home, id: "x\u0000y" }, + ])("rejects invalid scope rather than changing identity", (scope) => { + expect(() => parseScope(scope)).toThrow("invalid_scope"); + }); + + it("measures complete UTF8 JSON including escaped text and clones the input", () => { + const value = { text: '中文😀\n"', nested: { selection: [0, 2] } }; + const bytes = Buffer.byteLength(JSON.stringify(value), "utf8"); + expect(cloneDraft(value, bytes)).toEqual(value); + expect(() => cloneDraft(value, bytes - 1)).toThrow("capacity_exceeded"); + const copy = cloneDraft(value, bytes); + value.nested.selection[0] = 4; + expect(copy).not.toEqual(value); + }); + + it.each([ + undefined, + BigInt(1), + NaN, + Infinity, + () => "x", + new Date(0), + new Map(), + Symbol("x"), + ])("rejects values JSON would lose", (item) => { + expect(() => cloneDraft({ item }, limits.maxDraftBytes)).toThrow( + "invalid_payload", + ); + }); + + it("does not execute accessors or toJSON, or accept cyclic/sparse data", () => { + let called = false; + const accessor = Object.defineProperty({}, "text", { + enumerable: true, + get() { + called = true; + return "secret"; + }, + }); + expect(() => cloneDraft(accessor, limits.maxDraftBytes)).toThrow( + "invalid_payload", + ); + expect(called).toBe(false); + expect(() => + cloneDraft({ toJSON: () => ({ lost: true }) }, limits.maxDraftBytes), + ).toThrow("invalid_payload"); + const circular: Record = {}; + circular.self = circular; + expect(() => cloneDraft(circular, limits.maxDraftBytes)).toThrow( + "invalid_payload", + ); + expect(() => cloneDraft({ list: Array(2) }, limits.maxDraftBytes)).toThrow( + "invalid_payload", + ); + }); + + it("preserves ordinary special property names without prototype mutation", () => { + const value = JSON.parse( + '{"__proto__":{"text":"x"},"constructor":"field"}', + ); + expect(cloneDraft(value, limits.maxDraftBytes)).toEqual(value); + expect(Object.getPrototypeOf(cloneDraft(value, limits.maxDraftBytes))).toBe( + Object.prototype, + ); + }); + + it("rejects unknown operation fields and unchanged version tokens", () => { + const put = { + op: "put", + scope: home, + draftId: "d", + expectedVersion: null, + nextVersion: "v1", + draft: {}, + }; + expect(parseMutation(put, limits)).toEqual(put); + expect(() => + parseMutation({ ...put, arbitraryPath: "/tmp/x" }, limits), + ).toThrow("invalid_request"); + expect(() => + parseMutation({ ...put, expectedVersion: "v1" }, limits), + ).toThrow("version_conflict"); + expect(() => parseMutation({ op: "replace", atom: {} }, limits)).toThrow( + "invalid_request", + ); + }); + + it("loads a valid rich document and a payload-free tombstone", () => { + expect(parseEnvelope(envelope(), limits)).toEqual(envelope()); + const data = envelope(), + atom = data.atoms["composer-drafts-v1"]; + const consumed = { + draftId: "old", + scopeVersion: "v-old", + originScopeKey: scopeKey(home), + state: "consumed", + consumption: "accepted", + updatedAt: 2, + consumedAt: 2, + }; + expect( + parseEnvelope( + { + ...data, + atoms: { + "composer-drafts-v1": { + ...atom, + entries: { ...atom.entries, old: consumed }, + }, + }, + }, + limits, + ).atoms["composer-drafts-v1"].entries.old, + ).toEqual(consumed); + expect(() => + parseEnvelope( + { + ...data, + atoms: { + "composer-drafts-v1": { + ...atom, + entries: { + ...atom.entries, + old: { ...consumed, draft: { text: "sent" } }, + }, + }, + }, + }, + limits, + ), + ).toThrow(); + }); + + it.each([ + (data: PersistedAtomEnvelope) => ({ ...data, version: 2 }), + (data: PersistedAtomEnvelope) => ({ + ...data, + atoms: { ...data.atoms, other: {} }, + }), + (data: PersistedAtomEnvelope) => ({ ...data, storeRevision: -1 }), + (data: PersistedAtomEnvelope) => ({ + ...data, + atoms: { + "composer-drafts-v1": { + ...data.atoms["composer-drafts-v1"], + bindings: { [scopeKey(home)]: "absent" }, + }, + }, + }), + (data: PersistedAtomEnvelope) => ({ + ...data, + atoms: { + "composer-drafts-v1": { + ...data.atoms["composer-drafts-v1"], + bindings: {}, + }, + }, + }), + (data: PersistedAtomEnvelope) => ({ + ...data, + atoms: { + "composer-drafts-v1": { + ...data.atoms["composer-drafts-v1"], + bindings: { + [scopeKey(home)]: "draft-1", + [scopeKey({ workspaceId: "w", kind: "draft", id: "tab" })]: + "draft-1", + }, + }, + }, + }), + ])("fails closed for inconsistent/future durable envelopes", (change) => { + expect(() => parseEnvelope(change(envelope()), limits)).toThrow(); + }); + + it("rejects a local binding whose session metadata is a canonical thread id", () => { + const data = envelope(); + expect(() => + parseEnvelope( + { + ...data, + atoms: { + "composer-drafts-v1": { + ...data.atoms["composer-drafts-v1"], + bindings: { + [scopeKey({ + workspaceId: "w", + kind: "local", + id: "session-1", + })]: "draft-1", + }, + }, + }, + }, + limits, + ), + ).toThrow(); + }); +}); diff --git a/electron/persistedAtomRecords.ts b/electron/persistedAtomRecords.ts new file mode 100644 index 000000000..eb2f049e3 --- /dev/null +++ b/electron/persistedAtomRecords.ts @@ -0,0 +1,389 @@ +import { Buffer } from "node:buffer"; +import { + assertPersistedDraftPayload, + type PersistedAtomLimits, +} from "../src/lib/api/persistedAtomProtocol"; +import type { + DraftIdentity, + DraftScope, + JsonObject, + PersistedAtomEnvelope, + PersistedAtomMutation, + PersistedAtomReason, + PersistedDraftEntry, + PersistedDraftIndexItem, +} from "../src/lib/api/persistedAtomTypes"; + +export const COMPOSER_ATOM_KEY = "composer-drafts-v1" as const; + +export class AtomValidationError extends Error { + constructor(readonly reason: PersistedAtomReason) { + super(reason); + } +} + +export function invalid(reason: PersistedAtomReason): never { + throw new AtomValidationError(reason); +} + +function record(value: unknown): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) + invalid("invalid_request"); + if ( + Object.getOwnPropertySymbols(value).length || + Object.values(Object.getOwnPropertyDescriptors(value)).some( + (descriptor) => !descriptor.enumerable || !("value" in descriptor), + ) + ) + invalid("invalid_request"); + return value as Record; +} + +export function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + const object = record(value); + const descriptors = Object.getOwnPropertyDescriptors(object); + if ( + Object.getOwnPropertySymbols(object).length || + required.some((key) => !Object.hasOwn(object, key)) || + Object.keys(descriptors).some( + (key) => + (!required.includes(key) && !optional.includes(key)) || + !descriptors[key].enumerable || + !("value" in descriptors[key]), + ) + ) + invalid("invalid_request"); + return object; +} + +export function identifier(value: unknown, maxLength = 256): string { + if ( + typeof value !== "string" || + !value.trim() || + value.length > maxLength || + [...value].some((character) => character.charCodeAt(0) < 32) + ) + invalid("invalid_request"); + return value; +} + +export function parseScope(value: unknown): DraftScope { + try { + const scope = exactRecord(value, ["workspaceId", "kind", "id"]); + if ( + typeof scope.kind !== "string" || + !["home", "draft", "local"].includes(scope.kind) + ) + invalid("invalid_scope"); + const id = identifier(scope.id, 8_192); + if (scope.kind === "home" && id !== "new-conversation") + invalid("invalid_scope"); + return { + workspaceId: + scope.workspaceId === null + ? null + : identifier(scope.workspaceId, 4_096), + kind: scope.kind as DraftScope["kind"], + id, + }; + } catch { + return invalid("invalid_scope"); + } +} + +export function scopeKey(scope: DraftScope): string { + return JSON.stringify([scope.workspaceId, scope.kind, scope.id]); +} + +function compareScopeKeys(left: string, right: string): number { + let a = 0, + b = 0; + while (a < left.length && b < right.length) { + const x = left.codePointAt(a)!, + y = right.codePointAt(b)!; + if (x !== y) return x - y; + a += x > 0xffff ? 2 : 1; + b += y > 0xffff ? 2 : 1; + } + return a < left.length ? 1 : b < right.length ? -1 : 0; +} + +/** Read-only projection from the store owner; no parallel index or cache. */ +export function projectWorkspaceDraftIndex( + bindings: ReadonlyMap, + entries: ReadonlyMap, + workspaceId: string | null, +): readonly PersistedDraftIndexItem[] { + const items: PersistedDraftIndexItem[] = []; + for (const [key, draftId] of bindings) { + const entry = entries.get(draftId); + if (!entry || entry.state !== "draft" || entry.draftId !== draftId) + continue; + let scope: DraftScope; + try { + const parts: unknown = JSON.parse(key); + if (!Array.isArray(parts) || parts.length !== 3) continue; + scope = parseScope({ + workspaceId: parts[0], + kind: parts[1], + id: parts[2], + }); + } catch { + continue; + } + if ( + scopeKey(scope) !== key || + scope.workspaceId !== workspaceId || + (scope.kind === "local" && entry.sessionId !== scope.id) + ) + continue; + items.push({ + scope, + identity: { draftId: entry.draftId, scopeVersion: entry.scopeVersion }, + updatedAt: entry.updatedAt, + ...(entry.sessionId !== undefined ? { sessionId: entry.sessionId } : {}), + ...(entry.threadId !== undefined ? { threadId: entry.threadId } : {}), + }); + } + return items.sort((left, right) => { + if (left.updatedAt !== right.updatedAt) + return right.updatedAt - left.updatedAt; + return compareScopeKeys(scopeKey(left.scope), scopeKey(right.scope)); + }); +} + +export function parseIdentity(value: unknown): DraftIdentity { + const identity = exactRecord(value, ["draftId", "scopeVersion"]); + return { + draftId: identifier(identity.draftId), + scopeVersion: identifier(identity.scopeVersion), + }; +} + +/** Reject lossy JSON values, accessors and cycles before serialization. */ +export function cloneDraft(value: unknown, maxBytes: number): JsonObject { + try { + assertPersistedDraftPayload(value); + } catch { + return invalid("invalid_payload"); + } + const json = JSON.stringify(value); + if (Buffer.byteLength(json, "utf8") > maxBytes) invalid("capacity_exceeded"); + return JSON.parse(json) as JsonObject; +} + +export function parseMutation( + value: unknown, + limits: PersistedAtomLimits, +): PersistedAtomMutation { + const op = record(value).op; + if (op === "put") { + const update = exactRecord(value, [ + "op", + "scope", + "draftId", + "expectedVersion", + "nextVersion", + "draft", + ]); + const expectedVersion = + update.expectedVersion === null + ? null + : identifier(update.expectedVersion); + const nextVersion = identifier(update.nextVersion); + if (expectedVersion === nextVersion) invalid("version_conflict"); + return { + op, + scope: parseScope(update.scope), + draftId: identifier(update.draftId), + expectedVersion, + nextVersion, + draft: cloneDraft(update.draft, limits.maxDraftBytes), + }; + } + if (op === "promote") { + const update = exactRecord( + value, + ["op", "source", "target", "identity", "expectedTarget"], + ["sessionId", "threadId"], + ); + return { + op, + source: parseScope(update.source), + target: parseScope(update.target), + identity: parseIdentity(update.identity), + expectedTarget: + update.expectedTarget === null + ? null + : parseIdentity(update.expectedTarget), + ...(update.sessionId === undefined + ? {} + : { sessionId: identifier(update.sessionId) }), + ...(update.threadId === undefined + ? {} + : { threadId: identifier(update.threadId) }), + }; + } + if (op === "consume" || op === "clear") { + const update = exactRecord(value, ["op", "identity"]); + return { op, identity: parseIdentity(update.identity) }; + } + return invalid("invalid_request"); +} + +function timestamp(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + invalid("invalid_request"); + return value; +} + +function parseEntry( + value: unknown, + limits: PersistedAtomLimits, +): PersistedDraftEntry { + const state = record(value).state; + const base = [ + "draftId", + "scopeVersion", + "originScopeKey", + "state", + "updatedAt", + ]; + const entry = exactRecord( + value, + [ + ...base, + ...(state === "draft" ? ["draft"] : ["consumption", "consumedAt"]), + ], + ["sessionId", "threadId"], + ); + let origin: unknown; + try { + origin = JSON.parse(identifier(entry.originScopeKey, 16_384)); + } catch { + return invalid("invalid_request"); + } + if (!Array.isArray(origin) || origin.length !== 3) invalid("invalid_request"); + const originScope = parseScope({ + workspaceId: origin[0], + kind: origin[1], + id: origin[2], + }); + if (scopeKey(originScope) !== entry.originScopeKey) + invalid("invalid_request"); + const metadata = { + draftId: identifier(entry.draftId), + scopeVersion: identifier(entry.scopeVersion), + originScopeKey: entry.originScopeKey as string, + updatedAt: timestamp(entry.updatedAt), + ...(entry.sessionId === undefined + ? {} + : { sessionId: identifier(entry.sessionId) }), + ...(entry.threadId === undefined + ? {} + : { threadId: identifier(entry.threadId) }), + }; + if (state === "draft") + return { + ...metadata, + state, + draft: cloneDraft(entry.draft, limits.maxDraftBytes), + }; + if ( + state !== "consumed" || + typeof entry.consumption !== "string" || + !["accepted", "clear"].includes(entry.consumption) + ) + invalid("invalid_request"); + return { + ...metadata, + state, + consumption: entry.consumption as "accepted" | "clear", + consumedAt: timestamp(entry.consumedAt), + }; +} + +export function parseEnvelope( + value: unknown, + limits: PersistedAtomLimits, +): PersistedAtomEnvelope { + const envelope = exactRecord(value, [ + "format", + "version", + "storeRevision", + "atoms", + ]); + if ( + envelope.format !== "electron-persisted-atom-state" || + envelope.version !== 1 + ) + invalid("invalid_request"); + const atoms = exactRecord(envelope.atoms, [COMPOSER_ATOM_KEY]); + const atom = exactRecord(atoms[COMPOSER_ATOM_KEY], ["bindings", "entries"]); + const rawEntries = record(atom.entries); + const rawBindings = record(atom.bindings); + if ( + Object.keys(rawEntries).length > limits.maxEntries || + Object.keys(rawBindings).length > limits.maxBindings + ) + invalid("capacity_exceeded"); + const entries = Object.fromEntries( + Object.entries(rawEntries).map(([id, item]) => { + const entry = parseEntry(item, limits); + if (id !== entry.draftId) invalid("invalid_request"); + return [id, entry]; + }), + ); + const seen = new Set(); + const bindings = Object.fromEntries( + Object.entries(rawBindings).map(([key, id]) => { + let parts: unknown; + try { + parts = JSON.parse(key); + } catch { + return invalid("invalid_request"); + } + if (!Array.isArray(parts) || parts.length !== 3) + invalid("invalid_request"); + const scope = parseScope({ + workspaceId: parts[0], + kind: parts[1], + id: parts[2], + }); + const identity = identifier(id); + if ( + scopeKey(scope) !== key || + !Object.hasOwn(entries, identity) || + entries[identity].state !== "draft" || + seen.has(identity) + ) + invalid("invalid_request"); + if (scope.kind === "local" && entries[identity].sessionId !== scope.id) + invalid("invalid_request"); + seen.add(identity); + return [key, identity]; + }), + ); + if ( + Object.values(entries).some( + (entry) => entry.state === "draft" && !seen.has(entry.draftId), + ) + ) + invalid("invalid_request"); + return { + format: "electron-persisted-atom-state", + version: 1, + storeRevision: timestamp(envelope.storeRevision), + atoms: { [COMPOSER_ATOM_KEY]: { entries, bindings } }, + }; +} diff --git a/electron/persistedAtomStore.integration.test.ts b/electron/persistedAtomStore.integration.test.ts new file mode 100644 index 000000000..b6af232b5 --- /dev/null +++ b/electron/persistedAtomStore.integration.test.ts @@ -0,0 +1,354 @@ +import { Buffer } from "node:buffer"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PersistedAtomUpdateResult } from "../src/lib/api/persistedAtomTypes"; +import { + persistedAtomFileIO, + type PersistedAtomFileIO, +} from "./persistedAtomFile"; +import { PersistedAtomStore } from "./persistedAtomStore"; +import { + cleanup, + createRoot, + createStore, + deferred, + entry, + home, + identity, + limits, + local, + promotion, + put, + trackStore, +} from "./persistedAtomStore.test-support"; +afterEach(cleanup); + +describe("persisted atom save queue and recovery", () => { + it("reserves tombstone overhead before accepting a minimal draft at capacity", async () => { + const minimal = { + op: "put", + scope: { workspaceId: null, kind: "draft", id: "x" }, + draftId: "d1", + expectedVersion: null, + nextVersion: "v1", + draft: {}, + } as const; + const baseline = await createStore(); + expect(baseline.store.updatePersistedAtomState(minimal).outcome).toBe( + "applied", + ); + await baseline.store.flush(); + const actualBytes = ( + await readFile(path.join(baseline.root, "host-state.json")) + ).byteLength; + const narrow = await createStore({ + limits: { ...limits, maxFileBytes: actualBytes }, + }); + expect(narrow.store.updatePersistedAtomState(minimal)).toMatchObject({ + outcome: "invalid", + reason: "capacity_exceeded", + storeRevision: 0, + }); + const bounded = await createStore({ + limits: { ...limits, maxFileBytes: actualBytes + 128 }, + }); + expect(bounded.store.updatePersistedAtomState(minimal).outcome).toBe( + "applied", + ); + expect( + bounded.store.updatePersistedAtomState({ op: "consume", identity }), + ).toMatchObject({ outcome: "applied", entry: { state: "consumed" } }); + await bounded.store.flush(); + expect( + (await readFile(path.join(bounded.root, "host-state.json"))).byteLength, + ).toBeLessThanOrEqual(actualBytes + 128); + }); + + it("malformed UTF8 durable files fail closed without replacing invalid bytes", async () => { + const root = await createRoot(); + const broken = Buffer.from([ + 0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d, + ]); + await writeFile(path.join(root, "host-state.json"), broken); + const store = new PersistedAtomStore(root, { limits }); + trackStore(store); + await store.ready; + expect(store.metadata()).toMatchObject({ + persistence: { + state: "unavailable", + diagnostic: { code: "INVALID_STATE" }, + }, + }); + expect(store.updatePersistedAtomState(put()).outcome).toBe("unavailable"); + await store.flush(); + expect(await readFile(path.join(root, "host-state.json"))).toEqual(broken); + }); + it("coalesces edits over 500 ms and writes only the latest full snapshot", async () => { + vi.useFakeTimers(); + const rename = vi.fn(persistedAtomFileIO.rename); + const { store } = await createStore({ + io: { ...persistedAtomFileIO, rename }, + }); + store.updatePersistedAtomState(put()); + await vi.advanceTimersByTimeAsync(250); + store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "latest")); + await vi.advanceTimersByTimeAsync(499); + expect(rename).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await store.flush(); + expect(rename).toHaveBeenCalledTimes(2); + expect(store.metadata()).toMatchObject({ + storeRevision: 2, + persistence: { state: "saved", savedRevision: 2 }, + }); + }); + + it("a late saved revision cannot mark a newer in-memory edit saved", async () => { + const started = deferred(), + release = deferred(); + let first = true; + const io: PersistedAtomFileIO = { + ...persistedAtomFileIO, + rename: async (from, to) => { + if (first) { + first = false; + started.resolve(); + await release.promise; + } + await persistedAtomFileIO.rename(from, to); + }, + }; + const { store, root } = await createStore({ io }); + store.updatePersistedAtomState(put()); + const saving = store.flush(); + await started.promise; + store.updatePersistedAtomState( + put(home, "d1", "v2", "v1", "newer pending"), + ); + release.resolve(); + await saving; + expect(store.metadata()).toMatchObject({ + storeRevision: 2, + persistence: { state: "pending", savedRevision: 1 }, + }); + const persisted = JSON.parse( + await readFile(path.join(root, "host-state.json"), "utf8"), + ); + expect(persisted.storeRevision).toBe(1); + expect(entry(store)).toMatchObject({ scopeVersion: "v2" }); + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 2 }, + }); + }); + + it("serializes concurrent flushes without overlapping or rolling the durable revision back", async () => { + const started = deferred(), + release = deferred(); + let first = true, + active = 0, + maximum = 0; + const io: PersistedAtomFileIO = { + ...persistedAtomFileIO, + writeFile: async (file, text) => { + active += 1; + maximum = Math.max(maximum, active); + if (first) { + first = false; + started.resolve(); + await release.promise; + } + await persistedAtomFileIO.writeFile(file, text); + active -= 1; + }, + }; + const { store } = await createStore({ io }); + store.updatePersistedAtomState(put()); + const oldSave = store.flush(); + await started.promise; + store.updatePersistedAtomState(put(home, "d1", "v2", "v1")); + const newSave = store.flush(); + release.resolve(); + await Promise.all([oldSave, newSave]); + expect(maximum).toBe(1); + expect(store.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 2 }, + }); + }); + + it("keeps latest memory/old durable files after IO failure, without gating further mutations", async () => { + let fail = false; + const io = { + ...persistedAtomFileIO, + rename: async (from: string, to: string) => { + if (fail) + throw Object.assign(new Error("draft text attachment secret"), { + code: "ENOSPC", + }); + await persistedAtomFileIO.rename(from, to); + }, + }; + const { store, root } = await createStore({ io }); + store.updatePersistedAtomState(put()); + await store.flush(); + fail = true; + store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "new memory")); + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { + state: "error", + savedRevision: 1, + diagnostic: { phase: "primary", code: "ENOSPC" }, + }, + }); + expect(JSON.stringify(store.metadata())).not.toContain("attachment secret"); + expect(entry(store)).toMatchObject({ scopeVersion: "v2" }); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")) + .storeRevision, + ).toBe(1); + const accepted: PersistedAtomUpdateResult = store.updatePersistedAtomState({ + op: "consume", + identity: { ...identity, scopeVersion: "v2" }, + }); + expect(accepted).toMatchObject({ + outcome: "applied", + entry: { state: "consumed" }, + }); + fail = false; + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 3 }, + }); + }); + + it("partial backup failure is an error rather than a false saved receipt", async () => { + let failBackup = false; + const io = { + ...persistedAtomFileIO, + rename: async (from: string, to: string) => { + if (failBackup && to.endsWith(".bak")) + throw Object.assign(new Error("private"), { code: "EIO" }); + await persistedAtomFileIO.rename(from, to); + }, + }; + const { store, root } = await createStore({ io }); + store.updatePersistedAtomState(put()); + await store.flush(); + failBackup = true; + store.updatePersistedAtomState(put(home, "d1", "v2", "v1")); + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { + state: "error", + savedRevision: 1, + diagnostic: { phase: "backup", code: "EIO" }, + }, + }); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json.bak"), "utf8")) + .storeRevision, + ).toBe(1); + failBackup = false; + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 2 }, + }); + }); + + it("a new owner restores durable rich records and non-resurrectable tombstones", async () => { + const { store, root } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState(promotion()); + store.updatePersistedAtomState(put(home, "new-home", "new-version")); + store.updatePersistedAtomState({ op: "consume", identity }); + await store.flush(); + const restored = new PersistedAtomStore(root, { limits }); + trackStore(restored); + await restored.ready; + expect(entry(restored, local)).toBeNull(); + expect(entry(restored)).toEqual(entry(store)); + expect(restored.updatePersistedAtomState(put())).toMatchObject({ + outcome: "conflict", + reason: "retired_draft", + }); + expect(restored.metadata()).toMatchObject({ + storeRevision: 4, + persistence: { state: "saved", savedRevision: 4 }, + }); + }); + + it("repairs a corrupt primary from valid backup without losing the record", async () => { + const { store, root } = await createStore(); + store.updatePersistedAtomState(put()); + await store.flush(); + await writeFile(path.join(root, "host-state.json"), "corrupt"); + const restored = new PersistedAtomStore(root, { limits }); + trackStore(restored); + await restored.ready; + expect(entry(restored)).toEqual(entry(store)); + expect(restored.metadata()).toMatchObject({ + persistence: { state: "pending", savedRevision: 1 }, + }); + await restored.flush(); + expect(restored.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 1 }, + }); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")) + .storeRevision, + ).toBe(1); + }); + + it("keeps corrupt durable files unavailable and never overwrites them with empty state", async () => { + const root = await createRoot(); + await writeFile( + path.join(root, "host-state.json"), + "private-broken-primary", + ); + await writeFile( + path.join(root, "host-state.json.bak"), + "private-broken-backup", + ); + const store = new PersistedAtomStore(root, { limits }); + trackStore(store); + await store.ready; + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "unavailable", + changed: false, + }); + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { + state: "unavailable", + savedRevision: null, + diagnostic: { code: "INVALID_STATE" }, + }, + }); + expect(JSON.stringify(store.metadata())).not.toContain("private-broken"); + expect(await readFile(path.join(root, "host-state.json"), "utf8")).toBe( + "private-broken-primary", + ); + expect(await readFile(path.join(root, "host-state.json.bak"), "utf8")).toBe( + "private-broken-backup", + ); + }); + + it("dispose is synchronous best effort and closed stores refuse new mutations", async () => { + const { store, root } = await createStore(); + store.updatePersistedAtomState(put()); + expect(store.dispose()).toBeUndefined(); + expect( + store.updatePersistedAtomState(put(home, "d1", "v2", "v1")), + ).toMatchObject({ + outcome: "unavailable", + reason: "closed", + }); + await store.flush(); + expect( + JSON.parse(await readFile(path.join(root, "host-state.json"), "utf8")) + .storeRevision, + ).toBe(1); + }); +}); diff --git a/electron/persistedAtomStore.test-support.ts b/electron/persistedAtomStore.test-support.ts new file mode 100644 index 000000000..b1022ea7c --- /dev/null +++ b/electron/persistedAtomStore.test-support.ts @@ -0,0 +1,123 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { tmpdir } from "node:os"; +import { vi } from "vitest"; +import type { + DraftScope, + PersistedAtomMutation, +} from "../src/lib/api/persistedAtomTypes"; +import { PersistedAtomStore } from "./persistedAtomStore"; + +const roots: string[] = [], + stores: PersistedAtomStore[] = []; +export const limits = { + maxDraftBytes: 2_048, + maxFileBytes: 16_384, + maxEntries: 20, + maxBindings: 20, +}; +export const home: DraftScope = { + workspaceId: null, + kind: "home", + id: "new-conversation", +}; +export const local: DraftScope = { + workspaceId: "w", + kind: "local", + id: "session-1", +}; +export const identity = { draftId: "d1", scopeVersion: "v1" }; + +export function put( + scope: DraftScope = home, + draftId = "d1", + nextVersion = "v1", + expectedVersion: string | null = null, + text = "rich 中文😀", +): PersistedAtomMutation { + return { + op: "put", + scope, + draftId, + nextVersion, + expectedVersion, + draft: { + text, + editorDocument: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text, + marks: [ + { type: "link", attrs: { href: "https://example.test" } }, + ], + }, + ], + }, + ], + }, + selectionStart: 0, + selectionEnd: text.length, + attachments: [ + { data: "aW1hZ2U=", mediaType: "image/png", sourcePath: "fixture.png" }, + ], + annotations: [{ id: "annotation-1" }], + pendingPastes: [{ text: "folded", placeholder: "paste-1" }], + inputCapabilityRoute: { route: "image" }, + }, + }; +} +export function promotion( + source: DraftScope = home, + target: DraftScope = local, +): PersistedAtomMutation { + return { op: "promote", source, target, identity, expectedTarget: null }; +} +export function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} +export function trackStore(store: PersistedAtomStore): void { + stores.push(store); +} +export async function createRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "atom-store-")); + roots.push(root); + return root; +} +export async function createStore( + options: ConstructorParameters[1] = {}, +) { + const root = await createRoot(), + store = new PersistedAtomStore(root, { + limits, + now: () => 1_000, + ...options, + }); + trackStore(store); + await store.ready; + return { root, store }; +} +export function entry(store: PersistedAtomStore, scope: DraftScope = home) { + const result = store.sendPersistedAtomState({ kind: "scope", scope }); + if (result.outcome !== "ready" || result.data.queryKind !== "scope") + return null; + return result.data.view.entry; +} +export async function cleanup(): Promise { + vi.useRealTimers(); + for (const store of stores.splice(0)) { + store.dispose(); + await store.flush(); + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +} diff --git a/electron/persistedAtomStore.test.ts b/electron/persistedAtomStore.test.ts new file mode 100644 index 000000000..71e4bef06 --- /dev/null +++ b/electron/persistedAtomStore.test.ts @@ -0,0 +1,453 @@ +import { Buffer } from "node:buffer"; +import { readdir } from "node:fs/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import type { + DraftScope, + PersistedAtomMetadata, +} from "../src/lib/api/persistedAtomTypes"; +import { persistedAtomFileIO } from "./persistedAtomFile"; +import { PersistedAtomStore } from "./persistedAtomStore"; +import { + cleanup, + createRoot, + createStore, + deferred, + entry, + home, + identity, + limits, + local, + promotion, + put, + trackStore, +} from "./persistedAtomStore.test-support"; +afterEach(cleanup); + +describe("persisted atom Main CAS and draft lifecycle", () => { + it("returns explicit loading unavailability without accepting or awaiting a put", async () => { + const root = await createRoot(), + gate = deferred(); + const store = new PersistedAtomStore(root, { + limits, + io: { ...persistedAtomFileIO, readFile: () => gate.promise }, + }); + trackStore(store); + expect( + store.sendPersistedAtomState({ kind: "scope", scope: home }), + ).toMatchObject({ + outcome: "unavailable", + persistence: { state: "loading" }, + data: null, + }); + const result = store.updatePersistedAtomState(put()); + expect(result).not.toBeInstanceOf(Promise); + expect(result).toMatchObject({ + outcome: "unavailable", + changed: false, + storeRevision: 0, + }); + gate.resolve(Buffer.from("invalid")); + await store.ready; + expect( + store.sendPersistedAtomState({ kind: "scope", scope: home }), + ).toMatchObject({ + outcome: "unavailable", + persistence: { state: "unavailable" }, + }); + expect(await readdir(root)).toEqual([]); + }); + + it("accepts a complete rich draft synchronously before durable IO", async () => { + const { root, store } = await createStore(); + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "applied", + changed: true, + storeRevision: 1, + persistence: { state: "pending", savedRevision: 0 }, + }); + expect(await readdir(root)).toEqual([]); + const draft = put(); + expect(entry(store)?.state).toBe("draft"); + expect(entry(store)).toMatchObject({ + draftId: "d1", + scopeVersion: "v1", + draft: "draft" in draft ? draft.draft : null, + }); + await store.flush(); + expect(store.metadata()).toMatchObject({ + persistence: { state: "saved", savedRevision: 1 }, + }); + }); + + it("uses exact expected versions and does not rebase a stale writer", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + expect( + store.updatePersistedAtomState(put(home, "d1", "v2", "v1", "new edit")), + ).toMatchObject({ outcome: "applied", storeRevision: 2 }); + expect( + store.updatePersistedAtomState( + put(home, "d1", "stale", "v1", "stale overwrite"), + ), + ).toMatchObject({ + outcome: "conflict", + reason: "version_conflict", + changed: false, + storeRevision: 2, + }); + expect(entry(store)).toMatchObject({ + scopeVersion: "v2", + draft: { text: "new edit" }, + }); + expect( + store.updatePersistedAtomState(put(home, "other", "v1")), + ).toMatchObject({ + outcome: "conflict", + reason: "identity_mismatch", + }); + expect( + store.updatePersistedAtomState(put(home, "d1", "v2", "v2")), + ).toMatchObject({ + outcome: "invalid", + reason: "version_conflict", + }); + }); + + it("isolates home/draft/session and workspace namespaces", async () => { + const { store } = await createStore(); + const scopes: DraftScope[] = [ + home, + { ...home, workspaceId: "w" }, + { workspaceId: "w", kind: "draft", id: "tab-1" }, + local, + ]; + scopes.forEach((scope, index) => + expect( + store.updatePersistedAtomState( + put(scope, `draft-${index}`, `v-${index}`, null, `scope ${index}`), + ).outcome, + ).toBe("applied"), + ); + scopes.forEach((scope, index) => + expect(entry(store, scope)).toMatchObject({ + draftId: `draft-${index}`, + draft: { text: `scope ${index}` }, + }), + ); + expect(entry(store, local)).toMatchObject({ sessionId: "session-1" }); + }); + + it.each([ + { name: "Home without tab", scope: home }, + { + name: "direct draft", + scope: { + workspaceId: "w", + kind: "draft", + id: "tab-direct", + } as DraftScope, + }, + { + name: "materialized draft", + scope: { + workspaceId: "w", + kind: "draft", + id: "tab-materialized", + } as DraftScope, + }, + ])( + "promotes $name without changing logical draft/version/payload", + async ({ scope }) => { + const { store } = await createStore(); + store.updatePersistedAtomState(put(scope)); + const before = entry(store, scope); + const result = store.updatePersistedAtomState(promotion(scope)); + expect(result).toMatchObject({ + outcome: "applied", + changed: true, + storeRevision: 2, + }); + expect(entry(store, scope)).toBeNull(); + expect(entry(store, local)).toMatchObject({ + draftId: before?.draftId, + scopeVersion: before?.scopeVersion, + originScopeKey: before?.originScopeKey, + sessionId: "session-1", + draft: before?.state === "draft" ? before.draft : null, + }); + expect(store.updatePersistedAtomState(promotion(scope))).toMatchObject({ + outcome: "applied", + changed: false, + storeRevision: 2, + }); + }, + ); + + it("keeps both source and occupied target intact when promotion conflicts", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState( + put(local, "target", "target-v1", null, "target text"), + ); + const beforeSource = entry(store), + beforeTarget = entry(store, local); + expect(store.updatePersistedAtomState(promotion())).toMatchObject({ + outcome: "conflict", + reason: "target_conflict", + storeRevision: 2, + }); + expect(entry(store)).toEqual(beforeSource); + expect(entry(store, local)).toEqual(beforeTarget); + }); + + it("does not overwrite a target even if the caller supplies its expected identity", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState(put(local, "target", "target-v1")); + expect( + store.updatePersistedAtomState({ + ...promotion(), + expectedTarget: { draftId: "target", scopeVersion: "target-v1" }, + }), + ).toMatchObject({ outcome: "conflict", reason: "target_conflict" }); + expect(entry(store)).toMatchObject(identity); + expect(entry(store, local)).toMatchObject({ draftId: "target" }); + }); + + it("adds canonical thread metadata after promotion, preserving a reused Home binding", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState(promotion()); + store.updatePersistedAtomState( + put(home, "new-home", "new-version", null, "new Home"), + ); + expect( + store.updatePersistedAtomState({ + ...promotion(), + threadId: "canonical-thread-1", + }), + ).toMatchObject({ outcome: "applied", changed: true }); + expect(entry(store, local)).toMatchObject({ + ...identity, + sessionId: "session-1", + threadId: "canonical-thread-1", + }); + expect(entry(store)).toMatchObject({ + draftId: "new-home", + draft: { text: "new Home" }, + }); + expect( + store.updatePersistedAtomState({ + ...promotion(local, local), + expectedTarget: identity, + threadId: "other-thread", + }), + ).toMatchObject({ outcome: "conflict", reason: "identity_mismatch" }); + }); + + it("rejects a promotion with mismatched session metadata or stale source version", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + expect( + store.updatePersistedAtomState({ + ...promotion(), + sessionId: "canonical-thread", + }), + ).toMatchObject({ outcome: "conflict", reason: "identity_mismatch" }); + expect( + store.updatePersistedAtomState({ + ...promotion(), + identity: { ...identity, scopeVersion: "wrong" }, + }), + ).toMatchObject({ outcome: "conflict", reason: "version_conflict" }); + expect(entry(store)).toMatchObject(identity); + expect(entry(store, local)).toBeNull(); + }); + + it("consumes the exact promoted token, releases its binding and drops all payload", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState(promotion()); + const result = store.updatePersistedAtomState({ op: "consume", identity }); + expect(result).toMatchObject({ + outcome: "applied", + changed: true, + entry: { ...identity, state: "consumed", consumption: "accepted" }, + }); + expect(result.entry).not.toHaveProperty("draft"); + expect(entry(store, local)).toBeNull(); + expect( + store.updatePersistedAtomState({ op: "consume", identity }), + ).toMatchObject({ + outcome: "applied", + changed: false, + storeRevision: 3, + }); + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "conflict", + reason: "retired_draft", + storeRevision: 3, + }); + expect( + store.updatePersistedAtomState( + put(local, "d1", "late", "v1", "late write"), + ), + ).toMatchObject({ outcome: "conflict", reason: "retired_draft" }); + }); + + it("accepted/clear of an old version leaves newer same-scope edits untouched", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState( + put(home, "d1", "v2", "v1", "edited after send"), + ); + for (const op of ["consume", "clear"]) + expect(store.updatePersistedAtomState({ op, identity })).toMatchObject({ + outcome: "conflict", + changed: false, + reason: "version_conflict", + }); + expect(entry(store)).toMatchObject({ + scopeVersion: "v2", + draft: { text: "edited after send" }, + }); + }); + + it("old accepted replay cannot clear a newly reused Home draft id", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState({ op: "consume", identity }); + store.updatePersistedAtomState( + put(home, "new-home", "new-version", null, "next draft"), + ); + expect( + store.updatePersistedAtomState({ op: "consume", identity }), + ).toMatchObject({ + outcome: "applied", + changed: false, + }); + expect(entry(store)).toMatchObject({ + draftId: "new-home", + draft: { text: "next draft" }, + }); + }); + + it("explicit clear creates the same non-resurrectable minimal tombstone", async () => { + const { store } = await createStore(); + store.updatePersistedAtomState(put()); + expect( + store.updatePersistedAtomState({ op: "clear", identity }), + ).toMatchObject({ + outcome: "applied", + entry: { state: "consumed", consumption: "clear" }, + }); + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "conflict", + reason: "retired_draft", + }); + }); + + it("does not evict tombstones when the entry budget is full", async () => { + const { store } = await createStore({ + limits: { ...limits, maxEntries: 1 }, + }); + store.updatePersistedAtomState(put()); + store.updatePersistedAtomState({ op: "consume", identity }); + expect( + store.updatePersistedAtomState(put(home, "new-home", "new-version")), + ).toMatchObject({ + outcome: "invalid", + reason: "capacity_exceeded", + storeRevision: 2, + }); + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "conflict", + reason: "retired_draft", + }); + expect( + store.updatePersistedAtomState({ op: "consume", identity }), + ).toMatchObject({ + outcome: "applied", + changed: false, + }); + }); + + it("enforces draft UTF8 and aggregate file budgets without changing an old draft", async () => { + const { store } = await createStore({ + limits: { ...limits, maxDraftBytes: 800, maxFileBytes: 1_500 }, + }); + expect( + store.updatePersistedAtomState(put(home, "d1", "v1", null, "x")), + ).toMatchObject({ + outcome: "applied", + }); + const before = entry(store); + expect( + store.updatePersistedAtomState( + put(home, "d1", "v2", "v1", "中文".repeat(200)), + ), + ).toMatchObject({ outcome: "invalid", reason: "capacity_exceeded" }); + expect(entry(store)).toEqual(before); + expect( + store.updatePersistedAtomState( + put(local, "d2", "v-other", null, "中文".repeat(25)), + ), + ).toMatchObject({ outcome: "invalid", reason: "capacity_exceeded" }); + expect(entry(store, local)).toBeNull(); + expect(entry(store)).toEqual(before); + }); + + it("input/read/result objects cannot mutate the Main-owned record", async () => { + const { store } = await createStore(); + const input = put(), + result = store.updatePersistedAtomState(input); + if (input.op === "put") + (input.draft as { text: string }).text = "external mutation"; + const read = entry(store); + if (read?.state === "draft") + (read.draft as { text: string }).text = "read mutation"; + if (result.entry?.state === "draft") + (result.entry.draft as { text: string }).text = "result mutation"; + expect(entry(store)).toMatchObject({ draft: { text: "rich 中文😀" } }); + }); + + it("notification failures cannot roll back a committed record and events contain no draft", async () => { + const { store } = await createStore(), + events: PersistedAtomMetadata[] = []; + store.subscribe(() => { + throw new Error("listener failure"); + }); + store.subscribe((event) => events.push(event)); + expect(store.updatePersistedAtomState(put())).toMatchObject({ + outcome: "applied", + }); + expect(events).toHaveLength(1); + expect(events[0]).not.toHaveProperty("entry"); + expect(events[0]).not.toHaveProperty("draft"); + expect(JSON.stringify(events)).not.toContain("rich 中文"); + }); + + it("invalid/accessor requests have no side effects or getter execution", async () => { + const { store } = await createStore(); + let called = false; + const request = Object.defineProperty({}, "op", { + enumerable: true, + get() { + called = true; + return "consume"; + }, + }); + expect(store.updatePersistedAtomState(request)).toMatchObject({ + outcome: "invalid", + changed: false, + storeRevision: 0, + }); + expect(called).toBe(false); + expect( + store.updatePersistedAtomState({ op: "replace", data: {} }), + ).toMatchObject({ + outcome: "invalid", + storeRevision: 0, + }); + }); +}); diff --git a/electron/persistedAtomStore.ts b/electron/persistedAtomStore.ts new file mode 100644 index 000000000..b29eb0464 --- /dev/null +++ b/electron/persistedAtomStore.ts @@ -0,0 +1,487 @@ +import { + PERSISTED_ATOM_LIMITS, + parsePersistedAtomReadQuery, + type PersistedAtomLimits, +} from "../src/lib/api/persistedAtomProtocol"; +import { Buffer } from "node:buffer"; +import type { + DraftIdentity, + PersistedAtomEnvelope, + PersistedAtomMetadata, + PersistedAtomMutation, + PersistedAtomPersistence, + PersistedAtomReadResult, + PersistedAtomReadQuery, + PersistedAtomReason, + PersistedAtomUpdateResult, + PersistedDraftEntry, +} from "../src/lib/api/persistedAtomTypes"; +import { + PersistedAtomFile, + PersistedAtomFileError, + type PersistedAtomFileOptions, +} from "./persistedAtomFile"; +import { + AtomValidationError, + COMPOSER_ATOM_KEY, + parseMutation, + projectWorkspaceDraftIndex, + scopeKey, +} from "./persistedAtomRecords"; + +interface PersistedAtomStoreOptions extends PersistedAtomFileOptions { + readonly limits?: PersistedAtomLimits; + readonly now?: () => number; +} + +type MutationPlan = { + entry: PersistedDraftEntry; + detach: readonly string[]; + bind?: string; +}; + +function identityMatches( + entry: PersistedDraftEntry | undefined, + identity: DraftIdentity, +): boolean { + return ( + entry?.draftId === identity.draftId && + entry.scopeVersion === identity.scopeVersion + ); +} + +function detached(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +export class PersistedAtomStore { + readonly ready: Promise; + readonly #file: PersistedAtomFile; + readonly #limits: PersistedAtomLimits; + readonly #now: () => number; + readonly #entries = new Map(); + readonly #bindings = new Map(); + readonly #listeners = new Set<(metadata: PersistedAtomMetadata) => void>(); + #storeRevision = 0; + #persistence: PersistedAtomPersistence = { + state: "loading", + savedRevision: null, + }; + #available = false; + #closed = false; + #repairPending = false; + #timer: ReturnType | undefined; + #diskQueue: Promise = Promise.resolve(); + + constructor(appDataRoot: string, options: PersistedAtomStoreOptions = {}) { + this.#limits = Object.freeze({ + ...(options.limits ?? PERSISTED_ATOM_LIMITS), + }); + if ( + Object.values(this.#limits).some( + (limit) => !Number.isSafeInteger(limit) || limit <= 0, + ) + ) + throw new Error("invalid persisted atom budget"); + this.#now = options.now ?? Date.now; + this.#file = new PersistedAtomFile(appDataRoot, this.#limits, options); + this.ready = this.#load(); + } + + metadata(): PersistedAtomMetadata { + return { + key: COMPOSER_ATOM_KEY, + storeRevision: this.#storeRevision, + persistence: detached(this.#persistence), + }; + } + + subscribe(listener: (metadata: PersistedAtomMetadata) => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + + sendPersistedAtomState(query: unknown): PersistedAtomReadResult { + let parsed: PersistedAtomReadQuery; + try { + parsed = parsePersistedAtomReadQuery(query); + } catch { + return { + ...this.metadata(), + outcome: "invalid", + reason: "invalid_request", + data: null, + }; + } + if (!this.#available || this.#closed) + return { + ...this.metadata(), + outcome: "unavailable", + ...(this.#closed ? { reason: "closed" as const } : {}), + data: null, + }; + if (parsed.kind === "workspace-index") + return { + ...this.metadata(), + outcome: "ready", + data: { + queryKind: parsed.kind, + workspaceId: parsed.workspaceId, + items: projectWorkspaceDraftIndex( + this.#bindings, + this.#entries, + parsed.workspaceId, + ), + }, + }; + const id = this.#bindings.get(scopeKey(parsed.scope)); + return { + ...this.metadata(), + outcome: "ready", + data: { + queryKind: parsed.kind, + view: { + scope: parsed.scope, + entry: id ? detached(this.#entries.get(id) ?? null) : null, + }, + }, + }; + } + + /** All CAS checks and Map commits are synchronous; disk IO is separate. */ + updatePersistedAtomState(value: unknown): PersistedAtomUpdateResult { + if (!this.#available || this.#closed) + return this.#result( + "unavailable", + null, + this.#closed ? "closed" : undefined, + ); + let mutation: PersistedAtomMutation; + try { + mutation = parseMutation(value, this.#limits); + } catch (error) { + return this.#result( + "invalid", + null, + error instanceof AtomValidationError ? error.reason : "invalid_request", + ); + } + const id = + mutation.op === "put" ? mutation.draftId : mutation.identity.draftId; + const current = this.#entries.get(id); + const plan = + mutation.op === "put" + ? this.#put(mutation, current) + : mutation.op === "promote" + ? this.#promote(mutation, current) + : this.#consume(mutation, current); + if ("outcome" in plan) return plan; + if (!this.#fits(plan)) + return this.#result("invalid", current ?? null, "capacity_exceeded"); + if (this.#storeRevision === Number.MAX_SAFE_INTEGER) + return this.#result("invalid", current ?? null, "capacity_exceeded"); + for (const key of plan.detach) this.#bindings.delete(key); + if (plan.bind) this.#bindings.set(plan.bind, plan.entry.draftId); + this.#entries.set(plan.entry.draftId, plan.entry); + this.#storeRevision += 1; + this.#persistence = { + state: "pending", + savedRevision: this.#persistence.savedRevision, + }; + this.#broadcastPersistedAtomUpdate(); + this.#schedule(); + return this.#result("applied", plan.entry, undefined, true); + } + + flush(): Promise { + if (this.#timer) clearTimeout(this.#timer); + this.#timer = undefined; + if (!this.#available) return this.#diskQueue; + const snapshot = this.#snapshot(); + this.#diskQueue = this.#diskQueue.then(async () => { + if ( + !this.#repairPending && + this.#persistence.savedRevision !== null && + this.#persistence.savedRevision >= snapshot.storeRevision + ) + return; + try { + await this.#file.save(snapshot); + this.#repairPending = false; + this.#persistence = { + state: + snapshot.storeRevision === this.#storeRevision + ? "saved" + : "pending", + savedRevision: snapshot.storeRevision, + }; + } catch (error) { + this.#persistence = { + state: "error", + savedRevision: this.#persistence.savedRevision, + diagnostic: + error instanceof PersistedAtomFileError + ? error.diagnostic + : { phase: "serialize", code: "INVALID_JSON" }, + }; + } + this.#broadcastPersistedAtomUpdate(); + }); + return this.#diskQueue; + } + + /** Best effort only. No Quit handshake or sender waits on this operation. */ + dispose(): void { + this.#closed = true; + this.#listeners.clear(); + void this.flush(); + } + + #put( + mutation: Extract, + current: PersistedDraftEntry | undefined, + ): MutationPlan | PersistedAtomUpdateResult { + const key = scopeKey(mutation.scope); + const bound = this.#bindings.get(key); + if (current?.state === "consumed") + return this.#result("conflict", current, "retired_draft"); + if (mutation.expectedVersion === null) { + if (current || bound) + return this.#result("conflict", current ?? null, "identity_mismatch"); + } else { + if (!current || bound !== mutation.draftId) + return this.#result("conflict", current ?? null, "identity_mismatch"); + if (current.scopeVersion !== mutation.expectedVersion) + return this.#result("conflict", current, "version_conflict"); + } + return { + entry: { + draftId: mutation.draftId, + scopeVersion: mutation.nextVersion, + originScopeKey: current?.originScopeKey ?? key, + updatedAt: this.#now(), + ...(current?.sessionId + ? { sessionId: current.sessionId } + : mutation.scope.kind === "local" + ? { sessionId: mutation.scope.id } + : {}), + ...(current?.threadId ? { threadId: current.threadId } : {}), + state: "draft", + draft: mutation.draft, + }, + detach: [], + bind: key, + }; + } + + #promote( + mutation: Extract, + current: PersistedDraftEntry | undefined, + ): MutationPlan | PersistedAtomUpdateResult { + if ( + !identityMatches(current, mutation.identity) || + current?.state !== "draft" + ) + return this.#result("conflict", current ?? null, "version_conflict"); + const source = scopeKey(mutation.source), + target = scopeKey(mutation.target); + const sourceId = this.#bindings.get(source), + targetId = this.#bindings.get(target); + const targetEntry = targetId ? this.#entries.get(targetId) : undefined; + if (targetId && targetId !== current.draftId) + return this.#result("conflict", current, "target_conflict"); + if ( + mutation.expectedTarget && + !identityMatches(targetEntry, mutation.expectedTarget) + ) + return this.#result("conflict", current, "target_conflict"); + if (sourceId !== current.draftId && targetId !== current.draftId) { + return this.#result("conflict", current, "identity_mismatch"); + } + const sessionId = + mutation.sessionId ?? + (mutation.target.kind === "local" + ? mutation.target.id + : current.sessionId); + if ( + (mutation.target.kind === "local" && sessionId !== mutation.target.id) || + (current.sessionId && sessionId !== current.sessionId) || + (current.threadId && + mutation.threadId && + current.threadId !== mutation.threadId) + ) + return this.#result("conflict", current, "identity_mismatch"); + const threadId = mutation.threadId ?? current.threadId; + if ( + targetId === current.draftId && + current.sessionId === sessionId && + current.threadId === threadId + ) + return this.#result("applied", current); + return { + entry: { + ...current, + ...(sessionId ? { sessionId } : {}), + ...(threadId ? { threadId } : {}), + updatedAt: this.#now(), + }, + detach: source === target || sourceId !== current.draftId ? [] : [source], + bind: target, + }; + } + + #consume( + mutation: Extract, + current: PersistedDraftEntry | undefined, + ): MutationPlan | PersistedAtomUpdateResult { + if (!identityMatches(current, mutation.identity)) + return this.#result("conflict", current ?? null, "version_conflict"); + if (current?.state === "consumed") return this.#result("applied", current); + if (!current) return this.#result("conflict", null, "identity_mismatch"); + const { draft: _draft, ...metadata } = current; + return { + entry: { + ...metadata, + state: "consumed", + consumption: mutation.op === "consume" ? "accepted" : "clear", + consumedAt: this.#now(), + updatedAt: this.#now(), + }, + detach: [...this.#bindings] + .filter(([, id]) => id === current.draftId) + .map(([key]) => key), + }; + } + + #fits(plan: MutationPlan): boolean { + const snapshot = this.#snapshot(); + const atom = snapshot.atoms[COMPOSER_ATOM_KEY]; + const bindings = { ...atom.bindings }, + entries = { ...atom.entries, [plan.entry.draftId]: plan.entry }; + for (const key of plan.detach) delete bindings[key]; + if (plan.bind) bindings[plan.bind] = plan.entry.draftId; + if ( + Object.keys(entries).length > this.#limits.maxEntries || + Object.keys(bindings).length > this.#limits.maxBindings + ) + return false; + try { + const reservedEntries = Object.fromEntries( + Object.entries(entries).map(([id, entry]) => { + if (entry.state === "consumed") return [id, entry]; + const { draft: _draft, ...metadata } = entry; + const tombstone: PersistedDraftEntry = { + ...metadata, + state: "consumed", + consumption: "accepted", + updatedAt: Number.MAX_SAFE_INTEGER, + consumedAt: Number.MAX_SAFE_INTEGER, + }; + return [ + id, + Buffer.byteLength(JSON.stringify(tombstone), "utf8") > + Buffer.byteLength(JSON.stringify(entry), "utf8") + ? tombstone + : entry, + ]; + }), + ); + return ( + Buffer.byteLength( + JSON.stringify({ + ...snapshot, + storeRevision: Number.MAX_SAFE_INTEGER, + atoms: { + [COMPOSER_ATOM_KEY]: { bindings, entries: reservedEntries }, + }, + }), + "utf8", + ) <= this.#limits.maxFileBytes + ); + } catch { + return false; + } + } + + #result( + outcome: PersistedAtomUpdateResult["outcome"], + entry: PersistedDraftEntry | null, + reason?: PersistedAtomReason, + changed = false, + ): PersistedAtomUpdateResult { + return { + ...this.metadata(), + outcome, + changed, + ...(reason ? { reason } : {}), + entry: entry ? detached(entry) : null, + }; + } + + #snapshot(): PersistedAtomEnvelope { + return { + format: "electron-persisted-atom-state", + version: 1, + storeRevision: this.#storeRevision, + atoms: { + [COMPOSER_ATOM_KEY]: { + bindings: Object.fromEntries(this.#bindings), + entries: Object.fromEntries(this.#entries), + }, + }, + }; + } + + async #load(): Promise { + try { + const { envelope, recovered } = await this.#file.load(); + if (envelope) { + const atom = envelope.atoms[COMPOSER_ATOM_KEY]; + for (const [id, entry] of Object.entries(atom.entries)) + this.#entries.set(id, entry); + for (const [key, id] of Object.entries(atom.bindings)) + this.#bindings.set(key, id); + this.#storeRevision = envelope.storeRevision; + } + this.#available = true; + this.#repairPending = recovered; + this.#persistence = { + state: recovered ? "pending" : "saved", + savedRevision: this.#storeRevision, + }; + if (recovered && !this.#closed) this.#schedule(); + } catch (error) { + this.#persistence = { + state: "unavailable", + savedRevision: null, + diagnostic: + error instanceof PersistedAtomFileError + ? error.diagnostic + : { phase: "load", code: "INVALID_STATE" }, + }; + } + this.#broadcastPersistedAtomUpdate(); + } + + #schedule(): void { + if (this.#closed) return; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = setTimeout(() => { + this.#timer = undefined; + void this.flush(); + }, 500); + this.#timer.unref?.(); + } + + #broadcastPersistedAtomUpdate(): void { + for (const listener of this.#listeners) { + try { + listener(this.metadata()); + } catch { + /* Notification failure cannot roll back a committed atom. */ + } + } + } +} diff --git a/electron/preload.test.ts b/electron/preload.test.ts index 0b6488d5d..27b9b1c86 100644 --- a/electron/preload.test.ts +++ b/electron/preload.test.ts @@ -95,6 +95,98 @@ describe("electron/preload", () => { exposedGlobals.clear(); }); + it("Host persistence commands cross the same invoke boundary", async () => { + const api = await loadPreloadApi(); + expect(api.supportsCommand("persisted_atom_read")).toBe(true); + expect(api.supportsCommand("persisted_atom_update")).toBe(true); + const args = { + key: "composer-drafts-v1", + query: { + kind: "scope", + scope: { workspaceId: null, kind: "home", id: "new-conversation" }, + }, + }; + const result = { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + outcome: "ready", + data: { + queryKind: "scope", + view: { scope: args.query.scope, entry: null }, + }, + }; + ipcInvoke.mockResolvedValueOnce({ ok: true, result }); + await expect(api.invoke("persisted_atom_read", args)).resolves.toEqual( + result, + ); + expect(ipcInvoke).toHaveBeenCalledWith( + IPC_INVOKE_CHANNEL, + "persisted_atom_read", + args, + ); + }); + + it.each(["workspace", null])( + "workspace-index query and ready data cross the same public IPC", + async (workspaceId) => { + const api = await loadPreloadApi(); + const args = { + key: "composer-drafts-v1", + query: { kind: "workspace-index", workspaceId }, + }; + const result = { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + outcome: "ready", + data: { queryKind: "workspace-index", workspaceId, items: [] }, + }; + ipcInvoke.mockResolvedValueOnce({ ok: true, result }); + await expect(api.invoke("persisted_atom_read", args)).resolves.toEqual( + result, + ); + expect(ipcInvoke).toHaveBeenCalledExactlyOnceWith( + IPC_INVOKE_CHANNEL, + "persisted_atom_read", + args, + ); + }, + ); + + it("preload rejects renderer persistence notifications before sending IPC", async () => { + const api = await loadPreloadApi(); + await expect( + api.emit("persisted-atom-update", { state: "saved" }), + ).rejects.toThrow("Renderer event is not allowed"); + expect(() => api.send("persisted-atom-update", { state: "saved" })).toThrow( + "Renderer event is not allowed", + ); + expect(ipcSend).not.toHaveBeenCalled(); + }); + + it("real Main persistence notifications remain observable and removable", async () => { + const api = await loadPreloadApi(); + const handler = vi.fn(); + const stop = api.listen("persisted-atom-update", handler); + const listener = ipcOn.mock.calls[0][1]; + const payload = { + key: "composer-drafts-v1", + storeRevision: 1, + persistence: { state: "saved", savedRevision: 1 }, + }; + listener({}, { event: "persisted-atom-update", payload }); + expect(handler).toHaveBeenCalledExactlyOnceWith({ + event: "persisted-atom-update", + payload, + }); + stop(); + expect(ipcRemoveListener).toHaveBeenCalledWith( + "evt:persisted-atom-update", + listener, + ); + }); + it("暴露真实 Electron bridge 标记并用 host command 白名单判断支持命令", async () => { const api = await loadPreloadApi(); diff --git a/electron/preload.ts b/electron/preload.ts index 80287e86d..35d4c6cc6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,3 +1,4 @@ +import { canBroadcastRendererEvent } from "./rendererEventPolicy"; import { contextBridge, ipcRenderer } from "electron"; import { ELECTRON_HOST_COMMANDS, @@ -62,9 +63,13 @@ contextBridge.exposeInMainWorld("electronAPI", { }; }, emit: async (event: string, payload?: unknown) => { + if (!canBroadcastRendererEvent(event)) + throw new Error("Renderer event is not allowed"); ipcRenderer.send(IPC_EMIT_CHANNEL, event, payload); }, send: (event: string, payload?: unknown) => { + if (!canBroadcastRendererEvent(event)) + throw new Error("Renderer event is not allowed"); ipcRenderer.send(IPC_EMIT_CHANNEL, event, payload); }, convertFileSrc: (filePath: string, protocol = "file") => { @@ -77,8 +82,10 @@ contextBridge.exposeInMainWorld("electronAPI", { return `${protocol}://${encodeURIComponent(filePath)}`; }, dialog: { - open: (options?: unknown) => ipcRenderer.invoke(IPC_DIALOG_OPEN_CHANNEL, options), - save: (options?: unknown) => ipcRenderer.invoke(IPC_DIALOG_SAVE_CHANNEL, options), + open: (options?: unknown) => + ipcRenderer.invoke(IPC_DIALOG_OPEN_CHANNEL, options), + save: (options?: unknown) => + ipcRenderer.invoke(IPC_DIALOG_SAVE_CHANNEL, options), }, shell: { open: (target: string, openWith?: string) => @@ -154,6 +161,9 @@ function normalizeEvent(event: string, payload: unknown): unknown { return { event, payload }; } -function invokeWindow(command: string, ...args: unknown[]): Promise { +function invokeWindow( + command: string, + ...args: unknown[] +): Promise { return ipcRenderer.invoke(IPC_WINDOW_COMMAND_CHANNEL, command, ...args); } diff --git a/electron/rendererEventPolicy.test.ts b/electron/rendererEventPolicy.test.ts new file mode 100644 index 000000000..629a7cbfe --- /dev/null +++ b/electron/rendererEventPolicy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { IPC_EMIT_CHANNEL } from "./ipcChannels"; +import { + canBroadcastRendererEvent, + registerRendererEventHandler, +} from "./rendererEventPolicy"; + +describe("renderer event broadcast authority", () => { + it("allows existing renderer events and rejects Main persistence notifications", () => { + expect(canBroadcastRendererEvent("config-changed")).toBe(true); + expect(canBroadcastRendererEvent("persisted-atom-update")).toBe(false); + }); + + it("does not coerce malformed IPC names or execute their getters", () => { + const coerce = vi.fn(() => "config-changed"); + for (const value of [null, undefined, 42, [], { toString: coerce }]) + expect(canBroadcastRendererEvent(value)).toBe(false); + expect(coerce).not.toHaveBeenCalled(); + }); + + it("rejects forged saved acknowledgements at the Main IPC handler", () => { + const on = vi.fn(); + const broadcast = vi.fn(); + registerRendererEventHandler({ on }, broadcast); + expect(on).toHaveBeenCalledWith(IPC_EMIT_CHANNEL, expect.any(Function)); + const handler = on.mock.calls[0][1]; + handler({ sender: { id: 42 } }, "persisted-atom-update", { + persistence: { state: "saved" }, + }); + handler({ sender: { id: 42 } }, null, "malformed"); + expect(broadcast).not.toHaveBeenCalled(); + handler({ sender: { id: 42 } }, "config-changed", { theme: "dark" }); + expect(broadcast).toHaveBeenCalledExactlyOnceWith("config-changed", { + theme: "dark", + }); + }); +}); diff --git a/electron/rendererEventPolicy.ts b/electron/rendererEventPolicy.ts new file mode 100644 index 000000000..aebcd23fd --- /dev/null +++ b/electron/rendererEventPolicy.ts @@ -0,0 +1,21 @@ +import { IPC_EMIT_CHANNEL } from "./ipcChannels"; + +/** Persisted-state acknowledgements are emitted only by their Main owner. */ +export function canBroadcastRendererEvent(event: unknown): event is string { + return typeof event === "string" && event !== "persisted-atom-update"; +} + +type RendererEventListener = ( + sender: unknown, + event: unknown, + payload?: unknown, +) => void; + +export function registerRendererEventHandler( + ipc: { on(channel: string, listener: RendererEventListener): unknown }, + broadcast: (event: string, payload?: unknown) => void, +): void { + ipc.on(IPC_EMIT_CHANNEL, (_sender, event, payload) => { + if (canBroadcastRendererEvent(event)) broadcast(event, payload); + }); +} diff --git a/eslint.config.js b/eslint.config.js index 34a1402af..54504be90 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -473,6 +473,14 @@ const agentRuntimeCommandSelectors = [ "agent runtime 命令只允许集中放在 `src/lib/api/agentRuntime` 网关目录;旧 agent_/agent_ 命令已废弃,禁止在业务模块直接扩散。", })); +const persistedAtomGatewayCommandSelectors = ( + agentCommandCatalog.persistedAtomCommands ?? [] +).map((command) => ({ + selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, + message: + "桌面草稿持久化只允许通过 src/lib/api/persistedAtoms.ts 网关,禁止在业务层散落裸 invoke。", +})); + const projectGatewayCommandSelectors = [ "workspace_create", "workspace_get_projects_root", @@ -1028,6 +1036,7 @@ export default [ "error", ...generalChatCompatCommandSelectors, ...agentRuntimeCommandSelectors, + ...persistedAtomGatewayCommandSelectors, ...projectGatewayCommandSelectors, ...materialGatewayCommandSelectors, ...templateGatewayCommandSelectors, @@ -1139,6 +1148,7 @@ export default [ "src/lib/api/serverTools.ts", "src/lib/api/voiceTools.ts", "src/lib/api/appServerTransport.ts", + "src/lib/api/persistedAtoms.ts", ], rules: { "no-restricted-syntax": "off", @@ -1148,6 +1158,7 @@ export default [ files: [ "src/lib/desktop-host/core.test.ts", "src/lib/dev-bridge/safeInvoke.test.ts", + "src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts", ], rules: { "no-restricted-syntax": "off", diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md index c704b25ed..1d36e2c1c 100644 --- a/internal/aiprompts/architecture.md +++ b/internal/aiprompts/architecture.md @@ -1128,6 +1128,92 @@ Responsible developer confirmation: root, 2026-09-01. Window orchestration owner 2026-09-01. Native helper protocol/readiness owner/data flow updated: root, 2026-09-02. Apple Events authorization owner/data flow updated: root, 2026-09-01. +### 4.3 Desktop persisted atom + +未发送的桌面 UI 草稿保存归 `PersistedAtomHost` / `PersistedAtomStore` / `PersistedAtomFile`,固定使用统一 `AppDataRoot/host-state.json`。它不保存 canonical transcript、Thread/Turn/Item、队列或 runtime 状态;业务持久化仍由 App Server 与 thread-store 承接。 + +```mermaid +flowchart LR + Gateway[Renderer typed persistedAtoms gateway] --> Bridge[Current safeInvoke / preload IPC] + Bridge --> Main[Main: lifecycle and delegation] + Main --> Host[PersistedAtomHost] + Host --> Store[PersistedAtomStore: synchronous CAS and bindings] + Store --> File[PersistedAtomFile: serialized IO and atomic rename] + File --> Disk[AppDataRoot / host-state.json and .bak] + Store --> Metadata[Revision and persistence metadata] + Metadata --> Host + Host --> Event[Main persisted-atom-update notification] + Event --> Gateway +``` + +`persisted_atom_read` / `persisted_atom_update` 只经 `src/lib/api/persistedAtoms.ts` 的唯一 typed gateway。types、JSON transport parser 与预算归 `src/lib/api/persistedAtomTypes.ts` / `persistedAtomProtocol.ts`;Main 不引入 PM/React。Host 方法名对齐可确认的 `sendPersistedAtomState`、`updatePersistedAtomState` 与 `broadcastPersistedAtomUpdate`,新增类名是 Lime 的领域命名,不冒充安装包原开发源码名称。 + +唯一 atom 为 `composer-drafts-v1`。put/promote/consume/clear 在同一 Store 中执行 draftId + opaque scopeVersion CAS;promotion 不改变版本,consume/clear 保留无正文 tombstone,拒绝迟到 put。record/binding 预算各为10,000,单 draft128MiB、完整UTF8 JSON256MiB,整次拒绝保旧,不截断或自动驱逐。应用 revision 与 savedRevision 分开,500ms 合并进入串行落盘;主文件原子替换后再更新 backup,primary 成功而 backup 失败时可能是新 primary / 旧 backup。无效文件恢复失败保持 unavailable,不覆盖为空。 + +`rendererEventPolicy` 是 Main handler 与 preload 的同一事件策略,禁止 Renderer 伪造 `persisted-atom-update`。current safeInvoke/HTTP 诊断复用 `invokeDiagnostics`,草稿命令只记录固定 key/op/error 分类,不回显正文、media、scope 或原始错误。原始 transport 错误仍向调用者传播;native notification 订阅失败显式失败。 + +Main 只负责组装、命令委托与 dispose;不在大文件添加保存、CAS 或 schema 业务。退出 best effort,不建立 Quit 握手或发送 checkpoint。当前 read 使用唯一 query/data 判别合同:scope 查询或 workspace-index 查询。索引仅投影当前 live binding 的 identity/scope/updatedAt/sessionId?/threadId?,workspace 精确匹配(含null)、updatedAt降序与canonical scope codepoint升序,不返回正文或media,不推进revision,不截断10,000预算内结果。实际编辑目标按scope重读并继续同一CAS。旧scope-only/top-level view已删除,gateway/fixture/preload/tests/guard成套迁移。公开 IPC 的正常重启证据只证明原生边界;GUI 富草稿恢复与 acceptance 的验证在 current 执行计划独立记录。 + +Architecture impact: major; Desktop UI persistence owner and IPC boundary added. Architecture diagram confirmation: root, 2026-10-14; Host/gateway/parser/file/event/diagnostic ownership and separation from the canonical App Server chain checked. Product verification remains recorded in the current execution plan. + +Renderer 未发正文的唯一 owner 是 `ComposerController`;`ComposerDraftCoordinator` 只协调 scope、250ms 发布队列、CAS 与接受后消费,codec 复用原 schema/selection/resources。唯一 React owner `useWorkspaceComposerDraftRuntime` 创建/解绑 coordinator,并在目标实际提交后调用 `bindCommitted`。公开编辑目标只有稳定 home/new-conversation 与 canonical local/session,workspace 精确匹配(含 null);原生 metadata index 没有第二个 renderer 草稿发现或标签 owner。编辑门禁同时要求真实 binding ready 和可见目标与绑定 scope 完全一致;首次读取被替代、失败或模式不匹配不能靠 await/finally 开放旧正文。 + +```mermaid +flowchart LR + Surface[Home / Inputbar] --> Controller[ComposerController] + Controller --> Coordinator[ComposerDraftCoordinator] + Navigation[Committed Home / canonical session target] --> ReactOwner[useWorkspaceComposerDraftRuntime] + ReactOwner --> Coordinator + Coordinator --> Gateway[Typed persistedAtoms gateway] + Gateway --> Host[PersistedAtomHost / Store / File] + Surface --> Send[Existing send / queue owners] + Send --> AppServer[Canonical App Server JSON-RPC] + AppServer --> Receipt[RuntimeSubmissionResult and captured acceptance] + Receipt --> Promotion[Captured source promotion] + Promotion --> Coordinator +``` + +发送捕获原 receipt、draftId/version 与 scope,业务请求不等待保存。仅 `runtime-accepted` / `queue-accepted` 触发 promotion 后消费;`local-handled` / `rejected` / `unknown` 保留草稿。旧接受可消费旧存储,但可见 Controller 清理必须仍匹配 generation、scope 和 revision。导航前仅发布旧 scope;成功并匹配启动时 UI epoch 与真实 session/workspace 后提交新目标。同次 topic hydration 的私有 onHydrated 生命周期在 source-current 检查后、自身 scope 提交前更新 presentation,不用 epoch 加一豁免。opaque restore token 只在 renderer 进程内透传,不进入协议、日志、metric 或 transcript。 + +责任 root 于 2026-10-15 确认上述更新:Desktop 保存仅管理 UI 草稿,App Server 仍唯一拥有 Thread/Turn/Item 与业务接受事实。随机草稿索引、预热与 materialized 首发路径已删除;binding.ready 是唯一就绪事实,不建立第二个 ready authority。正式组合回归、真实 Electron 富草稿冷恢复及默认门禁按 current 执行计划记录,基础 Host receipt 不能替代它们。 + +程序化初始输入(含 Skills 工作台试用)没有 Composer 草稿 settlement;CommandWiring 在既有 sendMessage 收到真实 accepted 后,以捕获的 navigation epoch 提交同一可见 workspace 的 topic,仍由现有 navigation owner 等待真实 session 再绑定。该路径不 consume Home 草稿;Composer 自身发送继续走原 captured identity promotion/consume。非接受、迟到或不可见回执不能越过 ready/scope 门禁。此为既有4.3链路的接线修复,非新增 authority;root,2026-10-15确认。 + +### 4.4 Desktop 设置章节 + +`src/components/settings-v2/sections.tsx` 是 current 设置章节的唯一登记 owner,派生导航、可用性、预加载与内容渲染。公开入口直接导出已确认的 `SettingsPage`;category hook 只做翻译分组,layout 只组装容器和 Suspense。Home 保持同步入口,由 layout 注入 render factory,避免 Home/category/sections 循环依赖。业务页继续拥有各自的编辑与保存逻辑。 + +```mermaid +flowchart LR + Route[Existing PageParams] --> Page[SettingsPage] + Sections[settingsSections] --> Category[Translated navigation groups] + Sections --> Page + Category --> Navigation[SettingsNavigation] + Navigation --> Page + Page --> Business[Existing settings page owners] +``` + +`SettingsPageV2` / `SettingsLayoutV2`、重复章节名单及无消费者 `SETTINGS_GROUPS` / `ExecutionTracker` 已删除。既有 Experimental 只委托 Developer;providerView/focus 与 executionPolicyFocus 保真。内部章节的 canonical 路由、返回上下文、内容搜索与 dirty authority 尚待后续切片,不新增平行导航或业务 runtime。责任 root 于 2026-10-15 确认此图;正式回归和 GUI 状态见 current 执行计划。 + +### 4.5 Desktop 公开新建会话 + +`useAppNavigation.startNewConversation` 是唯一公开动作,typed intent 明确保留 null/project identity 与 Home 恢复或 New fresh 意图。AppPageContent 只在真实 route/request 提交后交付请求;requested preview 不能清稿。root draft runtime 提交稳定 Home scope 与单调 epoch,同一 Coordinator 在有效 hydration 后执行受保护的 identity CAS,只有 applied 才清空 Controller。冲突、不可用、再次导航或新编辑均保稿;fresh 请求不进入 reload restore,避免冷启动重放。 + +```mermaid +flowchart LR + Public[Home / Search New / Project New] --> Action[startNewConversation] + Action --> Route[Existing App navigation commit] + Route --> Root[Root draft runtime and exact scope] + Root --> Coordinator[ComposerDraftCoordinator] + Coordinator --> CAS[Existing persisted atom clear CAS] + CAS -->|applied and unchanged| Controller[Controller clear] + Controller --> Send[Existing typed first submission] + Send --> Receipt[App Server canonical acceptance] + Receipt --> Local[Canonical local scope and source consume] +``` + +cancelable 新建事件、随机 draftTabs/discovery、新页 owner、1ms 输入预热、materialized 首发和 general 第二层 strip 已直接删除;canonical session、Canvas、image workbench 与后台 queue 继续走既有 owner。Home `freshDraft=false`、New `true` 是 Lime 的明确决策;REA 确认公开导出名与静态职责,普通 Codex sidebar 的运行时 fresh 行为仍 unknown。责任 root 于 2026-10-15 确认此图,正式回归和真实 Gate 状态见 current 执行计划。 + ## 5. TypeScript Package 规范 | Package | 职责 | @@ -1202,6 +1288,14 @@ flowchart TD Chat[Conversation ComposerEditor] -->|Transaction| Controller Controller -->|state / revision / snapshot| Home Controller -->|state / revision / snapshot| Chat + Controller --> Registry[CodeBlockNodeViewRegistry] + Registry --> NodeView[CodeBlockNodeView: toolbar container + PM code contentDOM] + Registry --> Portal[React CodeBlockToolbar Portal] + Portal -->|language attrs Transaction| Controller + Controller --> Highlight[PM highlight plugin: DecorationSet] + Highlight --> NodeView + Controller --> Auto[PM Auto plugin: generation + document identity] + Auto -->|no-history attrs Transaction| Controller Controller --> Receipt[Submit receipt: revision + draft + submission] Receipt -->|draft: original JSON + selection + resources| Restore[Restore / local rich history] Restore --> Controller @@ -1215,9 +1309,15 @@ flowchart TD Workspace 的 `useAgentChatWorkspaceLocalDisplayState` 持有同一个 Controller,原 `input` string store 被 snapshot 派生值替换;`setInput` 仅为 text adapter。首页 materialization 保留该 owner 与 revision,显式换会话/new-chat/back-home 使用 clear 重置完整草稿并推进 revision;clear/restore 即使内容相同也强制新生命周期,普通同值 text update 保持无更新。附件 adapter 直接消费 Controller resources,能力选择仅把 UI 候选详情映射到 Controller 的 canonical route,禁止新 surface 的空镜像覆盖原资源。路径侧栏的 `usePathReferences` 同步读写同一 Controller 并从 snapshot 派生,不保留 path useState 或 passive effect 镜像。资源恢复由原始 draft 一次更新;历史回放不再 clear/add 资源导致导航游标失效。 +真实接受沿 `turn/start|turn/steer response -> runtimeAcceptance -> raw send -> typed observer / RuntimeSubmissionResult -> surface settlement` 传播。`RuntimeAcceptanceReceipt` 的 session/thread 在异步前捕获,turn取真实响应;接受observer先于UI发布/统计/refresh结算,后处理失败不能撤销已接受事实。外层只接受 `runtime-accepted` 或真实 `queue-accepted`,`local-handled`、`rejected`、`unknown` 不消费草稿。queue receipt是实际QueuedSubmission与请求前canonical context,不伪造成turn/start或由boolean/void猜测接受。该类型合同属于Renderer既有send owner,未新增AppServer wire字段。Architecture diagram confirmation: root, 2026-10-14; acceptance receipt flow and canonical ownership checked. + `draft` 保留折叠 paste、原始 JSON、link/code 属性、UTF16 选择区、图片、路径和能力供恢复;`submission` 将链接与代码块序列化为 Markdown、展开 paste 并携带 typed TextElement/inputMentions,唯一决定发送内容。代码 fence 长度为正文最长连续反引号数加一且至少三个,正文空白原样保留。literal link 只有在完整安全链接的 label/href/title 均匹配时用于草稿重放;编辑清除旧 literal/occurrence/autolink 元数据。ProseMirror position、显示文本 UTF16 offset 与最终 Markdown 发送文本 UTF8 byteRange 分别映射,不混用。annotation 更新通过 PM Transaction 保留富文档与资源;HTML/Markdown paste 进入同一 Controller,code 中 Enter/ShiftEnter 插入文本换行。`composerDraft`/`composerSubmission` 显式贯通 start/steer/queue;无法映射的 textElements 在 IPC 前拒绝,不能猜 range 或退回纯文本。Controller 不拥有 Thread/Turn/Queue 事实;workspace 依据 canonical read model 解析 intent 并在真实接受结果结束前持有发送锁。失败保留完整 draft;成功只在 receipt revision 未变化时 commit,迟到结果不清新草稿。提交瞬间直接捕获当前 canonical 文档,不把旧 React 闭包的资源回灌;能力初始化与恢复请求按同 Controller identity 去重,跨 surface 重挂载不重放旧请求。 -Queue mutation 进入 Codex v2 `thread/queue/*`,`thread/queue/changed` 或显式 refresh 只触发 canonical queue 重读;Renderer 不建第二 queue facade、乐观删除或 runtime。现有 `@tiptap/pm` 提供 schema/state/view/history/keymap,不新增依赖或 public JSON-RPC 字段。Architecture impact: major; Renderer document/transaction and cross-surface draft owner converged. Architecture diagram updated: Composer flow above. Responsible developer confirmation: root, 2026-10-14; directory, ownership, transaction/submission/restoration flow and protocol boundary checked. Runtime verification remains tracked in the current execution plan. +Queue mutation 进入 Codex v2 `thread/queue/*`,`thread/queue/changed` 或显式 refresh 只触发 canonical queue 重读;Renderer 不建第二 queue facade、乐观删除或 runtime。现有 `@tiptap/pm` 提供 schema/state/view/history/keymap;代码块增加直接依赖 `lowlight 3.3.0`、`highlight.js 11.11.1`、`jsonc-parser 3.3.1`,不新增 public JSON-RPC 字段。Architecture impact: major; Renderer document/transaction and cross-surface draft owner converged. Architecture diagram updated: Composer flow above. Responsible developer confirmation: root, 2026-10-14; directory, ownership, transaction/submission/restoration flow and protocol boundary checked. Runtime verification remains tracked in the current execution plan. + +代码块的唯一视图工厂归 Controller 持有的 `CodeBlockNodeViewRegistry`;Editor 创建 NodeView,React 仅向不可编辑 toolbar container 建 Portal。正文和高亮 span 均由 PM contentDOM/Decoration 管理,不用 `innerHTML` 或第二份 React document 替换正文。`composerState` 只装配 EditorState/plugins 和投影资源,不持有另一个状态 store;文档输入/配置类型统一归 `types`,旧 Controller type export 已直接迁净。有效 rich/code/plain 配置决定 NodeView 与两个独立 plugin 的装配,外部注入 Controller 不被 hook 强加 defaults。 + +高亮按位置和 `Node.eq` 缓存、映射移动块,顶层 HAST scope 转为 UTF16 inline Decoration。raw parser 的 53 grammar/5 alias 与 GUI 41 项目录/21 alias 分开,Auto subset 为 39 项。Auto 延迟150ms、按 Node identity缓存,并在异步结果前复核 generation/同 doc/未销毁;结果写 attrs但不进入 undo history。纯 Decoration/meta 更新 state/View,保留同一 draft snapshot/revision与receipt;真实 attrs/selection/正文改变才更新草稿。clear/restore 同值也创建 fresh plugins,旧 View 异步结果失效;View 卸载注销 entries,但不永久销毁可重挂载的 Controller registry。旧 schema `toDOM` 仅保留 serialization/clipboard 责任;主 GUI 及 fixture 已直接迁到 NodeView DOM,无 compat 视图。Architecture diagram confirmation: root, 2026-10-14; registry、插件、Portal、资源、receipt、生命周期与既有 App Server 边界逐项确认。正式产品验证在 current 计划独立记录。 Composer 纯文本历史只允许沿 `Composer -> typed AppServerClient -> app_server_handle_json_lines -> App Server promptHistory/read|append -> RuntimeCore PromptHistoryStore` 流动;Electron 仅转发 JSON-RPC,Renderer 不得以 `localStorage`、SQLite 或旧 Desktop facade 建立第二事实源。`PromptHistoryStore` 使用受控数据根下的 append-only `prompt_history.jsonl`、文件锁和 4 MiB 保留上限,坏行跳过但保留 offset;macOS/Linux 的 `logId` 使用 inode,Windows 使用文件 creation time,用于分页 cursor 的快照一致性。读取返回 newest-first bounded pages,客户端在 Composer 内按 oldest-first 合并;异步读取完成时只合并服务端快照与本地新提交的边界重叠,不覆盖已产生的 draft/history。只追加成功提交的正文和 canonical Thread identity:公开 append 为 `{threadId,text}`,entry 返回 `threadId`,内部 JSONL 的 `session_id` 字段记录同一个 Thread ID。TUI 选定 Skill 元素可经 `mention_codec` 编码为 path link,再从同一 history owner 恢复;附件、普通路径引用与大粘贴 payload 仍为会话内 rich state,不把正文 codec 冒充完整附件持久化。`promptHistory/read|append` 是稳定 v2 方法,不要求 `initialize.capabilities.experimentalApi`。Architecture impact: major; persistent history owner and cross-platform identity semantics fixed. Responsible developer confirmation: root, 2026-09-01. diff --git a/internal/aiprompts/commands.md b/internal/aiprompts/commands.md index aa266e47f..f02d55188 100644 --- a/internal/aiprompts/commands.md +++ b/internal/aiprompts/commands.md @@ -48,6 +48,25 @@ Electron `safeStorage` 加密后落在统一 `AppDataRoot/credentials`,状态 query、Debug 或日志;safeStorage 不可用时必须 fail closed。该 owner 只是 Cloud transport foundation,不会启用默认远端 App Server,也不替代 App Server JSON-RPC。 +## Desktop persisted atom + +桌面未发送 UI 草稿只允许: + +```text +Renderer -> src/lib/api/persistedAtoms.ts + -> current safeInvoke -> preload / persisted_atom_read|persisted_atom_update + -> Main delegate -> PersistedAtomHost -> PersistedAtomStore -> PersistedAtomFile + -> fixed AppDataRoot/host-state.json +``` + +read 为 `{key:"composer-drafts-v1",query}`,query 判别为 `{kind:"scope",scope}` 或 `{kind:"workspace-index",workspaceId}`;ready 结果分别为 `data:{queryKind:"scope",view}` 或 `data:{queryKind:"workspace-index",workspaceId,items}`,invalid/unavailable 为 data:null。update 为 `{key,mutation}`,仅支持 put/promote/consume/clear。read/update 结果与 `persisted-atom-update` notification 必须经过同一纯 JSON parser。notification 仅含 revision / persistence metadata,无草稿正文;Renderer emit/send 与 Main IPC handler 拒绝伪造该事件。旧 scope-only/top-level view 已直接删除。索引精确匹配 workspace(含null),只发现当前 live binding,返回 scope/identity/updatedAt/sessionId?/threadId?,不读正文、不改变 revision、不截断。选中后重新读scope并执行原CAS。 + +`PersistedAtomStore` 为唯一 CAS/binding owner,`PersistedAtomFile` 为唯一原子保存/backup owner。typed gateway 之外禁止裸 invoke/listen;catalog、preload 白名单、实际 handler、ESLint restriction 与 command guard 同批同步。safeInvoke 与 HTTP 诊断由同一 `invokeDiagnostics` 承接,保存参数和任意原始错误正文不进入 trace/console。 + +该能力属于 Desktop UI 保存,不能承接业务 session、canonical transcript、队列、App Server runtime 或 Provider 请求;不新增 JSON-RPC method、CLI/TUI 草稿后端或生产 mock。Host ready 仅证明加载完成,savedRevision 才证明该 revision 已落盘;发送和退出不增加持久 checkpoint 或等待握手。真实正常重启仅证明 Host/IPC/file 基础能力,GUI 富草稿恢复仍需另验。 + +GUI send 的本地返回统一为 `RuntimeSubmissionResult`:`runtime-accepted` / `queue-accepted` / `local-handled` / `rejected` / `unknown`。start/steer 的接受从真实 response 捕获,queue 从真实 QueuedSubmission 投影;后处理失败不能撤销接受。composer capture、restore token 与导航 epoch 都是 renderer 本地能力,不增加 App Server wire 字段,也不以 void/boolean 或 dispatched 猜测接受。TaskCenter 请求保留原 capture,发送驱动的 materialization 禁止提前 promotion;接受后由唯一 React owner 使用 receipt 的 sessionId/threadId 先 promotion 再 consume。 + ## TUI/CLI 与 Cloud transport 边界 TUI 推理显示只消费同一协议的 summary/summaryIndex;原始 content 默认隐藏。摘要 diff --git a/internal/exec-plans/README.md b/internal/exec-plans/README.md index 59a384ad7..a642f6f20 100644 --- a/internal/exec-plans/README.md +++ b/internal/exec-plans/README.md @@ -28,7 +28,7 @@ - 路线图主线:`internal/roadmap/` - Runtime 当前对齐入口:`internal/aiprompts/architecture.md` 与 `internal/exec-plans/codex-alignment-v1-coordination-plan.md` - Refactor v2 全项目 Gate A/B 验收计划:`internal/exec-plans/project-gate-a-b-acceptance-plan.md` -- 当前发布执行计划:`internal/exec-plans/release-v1.148.0-plan.md` +- 当前发布执行计划:`internal/exec-plans/release-v1.158.0-plan.md` - 已结束版本的发布过程不保留在 active tree;结果以 Git tag、GitHub Release、Release Notes 与 Git history 为准。 - Codex App GUI 历史基线及领域计划索引:`internal/exec-plans/codex-app-gui-alignment-plan.md`;GUI current 实施统一见下方全面对齐计划。 - Codex Desktop 跨平台底层对比与对齐计划:`internal/exec-plans/codex-desktop-platform-parity-plan.md` diff --git a/internal/exec-plans/codex-gui-alignment-current-plan.md b/internal/exec-plans/codex-gui-alignment-current-plan.md index 8f3669e9c..fdc1a894b 100644 --- a/internal/exec-plans/codex-gui-alignment-current-plan.md +++ b/internal/exec-plans/codex-gui-alignment-current-plan.md @@ -1,632 +1,113 @@ # Codex Desktop 与 Lime GUI 全面对齐 -状态:active / partial。校准日期:2026-10-14。 +状态:active / partial。校准日期:2026-10-15。 -## 主目标与参考 +## 目标与事实源 -使用 REA 分析指定 Codex Desktop 安装包,直接对齐可确认的 UI、交互、代码结构、目录职责、函数、类型和类;同时迁移或删除被替代的代码、文档、测试、fixture、snapshot、护栏和白名单。只保留 Lime 主题、多模型支持、首页 Banner 三项差异,不为旧规则、旧测试或未知实现新增保留项。 +使用 REA 对齐可确认的 GUI、交互、目录职责、函数、类型、类与调用链,同时直接迁移或删除旧代码、文档、测试、fixture、snapshot 和冲突护栏。仅保留 Lime 主题、多模型支持、首页 Banner,分别继续走 token/skin、catalog/provider 和 Home presentation owner。 -- GUI 参考:`/Applications/ChatGPT.app`,Bundle ID `com.openai.codex`,版本 `26.1007.30912`,build `20156`。 +- 参考安装包:`/Applications/ChatGPT.app`,实际为 Codex Desktop,Bundle ID `com.openai.codex`,版本 `26.1007.30912`,build `20156`。 - ASAR SHA-256:`75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。 -- REA 安装:`~/.local/bin/rea` 6.4.0;独立 Node 24.21.0。原始 Evidence 与提取包位于 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/`。 -- 共享业务语义:`/Users/coso/Documents/dev/rust/codex` current 源码,已核对 revision `a9b0815167c27ab13719dd12d13b63e02b70d221`;下次实现前复核。 -- 2026-10-14 复核公开仓库 HEAD 已为 `31434401fa3cd111866acdc1bba2a73b561f53d5`。安装包 CLI manifest 为 `0.162.0-alpha.17.2`;Desktop Host 与公开 TUI 均有 App Server 路径,精确构建等价仍 unknown,不能将最新 checkout 当作已安装二进制源码。 -- [研究报告](../research/codex-desktop-rea/report.md) 与 [证据索引](../research/codex-desktop-rea/evidence-index.json) 供复用,不能当作 Lime 产品已实现证据。 +- REA:6.4.0,独立 Node 24.21.0;当前安装、注册与 Evidence 身份见[索引](../research/codex-desktop-rea/evidence-index.json)。 +- 公开 runtime 源码:`/Users/coso/Documents/dev/rust/codex`,最近已核 HEAD `31434401fa3cd111866acdc1bba2a73b561f53d5`;安装包附带 CLI 为 `0.162.0-alpha.17.2`,精确构建等价 unknown。公开仓库不含 Desktop 前端。 +- 唯一业务链:`Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`。GUI、CLI/TUI 共享协议、runtime、工具和持久化 owner。 +- 研究入口:[差距报告](../research/codex-desktop-rea/report.md)、[结构与命名账本](../research/codex-desktop-rea/structure-alignment-ledger.md)、[证据索引](../research/codex-desktop-rea/evidence-index.json)。 + +本页仅维护当前进度、写集、退出条件与下一刀。已执行合同、每轮失败和原身份保存在[历史快照](codex-gui-alignment-history-20261015.md)及领域审计/收据,历史清单不再作为 current 指令。2026-10-14 用户删除旧 `.lime/` 后缺失的证据不能恢复为有效原件;本轮重新建立的 freeze/packet 独立记录。 ## 当前阶段与写集 -当前阶段:审批、Files/diff、时间线、队列动作身份、Header、设置通知、ComposerUtilityBar、公共 pending Queue 与 Browser tab 恢复均已迁移并取得各自定向与真实 Gate B。结构化主 Composer 的既有切片已关闭。长 UserMessage 折叠已补 canonical 去重、原生复制权限、动态测量、scrollTop clamp 与手动阅读视口竞争的先红回归;前轮默认门禁126/126批及7个Rust crate3179/3179均通过。rich第五轮69/69、long-list第六轮/冷启动与live-tail65/65通过;resize/reflow页头64px/46px越界及consumer placement已修复,40项诊断/几何、57项页头回归、真实fixed-5三尺寸66/66及标准typecheck通过。新默认门禁85批发现复制反馈卸载泄漏;已修复,五文件69/69、lint/typecheck、默认127/127批及fresh复制Gate69/69通过。长UserMessage合同100%关闭,证据按renderer版本分别保存。基础link/code已迁入实际input-kit,22红/8绿后20文件224/224通过;dictation接线及单路径清理已实施,72项回归通过。本批新Electron和默认门禁正在关闭,persistent draft、Code NodeView和完整产品账本仍未完成。旧默认绿不覆盖本批新产品写集。 +主目标仍为全面对齐。Composer 富输入、草稿恢复、新建导航和 Skills 接线的产品验证已关闭;最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭;下一刀为设置 canonical 路由与返回,设置产品源码尚未改动。 -规则写集:两个 skill、设计 skill UI metadata、skill 索引、`design-language.md`、`quality-workflow.md`、Verification Contract 的 current method 示例、`.gitignore` 精确白名单与本计划。设计 skill 原先被忽略,加入版本控制准入,避免规则仅保存在本机。 +本轮产品行为沿用已采用的 current owner:非 Composer accepted 回执进入既有可见 topic commit;Agent 文案复用 createI18n 五语言表。epoch、scope、binding.ready、revision、CAS、accepted 消费和新编辑保护保持有效。没有新增业务 runtime、read model、cache authority 或 compat。 -后续产品写集在每个切片实现前登记。避让已有 App Server/agent/tool-runtime、命令 catalog/executor、五语言 `agent.json` 与未知未跟踪改动;不提交、推送、重置或创建分支。 +本轮测试写集: -规则更新不改变实际 owner、依赖方向或门禁实现;架构影响为非重大。后续若改变目录/owner/协议或数据流,必须同步 `architecture.md` 并填写责任开发者确认,不能沿用本次非重大判断。 +- current fixture 退出护栏:`claw-chat-current-fixture-smoke.mjs` 仅接入独立 teardown helper 与相邻行为测试,`current-fixture-regression-smoke.mjs` 纳入该回归,复用既有 `captureMainProcessExit/quitMainProcessNormally` owner。只有真实 Main code0/signalnull 和全部资源释放后打印 pass;15秒正常退出期限不放宽,强制回收只允许失败后且永不计入正常退出。巨型 smoke 入口仅替换 finally 接线,不继续堆叠收尾业务;后续仍须拆分原入口职责。 +- `useAgentRuntimeSyncEffects.test.tsx`:旧 turn/start 回执迁 `turn.id/inProgress`,hook 与通知共享 canonical thread identity。 +- `useWorkspaceSendStructured.unit.test.tsx`:boolean/void 夹具迁完整 typed accepted/rejected,保 dispatch lock、富稿与新编辑保护。 +- `useWorkspaceResetRuntime.unit.test.tsx`、`newConversationPublicNavigation.integration.test.tsx`:覆盖根导航委托及编辑后的富稿保存/恢复;重复 `workspaceComposerReset.integration.test.tsx` 删除。 +- `Inputbar/index.test.tsx`:六个成功、一个拒绝和一个延迟接受夹具迁 typed result,原 recent usage/恢复/元数据断言保留。既有大测试文件只做夹具窄改;不再增加业务分支,职责拆分另刀。 +- 本计划、历史快照、研究报告/审计/索引及 `.gitignore` 精确文档准入。 -## 对齐账本 +沿用此前认领的翻译键清理、CommandWiring 与文案资源修复;不覆盖未知 release plan、`local-conversation-page-a83a1af2c24b.js`、`tsconfig.node.tsbuildinfo` 或媒体设置实现。没有提交、推送、重置或创建分支。 -| 能力 | 参考 | Lime current owner / 待办 | 清理与验证状态 | -| --------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| 设计与质量规则 | 用户三项差异、current 架构与 package scripts | 两个 skill 与领域事实源 | 完成;validator、引用、脚本与文档守卫通过 | -| 长历史与流式时间线 | `virtualized-turn-list`、`thread-virtualizer` 包模块 | `threadVirtualizer` / `VirtualizedTurnList`;canonical paging 与 DOM range 分开 | 已直接替换;29 文件 / 318 tests;真实滚动与冷恢复 Gate B 通过 | -| 待处理审批 | pending approval 包组件及 Codex server request/decision 合同 | `PendingRequestItemPanel` → `ApprovalCard`,复用现有 approval authority | 已替换;146 首轮 tests +追加回归通过,Gate B 待聚合 | -| Files → diff | review tree、file-diff 与 side panel 包模块 | `ReviewThreadSidePanelTab` → 同一 changeView / ChangesPanel / RightSurface | 已替换;真实四文件 diff/选择/定位 Gate B 通过,整体视觉仍待核对 | -| 导航、页头、Composer、队列 | 对应 Desktop 模块与 shared Thread/Turn/Item | 已有 sidebar/header/composer/queue owner | 已有能力须重新核对行为与命名,不能仅按旧计划判完成 | -| Queue mutation / Header identity | `QueuedMessageList` confirmed export、Thread scoped actions | 旧 ThreadQueueStatus 已迁净;public pending visibility 与既有 typed queue/store | 内部Item/媒体/动作回归通过;public history/paging74、搜索17、rollout恢复23;新版真实pending Gate B通过,旧 transcript Gate已失效 | -| ComposerUtilityBar | REA confirmed export、project/run-location/branch 分工 | 共享 utility + keyed mutation actions,删除旧组件/未消费透传 | 12文件343 tests;真实两项目选择、branch create/checkout、冷重启与Git readback Gate B通过;staged worktree/remote待办 | -| 结构化主 Composer | REA 主模块、initial Controller/keymap/View | 唯一 EditorState + EditorView;原始 draft / typed submission;共享 workspace scope | 当前切片关闭:rich68/68、聚合22/22、完整默认门禁通过;marks/persistent/voice 未关闭 | -| 长用户正文折叠 | REA collapsible-user-message-content、20行自然高度合同 | 两入口共享正文显示组件;现有Markdown、row测量与滚动owner | 已验收;guard39/39、默认127/127批、rich69/69、long-list/冷启动、live-tail65/65及resize66/66通过;证据分版本 | -| Thread 设置通知消费 | shared `thread/settings/updated` canonical settings | EventBus → scoped adapter → 现有 preference/threadRead cache → model/header | 120 定向 tests + production consumer 8;idle model/cwd Gate B 通过,已迁 known_projected;不声明 active/future Turn 执行 | -| 右侧面、Browser、terminal、artifact、subagent | 已提取 side-panel 与 surface 模块 | 单一 RightSurfaceHost/registry、scoped local state与既有native owner | scoped生命周期73、完整tab恢复82、actual sender41项测试通过;双tab/primary关闭不复活的真实Gate B通过;其他surface继续核对 | -| 设置、Skills、MCP、插件、自动化 | 对应包模块与公开协议 owner | 既有设置与 App Server current owner | 待完整清单与行为/结构核对 | -| diff/editor、Office/专业文件、原生操作、环境/remote | 对应包与平台证据 | 既有 artifact、document-preview、Desktop Host、environment、transport owner | 待查证/实现;不自动排除,不复制业务后端 | +## 已采用切片与证据边界 -每行继续拆成具体“上游符号 → current 符号 → 调用者 → 行为 → 删除项 → 测试/护栏 → 证据”的映射。压缩局部变量与无 source map 的开发源码名称记 unknown。 +| 范围 | current owner / 迁移 | 已取得证据与剩余范围 | +| ----------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 设计/质量规则 | 两个 Lime skill 与领域事实源 | 已按三项保留差异、结构命名和风险门禁更新;不代表产品全面完成 | +| 长历史/用户正文 | threadVirtualizer、VirtualizedTurnList、共享正文显示 | 原身份真实滚动、动态测量、冷恢复和长正文 Gate 已过;不是完整视觉等价 | +| 审批/Queue | PendingRequestItemPanel、ApprovalCard、QueuedMessageList | 同一 server request/decision/queue authority;pending、history/paging/search、图片真实读取有对应证据;自动审查及全部变体待核 | +| Files/diff/右侧面 | ReviewThreadSidePanelTab、ChangesPanel、RightSurfaceHost | 四文件 diff/定位与 Browser 双 tab/关闭不复活已验收;其他 editor/surface 完整行为待核 | +| Composer/NodeView | ComposerController、EditorView、NodeView registry/toolbar/plugins | 单一正文、结构化输入、语言/高亮与 rich Gate 已采用;原类名未知的项目不搬压缩名 | +| 持久草稿/新建导航 | Coordinator、Host persisted atom Store/File、useAppNavigation | packet21 三入口首发、rich Stop、Home 冷恢复、三工作区两正常冷恢复四合同通过,均绑定原源码/产物 | +| Skills accepted | CommandWiring -> existing visible topic commit | packet22 三路径及 current22通过;最终新构建结果另记 | +| 设置第一切片 | 单一 sections、公开 SettingsPage | 九源正式采用,重复名单/SETTINGS_GROUPS/ExecutionTracker/V2 aliases 删除;15章节/24首屏/157原生读通过,路由/返回/search/dirty待交付 | +| Thread settings | scoped EventBus/adapter/既有 cache | idle model/cwd 已验收;不推导 active/future Turn 执行 | -## 验证合同 +完整映射、调用者、删除项与 Evidence ID 由结构账本和领域审计持有;上表不按同名文件数计算完成度。 -规则更新:`budget:tight`,仅格式、链接、命令存在性、相关文档守卫与 diff 检查。无产品代码变化时不启动 Electron 或全量 Rust,也不形成产品/release evidence。 +## 当前验证状态 -产品切片:实现前按模板补齐入口、网关、IPC、public method、domain owner、read model、事件、GUI、happy path、失败终态与测试资产。仍沿唯一 `Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection` 主链。 +| 验证 | 结果 | 身份或限制 | +| -------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| packet21 四项产品合同 | 4/4通过 | first-send8、rich Stop、Home7、local17;7885源/2093产物零漂移;旧正常退出超时根因仍unknown | +| packet22 current 聚合第4轮 | 22/22通过 | 定向31/34/3/294,7885源/2198产物零漂移;不能覆盖后续修改 | +| 五语言废弃设置键清理 | 42/42通过 | 只删无消费者键,loader/type tests迁current键 | +| CommandWiring稳定函数引用 | 61/61、严格lint通过 | 原窄lint曾exit0带warning,纠正收据保留;后续标准全src lint零warning | +| 文案资源热路径 | 81/81通过 | 先3过1行为红;复用既有资源;historyWindow原14项和5秒期限未改,默认89批147/147通过 | +| 本轮旧夹具迁移 | 60/60、24/24、16/16、113/113通过 | 分别为通知、Workspace发送、导航覆盖归并、Inputbar;原默认失败日志保留 | +| 默认前置检查 | 通过 | 第4轮版本、五语言结构/unused、全src零warning lint、112源文案扫描、标准typecheck | +| 前端默认续跑 | 132/132批通过 | 1761历史登记路径,现存1756、已删5;原passed批保留,修改的早期owner另跑定向;不是所有批都在本轮重新执行 | +| contracts / legacy | 通过 | 标准test:contracts、legacy report;无边界违规 | +| 新标准GUI构建/smoke | 24/24通过、标准wrapper exit0 | run codex-composer-final-shell-20261015-1;freshness全ready,2303目录产物 | +| 退出护栏与原审批隔离 | 110/110、66/66通过 | 6项新行为回归;Main34547正常code0/signalnull,327ms,未强制回收。仅fixture变化,不能推导原挂起根因 | +| 新产物完整current22 | 第7轮22/22、入口exit0 | 四组31/34/3/300;21个Claw正常Main回执、强制回收0;Coding仅保留既有子进程证据。7886源/2303产物和产物路径集零漂移;67份本轮证据独立冻结,旧性能/退出失败根因仍unknown | -默认顺序:定向行为测试 → 受影响 current fixture → 对应 Electron fixture/Gate B → GUI smoke → 默认本地门禁。协议/bridge 变更追加 contracts;Windows 未实跑不写 Platform 通过。外部 fixture 绕过的 provider/runtime 层要明确。 +默认验证绑定 freeze30 的7884路径,SHA `727cc715a2b6af374ec59d991f8ac436927df53465cf40be48203f4c3be038bd`;退出护栏另建 freeze31,共7886路径,仅4项fixture变化(2修改、2新增),产品与2303产物不变、freshness全ready,无需重建。新聚合第7轮已exit0、22/22通过;packet31位于 `.lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-31.json`,SHA `8dcf4bddbb89e8b6d57f8502f6ec06e18718a14dfc00e8b317fa349fd660e57e`。新测试由定向及聚合执行,不冒充已进入旧续跑状态。默认组合最终收据:`.lime/qc/composer-persistence/default-combined-current-20261015/final-receipt.json`;前置收据:`.lime/qc/composer-persistence/default-combined-current-20261015/pre-gui-receipt.json`。 -## 完成标准与限制 +默认按已过前置检查 + 原生test:resume + contracts + 标准GUI关闭组合步骤;原verify:local wrappers均保留exit1,不改写成单次wrapper绿。续跑不按源码摘要作全量缓存,修改过的owner已单独回归。fixture是受控backend,不能冒充live provider、Windows、系统IME/mic或安装包等价。 -每个切片必须同时关闭行为、结构/命名、调用者迁移、旧资产清理和风险匹配验证。全面对齐要逐项关闭完整账本,不能用三个切片或同名文件数量替代。 +## 本切片 Verification Contract 与退出条件 -已知限制:此前 Computer Use 拒绝操作 `com.openai.codex`;不绕过限制。可继续静态 REA 与 Lime 真实 GUI 验证,Codex 实时窗口效果记 not-run。REA 三文件 timeline 与六文件 surfaces 的结构覆盖完整,但语义仍有 unknown,不宣称全包完整分析。 +负责人root。风险P1,非live。入口为Home/Inputbar/Skills试用/根新建动作,经真实Electron、preload/IPC、`app_server_handle_json_lines`、current JSON-RPC、runtime/read model到同一canonical thread/turn/item与可见状态。草稿UI保存只走Controller -> Coordinator -> Host Store/File,业务历史仍归App Server。 -下一刀:完成当前长UserMessage整体折叠的真实两入口/clipboard/焦点/长历史锚点与统一门禁;随后按完整账本继续scoped persistent draft等能力。Queue active-turn Steer/交付状态、专业文件、原生工具和 remote 继续保留在全面账本中。pending Queue 诊断 export / unload 另立合同,不由 public read 通过推导完成。 +- Happy path:非Composer接受提交后切到实际可见会话,编辑器恢复可编辑;富输入、资源字节、hidden paste、selection和接受消费可追溯;Home/local冷恢复隔离。 +- 失败终态:拒绝保稿、接受后的新编辑不被消费、迟到旧turn不能夺取新scope、pending discard通知不能重绑旧稿;严格身份/readiness/CAS守卫不放宽。 +- 测试/护栏:按current方法迁fixture与过期断言;无超时放宽、skip、生产mock fallback或手改续跑状态。旧实现只在负向guard/历史Evidence出现。 +- 最终构建后重核全部声明源码、dist、dist-electron和client dist的regular非symlink产物;保留旧chunk的目录manifest不声称全部被加载。 +- 标准GUI及新current22均已退出成功,前后源码/产物零漂移。第7轮manifest位于 `.lime/qc/composer-persistence/current-aggregate-7-complete/manifest.json`,SHA `69340c52a05833c450ad6f0d8667add345fa61d262b0aec3a6d8485674c9973c`;summary及其显式ledger/截图和log共67份独立冻结,排除旧前缀遗留失败截图,旧manifest不覆盖。 +- 更新索引/报告/本计划,格式、文档边界、脚本治理与diff检查通过,才关闭本验证切片。 -## 首刀:审批卡 Verification Contract +架构:持久草稿/新建导航/设置单一章节的重大图变更已由root于2026-10-14/15确认,见[architecture 4.3/4.4/4.5](../aiprompts/architecture.md)。本轮仅既有owner接线、资源复用和测试/文档整理,非重大;不新增authority或依赖方向。 -- 名称:Pending Request Item Panel / Approval Card 对齐。负责人:root,2026-10-13。 -- 预算:`budget:normal`;风险 P1;不调用 live Provider。 -- 参考:包内 `pending-request-item-panel-770855267df2.js`,SHA-256 `65c883ee4c9b507b6fe86e9edd73ac0eb2e085730dfbb1f72d0761ccde22be69`;通用卡位于 `app-initial-6350d04672d0.js`。module-derived 文件名与原始压缩符号分别记录,不能声称已恢复开发源码名称。 -- 写集:新 `components/ApprovalCard.tsx`、`PendingRequestItemPanel.tsx` 与对应测试/纯投影;删除 `Inputbar/components/InputbarApprovalPrompt` 源码与测试;迁移 PendingInteractionLayer、Workspace inputbar、相关 DOM 定位器;补五语言 `agentInputbar.json`;迁移审批 GUI fixture/断言/脚本测试;更新旧审批 PRD 与执行记录。public request 的 cwd/grantRoot 只补到现有 pending projection,不改 schema 或业务 authority。 -- 避让:已脏 Rust approval/runtime 与 `agent.json`,不接管其实现。 -- 前端入口:PendingInteractionLayer / Workspace Composer accessory → PendingRequestItemPanel → ApprovalCard。 -- 网关与 Host:既有 pending interaction controller → AppServer server request dispatcher → Electron preload/IPC → `app_server_handle_json_lines`。 -- App Server:`item/commandExecution/requestApproval`、`item/fileChange/requestApproval` reverse request 与同 id response;不调用已退役私有 action method。 -- Owner:审批 authority 仍归 App Server / tool-runtime;卡片只展示请求并提交现有 decision。 -- Read model / event:同一 thread/turn/item pending interaction,response 后清除;terminal 使用 current `turn/completed` 与 canonical read model。 -- Happy path:显示完整原因、命令预览、目录与已知网络/授权范围;长命令可展开;仅显示服务端允许的决定;提交期间禁止重复响应,失败可重试;响应成功后恢复现有运行状态。 -- 失败边界:没有 submit owner 或请求已提交时按钮禁用;未知授权能力不生成“Always allow”或“Allow similar commands”假动作。 -- 证据:component/unit 与五语言回归、current fixture、对应 approval Electron fixture、GUI smoke 与默认本地门禁;GUI evidence 需同 identity 的 IPC/request/response/read model/可见状态,不能只看卡片截图。 -- 必跑:定向组件/投影/fixture tests;`npm run test:contracts`;`npm run smoke:agent-runtime-current-fixture`;`npm run verify:gui-smoke`;`npm run verify:local`。失败按 owner 修复或明确记录外部脏写集阻塞,不恢复旧断言。 -- 架构影响:非重大;既有 projection/response owner、协议与依赖方向保持一致,拆分仅为 Renderer presentation;架构图无需改变。 -- 退出条件:旧 compact-only 规则、源文件和测试不再作为 current 入口;新卡行为与支持的决定完成定向验证。file diff 与 canonical item_id 接线、policy/network amendment 和自动审查专用详情仍是呈现缺口;协议已有字段不能被记成协议缺失。不宣称整个审批域完全对齐。 +## 下一刀:设置canonical章节与返回 -## Files/diff Verification Contract +前一验证切片已关闭;设置第二切片现进入实施,详见[设置审计](../research/codex-desktop-rea/settings-navigation-audit.md)。 -- 负责人:lime_gui_comparison;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。 -- 参考:REA `ev_7e96cf795451f834a7bddc70daacbc950320070656781f6347f1cf9c759e4299`;经 ASAR SHA 校验的 review tree/side pane 补证。`ReviewThreadSidePanelTab` 是确认导出;压缩局部名不作开发源码事实。 -- 写集:Files renderer、workspaceFiles、既有 ChangesPanel/DiffList/ReviewState、右侧 composition 所需接线及对应测试;文档、架构、fixture 门禁归 root。避让 Rust、命令目录、审批和 agent.json。 -- 入口与事实源:统一 RightSurfaceHost 的 Files → 同一 canonical coding changeView → 既有 CanvasWorkbenchChangesPanel;Git/read/full-file 仍走 existing frontend gateway → Electron IPC → App Server current methods,不新建 diff 或文件读取 owner。 -- 接线:会话 coding projection 只计算一次,分别供 Canvas 与 Files 消费。若使用 Context,只传只读 projection,不增加业务状态或 runtime。 -- Happy path:打开 Files 显示真实 patch/diff;点击文件选择并定位相应 diff;再次点击仍定位;打开原文件是显式动作;已有 inline/split、baseline、full-file 复用。 -- 失败边界:Git/full-file 失败可重试;线程/目录切换清空局部缓存;迟到异步结果不得写回新 identity;无协议能力不伪装 comparison base。 -- 旧资产:删除 patch→path-only 列表与解析 helper;迁移旧“选择会启动外部应用”测试;保留唯一 RightSurface owner 守卫并同步接线。 -- 验证:定向行为测试、typecheck、current fixture、`smoke:code-artifact-workbench-electron-fixture -- --scenario gui-coding-input` 加 Files→diff 断言、GUI smoke、verify:local。 -- 架构影响:共享 renderer projection 新增消费者,更新 architecture 图。责任开发者图确认:root,2026-10-14;已复核 Scene 只读 Context 直接传同次 changeView / root / loadFilePreview,Canvas 与 Files 共用 review owner,无第二数据 store。 +本刀认领:`useAppNavigation`及相邻测试、`types/page.ts`、App/AppPageContent及接线测试、AgentChatPage/contract/既有workspace surface回调、SettingsPage/SettingsNavigation及测试、五语言settings资源与类型/加载回归、settings-about/project-gate-settings-a/tool-surface导航消费者、对应文档。现有脏内容只作窄增量,不回退他人修改;release plan、媒体设置实现、参考解包chunk与tsbuildinfo继续避让。 -## 长历史 Verification Contract +Verification Contract:root,budget:normal,P1。`SettingsPage -> useAppNavigation -> AppPageContent`是唯一导航owner链;实际会话位置仅沿现有workspace surface投影发布session/workspace,由导航requestId拒绝迟到报告,不新建runtime/read model/history。返回只保存最小可恢复Agent参数,去除发送、新建、启动等一次性intent。章节选择直接消费/回写canonical SettingsPageParams,删除initial-props/local-selection双轨;页头返回与rail Home分开。真实Electron合同为Home首发生成会话后进入设置切章返回、已有会话切章返回;要求preload/IPC、current thread读取、同一session/workspace、可见会话与零重复发送/production mock。直达无来处、非Agent来处及快速导航以owner/component回归补足。先定向与五语言/type/lint,再标准GUI smoke及真实Electron场景;新产品变化开启本刀默认本地验证,历史通过结果不冒充新源码缓存。无live/Windows/参考实时观察;dirty/search后置。不需要LLM judge或release artifact。 -- 负责人:lime_desktop_capabilities;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。 -- 参考:既有 timeline REA 与 `thread-virtualizer` / `virtualized-turn-list` 包内纯几何、viewport range、高度测量、锚点和导航;导出为压缩 alias,语义命名与原始绑定分开记录。 -- 写集:MessageList、旧 RenderWindow owner、scroll controller、timeline/telemetry/historical chrome/constants、相关纯 projection 与测试/样式;不修改 Files 或 conversation composition。 -- 入口与事实源:App Server thread/read 与 canonical paging → 同一 Thread/Turn/Item projection → viewport turn list。保留后端分页 owner,不增加 history store。 -- Happy path:离屏 turn 真正卸载;稳定 key 与动态高度;窗口按 viewport/overscan 改变;prepend/流式高度变化保持阅读锚点;用户在底部时尾随;focused/current item 导航先挂载目标再定位。 -- 失败边界:迟到测量不污染新线程;ResizeObserver/RAF/事件清理;键盘、触摸、滚轮与滚动条操作能停止尾随;离屏数量不等于未加载历史或 hydration pending。 -- 旧资产:删除 idle 渐进挂载与 Expand All 全量 DOM 路径;保留 canonical Load More;同步迁移初始窗口固定 10/36 的测试与性能 fixture,不保留双裁剪 owner。 -- 验证:纯几何与 component/hook 行为、长历史真实 Electron fixture、current fixture、GUI smoke、verify:local。实际性能数字只有跑过才落库。 -- 架构影响:renderer window owner 直接替换,更新 architecture 历史窗口段落及图;责任开发者图确认:root,2026-10-14。MessageList 传全部已加载 canonical entries,唯一 virtualizer 持有 DOM/测量,后端 paging 保持独立。 +架构影响:既有导航owner内增加返回位置与显示投影接线,跨层业务边界不变;同步architecture 4.4的数据流,由root于2026-10-15确认后记录实际验证,不提前宣称完成。 -## 验证记录 +- Owner:useAppNavigation既有requested/committed/requestId持有设置参数与进入上下文;SettingsPage消费canonical章节,内部点击回写同一PageParams;不新增第二history。 +- 入口接线:App/AppPageContent -> SettingsPage/SettingsNavigation;providerView/providerFocus/executionPolicyFocus与Experimental委托语义保真,离开章节不重放旧焦点。 +- 返回与Home分开:页头返回恢复进入设置前实际可见会话/workspace,rail Home仍进入首页。不能仅保存旧根params,因为Home首发后params可能尚无真实会话,并可能含一次性发送/newConversationRequest。 +- 先沿既有onSessionChange投影携带真实workspace/session;它仅是导航显示位置,不建立新业务read model。剔除初始发送、fresh与其他一次性intent。 +- 窄写集:useAppNavigation、App/AppPageContent、既有session投影回调、SettingsPage/SettingsNavigation、PageParams及定向测试;五语言返回文案;settings-about/project-gate-settings-a/tool-surface消费者按新返回语义迁移。实施前核对各文件最新脏写集。 +- 必须覆盖Home首发后进入设置再返回同一会话、已有会话切章再返回、直达设置fallback Home、快速导航/迟到投影不夺取返回目标。 +- 验证:owner/component接线回归、五语言稳定断言、contracts按边界触发、标准GUI及真实Electron两条返回路径;同一identity的bridge/read model/可见状态和零production mock。 +- Memory/Web Search dirty owner已只读确认;其他表单与内容搜索继续查实际owner,不能复制未提取registry/隐藏章节或新造全局dirty业务状态。 -- 规则:两个 skill validator、链接/脚本存在性、docs boundary、12 个 current-docs guard 通过。 -- 首轮审批定向验证:6 文件 / 146 tests 通过;contracts 与 typecheck 通过。 -- 追加:file grantRoot request/response 回归与 dead surface 物理恢复守卫通过;键盘菜单 Escape 焦点回归已修复并通过,PendingRequestItemPanel 13 tests 通过。 -- Files:9 文件 / 92 tests、package projection 42 tests,移除无消费者菜单后 3 文件 / 26 tests;typecheck、lint 通过。真实 Electron `gui-coding-input` 四个 canonical 文件的 diff 方向、选中与头部可见全部通过;删除项禁用外部打开,rename 仍按 source path 选择、显式打开 destination。选择导致 external open 为 0,console/page errors 为 0。证据:`.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-files-final-summary.json`。 -- 长历史:29 文件 / 318 tests、typecheck、lint 通过。真实 Gate B 与冷重启恢复通过,fixture seed 240 Turn / 720 Item;实际已加载 14 entries,首帧挂载 3 Turn / 9 Item,滚到已加载页首挂载 8 Turn 并卸载原尾部行,然后返回尾部。首帧文本 2,068 字;first paint 38ms / cold 97ms,compute max 0ms / cold 1ms。预算仍为 ≤12 Turn / ≤36 Item、paint ≤8,000ms、compute ≤500ms;不把 seed 总数当成全量加载性能。证据:`.lime/qc/gui-evidence/agent-session-history-electron-fixture/codex-gui-alignment-history-final-summary.json`,console/page errors 为 0。 -- Gate 迁移:Files helper 8 tests + smoke guard 8 tests;viewport oracle 9 tests;current-rules 与 viewport 合计 20 tests;五语言 loader 10 tests。旧 current-rules 的 skill 重复模板断言迁为权威引用/主链/证据要求;旧全历史文案断言迁为分页文案。没有降低 DOM/性能预算或移除权限/mock 护栏。 -- 默认本地门禁已通过五语言完整性/unused、lint、硬编码扫描与 typecheck,Vitest 续跑到批次 91/121;contentParts final body case 在全量负载下5秒超时,独立case与整文件14tests均通过,未扩大timeout或更改产品逻辑掩盖。待写集冻结后resume。current-rules、loader与Codex async源码anchor等过期断言已迁移,保留有效权限/mock/Reasoning护栏。本轮新改动后current fixture、GUI smoke与受影响Rust门禁待收敛。 -- 写集例外:仅精确移除五语言 `agent.json` 中六个孤立旧 Files/菜单键,保留其他并行编辑;`agentMessageList.json` 删除三个旧尾窗键。新增 loader test 与 current-rules test 用于迁移过期护栏。 -- 真实桌面发现 `thread/settings/updated` 原先只有 typed guard / package oracle。完成生产 consumer、现有 cache 接线和真实 Electron Gate B 后,迁为 `known_projected`;不通过静默白名单掩盖缺口。 -- REA 审批:1 文件、23,095 AST nodes、199 findings、0 parse failures;结构 complete、语义 unknown。Wakaru source recovery 因 Linux x64 引擎不适用于当前 host 而未执行;不影响静态分析,不声称恢复完整源码。 +## 分类、完成度与未验证范围 -规则更新完成度 100%;全面 GUI 对齐仍为 partial,不从三项首批切片推导全产品百分比。 +- current:表中既有owner、当前协议fixture与新build验证链。 +- compat:仅已存在的Experimental -> Developer设置委托;本轮新增0。 +- deprecated:本轮没有新增延期双轨。 +- dead/deleted:旧组件/名单/V2 aliases、general草稿事件双轨、布尔接受夹具、重复reset integration、finally前打印pass及吞掉资源退出失败的旧fixture路径;有效风险由current语义回归承接,禁止恢复旧实现迁就测试。 -## 下一批:队列呈现与动作隔离 Verification Contract +前端续跑为132/132批(100%仅该历史测试发现集合),新增退出回归单独及聚合通过;默认组合、新current22、后验摘要及最终文档检查已关闭(本验证合同100%,不代表产品对齐100%);最终收据 `.lime/qc/composer-persistence/fixture-teardown-final-20261015/receipt.json`。整体GUI仍partial:设置完整路由/返回/search/dirty、专业文件排版与批注、原生应用操作、remote产品入口、完整符号覆盖、OS输入及Windows未完成;全产品分母尚未完整枚举,不给伪百分比。 -- 负责人:lime_gui_comparison;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。 -- 参考:[导航/Composer 审计](../research/codex-desktop-rea/navigation-composer-audit.md);已确认 shipped export `QueuedMessageList`。只迁移同义列表职责,不把压缩 alias 作为函数名。 -- 写集:`ThreadQueueStatus` 直接迁为 `QueuedMessageList`、纯输入编辑 helper、既有 queue action UI 与 tests、Scene import/callsite、`queuedTurnCurrentBoundary` 的 current path 和 dead 回流守卫;独立修复 `ThreadWorkspaceHeader` 重命名 session identity 及测试。root 拥有执行计划、架构、五语言文案和 Electron mutation gate。 -- 既有主链:QueuedMessageList → current threadQueueActions → typed client → Electron preload/IPC → `app_server_handle_json_lines` → `thread/queue/update|delete|reorder` → 既有 App Server queue/store → scoped notification/read hook → 同一 canonical queue projection。禁止新建 queue store 或在 renderer 删除后再 steer。 -- 行为:线程切换清空旧编辑与错误;旧 action 的 then/catch/finally 不影响新 identity;重复提交 fail closed;互斥队列变更不会被另一个请求提前解锁;保存失败保留草稿。多 text block 编辑无重复,非文本与 client message identity 保留;未改文本保留原始 textElements,修改后不沿用失效 ranges。 -- Header:编辑绑定开始编辑时的 session;切换取消旧草稿,旧 blur/key handler 不能调用新会话 rename。 -- 暂停/sending/outcome-unknown 尚无 canonical DTO,保持显式 gap,不伪造状态。当前 `thread/queue/start` 只支持 idle,不把它改文案冒充 active-turn Steer;该协议/事务切片另立合同。 -- 验证:跨线程迟到成功/失败、重复与并发、富输入保存和失败恢复、键盘 preview、header session 切换;scoped reader 和 boundary guard;真实 Electron mutation 需覆盖 update/reorder/delete 的 typed request、canonical queue refresh、正文唯一,单独报告,不冒充 live runtime。 -- 架构:业务 owner/协议不变,仅同义 renderer 直接迁移;同步图和当前入口,不保留 alias/compat。退出条件是旧组件与其正向测试删除、调用者/护栏迁移、定向与对应 Gate B 通过。 - -## 下一批:设置通知消费 Verification Contract - -- 负责人:lime_desktop_capabilities;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。 -- 已确认缺口:typed client 已解析 `thread/settings/updated`,projection 中现有 oracle 没有生产 consumer;`agentRuntimeAdapter` 丢弃 RPC response.notifications,`useAgentContext` 以 ACK 后的请求选择更新 UI。warning 间接触发 detail refresh 不算通知消费。 -- 写集:既有 settings adapter、EventBus/typed notification route、session/read-cache 与 useAgentContext 的最小接线和回归;不改 Rust、协议、Files、MessageList、queue、命令 catalog 或外部脏 owner。先复核生产订阅/identity 设计,再实现;root 拥有 Electron settings oracle、执行计划和架构。 -- 主链:既有 settings RPC → App Server canonical settings → typed `thread/settings/updated`(response 与后续 drain)→ 现有 event/同步 owner → 同一 thread/session read cache → useAgentContext/header/model/cwd projection。ACK 仅结束提交,不以请求值替代服务端有效值;没有第二设置 store。 -- 回归:服务端 canonical model/provider/effort/serviceTier/cwd、inactive thread cache、切换迟到隔离、响应与 drain 重复幂等、失败恢复、active turn 不变而 future turn 使用新设置、通知不写 transcript/Item。对于当前不支持显示的字段不合成 UI 行为。 -- 门禁:真实 production consumer 定向测试、既有 settings live-update oracle/typed contracts、真实 Electron model/cwd/header 证据;通过后才能把 method 从 known_unprojected 移到 PROJECTED。不可先加白名单消警告。退出条件按实际可消费字段逐项确认,不把 oracle 导出数当接线。 - -## 第二批实施与桌面验收(2026-10-14) - -- Queue/Header:旧组件与正向测试物理删除,无 alias/compat。thread-keyed 实例、mounted/request token、同步整队 mutation 锁隔离 A→B→A 迟到 success/error/finally;保存失败保留草稿。纯输入 helper 保留未改 blocks/ranges,修改后合为一段 text,保留非文本顺序,清除失效 ranges。Header 绑定编辑开始的 session/token/callback。6 文件 / 36 tests、renderer typecheck、窄写集 lint 通过。 -- Queue Gate B:真实 GUI 保存、上移、删除和 preview 焦点均经 current IPC,canonical list 同一 identity 读回。seed 内联图片入库后成为 `sidecar://media/input-.png`,oracle 先校验 seed bytes 的哈希基线,再精确比较 update/read-back 全部非文本输入;附件丢失、替换和空基线均 fail closed。原 raw dataURI 比较是过期验收基线,未为通过而移除附件要求。GUI list 可见先于 trace 完成,采证改为等待生产同线程 list,未补 fake request。证据 `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-final-summary.json`:零 invoke/console/page/mock error;不启动 Turn,不证明 active Steer、拖拽或发送状态。 -- Settings:EventBus 消耗 response/drain 成对镜像且保留 A→B→A,scoped adapter/cache/context/header 接线不建第二 store。ACK 不替代 canonical 设置,串行选择和 future-send wait 有定向回归;`useAgentContext` 已拆至 765 行。10 文件 / 120 tests、既有 oracle 8、最后 production consumer 8、renderer typecheck/lint 通过。真实 Gate B 后迁 `known_projected`,新增 drift/privacy/typed passthrough 回归。 -- Settings Gate B:真实 GUI model selection 与同线程 raw cwd RPC,旁路观察原 Host IPC handler 输出,不额外 drain、不注入 consumer。当前 stdio ACK 与 notification 分离,原要求 response.decoded 含通知是 oracle 错误;验收依据生产 drain 中 canonical model/provider/cwd 通知与可见 header,以及 `thread/resume|read` 读回。证据 `.lime/qc/gui-evidence/thread-settings-electron-gate-b/codex-gui-alignment-settings-final-summary.json`:零新 Turn、transcript 通知和 invoke/console/page error。只证明 idle model/cwd,不声明 active/future Turn 执行、live Provider、Windows 或 packaged。 -- 架构图:Queue/Header 与 settings 数据流已更新;责任开发者 root,2026-10-14 confirmed。业务 owner、协议和依赖方向未新增双轨。实际 App Server sidecar 使用显式 binary override,SHA 记录到最终 Gate summary;未建立与当前外部脏 Rust 树的精确构建等价,不用 mtime 冒充编译证明。 -- 分类:新增/迁移 presentation、通知 consumer、fixture/oracle 为 current;旧审批、Files、尾窗与 ThreadQueueStatus owner 为 dead/deleted/forbidden-to-restore;本批没有 compat/deprecated。现有队列 store、runtime、thread paging/read model 是 current,不因删除 UI 旧入口而删除业务 owner。 -- 完成度:规则更新100%;Queue/Header的mutation list/identity/结构/清理已通过既有验收,设置idle model/cwd切片通过。随后补齐内部canonical Item/媒体一致性,并由公开源码纠正“pending进入主时间线”的错误验收合同;旧timeline-final summary不再作为最终pending证据。完整产品仍partial,全面账本未穷举到稳定分母,不给无依据的全产品百分比。 -- current fixture 聚合已通过:22 个真实 Electron 子场景,涵盖审批 allow/decline/cancel/full-access、首页热路径、Files/coding recovery、图片意图、停止续聊、富草稿恢复、active steer、Plan/Skills/MCP/media/Expert/文章产物;绕过模型的 external/unavailable fixture 不是 live provider。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-gui-alignment-current-fixture-final.log`,各场景 summary 位于 `.lime/qc/gui-evidence/` 对应目录。不能用聚合通过覆盖上述新发现或完整视觉等价。 -- 前批contracts、规则/dead surface/queue/settings Gate guard、窄写集ESLint与legacy report通过;零引用、分类漂移、边界违规均为0。本轮新增public history/native tab合同后需重验受影响边界,旧75批记录已由上方91批进度取代。 - -## Queue canonical Item 与主时间线一致性 Verification Contract - -- 触发:真实 mutation 截图显示旧 rich 文本与已删除 sentinel。`queue.added` 自动注入 message.created 生成 canonical User Item,`queue.updated` 没有更新该 Item;reorder 的 removed/added 事务会移除后重建 Turn,却不重建 Item。renderer queue.changed 只刷新 list,普通 read-model merge 保留删除项。PNG fixture 的 IDAT CRC 无效已替换,并在浏览器 Image.decode 验证 seed。 -- 负责人:Rust materializer 与独立回归由 lime_desktop_capabilities,root 拥有 renderer read refresh、Gate、计划与架构;P1,`budget:normal`,无 live Provider。 -- 窄写集:`runtime/thread_item_projection/materializer.rs`、`materializer/lowering.rs`、`materializer/lifecycle.rs` 与该领域新独立 Queue regression module/必要 test module 注册;root 的现有 session refresh/projection/helper 与 scoped queue.changed hook、既有 SyncEffects 一处接入、对应定向测试;既有 Queue Gate 增强。避让外部脏 approval/runtime/backend owner,不修改 public v2 schema 或新建 queue/history store。 -- 主链:Queue typed mutation → App Server 既有 queue events → 唯一 materializer → 既有 ThreadStore canonical User Item → queue.changed → scoped existing session read refresh → 同一 canonical Thread/Turn/Item 主线。编辑使用稳定 user item/client identity、完整 typed input(含 sidecar)覆盖快照,不重新 append message.created 或完成生命周期。 -- 行为:add/edit/reorder 保留一份准确 User Item;delete 清 Turn/Item;重启和 thread/read 一致。frontend 自动读回更新文本/顺序和删除,不直接从queue list patch历史;读取为完整 read model才允许replace,分页detail不能伪装完整快照,保留 active streaming/未确认 optimistic 边界。 -- 失败与竞争:读取失败不伪造删除成功;订阅session/thread/workspace generation、A→B→A和迟到read隔离;连续通知串行且末次不能丢失。v2 Turn 未携带内部queue状态是既有协议gap,不依靠当前不可命中的isQueuedTurn判断;另立协议合同,不随本刀扩schema。 -- 验证:独立 materializer + canonical store add→edit→reorder→delete/restart 回归、受影响Rust crate、frontend scoped read/projection/hook 回归、contracts、增强真实Electron Gate B(raw thread/read正文/Items + GUI更新/删项 + canonical image decode)、默认本地门禁。退出条件是上述同一identity证据通过;原queue list Gate通过不关闭新问题。 -- 架构影响:恢复既有唯一projection owner的一致性,不新建业务owner;更新architecture现有Queue/read flow并由root复核图。canonical内部queue状态与v2传输的完整对齐仍待独立关闭。 -- 测试迁移:`ChatModelSelector.integration.test.tsx` 补canonical设置通知和隔离catalog,保留跨session恢复的模型与thread/settings cache断言,4 tests通过;不会恢复ACK请求值提交逻辑。 - -## ComposerUtilityBar 结构与动作隔离 Verification Contract - -- 负责人:lime_gui_comparison;root 记录,2026-10-14。P1,`budget:normal`,无 live Provider。 -- 参考:focused REA `ev_a12d13494c92f390f31b4493b08cc34bd42867413b8d6fcfbe597601fad22a18` / exact export view `ev_d226fcac113fff14952965ae9465453d279c1f1431cdef57be2026d60b9e06d9`;证据限制见 navigation-composer-audit。只迁 project/run-location/branch/local context 职责,不迁整个 model/permissions/editor。 -- 窄写集:`InputbarProjectContextBar` 源文件/独立动作hook/对应测试,直接迁 `ComposerUtilityBar` 与首页、ChatNavbar、EmptyState类型、Inputbar.index、既有workspace透传的必要引用;仅清理实际未消费的props。root 拥有docs、架构、dead surface catalog/guard和Electron验收。避让Queue/read model、审批、Rust、共享command owner和不相关脏修改。 -- 合同:现有project/Git gateway → current Electron IPC/App Server与domain owner不变;所有mutation绑定projectId/rootPath/workspace generation/request identity。同步互斥,A→B→A旧success/error/finally不能改新视图或导航;失败可重试,正确当前项目动作仍刷新/导航;单一owner同时服务home与navbar。 -- 清理:旧组件、copy/project类型与测试路径直接迁;不建alias/compat。动作职责拆成hook,renderer文件保持小于800行;主题使用既有token。不合成cloud/remote/environment能力、不以Local文案冒充readiness,现有worktree立即创建与上游staged mode差异另立跨层合同。 -- 验证:既有home/navbar/Inputbar/Git及workspace调用者测试、mutation隔离/同步重复/失败恢复/键盘与主题回归,renderer typecheck/lint、受影响Electron场景、GUI smoke、本地续跑;必要文案同步五语言。结构迁移不等于完整worktree/run target行为等价。 -- 架构影响:非重大,沿用唯一project/Git owner及composer controller;root 最终复核引用图。退出条件按结构、命名、调用者、已确认动作语义、清理与对应测试分别记录,未知能力仍在全面账本。 -- 巨型既有session hook例外:本轮仅接入已拆分的Queue read helper,新增业务保持在独立helper/hook;不向`useAgentSession`继续堆叠状态机。退出条件是该helper测试/接线证据通过并冻结最小接线,后续迁移另立owner拆分合同。 - -## Queue 输入图片真实读取补充合同 - -- 触发:增强Gate已观察主时间线正文更新,但图片仍显示unavailable;合法seed已由浏览器decode通过。输入writer生成既有`outputRefs`对象,`session_media_refs`只识别嵌套`sidecarRef`,需查明同一canonical input URI的真实`media/read`结果。 -- 负责人:lime_desktop_capabilities窄写`runtime/session_media_refs.rs`及`session_media_reader.rs`该领域独立回归/必要注册;root拥有Gate和docs。避让外部脏runtime/backend、协议与input writer;先真实RPC或Rust集成复现,再修复已经存在的reference lowering,禁止绕过session ownership/sha/bytes/range/cancel校验或读取任意路径。 -- 合同:typed input media writer → 既有outputRefs → media/read唯一reference owner → sidecar verified bytes → MessageImageAttachments。不新增media metadata key、平行store或renderer raw文件读取;保留跨Thread拒绝、篡改哈希/大小失败、未知URI失败和请求取消。 -- 验证:真实queue/add与合法inlinePNG后的media/read,在edit/reorder及冷恢复后保留同一URI/bytes;负向scope/sha/read保护、受影响AppServer tests和新binary SHA、增强Electron image.decode、contracts与本地门禁。此发现继续阻止Queue完整验收,原列表和正文通过不覆盖图片。 - -## ComposerUtilityBar Electron 验收补充合同 - -- root 窄写集:既有 `scripts/electron/lib/` 内独立 utility Gate 与 oracle、scripts 导航、legacy catalog、执行计划和架构。生产组件及动作 hook 由既有 worker 冻结后再构建。 -- 使用隔离 app data、两个真实 Project 与临时 Git 仓库;初始对象用 Git plumbing 生成测试基线,不提交或切换 Lime 源码仓库。setup 可从 current IPC 建 Project/写本地选择基线,但成功证据必须来自 GUI 项目选择、分支创建/切换、真实 IPC 与 Git/readback 同一 root。 -- 验证 home UtilityBar project/root identity、分支菜单/搜索/create/checkout、reload 后恢复;全部 Git mutation 必须匹配当前 workspace root,禁用 mock fallback。错误/重试和 A→B→A 由组件回归覆盖,不用受控成功场景冒充故障恢复实跑。 -- 不启动 Turn、不调用模型;不把立即创建 worktree 证明为上游 staged composerMode 等价,不包含 cloud/remote/environment。 -- 实跑补充:首页把 `useWorkspaceOpenedProjectsRuntime.enabled` 永久绑定到 auxiliary deferral,导致已打开项目虽存在于侧栏/存储,UtilityBar picker 始终为空。root 窄写补 `useAgentChatWorkspaceEntryRuntime` 的这一处接线;只恢复现有 opened IDs 的 workspace/read,不开放 memory/model 等其他 auxiliary owner。真实 Gate 使用 workspace/ensure 基线,项目必须经 GUI 选择,不能拿 Project 目录实体替代 Workspace 实体。 - -## 未消费 Queue 的 public history 与运行状态合同 - -- 触发:增强 Gate 的正文/附件一致性已通过,但截图发现没有启动的 Queue 仍被显示为运行中。公开 Codex `ext/queue` 的 enqueue 只写独立 queued_items;start/admission 后才创建执行 Turn。public v2 Turn 不携带 queue 状态,不新增猜测 enum 或 renderer 私有 wire 字段。 -- 保留 Lime 内部 current durable queue materializer、稳定 User Item、媒体与冷恢复 owner;尚未消费的内部 Queued Turn 不属于 public transcript,Queue 列表继续由 `thread/queue/list` 唯一供给。内部有事实不等于可向 public history 投影。 -- 窄写集:既有 RuntimeCore thread read/turns/items/history pagination owner 与独立 regression module、必要 public projection 过滤及 processor tests;不修改 schema、agent loop、queue scheduler、shared runtime/approval/backend 外部热区。root 接管 frontend 公共 projection 中不可命中的私有 queue 字段假设、Electron oracle、docs/架构。 -- 失败边界:queued 不能触发 orphan interruption、terminal-projection-pending 或 active 状态;真实 active/terminal/drain 语义保持。过滤必须覆盖 read/list/resume、turn/item paging 与指定 queued Turn read,不泄漏 Item;不能在截断后的单页简单丢条目。使用既有 opaque cursor,补足可见 page 或明确 EOF,重复 cursor fail closed。 -- 验证:混合 queue/history、全 queued 页、反向 paging、同库/生产分库冷恢复、queue admission 后可见、真实 active 仍 active;pending Gate 改断言 public history 未含 queued正文、header/Composer idle、队列 edit/reorder/delete 保真。图片由真实 media/read 解码验证,不能为通过测试保留错误的未启动 transcript。 -- REA 精确缩略补证:同一 Queue chunk UTF16 offset 11048 有真实 `img`,src 来自 submission context 第一张 image preview,size-6;不是完整 gallery。root 窄写 `QueuedMessageList`、`queuedMessageInput` 的首图转换、既有 `MessageImageAttachments` 的 thumbnail presentation 及定向回归;复用同一 media/read/abort owner,不建第二图片读取链。Queue Gate 改在真实 row 解码缩略,公共历史必须为空。 - -## Right Surface 切换与关闭合同 - -- 触发:同线程 Browser → Files 仅切换选中项,却使 Browser 从 openSurfaces 消失。select 与 close 必须分别表达;不从 active pane 卸载猜测 native Browser 已销毁。 -- 参考:已保留 REA surfaces 模块及确认导出 `LocalConversationBackgroundTerminalTab`、`LocalConversationSubagentsPanelTab`、`ReviewThreadSidePanelTab`;通用 host/artifact 压缩局部名仍 unknown,不凭猜测迁名。 -- 窄写集:既有 `useWorkspaceRightSurfaceLocalStateRuntime`、`useWorkspaceRightSurfaceProjectionRuntime`、`useWorkspaceRightSurfaceCoordinatorRuntime`、`useWorkspaceRightSurfaceActionRuntime` 与相邻独立 projection/action 回归;不修改 queue、Composer、runtime、native/browser host 或 shared registry 协议。worker 避让并保留前轮 Files/diff 修改。 -- 同线程显式打开 surface 集合属于局部显示 owner;select 不删除其他 tab,close 才删除;thread/workspace epoch 切换必须隔离旧集合与迟到动作。resource availability 仍由现有 registry/definitions 判断,不新增 browser/artifact/subagent store。 -- 验证:open Browser → Files → Browser、close 选中/非选中 tab、无资源 surface、线程 A→B→A;定向测试与真实 Electron 相同 thread tab/资源 identity,禁止 mock fallback。Gate 证明 tab 生命周期,不冒充 native Browser 进程跨线程持久化。 - -## Pending Queue 搜索可见性合同 - -- 触发:只读追踪确认 canonical User Item 已进入 `thread/search` 的 snippet 与 `thread/searchOccurrences` 的 occurrence/turn cursor;搜索还透传 raw Active 状态。pending Queue 属于独立列表,搜索执行历史不能把它返回为 transcript 结果。 -- 负责人:lime_gui_comparison;root 记录,2026-10-14。窄写集为既有 thread-store canonical search owner 与相邻独立 regression module/注册、RuntimeCore public search 包装的最小有效状态投影及必要 processor/JSONL 回归。保留 external 脏写集、现有 visibility 与 opaque cursor,不改 schema、queue scheduler、export、rollout rebuild 或全局 active helper。 -- 合同:已执行 transcript 匹配、排序、分页与已有 title/path 搜索继续由当前 owner;仅 pending 文本不能命中 snippet/occurrence。Thread overview 状态取同一 execution state 规范化。queue admission 后该 input 成为正常可搜索历史;同库/分库冷恢复保持语义,不合成游标或由 renderer 二次过滤。 -- 验证:interrupt 基线 + 唯一 pending 字符串负向、混合执行/queue 同词、分页 EOF/双向、admission 后正向、真实 JSONL 与 thread-store 定向。不把诊断 export 中显式 pending/queued 字段定义为泄漏;其 transcript 与队列区分另立合同。rollout rebuild 与 idle unload 为待复现风险,未更改。 -- 真实 pending Queue Gate 前置条件校准:sidebar `thread/resume` 会唤醒 idle queue,直接 add 到全新 Thread 的旧 fixture 会 admission 第一条。改用 current external 阻塞 backend 启动唯一基线 Turn,再真实 interrupt 确认终态,随后添加 pending Queue;只含已执行基线历史且 idle,pending 不出现在 transcript。该 fixture 仍绕过 live Provider,不证明跨 unload/restart 暂停持久化。 -- 大文件例外:code-artifact fixture 只接入独立 lifecycle helper 的 import/call,业务与 oracle 位于 `scripts/electron/lib/`,不继续向原巨型入口堆叠;退出条件为相同 Thread Gate 完成并冻结接线。 - -## Browser live tab 恢复合同 - -- 触发:同线程 Browser→Files→Browser 卸载/重建 BrowserWorkspace,仅 mount primary,丢失完整 native tabs 的投影;primary存在时第二tab无法恢复可见性,primary已关闭时会复活primary。BrowserTabHost是既有live tabs/selected/URL/native view唯一authority;App Server继续只分配canonical browser session/thread identity。 -- 负责人:lime_desktop_capabilities;root记录,2026-10-14。生产窄写集为BrowserTabHost、ipcChannels、browserTab typed API、BrowserWorkspace及必要独立hydration helper;相邻host/API/component/IPC合同tests。根治理catalog与既有mock/fixture若当前native命令已纳入必须同步,不建生产fallback。 -- 新增host-only只读`browser_tab_list`:从实际IPC sender派生window,按window/ownerWebContents/browserSession/thread返回完整live snapshot;已知跨scope资源拒绝,不将身份不符伪装empty。沿既有preload白名单及main native dispatch,不增加Rust业务runtime或猜测上游符号;名称为本地领域命名,未声称REA确认上游导出。 -- renderer在同一canonical identity下hydrate,非空恢复host selected与既有setBounds,只在确认真正empty时创建primary。snapshot失败、scope混入、无selected中间态不能bootstrap;event/snapshot/selected/close交错不让旧快照覆盖新事实;origin+单调generation隔离ABA与迟到bounds。 -- 验证:双idle tab选第二→切换保持列表/URL/view/webContents;关闭primary→切换不得复活;跨window/session/thread/伪造owner/mixed/duplicate snapshot拒绝;empty、失败、late event/snapshot/bounds和ABA;定向host/API/renderer、`test:contracts`、真实Electron双tabGate、GUI smoke与本地门禁。 -- 大文件处理:BrowserTabHost既有超过1000行,新增snapshot参数解析/校验/列表拼装进入该领域独立模块,主类仅注册和窄委托;记录既有文件拆分风险,退出条件为不向主类继续追加业务算法。BrowserWorkspace接近800行,hydration逻辑拆独立hook/helper,并直接替换旧bootstrap effect,不保留双轨。 -- 不证明native跨线程或重启持久化,不推导Codex运行等价;本刀修复已确认本地生命周期缺口。root最终复核并更新架构图;command/bridge变更不通过时不能进入产品证据。 - -## 本轮冻结与证据记录(2026-10-14) - -- pending public read/list/resume/paging:11文件,74/74 tests、rustfmt与窄diff通过。定向JSONL含queue6、v2 thread19、resume replay2。日志`.lime/qc/validation/codex-queue-public-history/`。分页读取Summary pending IDs的长历史成本尚未benchmark。 -- pending搜索:7文件,store6 + JSONL11 = 17/17 tests、rustfmt与窄diff通过。fixture显式`sourceKinds:["appServer"]`,先正向证明已执行内容可命中,再断言pending词不命中,避免默认CLI/VSCode scope导致负向空跑;默认产品scope不变。日志`.lime/qc/validation/codex-queue-search/`;最终build SHA`1188f12c7150afbe6a0c666eb57c3e109ed9c8682b698aa23667a2faa26778a3`,Gate prepare后需重新记录实际binary。 -- Right Surface局部标签:6生产 + 2新增tests + 1迁移guard,73/73 tests、renderer typecheck、窄lint/diff通过。日志`/tmp/lime-right-surface-tests-final.log`。旧availability自动造open tabs与plugin-only epoch reset删除,未猜测unknown上游名称。 -- Browser live tab:8生产文件与相邻测试/fixture写集冻结,11文件82/82 tests;renderer/Electron typecheck、20文件窄lint/diff及`test:contracts`通过。BrowserWorkspace从802降为689行;原primary-only恢复effect删除,无compat。新增snapshot/selection算法进入独立helper;主Host只注册/委托与修正visibility。日志`/tmp/lime-browser-hydration-{tests-final,renderer-tsc-final,electron-tsc-final,lint-final,contracts}.log`;当时 native 双tab Gate 待 Rust 冻结;随后通过,见下方最终 Browser / Right Surface Gate B。 -- root Gate oracle:4文件56/56 tests通过,日志`/tmp/lime-pending-and-surface-oracle-final.log`;标准`npm run typecheck`通过,日志`/tmp/lime-codex-alignment-standard-typecheck-final.log`。首次误用Node test runner被Vitest拒绝,改用实际Vitest入口重验,未修改产品或放宽断言。 -- Composer真实Gate B通过:`.lime/qc/gui-evidence/composer-utility-electron-gate-b/codex-gui-alignment-composer-summary.json`。两项目A→B→A GUI选择、branch create/checkout、Electron/AppServer冷重启后GUI恢复与Git readback;真实IPC4次、mock/invoke/console/page error为0,实际binary SHA`1c10f38b5d4927d32c01d4c92032fd7c95b09aa8325b96606e6bfad12fe1bcbf`。跨重启分别采集真实trace后合并,未补fake request。 -- Gate失败已修原因:pending旧fixture等待marker正文,sidebarresume实际admission第一条,已迁interrupt基线;RightSurface初版request只填session,但GUI按workspace ID/root查询,已补同workspace scope。当时两项均待最终冻结后重跑;随后新版最终 Gate 已通过,失败结果不计成功证据。一次Gate freshness发现搜索半写集并开始prepare,已中止exit130,无剩余prepare/cargo进程,不将其产物作为最终证据。 -- 新oracle:真实host/身份/资源/EOF/图片读/选择关闭负向回归通过;Browser Gate已增强双tab与关闭primary后恢复,必须出现真实native snapshot IPC,不把单primary保持等同完整tab恢复。 -- 分类:以上新增owner/helper/client/fixtures为current;旧入口直接dead/deleted/forbidden-to-restore;无新增compat/deprecated。规则100%,Composer本合同100%,public pending与RightSurface本合同的真实Gate/默认门禁尚未关闭;全产品仍partial。 - -## Queue rollout 重建恢复合同 - -- 触发:projection_rebuild按TurnStatus将pending Queued/InProgress写成running。真实runtime queue/add写rollout,保留原DB/event log,并以同一rollout root初始化全新同库DB:canonical完整快照相等,但projected Turn.status=Running、冷queue/list由1变0。复现日志`.lime/qc/validation/codex-queue-rollout-rebuild/reproduction.log`;修复限定现有重建owner,不改变watermark或全局状态机。 -- 负责人:lime_gui_comparison,root记录,2026-10-14。先用临时数据与既有真实rollout写入/空DB重建路径复现pending queue/list/read/resume;避免删除用户DB。窄写集为现有canonical_thread_store/projection_rebuild及该领域独立queue regression与必要注册;若没有复现,不增加生产补丁并记录证据。禁止改scheduler/unload/export/schema/普通admitted与terminal恢复语义。 -- 若复现:重建必须保留canonical Turn.queue事实、稳定User Item/client/input/媒体与pending projection;唯一当前owner直接修复,不恢复第二journal/store。验证同库/分库、pending+executed混合、admission后可见、public history/search排除pending及既有completed/active/repair回归。 -- 退出条件为实际复现分类与定向测试通过,冻结新binary后再跑最终Gate;诊断export与idle unload仍独立待核对。本合同不扩大为全workspace测试。 - -## 完整 Composer 只读补证 - -- 负责人:lime_desktop_capabilities;与root Gate收敛独立。无生产、测试、fixture写集,不触发build freshness。 -- 先复用指定ASAR与现有REA Evidence,定位主editor而非UtilityBar/BrowserSkills/Sketch;仅当明确seed未被分析时补focused REA,不重跑全包、不执行Codex或绕过Computer Use拒绝。 -- 返回真实模块/可确认导出、草稿/IME/附件/提交数据流与Lime input-kit/Inputbar调用链,记录Evidence ID、SHA/UTF16 anchor、观察/推断/unknown及最小可实施缺口。root随后落入本账本,不由静态补证标记产品完成。 - -## Browser actual sender dispatch 复核 - -- root 复核发现 `main.ts` 的 `currentWindow(event)` 会在未知 sender 时 fallback 到 mainWindow,Host 仅看到 window 而失去实际 sender。窄写集为 snapshot 领域 sender resolver / 相邻真实 Host 测试,以及 main browser-tab dispatch 的委托接线;不改 hydration 或其他 Host 命令。 -- BrowserTab 命令只能使用 `BrowserWindow.fromWebContents(actualSender)` 且 `window.webContents.id == actualSender.id`;未知或 child WebContents 直接拒绝,不返回 empty、不回退 mainWindow。主文件超过1000行的例外仅限 import / 委托接线,算法位于已有领域独立模块。 -- 定向验证 exact sender、unknown sender、child sender 经真实Host list的成功/拒绝;Electron typecheck、窄lint、contracts,再进入新版双tabGate。 - -## rollout 与 sender 最终冻结 - -- rollout:生产只在现有 projection_rebuild 的 Turn.queue 分支保留 queued;独立 queue regression/注册在同领域。新增及既有状态3 + repair/rebuild13 + queue JSONL7 = 23/23通过,rustfmt/diff通过。worker 当时 rebuild binary SHA `1d93af0f9d52a9e7ae2add71d5556fe42c44aa17148254c270d603e44fae4478`(不作为实际 Gate prepare 后摘要);日志 `.lime/qc/validation/codex-queue-rollout-rebuild/`,原失败仍保留。worker domain测试显式MockBackend,真实Electron Gate另用external fixture,不冒充live Provider。 -- sender:root新增 actual sender 接线3项回归;受影响5文件41tests、Electron typecheck、root窄lint与最终contracts全部通过。日志 `/tmp/lime-browser-sender-tests-final.log`、`/tmp/lime-browser-sender-electron-typecheck-final.log`、`/tmp/lime-codex-alignment-root-lint-final.log`、`/tmp/lime-codex-alignment-contracts-final.log`。 -- 所有产品与测试写集已冻结,root顺序跑新版pending Queue与双tab Right Surface Gate。两个worker只做完整Composer/Queue activeSteer只读补证,不影响build freshness。 -- Queue activeSteer只读补证由lime_gui_comparison负责:追踪同ASAR Steer callback的外部owner、消费与steer事务/失败顺序、expectedTurnId/client identity及交付状态;对照Codex current ext/queue与Lime既有steer/session actor,不用renderer delete→steer,不写产品/测试/文档。返回sources/Evidence/unknown与最小正确后续合同。 - -## 最终 pending Queue Gate B - -- `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-pending-final-summary.json` 的 `ok=true`:rich input/client identity、update/reorder/delete、独立preview/首图真实decode、public read/paging/search pending排除、idle与正向执行搜索基线全部通过,mock/invoke/console/page error均0。 -- 实际postprepare App Server SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。初始worker build SHA与prepare后binary不同,最终证据按实际值记录;不推导未知脏树的精确源码等价。 -- 首次新Gate的search数据全部符合合同,但直接preload读取并不生成safeInvoke trace,oracle错误要求该trace。诊断证明baseline=1/idle、pending=0/EOF、occurrence=0/EOF;已将直接调用取证迁到真实发送params及匹配request/response ID的`ipcObservation`,不制造trace、不补fake request、不放宽搜索断言。 -- 共享观察predicate位于现有mcp-config-fixture-evidence;直接call在真实preload返回成功后记录observation。Queue/RightSurface分别校验准确method、identity与输入,同步负向unknown/mismatched response/mock回归。4文件61tests通过,日志`/tmp/lime-preload-proof-tests-final.log`;最后统一门禁继续验证。 - -## 最终 Browser / Right Surface Gate B - -- `.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-surface-final-summary.json` 的 `ok=true`:真实GUI新建/选择第二tab,Browser→Files→Browser恢复完整tabs/selected/session/tab/webContents及真实native URL;关闭primary后再次切换不复活,最后分别关闭Browser/Files。canonical请求、native mount/list均为真实preload/IPC观察;mock/invoke error0。 -- 同场景保留原四文件Files diff/选择/定位回归,external file open为0;不把native跨线程、Electron重启或Codex实时GUI列为已验证。 -- 直接preload的workspaceRightSurface/request与Queue search复用同一实际发送/响应观察predicate;native mount/list继续要求safeInvoke真实IPC trace,不能拿测试构造trace补证。当前fixture22聚合进行中。 -- 后续只读Queue activeSteer报告已落 `internal/research/codex-desktop-rea/queue-steer-audit.md` 与证据索引;前端实际steer成功后delete、unknown对账、有限TTL锁已确认,持久幂等未知。Lime日志失败/终态竞争风险仍为待复现,不按静态风险直接扩大生产写集。 - -## Composer 附件异步隔离 Verification Contract - -- 负责人:lime_desktop_capabilities;root 登记,2026-10-14。预算 `budget:normal`,风险 P1;上一批 current fixture 22 场景通过且退出码 0 后开启本切片。 -- 参考:REA 主 Composer `ev_18100ea823ab540f529ba756499161870e17f824267ed48a0665d3fb48cd0bb4`,原成员 SHA 与 UTF16 anchors 见 main-composer-audit。上游文件附件捕获 generation 并在完成时检查;只支持生命周期隔离原则,不声称与本地图片 hook 精确同义。 -- 窄写集:`Inputbar/hooks/useImageAttachments.ts` 与相邻测试、`components/useEmptyStateAttachments.ts` 与新相邻行为测试,以及 `components/useAttachmentGeneration.ts` 小型共享 lifecycle helper。不迁附件状态 owner、不修改 runtime/store/协议/五语言文案;不夹写 root 的 BaseComposer、文档与 Gate。 -- Current 主链:首页/会话读取附件 → 当前草稿 owner → 既有 submit → preload/IPC → App Server `turn/start` 或 queue method → canonical Item/media readback。generation 只隔离本地读取;不建立第二套业务后端。 -- Happy path:同代读取正常接收并遵守既有容量、格式、路径与文案合同;clear/replace/unmount 后旧 success/error 均不改变新草稿或发旧 toast;下一代仍正常读取。先以 deferred FileReader 复现旧行为,再修复;不从 hook 测试推导跨线程 scope 或跨重启附件持久化已完成。 -- Evidence:确定性 hook/DOM 回归必需;GUI 使用受影响真实输入 fixture 验证发送与 Item/media 消费;不跑 live Provider,不形成 packaged/Windows 证据。失败在本地草稿 owner 停止,既有发送失败保留草稿规则仍有效。 -- 必跑:两个 hook 及输入消费者定向测试、renderer typecheck、窄 lint;冻结后受影响 Electron 输入 fixture、`verify:gui-smoke`、`verify:local`(Vitest resume)。无 public boundary 变化,不为该切片重复 contracts。 -- 场景:P1 首页/会话 rich input 与发送恢复。Supervisor/LLM judge 不适用;回写 deferred lifecycle tests,关闭条件为迟到 success/error 不回流且同代正向接收。 -- 架构:非重大,现有附件 owner 内提取共享生命周期原语,目录职责/协议/read model/依赖方向不变;全面 Composer 编辑器结构仍待对齐。 - -## Composer IME 候选确认 Verification Contract - -- 负责人:root,2026-10-14。预算 `budget:normal`,风险 P1。 -- 参考:REA initial 六声明 `ev_38693c282fb15af95ea4b8f1caa292ed199fee587ea26d709b126123c3fb8153`;原 initial SHA `604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f`,cBc UTF16 10282434 检查 event/view composing/keyCode229,uHc 10371337 的 compositionEnd 只更新编辑状态。 -- 窄写集:`input-kit/BaseComposer.tsx` 与相邻测试、`Inputbar/inputbarSendPayload.ts` 与发送测试的 trigger union、既有 Electron 输入 fixture 中独立 IME oracle、研究/规则/执行文档。附件由既有 worker 独占;避让外部 Rust/command/approval 脏写集与未知根 JS。 -- Current 主链:BaseComposer DOM 编辑 → 既有 InputbarSend → current gateway/preload/IPC → App Server `turn/start` → canonical projection。候选确认不得发出 submit 请求;另一次普通 Enter 或显式按钮仍走既有主链。 -- Happy path:compositionstart 内部状态、native isComposing、Process、keyCode229 均保护编辑;compositionend 不安排发送;普通 Enter、Shift+Enter、button/stop 与历史回放保持各自合同。删除 pendingImeSendRef、旧 composition-send 和 ime trigger 类型,不保留 compat;将旧自动发送正向测试迁为负向及随后显式提交正向。 -- Evidence:DOM 确定性回归证明下一次 Enter 和显式按钮正常提交;真实 Electron rich-input fixture 以合成 composition/keydown 事件验证无提交请求及 canonical 空历史,随后真实按钮提交并读回同 Thread 的 Item/完整富输入。合成事件不冒充系统 IME 候选窗口或 Codex 实时 GUI 证据;不跑 live Provider。 -- 必跑:BaseComposer、ComposerController、InputbarSend/输入消费者定向测试;renderer typecheck、窄 lint;冻结后受影响输入 fixture、GUI smoke、默认本地门禁续跑。P1 多语言输入/意外提交;Supervisor 不适用,失败回写 owner tests 与 Gate oracle。 -- 架构:非重大,现有编辑事件与 metadata 合同内收敛,无 public protocol/read model/业务 owner 变化。完整富文本编辑器与 scoped persistent draft 仍单独待办。 - -## Composer 附件与 IME 冻结记录(2026-10-14) - -- 附件:3生产/2测试文件,deferred FileReader 在旧实现中复现12项生命周期失败,修复后两个hook21/21通过;5个输入消费者172/172通过。只隔离回调,不中止FileReader;卸载采用layout cleanup同步失效。日志 `/tmp/lime-attachment-generation-{repro,hooks,consumers,lint}.log`。 -- IME:旧pendingImeSendRef、compositionEnd RAF submit、ime trigger类型及旧正向断言已删除;BaseComposer/Controller/InputbarSend 3文件41/41、IME与受影响oracle 6文件157/157通过。未将重叠的InputbarSend用例重复合计。 -- 标准typecheck、root窄lint、12项current/dead回流守卫通过;legacy report的零引用/分类漂移/边界违规均为0。新两个研究报告原被research全局ignore覆盖,已加入精确白名单;不纳入原始应用源码或Evidence大包。 -- 真实输入Gate:`.lime/qc/gui-evidence/claw-chat-current-fixture/codex-gui-alignment-composer-ime-final-summary.json` 的ok=true、exit0;四类IME信号无submit请求/无canonical Turn,随后真实按钮提交,backend富输入/取消、GUI文字/图片/路径/Skill恢复及read model interrupted均通过。mock/legacy/console/page error为0;合成DOM事件不证明系统候选窗口或Codex实时GUI。 -- 实际App Server路径为`lime-rs/target/debug/app-server`,SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`;pendingQueue/Browser最终Gate也使用prepare后的此摘要。worker此前重建摘要不替代实际Gate摘要,不推导脏树精确源码构建等价。 -- 默认门禁 `verify:local` 已退出0:五语言/unused、lint、硬编码扫描、标准typecheck、Vitest 91→121的31批3553项续跑(状态121/121 passed)、contracts、7个受影响Rust crate的3179项测试以及真实Electron GUI smoke全部通过。shell evidence为`.lime/qc/project-gates/standalone-shell-01-20261013203631-5833/shell-01-electron-smoke/summary.json`,含冷reload、3个窗口尺寸、memory设置和真实App Server初始化。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-composer-local-final.log`。 -- 本切片current,无新增compat/deprecated;被替代IME行为为dead/deleted/forbidden-to-restore。完整富文本结构、text_elements发送/恢复、持久草稿与全面账本仍未关闭。 -- 两项风险切片完成度100%;该百分比仅指上述合同,不作为全面GUI百分比或release/Windows证据。 - -## 结构化 Composer / EditorView Verification Contract - -- 负责人:root协调,lime_gui_comparison负责input-kit文档/状态/serializer/view,lime_desktop_capabilities负责typed submission传输;GUI消费者与测试/fixture分属窄写集,在接口冻结后开启。预算`budget:normal`,风险P1。前一冻结写集的默认门禁已全部通过,不复用为本切片产品证据。 -- 参考:main-composer-audit的REA/ASAR identity;观察到ProseMirror EditorView、controller、serializer、keymap、submit-ready、destroy职责。开发源码类名unknown,不妨碍领域结构直接迁移;只确认style/诊断的名称不冒称export。 -- Current主链:同一首页/会话ComposerEditor → ComposerController唯一EditorState/document → 同次submit receipt → typed ComposerSubmission → start/steer/queue gateway → preload/IPC → App Server现有UserInput/text_elements → canonical Thread/Turn/Item;恢复只读receipt.draft,发送只读receipt.submission。 -- 冻结内部接口:ComposerDocument.editorDocument为严格JSON;display text/selection/mention/textElements/pending paste从唯一PM文档派生。ComposerSubmission为展开paste的text、typed TextElement(最终文本UTF8 byteRange)、typed skill/mention、attachments/path/capability。receipt保留revision与原始完整draft,新增submission;不以展开文本覆盖折叠JSON。SendMessageOptions显式传composerDraft/composerSubmission,不从metadata反推。 -- 文档owner:Controller持有EditorState,View只引用该state并回交Transaction;applyTransaction消费全部appended transaction后原子更新revision/snapshot。PM positions、display UTF16和最终UTF8分别映射;不建第二React/TipTap document store。已有`tiptap/pm`足够,不安装/升级依赖,不新增Rust/JSON-RPC字段。 -- 窄写集A:input-kit的Controller/types/hook/exports;新schema/serialization/positions/keymap/ComposerEditor及相邻行为tests。避让BaseComposer、产品adapters、wire与fixtures。 -- 窄写集B:InputbarSend及snapshot→HandleSendOptions/SendMessageOptions→preparation/execution→buildTurnInput的明确透传、queue的统一serializer与stream恢复draft构造;相邻typed/fixture tests。避让input-kit、BaseComposer、InputbarController/EmptyState/CharacterMention与外部Rust/command脏写集。 -- root写集:BaseComposer、InputbarCore/ComposerSection/index/styles、首页/EmptyStateComposerPanel、useInputbarController/HintRoutes/Dictation、CharacterMention及相邻tests/必要领域helper。主textarea/textareaRef/textareaProps/value/selectionStart/setSelectionRange直接迁移为ComposerEditorHandle,不模拟HTMLTextAreaElement。CharacterMention已超过1000行,只拆领域接线,不继续堆业务。 -- fixture写集在独立worker登记:仅主输入选择器迁`[data-testid="composer-editor"][role="textbox"]`,普通设置表单textarea保留;40余脚本的value setter/selection/disabled/读取与shared helper同步,DOM操作走真实EditorView事务,不调用测试侧第二业务API或生产mock。 -- Happy path:输入、mention/file/skill/plugin atom、长paste折叠/展开、selection、换行/撤销、图片/路径和完整history/draft恢复;start/steer/queue消费同一submission且保留typed ranges。确定失败保留完整draft,旧receipt与旧附件读取不覆盖新代。先复现text_elements/restore丢失,再迁移对应旧正向测试与护栏。 -- 键盘合同:IME guard先于mention/hint,检查event/View composing、Process/229;compositionEnd只编辑。hint消费后才history/submit;Shift+Enter换行,普通Enter/button按原canSend/defer/pointer去重/stop合同。paste图片/路径owner先消费,否则统一CRLF/CR→LF、大pasteatom;不允许HTML绕过schema。 -- 必跑:Controller/serialization/positions/View事务行为,输入消费者、mention/dictation、typed wire、queue/restore与fixture oracle定向测试;typecheck/lint/i18n;冻结后真实rich-input/IME/active-steer/home输入Gate、current fixture、contracts、GUI smoke与默认本地门禁。source-string或synthetic事件不冒充系统IME/Windows、跨重启或live Provider。 -- Evidence:deterministic与GUI trace/canonical readback必需,release-artifact与live/eval不需要;P1输入/错误恢复,Supervisor不适用。故障回写最近owner及产品Gate,禁止为旧selector恢复textarea。完整marks/code-block/链接编辑、voice、跨线程/跨重启persistent rich draft与Queue durable active handoff继续独立登记。 -- 架构影响:重大,Renderer document/transaction owner与typed输入数据流收敛;实施同刀更新architecture中的Composer图,root在冻结时核对state、snapshot、wire、恢复与测试门禁并确认。未确认/未取得新Gate不标记本切片完成。 - -## 结构化 Composer 实施与迁移记录(2026-10-14) - -- A:schema/serializer/positions/keymap/Controller/View 的 5 文件48/48;7个消费者149/149;第二批 helper/五语言主输入命名7文件68/68(新增helper18)。各窄lint/diff通过。以上测试集有重叠,不合计成唯一总数。 -- B:typed submission→start/steer/queue 显式传输,完整 restoreDraft 深拷,精确 textElements remap、真实接受结果透传与失败重抛;20文件316/316及31文件窄lint通过。旧失败复现4/4保留;不以传输单测代替跨 surface 验收。 -- root:BaseComposer、首页/会话/mention/dictation迁至真实Editor;CharacterMention 1408行拆为主组件、选择hook、types和纯helper。root上一聚合3文件104/104通过,之后追加 shared owner/history 接线需 fresh 验证。IME guard先于hint/mention,compositionEnd只编辑;View卸载销毁,不创建textarea适配器。 -- fixture:首批72文件迁移,71 mjs语法检查、smokeChecks窄lint、14文件220/220与current-entrypoints16/16。追加完整typed input/same client User Item/media字节/active Steer/独立IME/唯一可见editor oracle:4文件84/84、受影响smoke9/9;未重复220基线。history Gate在同rich场景追加,冻结结果待记。 -- dead/deleted:`InputbarPluginBadge.tsx`、`SkillBadge.tsx`及3个旧Plugin文本helpers;catalog为retired-composer-reference-sidecars禁止回流,调用者/tests直接迁。旧主textarea领域名称、styles/ref/props及五语言core.textarea key均直接替换,无新增compat/deprecated。普通设置表单textarea不属于被退役主输入。 -- root文档写集:architecture Composer state/transaction/receipt/wire/restoration图、main-composer-audit、design-language、scripts README与本计划。重大架构确认:root,2026-10-14;已核对目录归属、数据流、依赖方向、既有协议边界与验证门禁;实际Gate尚待新冻结产物。 -- 1000行例外:EmptyState约1130行,本次仅注入共享Controller与既有资源/历史接线,不继续堆业务算法。能力route UI派生已拆独立hook;退出条件是后续首页能力/推荐逻辑改动先拆领域owner,当前不为行数引入第二store或接口包装。 - -## 首页/会话共享草稿与接受锁 Verification Contract - -- 负责人:B负责既有workspace local display owner/scene与transport;root负责surface props、能力route与资源消费者;A负责Controller hook注入和附件generation。风险P1,budget:normal;沿用上节唯一业务主链,不新增JSON-RPC/Rust字段/依赖。 -- 实际缺口:request发布即切首页→聊天,局部Controller和图片丢失;failure只保留string。`return executeSendPlan` 未await导致finally提前释放prepare锁;迟到success能清新surface draft,queue可重复add。先失败复现:6项新断言均失败,日志`/tmp/lime-composer-scope-repro.log`。 -- 最小修复:用workspace Controller snapshot替换原input string store,同实例注入Home与Inputbar;materialization preserveInput延续scope,显式导航/new-chat/back-home完整reset并同步清canonical路径。附件直接消费Controller resources,不建空挂载镜像;能力canonical route在同owner,UI候选详情不是第二事实源。 -- 接受前同scope互斥;start/steer/queue与local confirmation等待真实返回后finally释放。确定false/reject完整草稿保留,success只有相同revision可commit;外部编辑/restore/reset或surface remount的旧receipt不清新输入,结束后正常重试/下一条仍可发送。 -- 必跑:shared hook mount/unmount与严格JSON/资源、deferred true/false/reject、新revision、explicit scope reset与same-scope hydration、materializer preserveInput、queue重复/失败重试;输入消费者history ArrowUp/Down回归;typecheck/lint,冻结后同新版Gate与默认门禁。 -- Evidence边界:Gate键盘history检查真实可见token/附件/path/capability与无额外input;hidden paste payload恢复由Controller行为test证明,初始wire另证明完整展开正文。不复制paste原文到DOM attribute以伪造可观察性,不把分层证据提升成不存在的history payload readback。 - -### 共享草稿补充护栏与新冻结 - -- A第三批9文件:external Controller不建shadow;两个附件hook直接canonical派生;generation对精确own写入豁免、外部同步重入/reset失效。clear/restore每次force新state/revision,普通同值setText/replaceDocument仍no-op。修前6红/38绿,修后6文件72/72与窄lint/diff通过。 -- B第三批19文件:shared workspace owner、显式scope reset、首页materialization preserveInput、await acceptance与统一start/steer/queue pending锁;9文件89/89、追加Reset2/2、19文件lint通过。 -- B第四批7文件:usePathReferences删除useState,同步canonical写与snapshot派生;空scope使用clear;useInputbarSend提交瞬间capture canonical资源/route,不回灌旧React props。path/scope旧行为3 fail、send旧closure3 fail;修后3文件26/26及7文件lint/diff通过。 -- root移除两surface路径与附件镜像;历史/中断恢复不再clear/add同owner资源,UI直接canonical派生。能力初始化signature与恢复requestId按Controller identity一次消费,避免重挂载回放旧预选/恢复覆盖用户编辑。Home发送同样读取当前文档,restoreDraft严格保留receipt原始route,不从fallback候选重建。 -- 新root失败归因:force restore后非稳定effect重复执行导致maximum-depth,已在同Controller恢复request owner加once消费;builtin fallback候选不能改原receipt route,恢复源回到完整原稿。失败日志`/tmp/lime-composer-root-canonical-tests.log`保留,不列成功证据。 -- history fixture追加5文件:真实accepted/clear→ArrowUp富草稿→ArrowDown空草稿;可信键盘事件、同session焦点、DOM结构/token identity/PNG字节/path identity/已挂载Skill、canonical与submit trace无新增。77/77新oracle、既有rich合同1/1与syntax/diff通过。真实Gate仍待统一build。 - -### 结构化 Composer 冻结后验证(持续更新) - -- root五核心文件212/212、新增恢复请求重放回归后的Inputbar 92/92通过;标准renderer/node typecheck与窄lint通过。guards273/273、contracts、legacy report通过,后者零候选/分类漂移/违规。测试集有重叠,不合计为唯一总数。 -- 删除SkillBadge后唯一unused的`skills.inputBadge.remove`已从五语言`agentSkills.json`清理;i18n完整性与unused检查均exit0。第一次`verify:local`在该unused键失败,尚未形成新编辑器默认门禁成功证据。 -- GUI续测事实源移除旧主textarea选择器,改为唯一可见同session的ComposerEditor与真实EditorView编辑;普通表单与TUI textarea不在清理范围。 -- 首次新版rich Gate exit1,证据`.lime/qc/composer-alignment-review/rich/rich-summary.json`:prompt、大paste atom与Skill已插入,无console/page/invoke错误;expected `/var/.../SKILL.md`与App Server canonical `/private/var/.../SKILL.md`别名导致严格身份断言失败。夹具将解析实际文件realpath作为同一expected identity,保留错文件/错身份失败,不放宽为suffix检查。该失败不记成功;五语言清理后重新prepare并冻结再取产品证据。 -- 追加Skill identity:公开机器name是目录名`capability-report`,frontmatter `Capability Report`是display name;夹具明确机器name/token id/label,DOM/wire/canonical/history共用真实文件identity。同文件别名通过,同名同内容其他文件/missing/wrong identity失败。8脚本冻结,102/102新定向、既有rich合同1/1、syntax/format/diff通过;失败predicate回放保留。生产和App Server未改。 -- 新build/prepare exit0,实际二进制与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。新版active Steer、home-hotpath、home-hotpath-greeting均exit0(`.lime/qc/composer-alignment-review/{steer,home,greeting}`);Steer为runtime+本地provider fixture,typed request/client User Item/同Turn终态保真且无新turn/start或queue;home为external backend,绕过provider执行层。零console/page错误,不冒充live。 -- `verify:local`第二次在`InputbarCore.test`迁移后未使用的两个editor工具import处失败,已精确删除并清理旧textarea局部名/描述。后续默认门禁停在22/125:模型设置fixture缺少canonical通知、失败发送测试仍期待resolve,以及Node24原生fetch与jsdom AbortSignal混用。已分别修正合法空ACK/显式canonical通知、reject断言、Host测试的Node环境;完整useAgentChat 187/187、Host 49/49及窄lint通过。生产行为和真实HTTP/PNG缓存断言未放宽,下一次从失败批次resume。 -- 图片上传fixture已从错误Core祖先查找迁为实际添加图片按钮→filechooser,并严格核对chooser input属于同session唯一Editor所在Surface。53/53定向及rich合同1/1通过,媒体字节和identity oracle保持严格。 -- rich真实历史恢复暴露P1产品问题:CharacterMention将恢复的不可编辑atom标签解析为新提及,捕获方向键后阻断ArrowDown。只读诊断确认Controller历史游标和PM/display文末选区正确,诊断结果不作成功Gate。最小修复只从现有PM文档投影解析候选,并拒绝候选范围与atom重叠,不建第二状态owner、不改serialization或paste源文。 -- 新增五类atom的真实EditorView ArrowUp/Down组件回归与atom后新输入`@`/`/`正向;修前4项失败(text_element另改实际展示内容后覆盖),修后7/7,相关21文件162/162。renderer typecheck与四文件lint通过。修前/修后日志分别为`/tmp/lime-composer-history-atom-{repro,fixed}.log`;聚合为`/tmp/lime-composer-history-related.log`。 -- 键盘Gate observer修正capture+queueMicrotask提前读取问题,改同Editor后注册的bubble listener同步记录defaultPrevented。55/55脚本定向含6项事件时序回归,trusted/focused/session/严格消费与canonical不变;日志`/tmp/lime-composer-history-keyboard-tests.log`。夹具修复不能替代真实产品Gate。 -- 新增跨atom且含emoji的查询边界回归,history组件8/8;全部提及选择动作同样使用atom-aware解析,renderer typecheck与五文件lint通过。fresh rich第一次已证明history键消费、完整可见原稿/资源及返回空稿,但发送接受证据predicate仍失败,`.lime/qc/composer-alignment-review/rich-final/rich-summary.json`保留exit1,不计成功。 -- 修复真实质量缺口:Vitest resume只加载旧files会遗漏新增回归,并重跑失败批次之后已passed的批次。既有runner现复用同一collect/live过滤/buildBatches,追加新pending,保留旧批次/开始时间/失败起点并跳过passed。7项真实CLI+fake Vitest行为回归,runner19/19、相关30/30通过;只读preview原125批保留,新增126包含图片上传和CharacterMention.history测试,未修改默认状态。日志`/tmp/lime-vitest-resume-discovery-{baseline,targeted,related,preview}.log`。默认门禁现重新resume,新测试将实际进入执行。 -- 该次resume从22运行到34/126,在过期Skills静态guard失败:同session选择器已迁入真实shared helper,但guard的源集合未包含它。仅补`composer-editor-dom.mjs`进入真实依赖集合,原断言保留;完整guard82/82、窄lint通过。日志`/tmp/lime-composer-fixture-selector-guard-fixed.log`。生产mock/唯一owner/identity护栏未放宽。 -- 独立review发现P2:send preparation在结构化原稿分支以dispatch备用route覆盖receipt.draft原始route,造成失败恢复丢选择。仅保留原稿route,发送仍用当前dispatch route,非结构化fallback不变。新增3红复现(缺备用、冲突、原稿无route)后preparation8/8及相关14/14通过,原稿JSON/paste与发送/恢复route分开断言;日志`/tmp/lime-composer-receipt-route-{repro,related}.log`。此生产补丁重新开启冻结,需fresh renderer及产品Gate。 -- fresh rich发现发送接受oracle混淆原稿与最终dispatch文本:当前installed_skill route在正文前加已确认的`/capability-report `;真实wire/backend完整正文相同,client/canonical/keyboard均通过。期望现在由fixture机器名与展开原稿独立生成,wrong/missing/double前缀、尾缀、截断、status/client、多文本/多request均失败,最终wire的UTF8范围精确匹配;history/rich115/115及窄检查通过。正式rich已执行历史与cancel闭环,但common旧断言仍期待纯PROMPT,正在迁到完整prepared原稿精确恢复;该次exit1不记整体成功。 -- 默认门禁第二次续跑至43/126,旧Agent API fixture仍期待不存在的零queue count并把pending queue伪造成canonical Turn。测试迁为真实thread/read合同,独立queue owner保留;不恢复renderer排队历史过滤,不合成零值。Agent API与canonical projection42/42、lint通过;日志`/tmp/lime-canonical-session-fixture-fixed.log`。下一次从43续跑。 -- 旧textarea回流护栏的扫描范围遗漏新owner,现只迁该条catalog至完整input-kit与Home EmptyState,普通表单/TUI继续合法。隔离源树真实CLI复现4项漏检后,Editor/Controller/adapter/Home恢复旧路均exit1,真实current/普通表单/TUI源码exit0。相关core/summary/catalog234/234、真实全仓report零违规、lint/JSON/diff通过;日志`/tmp/lime-composer-legacy-guard-{repro,related,lint}.log`。其他catalog并行改动原样保留,无新增compat。 -- 生产写集再次冻结,重新prepare后取fresh rich/IME/history、active Steer/home、current fixture、GUI smoke与默认门禁证据。未修改依赖、锁文件、Rust或公共JSON-RPC字段;完整marks/link/code-block、persistent draft与voice仍独立待办。前轮成功不覆盖本次atom-aware trigger改动。 - -- 最终fresh rich Gate exit0:`.lime/qc/composer-alignment-review/rich-complete-final/rich-summary.json`,68/68,history11项与发送接受14项全部通过。历史导航前后完整输入request各1,无额外start/steer/queue;DOM原稿、Skill/paste identity、图片字节、路径及能力选择保真,ArrowDown回空稿;typed/full canonical/media readback13项通过。same-session唯一可见可用Editor、send-ready、cancel后无正文;console/page/invoke/actionable errors与mock fallback均0。App Server与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。external controlled fixture绕过live provider/runtime prompt lowering;键盘真实,IME/paste为synthetic DOM事件,hidden paste源文恢复由Controller tests证明。脚本134/134与场景合同1/1通过;`/tmp/lime-composer-rich-complete-final.log`为实际进程日志。 -- 默认门禁第三次续跑至52/126,external controlled pending Queue Gate未登记进后端边界精确白名单。只在`appServerRuntimeBoundary.testSupport.ts`追加既有`thread-queue-gate-b.mjs`及相邻测试两条精确路径,未放宽生产默认runtime或目录范围。backend boundary与Queue Gate tests7/7、窄lint通过;日志`/tmp/lime-external-fixture-registration-{fixed,lint}.log`。新版默认门禁继续从失败批次resume。 - -- 默认门禁第四次续跑至72/126,inputRestoreGuard漏登记统一`composerDraftRestore`,且旧UI直写字符串扫描已无实际命中。迁为两UI只能委托同一helper、Controller.restoreDraft只能在helper写入的非空owner断言;仅增加helper精确路径,不扩大目录。guard与首页/会话恢复行为157/157、窄lint通过,日志`/tmp/lime-composer-restore-owner-guard-{fixed,lint}.log`。产品写集保持冻结,继续resume。 - -- 默认门禁第五次续跑至73/126,workspaceBoundaryGuard仍强制旧input useState。迁为共享Controller hook、snapshot派生、canonical setText/clear合同,并用regex禁止旧独立input store回流;不改变布局/Banner或runtime truth边界。guard与共享hook/显式reset19/19、窄lint通过;日志`/tmp/lime-composer-workspace-owner-guard-{fixed,lint}.log`。 - -- 剩余未通过批次中的Composer source/contract护栏完成只读复扫;风险相关5文件16/16,未发现其他旧textarea/badge或独立生产input/resources正向要求,日志`/tmp/lime-composer-remaining-source-guards.log`。行为夹具的useState不是生产owner回流,不误删。旧codex-app GUI文档与执行计划导航已明确迁为历史基线,current实施入口唯一;未接管global/CLI/TUI并行进度。 - -- 默认门禁第六次续跑已通过前端126/126批(包含resume追加的新回归文件)与contracts;当前继续执行工作树受影响的7个Rust crate和GUI smoke。日志`/tmp/lime-composer-local-resume-final-6.log`尚未退出,不记完整默认门禁成功。 - -- 默认门禁第六次exit101:agent-runtime211/211,App Server1855通过/1失败,`json_lines_loop_streams_turn_failed_after_partial_external_backend_events`等待JSONL输出超时。未改产品终态、超时或断言;正在从仓库Rust测试入口定向复现并区分调度/fixture与runtime缺陷。首次直接cargo遗漏仓库V8 resolver,下载404,日志`/tmp/lime-app-server-partial-failure-targeted.log`不作该测试结果;已改用`test:rust:unit`,GUI smoke尚未执行。 - -- 正规单项Rust入口1/1通过,日志`/tmp/lime-app-server-partial-failure-owned-targeted.log`;整包超时原因仍未知,不以单项替代整包。一次直接test artifact诊断缺少Cargo dylib环境而dyld退出,`/tmp/lime-app-server-partial-failure-built-artifact.log`仅为setup失败。新版聚合首次在首场景初始化前因raw target/debug/app-server不存在而exit1,`/tmp/lime-composer-current-fixture-final.log`与unknown-item失败summary留存,不计产品通过。Desktop冻结复制品仍存在且SHA仍为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`,后续以它作为明确APP_SERVER_BIN,不重建或伪造raw路径。默认门禁第七次启用明确Node24与backtrace继续验证;产品/超时/断言未变。 - -- 默认门禁第七次fresh前端125/125、contracts与7个Rust crate共3179/3179通过:agent-runtime211、App Server1856、lime-agent278、lime-mcp174、lime-scheduler36、lime-server119、tool-runtime505。前次partial external JSONL失败项本轮整包通过,原一次等待超时原因仍unknown;未提高timeout或修改assert。GUI smoke开始合法重建sidecar,最终需要重新记录实际产物SHA与exitcode。 -- raw binary只读bounded调查未找到明确删除consumer:fixture仅清lock、assets清dist目的且禁止source/dest相同、V8/sherpa清下载缓存与临时解压、Rust destructive调用所读48文件只清测试资源;本地Rust入口为cargo test --lib且没有cargo clean。无系统调用或进程追踪,Cargo/并行进程归因unknown,不按mtime推断。本轮没有保留该调查的产品或文档改动。 - -- 完整默认门禁第七次exit0,日志`/tmp/lime-composer-local-resume-final-7.log`。fresh前端125批共12220个通过test instances、1671个报告通过文件;与前次resume的126批分母不同,不混用。contracts与Rust3179通过后GUI smoke24/24,证据`.lime/qc/project-gates/standalone-shell-01-20261013234813-36535/shell-01-electron-smoke/summary.json`:真实Electron/preload/IPC/current App Server、reload与3个viewport通过,所有console/page/invoke/preload/renderer/trace/legacy/mock错误计数0。GUI smoke合法重建raw与Desktop复制品后,两者实际SHA均为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;这不是前轮rich的旧SHA,后续场景必须记录自己的产物。新版聚合以明确Desktop `APP_SERVER_BIN`串行执行,日志`/tmp/lime-composer-current-fixture-final-2.log`,目前未退出,不提前记22场景通过。 - -### 下一刀补证:scoped persistent draft - -- 独立只读REA与Lime存储/导航调查完成,无产品实施;[保存审计](../research/codex-desktop-rea/composer-draft-persistence-audit.md)与Evidence索引记录`ev_6ac3324c94242434149d9b820ee6cecdc0165021adadac0bdca91254ab787c29`,37 inert声明、structural complete/semantic unknown、parse失败0。 -- 上游两个key保存PM JSON/prompt/plainTextMode;document change合并发布、删除立即发布、dispose retained、pagehide/reload flush及main persisted atom同步有静态anchors。client/canonical local双写与home/cloud scope明确;真实重启/耐久性/完整资源保存unknown。 -- Lime目前没有keyed Controller草稿或持久store;TaskCenterDraftTab仅导航metadata,transcript cache与promptHistory不是未发草稿owner。两条首发materialization和无draft首页分支都需转绑定;sessionId和canonical threadId须显式区分,不可用restore改变待接受receipt revision。 -- 候选实施必须先登记新Verification Contract、唯一scope/I-O owner与架构图;不借用provider UI state、不预建runtime、不在现行Editor Gate期间夹入产品变更。已有存储封装quota删除重试与无成功结果需先修合同,跨重启附件不得用预览图或内存结果冒充。 - -### 下一刀补证:整条 UserMessage 折叠 - -- [用户消息补证](../research/codex-desktop-rea/user-message-collapse-audit.md)与索引已落仓;两个 parent Evidence 均为选定范围 structural complete / semantic unknown、parse failures 0,不运行目标。普通正文消费完整text字段,默认20行实测高度折叠,不能据此制造逐paste chip。 -- 名称只确认member stem `collapsible-user-message-content`、压缩绑定`v as t`与exports n/t;候选`CollapsibleUserMessageContent`是同职责领域命名,不声称恢复原开发符号。 -- current canonical用户正文在`ConversationTurnTimeline`直接走Markdown,`MessageUserBody`承接message_group另一入口;实现必须覆盖两者。复用MarkdownRenderer、VirtualizedTurnList的现有ResizeObserver与scrollController.holdViewport;不新增高度cache、消息store、协议或runtime。 -- 候选最窄写集为新折叠组件/相邻tests、两正文入口、MessageList/MessageListItem的callback透传与五语言agentMessageList.json;实现前另填Verification Contract。复制/编辑继续使用完整原文,图片/Skill标签留在正文折叠之外。代码块裁剪、隐藏交互焦点、内容/宽度重测与find reveal按可确认合同逐项验收,不能以字符数估计高度替代。 -- 当前只读准备完成,产品尚未实施;需在结构化Composer新版聚合/默认门禁关闭之后开启。参考窗口表现及未穷尽producer/Markdown分支继续unknown。 - -### 正常 UserMessage inline 引用追加补证 - -- 同摘要成员的普通exact AST闭合正常Markdown link token→Skill/plugin/app专用consumer链;新REA只核对primary Hd身份项1/1963,返回形状仍unknown,不能当完整模块语义。审计与索引保存4个成员SHA、12 exports、10 imports、30声明范围与37 anchors。 -- Skill按规范化href/path或无href时名称唯一匹配catalog才绑定身份;保留label/href/copy属性与条件侧栏导航。plugin/app有独立URI/host/metadata consumer。裸$skill、producer→wire→history、复制回贴与真实点击仍未闭合。 -- 旧“正文下层Skill/mention未知”已由这条静态consumer补证局部推进;原typed sidecar不直接进入字符串的观察仍成立。Lime已有Composer atom、typed wire和单个route标签,不足以证明正文内联身份回放已对齐;产品迁移继续独立待办,不改wire或伪装任意label。 - -## 长 UserMessage 折叠 Verification Contract - -- 负责人:root;登记:2026-10-14。预算 `budget:normal`,风险 P1。开启条件已满足:结构化 Composer 的新版 current fixture 聚合与完整默认门禁均 exit0;当前切片已开启。 -- 参考:[整条用户消息审计](../research/codex-desktop-rea/user-message-collapse-audit.md)的两个 REA parent 与同摘要精确 AST。确认 member stem、20 行实测阈值和整条正文折叠;原开发 export、精确裁剪样式与目标应用真实交互仍 unknown。`CollapsibleUserMessageContent` 为本地职责命名,不写成 confirmed upstream export。 -- 产品窄写集:新正文折叠组件及相邻行为测试;`ConversationTurnTimeline` 与 `MessageUserBody` 两条正文入口;`MessageList`/`MessageListItem` 只透传既有 `scrollController.holdViewport` 与 focused Item/reveal token;五语言 `agentMessageList.json`。如 installed Skill 分支正文需拆分,只改 `MessageListUserContent` 中同一正文接点,标签和图片留在折叠外。不修改 Markdown wire、Composer、runtime、store、协议、未知 Rust/approval/command 改动。 -- Current 主链:既有 typed submit 或历史读取 → Electron preload/IPC `app_server_handle_json_lines` → App Server `turn/start`、`turn/interrupt`、`thread/read` → 同一 canonical Thread/Turn/Item → `canonical_turn` 或 `message_group` → 现有 Markdown/纯正文 renderer → 显示折叠。折叠只拥有局部显示与焦点状态;没有新的 gateway、runtime event、tool、approval 或 artifact。 -- Happy path:全量正文布局高度严格超过 `20 * lineHeight + 1px` 才显示初始收起和切换按钮;短正文无按钮。正文/字体/宽度变化重测并尊重用户展开选择。折叠不裁掉源字符串,不改变 typed ranges/媒体/引用身份;复制与现有编辑仍传全文。 -- 焦点与列表:按钮支持真实 Tab、Enter、Space 与准确 `aria-expanded`;被遮挡的互动元素不可聚焦,可见互动元素保留可达。收起时若焦点即将被隐藏则回到切换按钮。使用现有 virtual row ResizeObserver/anchor 算法,切换前 holdViewport;不加第二高度 cache。现有指定 Item 导航与浏览器 find reveal 的支持范围需由实现和证据明确,不能默认宣称全部覆盖。 -- 定向回归:严格阈值上下 1px、软换行、代码块跨边界、正文/宽度重测、展开偏好、内容身份更新、observer 清理、隐藏/可见链接焦点、全文复制/编辑、两个入口与五语言。护栏验证两入口共享单一正文 owner,不保留字符数截断或平行用户正文实现。 -- Electron 窄写集:rich 场景只接独立 collapse helper;launcher/scenario-flow 仅传测试侧 clipboard reader;相邻 oracle/tests;session-history long-list fixture/oracle/runner补非尾部 canonical 长正文、冷启动、焦点与相邻锚点。全新 helper 进入既有 `scripts/agent-runtime/` 或 `scripts/electron/lib/`,不得新建脚本领域或产品 IPC。 -- Gate B:每条可见 bubble 要关联 ancestor `data-render-entry-kind`、exact Turn/Item、唯一 user bubble;canonical 另核 `data-direct-segment-kind=message`。rich 历史68/68未采该入口,且 itemId 为 null,不能据旧 readback推断其 renderer。真实点击/键盘后采 ARIA、实际几何与尾部;两状态用 Electron clipboard精确比对全文,仅落长度/hash/布尔证据。若 rich 实际走纯正文,只证明该入口;链接焦点由 canonical long-list补证。 -- 锚点与业务不变:真实 wheel进入距底大于64px旧历史,切换前后比较 surviving first-visible Turn key与viewport-relative top,后继row offset跟随实际高度且不重叠。完整trace禁止新增 `turn/start`、`turn/steer`、`thread/queue/add/update/start`;再次thread/read核正文/content parts/text elements/client/Item/Turn一致。缺trace、错入口/Item、只改ARIA、截断copy、隐藏链接可聚焦、锚点漂移或相邻重叠均失败。 -- 必跑:受影响 component/projection/oracle tests、renderer typecheck、窄lint/i18n、legacy report;冻结后rich与long-list真实Electron场景、current fixture、GUI smoke与默认本地门禁。未改public boundary时不重复contracts;无live Provider、Windows/packaged或参考应用实时证据。 -- 架构影响:非重大,renderer在两条既有正文路径复用单一局部显示组件,原投影/持久化/测量/滚动owner与依赖方向不变。实施结束复核实际调用图,若引入跨层owner改变则重新登记并更新architecture图。 -- 退出条件:两个入口与所有调用者迁移、有效旧测试/fixture/护栏同步、定向和真实证据通过。Skill/plugin/app专用inline consumer、persistent draft、marks/voice与其他全面账本不在本切片完成分母。当前产品/新Gate完成度0%。 - -### Composer 切片关闭与长正文实施记录 - -- 新版聚合exit0,22/22实际Electron场景成功,所有声明assertions为true;证据快照`.lime/qc/composer-alignment-review/current-fixture-complete-final/aggregate-summary.json`及各场景summary,日志`/tmp/lime-composer-current-fixture-final-2.log`。实际backend SHA仍为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;21个Claw场景明确mock/legacy计数0,Coding场景缺独立mock计数字段,不伪填0。全部console/page errors0、noInvokeErrors true;runtime场景使用本地provider fixture,其余external绕过执行层,liveProviderUsed=false。聚合通过不扩大各子场景合同。 -- 结构化Composer当前实施合同(唯一Controller/EditorView、typed submit、共享surface草稿、接受锁、atom历史与receipt恢复)100%关闭;marks/link/code-block、跨重启scoped草稿、voice及任意正文inline引用consumer仍在待办。全产品不提供无明确分母的百分比。 -- root认领两正文入口、tagged正文、MessageList/Item回调/准确identity接线、五locale及相邻integration/纯identity回归。组件归lime_gui_comparison,rich归composer_fixture_migration,long-list归lime_desktop_capabilities;产品开启前草稿只在/tmp,未夹写冻结产物。 -- 新共享组件从自然full root读取实际lineHeight;内层RO、同高度异步descendant变化MO、隐藏互动inert/原值恢复、焦点preventScroll、递增reveal去重。MO缺口先红复现后修复;组件11/11与窄lint通过。用户展开选择只在当前实例保留,虚拟卸载后重挂与冷重启默认重置;尚未声称跨卸载选择持久化。 -- 两入口的复制/编辑保留现有完整字符串,tag/image在折叠外。用户bubble identity纯转换移入`messageItemIdentity`,精确Item/client匹配并去重;同Turn多User未匹配或重复client时fail closed,不取第一项。根写集78/78(含组件11、identity6、integration11和既有消费者50)与标准typecheck通过,日志`/tmp/lime-user-message-collapse-{related,typecheck-final,root-lint}.log`。首次误用不存在的Vitest配置为setup失败;首次integration两用例被canonical投影吞并,改为纯identity测试与合法local正文reveal,不恢复错误renderer路径。 -- 新脚本治理通过:`/tmp/lime-user-message-collapse-scripts.log`;未新增根脚本或一级领域目录。rich 首命令因误传 `--output-dir` 在参数解析阶段退出,未启动 Electron,不算产品失败;改为 `--evidence-dir` 后真实 Electron 已启动,但共享 trace oracle 错误要求所有 current request 显式携带 `jsonrpc: "2.0"`,合法 sender 的缺省版本被拒绝。日志 `/tmp/lime-user-message-collapse-rich-gate-2.log`;失败证据 `.lime/qc/user-message-collapse/rich-final/user-message-collapse-rich-final-summary.json`。仅修复 oracle 对合法协议形状的识别,保留错误显式版本、截断/饱和、解析遗漏和输入 mutation 的负向门禁;真实 rich 与 long-list 尚未通过。 -- 缺省版本修复已冻结,current envelope 与既有 oracle/parser 合计243/243通过;实际 schema fixture 明确 `sendsJsonRpcVersionField=false`。历史 rich trace 达240条,不能凭过滤后的 request 数绕过完整性要求;增加仅测试侧的连续 append 观察,保留原 native Storage 调用语义,并以同一 observer run identity 校验 baseline/final 完整前缀;原 ring 模式饱和仍失败。长历史首跑的原滚动/分页 oracle通过,但新 probe224不在实际已加载227–240区间,故退出1;改为已加载非尾部 probe并保留前后邻居、真实 wheel 与冷启动合同,不能延长 timeout 或模拟滚动/历史请求。日志 `/tmp/lime-user-message-collapse-long-list-gate-1.log`;失败目录 `.lime/qc/user-message-collapse/long-list-final/`。 - -- 连续 collector/rich 写集于 `2026-10-14T00:44:42Z` 冻结,6套286/286通过;manifest `/tmp/lime-user-message-collapse-rich/continuous-collector-write-set.json`。collector仅测试侧连续观察 native Storage,独立重算每次 append 的前后 ring/hash,seed不算accepted input,hook丢失/非法写入/reset/restore失败拒绝通过;raw ledger只驻内存,证据仅留模式/run identity/count/hash及实际 `nativeRestored`。 -- long-list 写集已冻结,5套本体186/186与共享collector69/69(初次teardown错误修复后复跑)通过;9文件lint/格式通过。probe统一232,实际检查末页声明和mounted canonical tail,再以真实wheel验证231/232/233;正文UTF16 9682、UTF8 17673,SHA-256 `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`。runner最小消费pure白名单失败extractor,不落正文。产品15文件摘要存 `.lime/qc/user-message-collapse/product-write-set.json`,raw与Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新的真实Gate正在重跑,不能将以上unit/integration计作Electron通过。 - -- 新rich真实运行仍exit1:连续collector32733次append、`complete=true/nativeRestored=true`,但初始collapsed几何未达合同;console/page errors为0。日志 `/tmp/lime-user-message-collapse-rich-gate-3.log`;证据 `.lime/qc/user-message-collapse/rich-continuous-final-1/`。新增safe失败extractor21项与既有测试合计277/277通过,只补取证,不放宽identity/几何/timeout。 -- 新long-list真实运行exit1:进入已加载probe时工作区触发React #185更新循环,frame消失,故报wheel scope不匹配。safe诊断记录进入前准确session、14-entry末页、canonical tail240与连续trace无mutation/错误、native恢复;不能将scope消失误记夹具通过。证据 `.lime/qc/user-message-collapse/long-list-continuous-final-1/`;开发构建 `.lime/qc/user-message-collapse/long-list-debug-1/` 复现Maximum update depth exceeded。当前定位虚拟行重挂时full→collapsed高度与同步range/anchor的反馈,需完整堆栈与先红回归确认,尚未修产品。default/current-fixture/GUI smoke本切片均未开启。 - -- 开发构建第二次复现的完整stack `.lime/qc/user-message-collapse/long-list-debug-2/` 指向 `CollapsibleUserMessageContent.measure` 的 `setMeasurement` 与同步layout effect。组件owner认领原两文件,候选最小修为dispatch前同值ref guard、同步测量不依赖每次父render的新children对象;真实DOM变更继续由RO/MO重测,inert/reveal/20行合同保持。定向先红回归需区分重复测量触发路径与真正#185复现,真实dev及fresh packaged Gate负责关闭bug;不提前写已修复。 - -### 长正文动态测量反馈修复(2026-10-14,进行中) - -- 组件最小修已冻结:dispatch 前使用 measurement ref 拒绝同值,同步 effect 不再依赖父层新建的 children JSX;真实子树变化仍由 MO/RO 测量。组件与现有列表/接线 31/31,通过不代表真实更新循环已修,dev Electron 仍失败。 -- 新父层回归 `VirtualizedTurnList.measurement.component.test.tsx` 已直接先红复现 Maximum update depth exceeded:阅读锚点 turn-10 顶部 -40px,上方 overscan 中 turn-8 实际行高从 1200px 降至 224px,经父 RO 测量后 mounts=18/unmounts=18。真实 React、现有折叠组件和虚拟列表,仅为 jsdom 注入几何/RO;断言 DOM identity、锚点与挂载次数,不复制保锚算法。root 复跑失败日志 `/tmp/lime-user-message-collapse-parent-red-root.log`。 -- 测试侧被动 native-full-rect-call 采样已冻结,三文件窄写集:long-list input helper、geometry 白名单、新相邻 input test;4套78/78和 lint/格式/语法通过。最多300条、WeakMap mountId、错误时同步冻结、退出恢复 native;计数仅针对有界窗口,completeHistoryClaim=false,不宣称所有调用均由组件 measure 发起。 -- root 下一生产窄写集为既有 `VirtualizedTurnList.tsx` 的 render range 接点及上述相邻回归;不改共享协议、runtime、scroll owner 或增加高度 store。候选为 layout 变化时先复用 preserveThreadAnchor 求与新 layout 一致的 range top,既有 layout effect 继续提交实际滚动;等待 dev 被动采样再实施。所有 frontend/test 写入在真实运行期间冻结,避免 Vite reload 污染证据。 -- 本轮未关闭项仍为 fresh rich、long-list/cold restart、current fixture、GUI smoke 与 verify:local。旧 15 文件源码摘要已因组件修复失效,收口时重新生成。 -- 原生 dev 采样失败证据 `.lime/qc/user-message-collapse/long-list-native-debug-1/`:176次调用、172个mount、10个identity重挂,dimensionChangedMountCount=0/oscillatingMountCount=0;probe232反复新mount且自然正文8301.109375px保持不变。采样窗口不证明完整运行历史,摘要samples另受安全白名单有界输出限制;native已恢复,错误明确为Maximum update depth exceeded。 -- root最小修已落在原range接点,仅对pending layout使用既有preserveThreadAnchor或新tail top选窗口,不在render更新旧基准或滚动。先红1项修后通过,相关列表/几何/组件24/24、两个consumer/identity17/17及四文件lint通过。dev真实复跑进行中,仍未声明产品bug关闭。 -- 修后dev `.lime/qc/user-message-collapse/long-list-native-fixed-1/` 到达collapsed-copy:console/page errors均0;29次native调用、12个mount、remountedIdentityCount=0。因测试用无用户手势navigator.clipboard.writeText设置sentinel被权限拒绝而exit1,不能算完整Gate通过,也尚未证明产品copy成功。标准typecheck exit0;下一步把测试sentinel与finally恢复接到Electron main clipboard,真实按钮与全文readback仍为产品oracle,不修改权限或增加renderer fallback。 -- 新pending导航竞态回归已冻结:同一probe收缩期间目标DOM/位置稳定;真实owner的RAF提交后target top=10px且请求完成,后续用户滚75px旧请求不回拉,第二次导航仍对齐。5文件36/36、相邻lint/格式通过,无sleep或timeout放宽;测试与先前结果重叠,不相加为唯一总数。fresh packaged renderer已开始构建。 -- 同步更正GUI E2E规则中强制显式jsonrpc版本的过期断言,按生成schema与current sender接受缺省或显式2.0,仍拒绝错误版本;不改变协议、客户端或production authority。 -- fresh renderer构建exit0(33.98s),复用已新鲜的Host/sidecar;源码17文件与三个宿主产物摘要 `.lime/qc/user-message-collapse/product-write-set-after-anchor-fix.json`,renderer全文件摘要 `renderer-artifact-after-anchor-fix.json`。snapshot复核无漂移,raw/Desktop sidecar仍同SHA。legacy report零候选/分类漂移/违规、脚本治理、父接点格式、文档护栏12/12均通过。 -- long-list main clipboard接线三文件已冻结,14/14定向与lint/格式/语法通过;writer只设置sentinel和恢复,真实产品按钮产生正文,shared main readback继续核完整hash/length。该定向是controlled callback接线证据,不计实际OS/Electron复制成功。 -- rich/shared七文件于2026-10-14T02:20:41.624357Z冻结,6套319/319与lint/格式/语法通过,manifest `/tmp/lime-user-message-collapse-rich/host-clipboard-write-set.json`;缺回调、读失败/nontext、sentinel未交付、截断、按钮失败、恢复抛错/读失败/不匹配和双重异常都拒绝成功。旧helper page参数与renderer写剪贴板已删除;finally写回后另从main验证恢复。root核对manifest无漂移,fresh packaged long-list已启动。 -- fresh packaged long-list第二轮exit1,`.lime/qc/user-message-collapse/long-list-packaged-2/`:准确collapsed几何与邻居挂载通过,console/page errors0,随后真实产品按钮copy未满足全文hash/length。main sentinel准备已成功,不能把该失败再归为fixture写入权限;具体是clipboard rejection、错误正文还是交互反馈尚待安全观察确认。末尾scope仍正确,用户wheel引发的有界重挂不等于之前同步#185。新fresh rich正串行执行;所有product/scripts写集在运行期间冻结。 -- fresh rich第二轮exit1,`.lime/qc/user-message-collapse/rich-packaged-2/`:同一canonical Item/client同时出现canonical_turn正文(collapsed/root1)与message_group空壳(root0/26×14px);严格唯一identity拒绝通过,不能放宽matcher只取正文。源码确认canonical已经消费同一user image;纯projection复现为inputCapabilityRoute单独保留导致残留空bubble,并非图片未消费。capabilities认领projection与相邻ownership/userCollapse三文件,只迁移canonical已拥有user的route残留,不动其他richsurface或无canonical本地消息;media_reference重复候选单独登记。 -- Clipboard原生观察收敛为测试侧sharedhelper及相邻observation、long-list安全失败字段,不新增Host callback/权限/reader API。原promise/this/args/同步异常保持,侧链记录安全错误enum且清理恢复;观察引入rejection handler的微任务边界要注明。实际write错误确认前不改产品权限。 -- route残留三文件已冻结:projection只在exact canonical user-owned residual清inputCapabilityRoute,其他富surface与无canonical local消息保持;先红3fail,修后ownership/projection/userCollapse/directTimeline四文件53/53、三文件lint/格式/diff通过。新集成证明同client/Item只有1bubble/1正文/1图片/1copy,两个typed标签与完整copy/图片preview身份不丢。未改directTimeline既有脏补丁。此前17文件源码快照已因projection与userCollapse变化失效,后续重新取新的摘要和renderer构建。 -- 同边界独立待办:collectCanonicalMediaKeys未覆盖user image parts,可能产生同引用residual media_reference;尚未修改,不由route修复计作关闭。Clipboard补证已记录于user-message-collapse-audit:main存在readText/writeText宿主服务,但普通UserMessage完整caller链unknown,不把sandbox的权限策略移植到主窗口。 -- route修复后的renderer构建与标准typecheck均exit0,日志分别为`/tmp/lime-user-message-collapse-renderer-after-route-build.log`和`/tmp/lime-user-message-collapse-typecheck-after-route.log`。测试侧原生clipboard observer正在最终冻结:保持native this/全部参数/同步throw/原Promise,安全proof区分实际仍为sentinel、全文不匹配及权限/焦点错误;观察侧链会增加rejection handler和微任务,不冒称完全无影响。待真实错误证明后再决定产品权限或fixture焦点的最小修复,当前未修改主窗口permission owner。 -- clipboard观察11文件已最终冻结,8套258/258、lint/格式/11项Node语法与窄diff通过;manifest `/tmp/lime-user-message-collapse-rich/passive-clipboard-write-set.json`,SHA-256 `2082c12413842eacd391acaad8654454f3b243e0a549d0e65d1abeaee61761ed`,root复核全部11项无漂移。rich聚合oracle失败时未带成功返回phase proof是已登记的诊断限制,不影响long-list本次取证。fresh源码19文件与renderer3190文件快照分别为`.lime/qc/user-message-collapse/product-write-set-after-route-fix.json`、`renderer-artifact-after-route-fix.json`;旧快照保留,不覆盖。真实long-list第三轮已启动,目录`long-list-packaged-3`,未预判结果。 -- long-list第三轮exit1,真实native proof为唯一writeText调用、全文9682字符且expectedTextEqual=true、原receiver、focused/visible/userActivationActive全部成立,但NotAllowedError rejected,实际main read仍为sentinel。observer intervalComplete/nativeRestored=true,console/page errors0。errorKind未知,不擅自重写成已捕获permission-denied;结合生产permission policy明确拒绝clipboard-sanitized-write,进入最小权限修复并由fresh成功验证因果。 -- 权限修复窄写集:comparison worker仅迁移`electron/mainWindowMediaPermissions.ts`及相邻测试为`mainWindowPermissions.ts`/`.test.ts`,唯一策略同时承接request/check;root仅迁移`main.ts` import与安装两处,保留原browserTabOwner脏补丁。主window主frame允许sanitized-write,读/其他window/null/child/unknown拒绝,媒体保持原能力并区分request数组/check单值,不改embedded browser独立session,不加clipboard IPC/fallback。非重大架构变更,仍由既有Desktop Host permission owner管理;退出条件为先红回归、定向/Host typecheck、fresh Host、真实全文copy及对应门禁,无Windows实跑声明。 -- 权限模块迁移已冻结:旧策略27项中7项先红,迁移后permission27 + windowOptions7 + loadErrors3合计37/37;两文件lint/格式、完整Electron typecheck均通过。模块SHA `b98655e57bd66ee3b5388f327096feb0ac50a91e2b01b066c310a755f158e6d0`,测试SHA `a5eca6f91b68de7163346f5fae8ce21952f6eb6dd50c35f8fffd866621150643`;旧文件/符号在Electron中无残留,无alias。root增加测试侧native query helper和相邻oracle测试,session-history入口只接import/call;query主/child secure context与读写状态,不读剪贴板内容、不替换handler,finally移除iframe。17/17和root窄lint/格式通过,真实query与copy待fresh Gate;另window只由策略/handler回归覆盖,不把query结果扩大为实测。 -- tests/guard只读审计未发现产品旧文件名锁定,当前DOM字段服务真实几何和identity。generic collapse oracle合法支持两正文入口,继续保留其message_group正向;rich accepted canonical wrapper需额外明确canonical_turn,避免canonical退回local仍过验收。fixture worker认领shared helper/evidence与相邻rich行为回归,同时修复聚合失败遗漏安全clipboard phase proof;不改产品或long-list已冻结写集,不以源码字符串guard代替实际helper行为。 -- 同user裸图片media_reference的只读probe已定位独立缺口:相同exact Item/client与直接image URI应由canonical接管,当前仍留下空residual;caption、audio kind、不同URI、refId读取owner、assistant同图说明均需保留。该候选尚未产品实施,不无条件扩全Turn URI集合,不将sidecar缩略图成功当作点击预览职责等价。 -- 同Item裸图片去重补刀已认领原projection/ownership/UserCollapse三文件,写窗口与Electron permission互不相交。合同只覆盖exact canonical user owner的同一直接图片裸引用;caption/title、refId/sidecarRef/sha256/byteSize、不同previewUrl、MIME/定位冲突、unknown kind以及assistant同图语义保留。先红空残留与保护变体后最小修,不增加第二投影owner、不扩全Turn媒体集合、不碰directTimeline旧脏补丁;定向集成需继续锁一bubble/图片/copy与原preview identity。其他typed媒体职责不在本补刀完成分母。 -- 同Item裸图片三文件已冻结:先红3fail后projection/ownership/UserCollapse/directTimeline四文件77/77;自有三文件lint/格式/diff通过。projection SHA `740b58aa3a880dd86f00a695a6560057ddd165f1dc43f089345c50e58ea28c1f`,ownership `15014d8ee3f41dcca92741480195423d0ee22f83af333638a54e29c7d730fe65`,UserCollapse `dfd53221e27e78982b31c3d7564eba7c5d83dd7705dfc0a80c95604f43fb7f48`。匹配仅直接URI,额外已定义字段/未知字段与sidecar保留,不扩大媒体职责。 -- rich测试侧owner合同与安全phase补证已冻结:7项先红6失败、generic message_group1通过,最终5套184/184与lint/格式/3项语法/diff通过。manifest `/tmp/lime-user-message-collapse-rich/rich-canonical-phase-proof-write-set.json`,root核对3文件无漂移。generic两入口正向继续有效;rich wrapper要求canonical_turn,aggregate与native复制失败只保留安全clipboardPhases。前一passive manifest的shared helper摘要已被此新manifest取代,不覆盖历史快照。 -- fresh Host/renderer均exit0,main新SHA `9401e36c299c48f1032061406a561b1a27d7e60240953ee5ddb646d0df108003`;raw/Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新immutable源码22文件与renderer3287文件快照为`product-write-set-after-permission-fix.json`/`renderer-artifact-after-permission-fix.json`,query窄写集另存`permission-query-write-set.json`。root当前rules/docs guards23/23、脚本治理均通过。 -- fresh long-list第四轮exit1,目录`.lime/qc/user-message-collapse/long-list-packaged-4/`。主window query write=granted/read=denied,child write/read=denied且均secure、iframe已移除;query只证明check。collapsed/expanded两个严格全文copy阶段均通过,最后原生write fulfilled、全文9682/hash一致、nativeRestored=true;权限修复已有真实因果补证。随后collapse-anchor失败:收起前first-visible successor233 relativeTop=-8.03125px/scrollTop9714.5,收起后first227/scrollTop0;console/page errors0。冷启动尚未执行,不算Gate通过。 -- 新锚点修复写集:comparison worker仅在`VirtualizedTurnList.measurement.component.test.tsx`模拟浏览器随DOM总高收缩clamp scrollTop,保留真实React/现有virtualizer与折叠组件;0/非零prefix两变体先红,不复制保锚算法。root生产只认领原`VirtualizedTurnList.tsx` layout effect,候选直接提交`currentOffset + nextTop`以免在已clamp位置重复扣高度delta,并移出无用offset ref。现阶段为候选,不先写已修复;真实successor/cold Gate负责关闭。 - -- clamp回归先红完成:14条、prefix0/160两变体均被合法layout clamp780px,anchor DOM保持但top从-40变740;原30条overscan与pending导航均绿。root在原effect直接提交currentOffset+nextTop,删除无用previousOffsetRef;修后5文件40/40、生产/测试窄lint和格式通过。新增测试初始化已有一次probe卸载,绝对unmount=0误断言改为折叠前后无增量,DOM/top/clamp/mount其余oracle保持。测试SHA `4716403682585300138c0d1922968c193a679fb4055cd1c41d714661762e3a68`。单位回归已关闭clamp缺陷,fresh真实Gate仍待验证;renderer重新构建与标准typecheck正在执行。 - -- fresh renderer与标准typecheck均exit0,源码/宿主与renderer快照分别保存为`product-write-set-after-clamp-fix.json`和`renderer-artifact-after-clamp-fix.json`,未覆盖旧证据。真实long-list第五轮exit0,`.lime/qc/user-message-collapse/long-list-packaged-5/user-message-collapse-long-list-packaged-5-summary.json`:interactive/cold restart均ok;collapsed/expanded/cold copy完整9682字符,SHA `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`,native fulfilled/observer恢复、可信鼠标键盘均通过。后继233相对top从-8.03125到-8.234375px,漂移-0.203125px,无重叠;旧PID退出且新进程启动,之前expanded的实例冷恢复默认collapsed。canonical正文/content parts/text elements/User Items/Turns不变,连续trace完整、禁止mutation0,console/page errors0。只证明surviving successor锚点,不声明消失正文内部offset保持;实际macOS Electron+编译fixture,不等于Forge安装包或Windows/live Provider。 -- fresh rich第三轮exit1,`.lime/qc/user-message-collapse/rich-packaged-3/user-message-collapse-rich-packaged-3-summary.json`:同Item/client唯一canonical_turn气泡/正文成立,旧空泡已消失。copy-collapsed真实click遭同一user bubble/.space-y-2截获,sentinelObserved=true/buttonClicked=false/native calls0,observer完整并恢复,console/page errors0;这是可点击性失败,不能归为复制权限失败。现有安全快照缺footer/button实际rect及hit-test,fixture worker认领独立安全诊断及相邻测试,不存正文/URL/raw error,不force/dispatch/放宽timeout。root读取styles发现footer由MessageWrapper:hover或focus-within显示;generic wheel仅准备viewport,而long-list references探测经过Tab、rich无此准备。测试漏真实hover为候选,需前后几何和可信hover证据确认,未改产品样式。当前切片保持partial,旧Composer聚合/本地成功不得关闭本轮门禁。 -- 本轮contracts入口exit0,日志`/tmp/lime-user-message-collapse-contracts-final.log`,包含protocol/client/commands、脚本/发布/desktop reference/CLI边界与docs检查;更新后的current-rules/current-docs两套23/23,日志`/tmp/lime-user-message-collapse-docs-guards-final.log`。capabilities只读覆盖核验未发现产品/测试/脚本恢复旧权限名或锁死旧DOM的guard;当前主frame写与读/子frame/other/null/unknown拒绝、媒体request数组/check单值、唯一canonical rich正文、route/直接图片保护、bounded虚拟挂载/锚点消失/clamp/pending导航均保留行为回归。没有为了绿结果删除有效风险断言,未重跑已经通过且未变的定向集。 -- copy准备窄修已冻结:独立安全geometry与prepare helper,shared gate/evidence/rich行为回归合计5文件,manifest `/tmp/lime-user-message-collapse-rich/rich-copy-action-write-set.json`,root核对5/5摘要一致。geometry14/14、接线先红7/8、修后6套199/199;lint/格式/5项语法/窄diff通过。准确target真实hover→既有wheel→copy真实hover→原click,不force/dispatch/改timeout,产品样式、权限、runtime与long-list均未修改。`copyActionPhases`保留beforeHover/afterBubbleHover/ready及失败几何,只存有限CSS枚举/rect/结构关系,hover仅声明Playwright API完成,不伪称捕获可信hover事件。fresh rich第四轮已启动;contracts的protocol生成检查触发生成文件mtime更新,fixture按既有freshness要求重新构建renderer/Host,不绕过新鲜度检查,后续另存新产物摘要。 - -- fresh rich第四轮exit0:`.lime/qc/user-message-collapse/rich-packaged-4/user-message-collapse-rich-packaged-4-summary.json`,全部69项scenario assertions为true,UserMessage Gate全部19项布尔合同成立,五次真实collapsed/expanded/keyboard切换、两状态1705字符完整copy、同一canonical user/parts/Turns与continuous trace完整/无额外input均通过,console/page errors0。before-hover footerheight0/maxHeight0/pointer-events:none/opacity0、button中心命中bubble;ready height24/opacity1/pointer-events:auto并命中copy后代,关闭测试漏真实hover原因。afterBubbleHover采样落在transition起点仍height0/opacity0,不把它当settled;最终ready与真实click/readback负责验收。hover只声明API动作完成,没有event isTrusted采样。新renderer3384项及22项产品写集与clamp快照逐项均无digest漂移,main/sidecar也保持原SHA,因此long-list第五轮仍匹配同产品产物,无需重复。另存`product-write-set-after-rich-hover-gate.json`与`renderer-artifact-after-rich-hover-gate.json`。本轮current fixture已开始;聚合不含session-history独立Gate,long-list/cold证据分别报告。 -- 额外只读核验确认permission旧owner缺回流守卫:现有legacy-report仅扫描src,imports entry对“已恢复但零引用”的文件只列候选,不让门禁失败;current-entrypoints虽扫描electron却未禁旧权限owner。当前无残留结论仍成立,但不等于forbidden-to-restore已落实。聚合结束后认领最小catalog dead entry与Electron permission owner AST guard/相邻behavior tests,由既有current-entrypoints接入;禁止空旧源/旧测试文件、改名旧符号、第二注册owner与重复installer,保留独立embedded session current路径。复用同一检测函数对真实目录/临时fixture验收,不扩大整个legacy扫描根,不碰产品权限决策;在default门禁前完成。 -- 冻结窄例外:packaged聚合的产品产物、fixture加载链与构建输入继续冻结;capabilities可提前在未进入该加载链/构建输入的`permission-owner-guard.mjs`、相邻behavior test与`current-entrypoints.test.mjs`实施独立治理回归。root持有catalog entry,等聚合结束后写入并运行真实目录接线测试;不以测试侧临时mock catalog让current通过。该例外不改变正在运行的renderer/Host/sidecar或scene脚本。 -- 本切片聚合exit0,22个实际Electron场景全部ok且无false assertion、console/page errors;首末summary时间为2026-10-14T04:43:56.536Z / 05:01:32.373Z。日志`/tmp/lime-user-message-collapse-current-fixture-final.log`,root逐项读回并在`.lime/qc/user-message-collapse/current-fixture-final/aggregate-summary.json`冻结22份summary与摘要,不覆盖上一Composer聚合。该入口仍是controlled fixture而非live Provider;它不含独立长列表/冷启动Gate,不能互相替代。root已登记旧权限两文件dead entry;待AST guard真实接线冻结后运行`verify:local`,其内部`verify:gui-smoke`成功即计本轮GUI门禁,不为相同写集另重复一次。 - -### 下一批只读准备:marks 与 dictation - -- [marks/voice审计](../research/codex-desktop-rea/marks-voice-audit.md)已完成,复用三项Composer REA Evidence与两个同摘要原成员,无重复分析;13个UTF16锚点、11个本地链接及格式验证通过。root仅新增精确研究文档白名单,未改产品。 -- 基础schema确认link/code_block,rich PMU schema分支和provider默认选项另行记录,完整marks清单仍unknown。最小后续合同在唯一input-kit owner收敛schema/types、display/draft/submission序列化、UTF8 ranges、PM位置与实际View编辑;不能只加样式或推测bold/italic工具栏。 -- current录音/分段ASR/润色、Controller revision保护及现有API/权限/五语言/dead回流守卫保留。permission等待的starting、cancel、旧reject与不同Controller同revision迟到结果是待先红问题;未复现前不写已确认故障,不恢复已删除的global shortcut/device UI或另建音频后端。真实系统麦克风/权限/识别质量仍独立not-run。 -- 本范围研究映射100%;产品与对应Desktop Gate均0%,不计入长正文完成分母。长正文门禁关闭前不开新产品写窗口。 - -### 下一切片只读准备:scoped persistent draft - -状态:设计候选 / 未开启产品写入。长 UserMessage 的真实 Gate 和门禁收口后,再填写实施合同并认领写集。复用已有 REA 保存补证,不重跑相同分析。 - -- 唯一活动文档继续归 `ComposerController`;scope owner 管理保存记录与绑定,不创建平行可编辑 Controller。实际导航保存旧 scope 后恢复目标;blocked/missing/deferred/error 的 before callback 只能 flush,不提前绑定。 -- 首发覆盖 materialized、direct、有/无 draft tab 的 home→session;promotion 仅迁移稳定 entry 身份,不 restore/clear、不推进 pending receipt revision。sessionId 与 canonical threadId 分别使用真实映射,不能按字符串假定等价。 -- 两处 accepted 消费(Inputbar 与 EmptyState)需要同时结算源 scope/version 与当前 Controller revision。离开 source 后旧 commit 返回 false 只保护新草稿,不会自动消费已发送的旧记录;若忽略此接点,冷恢复会让已提交正文复活。新增编辑、reject/failure 与迟到 interrupted restore 均需专门回归。 -- 导航用 reset+setInput 路径直接迁出;显式编辑全文路径保留。draft tab 冷启动需由同一草稿索引投影 metadata,关闭 tab 与显式丢弃必须分别定义;不在3100行的 `useWorkspaceSendActions` 中堆逻辑。 -- 持久层实施前明确与 Codex main persisted atom 的结构映射。现有 `agentChatStorage.savePersisted` 序列化在 try 外、quota 失败先删旧值且不返回保存结果,不能充当已保存证据;持久 blob URL、跨窗口协调、附件容量与失败保旧需闭合合同。若扩展 Host store,同步既有IPC/preload/gateway/catalog/schema/测试及架构,不借用 provider UI state。 -- 验证必须包含 A↔B/home 富文档、Unicode/paste/mention/resource、真实退出重启、首发promotion、跨scope接受后消费、清除不复活与失败保旧;现有rich/history Gate不证明persistent draft。Codex附件耐久性、账户隔离与强杀窗口继续unknown,不制造上游保证。 -- storage只读补证:savePersisted唯一owner可把序列化纳入try、拒绝undefined序列化结果、单次setItem并返回boolean,失败保持旧值;删除quota remove+retry,不能连带删仍被saveTransient使用的quota判断。AST35处调用均不消费返回值;新draft owner必须消费结果。现有测试缺savePersisted合同,可补circular/BigInt/toJSON/undefined、quota/security/general失败保旧和后续成功;transcript大状态丢弃、可重建resource-manager quota清理仍是有效测试。snapshot索引和迁移marker忽略保存失败是另域既有缺口,本草稿切片不顺手扩写。仅只读结论,未实施。 - -### 权限唯一 owner 护栏 Verification Contract - -- 负责人:lime_desktop_capabilities 实施 AST 检测与恢复反例,root 持有 catalog、计划、报告与统一门禁;2026-10-14。风险 P1,`budget:normal`。 -- 写集:`scripts/electron/lib/permission-owner-guard.mjs`、相邻 `permission-owner-guard.behavior.test.mjs`、`current-entrypoints.test.mjs` 窄接线及 `legacySurfaceCatalog.json` 单项登记。不是产品权限策略修改,不重构 runtime 或扩大 legacy-report 扫描根。 -- 合同:同一 TypeScript AST 检测器消费真实目录和临时恢复 fixture;旧源/旧测试即使为空也失败,改名引用旧符号或旧模块失败。主窗口 request/check 注册归 `mainWindowPermissions.ts`,嵌入浏览器独立 session 归 `embeddedBrowserPermissions.ts`;第二注册 owner、重复/缺失主窗口安装失败,合法 import alias 与 namespace import 保持有效。 -- 验证:先红恢复反例,再真实 current 目录与反例绿;定向 lint/format/语法,catalog/治理和默认门禁。AST 守卫只证明覆盖的静态构造,既有权限行为回归及真实原生复制 Gate 分别证明决策与运行链,不能互相替代。 -- 研究文档校准:差距矩阵已迁到 current 状态,删除“仍缺虚拟列表/审批入口/Files 导航”的旧判断。rules/docs 回归23/23;legacy-report候选、分类漂移和边界违规均0。产品22项与renderer3384项快照复核无digest漂移,main及raw/Desktop sidecar均仍匹配前述真实Gate摘要。 -- link/code block 补证已冻结:`marks-voice-audit.md` SHA `a7da8a2cee7a246ff3af3447e5e292cad983f6a4e2740f50b0425ce73b73ca35`;3个原成员摘要、41个UTF16锚点、bounded AST与11个本地链接通过。普通mark协议、基础fence/语言/转义已确认,rich完整选项、PMU与真实roundtrip仍unknown;未扩产品写集。 - -### 默认门禁发现:贴底转手动阅读的视口竞争 - -- 权限 guard 已冻结,behavior22 + entrypoints17 = 39/39,lint/format/语法/diff通过。root逐项确认3文件SHA并保存 `permission-owner-guard-write-set.json`;worker先红为17 failed/5 passed,证据来自工具输出。新默认门禁实际执行了22/22及17/17,并非只引用旧结果。 -- 首次本轮 `verify:local` exit1,日志 `/tmp/lime-user-message-collapse-local-final.log`;五语言、lint、硬编码扫描与标准typecheck通过,前端103/126批通过,104批的MessageList两项键盘离底/取消已排队贴底帧回归失败。旧数值护栏未删除或放宽。 -- 实际问题:scroll controller先停跟随,原生scroll事件尚未更新virtualizer的viewport;布局变化直接写入了上次程序贴底的位置。root窄认领 `VirtualizedTurnList.tsx` 与相邻test,沿同一viewport owner在keydown/wheel/touchmove的capture阶段同步实际视口;不改变键盘业务策略、跟随判定、clamp绝对锚点提交或runtime。 -- 新独立反例3/3先红:三输入均从29200跳回291680,正确prepend位置应29492。修后五文件63/63,包含原两项失败、三输入竞争、DOM shrink clamp、prefix offset、后继锚点、bounded挂载、copy与折叠保护;两文件lint通过。日志 `/tmp/lime-user-message-collapse-user-input-scroll-{repro,final,lint}.log`。 -- 该生产补丁重新开启冻结。22场景聚合与rich4/long-list5仍是前一摘要基线,不能充当新renderer证明;续跑默认门禁后,针对本次GUI视口变化补fresh rich、long-list/cold、live-tail及resize/reflow真实场景。Host、sidecar、wire与fixture owner未改;不因这项局部GUI修复无差别重跑已通过的全部runtime场景。 - -### 视口竞争修复后的门禁与补验 - -- `verify:local` 续跑exit0,日志 `/tmp/lime-user-message-collapse-local-resume-final.log`:前端126/126批、1689个唯一测试文件;首轮103批与续跑104–126批合计12693 passed instances,失败104批的重复结果不计入。contracts、7个Rust crate3179/3179及真实Electron GUI smoke均通过;新增VirtualizedTurnList测试在106批实际通过。 -- GUI smoke:`.lime/qc/project-gates/standalone-shell-01-20261014055122-75182/shell-01-electron-smoke/summary.json` 为pass,覆盖真实Electron启动/reload/preload/IPC/App Server初始化、设置及三窗尺寸,不单独证明模型Turn或Codex像素一致。 -- 新冻结:产品22项只漂移`VirtualizedTurnList.tsx`这一预期补丁;renderer3481项重新快照。`product-write-set-after-user-input-scroll-fix.json` / `renderer-artifact-after-user-input-scroll-fix.json`保存于`.lime/qc/user-message-collapse/`。main与raw/Desktop sidecar摘要均保持原值。 -- fresh long-list第六轮exit0:9682字符三状态native复制完整、真实PID退出/新进程及默认collapsed恢复、后继锚点漂移-0.203125px、同canonical正文/parts/text elements/Items/Turns不变、完整trace和额外input为0;console/page errors均0。summary在`long-list-packaged-6/`。 -- fresh rich第五轮exit0:69/69 scenario assertions、UserMessage Gate19项布尔合同均true;唯一canonical_turn、1705字符两状态复制、可信键鼠与引用/原稿/接受/历史合同保持;console/page errors均0。summary在`rich-packaged-5/`。fresh live-tail65/65、exit0,GUI首次输出、canonical完成与终态repair通过,无console/page error;是external fixture,非live Provider。 -- resize/reflow首次补验exit1:wide窗口正文、表格、终态及Files面板已就绪,但activeThreadHeaderStable=false。日志 `/tmp/lime-user-message-collapse-resize-final.log` 与`resize-final/`失败summary/截图保留。error在统一sanitizer中截至2000字,关键header谓词未完整留下;不可据此判定已通过或直接修改布局。 -- fixture worker窄认领resize-reflow入口的import/call、独立安全header诊断helper与行为test;只把安全谓词/必要几何放入error前部,保留timeout、等待、header/compact-mode/no-overlap断言,禁止正文、URL/raw HTML。原入口近900行,新增逻辑进入独立helper。新产品写窗口保持关闭,取得具体失败项后再确定最近owner修复。 - -- 安全诊断3文件已冻结:入口仅import/timeout throw接线,独立helper将12个headerChecks及失败项置前,18/18反例与安全截断测试、既有resize接线1/1、lint/format/语法/scripts治理通过。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-diagnostics-write-set.json`;真实diagnostic-2正在执行,等待/超时/断言未变。 - -### Resize/reflow 页头修复 Verification Contract - -- diagnostic-2真实Electron exit1:唯一失败谓词childrenContained。wide页头y8..54、高46;toolbar/actions y-1..63、高64,确认为换行越界。context旧firstElementChild实际选导航,不能继续冒充标题组。 -- root窄写集:TaskCenterUtilityToolbar.tsx仅workbench-header布局分支,ThreadWorkspaceHeader.tsx标题组稳定context testid;task-strip继续按既有行布局。固定页头内工具栏单行,超宽仍归既有header actions横向滚动owner,不增加另一工具栏/业务owner。 -- fixture worker窄写集:resize-reflow入口及独立geometry helper/test;显式current context定位、可见几何消费overflow祖先裁剪。保留header/context/actions可见、contained、no-overlap与compact-mode-bar位置语义;不改timeout、等待、终态或表格/消息/input断言。 -- 验证:旧64/46高度反例继续拒绝;合法横向滚动与真实纵向越界分开覆盖;现有Toolbar/Header定向动作/identity回归、lint/typecheck、fresh renderer三尺寸真实resize Gate、GUI smoke及风险匹配默认门禁。无协议/Host/runtime改动,不扩Rust写集或无差别重跑旧22场景。 -- 架构影响非重大:既有header presentation、overflow与测量owner不变;诊断和几何helper均test-only。当前切片仍partial,fresh Gate通过前不标完成。 - -- root产品最小修复已实施:workbench-header root与group均单行且不收缩;task-strip继续wrap,沿现有actions overflow-x-auto访问超宽操作。标题组增加稳定context定位。现有Toolbar/Header三文件36/36、两产品文件ESLint/格式/diff通过;fresh renderer构建exit0。产品快照24项包含原22项无漂移与两页头文件;renderer本次174项,Host/sidecar三摘要实读匹配。快照为`product-write-set-after-header-layout-fix.json`及`renderer-artifact-after-header-layout-fix.json`,与之前3481项快照区分,不混用文件计数。 - -- geometry测试侧已冻结:旧测量12failed/5passed先红,新几何22/22+诊断18/18、原resize接线1/1与lint/format/语法/scripts治理通过。入口886→715行;唯一helper闭包自足直接evaluate,等待/readiness/timeout逐字保持。完整context与toolbar paint不允许旧回退;额外校验toolbar/actions raw纵向span,裁剪不能掩盖旧64/46错位。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-geometry-write-set.json`。 -- fixed-3启动exit1未进入resize:root误用浏览器build生成`/assets/...`绝对module路径,Electron file页面停在startup,两资源缺失、无业务invoke。该renderer174项快照只保留失败溯源,不能作有效Desktop产品证据。已使用`build:renderer:electron`成功重建为相对`./assets/...`;新快照`renderer-electron-artifact-after-header-layout-fix.json`,fresh fixed-4在跑,生产页头未因此回退。 -- 新护栏写集:fixture worker仅`electron-fixture-build.mjs`及相邻test,按实际HTML module/stylesheet的file资源路径检查renderer可复用性;独立于mtime与其他segment,不改显式override边界,禁止把browser构建或missing module artifact当fresh packaged资源。仅测试装配合同,无runtime/协议改动。 - -- fixed-4真实exit1继续仅childrenContained:完整context定位现正确,但toolbar仍64px;定位上层consumer仍按layoutMode聊天分支传task-strip,未随已经迁入header的位置更新。root追加认领WorkspaceConversationScene.tsx及既有consumer test的最小迁移;placement从同一shouldRenderThreadHeader派生,chat/canvas两种布局挂入header均使用workbench-header,非header使用task-strip,不新增owner或同义helper。 -- consumer先红:chat分支失败、canvas通过(1failed/1passed/19skipped),日志`/tmp/lime-user-message-collapse-header-placement-red.log`;修后Scene/Header/Toolbar四文件57/57,追加非header分支task-strip保持验证。页头横向溢出从起点排列,避免flex-end形成无法滚回的负方向内容;沿原overflow-x-auto,不加新交互。相关四文件ESLint通过,fresh Electron renderer重建中。真实fixed-4保持失败证据,不把helper裁剪后的部分可见当contained通过。 - -- fresh fixed-5真实exit0,三尺寸wide1280×820/compact880×720/restored1280×820,66/66 assertions,console/page errors0。页头46px、actions/toolbar32px(y15..47);完整context、paint containment、重叠、compact栏、table/message/input/right surface与canonical terminal均通过。wide/compact截图已人工检查,通知toast存在但未见toolbar越界或message/input重叠;不从DOM几何推出Codex像素一致或所有水平隐藏操作的键盘可达性。 -- 新源码26项基于原22项零漂移,包含Toolbar/Header/Scene及Scene consumer test;新Electron renderer174项与Host/sidecar三实读摘要匹配。快照`product-write-set-after-header-placement-fix.json`及`renderer-electron-artifact-after-header-placement-fix.json`;标准typecheck、四产品/测试文件格式与diff通过。此前rich5/long-list6/live-tail-final来自视口修复基线,页头补丁不改变其产品owner;fixed-5补验页头和消息/input/table布局,证据分版本保存。 -- 构建复用guard已冻结:旧实现14failed/10passed先红,最终28/28及相关source接线2/2通过;两文件lint/format/语法/scripts治理/diff通过。真实HTML按baseURI解析module/stylesheet现存file,空或无module/root-absolute/non-file/非法/missing/directory均使renderer segment stale;不执行脚本或取网络,finally关闭JSDOM。三segment构建、mtime、appUrl/build-ready显式override保持,依赖图不扫描。manifest `/tmp/lime-user-message-collapse-rich/electron-fixture-build-assets-write-set.json`。 -- 最新`verify:local`已启动,日志`/tmp/lime-user-message-collapse-header-layout-local-final.log`;当前生产与fixture写集冻结到门禁结束,不用前轮126批成功替代本次页头consumer/装配guard结果。 - -### 默认门禁发现:复制反馈生命周期 - -- 新默认门禁 exit1,停在85/126批。该批16文件178项断言均通过,但环境销毁后 `MessageList.tsx` 的两秒复制反馈定时器仍执行 `setCopiedId`,产生 `window is not defined`;失败不能按断言全绿忽略。 -- root窄例外写集:`MessageList.tsx` 复制反馈及独立 `MessageList.copyLifecycle.component.test.tsx`。连续复制从最新成功计时;卸载/切换会话取消旧反馈;迟到clipboard成功/失败不覆盖新会话或新复制。平台clipboard调用不伪称可撤回。其他产品与fixture继续冻结,隔离marks/dictation候选不进入src。 -- 验证:先运行现行产品的生命周期反例,再修复同一显示owner、定向消息回归/lint/typecheck并从85批续跑;不放宽timeout、关闭unhandled error检查或删旧断言。追加真实copy Gate按新的renderer摘要单独记录。架构owner与协议未变。 -- 原产品生命周期反例8failed/2passed;卸载计时器初始计数含jsdom Storage零延时事件,精确stack确认后仅drain 0ms,不推进两秒反馈计时器。修后10/10,含既有消息五文件合计69/69;产品/测试lint与标准typecheck通过,格式已统一。旧fixture选择器错误与诊断日志保留且不算业务先红证据。 -- 新快照 `product-write-set-after-copy-lifecycle-fix.json` 27项,只漂移此前26项中的MessageList.tsx并增加独立回归。新default续跑发现新增测试,追加127批,保留之前passed记录;日志 `/tmp/lime-message-copy-lifecycle-local-resume.log`。旧rich/long-list/resize证据仍按各自renderer版本引用,复制新生命周期尚未取得fresh Electron证据。 -- 最新default续跑exit0:127/127批、1692文件执行、12764 passed instances(首轮失败85批的178项不重复计),unhandled errors0;contracts、7 Rust crate3179/3179和真实Electron GUI smoke通过。smoke summary为 `standalone-shell-01-20261014075111-89042/shell-01-electron-smoke/summary.json`;批次与证据lineage记录到 `default-header-copy-lineage.json`。 -- 复制生命周期fresh rich Gate exit0,`.lime/qc/user-message-collapse/copy-lifecycle-rich/message-copy-lifecycle-rich-summary.json`:69/69 assertions,正文19项布尔合同均true,两个显示状态1705字符原生复制一致(SHA `3b5ab254cf9c0e7f9d2d7ffcb3e3829910c83fe8114afd88a2f04e050c3debcd`),唯一canonical_turn、真实键鼠、无额外input、console/page errors0。异步晚到与卸载由独立10项组件回归证明,本Gate不冒称验证clipboard延迟失败或live Provider。 -- 新Electron renderer271项及Host/sidecar三项保存为 `renderer-electron-artifact-after-copy-lifecycle-fix.json`;产品27项及274项产物逐项复核零漂移,Host/sidecar摘要保持此前值。此前long-list6/cold、live-tail-final、resize-fixed5分别保留其产物摘要,当前复制Gate补验新MessageList反馈,不混称同一renderer。长UserMessage合同(两入口/测量/锚点/复制/恢复/有效回流守卫)100%关闭;全面产品仍partial,marks/voice/persistent等不计入该分母。 - -### Link/code 与 dictation 生命周期 Verification Contract - -- 负责人:root负责input-kit迁移、计划、架构与统一门禁;lime_desktop_capabilities负责dictation hook/session、Core消费者与五语言。fixture worker先提交只读接线设计,产品冻结后再认领精确fixture。预算 `budget:normal`,风险P1。 -- 参考:复用 `marks-voice-audit.md` 冻结REA及普通AST锚点。基础link attrs、code_block与序列化采用已确认合同;完整PMU、主动打开/编辑dialog和Codex真实录音表现仍unknown,不把候选变量当原始源码名称。 -- root写集:唯一input-kit的schema/types/Controller/Editor/keymap/serialization、link helper及相邻行为测试;positions若无语义差异保持现状。Plain display UTF16与Markdown submission UTF8分开,annotation迁移保留富PM文档,禁第二Controller/renderer业务后端。 -- dictation写集:现有hook/draft/unit test、新独立session helper/lifecycle test,Core最小消费、独立Core听写回归、copy owner与五语言agentInputbar资源。独立starting/cancel动作;permission/RPC晚到按同session generation、Controller实例及revision保护;卸载/会话切换清资源。 -- IME接线:PM公开handleDOMEvents早于默认compositionend,`isComposing=false`不能证明事务已结算。首版采用有边界的fail-closed:composition相交即无回滚地失效整个voice session,保留编辑文档,拒绝后续ASR/润色;已有组合时不启动录音。不使用私有PM属性、盲timer或跨revision范围映射。明确取消才按当前revision恢复provisional草稿;这不宣称ASR与IME并行合并已实现。 -- 清理:替换旧hook重复refs/异步reset路径;旧纯文本paste fixture改为MIME-aware而保留原text/plain断言;旧positive shape不能阻碍current link/code语义。无新增compat/deprecated、无新依赖/协议/Rust变更。 -- 验证:候选先红/绿只作预验证,应用前逐文件检查baseline;实际src定向先红/绿、Controller/Editor/receipt/IME/纯文本/五语言回归,真实Electron link/code→Send→canonical→history/restore Gate,已有voice current权限/API合同与生命周期测试、GUI smoke/default。系统麦克风/真实ASR和Windows若未执行单列not-run,不能以mocked录音生命周期测试替代。 -- 架构影响:沿既有唯一input-kit、Desktop录音surface与ASR API owner;未新增跨层owner/协议,非重大。实现后同步architecture输入文档与取消生命周期描述。persistent draft/专业文件/原生操作/remote继续在整体待办。 - -### Dictation 实际源码与单路径收敛 - -- 原14文件actual写集冻结;最终manifest `.lime/qc/dictation-product/actual-manifest.json` SHA `3f009ce3725c04dab322650102b90f30797985cd89a43af1e4442be1e3e67d90`,root逐项复核14文件零漂移。既有Core/copy/五语言脏改动按baseline保留,仅接本合同。 -- 实际hook先红25 failed/14 passed,Core真实hook/PM接线先红7 failed/1 passed;补音频decode取消竞态先红1项。候选pending接口在Core fail-closed策略下没有生产消费者,删除状态/type/session字段、hasPendingFinal与flush/drop整轨;迁移current行为后先红5 failed/26 passed,最终5文件72/72通过。 -- 同Controller+sessionId+generation隔离所有异步结果;组合输入开始时整session无回滚discard,结果应用时发现composing同样终止。明确cancel才按最后provisional revision恢复。已发RPC只能隔离结果;音频decode context立即close,迟到decode不再发ASR。starting/独立cancel/输入法中断反馈覆盖五语言;系统mic/ASR质量与Windows仍not-run。 -- 全仓非候选对旧pending符号扫描0,类型检查含测试、窄lint、Prettier与diff检查通过。hook719行/session196行,lifecycle测试796行,后续新增场景必须独立测试文件,不继续堆叠。无新API/协议/依赖、无compat/deprecated。 -- root正在冻结构建与标准typecheck;新Electron link/code Gate、current fixture、GUI smoke/default尚未关闭,72项只计组件/生命周期证据。architecture已同步scope/cancel/discard与显式发送边界。 - -### Link/code 实际源码迁移 - -- 应用前逐文件核对12目标 baseline/candidate SHA,issues为空;冻结候选 manifest SHA 为 `7bd36954f2d5071239c23854e5e0e6ff16d43b54cca0c7b0fd1c3bc11db5d359`,patch 为 `ed0bd38d66719e645760be9526b45cc2a9c6d0ecc7424593d4dd195e15f047c9`。候选摘要不作为实际应用摘要,实际身份见 `.lime/qc/composer-marks-actual/applied-manifest.json`。 -- 先只迁入独立测试,在实际 src 复现22 failed/8 passed(2文件30项),随后迁入生产实现;20文件224/224通过,覆盖input-kit与typed submission、receipt、restore、workspace消费者。日志分别为 `/tmp/lime-composer-marks-product-red.log` 与 `/tmp/lime-composer-marks-product-green.log`,副本落在上述actual证据目录。 -- link安全协议、合法literal重放、UTF16 display/UTF8 Markdown范围、code fence/原始空白、PM annotation事务与实际EditorView paste/键盘均在current owner回归。旧clipboard getter只迁为MIME-aware,原CRLF/large-paste合同保留;`composerPositions.ts`无生产改动。 -- Prettier后三个test describe去掉candidate措辞;Controller达到803行,annotation事务构造拆入纯 `composerAnnotations.ts`,不增加文档owner或compat wrapper;直接调用者与既有行为回归5文件92/92通过,Controller762行/helper59行,窄lint通过。实际写集扩为13文件,actual manifest已刷新。 -- architecture的schema、Markdown submission、原始draft与range分工已同步。真实Electron HTML→code Enter/ShiftEnter→Send→canonical→history/restore、新default与系统voice仍分别pending/not-run;224项与旧127批不能替代这些门禁。 - -### Marks fixture 冻结 - -- 实际11文件写集与SHA见 `.lime/qc/composer-marks-gate-plan/actual-fixture-write-set.json`;7文件312/312通过,lint/Prettier/node syntax/scripts治理均通过。先红7 failed/127 passed,其中6项为业务断言、1项为测试DOM清理串扰,已明确区分。 -- 独立字面Markdown oracle固定五个反引号fence、最终4361 UTF8 bytes,paste `[197,4337)`、Skill `[4338,4361)`;不消费产品serializer、DOM/wire/canonical反推期望。code按真实locator.press记录trusted/focused/defaultPrevented/insideCode,DOM Selection单列programmatic,HTML paste单列synthetic。 -- history/cancel核对href/title/language/body与独立期望;最终canonical Turn/client/item、media、typed四类input、总input request恰1与终态均进入场景pass/fail。新4项marks断言复用原 `inputbar-rich-restore` 场景,没有新增协议或第二Controller。 -- 删除无调用的 `richInputExpandedText`/`richInputInstalledSkillText`;`isRichInputDraftRestored`仍有common caller,保留原current导出并验证19/19。真实Electron、新renderer/default尚未执行,312绿只计Unit/wiring。 - -### 草稿落盘补证 - -- 同一ASAR的bootstrap/startup-requirements成员integrity匹配,REA新增Evidence `ev_6b726d05b1186a46c3e78398b4559cddd089b886382b8619d60fb2d509426d15`:2文件577112 bytes、parse failures0;semantic heap exhaustion导致graph incomplete。类/文件/算法结论来自完整原成员的普通AST,不能冒充REA semantic或运行证据。 -- 已追到Codex Home `.codex-global-state.json`、export D→class的身份、main500ms debounce、串行保存、主/备份文件、同目录临时文件rename及Windows三档重试。renderer250ms发布与main500ms保存分开,broadcast不能充当落盘确认。原始成员与AST只留本机分析目录,合同/摘要/限制写入 `composer-draft-persistence-audit.md`。 -- 实际退出/重启、强杀耐久、账户隔离与完整附件持久化仍unknown;Lime scoped persistent draft仍未实施。未扩展Host协议或调用者写集。 - -### 本批门禁失败与 fixture 版本边界 - -- 首轮 rich Gate exit1,失败阶段为 UserMessage aggregate oracle;marks/cancel/history、canonical四类input和总input恰1通过,两次原生clipboard全文1779 UTF16字符(4361 UTF8 bytes)匹配,console/page errors0。`renderedFullTextPresent=false`,不能以复制成功替代整个Gate成功。 -- 初次prepare期间两个fixture于08:49:41Z变化,旧11文件manifest未同步;当前源含独立`renderedTextParts`与rich调用接线。原冻结和失败证据保留,修改者/精确旧进程完整模块SHA不可从现有记录还原,不声称该失败对应当前完整fixture身份。54项产品源码仍逐项零漂移。 -- root新增独立真实MarkdownRenderer消费者回归:确认安全link href/title、code原始空白/正文反引号和全部ordered可见片段;同一rendered DOM拒绝raw Markdown全文显示假设。该harness替换syntax-highlighter与原生资源依赖,仅计组件/fixture证据。首次新增case因测试包围DOM缺失失败,修正测试装配后6文件189/189;不计业务先红。窄lint和格式通过。 -- 当前冻结为 `.lime/qc/composer-marks-dictation/product-fixture-source-freeze-rendered-body.json`(54产品/14fixture);不覆写旧manifest。fresh进程rich Gate已启动,标准入口因源mtime判定重建Electron renderer;构建完成后重新记录实际Host/sidecar/renderer摘要。 -- 新`verify:local`在69/126批exit1:中断恢复拓扑guard把`inputbarDictationSession.cancel`的受控恢复算作非法writer。worker只认领guard及反例,不改生产dictation、不给voice伪造中断请求。保留两个UI消费者与request pass-through约束,更新扫描边界后定向验证并resume。旧127批绿不替代本次结果。 -- 首轮sidecar prepare出现`install_name_tool` rename竞态报错,既有helper允许missing-after-race或already-present-after-race后继续装配;具体分支未记录。当前桌面code-mode-host存在且具有`@executable_path` RPATH,但本富输入Gate不验证CodeMode运行能力。不修改未知Rust产物owner或绕过prepare。 -- fresh进程rich Gate exit0:73/73 assertions、UserMessage正文19项合同全true、两个显示状态全文1779字符原生copy、同一canonical Turn/Item/client和typed四类input、input总数恰1、marks取消恢复与历史键盘召回均通过,console/page errors0。新Electron smoke renderer271项和Host/sidecar实读摘要保存在`renderer-host-artifacts-rich-rendered-success.json`;raw与桌面App Server均为`df27ddb9…`,不混用旧`4d213628…`。54产品/14fixture逐项无漂移;截图已人工检查。HTML paste/IME仍为synthetic,code键与history/toggle为trusted真实按键,不宣称OS输入法或系统录音已验收。 -- 中断恢复guard已冻结:只改`AgentChatWorkspace.inputRestoreGuard.test.ts`,旧guard实际1failed/2passed;相关6文件66/66、UI消费者与透传5/5、lint/严格tsc/格式通过。按request类型/字段约束直接writer,保留原两个UI owner和pass-through清单,正向区分听写回滚、反例拒绝跨域直接恢复中断请求。guard SHA`8eef1609…`,manifest `.lime/qc/input-restore-guard/manifest.json`;dictation14项零漂移。root复核后重启`verify:local`,入口自动resume,不从第1批重跑。 +参考应用Computer Use曾被客户端拒绝,不绕过;参考实时效果仍not-run。REA静态结构、公开源码与Lime运行证据分别报告,不能互相替代。 diff --git a/internal/exec-plans/codex-gui-alignment-history-20261015.md b/internal/exec-plans/codex-gui-alignment-history-20261015.md new file mode 100644 index 000000000..738785ba1 --- /dev/null +++ b/internal/exec-plans/codex-gui-alignment-history-20261015.md @@ -0,0 +1,799 @@ +# Codex GUI 对齐历史快照(截至2026-10-15) + +状态:archived / historical。本文仅保留截至2026-10-15的过程、合同与旧证据身份,不作为执行清单;唯一当前计划见 [current计划](codex-gui-alignment-current-plan.md)。旧 pending/失败/完成措辞均归原记录时间。 + +## 本机证据保留状态(2026-10-14 空间清理后) + +用户为释放空间删除了整个 `.lime/`,并明确要求继续。下文涉及该目录的旧 manifest、候选副本、renderer/Host freeze、截图与测试状态均为历史记录,原件现已缺失;历史摘要不能用来证明新文件身份。`/tmp/lime-composer-marks-dictation-current-fixture.log` 和默认门禁日志仍保留,但不能替代被删除的完整 artifact 集。用户级 REA 原始提取目录按实际文件复核复用,不重建整个缓存目录。 + +本轮仅恢复必要候选到 `/var/folders/87/s6cpr7hd1_v43cs833x4s_900000gn/T/lime-code-nodeview-recovery-oulq764w`。候选来自各责任 Agent 自己的结构化工具写入记录;重新记录 baseline、摘要并重跑验证。恢复版结果独立于旧候选结果,正式 `src`、fixture 和根依赖在原默认门禁关闭前继续冻结。旧默认门禁已exit0,前端127/127批和标准GUI smoke通过;NodeView正式源码迁入后另起新的默认与真实富输入验证,证据独立。 + +## 主目标与参考 + +使用 REA 分析指定 Codex Desktop 安装包,直接对齐可确认的 UI、交互、代码结构、目录职责、函数、类型和类;同时迁移或删除被替代的代码、文档、测试、fixture、snapshot、护栏和白名单。只保留 Lime 主题、多模型支持、首页 Banner 三项差异,不为旧规则、旧测试或未知实现新增保留项。 + +- GUI 参考:`/Applications/ChatGPT.app`,Bundle ID `com.openai.codex`,版本 `26.1007.30912`,build `20156`。 +- ASAR SHA-256:`75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。 +- REA 安装:`~/.local/bin/rea` 6.4.0;独立 Node 24.21.0。原始 Evidence 与提取包位于 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/`。 +- 共享业务语义:`/Users/coso/Documents/dev/rust/codex` current 源码,已核对 revision `a9b0815167c27ab13719dd12d13b63e02b70d221`;下次实现前复核。 +- 2026-10-14 复核公开仓库 HEAD 已为 `31434401fa3cd111866acdc1bba2a73b561f53d5`。安装包 CLI manifest 为 `0.162.0-alpha.17.2`;Desktop Host 与公开 TUI 均有 App Server 路径,精确构建等价仍 unknown,不能将最新 checkout 当作已安装二进制源码。 +- [研究报告](../research/codex-desktop-rea/report.md) 与 [证据索引](../research/codex-desktop-rea/evidence-index.json) 供复用,不能当作 Lime 产品已实现证据。 + +## 当前阶段与写集 + +当前阶段:Composer/NodeView、持久化Host/索引/Coordinator、typed acceptance、新建会话78项/9旧文件删除、单一SettingsPage章节与visible-ready均已采用。local16完整富稿/三工作区/两正常cold和Home6稳定空会话冷恢复分别通过;15设置首屏导航通过。旧证据按原产物保留。三首发发现展示正文覆盖canonical富文本,Stop前置导航发现业务workspace覆盖可见scope;正文与可见scope修复已通过定向/构建/壳;新首发Home接受与正常退出通过,search-new暴露queued discard后旧通知重绑。Coordinator修复的标准typecheck/新renderer/壳通过;packet21 first8三入口首发、rich Stop、Home7与local17两正常cold四项真实场景均通过,原失败独立保留。原Stop5秒文件单次20/20已过;async-tabs迁current筛选与coverage计数3+11已过;current聚合前三组通过,guard组293/294、collector隔离68/69因原5秒超时失败;测试采集器去除已验证相邻窗口的重复序列化/散列后69/69(溢出用例1.57秒),不改产品、断言或timeout。聚合第3轮定向31/34/3/294全过、桌面前14场过,第15场Skills试用完成但Composer仍禁用;原失败已冻结。root窄认领CommandWiring及其navigation.integration.test,非Composer真实accepted复用既有可见topic commit,保epoch/ready/CAS;合同:Skills试用GUI→既有sendMessage→Electron/preload/IPC→app_server_handle_json_lines→App Server/runtime canonical accepted→当前可见session/topic→同Coordinator;非接受/迟到保稿,普通Composer不双消费,无协议变更。真实集成先红1fail/2pass,修后四suite36/36,最终六suite61/61含原Stop20和visible-ready5;标准typecheck/lint/格式过,renderer/Host/sidecar构建与同一标准末端壳通过(初次wrapper缓存失败保留);packet22原Skills三路径通过,current第4轮四组定向31/34/3/294及22个真实Electron场景全部通过、7885source/2198artifact前后零漂移;default首次在i18n:unused发现dead settings.layout.placeholder.notFound,未进入前端;本刀窄写集为五语言settings.json及既有loader/type两测试,删除无消费者键并将旧正向合同迁为current loading.skills,不碰媒体实现,42/42定向通过;default第二次在全src零警告lint命中新Wiring依赖警告。原窄lint实际有warning且exit0,不能报零警告通过;窄修提取sendRuntimeMessage/getComposerDraftNavigationEpoch稳定引用并保留原deps语义,原61/61及--max-warnings0通过,Prettier依赖数组仅格式且TS结构AST相同,freeze25留痕。default第3轮全src lint/112源文案扫描/标准typecheck过,原87/88批过,89/132在historyWindow两用例原5秒超时,隔离12pass/2同超时;CPU诊断显示loadNamespaceResource自耗43.67秒,栈经resolveRequiredAgentChatCopy→工具叙述→runtime status;每次文案查找重复合并整agent namespace。窄写集调整为src/i18n/agentChatCopy.ts和既有utils/agentChatCopy.test.ts,优先复用createI18n已持有的五语言静态资源表、保实时locale/插值/fallback,真实资源热路径先3pass/1fail(12重复load)后六suite81/81含未改historyWindow14/14,严格lint过,freeze26仅该两源delta;default第4轮从原89/132续;history源码/用例不改。该新产品叶需定向/lint/typecheck与新build/current证据,不将此归因为全部历史timeout;原状态从89续,再执行contracts/标准GUI及最终产物完整current22(包含Skills),避免用旧22身份覆盖新共用资源修复。架构4.3既有owner接线由root确认,非major。参考普通侧栏fresh和冷多草稿picker仍unknown,整体partial。 + +规则写集:两个 skill、设计 skill UI metadata、skill 索引、`design-language.md`、`quality-workflow.md`、Verification Contract 的 current method 示例、`.gitignore` 精确白名单与本计划。设计 skill 原先被忽略,加入版本控制准入,避免规则仅保存在本机。 + +后续产品写集在每个切片实现前登记。避让已有 App Server/agent/tool-runtime、命令 catalog/executor、五语言 `agent.json` 与未知未跟踪改动;不提交、推送、重置或创建分支。 + +规则更新不改变实际 owner、依赖方向或门禁实现;架构影响为非重大。后续若改变目录/owner/协议或数据流,必须同步 `architecture.md` 并填写责任开发者确认,不能沿用本次非重大判断。 + +## 对齐账本 + +| 能力 | 参考 | Lime current owner / 待办 | 清理与验证状态 | +| --------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 设计与质量规则 | 用户三项差异、current 架构与 package scripts | 两个 skill 与领域事实源 | 完成;validator、引用、脚本与文档守卫通过 | +| 长历史与流式时间线 | `virtualized-turn-list`、`thread-virtualizer` 包模块 | `threadVirtualizer` / `VirtualizedTurnList`;canonical paging 与 DOM range 分开 | 已直接替换;29 文件 / 318 tests;真实滚动与冷恢复 Gate B 通过 | +| 待处理审批 | pending approval 包组件及 Codex server request/decision 合同 | `PendingRequestItemPanel` → `ApprovalCard`,复用现有 approval authority | 已替换;146 首轮 tests +追加回归通过,Gate B 待聚合 | +| Files → diff | review tree、file-diff 与 side panel 包模块 | `ReviewThreadSidePanelTab` → 同一 changeView / ChangesPanel / RightSurface | 已替换;真实四文件 diff/选择/定位 Gate B 通过,整体视觉仍待核对 | +| 导航、页头、Composer、队列 | 对应 Desktop 模块与 shared Thread/Turn/Item | 已有 sidebar/header/composer/queue owner | 已有能力须重新核对行为与命名,不能仅按旧计划判完成 | +| Queue mutation / Header identity | `QueuedMessageList` confirmed export、Thread scoped actions | 旧 ThreadQueueStatus 已迁净;public pending visibility 与既有 typed queue/store | 内部Item/媒体/动作回归通过;public history/paging74、搜索17、rollout恢复23;新版真实pending Gate B通过,旧 transcript Gate已失效 | +| ComposerUtilityBar | REA confirmed export、project/run-location/branch 分工 | 共享 utility + keyed mutation actions,删除旧组件/未消费透传 | 12文件343 tests;真实两项目选择、branch create/checkout、冷重启与Git readback Gate B通过;staged worktree/remote待办 | +| 结构化主 Composer | REA 主模块、initial Controller/keymap/View | 唯一 EditorState + EditorView;原始 draft / typed submission;共享 workspace scope | NodeView与既有default/current已关闭;persistent已正式接线,新GUI冷恢复/首发/current/default待验;系统voice仍待办 | +| 长用户正文折叠 | REA collapsible-user-message-content、20行自然高度合同 | 两入口共享正文显示组件;现有Markdown、row测量与滚动owner | 已验收;guard39/39、默认127/127批、rich69/69、long-list/冷启动、live-tail65/65及resize66/66通过;证据分版本 | +| Thread 设置通知消费 | shared `thread/settings/updated` canonical settings | EventBus → scoped adapter → 现有 preference/threadRead cache → model/header | 120 定向 tests + production consumer 8;idle model/cwd Gate B 通过,已迁 known_projected;不声明 active/future Turn 执行 | +| 右侧面、Browser、terminal、artifact、subagent | 已提取 side-panel 与 surface 模块 | 单一 RightSurfaceHost/registry、scoped local state与既有native owner | scoped生命周期73、完整tab恢复82、actual sender41项测试通过;双tab/primary关闭不复活的真实Gate B通过;其他surface继续核对 | +| 设置、Skills、MCP、插件、自动化 | 对应包模块与公开协议 owner | 既有设置与 App Server current owner | 待完整清单与行为/结构核对 | +| diff/editor、Office/专业文件、原生操作、环境/remote | 对应包与平台证据 | 既有 artifact、document-preview、Desktop Host、environment、transport owner | 待查证/实现;不自动排除,不复制业务后端 | + +已取证范围集中到[结构与命名账本](../research/codex-desktop-rea/structure-alignment-ledger.md),本轮文档窄写集包含该账本及研究索引。每行继续拆成“上游符号 → current 符号 → 调用者 → 行为 → 删除项 → 测试/护栏 → 证据”;完整符号覆盖仍partial,压缩局部变量与无source map的原名记unknown。 + +## 验证合同 + +规则更新:`budget:tight`,仅格式、链接、命令存在性、相关文档守卫与 diff 检查。无产品代码变化时不启动 Electron 或全量 Rust,也不形成产品/release evidence。 + +产品切片:实现前按模板补齐入口、网关、IPC、public method、domain owner、read model、事件、GUI、happy path、失败终态与测试资产。仍沿唯一 `Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection` 主链。 + +默认顺序:定向行为测试 → 受影响 current fixture → 对应 Electron fixture/Gate B → GUI smoke → 默认本地门禁。协议/bridge 变更追加 contracts;Windows 未实跑不写 Platform 通过。外部 fixture 绕过的 provider/runtime 层要明确。 + +## 完成标准与限制 + +每个切片必须同时关闭行为、结构/命名、调用者迁移、旧资产清理和风险匹配验证。全面对齐要逐项关闭完整账本,不能用三个切片或同名文件数量替代。 + +已知限制:此前 Computer Use 拒绝操作 `com.openai.codex`;不绕过限制。可继续静态 REA 与 Lime 真实 GUI 验证,Codex 实时窗口效果记 not-run。REA 三文件 timeline 与六文件 surfaces 的结构覆盖完整,但语义仍有 unknown,不宣称全包完整分析。 + +下一刀:current第4轮22/22已关闭,继续默认前端第87/131批续跑;随后由既有useAppNavigation统一设置canonical章节参数与返回上下文。搜索/dirty需实际owner证据。专业文件、原生工具和remote仍留账本,不能由局部门禁推导全面完成。 + +## 首刀:审批卡 Verification Contract + +- 名称:Pending Request Item Panel / Approval Card 对齐。负责人:root,2026-10-13。 +- 预算:`budget:normal`;风险 P1;不调用 live Provider。 +- 参考:包内 `pending-request-item-panel-770855267df2.js`,SHA-256 `65c883ee4c9b507b6fe86e9edd73ac0eb2e085730dfbb1f72d0761ccde22be69`;通用卡位于 `app-initial-6350d04672d0.js`。module-derived 文件名与原始压缩符号分别记录,不能声称已恢复开发源码名称。 +- 写集:新 `components/ApprovalCard.tsx`、`PendingRequestItemPanel.tsx` 与对应测试/纯投影;删除 `Inputbar/components/InputbarApprovalPrompt` 源码与测试;迁移 PendingInteractionLayer、Workspace inputbar、相关 DOM 定位器;补五语言 `agentInputbar.json`;迁移审批 GUI fixture/断言/脚本测试;更新旧审批 PRD 与执行记录。public request 的 cwd/grantRoot 只补到现有 pending projection,不改 schema 或业务 authority。 +- 避让:已脏 Rust approval/runtime 与 `agent.json`,不接管其实现。 +- 前端入口:PendingInteractionLayer / Workspace Composer accessory → PendingRequestItemPanel → ApprovalCard。 +- 网关与 Host:既有 pending interaction controller → AppServer server request dispatcher → Electron preload/IPC → `app_server_handle_json_lines`。 +- App Server:`item/commandExecution/requestApproval`、`item/fileChange/requestApproval` reverse request 与同 id response;不调用已退役私有 action method。 +- Owner:审批 authority 仍归 App Server / tool-runtime;卡片只展示请求并提交现有 decision。 +- Read model / event:同一 thread/turn/item pending interaction,response 后清除;terminal 使用 current `turn/completed` 与 canonical read model。 +- Happy path:显示完整原因、命令预览、目录与已知网络/授权范围;长命令可展开;仅显示服务端允许的决定;提交期间禁止重复响应,失败可重试;响应成功后恢复现有运行状态。 +- 失败边界:没有 submit owner 或请求已提交时按钮禁用;未知授权能力不生成“Always allow”或“Allow similar commands”假动作。 +- 证据:component/unit 与五语言回归、current fixture、对应 approval Electron fixture、GUI smoke 与默认本地门禁;GUI evidence 需同 identity 的 IPC/request/response/read model/可见状态,不能只看卡片截图。 +- 必跑:定向组件/投影/fixture tests;`npm run test:contracts`;`npm run smoke:agent-runtime-current-fixture`;`npm run verify:gui-smoke`;`npm run verify:local`。失败按 owner 修复或明确记录外部脏写集阻塞,不恢复旧断言。 +- 架构影响:非重大;既有 projection/response owner、协议与依赖方向保持一致,拆分仅为 Renderer presentation;架构图无需改变。 +- 退出条件:旧 compact-only 规则、源文件和测试不再作为 current 入口;新卡行为与支持的决定完成定向验证。file diff 与 canonical item_id 接线、policy/network amendment 和自动审查专用详情仍是呈现缺口;协议已有字段不能被记成协议缺失。不宣称整个审批域完全对齐。 + +## Files/diff Verification Contract + +- 负责人:lime_gui_comparison;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。 +- 参考:REA `ev_7e96cf795451f834a7bddc70daacbc950320070656781f6347f1cf9c759e4299`;经 ASAR SHA 校验的 review tree/side pane 补证。`ReviewThreadSidePanelTab` 是确认导出;压缩局部名不作开发源码事实。 +- 写集:Files renderer、workspaceFiles、既有 ChangesPanel/DiffList/ReviewState、右侧 composition 所需接线及对应测试;文档、架构、fixture 门禁归 root。避让 Rust、命令目录、审批和 agent.json。 +- 入口与事实源:统一 RightSurfaceHost 的 Files → 同一 canonical coding changeView → 既有 CanvasWorkbenchChangesPanel;Git/read/full-file 仍走 existing frontend gateway → Electron IPC → App Server current methods,不新建 diff 或文件读取 owner。 +- 接线:会话 coding projection 只计算一次,分别供 Canvas 与 Files 消费。若使用 Context,只传只读 projection,不增加业务状态或 runtime。 +- Happy path:打开 Files 显示真实 patch/diff;点击文件选择并定位相应 diff;再次点击仍定位;打开原文件是显式动作;已有 inline/split、baseline、full-file 复用。 +- 失败边界:Git/full-file 失败可重试;线程/目录切换清空局部缓存;迟到异步结果不得写回新 identity;无协议能力不伪装 comparison base。 +- 旧资产:删除 patch→path-only 列表与解析 helper;迁移旧“选择会启动外部应用”测试;保留唯一 RightSurface owner 守卫并同步接线。 +- 验证:定向行为测试、typecheck、current fixture、`smoke:code-artifact-workbench-electron-fixture -- --scenario gui-coding-input` 加 Files→diff 断言、GUI smoke、verify:local。 +- 架构影响:共享 renderer projection 新增消费者,更新 architecture 图。责任开发者图确认:root,2026-10-14;已复核 Scene 只读 Context 直接传同次 changeView / root / loadFilePreview,Canvas 与 Files 共用 review owner,无第二数据 store。 + +## 长历史 Verification Contract + +- 负责人:lime_desktop_capabilities;记录:root,2026-10-14。预算 `budget:normal`,P1,不调用 live Provider。 +- 参考:既有 timeline REA 与 `thread-virtualizer` / `virtualized-turn-list` 包内纯几何、viewport range、高度测量、锚点和导航;导出为压缩 alias,语义命名与原始绑定分开记录。 +- 写集:MessageList、旧 RenderWindow owner、scroll controller、timeline/telemetry/historical chrome/constants、相关纯 projection 与测试/样式;不修改 Files 或 conversation composition。 +- 入口与事实源:App Server thread/read 与 canonical paging → 同一 Thread/Turn/Item projection → viewport turn list。保留后端分页 owner,不增加 history store。 +- Happy path:离屏 turn 真正卸载;稳定 key 与动态高度;窗口按 viewport/overscan 改变;prepend/流式高度变化保持阅读锚点;用户在底部时尾随;focused/current item 导航先挂载目标再定位。 +- 失败边界:迟到测量不污染新线程;ResizeObserver/RAF/事件清理;键盘、触摸、滚轮与滚动条操作能停止尾随;离屏数量不等于未加载历史或 hydration pending。 +- 旧资产:删除 idle 渐进挂载与 Expand All 全量 DOM 路径;保留 canonical Load More;同步迁移初始窗口固定 10/36 的测试与性能 fixture,不保留双裁剪 owner。 +- 验证:纯几何与 component/hook 行为、长历史真实 Electron fixture、current fixture、GUI smoke、verify:local。实际性能数字只有跑过才落库。 +- 架构影响:renderer window owner 直接替换,更新 architecture 历史窗口段落及图;责任开发者图确认:root,2026-10-14。MessageList 传全部已加载 canonical entries,唯一 virtualizer 持有 DOM/测量,后端 paging 保持独立。 + +## 验证记录 + +- 规则:两个 skill validator、链接/脚本存在性、docs boundary、12 个 current-docs guard 通过。 +- 首轮审批定向验证:6 文件 / 146 tests 通过;contracts 与 typecheck 通过。 +- 追加:file grantRoot request/response 回归与 dead surface 物理恢复守卫通过;键盘菜单 Escape 焦点回归已修复并通过,PendingRequestItemPanel 13 tests 通过。 +- Files:9 文件 / 92 tests、package projection 42 tests,移除无消费者菜单后 3 文件 / 26 tests;typecheck、lint 通过。真实 Electron `gui-coding-input` 四个 canonical 文件的 diff 方向、选中与头部可见全部通过;删除项禁用外部打开,rename 仍按 source path 选择、显式打开 destination。选择导致 external open 为 0,console/page errors 为 0。证据:`.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-files-final-summary.json`。 +- 长历史:29 文件 / 318 tests、typecheck、lint 通过。真实 Gate B 与冷重启恢复通过,fixture seed 240 Turn / 720 Item;实际已加载 14 entries,首帧挂载 3 Turn / 9 Item,滚到已加载页首挂载 8 Turn 并卸载原尾部行,然后返回尾部。首帧文本 2,068 字;first paint 38ms / cold 97ms,compute max 0ms / cold 1ms。预算仍为 ≤12 Turn / ≤36 Item、paint ≤8,000ms、compute ≤500ms;不把 seed 总数当成全量加载性能。证据:`.lime/qc/gui-evidence/agent-session-history-electron-fixture/codex-gui-alignment-history-final-summary.json`,console/page errors 为 0。 +- Gate 迁移:Files helper 8 tests + smoke guard 8 tests;viewport oracle 9 tests;current-rules 与 viewport 合计 20 tests;五语言 loader 10 tests。旧 current-rules 的 skill 重复模板断言迁为权威引用/主链/证据要求;旧全历史文案断言迁为分页文案。没有降低 DOM/性能预算或移除权限/mock 护栏。 +- 默认本地门禁已通过五语言完整性/unused、lint、硬编码扫描与 typecheck,Vitest 续跑到批次 91/121;contentParts final body case 在全量负载下5秒超时,独立case与整文件14tests均通过,未扩大timeout或更改产品逻辑掩盖。待写集冻结后resume。current-rules、loader与Codex async源码anchor等过期断言已迁移,保留有效权限/mock/Reasoning护栏。本轮新改动后current fixture、GUI smoke与受影响Rust门禁待收敛。 +- 写集例外:仅精确移除五语言 `agent.json` 中六个孤立旧 Files/菜单键,保留其他并行编辑;`agentMessageList.json` 删除三个旧尾窗键。新增 loader test 与 current-rules test 用于迁移过期护栏。 +- 真实桌面发现 `thread/settings/updated` 原先只有 typed guard / package oracle。完成生产 consumer、现有 cache 接线和真实 Electron Gate B 后,迁为 `known_projected`;不通过静默白名单掩盖缺口。 +- REA 审批:1 文件、23,095 AST nodes、199 findings、0 parse failures;结构 complete、语义 unknown。Wakaru source recovery 因 Linux x64 引擎不适用于当前 host 而未执行;不影响静态分析,不声称恢复完整源码。 + +规则更新完成度 100%;全面 GUI 对齐仍为 partial,不从三项首批切片推导全产品百分比。 + +## 下一批:队列呈现与动作隔离 Verification Contract + +- 负责人:lime_gui_comparison;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。 +- 参考:[导航/Composer 审计](../research/codex-desktop-rea/navigation-composer-audit.md);已确认 shipped export `QueuedMessageList`。只迁移同义列表职责,不把压缩 alias 作为函数名。 +- 写集:`ThreadQueueStatus` 直接迁为 `QueuedMessageList`、纯输入编辑 helper、既有 queue action UI 与 tests、Scene import/callsite、`queuedTurnCurrentBoundary` 的 current path 和 dead 回流守卫;独立修复 `ThreadWorkspaceHeader` 重命名 session identity 及测试。root 拥有执行计划、架构、五语言文案和 Electron mutation gate。 +- 既有主链:QueuedMessageList → current threadQueueActions → typed client → Electron preload/IPC → `app_server_handle_json_lines` → `thread/queue/update|delete|reorder` → 既有 App Server queue/store → scoped notification/read hook → 同一 canonical queue projection。禁止新建 queue store 或在 renderer 删除后再 steer。 +- 行为:线程切换清空旧编辑与错误;旧 action 的 then/catch/finally 不影响新 identity;重复提交 fail closed;互斥队列变更不会被另一个请求提前解锁;保存失败保留草稿。多 text block 编辑无重复,非文本与 client message identity 保留;未改文本保留原始 textElements,修改后不沿用失效 ranges。 +- Header:编辑绑定开始编辑时的 session;切换取消旧草稿,旧 blur/key handler 不能调用新会话 rename。 +- 暂停/sending/outcome-unknown 尚无 canonical DTO,保持显式 gap,不伪造状态。当前 `thread/queue/start` 只支持 idle,不把它改文案冒充 active-turn Steer;该协议/事务切片另立合同。 +- 验证:跨线程迟到成功/失败、重复与并发、富输入保存和失败恢复、键盘 preview、header session 切换;scoped reader 和 boundary guard;真实 Electron mutation 需覆盖 update/reorder/delete 的 typed request、canonical queue refresh、正文唯一,单独报告,不冒充 live runtime。 +- 架构:业务 owner/协议不变,仅同义 renderer 直接迁移;同步图和当前入口,不保留 alias/compat。退出条件是旧组件与其正向测试删除、调用者/护栏迁移、定向与对应 Gate B 通过。 + +## 下一批:设置通知消费 Verification Contract + +- 负责人:lime_desktop_capabilities;root 记录,2026-10-14。`budget:normal`,P1,不调用 live Provider。 +- 已确认缺口:typed client 已解析 `thread/settings/updated`,projection 中现有 oracle 没有生产 consumer;`agentRuntimeAdapter` 丢弃 RPC response.notifications,`useAgentContext` 以 ACK 后的请求选择更新 UI。warning 间接触发 detail refresh 不算通知消费。 +- 写集:既有 settings adapter、EventBus/typed notification route、session/read-cache 与 useAgentContext 的最小接线和回归;不改 Rust、协议、Files、MessageList、queue、命令 catalog 或外部脏 owner。先复核生产订阅/identity 设计,再实现;root 拥有 Electron settings oracle、执行计划和架构。 +- 主链:既有 settings RPC → App Server canonical settings → typed `thread/settings/updated`(response 与后续 drain)→ 现有 event/同步 owner → 同一 thread/session read cache → useAgentContext/header/model/cwd projection。ACK 仅结束提交,不以请求值替代服务端有效值;没有第二设置 store。 +- 回归:服务端 canonical model/provider/effort/serviceTier/cwd、inactive thread cache、切换迟到隔离、响应与 drain 重复幂等、失败恢复、active turn 不变而 future turn 使用新设置、通知不写 transcript/Item。对于当前不支持显示的字段不合成 UI 行为。 +- 门禁:真实 production consumer 定向测试、既有 settings live-update oracle/typed contracts、真实 Electron model/cwd/header 证据;通过后才能把 method 从 known_unprojected 移到 PROJECTED。不可先加白名单消警告。退出条件按实际可消费字段逐项确认,不把 oracle 导出数当接线。 + +## 第二批实施与桌面验收(2026-10-14) + +- Queue/Header:旧组件与正向测试物理删除,无 alias/compat。thread-keyed 实例、mounted/request token、同步整队 mutation 锁隔离 A→B→A 迟到 success/error/finally;保存失败保留草稿。纯输入 helper 保留未改 blocks/ranges,修改后合为一段 text,保留非文本顺序,清除失效 ranges。Header 绑定编辑开始的 session/token/callback。6 文件 / 36 tests、renderer typecheck、窄写集 lint 通过。 +- Queue Gate B:真实 GUI 保存、上移、删除和 preview 焦点均经 current IPC,canonical list 同一 identity 读回。seed 内联图片入库后成为 `sidecar://media/input-.png`,oracle 先校验 seed bytes 的哈希基线,再精确比较 update/read-back 全部非文本输入;附件丢失、替换和空基线均 fail closed。原 raw dataURI 比较是过期验收基线,未为通过而移除附件要求。GUI list 可见先于 trace 完成,采证改为等待生产同线程 list,未补 fake request。证据 `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-final-summary.json`:零 invoke/console/page/mock error;不启动 Turn,不证明 active Steer、拖拽或发送状态。 +- Settings:EventBus 消耗 response/drain 成对镜像且保留 A→B→A,scoped adapter/cache/context/header 接线不建第二 store。ACK 不替代 canonical 设置,串行选择和 future-send wait 有定向回归;`useAgentContext` 已拆至 765 行。10 文件 / 120 tests、既有 oracle 8、最后 production consumer 8、renderer typecheck/lint 通过。真实 Gate B 后迁 `known_projected`,新增 drift/privacy/typed passthrough 回归。 +- Settings Gate B:真实 GUI model selection 与同线程 raw cwd RPC,旁路观察原 Host IPC handler 输出,不额外 drain、不注入 consumer。当前 stdio ACK 与 notification 分离,原要求 response.decoded 含通知是 oracle 错误;验收依据生产 drain 中 canonical model/provider/cwd 通知与可见 header,以及 `thread/resume|read` 读回。证据 `.lime/qc/gui-evidence/thread-settings-electron-gate-b/codex-gui-alignment-settings-final-summary.json`:零新 Turn、transcript 通知和 invoke/console/page error。只证明 idle model/cwd,不声明 active/future Turn 执行、live Provider、Windows 或 packaged。 +- 架构图:Queue/Header 与 settings 数据流已更新;责任开发者 root,2026-10-14 confirmed。业务 owner、协议和依赖方向未新增双轨。实际 App Server sidecar 使用显式 binary override,SHA 记录到最终 Gate summary;未建立与当前外部脏 Rust 树的精确构建等价,不用 mtime 冒充编译证明。 +- 分类:新增/迁移 presentation、通知 consumer、fixture/oracle 为 current;旧审批、Files、尾窗与 ThreadQueueStatus owner 为 dead/deleted/forbidden-to-restore;本批没有 compat/deprecated。现有队列 store、runtime、thread paging/read model 是 current,不因删除 UI 旧入口而删除业务 owner。 +- 完成度:规则更新100%;Queue/Header的mutation list/identity/结构/清理已通过既有验收,设置idle model/cwd切片通过。随后补齐内部canonical Item/媒体一致性,并由公开源码纠正“pending进入主时间线”的错误验收合同;旧timeline-final summary不再作为最终pending证据。完整产品仍partial,全面账本未穷举到稳定分母,不给无依据的全产品百分比。 +- current fixture 聚合已通过:22 个真实 Electron 子场景,涵盖审批 allow/decline/cancel/full-access、首页热路径、Files/coding recovery、图片意图、停止续聊、富草稿恢复、active steer、Plan/Skills/MCP/media/Expert/文章产物;绕过模型的 external/unavailable fixture 不是 live provider。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-gui-alignment-current-fixture-final.log`,各场景 summary 位于 `.lime/qc/gui-evidence/` 对应目录。不能用聚合通过覆盖上述新发现或完整视觉等价。 +- 前批contracts、规则/dead surface/queue/settings Gate guard、窄写集ESLint与legacy report通过;零引用、分类漂移、边界违规均为0。本轮新增public history/native tab合同后需重验受影响边界,旧75批记录已由上方91批进度取代。 + +## Queue canonical Item 与主时间线一致性 Verification Contract + +- 触发:真实 mutation 截图显示旧 rich 文本与已删除 sentinel。`queue.added` 自动注入 message.created 生成 canonical User Item,`queue.updated` 没有更新该 Item;reorder 的 removed/added 事务会移除后重建 Turn,却不重建 Item。renderer queue.changed 只刷新 list,普通 read-model merge 保留删除项。PNG fixture 的 IDAT CRC 无效已替换,并在浏览器 Image.decode 验证 seed。 +- 负责人:Rust materializer 与独立回归由 lime_desktop_capabilities,root 拥有 renderer read refresh、Gate、计划与架构;P1,`budget:normal`,无 live Provider。 +- 窄写集:`runtime/thread_item_projection/materializer.rs`、`materializer/lowering.rs`、`materializer/lifecycle.rs` 与该领域新独立 Queue regression module/必要 test module 注册;root 的现有 session refresh/projection/helper 与 scoped queue.changed hook、既有 SyncEffects 一处接入、对应定向测试;既有 Queue Gate 增强。避让外部脏 approval/runtime/backend owner,不修改 public v2 schema 或新建 queue/history store。 +- 主链:Queue typed mutation → App Server 既有 queue events → 唯一 materializer → 既有 ThreadStore canonical User Item → queue.changed → scoped existing session read refresh → 同一 canonical Thread/Turn/Item 主线。编辑使用稳定 user item/client identity、完整 typed input(含 sidecar)覆盖快照,不重新 append message.created 或完成生命周期。 +- 行为:add/edit/reorder 保留一份准确 User Item;delete 清 Turn/Item;重启和 thread/read 一致。frontend 自动读回更新文本/顺序和删除,不直接从queue list patch历史;读取为完整 read model才允许replace,分页detail不能伪装完整快照,保留 active streaming/未确认 optimistic 边界。 +- 失败与竞争:读取失败不伪造删除成功;订阅session/thread/workspace generation、A→B→A和迟到read隔离;连续通知串行且末次不能丢失。v2 Turn 未携带内部queue状态是既有协议gap,不依靠当前不可命中的isQueuedTurn判断;另立协议合同,不随本刀扩schema。 +- 验证:独立 materializer + canonical store add→edit→reorder→delete/restart 回归、受影响Rust crate、frontend scoped read/projection/hook 回归、contracts、增强真实Electron Gate B(raw thread/read正文/Items + GUI更新/删项 + canonical image decode)、默认本地门禁。退出条件是上述同一identity证据通过;原queue list Gate通过不关闭新问题。 +- 架构影响:恢复既有唯一projection owner的一致性,不新建业务owner;更新architecture现有Queue/read flow并由root复核图。canonical内部queue状态与v2传输的完整对齐仍待独立关闭。 +- 测试迁移:`ChatModelSelector.integration.test.tsx` 补canonical设置通知和隔离catalog,保留跨session恢复的模型与thread/settings cache断言,4 tests通过;不会恢复ACK请求值提交逻辑。 + +## ComposerUtilityBar 结构与动作隔离 Verification Contract + +- 负责人:lime_gui_comparison;root 记录,2026-10-14。P1,`budget:normal`,无 live Provider。 +- 参考:focused REA `ev_a12d13494c92f390f31b4493b08cc34bd42867413b8d6fcfbe597601fad22a18` / exact export view `ev_d226fcac113fff14952965ae9465453d279c1f1431cdef57be2026d60b9e06d9`;证据限制见 navigation-composer-audit。只迁 project/run-location/branch/local context 职责,不迁整个 model/permissions/editor。 +- 窄写集:`InputbarProjectContextBar` 源文件/独立动作hook/对应测试,直接迁 `ComposerUtilityBar` 与首页、ChatNavbar、EmptyState类型、Inputbar.index、既有workspace透传的必要引用;仅清理实际未消费的props。root 拥有docs、架构、dead surface catalog/guard和Electron验收。避让Queue/read model、审批、Rust、共享command owner和不相关脏修改。 +- 合同:现有project/Git gateway → current Electron IPC/App Server与domain owner不变;所有mutation绑定projectId/rootPath/workspace generation/request identity。同步互斥,A→B→A旧success/error/finally不能改新视图或导航;失败可重试,正确当前项目动作仍刷新/导航;单一owner同时服务home与navbar。 +- 清理:旧组件、copy/project类型与测试路径直接迁;不建alias/compat。动作职责拆成hook,renderer文件保持小于800行;主题使用既有token。不合成cloud/remote/environment能力、不以Local文案冒充readiness,现有worktree立即创建与上游staged mode差异另立跨层合同。 +- 验证:既有home/navbar/Inputbar/Git及workspace调用者测试、mutation隔离/同步重复/失败恢复/键盘与主题回归,renderer typecheck/lint、受影响Electron场景、GUI smoke、本地续跑;必要文案同步五语言。结构迁移不等于完整worktree/run target行为等价。 +- 架构影响:非重大,沿用唯一project/Git owner及composer controller;root 最终复核引用图。退出条件按结构、命名、调用者、已确认动作语义、清理与对应测试分别记录,未知能力仍在全面账本。 +- 巨型既有session hook例外:本轮仅接入已拆分的Queue read helper,新增业务保持在独立helper/hook;不向`useAgentSession`继续堆叠状态机。退出条件是该helper测试/接线证据通过并冻结最小接线,后续迁移另立owner拆分合同。 + +## Queue 输入图片真实读取补充合同 + +- 触发:增强Gate已观察主时间线正文更新,但图片仍显示unavailable;合法seed已由浏览器decode通过。输入writer生成既有`outputRefs`对象,`session_media_refs`只识别嵌套`sidecarRef`,需查明同一canonical input URI的真实`media/read`结果。 +- 负责人:lime_desktop_capabilities窄写`runtime/session_media_refs.rs`及`session_media_reader.rs`该领域独立回归/必要注册;root拥有Gate和docs。避让外部脏runtime/backend、协议与input writer;先真实RPC或Rust集成复现,再修复已经存在的reference lowering,禁止绕过session ownership/sha/bytes/range/cancel校验或读取任意路径。 +- 合同:typed input media writer → 既有outputRefs → media/read唯一reference owner → sidecar verified bytes → MessageImageAttachments。不新增media metadata key、平行store或renderer raw文件读取;保留跨Thread拒绝、篡改哈希/大小失败、未知URI失败和请求取消。 +- 验证:真实queue/add与合法inlinePNG后的media/read,在edit/reorder及冷恢复后保留同一URI/bytes;负向scope/sha/read保护、受影响AppServer tests和新binary SHA、增强Electron image.decode、contracts与本地门禁。此发现继续阻止Queue完整验收,原列表和正文通过不覆盖图片。 + +## ComposerUtilityBar Electron 验收补充合同 + +- root 窄写集:既有 `scripts/electron/lib/` 内独立 utility Gate 与 oracle、scripts 导航、legacy catalog、执行计划和架构。生产组件及动作 hook 由既有 worker 冻结后再构建。 +- 使用隔离 app data、两个真实 Project 与临时 Git 仓库;初始对象用 Git plumbing 生成测试基线,不提交或切换 Lime 源码仓库。setup 可从 current IPC 建 Project/写本地选择基线,但成功证据必须来自 GUI 项目选择、分支创建/切换、真实 IPC 与 Git/readback 同一 root。 +- 验证 home UtilityBar project/root identity、分支菜单/搜索/create/checkout、reload 后恢复;全部 Git mutation 必须匹配当前 workspace root,禁用 mock fallback。错误/重试和 A→B→A 由组件回归覆盖,不用受控成功场景冒充故障恢复实跑。 +- 不启动 Turn、不调用模型;不把立即创建 worktree 证明为上游 staged composerMode 等价,不包含 cloud/remote/environment。 +- 实跑补充:首页把 `useWorkspaceOpenedProjectsRuntime.enabled` 永久绑定到 auxiliary deferral,导致已打开项目虽存在于侧栏/存储,UtilityBar picker 始终为空。root 窄写补 `useAgentChatWorkspaceEntryRuntime` 的这一处接线;只恢复现有 opened IDs 的 workspace/read,不开放 memory/model 等其他 auxiliary owner。真实 Gate 使用 workspace/ensure 基线,项目必须经 GUI 选择,不能拿 Project 目录实体替代 Workspace 实体。 + +## 未消费 Queue 的 public history 与运行状态合同 + +- 触发:增强 Gate 的正文/附件一致性已通过,但截图发现没有启动的 Queue 仍被显示为运行中。公开 Codex `ext/queue` 的 enqueue 只写独立 queued_items;start/admission 后才创建执行 Turn。public v2 Turn 不携带 queue 状态,不新增猜测 enum 或 renderer 私有 wire 字段。 +- 保留 Lime 内部 current durable queue materializer、稳定 User Item、媒体与冷恢复 owner;尚未消费的内部 Queued Turn 不属于 public transcript,Queue 列表继续由 `thread/queue/list` 唯一供给。内部有事实不等于可向 public history 投影。 +- 窄写集:既有 RuntimeCore thread read/turns/items/history pagination owner 与独立 regression module、必要 public projection 过滤及 processor tests;不修改 schema、agent loop、queue scheduler、shared runtime/approval/backend 外部热区。root 接管 frontend 公共 projection 中不可命中的私有 queue 字段假设、Electron oracle、docs/架构。 +- 失败边界:queued 不能触发 orphan interruption、terminal-projection-pending 或 active 状态;真实 active/terminal/drain 语义保持。过滤必须覆盖 read/list/resume、turn/item paging 与指定 queued Turn read,不泄漏 Item;不能在截断后的单页简单丢条目。使用既有 opaque cursor,补足可见 page 或明确 EOF,重复 cursor fail closed。 +- 验证:混合 queue/history、全 queued 页、反向 paging、同库/生产分库冷恢复、queue admission 后可见、真实 active 仍 active;pending Gate 改断言 public history 未含 queued正文、header/Composer idle、队列 edit/reorder/delete 保真。图片由真实 media/read 解码验证,不能为通过测试保留错误的未启动 transcript。 +- REA 精确缩略补证:同一 Queue chunk UTF16 offset 11048 有真实 `img`,src 来自 submission context 第一张 image preview,size-6;不是完整 gallery。root 窄写 `QueuedMessageList`、`queuedMessageInput` 的首图转换、既有 `MessageImageAttachments` 的 thumbnail presentation 及定向回归;复用同一 media/read/abort owner,不建第二图片读取链。Queue Gate 改在真实 row 解码缩略,公共历史必须为空。 + +## Right Surface 切换与关闭合同 + +- 触发:同线程 Browser → Files 仅切换选中项,却使 Browser 从 openSurfaces 消失。select 与 close 必须分别表达;不从 active pane 卸载猜测 native Browser 已销毁。 +- 参考:已保留 REA surfaces 模块及确认导出 `LocalConversationBackgroundTerminalTab`、`LocalConversationSubagentsPanelTab`、`ReviewThreadSidePanelTab`;通用 host/artifact 压缩局部名仍 unknown,不凭猜测迁名。 +- 窄写集:既有 `useWorkspaceRightSurfaceLocalStateRuntime`、`useWorkspaceRightSurfaceProjectionRuntime`、`useWorkspaceRightSurfaceCoordinatorRuntime`、`useWorkspaceRightSurfaceActionRuntime` 与相邻独立 projection/action 回归;不修改 queue、Composer、runtime、native/browser host 或 shared registry 协议。worker 避让并保留前轮 Files/diff 修改。 +- 同线程显式打开 surface 集合属于局部显示 owner;select 不删除其他 tab,close 才删除;thread/workspace epoch 切换必须隔离旧集合与迟到动作。resource availability 仍由现有 registry/definitions 判断,不新增 browser/artifact/subagent store。 +- 验证:open Browser → Files → Browser、close 选中/非选中 tab、无资源 surface、线程 A→B→A;定向测试与真实 Electron 相同 thread tab/资源 identity,禁止 mock fallback。Gate 证明 tab 生命周期,不冒充 native Browser 进程跨线程持久化。 + +## Pending Queue 搜索可见性合同 + +- 触发:只读追踪确认 canonical User Item 已进入 `thread/search` 的 snippet 与 `thread/searchOccurrences` 的 occurrence/turn cursor;搜索还透传 raw Active 状态。pending Queue 属于独立列表,搜索执行历史不能把它返回为 transcript 结果。 +- 负责人:lime_gui_comparison;root 记录,2026-10-14。窄写集为既有 thread-store canonical search owner 与相邻独立 regression module/注册、RuntimeCore public search 包装的最小有效状态投影及必要 processor/JSONL 回归。保留 external 脏写集、现有 visibility 与 opaque cursor,不改 schema、queue scheduler、export、rollout rebuild 或全局 active helper。 +- 合同:已执行 transcript 匹配、排序、分页与已有 title/path 搜索继续由当前 owner;仅 pending 文本不能命中 snippet/occurrence。Thread overview 状态取同一 execution state 规范化。queue admission 后该 input 成为正常可搜索历史;同库/分库冷恢复保持语义,不合成游标或由 renderer 二次过滤。 +- 验证:interrupt 基线 + 唯一 pending 字符串负向、混合执行/queue 同词、分页 EOF/双向、admission 后正向、真实 JSONL 与 thread-store 定向。不把诊断 export 中显式 pending/queued 字段定义为泄漏;其 transcript 与队列区分另立合同。rollout rebuild 与 idle unload 为待复现风险,未更改。 +- 真实 pending Queue Gate 前置条件校准:sidebar `thread/resume` 会唤醒 idle queue,直接 add 到全新 Thread 的旧 fixture 会 admission 第一条。改用 current external 阻塞 backend 启动唯一基线 Turn,再真实 interrupt 确认终态,随后添加 pending Queue;只含已执行基线历史且 idle,pending 不出现在 transcript。该 fixture 仍绕过 live Provider,不证明跨 unload/restart 暂停持久化。 +- 大文件例外:code-artifact fixture 只接入独立 lifecycle helper 的 import/call,业务与 oracle 位于 `scripts/electron/lib/`,不继续向原巨型入口堆叠;退出条件为相同 Thread Gate 完成并冻结接线。 + +## Browser live tab 恢复合同 + +- 触发:同线程 Browser→Files→Browser 卸载/重建 BrowserWorkspace,仅 mount primary,丢失完整 native tabs 的投影;primary存在时第二tab无法恢复可见性,primary已关闭时会复活primary。BrowserTabHost是既有live tabs/selected/URL/native view唯一authority;App Server继续只分配canonical browser session/thread identity。 +- 负责人:lime_desktop_capabilities;root记录,2026-10-14。生产窄写集为BrowserTabHost、ipcChannels、browserTab typed API、BrowserWorkspace及必要独立hydration helper;相邻host/API/component/IPC合同tests。根治理catalog与既有mock/fixture若当前native命令已纳入必须同步,不建生产fallback。 +- 新增host-only只读`browser_tab_list`:从实际IPC sender派生window,按window/ownerWebContents/browserSession/thread返回完整live snapshot;已知跨scope资源拒绝,不将身份不符伪装empty。沿既有preload白名单及main native dispatch,不增加Rust业务runtime或猜测上游符号;名称为本地领域命名,未声称REA确认上游导出。 +- renderer在同一canonical identity下hydrate,非空恢复host selected与既有setBounds,只在确认真正empty时创建primary。snapshot失败、scope混入、无selected中间态不能bootstrap;event/snapshot/selected/close交错不让旧快照覆盖新事实;origin+单调generation隔离ABA与迟到bounds。 +- 验证:双idle tab选第二→切换保持列表/URL/view/webContents;关闭primary→切换不得复活;跨window/session/thread/伪造owner/mixed/duplicate snapshot拒绝;empty、失败、late event/snapshot/bounds和ABA;定向host/API/renderer、`test:contracts`、真实Electron双tabGate、GUI smoke与本地门禁。 +- 大文件处理:BrowserTabHost既有超过1000行,新增snapshot参数解析/校验/列表拼装进入该领域独立模块,主类仅注册和窄委托;记录既有文件拆分风险,退出条件为不向主类继续追加业务算法。BrowserWorkspace接近800行,hydration逻辑拆独立hook/helper,并直接替换旧bootstrap effect,不保留双轨。 +- 不证明native跨线程或重启持久化,不推导Codex运行等价;本刀修复已确认本地生命周期缺口。root最终复核并更新架构图;command/bridge变更不通过时不能进入产品证据。 + +## 本轮冻结与证据记录(2026-10-14) + +- pending public read/list/resume/paging:11文件,74/74 tests、rustfmt与窄diff通过。定向JSONL含queue6、v2 thread19、resume replay2。日志`.lime/qc/validation/codex-queue-public-history/`。分页读取Summary pending IDs的长历史成本尚未benchmark。 +- pending搜索:7文件,store6 + JSONL11 = 17/17 tests、rustfmt与窄diff通过。fixture显式`sourceKinds:["appServer"]`,先正向证明已执行内容可命中,再断言pending词不命中,避免默认CLI/VSCode scope导致负向空跑;默认产品scope不变。日志`.lime/qc/validation/codex-queue-search/`;最终build SHA`1188f12c7150afbe6a0c666eb57c3e109ed9c8682b698aa23667a2faa26778a3`,Gate prepare后需重新记录实际binary。 +- Right Surface局部标签:6生产 + 2新增tests + 1迁移guard,73/73 tests、renderer typecheck、窄lint/diff通过。日志`/tmp/lime-right-surface-tests-final.log`。旧availability自动造open tabs与plugin-only epoch reset删除,未猜测unknown上游名称。 +- Browser live tab:8生产文件与相邻测试/fixture写集冻结,11文件82/82 tests;renderer/Electron typecheck、20文件窄lint/diff及`test:contracts`通过。BrowserWorkspace从802降为689行;原primary-only恢复effect删除,无compat。新增snapshot/selection算法进入独立helper;主Host只注册/委托与修正visibility。日志`/tmp/lime-browser-hydration-{tests-final,renderer-tsc-final,electron-tsc-final,lint-final,contracts}.log`;当时 native 双tab Gate 待 Rust 冻结;随后通过,见下方最终 Browser / Right Surface Gate B。 +- root Gate oracle:4文件56/56 tests通过,日志`/tmp/lime-pending-and-surface-oracle-final.log`;标准`npm run typecheck`通过,日志`/tmp/lime-codex-alignment-standard-typecheck-final.log`。首次误用Node test runner被Vitest拒绝,改用实际Vitest入口重验,未修改产品或放宽断言。 +- Composer真实Gate B通过:`.lime/qc/gui-evidence/composer-utility-electron-gate-b/codex-gui-alignment-composer-summary.json`。两项目A→B→A GUI选择、branch create/checkout、Electron/AppServer冷重启后GUI恢复与Git readback;真实IPC4次、mock/invoke/console/page error为0,实际binary SHA`1c10f38b5d4927d32c01d4c92032fd7c95b09aa8325b96606e6bfad12fe1bcbf`。跨重启分别采集真实trace后合并,未补fake request。 +- Gate失败已修原因:pending旧fixture等待marker正文,sidebarresume实际admission第一条,已迁interrupt基线;RightSurface初版request只填session,但GUI按workspace ID/root查询,已补同workspace scope。当时两项均待最终冻结后重跑;随后新版最终 Gate 已通过,失败结果不计成功证据。一次Gate freshness发现搜索半写集并开始prepare,已中止exit130,无剩余prepare/cargo进程,不将其产物作为最终证据。 +- 新oracle:真实host/身份/资源/EOF/图片读/选择关闭负向回归通过;Browser Gate已增强双tab与关闭primary后恢复,必须出现真实native snapshot IPC,不把单primary保持等同完整tab恢复。 +- 分类:以上新增owner/helper/client/fixtures为current;旧入口直接dead/deleted/forbidden-to-restore;无新增compat/deprecated。规则100%,Composer本合同100%,public pending与RightSurface本合同的真实Gate/默认门禁尚未关闭;全产品仍partial。 + +## Queue rollout 重建恢复合同 + +- 触发:projection_rebuild按TurnStatus将pending Queued/InProgress写成running。真实runtime queue/add写rollout,保留原DB/event log,并以同一rollout root初始化全新同库DB:canonical完整快照相等,但projected Turn.status=Running、冷queue/list由1变0。复现日志`.lime/qc/validation/codex-queue-rollout-rebuild/reproduction.log`;修复限定现有重建owner,不改变watermark或全局状态机。 +- 负责人:lime_gui_comparison,root记录,2026-10-14。先用临时数据与既有真实rollout写入/空DB重建路径复现pending queue/list/read/resume;避免删除用户DB。窄写集为现有canonical_thread_store/projection_rebuild及该领域独立queue regression与必要注册;若没有复现,不增加生产补丁并记录证据。禁止改scheduler/unload/export/schema/普通admitted与terminal恢复语义。 +- 若复现:重建必须保留canonical Turn.queue事实、稳定User Item/client/input/媒体与pending projection;唯一当前owner直接修复,不恢复第二journal/store。验证同库/分库、pending+executed混合、admission后可见、public history/search排除pending及既有completed/active/repair回归。 +- 退出条件为实际复现分类与定向测试通过,冻结新binary后再跑最终Gate;诊断export与idle unload仍独立待核对。本合同不扩大为全workspace测试。 + +## 完整 Composer 只读补证 + +- 负责人:lime_desktop_capabilities;与root Gate收敛独立。无生产、测试、fixture写集,不触发build freshness。 +- 先复用指定ASAR与现有REA Evidence,定位主editor而非UtilityBar/BrowserSkills/Sketch;仅当明确seed未被分析时补focused REA,不重跑全包、不执行Codex或绕过Computer Use拒绝。 +- 返回真实模块/可确认导出、草稿/IME/附件/提交数据流与Lime input-kit/Inputbar调用链,记录Evidence ID、SHA/UTF16 anchor、观察/推断/unknown及最小可实施缺口。root随后落入本账本,不由静态补证标记产品完成。 + +## Browser actual sender dispatch 复核 + +- root 复核发现 `main.ts` 的 `currentWindow(event)` 会在未知 sender 时 fallback 到 mainWindow,Host 仅看到 window 而失去实际 sender。窄写集为 snapshot 领域 sender resolver / 相邻真实 Host 测试,以及 main browser-tab dispatch 的委托接线;不改 hydration 或其他 Host 命令。 +- BrowserTab 命令只能使用 `BrowserWindow.fromWebContents(actualSender)` 且 `window.webContents.id == actualSender.id`;未知或 child WebContents 直接拒绝,不返回 empty、不回退 mainWindow。主文件超过1000行的例外仅限 import / 委托接线,算法位于已有领域独立模块。 +- 定向验证 exact sender、unknown sender、child sender 经真实Host list的成功/拒绝;Electron typecheck、窄lint、contracts,再进入新版双tabGate。 + +## rollout 与 sender 最终冻结 + +- rollout:生产只在现有 projection_rebuild 的 Turn.queue 分支保留 queued;独立 queue regression/注册在同领域。新增及既有状态3 + repair/rebuild13 + queue JSONL7 = 23/23通过,rustfmt/diff通过。worker 当时 rebuild binary SHA `1d93af0f9d52a9e7ae2add71d5556fe42c44aa17148254c270d603e44fae4478`(不作为实际 Gate prepare 后摘要);日志 `.lime/qc/validation/codex-queue-rollout-rebuild/`,原失败仍保留。worker domain测试显式MockBackend,真实Electron Gate另用external fixture,不冒充live Provider。 +- sender:root新增 actual sender 接线3项回归;受影响5文件41tests、Electron typecheck、root窄lint与最终contracts全部通过。日志 `/tmp/lime-browser-sender-tests-final.log`、`/tmp/lime-browser-sender-electron-typecheck-final.log`、`/tmp/lime-codex-alignment-root-lint-final.log`、`/tmp/lime-codex-alignment-contracts-final.log`。 +- 所有产品与测试写集已冻结,root顺序跑新版pending Queue与双tab Right Surface Gate。两个worker只做完整Composer/Queue activeSteer只读补证,不影响build freshness。 +- Queue activeSteer只读补证由lime_gui_comparison负责:追踪同ASAR Steer callback的外部owner、消费与steer事务/失败顺序、expectedTurnId/client identity及交付状态;对照Codex current ext/queue与Lime既有steer/session actor,不用renderer delete→steer,不写产品/测试/文档。返回sources/Evidence/unknown与最小正确后续合同。 + +## 最终 pending Queue Gate B + +- `.lime/qc/gui-evidence/thread-queue-electron-gate-b/codex-gui-alignment-queue-pending-final-summary.json` 的 `ok=true`:rich input/client identity、update/reorder/delete、独立preview/首图真实decode、public read/paging/search pending排除、idle与正向执行搜索基线全部通过,mock/invoke/console/page error均0。 +- 实际postprepare App Server SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。初始worker build SHA与prepare后binary不同,最终证据按实际值记录;不推导未知脏树的精确源码等价。 +- 首次新Gate的search数据全部符合合同,但直接preload读取并不生成safeInvoke trace,oracle错误要求该trace。诊断证明baseline=1/idle、pending=0/EOF、occurrence=0/EOF;已将直接调用取证迁到真实发送params及匹配request/response ID的`ipcObservation`,不制造trace、不补fake request、不放宽搜索断言。 +- 共享观察predicate位于现有mcp-config-fixture-evidence;直接call在真实preload返回成功后记录observation。Queue/RightSurface分别校验准确method、identity与输入,同步负向unknown/mismatched response/mock回归。4文件61tests通过,日志`/tmp/lime-preload-proof-tests-final.log`;最后统一门禁继续验证。 + +## 最终 Browser / Right Surface Gate B + +- `.lime/qc/gui-evidence/code-artifact-workbench-electron-fixture/codex-gui-alignment-surface-final-summary.json` 的 `ok=true`:真实GUI新建/选择第二tab,Browser→Files→Browser恢复完整tabs/selected/session/tab/webContents及真实native URL;关闭primary后再次切换不复活,最后分别关闭Browser/Files。canonical请求、native mount/list均为真实preload/IPC观察;mock/invoke error0。 +- 同场景保留原四文件Files diff/选择/定位回归,external file open为0;不把native跨线程、Electron重启或Codex实时GUI列为已验证。 +- 直接preload的workspaceRightSurface/request与Queue search复用同一实际发送/响应观察predicate;native mount/list继续要求safeInvoke真实IPC trace,不能拿测试构造trace补证。当前fixture22聚合进行中。 +- 后续只读Queue activeSteer报告已落 `internal/research/codex-desktop-rea/queue-steer-audit.md` 与证据索引;前端实际steer成功后delete、unknown对账、有限TTL锁已确认,持久幂等未知。Lime日志失败/终态竞争风险仍为待复现,不按静态风险直接扩大生产写集。 + +## Composer 附件异步隔离 Verification Contract + +- 负责人:lime_desktop_capabilities;root 登记,2026-10-14。预算 `budget:normal`,风险 P1;上一批 current fixture 22 场景通过且退出码 0 后开启本切片。 +- 参考:REA 主 Composer `ev_18100ea823ab540f529ba756499161870e17f824267ed48a0665d3fb48cd0bb4`,原成员 SHA 与 UTF16 anchors 见 main-composer-audit。上游文件附件捕获 generation 并在完成时检查;只支持生命周期隔离原则,不声称与本地图片 hook 精确同义。 +- 窄写集:`Inputbar/hooks/useImageAttachments.ts` 与相邻测试、`components/useEmptyStateAttachments.ts` 与新相邻行为测试,以及 `components/useAttachmentGeneration.ts` 小型共享 lifecycle helper。不迁附件状态 owner、不修改 runtime/store/协议/五语言文案;不夹写 root 的 BaseComposer、文档与 Gate。 +- Current 主链:首页/会话读取附件 → 当前草稿 owner → 既有 submit → preload/IPC → App Server `turn/start` 或 queue method → canonical Item/media readback。generation 只隔离本地读取;不建立第二套业务后端。 +- Happy path:同代读取正常接收并遵守既有容量、格式、路径与文案合同;clear/replace/unmount 后旧 success/error 均不改变新草稿或发旧 toast;下一代仍正常读取。先以 deferred FileReader 复现旧行为,再修复;不从 hook 测试推导跨线程 scope 或跨重启附件持久化已完成。 +- Evidence:确定性 hook/DOM 回归必需;GUI 使用受影响真实输入 fixture 验证发送与 Item/media 消费;不跑 live Provider,不形成 packaged/Windows 证据。失败在本地草稿 owner 停止,既有发送失败保留草稿规则仍有效。 +- 必跑:两个 hook 及输入消费者定向测试、renderer typecheck、窄 lint;冻结后受影响 Electron 输入 fixture、`verify:gui-smoke`、`verify:local`(Vitest resume)。无 public boundary 变化,不为该切片重复 contracts。 +- 场景:P1 首页/会话 rich input 与发送恢复。Supervisor/LLM judge 不适用;回写 deferred lifecycle tests,关闭条件为迟到 success/error 不回流且同代正向接收。 +- 架构:非重大,现有附件 owner 内提取共享生命周期原语,目录职责/协议/read model/依赖方向不变;全面 Composer 编辑器结构仍待对齐。 + +## Composer IME 候选确认 Verification Contract + +- 负责人:root,2026-10-14。预算 `budget:normal`,风险 P1。 +- 参考:REA initial 六声明 `ev_38693c282fb15af95ea4b8f1caa292ed199fee587ea26d709b126123c3fb8153`;原 initial SHA `604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f`,cBc UTF16 10282434 检查 event/view composing/keyCode229,uHc 10371337 的 compositionEnd 只更新编辑状态。 +- 窄写集:`input-kit/BaseComposer.tsx` 与相邻测试、`Inputbar/inputbarSendPayload.ts` 与发送测试的 trigger union、既有 Electron 输入 fixture 中独立 IME oracle、研究/规则/执行文档。附件由既有 worker 独占;避让外部 Rust/command/approval 脏写集与未知根 JS。 +- Current 主链:BaseComposer DOM 编辑 → 既有 InputbarSend → current gateway/preload/IPC → App Server `turn/start` → canonical projection。候选确认不得发出 submit 请求;另一次普通 Enter 或显式按钮仍走既有主链。 +- Happy path:compositionstart 内部状态、native isComposing、Process、keyCode229 均保护编辑;compositionend 不安排发送;普通 Enter、Shift+Enter、button/stop 与历史回放保持各自合同。删除 pendingImeSendRef、旧 composition-send 和 ime trigger 类型,不保留 compat;将旧自动发送正向测试迁为负向及随后显式提交正向。 +- Evidence:DOM 确定性回归证明下一次 Enter 和显式按钮正常提交;真实 Electron rich-input fixture 以合成 composition/keydown 事件验证无提交请求及 canonical 空历史,随后真实按钮提交并读回同 Thread 的 Item/完整富输入。合成事件不冒充系统 IME 候选窗口或 Codex 实时 GUI 证据;不跑 live Provider。 +- 必跑:BaseComposer、ComposerController、InputbarSend/输入消费者定向测试;renderer typecheck、窄 lint;冻结后受影响输入 fixture、GUI smoke、默认本地门禁续跑。P1 多语言输入/意外提交;Supervisor 不适用,失败回写 owner tests 与 Gate oracle。 +- 架构:非重大,现有编辑事件与 metadata 合同内收敛,无 public protocol/read model/业务 owner 变化。完整富文本编辑器与 scoped persistent draft 仍单独待办。 + +## Composer 附件与 IME 冻结记录(2026-10-14) + +- 附件:3生产/2测试文件,deferred FileReader 在旧实现中复现12项生命周期失败,修复后两个hook21/21通过;5个输入消费者172/172通过。只隔离回调,不中止FileReader;卸载采用layout cleanup同步失效。日志 `/tmp/lime-attachment-generation-{repro,hooks,consumers,lint}.log`。 +- IME:旧pendingImeSendRef、compositionEnd RAF submit、ime trigger类型及旧正向断言已删除;BaseComposer/Controller/InputbarSend 3文件41/41、IME与受影响oracle 6文件157/157通过。未将重叠的InputbarSend用例重复合计。 +- 标准typecheck、root窄lint、12项current/dead回流守卫通过;legacy report的零引用/分类漂移/边界违规均为0。新两个研究报告原被research全局ignore覆盖,已加入精确白名单;不纳入原始应用源码或Evidence大包。 +- 真实输入Gate:`.lime/qc/gui-evidence/claw-chat-current-fixture/codex-gui-alignment-composer-ime-final-summary.json` 的ok=true、exit0;四类IME信号无submit请求/无canonical Turn,随后真实按钮提交,backend富输入/取消、GUI文字/图片/路径/Skill恢复及read model interrupted均通过。mock/legacy/console/page error为0;合成DOM事件不证明系统候选窗口或Codex实时GUI。 +- 实际App Server路径为`lime-rs/target/debug/app-server`,SHA `a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`;pendingQueue/Browser最终Gate也使用prepare后的此摘要。worker此前重建摘要不替代实际Gate摘要,不推导脏树精确源码构建等价。 +- 默认门禁 `verify:local` 已退出0:五语言/unused、lint、硬编码扫描、标准typecheck、Vitest 91→121的31批3553项续跑(状态121/121 passed)、contracts、7个受影响Rust crate的3179项测试以及真实Electron GUI smoke全部通过。shell evidence为`.lime/qc/project-gates/standalone-shell-01-20261013203631-5833/shell-01-electron-smoke/summary.json`,含冷reload、3个窗口尺寸、memory设置和真实App Server初始化。日志 `~/.local/share/rea/analyses/codex-desktop-2026-10-13/lime-composer-local-final.log`。 +- 本切片current,无新增compat/deprecated;被替代IME行为为dead/deleted/forbidden-to-restore。完整富文本结构、text_elements发送/恢复、持久草稿与全面账本仍未关闭。 +- 两项风险切片完成度100%;该百分比仅指上述合同,不作为全面GUI百分比或release/Windows证据。 + +## 结构化 Composer / EditorView Verification Contract + +- 负责人:root协调,lime_gui_comparison负责input-kit文档/状态/serializer/view,lime_desktop_capabilities负责typed submission传输;GUI消费者与测试/fixture分属窄写集,在接口冻结后开启。预算`budget:normal`,风险P1。前一冻结写集的默认门禁已全部通过,不复用为本切片产品证据。 +- 参考:main-composer-audit的REA/ASAR identity;观察到ProseMirror EditorView、controller、serializer、keymap、submit-ready、destroy职责。开发源码类名unknown,不妨碍领域结构直接迁移;只确认style/诊断的名称不冒称export。 +- Current主链:同一首页/会话ComposerEditor → ComposerController唯一EditorState/document → 同次submit receipt → typed ComposerSubmission → start/steer/queue gateway → preload/IPC → App Server现有UserInput/text_elements → canonical Thread/Turn/Item;恢复只读receipt.draft,发送只读receipt.submission。 +- 冻结内部接口:ComposerDocument.editorDocument为严格JSON;display text/selection/mention/textElements/pending paste从唯一PM文档派生。ComposerSubmission为展开paste的text、typed TextElement(最终文本UTF8 byteRange)、typed skill/mention、attachments/path/capability。receipt保留revision与原始完整draft,新增submission;不以展开文本覆盖折叠JSON。SendMessageOptions显式传composerDraft/composerSubmission,不从metadata反推。 +- 文档owner:Controller持有EditorState,View只引用该state并回交Transaction;applyTransaction消费全部appended transaction后原子更新revision/snapshot。PM positions、display UTF16和最终UTF8分别映射;不建第二React/TipTap document store。已有`tiptap/pm`足够,不安装/升级依赖,不新增Rust/JSON-RPC字段。 +- 窄写集A:input-kit的Controller/types/hook/exports;新schema/serialization/positions/keymap/ComposerEditor及相邻行为tests。避让BaseComposer、产品adapters、wire与fixtures。 +- 窄写集B:InputbarSend及snapshot→HandleSendOptions/SendMessageOptions→preparation/execution→buildTurnInput的明确透传、queue的统一serializer与stream恢复draft构造;相邻typed/fixture tests。避让input-kit、BaseComposer、InputbarController/EmptyState/CharacterMention与外部Rust/command脏写集。 +- root写集:BaseComposer、InputbarCore/ComposerSection/index/styles、首页/EmptyStateComposerPanel、useInputbarController/HintRoutes/Dictation、CharacterMention及相邻tests/必要领域helper。主textarea/textareaRef/textareaProps/value/selectionStart/setSelectionRange直接迁移为ComposerEditorHandle,不模拟HTMLTextAreaElement。CharacterMention已超过1000行,只拆领域接线,不继续堆业务。 +- fixture写集在独立worker登记:仅主输入选择器迁`[data-testid="composer-editor"][role="textbox"]`,普通设置表单textarea保留;40余脚本的value setter/selection/disabled/读取与shared helper同步,DOM操作走真实EditorView事务,不调用测试侧第二业务API或生产mock。 +- Happy path:输入、mention/file/skill/plugin atom、长paste折叠/展开、selection、换行/撤销、图片/路径和完整history/draft恢复;start/steer/queue消费同一submission且保留typed ranges。确定失败保留完整draft,旧receipt与旧附件读取不覆盖新代。先复现text_elements/restore丢失,再迁移对应旧正向测试与护栏。 +- 键盘合同:IME guard先于mention/hint,检查event/View composing、Process/229;compositionEnd只编辑。hint消费后才history/submit;Shift+Enter换行,普通Enter/button按原canSend/defer/pointer去重/stop合同。paste图片/路径owner先消费,否则统一CRLF/CR→LF、大pasteatom;不允许HTML绕过schema。 +- 必跑:Controller/serialization/positions/View事务行为,输入消费者、mention/dictation、typed wire、queue/restore与fixture oracle定向测试;typecheck/lint/i18n;冻结后真实rich-input/IME/active-steer/home输入Gate、current fixture、contracts、GUI smoke与默认本地门禁。source-string或synthetic事件不冒充系统IME/Windows、跨重启或live Provider。 +- Evidence:deterministic与GUI trace/canonical readback必需,release-artifact与live/eval不需要;P1输入/错误恢复,Supervisor不适用。故障回写最近owner及产品Gate,禁止为旧selector恢复textarea。完整marks/code-block/链接编辑、voice、跨线程/跨重启persistent rich draft与Queue durable active handoff继续独立登记。 +- 架构影响:重大,Renderer document/transaction owner与typed输入数据流收敛;实施同刀更新architecture中的Composer图,root在冻结时核对state、snapshot、wire、恢复与测试门禁并确认。未确认/未取得新Gate不标记本切片完成。 + +## 结构化 Composer 实施与迁移记录(2026-10-14) + +- A:schema/serializer/positions/keymap/Controller/View 的 5 文件48/48;7个消费者149/149;第二批 helper/五语言主输入命名7文件68/68(新增helper18)。各窄lint/diff通过。以上测试集有重叠,不合计成唯一总数。 +- B:typed submission→start/steer/queue 显式传输,完整 restoreDraft 深拷,精确 textElements remap、真实接受结果透传与失败重抛;20文件316/316及31文件窄lint通过。旧失败复现4/4保留;不以传输单测代替跨 surface 验收。 +- root:BaseComposer、首页/会话/mention/dictation迁至真实Editor;CharacterMention 1408行拆为主组件、选择hook、types和纯helper。root上一聚合3文件104/104通过,之后追加 shared owner/history 接线需 fresh 验证。IME guard先于hint/mention,compositionEnd只编辑;View卸载销毁,不创建textarea适配器。 +- fixture:首批72文件迁移,71 mjs语法检查、smokeChecks窄lint、14文件220/220与current-entrypoints16/16。追加完整typed input/same client User Item/media字节/active Steer/独立IME/唯一可见editor oracle:4文件84/84、受影响smoke9/9;未重复220基线。history Gate在同rich场景追加,冻结结果待记。 +- dead/deleted:`InputbarPluginBadge.tsx`、`SkillBadge.tsx`及3个旧Plugin文本helpers;catalog为retired-composer-reference-sidecars禁止回流,调用者/tests直接迁。旧主textarea领域名称、styles/ref/props及五语言core.textarea key均直接替换,无新增compat/deprecated。普通设置表单textarea不属于被退役主输入。 +- root文档写集:architecture Composer state/transaction/receipt/wire/restoration图、main-composer-audit、design-language、scripts README与本计划。重大架构确认:root,2026-10-14;已核对目录归属、数据流、依赖方向、既有协议边界与验证门禁;实际Gate尚待新冻结产物。 +- 1000行例外:EmptyState约1130行,本次仅注入共享Controller与既有资源/历史接线,不继续堆业务算法。能力route UI派生已拆独立hook;退出条件是后续首页能力/推荐逻辑改动先拆领域owner,当前不为行数引入第二store或接口包装。 + +## 首页/会话共享草稿与接受锁 Verification Contract + +- 负责人:B负责既有workspace local display owner/scene与transport;root负责surface props、能力route与资源消费者;A负责Controller hook注入和附件generation。风险P1,budget:normal;沿用上节唯一业务主链,不新增JSON-RPC/Rust字段/依赖。 +- 实际缺口:request发布即切首页→聊天,局部Controller和图片丢失;failure只保留string。`return executeSendPlan` 未await导致finally提前释放prepare锁;迟到success能清新surface draft,queue可重复add。先失败复现:6项新断言均失败,日志`/tmp/lime-composer-scope-repro.log`。 +- 最小修复:用workspace Controller snapshot替换原input string store,同实例注入Home与Inputbar;materialization preserveInput延续scope,显式导航/new-chat/back-home完整reset并同步清canonical路径。附件直接消费Controller resources,不建空挂载镜像;能力canonical route在同owner,UI候选详情不是第二事实源。 +- 接受前同scope互斥;start/steer/queue与local confirmation等待真实返回后finally释放。确定false/reject完整草稿保留,success只有相同revision可commit;外部编辑/restore/reset或surface remount的旧receipt不清新输入,结束后正常重试/下一条仍可发送。 +- 必跑:shared hook mount/unmount与严格JSON/资源、deferred true/false/reject、新revision、explicit scope reset与same-scope hydration、materializer preserveInput、queue重复/失败重试;输入消费者history ArrowUp/Down回归;typecheck/lint,冻结后同新版Gate与默认门禁。 +- Evidence边界:Gate键盘history检查真实可见token/附件/path/capability与无额外input;hidden paste payload恢复由Controller行为test证明,初始wire另证明完整展开正文。不复制paste原文到DOM attribute以伪造可观察性,不把分层证据提升成不存在的history payload readback。 + +### 共享草稿补充护栏与新冻结 + +- A第三批9文件:external Controller不建shadow;两个附件hook直接canonical派生;generation对精确own写入豁免、外部同步重入/reset失效。clear/restore每次force新state/revision,普通同值setText/replaceDocument仍no-op。修前6红/38绿,修后6文件72/72与窄lint/diff通过。 +- B第三批19文件:shared workspace owner、显式scope reset、首页materialization preserveInput、await acceptance与统一start/steer/queue pending锁;9文件89/89、追加Reset2/2、19文件lint通过。 +- B第四批7文件:usePathReferences删除useState,同步canonical写与snapshot派生;空scope使用clear;useInputbarSend提交瞬间capture canonical资源/route,不回灌旧React props。path/scope旧行为3 fail、send旧closure3 fail;修后3文件26/26及7文件lint/diff通过。 +- root移除两surface路径与附件镜像;历史/中断恢复不再clear/add同owner资源,UI直接canonical派生。能力初始化signature与恢复requestId按Controller identity一次消费,避免重挂载回放旧预选/恢复覆盖用户编辑。Home发送同样读取当前文档,restoreDraft严格保留receipt原始route,不从fallback候选重建。 +- 新root失败归因:force restore后非稳定effect重复执行导致maximum-depth,已在同Controller恢复request owner加once消费;builtin fallback候选不能改原receipt route,恢复源回到完整原稿。失败日志`/tmp/lime-composer-root-canonical-tests.log`保留,不列成功证据。 +- history fixture追加5文件:真实accepted/clear→ArrowUp富草稿→ArrowDown空草稿;可信键盘事件、同session焦点、DOM结构/token identity/PNG字节/path identity/已挂载Skill、canonical与submit trace无新增。77/77新oracle、既有rich合同1/1与syntax/diff通过。真实Gate仍待统一build。 + +### 结构化 Composer 冻结后验证(持续更新) + +- root五核心文件212/212、新增恢复请求重放回归后的Inputbar 92/92通过;标准renderer/node typecheck与窄lint通过。guards273/273、contracts、legacy report通过,后者零候选/分类漂移/违规。测试集有重叠,不合计为唯一总数。 +- 删除SkillBadge后唯一unused的`skills.inputBadge.remove`已从五语言`agentSkills.json`清理;i18n完整性与unused检查均exit0。第一次`verify:local`在该unused键失败,尚未形成新编辑器默认门禁成功证据。 +- GUI续测事实源移除旧主textarea选择器,改为唯一可见同session的ComposerEditor与真实EditorView编辑;普通表单与TUI textarea不在清理范围。 +- 首次新版rich Gate exit1,证据`.lime/qc/composer-alignment-review/rich/rich-summary.json`:prompt、大paste atom与Skill已插入,无console/page/invoke错误;expected `/var/.../SKILL.md`与App Server canonical `/private/var/.../SKILL.md`别名导致严格身份断言失败。夹具将解析实际文件realpath作为同一expected identity,保留错文件/错身份失败,不放宽为suffix检查。该失败不记成功;五语言清理后重新prepare并冻结再取产品证据。 +- 追加Skill identity:公开机器name是目录名`capability-report`,frontmatter `Capability Report`是display name;夹具明确机器name/token id/label,DOM/wire/canonical/history共用真实文件identity。同文件别名通过,同名同内容其他文件/missing/wrong identity失败。8脚本冻结,102/102新定向、既有rich合同1/1、syntax/format/diff通过;失败predicate回放保留。生产和App Server未改。 +- 新build/prepare exit0,实际二进制与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。新版active Steer、home-hotpath、home-hotpath-greeting均exit0(`.lime/qc/composer-alignment-review/{steer,home,greeting}`);Steer为runtime+本地provider fixture,typed request/client User Item/同Turn终态保真且无新turn/start或queue;home为external backend,绕过provider执行层。零console/page错误,不冒充live。 +- `verify:local`第二次在`InputbarCore.test`迁移后未使用的两个editor工具import处失败,已精确删除并清理旧textarea局部名/描述。后续默认门禁停在22/125:模型设置fixture缺少canonical通知、失败发送测试仍期待resolve,以及Node24原生fetch与jsdom AbortSignal混用。已分别修正合法空ACK/显式canonical通知、reject断言、Host测试的Node环境;完整useAgentChat 187/187、Host 49/49及窄lint通过。生产行为和真实HTTP/PNG缓存断言未放宽,下一次从失败批次resume。 +- 图片上传fixture已从错误Core祖先查找迁为实际添加图片按钮→filechooser,并严格核对chooser input属于同session唯一Editor所在Surface。53/53定向及rich合同1/1通过,媒体字节和identity oracle保持严格。 +- rich真实历史恢复暴露P1产品问题:CharacterMention将恢复的不可编辑atom标签解析为新提及,捕获方向键后阻断ArrowDown。只读诊断确认Controller历史游标和PM/display文末选区正确,诊断结果不作成功Gate。最小修复只从现有PM文档投影解析候选,并拒绝候选范围与atom重叠,不建第二状态owner、不改serialization或paste源文。 +- 新增五类atom的真实EditorView ArrowUp/Down组件回归与atom后新输入`@`/`/`正向;修前4项失败(text_element另改实际展示内容后覆盖),修后7/7,相关21文件162/162。renderer typecheck与四文件lint通过。修前/修后日志分别为`/tmp/lime-composer-history-atom-{repro,fixed}.log`;聚合为`/tmp/lime-composer-history-related.log`。 +- 键盘Gate observer修正capture+queueMicrotask提前读取问题,改同Editor后注册的bubble listener同步记录defaultPrevented。55/55脚本定向含6项事件时序回归,trusted/focused/session/严格消费与canonical不变;日志`/tmp/lime-composer-history-keyboard-tests.log`。夹具修复不能替代真实产品Gate。 +- 新增跨atom且含emoji的查询边界回归,history组件8/8;全部提及选择动作同样使用atom-aware解析,renderer typecheck与五文件lint通过。fresh rich第一次已证明history键消费、完整可见原稿/资源及返回空稿,但发送接受证据predicate仍失败,`.lime/qc/composer-alignment-review/rich-final/rich-summary.json`保留exit1,不计成功。 +- 修复真实质量缺口:Vitest resume只加载旧files会遗漏新增回归,并重跑失败批次之后已passed的批次。既有runner现复用同一collect/live过滤/buildBatches,追加新pending,保留旧批次/开始时间/失败起点并跳过passed。7项真实CLI+fake Vitest行为回归,runner19/19、相关30/30通过;只读preview原125批保留,新增126包含图片上传和CharacterMention.history测试,未修改默认状态。日志`/tmp/lime-vitest-resume-discovery-{baseline,targeted,related,preview}.log`。默认门禁现重新resume,新测试将实际进入执行。 +- 该次resume从22运行到34/126,在过期Skills静态guard失败:同session选择器已迁入真实shared helper,但guard的源集合未包含它。仅补`composer-editor-dom.mjs`进入真实依赖集合,原断言保留;完整guard82/82、窄lint通过。日志`/tmp/lime-composer-fixture-selector-guard-fixed.log`。生产mock/唯一owner/identity护栏未放宽。 +- 独立review发现P2:send preparation在结构化原稿分支以dispatch备用route覆盖receipt.draft原始route,造成失败恢复丢选择。仅保留原稿route,发送仍用当前dispatch route,非结构化fallback不变。新增3红复现(缺备用、冲突、原稿无route)后preparation8/8及相关14/14通过,原稿JSON/paste与发送/恢复route分开断言;日志`/tmp/lime-composer-receipt-route-{repro,related}.log`。此生产补丁重新开启冻结,需fresh renderer及产品Gate。 +- fresh rich发现发送接受oracle混淆原稿与最终dispatch文本:当前installed_skill route在正文前加已确认的`/capability-report `;真实wire/backend完整正文相同,client/canonical/keyboard均通过。期望现在由fixture机器名与展开原稿独立生成,wrong/missing/double前缀、尾缀、截断、status/client、多文本/多request均失败,最终wire的UTF8范围精确匹配;history/rich115/115及窄检查通过。正式rich已执行历史与cancel闭环,但common旧断言仍期待纯PROMPT,正在迁到完整prepared原稿精确恢复;该次exit1不记整体成功。 +- 默认门禁第二次续跑至43/126,旧Agent API fixture仍期待不存在的零queue count并把pending queue伪造成canonical Turn。测试迁为真实thread/read合同,独立queue owner保留;不恢复renderer排队历史过滤,不合成零值。Agent API与canonical projection42/42、lint通过;日志`/tmp/lime-canonical-session-fixture-fixed.log`。下一次从43续跑。 +- 旧textarea回流护栏的扫描范围遗漏新owner,现只迁该条catalog至完整input-kit与Home EmptyState,普通表单/TUI继续合法。隔离源树真实CLI复现4项漏检后,Editor/Controller/adapter/Home恢复旧路均exit1,真实current/普通表单/TUI源码exit0。相关core/summary/catalog234/234、真实全仓report零违规、lint/JSON/diff通过;日志`/tmp/lime-composer-legacy-guard-{repro,related,lint}.log`。其他catalog并行改动原样保留,无新增compat。 +- 生产写集再次冻结,重新prepare后取fresh rich/IME/history、active Steer/home、current fixture、GUI smoke与默认门禁证据。未修改依赖、锁文件、Rust或公共JSON-RPC字段;完整marks/link/code-block、persistent draft与voice仍独立待办。前轮成功不覆盖本次atom-aware trigger改动。 + +- 最终fresh rich Gate exit0:`.lime/qc/composer-alignment-review/rich-complete-final/rich-summary.json`,68/68,history11项与发送接受14项全部通过。历史导航前后完整输入request各1,无额外start/steer/queue;DOM原稿、Skill/paste identity、图片字节、路径及能力选择保真,ArrowDown回空稿;typed/full canonical/media readback13项通过。same-session唯一可见可用Editor、send-ready、cancel后无正文;console/page/invoke/actionable errors与mock fallback均0。App Server与Desktop复制品SHA均为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`。external controlled fixture绕过live provider/runtime prompt lowering;键盘真实,IME/paste为synthetic DOM事件,hidden paste源文恢复由Controller tests证明。脚本134/134与场景合同1/1通过;`/tmp/lime-composer-rich-complete-final.log`为实际进程日志。 +- 默认门禁第三次续跑至52/126,external controlled pending Queue Gate未登记进后端边界精确白名单。只在`appServerRuntimeBoundary.testSupport.ts`追加既有`thread-queue-gate-b.mjs`及相邻测试两条精确路径,未放宽生产默认runtime或目录范围。backend boundary与Queue Gate tests7/7、窄lint通过;日志`/tmp/lime-external-fixture-registration-{fixed,lint}.log`。新版默认门禁继续从失败批次resume。 + +- 默认门禁第四次续跑至72/126,inputRestoreGuard漏登记统一`composerDraftRestore`,且旧UI直写字符串扫描已无实际命中。迁为两UI只能委托同一helper、Controller.restoreDraft只能在helper写入的非空owner断言;仅增加helper精确路径,不扩大目录。guard与首页/会话恢复行为157/157、窄lint通过,日志`/tmp/lime-composer-restore-owner-guard-{fixed,lint}.log`。产品写集保持冻结,继续resume。 + +- 默认门禁第五次续跑至73/126,workspaceBoundaryGuard仍强制旧input useState。迁为共享Controller hook、snapshot派生、canonical setText/clear合同,并用regex禁止旧独立input store回流;不改变布局/Banner或runtime truth边界。guard与共享hook/显式reset19/19、窄lint通过;日志`/tmp/lime-composer-workspace-owner-guard-{fixed,lint}.log`。 + +- 剩余未通过批次中的Composer source/contract护栏完成只读复扫;风险相关5文件16/16,未发现其他旧textarea/badge或独立生产input/resources正向要求,日志`/tmp/lime-composer-remaining-source-guards.log`。行为夹具的useState不是生产owner回流,不误删。旧codex-app GUI文档与执行计划导航已明确迁为历史基线,current实施入口唯一;未接管global/CLI/TUI并行进度。 + +- 默认门禁第六次续跑已通过前端126/126批(包含resume追加的新回归文件)与contracts;当前继续执行工作树受影响的7个Rust crate和GUI smoke。日志`/tmp/lime-composer-local-resume-final-6.log`尚未退出,不记完整默认门禁成功。 + +- 默认门禁第六次exit101:agent-runtime211/211,App Server1855通过/1失败,`json_lines_loop_streams_turn_failed_after_partial_external_backend_events`等待JSONL输出超时。未改产品终态、超时或断言;正在从仓库Rust测试入口定向复现并区分调度/fixture与runtime缺陷。首次直接cargo遗漏仓库V8 resolver,下载404,日志`/tmp/lime-app-server-partial-failure-targeted.log`不作该测试结果;已改用`test:rust:unit`,GUI smoke尚未执行。 + +- 正规单项Rust入口1/1通过,日志`/tmp/lime-app-server-partial-failure-owned-targeted.log`;整包超时原因仍未知,不以单项替代整包。一次直接test artifact诊断缺少Cargo dylib环境而dyld退出,`/tmp/lime-app-server-partial-failure-built-artifact.log`仅为setup失败。新版聚合首次在首场景初始化前因raw target/debug/app-server不存在而exit1,`/tmp/lime-composer-current-fixture-final.log`与unknown-item失败summary留存,不计产品通过。Desktop冻结复制品仍存在且SHA仍为`a2f19d3740d0c1a934a0415ae4b2f7e9f51e3e395cd9a692c48ea34961bf6e45`,后续以它作为明确APP_SERVER_BIN,不重建或伪造raw路径。默认门禁第七次启用明确Node24与backtrace继续验证;产品/超时/断言未变。 + +- 默认门禁第七次fresh前端125/125、contracts与7个Rust crate共3179/3179通过:agent-runtime211、App Server1856、lime-agent278、lime-mcp174、lime-scheduler36、lime-server119、tool-runtime505。前次partial external JSONL失败项本轮整包通过,原一次等待超时原因仍unknown;未提高timeout或修改assert。GUI smoke开始合法重建sidecar,最终需要重新记录实际产物SHA与exitcode。 +- raw binary只读bounded调查未找到明确删除consumer:fixture仅清lock、assets清dist目的且禁止source/dest相同、V8/sherpa清下载缓存与临时解压、Rust destructive调用所读48文件只清测试资源;本地Rust入口为cargo test --lib且没有cargo clean。无系统调用或进程追踪,Cargo/并行进程归因unknown,不按mtime推断。本轮没有保留该调查的产品或文档改动。 + +- 完整默认门禁第七次exit0,日志`/tmp/lime-composer-local-resume-final-7.log`。fresh前端125批共12220个通过test instances、1671个报告通过文件;与前次resume的126批分母不同,不混用。contracts与Rust3179通过后GUI smoke24/24,证据`.lime/qc/project-gates/standalone-shell-01-20261013234813-36535/shell-01-electron-smoke/summary.json`:真实Electron/preload/IPC/current App Server、reload与3个viewport通过,所有console/page/invoke/preload/renderer/trace/legacy/mock错误计数0。GUI smoke合法重建raw与Desktop复制品后,两者实际SHA均为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;这不是前轮rich的旧SHA,后续场景必须记录自己的产物。新版聚合以明确Desktop `APP_SERVER_BIN`串行执行,日志`/tmp/lime-composer-current-fixture-final-2.log`,目前未退出,不提前记22场景通过。 + +### 下一刀补证:scoped persistent draft + +- 独立只读REA与Lime存储/导航调查完成,无产品实施;[保存审计](../research/codex-desktop-rea/composer-draft-persistence-audit.md)与Evidence索引记录`ev_6ac3324c94242434149d9b820ee6cecdc0165021adadac0bdca91254ab787c29`,37 inert声明、structural complete/semantic unknown、parse失败0。 +- 上游两个key保存PM JSON/prompt/plainTextMode;document change合并发布、删除立即发布、dispose retained、pagehide/reload flush及main persisted atom同步有静态anchors。client/canonical local双写与home/cloud scope明确;真实重启/耐久性/完整资源保存unknown。 +- Lime目前没有keyed Controller草稿或持久store;TaskCenterDraftTab仅导航metadata,transcript cache与promptHistory不是未发草稿owner。两条首发materialization和无draft首页分支都需转绑定;sessionId和canonical threadId须显式区分,不可用restore改变待接受receipt revision。 +- 候选实施必须先登记新Verification Contract、唯一scope/I-O owner与架构图;不借用provider UI state、不预建runtime、不在现行Editor Gate期间夹入产品变更。已有存储封装quota删除重试与无成功结果需先修合同,跨重启附件不得用预览图或内存结果冒充。 + +### 下一刀补证:整条 UserMessage 折叠 + +- [用户消息补证](../research/codex-desktop-rea/user-message-collapse-audit.md)与索引已落仓;两个 parent Evidence 均为选定范围 structural complete / semantic unknown、parse failures 0,不运行目标。普通正文消费完整text字段,默认20行实测高度折叠,不能据此制造逐paste chip。 +- 名称只确认member stem `collapsible-user-message-content`、压缩绑定`v as t`与exports n/t;候选`CollapsibleUserMessageContent`是同职责领域命名,不声称恢复原开发符号。 +- current canonical用户正文在`ConversationTurnTimeline`直接走Markdown,`MessageUserBody`承接message_group另一入口;实现必须覆盖两者。复用MarkdownRenderer、VirtualizedTurnList的现有ResizeObserver与scrollController.holdViewport;不新增高度cache、消息store、协议或runtime。 +- 候选最窄写集为新折叠组件/相邻tests、两正文入口、MessageList/MessageListItem的callback透传与五语言agentMessageList.json;实现前另填Verification Contract。复制/编辑继续使用完整原文,图片/Skill标签留在正文折叠之外。代码块裁剪、隐藏交互焦点、内容/宽度重测与find reveal按可确认合同逐项验收,不能以字符数估计高度替代。 +- 当前只读准备完成,产品尚未实施;需在结构化Composer新版聚合/默认门禁关闭之后开启。参考窗口表现及未穷尽producer/Markdown分支继续unknown。 + +### 正常 UserMessage inline 引用追加补证 + +- 同摘要成员的普通exact AST闭合正常Markdown link token→Skill/plugin/app专用consumer链;新REA只核对primary Hd身份项1/1963,返回形状仍unknown,不能当完整模块语义。审计与索引保存4个成员SHA、12 exports、10 imports、30声明范围与37 anchors。 +- Skill按规范化href/path或无href时名称唯一匹配catalog才绑定身份;保留label/href/copy属性与条件侧栏导航。plugin/app有独立URI/host/metadata consumer。裸$skill、producer→wire→history、复制回贴与真实点击仍未闭合。 +- 旧“正文下层Skill/mention未知”已由这条静态consumer补证局部推进;原typed sidecar不直接进入字符串的观察仍成立。Lime已有Composer atom、typed wire和单个route标签,不足以证明正文内联身份回放已对齐;产品迁移继续独立待办,不改wire或伪装任意label。 + +## 长 UserMessage 折叠 Verification Contract + +- 负责人:root;登记:2026-10-14。预算 `budget:normal`,风险 P1。开启条件已满足:结构化 Composer 的新版 current fixture 聚合与完整默认门禁均 exit0;当前切片已开启。 +- 参考:[整条用户消息审计](../research/codex-desktop-rea/user-message-collapse-audit.md)的两个 REA parent 与同摘要精确 AST。确认 member stem、20 行实测阈值和整条正文折叠;原开发 export、精确裁剪样式与目标应用真实交互仍 unknown。`CollapsibleUserMessageContent` 为本地职责命名,不写成 confirmed upstream export。 +- 产品窄写集:新正文折叠组件及相邻行为测试;`ConversationTurnTimeline` 与 `MessageUserBody` 两条正文入口;`MessageList`/`MessageListItem` 只透传既有 `scrollController.holdViewport` 与 focused Item/reveal token;五语言 `agentMessageList.json`。如 installed Skill 分支正文需拆分,只改 `MessageListUserContent` 中同一正文接点,标签和图片留在折叠外。不修改 Markdown wire、Composer、runtime、store、协议、未知 Rust/approval/command 改动。 +- Current 主链:既有 typed submit 或历史读取 → Electron preload/IPC `app_server_handle_json_lines` → App Server `turn/start`、`turn/interrupt`、`thread/read` → 同一 canonical Thread/Turn/Item → `canonical_turn` 或 `message_group` → 现有 Markdown/纯正文 renderer → 显示折叠。折叠只拥有局部显示与焦点状态;没有新的 gateway、runtime event、tool、approval 或 artifact。 +- Happy path:全量正文布局高度严格超过 `20 * lineHeight + 1px` 才显示初始收起和切换按钮;短正文无按钮。正文/字体/宽度变化重测并尊重用户展开选择。折叠不裁掉源字符串,不改变 typed ranges/媒体/引用身份;复制与现有编辑仍传全文。 +- 焦点与列表:按钮支持真实 Tab、Enter、Space 与准确 `aria-expanded`;被遮挡的互动元素不可聚焦,可见互动元素保留可达。收起时若焦点即将被隐藏则回到切换按钮。使用现有 virtual row ResizeObserver/anchor 算法,切换前 holdViewport;不加第二高度 cache。现有指定 Item 导航与浏览器 find reveal 的支持范围需由实现和证据明确,不能默认宣称全部覆盖。 +- 定向回归:严格阈值上下 1px、软换行、代码块跨边界、正文/宽度重测、展开偏好、内容身份更新、observer 清理、隐藏/可见链接焦点、全文复制/编辑、两个入口与五语言。护栏验证两入口共享单一正文 owner,不保留字符数截断或平行用户正文实现。 +- Electron 窄写集:rich 场景只接独立 collapse helper;launcher/scenario-flow 仅传测试侧 clipboard reader;相邻 oracle/tests;session-history long-list fixture/oracle/runner补非尾部 canonical 长正文、冷启动、焦点与相邻锚点。全新 helper 进入既有 `scripts/agent-runtime/` 或 `scripts/electron/lib/`,不得新建脚本领域或产品 IPC。 +- Gate B:每条可见 bubble 要关联 ancestor `data-render-entry-kind`、exact Turn/Item、唯一 user bubble;canonical 另核 `data-direct-segment-kind=message`。rich 历史68/68未采该入口,且 itemId 为 null,不能据旧 readback推断其 renderer。真实点击/键盘后采 ARIA、实际几何与尾部;两状态用 Electron clipboard精确比对全文,仅落长度/hash/布尔证据。若 rich 实际走纯正文,只证明该入口;链接焦点由 canonical long-list补证。 +- 锚点与业务不变:真实 wheel进入距底大于64px旧历史,切换前后比较 surviving first-visible Turn key与viewport-relative top,后继row offset跟随实际高度且不重叠。完整trace禁止新增 `turn/start`、`turn/steer`、`thread/queue/add/update/start`;再次thread/read核正文/content parts/text elements/client/Item/Turn一致。缺trace、错入口/Item、只改ARIA、截断copy、隐藏链接可聚焦、锚点漂移或相邻重叠均失败。 +- 必跑:受影响 component/projection/oracle tests、renderer typecheck、窄lint/i18n、legacy report;冻结后rich与long-list真实Electron场景、current fixture、GUI smoke与默认本地门禁。未改public boundary时不重复contracts;无live Provider、Windows/packaged或参考应用实时证据。 +- 架构影响:非重大,renderer在两条既有正文路径复用单一局部显示组件,原投影/持久化/测量/滚动owner与依赖方向不变。实施结束复核实际调用图,若引入跨层owner改变则重新登记并更新architecture图。 +- 退出条件:两个入口与所有调用者迁移、有效旧测试/fixture/护栏同步、定向和真实证据通过。Skill/plugin/app专用inline consumer、persistent draft、marks/voice与其他全面账本不在本切片完成分母。当前产品/新Gate完成度0%。 + +### Composer 切片关闭与长正文实施记录 + +- 新版聚合exit0,22/22实际Electron场景成功,所有声明assertions为true;证据快照`.lime/qc/composer-alignment-review/current-fixture-complete-final/aggregate-summary.json`及各场景summary,日志`/tmp/lime-composer-current-fixture-final-2.log`。实际backend SHA仍为`4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`;21个Claw场景明确mock/legacy计数0,Coding场景缺独立mock计数字段,不伪填0。全部console/page errors0、noInvokeErrors true;runtime场景使用本地provider fixture,其余external绕过执行层,liveProviderUsed=false。聚合通过不扩大各子场景合同。 +- 结构化Composer当前实施合同(唯一Controller/EditorView、typed submit、共享surface草稿、接受锁、atom历史与receipt恢复)100%关闭;marks/link/code-block、跨重启scoped草稿、voice及任意正文inline引用consumer仍在待办。全产品不提供无明确分母的百分比。 +- root认领两正文入口、tagged正文、MessageList/Item回调/准确identity接线、五locale及相邻integration/纯identity回归。组件归lime_gui_comparison,rich归composer_fixture_migration,long-list归lime_desktop_capabilities;产品开启前草稿只在/tmp,未夹写冻结产物。 +- 新共享组件从自然full root读取实际lineHeight;内层RO、同高度异步descendant变化MO、隐藏互动inert/原值恢复、焦点preventScroll、递增reveal去重。MO缺口先红复现后修复;组件11/11与窄lint通过。用户展开选择只在当前实例保留,虚拟卸载后重挂与冷重启默认重置;尚未声称跨卸载选择持久化。 +- 两入口的复制/编辑保留现有完整字符串,tag/image在折叠外。用户bubble identity纯转换移入`messageItemIdentity`,精确Item/client匹配并去重;同Turn多User未匹配或重复client时fail closed,不取第一项。根写集78/78(含组件11、identity6、integration11和既有消费者50)与标准typecheck通过,日志`/tmp/lime-user-message-collapse-{related,typecheck-final,root-lint}.log`。首次误用不存在的Vitest配置为setup失败;首次integration两用例被canonical投影吞并,改为纯identity测试与合法local正文reveal,不恢复错误renderer路径。 +- 新脚本治理通过:`/tmp/lime-user-message-collapse-scripts.log`;未新增根脚本或一级领域目录。rich 首命令因误传 `--output-dir` 在参数解析阶段退出,未启动 Electron,不算产品失败;改为 `--evidence-dir` 后真实 Electron 已启动,但共享 trace oracle 错误要求所有 current request 显式携带 `jsonrpc: "2.0"`,合法 sender 的缺省版本被拒绝。日志 `/tmp/lime-user-message-collapse-rich-gate-2.log`;失败证据 `.lime/qc/user-message-collapse/rich-final/user-message-collapse-rich-final-summary.json`。仅修复 oracle 对合法协议形状的识别,保留错误显式版本、截断/饱和、解析遗漏和输入 mutation 的负向门禁;真实 rich 与 long-list 尚未通过。 +- 缺省版本修复已冻结,current envelope 与既有 oracle/parser 合计243/243通过;实际 schema fixture 明确 `sendsJsonRpcVersionField=false`。历史 rich trace 达240条,不能凭过滤后的 request 数绕过完整性要求;增加仅测试侧的连续 append 观察,保留原 native Storage 调用语义,并以同一 observer run identity 校验 baseline/final 完整前缀;原 ring 模式饱和仍失败。长历史首跑的原滚动/分页 oracle通过,但新 probe224不在实际已加载227–240区间,故退出1;改为已加载非尾部 probe并保留前后邻居、真实 wheel 与冷启动合同,不能延长 timeout 或模拟滚动/历史请求。日志 `/tmp/lime-user-message-collapse-long-list-gate-1.log`;失败目录 `.lime/qc/user-message-collapse/long-list-final/`。 + +- 连续 collector/rich 写集于 `2026-10-14T00:44:42Z` 冻结,6套286/286通过;manifest `/tmp/lime-user-message-collapse-rich/continuous-collector-write-set.json`。collector仅测试侧连续观察 native Storage,独立重算每次 append 的前后 ring/hash,seed不算accepted input,hook丢失/非法写入/reset/restore失败拒绝通过;raw ledger只驻内存,证据仅留模式/run identity/count/hash及实际 `nativeRestored`。 +- long-list 写集已冻结,5套本体186/186与共享collector69/69(初次teardown错误修复后复跑)通过;9文件lint/格式通过。probe统一232,实际检查末页声明和mounted canonical tail,再以真实wheel验证231/232/233;正文UTF16 9682、UTF8 17673,SHA-256 `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`。runner最小消费pure白名单失败extractor,不落正文。产品15文件摘要存 `.lime/qc/user-message-collapse/product-write-set.json`,raw与Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新的真实Gate正在重跑,不能将以上unit/integration计作Electron通过。 + +- 新rich真实运行仍exit1:连续collector32733次append、`complete=true/nativeRestored=true`,但初始collapsed几何未达合同;console/page errors为0。日志 `/tmp/lime-user-message-collapse-rich-gate-3.log`;证据 `.lime/qc/user-message-collapse/rich-continuous-final-1/`。新增safe失败extractor21项与既有测试合计277/277通过,只补取证,不放宽identity/几何/timeout。 +- 新long-list真实运行exit1:进入已加载probe时工作区触发React #185更新循环,frame消失,故报wheel scope不匹配。safe诊断记录进入前准确session、14-entry末页、canonical tail240与连续trace无mutation/错误、native恢复;不能将scope消失误记夹具通过。证据 `.lime/qc/user-message-collapse/long-list-continuous-final-1/`;开发构建 `.lime/qc/user-message-collapse/long-list-debug-1/` 复现Maximum update depth exceeded。当前定位虚拟行重挂时full→collapsed高度与同步range/anchor的反馈,需完整堆栈与先红回归确认,尚未修产品。default/current-fixture/GUI smoke本切片均未开启。 + +- 开发构建第二次复现的完整stack `.lime/qc/user-message-collapse/long-list-debug-2/` 指向 `CollapsibleUserMessageContent.measure` 的 `setMeasurement` 与同步layout effect。组件owner认领原两文件,候选最小修为dispatch前同值ref guard、同步测量不依赖每次父render的新children对象;真实DOM变更继续由RO/MO重测,inert/reveal/20行合同保持。定向先红回归需区分重复测量触发路径与真正#185复现,真实dev及fresh packaged Gate负责关闭bug;不提前写已修复。 + +### 长正文动态测量反馈修复(2026-10-14,进行中) + +- 组件最小修已冻结:dispatch 前使用 measurement ref 拒绝同值,同步 effect 不再依赖父层新建的 children JSX;真实子树变化仍由 MO/RO 测量。组件与现有列表/接线 31/31,通过不代表真实更新循环已修,dev Electron 仍失败。 +- 新父层回归 `VirtualizedTurnList.measurement.component.test.tsx` 已直接先红复现 Maximum update depth exceeded:阅读锚点 turn-10 顶部 -40px,上方 overscan 中 turn-8 实际行高从 1200px 降至 224px,经父 RO 测量后 mounts=18/unmounts=18。真实 React、现有折叠组件和虚拟列表,仅为 jsdom 注入几何/RO;断言 DOM identity、锚点与挂载次数,不复制保锚算法。root 复跑失败日志 `/tmp/lime-user-message-collapse-parent-red-root.log`。 +- 测试侧被动 native-full-rect-call 采样已冻结,三文件窄写集:long-list input helper、geometry 白名单、新相邻 input test;4套78/78和 lint/格式/语法通过。最多300条、WeakMap mountId、错误时同步冻结、退出恢复 native;计数仅针对有界窗口,completeHistoryClaim=false,不宣称所有调用均由组件 measure 发起。 +- root 下一生产窄写集为既有 `VirtualizedTurnList.tsx` 的 render range 接点及上述相邻回归;不改共享协议、runtime、scroll owner 或增加高度 store。候选为 layout 变化时先复用 preserveThreadAnchor 求与新 layout 一致的 range top,既有 layout effect 继续提交实际滚动;等待 dev 被动采样再实施。所有 frontend/test 写入在真实运行期间冻结,避免 Vite reload 污染证据。 +- 本轮未关闭项仍为 fresh rich、long-list/cold restart、current fixture、GUI smoke 与 verify:local。旧 15 文件源码摘要已因组件修复失效,收口时重新生成。 +- 原生 dev 采样失败证据 `.lime/qc/user-message-collapse/long-list-native-debug-1/`:176次调用、172个mount、10个identity重挂,dimensionChangedMountCount=0/oscillatingMountCount=0;probe232反复新mount且自然正文8301.109375px保持不变。采样窗口不证明完整运行历史,摘要samples另受安全白名单有界输出限制;native已恢复,错误明确为Maximum update depth exceeded。 +- root最小修已落在原range接点,仅对pending layout使用既有preserveThreadAnchor或新tail top选窗口,不在render更新旧基准或滚动。先红1项修后通过,相关列表/几何/组件24/24、两个consumer/identity17/17及四文件lint通过。dev真实复跑进行中,仍未声明产品bug关闭。 +- 修后dev `.lime/qc/user-message-collapse/long-list-native-fixed-1/` 到达collapsed-copy:console/page errors均0;29次native调用、12个mount、remountedIdentityCount=0。因测试用无用户手势navigator.clipboard.writeText设置sentinel被权限拒绝而exit1,不能算完整Gate通过,也尚未证明产品copy成功。标准typecheck exit0;下一步把测试sentinel与finally恢复接到Electron main clipboard,真实按钮与全文readback仍为产品oracle,不修改权限或增加renderer fallback。 +- 新pending导航竞态回归已冻结:同一probe收缩期间目标DOM/位置稳定;真实owner的RAF提交后target top=10px且请求完成,后续用户滚75px旧请求不回拉,第二次导航仍对齐。5文件36/36、相邻lint/格式通过,无sleep或timeout放宽;测试与先前结果重叠,不相加为唯一总数。fresh packaged renderer已开始构建。 +- 同步更正GUI E2E规则中强制显式jsonrpc版本的过期断言,按生成schema与current sender接受缺省或显式2.0,仍拒绝错误版本;不改变协议、客户端或production authority。 +- fresh renderer构建exit0(33.98s),复用已新鲜的Host/sidecar;源码17文件与三个宿主产物摘要 `.lime/qc/user-message-collapse/product-write-set-after-anchor-fix.json`,renderer全文件摘要 `renderer-artifact-after-anchor-fix.json`。snapshot复核无漂移,raw/Desktop sidecar仍同SHA。legacy report零候选/分类漂移/违规、脚本治理、父接点格式、文档护栏12/12均通过。 +- long-list main clipboard接线三文件已冻结,14/14定向与lint/格式/语法通过;writer只设置sentinel和恢复,真实产品按钮产生正文,shared main readback继续核完整hash/length。该定向是controlled callback接线证据,不计实际OS/Electron复制成功。 +- rich/shared七文件于2026-10-14T02:20:41.624357Z冻结,6套319/319与lint/格式/语法通过,manifest `/tmp/lime-user-message-collapse-rich/host-clipboard-write-set.json`;缺回调、读失败/nontext、sentinel未交付、截断、按钮失败、恢复抛错/读失败/不匹配和双重异常都拒绝成功。旧helper page参数与renderer写剪贴板已删除;finally写回后另从main验证恢复。root核对manifest无漂移,fresh packaged long-list已启动。 +- fresh packaged long-list第二轮exit1,`.lime/qc/user-message-collapse/long-list-packaged-2/`:准确collapsed几何与邻居挂载通过,console/page errors0,随后真实产品按钮copy未满足全文hash/length。main sentinel准备已成功,不能把该失败再归为fixture写入权限;具体是clipboard rejection、错误正文还是交互反馈尚待安全观察确认。末尾scope仍正确,用户wheel引发的有界重挂不等于之前同步#185。新fresh rich正串行执行;所有product/scripts写集在运行期间冻结。 +- fresh rich第二轮exit1,`.lime/qc/user-message-collapse/rich-packaged-2/`:同一canonical Item/client同时出现canonical_turn正文(collapsed/root1)与message_group空壳(root0/26×14px);严格唯一identity拒绝通过,不能放宽matcher只取正文。源码确认canonical已经消费同一user image;纯projection复现为inputCapabilityRoute单独保留导致残留空bubble,并非图片未消费。capabilities认领projection与相邻ownership/userCollapse三文件,只迁移canonical已拥有user的route残留,不动其他richsurface或无canonical本地消息;media_reference重复候选单独登记。 +- Clipboard原生观察收敛为测试侧sharedhelper及相邻observation、long-list安全失败字段,不新增Host callback/权限/reader API。原promise/this/args/同步异常保持,侧链记录安全错误enum且清理恢复;观察引入rejection handler的微任务边界要注明。实际write错误确认前不改产品权限。 +- route残留三文件已冻结:projection只在exact canonical user-owned residual清inputCapabilityRoute,其他富surface与无canonical local消息保持;先红3fail,修后ownership/projection/userCollapse/directTimeline四文件53/53、三文件lint/格式/diff通过。新集成证明同client/Item只有1bubble/1正文/1图片/1copy,两个typed标签与完整copy/图片preview身份不丢。未改directTimeline既有脏补丁。此前17文件源码快照已因projection与userCollapse变化失效,后续重新取新的摘要和renderer构建。 +- 同边界独立待办:collectCanonicalMediaKeys未覆盖user image parts,可能产生同引用residual media_reference;尚未修改,不由route修复计作关闭。Clipboard补证已记录于user-message-collapse-audit:main存在readText/writeText宿主服务,但普通UserMessage完整caller链unknown,不把sandbox的权限策略移植到主窗口。 +- route修复后的renderer构建与标准typecheck均exit0,日志分别为`/tmp/lime-user-message-collapse-renderer-after-route-build.log`和`/tmp/lime-user-message-collapse-typecheck-after-route.log`。测试侧原生clipboard observer正在最终冻结:保持native this/全部参数/同步throw/原Promise,安全proof区分实际仍为sentinel、全文不匹配及权限/焦点错误;观察侧链会增加rejection handler和微任务,不冒称完全无影响。待真实错误证明后再决定产品权限或fixture焦点的最小修复,当前未修改主窗口permission owner。 +- clipboard观察11文件已最终冻结,8套258/258、lint/格式/11项Node语法与窄diff通过;manifest `/tmp/lime-user-message-collapse-rich/passive-clipboard-write-set.json`,SHA-256 `2082c12413842eacd391acaad8654454f3b243e0a549d0e65d1abeaee61761ed`,root复核全部11项无漂移。rich聚合oracle失败时未带成功返回phase proof是已登记的诊断限制,不影响long-list本次取证。fresh源码19文件与renderer3190文件快照分别为`.lime/qc/user-message-collapse/product-write-set-after-route-fix.json`、`renderer-artifact-after-route-fix.json`;旧快照保留,不覆盖。真实long-list第三轮已启动,目录`long-list-packaged-3`,未预判结果。 +- long-list第三轮exit1,真实native proof为唯一writeText调用、全文9682字符且expectedTextEqual=true、原receiver、focused/visible/userActivationActive全部成立,但NotAllowedError rejected,实际main read仍为sentinel。observer intervalComplete/nativeRestored=true,console/page errors0。errorKind未知,不擅自重写成已捕获permission-denied;结合生产permission policy明确拒绝clipboard-sanitized-write,进入最小权限修复并由fresh成功验证因果。 +- 权限修复窄写集:comparison worker仅迁移`electron/mainWindowMediaPermissions.ts`及相邻测试为`mainWindowPermissions.ts`/`.test.ts`,唯一策略同时承接request/check;root仅迁移`main.ts` import与安装两处,保留原browserTabOwner脏补丁。主window主frame允许sanitized-write,读/其他window/null/child/unknown拒绝,媒体保持原能力并区分request数组/check单值,不改embedded browser独立session,不加clipboard IPC/fallback。非重大架构变更,仍由既有Desktop Host permission owner管理;退出条件为先红回归、定向/Host typecheck、fresh Host、真实全文copy及对应门禁,无Windows实跑声明。 +- 权限模块迁移已冻结:旧策略27项中7项先红,迁移后permission27 + windowOptions7 + loadErrors3合计37/37;两文件lint/格式、完整Electron typecheck均通过。模块SHA `b98655e57bd66ee3b5388f327096feb0ac50a91e2b01b066c310a755f158e6d0`,测试SHA `a5eca6f91b68de7163346f5fae8ce21952f6eb6dd50c35f8fffd866621150643`;旧文件/符号在Electron中无残留,无alias。root增加测试侧native query helper和相邻oracle测试,session-history入口只接import/call;query主/child secure context与读写状态,不读剪贴板内容、不替换handler,finally移除iframe。17/17和root窄lint/格式通过,真实query与copy待fresh Gate;另window只由策略/handler回归覆盖,不把query结果扩大为实测。 +- tests/guard只读审计未发现产品旧文件名锁定,当前DOM字段服务真实几何和identity。generic collapse oracle合法支持两正文入口,继续保留其message_group正向;rich accepted canonical wrapper需额外明确canonical_turn,避免canonical退回local仍过验收。fixture worker认领shared helper/evidence与相邻rich行为回归,同时修复聚合失败遗漏安全clipboard phase proof;不改产品或long-list已冻结写集,不以源码字符串guard代替实际helper行为。 +- 同user裸图片media_reference的只读probe已定位独立缺口:相同exact Item/client与直接image URI应由canonical接管,当前仍留下空residual;caption、audio kind、不同URI、refId读取owner、assistant同图说明均需保留。该候选尚未产品实施,不无条件扩全Turn URI集合,不将sidecar缩略图成功当作点击预览职责等价。 +- 同Item裸图片去重补刀已认领原projection/ownership/UserCollapse三文件,写窗口与Electron permission互不相交。合同只覆盖exact canonical user owner的同一直接图片裸引用;caption/title、refId/sidecarRef/sha256/byteSize、不同previewUrl、MIME/定位冲突、unknown kind以及assistant同图语义保留。先红空残留与保护变体后最小修,不增加第二投影owner、不扩全Turn媒体集合、不碰directTimeline旧脏补丁;定向集成需继续锁一bubble/图片/copy与原preview identity。其他typed媒体职责不在本补刀完成分母。 +- 同Item裸图片三文件已冻结:先红3fail后projection/ownership/UserCollapse/directTimeline四文件77/77;自有三文件lint/格式/diff通过。projection SHA `740b58aa3a880dd86f00a695a6560057ddd165f1dc43f089345c50e58ea28c1f`,ownership `15014d8ee3f41dcca92741480195423d0ee22f83af333638a54e29c7d730fe65`,UserCollapse `dfd53221e27e78982b31c3d7564eba7c5d83dd7705dfc0a80c95604f43fb7f48`。匹配仅直接URI,额外已定义字段/未知字段与sidecar保留,不扩大媒体职责。 +- rich测试侧owner合同与安全phase补证已冻结:7项先红6失败、generic message_group1通过,最终5套184/184与lint/格式/3项语法/diff通过。manifest `/tmp/lime-user-message-collapse-rich/rich-canonical-phase-proof-write-set.json`,root核对3文件无漂移。generic两入口正向继续有效;rich wrapper要求canonical_turn,aggregate与native复制失败只保留安全clipboardPhases。前一passive manifest的shared helper摘要已被此新manifest取代,不覆盖历史快照。 +- fresh Host/renderer均exit0,main新SHA `9401e36c299c48f1032061406a561b1a27d7e60240953ee5ddb646d0df108003`;raw/Desktop App Server仍同SHA `4d213628b3dc459ec1bfafe4ec08ec1b2c2db933bb2e55dd8836194124e4603a`。新immutable源码22文件与renderer3287文件快照为`product-write-set-after-permission-fix.json`/`renderer-artifact-after-permission-fix.json`,query窄写集另存`permission-query-write-set.json`。root当前rules/docs guards23/23、脚本治理均通过。 +- fresh long-list第四轮exit1,目录`.lime/qc/user-message-collapse/long-list-packaged-4/`。主window query write=granted/read=denied,child write/read=denied且均secure、iframe已移除;query只证明check。collapsed/expanded两个严格全文copy阶段均通过,最后原生write fulfilled、全文9682/hash一致、nativeRestored=true;权限修复已有真实因果补证。随后collapse-anchor失败:收起前first-visible successor233 relativeTop=-8.03125px/scrollTop9714.5,收起后first227/scrollTop0;console/page errors0。冷启动尚未执行,不算Gate通过。 +- 新锚点修复写集:comparison worker仅在`VirtualizedTurnList.measurement.component.test.tsx`模拟浏览器随DOM总高收缩clamp scrollTop,保留真实React/现有virtualizer与折叠组件;0/非零prefix两变体先红,不复制保锚算法。root生产只认领原`VirtualizedTurnList.tsx` layout effect,候选直接提交`currentOffset + nextTop`以免在已clamp位置重复扣高度delta,并移出无用offset ref。现阶段为候选,不先写已修复;真实successor/cold Gate负责关闭。 + +- clamp回归先红完成:14条、prefix0/160两变体均被合法layout clamp780px,anchor DOM保持但top从-40变740;原30条overscan与pending导航均绿。root在原effect直接提交currentOffset+nextTop,删除无用previousOffsetRef;修后5文件40/40、生产/测试窄lint和格式通过。新增测试初始化已有一次probe卸载,绝对unmount=0误断言改为折叠前后无增量,DOM/top/clamp/mount其余oracle保持。测试SHA `4716403682585300138c0d1922968c193a679fb4055cd1c41d714661762e3a68`。单位回归已关闭clamp缺陷,fresh真实Gate仍待验证;renderer重新构建与标准typecheck正在执行。 + +- fresh renderer与标准typecheck均exit0,源码/宿主与renderer快照分别保存为`product-write-set-after-clamp-fix.json`和`renderer-artifact-after-clamp-fix.json`,未覆盖旧证据。真实long-list第五轮exit0,`.lime/qc/user-message-collapse/long-list-packaged-5/user-message-collapse-long-list-packaged-5-summary.json`:interactive/cold restart均ok;collapsed/expanded/cold copy完整9682字符,SHA `2d26958c2e7f9e8fad508f835b9e4f145b2580ff4e5f525339afaec0590002a1`,native fulfilled/observer恢复、可信鼠标键盘均通过。后继233相对top从-8.03125到-8.234375px,漂移-0.203125px,无重叠;旧PID退出且新进程启动,之前expanded的实例冷恢复默认collapsed。canonical正文/content parts/text elements/User Items/Turns不变,连续trace完整、禁止mutation0,console/page errors0。只证明surviving successor锚点,不声明消失正文内部offset保持;实际macOS Electron+编译fixture,不等于Forge安装包或Windows/live Provider。 +- fresh rich第三轮exit1,`.lime/qc/user-message-collapse/rich-packaged-3/user-message-collapse-rich-packaged-3-summary.json`:同Item/client唯一canonical_turn气泡/正文成立,旧空泡已消失。copy-collapsed真实click遭同一user bubble/.space-y-2截获,sentinelObserved=true/buttonClicked=false/native calls0,observer完整并恢复,console/page errors0;这是可点击性失败,不能归为复制权限失败。现有安全快照缺footer/button实际rect及hit-test,fixture worker认领独立安全诊断及相邻测试,不存正文/URL/raw error,不force/dispatch/放宽timeout。root读取styles发现footer由MessageWrapper:hover或focus-within显示;generic wheel仅准备viewport,而long-list references探测经过Tab、rich无此准备。测试漏真实hover为候选,需前后几何和可信hover证据确认,未改产品样式。当前切片保持partial,旧Composer聚合/本地成功不得关闭本轮门禁。 +- 本轮contracts入口exit0,日志`/tmp/lime-user-message-collapse-contracts-final.log`,包含protocol/client/commands、脚本/发布/desktop reference/CLI边界与docs检查;更新后的current-rules/current-docs两套23/23,日志`/tmp/lime-user-message-collapse-docs-guards-final.log`。capabilities只读覆盖核验未发现产品/测试/脚本恢复旧权限名或锁死旧DOM的guard;当前主frame写与读/子frame/other/null/unknown拒绝、媒体request数组/check单值、唯一canonical rich正文、route/直接图片保护、bounded虚拟挂载/锚点消失/clamp/pending导航均保留行为回归。没有为了绿结果删除有效风险断言,未重跑已经通过且未变的定向集。 +- copy准备窄修已冻结:独立安全geometry与prepare helper,shared gate/evidence/rich行为回归合计5文件,manifest `/tmp/lime-user-message-collapse-rich/rich-copy-action-write-set.json`,root核对5/5摘要一致。geometry14/14、接线先红7/8、修后6套199/199;lint/格式/5项语法/窄diff通过。准确target真实hover→既有wheel→copy真实hover→原click,不force/dispatch/改timeout,产品样式、权限、runtime与long-list均未修改。`copyActionPhases`保留beforeHover/afterBubbleHover/ready及失败几何,只存有限CSS枚举/rect/结构关系,hover仅声明Playwright API完成,不伪称捕获可信hover事件。fresh rich第四轮已启动;contracts的protocol生成检查触发生成文件mtime更新,fixture按既有freshness要求重新构建renderer/Host,不绕过新鲜度检查,后续另存新产物摘要。 + +- fresh rich第四轮exit0:`.lime/qc/user-message-collapse/rich-packaged-4/user-message-collapse-rich-packaged-4-summary.json`,全部69项scenario assertions为true,UserMessage Gate全部19项布尔合同成立,五次真实collapsed/expanded/keyboard切换、两状态1705字符完整copy、同一canonical user/parts/Turns与continuous trace完整/无额外input均通过,console/page errors0。before-hover footerheight0/maxHeight0/pointer-events:none/opacity0、button中心命中bubble;ready height24/opacity1/pointer-events:auto并命中copy后代,关闭测试漏真实hover原因。afterBubbleHover采样落在transition起点仍height0/opacity0,不把它当settled;最终ready与真实click/readback负责验收。hover只声明API动作完成,没有event isTrusted采样。新renderer3384项及22项产品写集与clamp快照逐项均无digest漂移,main/sidecar也保持原SHA,因此long-list第五轮仍匹配同产品产物,无需重复。另存`product-write-set-after-rich-hover-gate.json`与`renderer-artifact-after-rich-hover-gate.json`。本轮current fixture已开始;聚合不含session-history独立Gate,long-list/cold证据分别报告。 +- 额外只读核验确认permission旧owner缺回流守卫:现有legacy-report仅扫描src,imports entry对“已恢复但零引用”的文件只列候选,不让门禁失败;current-entrypoints虽扫描electron却未禁旧权限owner。当前无残留结论仍成立,但不等于forbidden-to-restore已落实。聚合结束后认领最小catalog dead entry与Electron permission owner AST guard/相邻behavior tests,由既有current-entrypoints接入;禁止空旧源/旧测试文件、改名旧符号、第二注册owner与重复installer,保留独立embedded session current路径。复用同一检测函数对真实目录/临时fixture验收,不扩大整个legacy扫描根,不碰产品权限决策;在default门禁前完成。 +- 冻结窄例外:packaged聚合的产品产物、fixture加载链与构建输入继续冻结;capabilities可提前在未进入该加载链/构建输入的`permission-owner-guard.mjs`、相邻behavior test与`current-entrypoints.test.mjs`实施独立治理回归。root持有catalog entry,等聚合结束后写入并运行真实目录接线测试;不以测试侧临时mock catalog让current通过。该例外不改变正在运行的renderer/Host/sidecar或scene脚本。 +- 本切片聚合exit0,22个实际Electron场景全部ok且无false assertion、console/page errors;首末summary时间为2026-10-14T04:43:56.536Z / 05:01:32.373Z。日志`/tmp/lime-user-message-collapse-current-fixture-final.log`,root逐项读回并在`.lime/qc/user-message-collapse/current-fixture-final/aggregate-summary.json`冻结22份summary与摘要,不覆盖上一Composer聚合。该入口仍是controlled fixture而非live Provider;它不含独立长列表/冷启动Gate,不能互相替代。root已登记旧权限两文件dead entry;待AST guard真实接线冻结后运行`verify:local`,其内部`verify:gui-smoke`成功即计本轮GUI门禁,不为相同写集另重复一次。 + +### 下一批只读准备:marks 与 dictation + +- [marks/voice审计](../research/codex-desktop-rea/marks-voice-audit.md)已完成,复用三项Composer REA Evidence与两个同摘要原成员,无重复分析;13个UTF16锚点、11个本地链接及格式验证通过。root仅新增精确研究文档白名单,未改产品。 +- 基础schema确认link/code_block,rich PMU schema分支和provider默认选项另行记录,完整marks清单仍unknown。最小后续合同在唯一input-kit owner收敛schema/types、display/draft/submission序列化、UTF8 ranges、PM位置与实际View编辑;不能只加样式或推测bold/italic工具栏。 +- current录音/分段ASR/润色、Controller revision保护及现有API/权限/五语言/dead回流守卫保留。permission等待的starting、cancel、旧reject与不同Controller同revision迟到结果是待先红问题;未复现前不写已确认故障,不恢复已删除的global shortcut/device UI或另建音频后端。真实系统麦克风/权限/识别质量仍独立not-run。 +- 本范围研究映射100%;产品与对应Desktop Gate均0%,不计入长正文完成分母。长正文门禁关闭前不开新产品写窗口。 + +### 下一切片只读准备:scoped persistent draft + +状态:设计候选 / 未开启产品写入。长 UserMessage 的真实 Gate 和门禁收口后,再填写实施合同并认领写集。复用已有 REA 保存补证,不重跑相同分析。 + +- 唯一活动文档继续归 `ComposerController`;scope owner 管理保存记录与绑定,不创建平行可编辑 Controller。实际导航保存旧 scope 后恢复目标;blocked/missing/deferred/error 的 before callback 只能 flush,不提前绑定。 +- 首发覆盖 materialized、direct、有/无 draft tab 的 home→session;promotion 仅迁移稳定 entry 身份,不 restore/clear、不推进 pending receipt revision。sessionId 与 canonical threadId 分别使用真实映射,不能按字符串假定等价。 +- 两处 accepted 消费(Inputbar 与 EmptyState)需要同时结算源 scope/version 与当前 Controller revision。离开 source 后旧 commit 返回 false 只保护新草稿,不会自动消费已发送的旧记录;若忽略此接点,冷恢复会让已提交正文复活。新增编辑、reject/failure 与迟到 interrupted restore 均需专门回归。 +- 导航用 reset+setInput 路径直接迁出;显式编辑全文路径保留。draft tab 冷启动需由同一草稿索引投影 metadata,关闭 tab 与显式丢弃必须分别定义;不在3100行的 `useWorkspaceSendActions` 中堆逻辑。 +- 持久层实施前明确与 Codex main persisted atom 的结构映射。现有 `agentChatStorage.savePersisted` 序列化在 try 外、quota 失败先删旧值且不返回保存结果,不能充当已保存证据;持久 blob URL、跨窗口协调、附件容量与失败保旧需闭合合同。若扩展 Host store,同步既有IPC/preload/gateway/catalog/schema/测试及架构,不借用 provider UI state。 +- 验证必须包含 A↔B/home 富文档、Unicode/paste/mention/resource、真实退出重启、首发promotion、跨scope接受后消费、清除不复活与失败保旧;现有rich/history Gate不证明persistent draft。Codex附件耐久性、账户隔离与强杀窗口继续unknown,不制造上游保证。 +- storage只读补证:savePersisted唯一owner可把序列化纳入try、拒绝undefined序列化结果、单次setItem并返回boolean,失败保持旧值;删除quota remove+retry,不能连带删仍被saveTransient使用的quota判断。AST35处调用均不消费返回值;新draft owner必须消费结果。现有测试缺savePersisted合同,可补circular/BigInt/toJSON/undefined、quota/security/general失败保旧和后续成功;transcript大状态丢弃、可重建resource-manager quota清理仍是有效测试。snapshot索引和迁移marker忽略保存失败是另域既有缺口,本草稿切片不顺手扩写。仅只读结论,未实施。 + +### 权限唯一 owner 护栏 Verification Contract + +- 负责人:lime_desktop_capabilities 实施 AST 检测与恢复反例,root 持有 catalog、计划、报告与统一门禁;2026-10-14。风险 P1,`budget:normal`。 +- 写集:`scripts/electron/lib/permission-owner-guard.mjs`、相邻 `permission-owner-guard.behavior.test.mjs`、`current-entrypoints.test.mjs` 窄接线及 `legacySurfaceCatalog.json` 单项登记。不是产品权限策略修改,不重构 runtime 或扩大 legacy-report 扫描根。 +- 合同:同一 TypeScript AST 检测器消费真实目录和临时恢复 fixture;旧源/旧测试即使为空也失败,改名引用旧符号或旧模块失败。主窗口 request/check 注册归 `mainWindowPermissions.ts`,嵌入浏览器独立 session 归 `embeddedBrowserPermissions.ts`;第二注册 owner、重复/缺失主窗口安装失败,合法 import alias 与 namespace import 保持有效。 +- 验证:先红恢复反例,再真实 current 目录与反例绿;定向 lint/format/语法,catalog/治理和默认门禁。AST 守卫只证明覆盖的静态构造,既有权限行为回归及真实原生复制 Gate 分别证明决策与运行链,不能互相替代。 +- 研究文档校准:差距矩阵已迁到 current 状态,删除“仍缺虚拟列表/审批入口/Files 导航”的旧判断。rules/docs 回归23/23;legacy-report候选、分类漂移和边界违规均0。产品22项与renderer3384项快照复核无digest漂移,main及raw/Desktop sidecar均仍匹配前述真实Gate摘要。 +- link/code block 补证已冻结:`marks-voice-audit.md` SHA `a7da8a2cee7a246ff3af3447e5e292cad983f6a4e2740f50b0425ce73b73ca35`;3个原成员摘要、41个UTF16锚点、bounded AST与11个本地链接通过。普通mark协议、基础fence/语言/转义已确认,rich完整选项、PMU与真实roundtrip仍unknown;未扩产品写集。 + +### 默认门禁发现:贴底转手动阅读的视口竞争 + +- 权限 guard 已冻结,behavior22 + entrypoints17 = 39/39,lint/format/语法/diff通过。root逐项确认3文件SHA并保存 `permission-owner-guard-write-set.json`;worker先红为17 failed/5 passed,证据来自工具输出。新默认门禁实际执行了22/22及17/17,并非只引用旧结果。 +- 首次本轮 `verify:local` exit1,日志 `/tmp/lime-user-message-collapse-local-final.log`;五语言、lint、硬编码扫描与标准typecheck通过,前端103/126批通过,104批的MessageList两项键盘离底/取消已排队贴底帧回归失败。旧数值护栏未删除或放宽。 +- 实际问题:scroll controller先停跟随,原生scroll事件尚未更新virtualizer的viewport;布局变化直接写入了上次程序贴底的位置。root窄认领 `VirtualizedTurnList.tsx` 与相邻test,沿同一viewport owner在keydown/wheel/touchmove的capture阶段同步实际视口;不改变键盘业务策略、跟随判定、clamp绝对锚点提交或runtime。 +- 新独立反例3/3先红:三输入均从29200跳回291680,正确prepend位置应29492。修后五文件63/63,包含原两项失败、三输入竞争、DOM shrink clamp、prefix offset、后继锚点、bounded挂载、copy与折叠保护;两文件lint通过。日志 `/tmp/lime-user-message-collapse-user-input-scroll-{repro,final,lint}.log`。 +- 该生产补丁重新开启冻结。22场景聚合与rich4/long-list5仍是前一摘要基线,不能充当新renderer证明;续跑默认门禁后,针对本次GUI视口变化补fresh rich、long-list/cold、live-tail及resize/reflow真实场景。Host、sidecar、wire与fixture owner未改;不因这项局部GUI修复无差别重跑已通过的全部runtime场景。 + +### 视口竞争修复后的门禁与补验 + +- `verify:local` 续跑exit0,日志 `/tmp/lime-user-message-collapse-local-resume-final.log`:前端126/126批、1689个唯一测试文件;首轮103批与续跑104–126批合计12693 passed instances,失败104批的重复结果不计入。contracts、7个Rust crate3179/3179及真实Electron GUI smoke均通过;新增VirtualizedTurnList测试在106批实际通过。 +- GUI smoke:`.lime/qc/project-gates/standalone-shell-01-20261014055122-75182/shell-01-electron-smoke/summary.json` 为pass,覆盖真实Electron启动/reload/preload/IPC/App Server初始化、设置及三窗尺寸,不单独证明模型Turn或Codex像素一致。 +- 新冻结:产品22项只漂移`VirtualizedTurnList.tsx`这一预期补丁;renderer3481项重新快照。`product-write-set-after-user-input-scroll-fix.json` / `renderer-artifact-after-user-input-scroll-fix.json`保存于`.lime/qc/user-message-collapse/`。main与raw/Desktop sidecar摘要均保持原值。 +- fresh long-list第六轮exit0:9682字符三状态native复制完整、真实PID退出/新进程及默认collapsed恢复、后继锚点漂移-0.203125px、同canonical正文/parts/text elements/Items/Turns不变、完整trace和额外input为0;console/page errors均0。summary在`long-list-packaged-6/`。 +- fresh rich第五轮exit0:69/69 scenario assertions、UserMessage Gate19项布尔合同均true;唯一canonical_turn、1705字符两状态复制、可信键鼠与引用/原稿/接受/历史合同保持;console/page errors均0。summary在`rich-packaged-5/`。fresh live-tail65/65、exit0,GUI首次输出、canonical完成与终态repair通过,无console/page error;是external fixture,非live Provider。 +- resize/reflow首次补验exit1:wide窗口正文、表格、终态及Files面板已就绪,但activeThreadHeaderStable=false。日志 `/tmp/lime-user-message-collapse-resize-final.log` 与`resize-final/`失败summary/截图保留。error在统一sanitizer中截至2000字,关键header谓词未完整留下;不可据此判定已通过或直接修改布局。 +- fixture worker窄认领resize-reflow入口的import/call、独立安全header诊断helper与行为test;只把安全谓词/必要几何放入error前部,保留timeout、等待、header/compact-mode/no-overlap断言,禁止正文、URL/raw HTML。原入口近900行,新增逻辑进入独立helper。新产品写窗口保持关闭,取得具体失败项后再确定最近owner修复。 + +- 安全诊断3文件已冻结:入口仅import/timeout throw接线,独立helper将12个headerChecks及失败项置前,18/18反例与安全截断测试、既有resize接线1/1、lint/format/语法/scripts治理通过。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-diagnostics-write-set.json`;真实diagnostic-2正在执行,等待/超时/断言未变。 + +### Resize/reflow 页头修复 Verification Contract + +- diagnostic-2真实Electron exit1:唯一失败谓词childrenContained。wide页头y8..54、高46;toolbar/actions y-1..63、高64,确认为换行越界。context旧firstElementChild实际选导航,不能继续冒充标题组。 +- root窄写集:TaskCenterUtilityToolbar.tsx仅workbench-header布局分支,ThreadWorkspaceHeader.tsx标题组稳定context testid;task-strip继续按既有行布局。固定页头内工具栏单行,超宽仍归既有header actions横向滚动owner,不增加另一工具栏/业务owner。 +- fixture worker窄写集:resize-reflow入口及独立geometry helper/test;显式current context定位、可见几何消费overflow祖先裁剪。保留header/context/actions可见、contained、no-overlap与compact-mode-bar位置语义;不改timeout、等待、终态或表格/消息/input断言。 +- 验证:旧64/46高度反例继续拒绝;合法横向滚动与真实纵向越界分开覆盖;现有Toolbar/Header定向动作/identity回归、lint/typecheck、fresh renderer三尺寸真实resize Gate、GUI smoke及风险匹配默认门禁。无协议/Host/runtime改动,不扩Rust写集或无差别重跑旧22场景。 +- 架构影响非重大:既有header presentation、overflow与测量owner不变;诊断和几何helper均test-only。当前切片仍partial,fresh Gate通过前不标完成。 + +- root产品最小修复已实施:workbench-header root与group均单行且不收缩;task-strip继续wrap,沿现有actions overflow-x-auto访问超宽操作。标题组增加稳定context定位。现有Toolbar/Header三文件36/36、两产品文件ESLint/格式/diff通过;fresh renderer构建exit0。产品快照24项包含原22项无漂移与两页头文件;renderer本次174项,Host/sidecar三摘要实读匹配。快照为`product-write-set-after-header-layout-fix.json`及`renderer-artifact-after-header-layout-fix.json`,与之前3481项快照区分,不混用文件计数。 + +- geometry测试侧已冻结:旧测量12failed/5passed先红,新几何22/22+诊断18/18、原resize接线1/1与lint/format/语法/scripts治理通过。入口886→715行;唯一helper闭包自足直接evaluate,等待/readiness/timeout逐字保持。完整context与toolbar paint不允许旧回退;额外校验toolbar/actions raw纵向span,裁剪不能掩盖旧64/46错位。manifest `/tmp/lime-user-message-collapse-rich/resize-reflow-geometry-write-set.json`。 +- fixed-3启动exit1未进入resize:root误用浏览器build生成`/assets/...`绝对module路径,Electron file页面停在startup,两资源缺失、无业务invoke。该renderer174项快照只保留失败溯源,不能作有效Desktop产品证据。已使用`build:renderer:electron`成功重建为相对`./assets/...`;新快照`renderer-electron-artifact-after-header-layout-fix.json`,fresh fixed-4在跑,生产页头未因此回退。 +- 新护栏写集:fixture worker仅`electron-fixture-build.mjs`及相邻test,按实际HTML module/stylesheet的file资源路径检查renderer可复用性;独立于mtime与其他segment,不改显式override边界,禁止把browser构建或missing module artifact当fresh packaged资源。仅测试装配合同,无runtime/协议改动。 + +- fixed-4真实exit1继续仅childrenContained:完整context定位现正确,但toolbar仍64px;定位上层consumer仍按layoutMode聊天分支传task-strip,未随已经迁入header的位置更新。root追加认领WorkspaceConversationScene.tsx及既有consumer test的最小迁移;placement从同一shouldRenderThreadHeader派生,chat/canvas两种布局挂入header均使用workbench-header,非header使用task-strip,不新增owner或同义helper。 +- consumer先红:chat分支失败、canvas通过(1failed/1passed/19skipped),日志`/tmp/lime-user-message-collapse-header-placement-red.log`;修后Scene/Header/Toolbar四文件57/57,追加非header分支task-strip保持验证。页头横向溢出从起点排列,避免flex-end形成无法滚回的负方向内容;沿原overflow-x-auto,不加新交互。相关四文件ESLint通过,fresh Electron renderer重建中。真实fixed-4保持失败证据,不把helper裁剪后的部分可见当contained通过。 + +- fresh fixed-5真实exit0,三尺寸wide1280×820/compact880×720/restored1280×820,66/66 assertions,console/page errors0。页头46px、actions/toolbar32px(y15..47);完整context、paint containment、重叠、compact栏、table/message/input/right surface与canonical terminal均通过。wide/compact截图已人工检查,通知toast存在但未见toolbar越界或message/input重叠;不从DOM几何推出Codex像素一致或所有水平隐藏操作的键盘可达性。 +- 新源码26项基于原22项零漂移,包含Toolbar/Header/Scene及Scene consumer test;新Electron renderer174项与Host/sidecar三实读摘要匹配。快照`product-write-set-after-header-placement-fix.json`及`renderer-electron-artifact-after-header-placement-fix.json`;标准typecheck、四产品/测试文件格式与diff通过。此前rich5/long-list6/live-tail-final来自视口修复基线,页头补丁不改变其产品owner;fixed-5补验页头和消息/input/table布局,证据分版本保存。 +- 构建复用guard已冻结:旧实现14failed/10passed先红,最终28/28及相关source接线2/2通过;两文件lint/format/语法/scripts治理/diff通过。真实HTML按baseURI解析module/stylesheet现存file,空或无module/root-absolute/non-file/非法/missing/directory均使renderer segment stale;不执行脚本或取网络,finally关闭JSDOM。三segment构建、mtime、appUrl/build-ready显式override保持,依赖图不扫描。manifest `/tmp/lime-user-message-collapse-rich/electron-fixture-build-assets-write-set.json`。 +- 最新`verify:local`已启动,日志`/tmp/lime-user-message-collapse-header-layout-local-final.log`;当前生产与fixture写集冻结到门禁结束,不用前轮126批成功替代本次页头consumer/装配guard结果。 + +### 默认门禁发现:复制反馈生命周期 + +- 新默认门禁 exit1,停在85/126批。该批16文件178项断言均通过,但环境销毁后 `MessageList.tsx` 的两秒复制反馈定时器仍执行 `setCopiedId`,产生 `window is not defined`;失败不能按断言全绿忽略。 +- root窄例外写集:`MessageList.tsx` 复制反馈及独立 `MessageList.copyLifecycle.component.test.tsx`。连续复制从最新成功计时;卸载/切换会话取消旧反馈;迟到clipboard成功/失败不覆盖新会话或新复制。平台clipboard调用不伪称可撤回。其他产品与fixture继续冻结,隔离marks/dictation候选不进入src。 +- 验证:先运行现行产品的生命周期反例,再修复同一显示owner、定向消息回归/lint/typecheck并从85批续跑;不放宽timeout、关闭unhandled error检查或删旧断言。追加真实copy Gate按新的renderer摘要单独记录。架构owner与协议未变。 +- 原产品生命周期反例8failed/2passed;卸载计时器初始计数含jsdom Storage零延时事件,精确stack确认后仅drain 0ms,不推进两秒反馈计时器。修后10/10,含既有消息五文件合计69/69;产品/测试lint与标准typecheck通过,格式已统一。旧fixture选择器错误与诊断日志保留且不算业务先红证据。 +- 新快照 `product-write-set-after-copy-lifecycle-fix.json` 27项,只漂移此前26项中的MessageList.tsx并增加独立回归。新default续跑发现新增测试,追加127批,保留之前passed记录;日志 `/tmp/lime-message-copy-lifecycle-local-resume.log`。旧rich/long-list/resize证据仍按各自renderer版本引用,复制新生命周期尚未取得fresh Electron证据。 +- 最新default续跑exit0:127/127批、1692文件执行、12764 passed instances(首轮失败85批的178项不重复计),unhandled errors0;contracts、7 Rust crate3179/3179和真实Electron GUI smoke通过。smoke summary为 `standalone-shell-01-20261014075111-89042/shell-01-electron-smoke/summary.json`;批次与证据lineage记录到 `default-header-copy-lineage.json`。 +- 复制生命周期fresh rich Gate exit0,`.lime/qc/user-message-collapse/copy-lifecycle-rich/message-copy-lifecycle-rich-summary.json`:69/69 assertions,正文19项布尔合同均true,两个显示状态1705字符原生复制一致(SHA `3b5ab254cf9c0e7f9d2d7ffcb3e3829910c83fe8114afd88a2f04e050c3debcd`),唯一canonical_turn、真实键鼠、无额外input、console/page errors0。异步晚到与卸载由独立10项组件回归证明,本Gate不冒称验证clipboard延迟失败或live Provider。 +- 新Electron renderer271项及Host/sidecar三项保存为 `renderer-electron-artifact-after-copy-lifecycle-fix.json`;产品27项及274项产物逐项复核零漂移,Host/sidecar摘要保持此前值。此前long-list6/cold、live-tail-final、resize-fixed5分别保留其产物摘要,当前复制Gate补验新MessageList反馈,不混称同一renderer。长UserMessage合同(两入口/测量/锚点/复制/恢复/有效回流守卫)100%关闭;全面产品仍partial,marks/voice/persistent等不计入该分母。 + +### Link/code 与 dictation 生命周期 Verification Contract + +- 负责人:root负责input-kit迁移、计划、架构与统一门禁;lime_desktop_capabilities负责dictation hook/session、Core消费者与五语言。fixture worker先提交只读接线设计,产品冻结后再认领精确fixture。预算 `budget:normal`,风险P1。 +- 参考:复用 `marks-voice-audit.md` 冻结REA及普通AST锚点。基础link attrs、code_block与序列化采用已确认合同;完整PMU、主动打开/编辑dialog和Codex真实录音表现仍unknown,不把候选变量当原始源码名称。 +- root写集:唯一input-kit的schema/types/Controller/Editor/keymap/serialization、link helper及相邻行为测试;positions若无语义差异保持现状。Plain display UTF16与Markdown submission UTF8分开,annotation迁移保留富PM文档,禁第二Controller/renderer业务后端。 +- dictation写集:现有hook/draft/unit test、新独立session helper/lifecycle test,Core最小消费、独立Core听写回归、copy owner与五语言agentInputbar资源。独立starting/cancel动作;permission/RPC晚到按同session generation、Controller实例及revision保护;卸载/会话切换清资源。 +- IME接线:PM公开handleDOMEvents早于默认compositionend,`isComposing=false`不能证明事务已结算。首版采用有边界的fail-closed:composition相交即无回滚地失效整个voice session,保留编辑文档,拒绝后续ASR/润色;已有组合时不启动录音。不使用私有PM属性、盲timer或跨revision范围映射。明确取消才按当前revision恢复provisional草稿;这不宣称ASR与IME并行合并已实现。 +- 清理:替换旧hook重复refs/异步reset路径;旧纯文本paste fixture改为MIME-aware而保留原text/plain断言;旧positive shape不能阻碍current link/code语义。无新增compat/deprecated、无新依赖/协议/Rust变更。 +- 验证:候选先红/绿只作预验证,应用前逐文件检查baseline;实际src定向先红/绿、Controller/Editor/receipt/IME/纯文本/五语言回归,真实Electron link/code→Send→canonical→history/restore Gate,已有voice current权限/API合同与生命周期测试、GUI smoke/default。系统麦克风/真实ASR和Windows若未执行单列not-run,不能以mocked录音生命周期测试替代。 +- 架构影响:沿既有唯一input-kit、Desktop录音surface与ASR API owner;未新增跨层owner/协议,非重大。实现后同步architecture输入文档与取消生命周期描述。persistent draft/专业文件/原生操作/remote继续在整体待办。 + +### Dictation 实际源码与单路径收敛 + +- 原14文件actual写集冻结;最终manifest `.lime/qc/dictation-product/actual-manifest.json` SHA `3f009ce3725c04dab322650102b90f30797985cd89a43af1e4442be1e3e67d90`,root逐项复核14文件零漂移。既有Core/copy/五语言脏改动按baseline保留,仅接本合同。 +- 实际hook先红25 failed/14 passed,Core真实hook/PM接线先红7 failed/1 passed;补音频decode取消竞态先红1项。候选pending接口在Core fail-closed策略下没有生产消费者,删除状态/type/session字段、hasPendingFinal与flush/drop整轨;迁移current行为后先红5 failed/26 passed,最终5文件72/72通过。 +- 同Controller+sessionId+generation隔离所有异步结果;组合输入开始时整session无回滚discard,结果应用时发现composing同样终止。明确cancel才按最后provisional revision恢复。已发RPC只能隔离结果;音频decode context立即close,迟到decode不再发ASR。starting/独立cancel/输入法中断反馈覆盖五语言;系统mic/ASR质量与Windows仍not-run。 +- 全仓非候选对旧pending符号扫描0,类型检查含测试、窄lint、Prettier与diff检查通过。hook719行/session196行,lifecycle测试796行,后续新增场景必须独立测试文件,不继续堆叠。无新API/协议/依赖、无compat/deprecated。 +- root正在冻结构建与标准typecheck;新Electron link/code Gate、current fixture、GUI smoke/default尚未关闭,72项只计组件/生命周期证据。architecture已同步scope/cancel/discard与显式发送边界。 + +### Link/code 实际源码迁移 + +- 应用前逐文件核对12目标 baseline/candidate SHA,issues为空;冻结候选 manifest SHA 为 `7bd36954f2d5071239c23854e5e0e6ff16d43b54cca0c7b0fd1c3bc11db5d359`,patch 为 `ed0bd38d66719e645760be9526b45cc2a9c6d0ecc7424593d4dd195e15f047c9`。候选摘要不作为实际应用摘要,实际身份见 `.lime/qc/composer-marks-actual/applied-manifest.json`。 +- 先只迁入独立测试,在实际 src 复现22 failed/8 passed(2文件30项),随后迁入生产实现;20文件224/224通过,覆盖input-kit与typed submission、receipt、restore、workspace消费者。日志分别为 `/tmp/lime-composer-marks-product-red.log` 与 `/tmp/lime-composer-marks-product-green.log`,副本落在上述actual证据目录。 +- link安全协议、合法literal重放、UTF16 display/UTF8 Markdown范围、code fence/原始空白、PM annotation事务与实际EditorView paste/键盘均在current owner回归。旧clipboard getter只迁为MIME-aware,原CRLF/large-paste合同保留;`composerPositions.ts`无生产改动。 +- Prettier后三个test describe去掉candidate措辞;Controller达到803行,annotation事务构造拆入纯 `composerAnnotations.ts`,不增加文档owner或compat wrapper;直接调用者与既有行为回归5文件92/92通过,Controller762行/helper59行,窄lint通过。实际写集扩为13文件,actual manifest已刷新。 +- architecture的schema、Markdown submission、原始draft与range分工已同步。真实Electron HTML→code Enter/ShiftEnter→Send→canonical→history/restore、新default与系统voice仍分别pending/not-run;224项与旧127批不能替代这些门禁。 + +### Marks fixture 冻结 + +- 实际11文件写集与SHA见 `.lime/qc/composer-marks-gate-plan/actual-fixture-write-set.json`;7文件312/312通过,lint/Prettier/node syntax/scripts治理均通过。先红7 failed/127 passed,其中6项为业务断言、1项为测试DOM清理串扰,已明确区分。 +- 独立字面Markdown oracle固定五个反引号fence、最终4361 UTF8 bytes,paste `[197,4337)`、Skill `[4338,4361)`;不消费产品serializer、DOM/wire/canonical反推期望。code按真实locator.press记录trusted/focused/defaultPrevented/insideCode,DOM Selection单列programmatic,HTML paste单列synthetic。 +- history/cancel核对href/title/language/body与独立期望;最终canonical Turn/client/item、media、typed四类input、总input request恰1与终态均进入场景pass/fail。新4项marks断言复用原 `inputbar-rich-restore` 场景,没有新增协议或第二Controller。 +- 删除无调用的 `richInputExpandedText`/`richInputInstalledSkillText`;`isRichInputDraftRestored`仍有common caller,保留原current导出并验证19/19。真实Electron、新renderer/default尚未执行,312绿只计Unit/wiring。 + +### 草稿落盘补证 + +- 同一ASAR的bootstrap/startup-requirements成员integrity匹配,REA新增Evidence `ev_6b726d05b1186a46c3e78398b4559cddd089b886382b8619d60fb2d509426d15`:2文件577112 bytes、parse failures0;semantic heap exhaustion导致graph incomplete。类/文件/算法结论来自完整原成员的普通AST,不能冒充REA semantic或运行证据。 +- 已追到Codex Home `.codex-global-state.json`、export D→class的身份、main500ms debounce、串行保存、主/备份文件、同目录临时文件rename及Windows三档重试。renderer250ms发布与main500ms保存分开,broadcast不能充当落盘确认。原始成员与AST只留本机分析目录,合同/摘要/限制写入 `composer-draft-persistence-audit.md`。 +- 实际退出/重启、强杀耐久、账户隔离与完整附件持久化仍unknown;Lime scoped persistent draft仍未实施。未扩展Host协议或调用者写集。 + +### 本批门禁失败与 fixture 版本边界 + +- 首轮 rich Gate exit1,失败阶段为 UserMessage aggregate oracle;marks/cancel/history、canonical四类input和总input恰1通过,两次原生clipboard全文1779 UTF16字符(4361 UTF8 bytes)匹配,console/page errors0。`renderedFullTextPresent=false`,不能以复制成功替代整个Gate成功。 +- 初次prepare期间两个fixture于08:49:41Z变化,旧11文件manifest未同步;当前源含独立`renderedTextParts`与rich调用接线。原冻结和失败证据保留,修改者/精确旧进程完整模块SHA不可从现有记录还原,不声称该失败对应当前完整fixture身份。54项产品源码仍逐项零漂移。 +- root新增独立真实MarkdownRenderer消费者回归:确认安全link href/title、code原始空白/正文反引号和全部ordered可见片段;同一rendered DOM拒绝raw Markdown全文显示假设。该harness替换syntax-highlighter与原生资源依赖,仅计组件/fixture证据。首次新增case因测试包围DOM缺失失败,修正测试装配后6文件189/189;不计业务先红。窄lint和格式通过。 +- 当前冻结为 `.lime/qc/composer-marks-dictation/product-fixture-source-freeze-rendered-body.json`(54产品/14fixture);不覆写旧manifest。fresh进程rich Gate已启动,标准入口因源mtime判定重建Electron renderer;构建完成后重新记录实际Host/sidecar/renderer摘要。 +- 新`verify:local`在69/126批exit1:中断恢复拓扑guard把`inputbarDictationSession.cancel`的受控恢复算作非法writer。worker只认领guard及反例,不改生产dictation、不给voice伪造中断请求。保留两个UI消费者与request pass-through约束,更新扫描边界后定向验证并resume。旧127批绿不替代本次结果。 +- 首轮sidecar prepare出现`install_name_tool` rename竞态报错,既有helper允许missing-after-race或already-present-after-race后继续装配;具体分支未记录。当前桌面code-mode-host存在且具有`@executable_path` RPATH,但本富输入Gate不验证CodeMode运行能力。不修改未知Rust产物owner或绕过prepare。 +- fresh进程rich Gate exit0:73/73 assertions、UserMessage正文19项合同全true、两个显示状态全文1779字符原生copy、同一canonical Turn/Item/client和typed四类input、input总数恰1、marks取消恢复与历史键盘召回均通过,console/page errors0。新Electron smoke renderer271项和Host/sidecar实读摘要保存在`renderer-host-artifacts-rich-rendered-success.json`;raw与桌面App Server均为`df27ddb9…`,不混用旧`4d213628…`。54产品/14fixture逐项无漂移;截图已人工检查。HTML paste/IME仍为synthetic,code键与history/toggle为trusted真实按键,不宣称OS输入法或系统录音已验收。 +- 中断恢复guard已冻结:只改`AgentChatWorkspace.inputRestoreGuard.test.ts`,旧guard实际1failed/2passed;相关6文件66/66、UI消费者与透传5/5、lint/严格tsc/格式通过。按request类型/字段约束直接writer,保留原两个UI owner和pass-through清单,正向区分听写回滚、反例拒绝跨域直接恢复中断请求。guard SHA`8eef1609…`,manifest `.lime/qc/input-restore-guard/manifest.json`;dictation14项零漂移。root复核后重启`verify:local`,入口自动resume,不从第1批重跑。 +- default续跑在94/127批exit1:`MessageList.historyWindow.test.tsx`两条同步render case超过既有5秒上限;单独完整文件仍2failed/12passed,但失败case变化;聚焦同三条case在原上限分别通过(两项3032/3223ms,另一项989ms),不据此关闭全文件。只读诊断test harness计时/locale/无布局测量,未扩大timeout、跳过断言或修改生产。新current fixture聚合已启动,产品与fixture仍冻结;默认完整结果仍pending。 +- 独立诊断显式Node18.20.2:同步render5469.29ms,getComputedStyle一次60.15ms,仅1.10%;15项源码零漂移,不能据此将数秒超时归因无布局测量。第一次诊断导出装配错误与第二次原5秒timeout分别保存。原default进程未记录runtime,不追溯假定为Node18或Node24。root改用独立Node24.21.0运行未修改完整原文件,14/14在原5秒限制内通过,最长2939ms;支持runtime下完整default仍待resume,不从此结果推导原超时唯一根因。 +- 新测试命令显式以REA独立Node24目录前置PATH,不改变用户全局Node。旧已通过的93批仍是原进程证据,不能将最终续跑描述成全部Node24重跑。当前HEAD已包含外部release/guard提交;root未执行commit、push或分支操作。后续default使用明确基线,避免外部提交使smart检测只剩文档而漏掉本批验证。 +- current聚合已exit0:22个真实Electron子场景均通过,包含新rich73/73、审批四类、停止继续、同Turn steer、Plan历史、Skills/MCP、媒体引用、Expert入口和Article Editor。54产品/14fixture仍零漂移;逐场景summary摘要与进程证据等级保存于`current-aggregate-success.json`,结束后实际renderer/Host/sidecar逐文件复核单列artifact freeze。此聚合为unit与真实Electron混合入口,external/provider fixture不冒充live,系统录音/OS IME/Windows未跑。 +- 显式Node24 default以`--base 73ccc7f48`续跑,94批通过,在95/127批失败:ShellBackgroundTerminalList两条中文断言观察到英文,2failed/162passed。测试依赖继承locale的根因按窄写集处理,不改生产文案或降低断言;修后继续从失败批次,不重跑已passed批次。contracts全入口Node24 exit0,current-rules guard11/11;完整default与GUI smoke仍pending。 + +### Code NodeView 下一刀 Verification Contract + +- 主目标:把主富Composer的基础schema视图迁为Controller下的registry/NodeView/Portal语言选择器,连同测试和DOM护栏直接迁移。基础link/code发送Gate不计此切片;主题、多模型、首页Banner仍是三项保留差异。 +- 证据:[NodeView仓库合同](../research/codex-desktop-rea/composer-code-nodeview-audit.md),本机审计manifest SHA`4900789d…`、86个原成员UTF16锚点;REA新增`ev_bf209739…`仅三inert slices的structural证据,semantic unknown。[高亮/Auto补证](../research/codex-desktop-rea/composer-code-highlight-audit.md)静态链已闭合,新增81锚点、5成员integrity、15产物SHA均匹配;REA `ev_32f87b5f…`依旧semantic unknown,不证明运行准确率。 +- owner/写集候选:唯一`input-kit::ComposerController`增加registry持有/公开只读snapshot,`ComposerEditor`接NodeView/Portal;同目录Registry/NodeView/Toolbar、语言catalog与独立行为测试;样式继续existing Inputbar owner或迁为唯一共享View样式;五语言agentInputbar keys;marks fixture/observer及相邻tests迁新DOM。若高亮接入,单列stateFrom/plugin装配与Decorator/parser owner。开始前实际冻结精确SHA写集,避让Rust/命令热区。 +- 结构:code是PM contentDOM,toolbar是不可编辑DOM,React只portal toolbar。语言动作由当前getPos和node重新核验后setNodeMarkup,保留正文/其他attrs/资源;Auto=null+auto true,Plain=plaintext+auto false,显式语言+auto false。源可确认方法名按合同迁;新增descriptive类名是Lime命名,不冒充压缩符号原开发名称。 +- 失败边界:stale/foreign/destroyed位置、disabled、composition进行中拒绝改变;同值不生成无效revision/history;View销毁注销entry/listeners/Portal,迟到parser结果不dispatch;Toolbar mutation/events与代码编辑隔离。用户代码text nodes不能innerHTML/TextContent替换,不加第二document或runtime。 +- 验证:registry多实例/稳定snapshot、actual EditorView two blocks/update/remove、语言及undo/redo/selection/resources、IME/paste/Enter、五语言label/search/keyboard/disabled、无旧DOM正向回退;真实Electron新DOM→语言改变→继续编辑→Send→canonical→history/cancel,源码/renderer/Host摘要与input恰1。高亮单独验证PM Decoration范围/mapping/async generation/无history刷新和实际token spans;Auto结果/发送属性不得只靠label。系统IME/Windows/reference live仍独立not-run。 +- 清理:主编辑DOM旧pre/data-language观察直接替换;schema toDOM仍用于serialization,独立测试不保留双轨UI。缺主路径证据的wrap/copy/preview不作为迁移范围。架构图在实际实施同批更新,由root确认,不用本合同当产品已完成。 +- 当前状态:等待本批default与current聚合关闭;设计/有界研究完成,NodeView产品实施0%、新产品Gate0%。不把本批基础schema73/73或研究100%计为全面GUI完成。 +- 等待期间仅在`.lime/qc/composer-code-product-candidate/`准备独立候选:NodeView/Registry/Toolbar与language/highlight/Auto按窄写集分工,src/fixture/根依赖/renderer保持冻结。候选测试不计产品先红或Gate。现有lowlight1.20/highlight.js10.7为传递依赖,正式迁入计划声明直接lowlight3.3.0,候选在独立package验证现代parser,不改根node_modules。TSRX等grammar差异明确列gap,不伪造regex高亮或扩Picker。正式接线前登记精确写集、依赖/锁文件、架构确认与新的产品证据身份。 +- root独立集成反例直接导入未修改的src Controller/Editor:7/7失败,覆盖主DOM/语言事务资源/纯meta revision与receipt/旧receipt保护/切Controller注销/disabled/实际token spans。测试暂在candidate,精确正文与日志保存;首次config callback装配失败单列,不计业务红。plugins/NodeView候选的stub红也不计正式产品先红。现代按需grammar适配需要直接`highlight.js11.11.1`与`lowlight3.3.0`两个依赖,根package尚未改变。 +- 第95批语言隔离已修:只改`ShellBackgroundTerminalList.test.tsx`,真实前序`await changeLimeLocale("en-US")`下先复现2failed/1passed;beforeEach显式await zh-CN,卸载后afterEach await en-US。保留原中文断言,同前序3/3与原95批16文件164/164通过,lint/格式通过;其余22个保护文件零漂移。final-manifest SHA`0c174dbf2d53a3d81317d349ce199186903ea07be9184672526eb14750c442ee`。default显式Node24以原base续跑,lint/typecheck已过,当前从95/127继续;不改变生产文案、超时或断言。 +- current22聚合结束后的renderer/Host/sidecar共277文件逐项无漂移,见`renderer-host-artifacts-current-aggregate-success.json`,与本批fresh rich产物一致。此身份仍属于基础marks/dictation,不能覆盖下一刀NodeView。 +- 隔离候选复核发现parser与detector永久共享失败Promise会丢失参考detector重试合同;正式接线前分离失败恢复责任并补加载失败后恢复回归。JSONC严格接受comments、拒绝trailing comma,TSRX为独立现代grammar扩展;二者仍仅候选证据。新增直接依赖计划为`lowlight3.3.0`、`highlight.js11.11.1`、`jsonc-parser3.3.1`,根manifest/lock尚未修改。 +- 原default前端已完成127/127批,标准Electron smoke正在build/执行,入口最终exit尚pending。正式接线overlay19/19(原独立7项+12项能力/生命周期)通过,未应用src;独立实际五语言主Editor反例5/5失败,原因均为正式主Editor没有NodeView toolbar,非假i18n资源。等待插件/fixture最终冻结及原default exit0后正式迁入。 +- 高亮scope补查:raw parser完整53 grammar/5 alias组与主GUI的BVr41过滤分开;不由53注册扩展Picker或GUI输入。参考PM只投影HAST root scope,迁掉候选递归nested Decoration差异。原成员gRc/BVr/dRc/SRc与范围写入仓库高亮audit,原15项证据manifest保持历史冻结;产品候选的补测和最终manifest分别登记。 + +### Code NodeView 正式迁移(空间清理后的新证据) + +- 原 marks/dictation default 已 exit0:前端127/127批、lint/typecheck与标准真实 Electron smoke通过;清理后 sidecar重新编译23m35s。smoke身份为 `standalone-shell-01-20261014114136-18147`,真实主界面、reload、三尺寸、memory settings全部通过。本结果关闭原切片,不能覆盖随后新NodeView源码。 +- 恢复候选重新验证:插件3文件105/105;NodeView/Registry/Toolbar33/33;实际Controller/Editor overlay19/19(root7+own12);fixture6文件189/189。严格TS、lint、格式、补丁检查及源摘要复核均通过。新manifest分别为插件`0378f2d3…`、NodeView`3713a1f0…`、接线`7e7ba459…`、fixture`43d94186…`;原历史manifest不计覆盖。 +- 正式窄写集43路径:40个input-kit源/测试、fixture/helper及五语言资源,加根package.json、唯一依赖锁pnpm-lock.yaml与architecture。迁入前所有源码baseline零漂移,详见 recovery/evidence/formal-before-manifest.json;已迁40路径,直接依赖安装与正式验证进行中。原清单误登记不存在的package-lock.json,安装前已核对packageManager为pnpm@9.15.9并补真实锁文件baseline,未创建第二锁。npm Arborist安装失败且未改manifest;旧Corepack签名校验失败,使用临时获取的同版本pnpm与独立Node24,不修改全局工具。测试只将外置绝对路径变为仓库相对路径、移除candidate标签,原行为断言保留;实际parser案例mount后等待真实loader完成,不放宽timeout、不预加载、不stub。静态语言reference fixture独立于生产catalog。 +- current:Controller→Registry→NodeView/contentDOM→Portal语言选择器;独立highlight/Auto插件;types与composerState单一职责拆分。dead/replaced:主编辑DOM的schema直接视图、旧pre/data-language正向observer与历史投影、Controller旧type export。schema toDOM仍是current serialization/clipboard,未增加compat/deprecated轨道。主题token继续承接配色,多模型和首页Banner仍走既有owner。 +- 同批更新architecture的Composer图、依赖、revision/receipt与View/plugin生命周期;责任开发者root于2026-10-14确认。新源码定向测试、五语言、contracts、标准Electron构建和语言选择/发送/cancel/history Gate尚pending,不写此切片验收完成。 + +- 正式验证首轮发现五语言5/5失败:Toolbar误将资源分片agentInputbar当作运行命名空间。现已改为current agent namespace,独立Toolbar fixture同步;保留真实五语言原断言,修后5/5通过。原input-kit首轮20文件348项通过,失败不计完成;fixture6文件189/189、app-version与contracts通过。其余定向/默认与新Gate继续执行。 +- 根依赖由独立pnpm9解析三项exact版本,合并后语义校验其余importer/package/snapshot不变;正式pnpm --filter lime install --frozen-lockfile --ignore-scripts成功,仅新增4包,实际加载版本11.11.1/3.3.1/3.3.0。existing agent-runtime-client声明1.71.0、local app-server-client1.83.0的版本不一致使直接add解析失败,未改该外部owner。清理同一风险的构建护栏窄扩写集2文件:electron-fixture-build.mjs/test,移除不存在的npm锁输入、追踪真实pnpm-lock与workspace文件;2项先红,修后待回归,不把锁变化误判为fresh artifact。 + +- NodeView正式45路径(包含依赖锁与两项构建护栏)已冻结:`.lime/qc/composer-code-nodeview/formal-source-before-gate.json`,SHA `d954ae8a…`。五语言及Toolbar/实际Editor4文件41/41;相邻消费者6文件141/141;fixture6文件189/189;pnpm构建护栏先红2项后30/30;新source标准typecheck由默认门禁重跑。首次typecheck读取修复前namespace后exit2,不能计绿。新rich Gate与verify:local均已启动,无预判通过。 + +### Persistent draft 下一刀的实施合同 + +- 主目标:同一Controller按workspace/home/draft/session scope保留未发富草稿;Main persisted atom为唯一桌面UI保存owner,不进入canonical transcript。最小Host候选先隔离准备,NodeView正式身份与本轮门禁不变。参考静态250ms发布/Main500ms合并与原子文件替换见保存audit;CAS/promotion/明确accepted保护为Lime实施合同,不冒充参考实测。 +- proposed current:PersistedAtomStore/Host→固定AppDataRoot/host-state.json;只允许composer-drafts-v1,read/update两Host命令与typed gateway。更新为put/promote/consume/clear;draftId+opaque scopeVersion CAS,promotion不改变版本或receipt,consume保留无正文tombstone;不自动驱逐或以absent重建旧ID。失败、unknown、仅dispatched不消费。 +- renderer协调者独立于Controller:250ms合并后台写;捕获发送token固定pending put,后台保证captured put先于consume;发送不await保存、不增加durable checkpoint或Quit握手。accepted跨导航也消费旧存储;当前UI只在相同逻辑binding且receipt匹配时commit,保留新稿。materialized/direct/home三种promotion必须覆盖,sessionId与threadId分开。 +- Host候选窄写集只在recovery/persistence-host-candidate;正式接线再更新architecture并确认图。主文件/backup失败保旧,保存revision与pending/error分开,Windows rename有限重试;loaded失败保持unavailable,不能写空覆盖。当前附件为20张、base64合计100MiB;本轮固定draft128MiB/总文件256MiB、10,000records/bindings保护预算,UTF8 JSON计费、整次拒绝,不截断或影响发送。 +- 验证合同:先CAS/late put/tombstone/promotion冲突/幂等、文件失败与保存竞态定向;同步Host/preload/gateway/catalog与领域guard,保留诊断trace但不写完整草稿,Main通知禁止renderer spoof。真实IPC与正常退出重启证明Host基础能力;随后GUI导航、三种首发、failed/newer draft与current AppServer Gate。Host单刀不计用户草稿已交付,Windows真实进程/强杀/断电另列not-run。 + +- 新rich-1真实macOS Electron Gate已exit0:74/74 assertions,console/page errors0,legacy/mock hits0;session/thread `01a13a48-fea9-7841-8b7c-bbd8634f2370`,canonical User Item `user-turn_d0cb04df803547f6afa24610073a61b1`。实际Picker搜索Auto→Plain→TypeScript、可信click/Escape、query清空、owned Portal、回焦点与真实const高亮均通过;body/资源/selection/history/cancel同identity,input请求前后均1。receipt `.lime/qc/composer-code-nodeview/rich-1-receipt.json` SHA `16dfd944…`;464项renderer/Host/preload/resource/sidecar摘要见`renderer-host-artifacts-rich-1.json`,正式45源零漂移。controlled external backend不是live Provider,HTML/paste/IME边界仍synthetic;Windows/Forge安装包/OS IME未跑。current聚合与新default尚pending,未计此切片全部门禁关闭。 + +### Persistent Host 接线候选(正式源码未迁入) + +- root隔离写集为recovery/persistence-wiring-candidate:Main仅生命周期、路径注入与两个Host方法委托;IPC白名单、preload、catalog与current safeInvoke诊断同步准备。Main原大文件不堆保存或CAS业务;renderer event注册抽到独立纯Host policy,默认退出不增加落盘等待、checkpoint或Quit握手。正式NodeView的45项源摘要仍零漂移,架构正式接线待本轮门禁关闭后登记。 +- 桥接诊断先红8/8:富草稿正文、base64、scope和任意identity经现有sanitizeValue进入trace/error缓冲,错误message也可回显正文;保存通知原生订阅失败被safeListen吞成空取消函数。候选抽出唯一invokeDiagnostics owner,两个buffer recorder仅保留固定key/op、command/transport/status,不检查任意错误正文;其他命令保留既有诊断行为。原错误仍返回调用者,不伪造保存成功。先红/绿日志分别为`/tmp/lime-persisted-atom-bridge-diagnostics-red.log`与`/tmp/lime-persisted-atom-bridge-diagnostics-green.log`,修后2文件25/25。 +- Main IPC与preload共用rendererEventPolicy:真实handler拒绝renderer广播persisted-atom-update,预加载emit/send同步拒绝;Main owner主动通知仍可原生listen和反注册。独立policy及实际preload/bridge消费者4文件38/38,日志`/tmp/lime-persisted-atom-wiring-candidate-tests.log`。这只是隔离预验证,不能算新Host真实IPC或草稿正常重启已验收;source guard、严格TS/lint和候选最终摘要待冻结。 +- Host与gateway共享纯JSON transport types/parser及单源预算,避免Main引入PM/React或复制response/event schema。Host候选继续补消费tombstone开销预留、IO读取预算与无效UTF8拒绝;renderer只读接线审查限定recovery/persistence-renderer-design,核实三条promotion和accepted/false/throw/仅dispatched,未扩正式GUI写集。 + +### NodeView 默认门禁续跑与旧测试清理 + +- 新默认门禁exit1,停在29/127批:16文件279项中277通过,唯一失败文件为composer-marks-renderer.test.mjs的两个旧独立literal。当前fixture已迁为`const value`正文与typescript语言,旧断言仍期待只含中😀的正文和ts fence;真实新rich与聚合rich均通过,不能为旧test恢复旧fixture或产品实现。 +- root窄例外写集只含该MarkdownRenderer消费者test:更新独立字面正文及五反引号typescript fence,保留原空白、可见片段、链接、clipboard/raw Markdown与canonical边界断言,不读取产品serializer或DOM反推期望。正式45项产品/fixture源不变;新增test后记录46项freeze lineage,定向回归后从29批resume,前28批不重跑。日志`/tmp/lime-code-nodeview-verify-local-node24.log`保留原失败,下一轮使用独立resume日志。 +- Persistent Host候选13文件已冻结121/121;gateway/fixture候选4文件已冻结70/70。它们仍只在recovery目录。renderer审查发现await Promise与丢弃QueuedSubmission被包装为true、steer接受后refresh失败、卸载preview先settle false后真实发送仍accepted、迟到首发重读activeSessionIdRef,以及冷启动不知旧draftTabId的轻量index缺口。GUI正式接入前必须沿existing AppServer owner透传typed receipt、captured identity并补同一atom workspace index,不能将Host基础read成功当作草稿产品已交付。 + +- 旧MarkdownRenderer独立literal迁移后2文件54/54通过,窄ESLint通过;原45源SHA全部零漂移。新的46项lineage为 `.lime/qc/composer-code-nodeview/formal-source-before-resume.json`,SHA `f12f4accfe6b35f4b230bf524f77cb5aadce3152a37e00e63f44b28dc8b27e45`。Node24默认续跑已启动,日志 `/tmp/lime-code-nodeview-verify-local-node24-resume.log`;current聚合仍运行,不预判22场通过。 +- Persistent隔离接线新增HTTP诊断边界:原HTTP入口直接console打印cmd/args与error,3项回归首次2红1绿;现复用invokeDiagnostics,草稿仅记录固定key/op,调用者仍收到原错误、wire仍含完整请求,其他命令原console参数保留。新HTTP3项及旧HTTP30项已绿;综合候选80项出现source guard正则误报与实际ESLint测试在并发负载下5秒超时,未修改产品或扩大timeout。已修正guard只匹配直接raw error参数,独立9项复测进行中;最终冻结尚pending,正式源码未迁入。 + +- root接线候选最终16文件(9改7新)已冻结:manifest SHA `566d986f91640f8b52b3912e52180ce3d82a47ddedf17d3b88b539278928e622`,7文件80/80;strict TS、窄ESLint、Prettier和patch-check通过。source guard收窄到HTTP invoke直接raw error,ESLint配置在module初始化加载一次,断言仍用真实lintText,原5秒timeout不变。driver packet SHA `1322a3280a23f56cf4d2c6089fe12c5868628374ea0d7b41a815ac960ff0de19` 含3项真实policy passed receipt;正式源未迁入,driver必须继续fail closed且不build/launch。 +- renderer只读设计已冻结55源/134锚点零漂移、43场景,manifest SHA `7085719e0e5982acf7dfe25385cb307de06ed9d8ab3b137bcc660568b5addcc4`。同atom冷启动索引、准确accepted透传、后处理/卸载/迟到identity与导航reset合同已落入保存audit。下一窄候选由原renderer worker负责acceptance core到useAgentStream与typed observer,外层slash/Workspace/TaskCenter/coordinator不进入本刀;正式源码保持46项freeze。 + +- root复核同SHA参考的Windows rename backoff,发现原Host候选25/50/75ms不必要偏离100/200/400ms。原Host候选保持冻结;独立File二路径overlay已18/18通过,严格TS/lint/格式通过,重试次数、错误分类、目的文件保留与partial backup语义不变。真实Windows未跑,不将注入platform的测试写成Windows产品证据。 +- Persistent基础正式迁移包仍37源(9改28新),仅两File路径采用上述overlay;其余源逐项匹配四个冻结manifest。combined source patch SHA `dd5be3da6548d5dbf9ea41a50f67beede21a28abba1a1d144fee174e41256f7b` 已git apply --check通过,尚未apply;NodeView默认/current门禁关闭后再迁。 + +- current聚合入口现已exit0,通用smoke日志记录22/22场真实Electron成功(包括独立coding workbench入口)。聚合receipt和464项产物最终摘要复核正在收尾,不把顶层初始session当作后续场景canonical身份。 +- 第二次Node24默认入口exit1:29批279/279通过;30批397绿/1红,唯一旧code-space反例仍替换已不存在的` 中😀`,实际为no-op。root只改该rich-input-evidence.test.mjs:独立literal改为` const value`,并为六种Markdown漂移共同断言输入确实变化。3文件126/126及窄lint通过,原46源零漂移,登记47项lineage。两次verify:local失败日志保留。 +- 按quality-workflow事实源继续`npm run test:resume`,从30批续跑;已通过的app-version、五语言结构/unused、全src lint、hardcoded与标准typecheck在产品源码SHA不变下复用,随后显式执行原计划剩余的标准`verify:gui-smoke`。这是默认步骤的组合收尾,不能改写此前wrapper为exit0,也不重复无新风险的前置门禁。暂不迁入Persistent正式源码。 + +- current聚合最终receipt SHA `a678fc058cc0fa4cc9e2aa57c35ed7ebfe5693004afcca5339211c999d67e86b`:22场/1476项assertions,47源与464产物完整SHA校验零漂移;21场使用自身gateBContract identity/outcome,coding使用独立accepted/recovery合同,不合成其未提供的legacy/mock计数。47项lineage SHA `f1a4c8cf4c9484519aae998881c5d0e21c65c07b350d5bf17414ba40afa5353d`。23项current-docs/rules与格式、diff检查通过。 +- workspace-index候选已冻结:manifest SHA `3fb82df155014550121fd896a2aacf3edb0c9e77d9f2df9df60b61b15e1db784`,191/191、strict/lint/格式/apply-check通过;只11路径替换原Host read合同,File和update/CAS/IO原算法不变。acceptance core候选25路径、12测试文件113项最终通过,manifest SHA `af0789c288f170584f67bd11860fdcad5f7e452972ea8fa557b7a59cd8048a6e`;strict25入口、lint23文件零warning,2份tracked d.ts按官方忽略,生成owner仍unknown。两者正式采用0%。 +- 独立下一候选分工:desktop worker仅在recovery/index-consumers迁gateway/公开IPC fixture/driver及相关read fixture;renderer worker仅在recovery/acceptance-surface迁真实start/steer/queue结果到外层send/slash/Workspace/TaskCenter;fixture worker仅在recovery/codec准备完整富草稿JSON边界。原冻结包和正式源码不夹写。队列结果只能由真实QueuedSubmission和请求前捕获的canonical context构造独立queue-accepted,不能从true/void推导Turn接受。Workspace3107行owner只保留小模块委托,业务判别迁小模块为本刀退出条件。 + +- 前端续跑30–77批通过,78批16文件92项均绿但有未捕获异常:image-gen页的3秒timer在环境卸载后更新React state,入口exit1。root窄扩写集:image-gen源/test、将voice/useAutoDismissMessage迁为shared唯一owner、voice仅import机械迁移,以及video-gen/media-services两处完全相同的timer重复消费者。新增3项真实页面回归先红,修后image+voice19/19通过:卸载取消、第二提示完整计时、保存晚到不建timer。不吞Vitest错误、不扩大timeout、不通过测试清timer遮蔽产品缺陷。voice1246行文件本刀只迁import,无新增业务;整页拆分不在本缺陷写集,退出条件为无业务追加、共享helper替代旧timer入口。本变更需要新typecheck/定向回归/renderer构建身份,原NodeView47源仍冻结,随后新lineage登记新增/删除路径。 + +- 生命周期修复后video/media10项通过,四页面合计29项;窄lint/格式和标准typecheck已exit0。新53源/1删除lineage SHA `6036d717607e29936d3e1e65bd0ab74ea6e38b889e5aa6f84efe2a1c2e888b60`。标准`verify:gui-smoke`已exit0,run `standalone-nodeview-timer-20261014-2130`,summary在`.lime/qc/composer-code-nodeview/gui-smoke-timer/summary.json`,真实startup/reload/preload IPC/App Server/三尺寸/memory settings通过;无provider turn,不拿壳smoke替代74项富输入或22场current证据。 +- 续跑78–81批通过,82批8个CharacterMention套件collection失败:新Toolbar导入Popover时触发旧hoisted mock的顶层React初始化顺序。root仅迁同一个testFixtures文件的Popover/Command mock工厂,改为各自await import React,不改产品或行为断言;8文件80/80通过。54源/1删除lineage SHA `e6a7d7f65395ca778b995a6f1afc4c26fab1df1f77c36b7800b962e3f7660381`,从82批再次resume;53产品源仍零漂移,不为test-only改动重build。 +- 基础Host37源新增独立Driver test-root overlay,移除2份test的REC绝对常驻路径,使用os.tmpdir/mkdtemp并afterEach仅清自己目录,沿35/35回归通过。原冻结Driver包未改;新正式combined patch SHA `d60359b76a609ebec3258dd4a82de1c76cfcb732a1f765b5874b0a8da4547f2c`,migration manifest SHA `c897066cd966bca4c817031b0909a06ec743dca75213a625ef780f3a6a9098a5`,apply-check通过,尚未apply。 +- Codec候选两新文件、38/38、strict/lint/格式/apply-check通过,manifest SHA `608e62bd0e8c0cfdc3f8bc0bd0c23329eff77bab9b6fe384fb790a85324eb1bb`。真实Controller/schema/原preview helper验证完整富JSON→submission往返;剔previewUrl暂态但保data,plainTextMode为显式参数,当前Controller无模式getter且restore不改变immutable options,GUI模式生命周期仍须接线。正式采用0%。 + +### NodeView 默认组合收尾关闭与 Host 正式迁入 + +- 前端最后续跑exit0,状态passed,127/127批、1713测试文件完成;原两次verify:local exit1保留,随后timer与hoisted fixture续跑失败也保留。按质量事实源复用已过前置步骤、修后标准typecheck、test:resume和显式标准GUI smoke,组合默认步骤关闭,不捏造新wrapper exit0。最终receipt `.lime/qc/composer-code-nodeview/default-combined-final-receipt.json` SHA `7078735e6eeb6550608256d965113ed7271ef63c9fc092bdac63d73f19e8228b`;54源/1删除、最新464产物逐项零漂移。rich74/74与current22/22仍关联各自原build,不能说它们与后续shell build逐字节相同。 +- NodeView切片行为、结构、旧fixture/护栏迁移和本地验收100%;整体GUI仍partial,参考运行/live provider/Windows/OS IME/mic/Forge未验。current为Controller/Registry/NodeView/Portal及两个plugin;旧主DOM观察路径直接替换,旧voice timer helper已删除并由shared唯一owner承接,新增compat为0。 +- foundation独立只读审查未发现确定缺陷/迁移阻塞;64冻结entries/40有效源与consumer beforeSHA链一致。审查SHA:MD `1a554160c8fded926da92d468eb052fd13eccaad00279e1fda9d882660922422`,JSON `8ede73ff6f55e194ddf0124007b99c6bb24ee8edf21f68c08e545fa6bf9be104`。dispose为best effort,真实fixture先等saved再正常quit,不证明pending即退出。 +- root下一窄正式写集为latest combined基础37源(含File100/200/400ms与Driver test-root overlay)、architecture/commands、保存audit/report/index/本计划;避让release plan、未知local-conversation产物和其他外部改动。迁前重新核before/final SHA,再迁为唯一current Host保存链。基础Host真实公开IPC/正常新PID重启关闭后才迁index及消费者;GUI coordinator/codec/acceptance继续独立交付。 + +- latest37正式迁入完成,所有before/final SHA一致;architecture 4.3与commands同批同步。架构影响major,责任root于2026-10-14确认图:唯一Host UI保存owner与AppServer canonical事实分离,Main只组装/委托/dispose。Main超过1000行,本刀新增行为限小owner,退出条件为不向Main追加CAS/IO/parser业务、两个IPC仅委托,拆分Host自身实现已完成。正式验证pending,不能计Host或GUI验收已过。 + +- 正式Host首轮17文件233绿/58红、1suite collection失败;58项均被夹具new URL(.test-data, import.meta.url)在默认jsdom下转换为非file URL阻断,未执行产品断言。两个test root改为node:os.tmpdir()+mkdtemp,仍仅清自有根;standalone是Node动态加载/编译orchestration,默认jsdom的Vite浏览器转换报Expected ident,显式Node诊断15/15已通过,文件增加node环境pragma使默认入口使用真实运行环境。不改产品/断言/timeout。原失败JSON保留foundation-tests-first-failed.json,正式复测独立输出;冻结候选包不改,后续index patch的File不覆盖本test-root修复,test-support也须保留。 + +- 同一root问题第三消费者Host.test也已改为os.tmpdir自有根;修后剩余Host9/9通过。正式定向结果为17suites/306项(第二轮16suites297绿 + 独立Host9绿),不是声称单次全量绿;所有产品断言不变。contracts与标准typecheck均exit0,窄lint已过;格式首轮仅evidence-index未格式化,已按Prettier格式同步。下一步标准Electron build/smoke与正式policy三项JSON构造packet、公开IPC重启。 + +- 基础标准GUI smoke已exit0,run standalone-persistence-foundation-20261014-2157;真实Host/preload/AppServer/startup/reload/三尺寸/settings通过。REA注册丢失已scoped setup恢复及显式Node24修复,当前非REA配置原样保留,doctor三项healthy、真实stdio170tools;备份记录 `.lime/qc/composer-persistence/rea-registration-recovery.json`。 +- 正式基础Host公开IPC首轮失败,进程已正常code0/signalnull退出,无残留;实际文件revision7表明前面CAS/promotion/tombstone与保存已执行,但driver.inspectFiles调用current parseEnvelope漏required limits,不能产生有效文件receipt或重启通过。新增真实parser+实际有效envelope回归,修driver而不绕过parseEnvelope/降低校验。原失败证据保留;静态review0缺陷是历史结论,不覆盖此运行发现。 + +- 基础Host公开IPC修后exit0,15场景/36调用/7原生metadata事件、revision7;Main PID96100与96356均正常code0/signalnull退出,无残留。真实parser回归先1红/20绿,修required limits后21/21与standalone15/15;定向组合307项。基础receipt SHA `ef8ed5e08e98557448897860736088a55da3b7afb32ca680715855836ac9f268`,39源/674完整产物(含构建目录旧chunk,非宣称全被加载)。saved-before-quit边界保留,GUI/Agent恢复不计。 +- foundation Gate关闭后root直接迁index+consumers23源(20改3新),正式before/final SHA全部一致;manifest SHA `4488863763d3eb060862b375e2d72693d2153bd45abe2d0eb7eaa39702da29db`。File retry/test-root、正式driver limits修复两源完全保留。旧scope-only read/top-level view直接替换为query/data,gateway/preload/fixture/standalone/guard同步,无双轨。architecture/commands图由root确认:索引只读metadata,不改业务/runtime/CAS。隔离388项不算正式产品;新正式定向/contracts/typecheck/build/IPC索引重启pending。 + +- index正式20文件439/439、contracts/typecheck/lint/格式与新标准GUI smoke均exit0;公开IPC新query/data21场景/46调用/10事件,revision10。PID24613/25004正常退出,current/other/null索引在保存前后及新PID恢复同revision与itemsSHA,consumed排除;receipt `.lime/qc/composer-persistence/index-final-receipt.json` SHA `411ba2f4f4d59805b635780f9f2ba89ee177f8a8bd9e070766da21f3c48ef941`。42源逐项零漂移,779当前目录产物摘要(含保留旧chunk,非全部加载声明)。基础Host+同源index实现/定向/真实Host验收100%,GUI富草稿仍0%;无compat,旧read合同已删除。 +- 下一刀沿同一AppServer事实透传真实start/steer/queue accepted到GUI发送;core+surface合成在recovery准备,coordinator独立候选负责250ms队列/CAS/跨导航消费,root后续接scope导航、冷标签与GUI真实恢复。新默认门禁将在最终产品切片关闭时执行,前轮NodeView127批不覆盖新保存/接受源码。 + +- root正式窄迁codec两新文件,manifest SHA `608e62bd0e8c0cfdc3f8bc0bd0c23329eff77bab9b6fe384fb790a85324eb1bb`,before absent/final SHA完全匹配。它复用当前schema/serializer/selection/resources和JSON预算,剔previewUrl临时项、保data与路径引用,plainTextMode显式保存/解码,不复制schema或store。正式38项回归、lint验证进行中,未GUI接线;模式生命周期由下一React owner解决,不能称冷恢复已交付。 + +- root正式codec定向38/38、窄lint exit0;不代表GUI冷恢复。 +- root核冻结acceptance合成manifest `889bed96b9c74e54c517a6114a5afcec62404802ffbf34fc377babc0fe5dfa58`、patch `f108518990d9835586f2b5951b5e8557580998e01426ac4cbabfa5da1a2b43fa`,62源迁前/迁后SHA逐项一致。正式27文件定向、typecheck/contracts执行中。候选485/485只作采用审查证据,不能替代正式验证。唯一wire与AppServer事实未改变;local-handled/rejected/unknown不当作接受,queue-accepted保持独立QueuedSubmission事实。 +- 下一GUI写集分工:root认领Workspace React保存hook、CommandWiring/cold index与场景props/i18n/状态展示;navigation worker仅recovery候选的topic switch/draft open-select/materialization和组合props;send worker仅recovery候选Inputbar/EmptyState与opaque settlement helper;coordinator worker冻结后仅recovery候选的restore-token类型透传/ActiveStream/fallback/stop。所有冻结包只读,不覆盖未知release计划或local-conversation脚本。 +- Workspace current Controller采用固定rich模式(plainTextMode:false),没有GUI动态模式切换入口;后续coordinator必须显式用同一模式,遇到保存的plain模式拒绝恢复并显示异常状态,保正文,不做静默转换。其他surface配置不变。 + +- acceptance正式27文件485/485和contracts exit0;标准typecheck exit2指出5外围boolean消费者。root认领CanvasWorkflow、ImageCommandAction、ArticleDispatch、CodingViews/RecoveryPanel及sceneGate send适配与对应tests,直接迁typed返回;sceneGate只把明确isRuntimeSubmissionAccepted投影为本地表单关闭boolean。原失败log `/tmp/lime-acceptance-formal-typecheck.log`保留。 +- coordinator四新源正式迁入,before absent/finalSHA核一致;codec+coor正式82/82。root新增React runtime6、cold index3均绿,后续props/真实GUIpending。Workspace固定rich mode显式fail-closed保存plain-mode mismatch,不静默转换。 +- root正式先红证明同步Controller.commit subscriber新edit留UI但Store无entry:`coordinator-reentrant-first-red.json`1failed/30skip。修后退休accepted identity,再observe新revision形成新draft,正式coor31+binding14+React6共51/51。正式Coordinator已偏离冻结候选ce4f1b...,当前SHA d7eeaab37199b931f8350a1d3ff80c538d56b231eea4bc2b9d459e305fad50a3;冻结包不改,send候选须登记依赖delta。 +- restore-token worker构造shadow时误让11路径经symlink写正式,已停并按独立expected-after CAS恢复8owner beforeSHA、3新test absent,零漂移。证据 `persistence-restore-token-candidate/evidence/unintended-formal-write/restore-receipt.json`;首轮shadow验证作废。重建owned以lstat普通文件+resolve在recovery校验,不纳误写进正式证据。 + +### GUI 富草稿正式接线(2026-10-14,进行中) + +- root 已采用发送协调11源(manifest `52cc76430d92fd4cdf486c1a8bd7fe62428d6be71e4bdd2331124ac1bfd600aa`)、restore-token11源(`fd3094e61b80d530712e446203aabcdb5d5821918c229ad7dd620aec9b5308d0`)、导航11源(`53594dff7c16ea23f1117ebd41fb1067b55c5179657b1c2d0f3ebda95d009cdd`);迁前/迁后SHA一致,helper原已按同SHA存在,仅该源排除重复apply。冻结候选只读,正式收据落 `.lime/qc/composer-persistence/`。 +- 正式 owner/codec/status/index/opaque恢复7文件48/48;root目标scope/迟到promotion/reset/恢复门禁4文件45/45;导航source守卫4文件45/45、strict775文件零诊断。各组合有重复测试,不相加为唯一场景总数。五语言保存状态已修为实际agent flat key,保存仅以savedRevision覆盖当前storeRevision成立。 +- root 修 Shell 共享props层级,after等待真实session/workspace与启动epoch;TopicSwitch在project解析/原请求完成/deferred resume均核启动source,不把旧完成清新draft。React owner同scope不递增epoch,promotion在layout前也复核同步intended target;实际workspace变化发布旧稿并提交home。初始化coordinator之前不处理new-task清空;返回home/new-task只导航保存,不discard。 +- root 窄写集为Workspace draft runtime/index/status、CommandWiring/Shell/Scene/props/Reset、五语言与相应tests及事实源;TaskCenter send worker持四个send/request owner与tests,fixture worker只在recovery准备真实Electron候选。避让未知release计划、local-conversation脚本与tsbuildinfo。结构超限退出条件:EmptyState本轮只委托外置settlement owner,不继续增加接受/存储业务;后续拆分其presentation与资源动作。InputbarController接近800行,新增生命周期复用外置helper,不新增第二正文owner。 +- 架构major,由root确认architecture 4.3新增图:Controller唯一正文,Coordinator唯一renderer保存协调,Host唯一CAS/文件;业务链仍App Server canonical。当前GUI切片未完成,TaskCenter三发送路径、真实冷标签/完整富JSON/accepted/新编辑/正常restart、current fixture/标准GUI/default门禁pending。旧NodeView127批和Host索引receipt不覆盖此新切片。 + +### GUI 发送竞态与真实验收准备(2026-10-15) + +- TaskCenter 正式四生产 owner 已停写;73 项不同测试分批通过(48 + 15 + 10),最终真实 Coordinator/gateway integration 10/10、窄 lint/格式/diff 通过。它们不是单次 73 项全量运行,也不是 Electron Gate B。`useTaskCenterDraftSendRuntime.ts` 828 行,本刀退出条件为发送身份判断委托现有 settlement owner、不再追加持久化业务;后续拆分参数组装与 presentation actions。 +- root 新增同 lane 回归:captured home 提交后,250ms 队列内新编辑先保持旧存储版本;接受后 promotion 同步 root target 和实际 binding,新版本保存到 local,旧 consume CAS 冲突,不清新正文/revision,重复 target 不递增 epoch。正式 runtime 9/9。第一次 oracle 错用 entry.payload,改为 current entry.draft.document;保留失败 JSON,不将该失败称产品缺陷。 +- root 前一标准 typecheck、追加 owner guard 后 contracts、窄 lint、格式均 exit0。原阶段 GUI build 因产品源变化主动 SIGTERM,wrapper exit1,不能作最终证据;已确认自有旧 build 无残留并启动新标准 GUI smoke。 +- 三工作区既有 local 富草稿 fixture 五源正式采用;manifest `c170647d76df454e4d0e93c09512f8bd695af862d71dd659f2110742f98a54ab`、patch `f21034acd0b75c86f00b49fff666e39b94a159e592fc6e575e73d226eb155adb`,迁前 absent、迁后与 17 依赖 SHA 一致。正式 Node evidence tests 20/20、lint/格式/脚本治理通过。误用 Vitest 运行 node:test 文件产生 collection failure 的日志保留,随后使用真实 Node test runner;不改 runner/断言隐藏错误。 +- 新 GUI production source freeze 与 verification packet 在 `.lime/qc/composer-persistence/`,覆盖 renderer/desktop 输入及 fixture owner,仅为源码身份,不声称每个模块均加载。正常 quit 前必须等 savedRevision;三 local fixture 的真实冷恢复、失败保留、accepted/new-edit/跨 scope interrupt 仍待本轮构建后运行,不能冒用 Host receipt。 +- 默认 `verify:local` 已启动。冷 draftTab 发现与 Home/direct/materialized 三首发分别在独立 recovery candidate 准备,不 seed atom,不以三个 existing local workspace 冒充三首发。current fixture、真实 GUI 和默认门禁尚未关闭;整体 GUI 继续 partial。 + +- 本轮新标准 GUI smoke exit0,run `standalone-composer-draft-gui-20261015-final-1`,receipt SHA `77b6ac42c17732027cf03002c391e97f9bcca5c60e70183bf651d31bbeb35011`;2,606 production inputs / 989 目录产物(含保留旧chunk)零源码漂移,非全部模块加载声明。真实 local fixture 首两轮失败,PID99745/13174 均公开正常quit/code0/signalnull;测试oracle修正字符串版本、consume后null binding与disk exact tombstone、skill执行key和mention id分工。Node证据24/24,版本合同真实先红;skill-route intended-red命令在读取源前已修正,实际24绿,不能当先红证据。所有旧log保留。第三run、受影响rich current fixture和新默认130批仍进行中。 + +- 真实 current rich fixture 首轮揭示产品阻塞:无可见输出的 GUI Stop 后 input/path/image/skill 未恢复;日志证明恢复计划与 Workspace request 已到达,初步根因是 Stop 自己的 Controller.submit(interrupt) 递增 revision 使严格 token 过期。保留原恢复正向合同,独立跨 owner 回归/最小修复候选准备中,不能标记恢复通过。 +- local 第三/四轮都卡在第二工作区 sidebar:fixture 同名 workspace/session 触发 broad fallback 误点项目 header,第四轮分阶段/截图证据确认后改为真实 header 展开和 exact data-session-id row,并区分 workspaceName。PID19701/29741 公开正常退出。第五轮 freshness fail closed、未启动 Electron(typed page fixture 更新时间被当前 inspector 视为 renderer 输入);下一次统一真实产品修复后重建,不跳过 freshness。 +- 新 verify:local 在第4/130批 exit1,三个初始意图测试仍以 Promise mock 发送接受。只迁共享 index.testFixtures 的默认结果到 current typed accepted,原产品断言不变;index+autoGuide 27/27通过,窄lint通过。该 >1000行旧fixture本刀仅两行 import/return 合同迁移,不添加业务或测试抽象;后续拆分独立 mock owner。按质量事实源从第4批 test:resume,先前 app-version/五语言结构和unused/87源硬编码扫描/全src lint/标准typecheck已绿,但新 Stop 产品修复后须新 typecheck/定向/构建证据。 + +- 续跑第16/130批暴露另一处旧接受夹具:workbench01 明确以 undefined 表示派发完成。只迁默认接受结果与 pending Promise 类型到 typed acceptance,原可见预览断言保持不变;正式定向15/15、窄lint/格式通过,再从第16批续跑。旧 wrapper/两次失败日志保留,不能写 verify:local 单次全绿。 +- +tab 真实组件回归确认 React SyntheticBaseEvent 泄漏到零参数 onCreateTask,随后被 optional requestedProjectId 误解释。候选旧实现10通过/1失败,零实参适配后11/11;核before/after及8依赖后正式采用,定向11/11、lint/格式通过。related 曾因 Vite EISDIR 读 electron 目录失败,原log保留。后续公开入口审查确认 general GUI strip 不可达,组件修复不算真实 +tab 用户路线通过。 +- Stop 正式采用两源最小修复及跨owner回归:删除无用途 interrupt submit,仅调 onStop;候选同一测试4失败/6通过→10/10,正式4 suites121/121、标准typecheck和窄lint通过。严格opaque token/revision/scope/generation守卫不变。新标准GUI smoke `standalone-composer-stop-tab-20261015-1` 通过,仅证明shell/preload/IPC/AppServer/reload/三尺寸/settings;current-rich真实复验独立进行。 +- 当前root窄写集为上述产品修复、typed acceptance旧fixture迁移、一个Electron护栏、shared四源及本计划/研究索引。shared/guard正式receipt见 `.lime/qc/composer-persistence/shared-and-guard-formal-adoption.json`:81/81 Vitest、24/24 Node、lint/格式通过;误混runner的collection失败保留。Electron护栏精确分类test-support,并新增生产→测试层六类import风险回归;候选10失败/17通过→27/27,默认从26/131批续跑。未知release计划、local-conversation脚本及tsbuildinfo继续避让。 +- 冷draftTab真实缺口:同atom索引可发现metadata,但general Workspace的ThreadHeader/utility toolbar排除唯一TaskCenterTabStrip选择consumer,Home/侧栏新建也无可达标签。原cold四源不采用,shared四源独立采用;当前按REA确认公开恢复导航。三首发改Home/搜索新建/项目新建,用实际index+scope read唯一绑定logical identity,不猜UUID、不seed atom,也不证明冷选择或标签UI。完整输入freeze将按freshness声明目录记录superset,避免旧2,606子集冒称完整构建图。 + +- 2026-10-15 root 本轮窄写集:Coordinator refresh 一行、Inputbar Stop integration、rich/history 两 fixture、AppSidebar 与 regression、三个过期 current 护栏,以及 first-send 四叶/Home cold 三叶。第二 Stop 回归真实 Workspace local/home/draft 先3失败/14通过→17/17;自动空投影用 replaceDocument({}),不推进同一空正文 revision,人工 restore/history 和严格 token 保持。正式8 suites269通过/1trace超时;原5秒上限单独69/69复跑通过,不隐藏首轮失败。标准typecheck exit0。 +- Sidebar 真实第6轮卡第二项目:显示全部 opened projects,却按 active project 过滤查询。统一三处 conversationDisplayProjects,真实两返回会话/点击第二 row 回归先红后71/71;生产 AppSidebar 格式按现有 Prettier 规范,无其他逻辑。受控共享driver精确登记,默认旧promptHistory、restore叶分类、捕获workspace与DTO委托护栏迁 current;默认已续过75/131,继续原状态,不重头且不写 wrapper 单次全绿。 +- 首发资源增强四叶与Home冷恢复三叶 frozen SHA、before absent及全部依赖复核零漂移,正式采用收据 `.lime/qc/composer-persistence/first-send-home-cold-formal-adoption.json`;Node84/84、lint通过。首发必须同一 User Item完整ordered input和实际thread-scoped media/read字节;Home仅稳定 current scope,两正常Main进程/不materialize/文件与revision不变。均待真实Gate,不证明顶部tab/全草稿picker/pending精确竞态/live provider。 +- REA 设置7member/20 byte anchors收尾,source-only映射与next-slice冻结在 recovery/settings-navigation-audit-candidate。原worker仅独立准备单section owner、SettingsPage命名及消费者/tests清理候选;canonical路由/返回/search/dirty后置,正式采用与GUI验收0%。原freeze、失败log与未知release/local-conversation/tsbuildinfo继续避让。 +- root 全量复核src/electron/scripts/packages,仅composerSubmissionSettlement.ts遗留指向recovery候选的symlink;按正式send收据SHA `173a9c...` 原样落成普通文件。新 renderer-only build、标准 GUI smoke 均exit0,47/47结算/Stop/TaskCenter定向绿;只证明对应范围,完整富输入仍待实证。 +- 原rich1/诊断rich2均exit1;诊断确认token/Controller/Coordinator/revision/generation/scope全匹配,消费后identity重建触发binding_changed。两旧读时序先1失败/1通过,#retire递增read后五文件94/94、标准typecheck通过,strict guard/oracle不变。新renderer build exit0(6m7s);原rich3 `composer-stop-retire-read-20261015-3` exit0,common34/34、scenario21/21,Stop资源/marks、canonical input、history和折叠均过,console/page errors为0。freeze5前后7887source/1568artifact零漂移,packetSHA `989a1281…`;收据 `.lime/qc/composer-persistence/retire-read-rich-final-receipt.json`。受控backend,非live/冷恢复/Windows;local后续结果见下条。 +- local7卡code picker,fixture补same-session editable前提;local8拒绝保稿后Undo选区失败且正常quit收据失败。local9三rich准备后通过选区,却在Undo后code/mark断言失败,磁盘仅marker+image/path;Main59037正常code0/signalnull退出,freeze5前后零漂移。reject前后deepEqual未证明原稿基线仍在,fixture改为先用原JSON/DOM验证导航后的完整rich基线,并记录reject/Undo只读摘要;原恢复/marks/资源断言不变,Node24/24。local10原rich基线、reject前后精确保留、Undo恢复原payload/selection均证实;随后fixture重选变为collapsed,正文/marks未坏。Main74494正常退出,freeze6前后零漂移。fixture删除Undo后补marker与重选,改直接deepEqual baseline.draft与clean.draft,Node24/24/lint/格式过;local11用freeze7仅一fixture delta、产品/产物不变;第三workspace code picker trigger超时,截图空编辑器、未达Undo。Main82377正常退出,前后零漂移,静态确认visible B/bound A可编辑窗口,但缺时间线不能断言本轮命中;readiness候选以真实binding.ready和exact visible scope禁编辑,5个真实EditorView用例已正式test-first:旧实现5/5因contenteditable错误为true而红,无API缺字段失败;修复须等new-conversation采用。未达cold,Home/首发仍未执行。 +- 默认resume13/14在87/131批各211pass/1原5秒首例timeout,失败分别references与EmptyState slogan;references隔离9/9。不改timeout/skip/state,不写wrapper单次全绿。自有重负载结束后再续原状态;资源压力不是产品无缺陷证明。 +- 设置九源已正式采用(formal-adoption.json),单一sections/SettingsPage、重复名单与dead定义清理;架构4.4由root确认。正式六suite78/78、九源lint/格式通过;current15key验收脚本core/states10/10,真实broad smoke待验;canonical route/返回/search/dirty后置。Home冷恢复oracle三叶同步公开null scope与default业务workspace分离,Node33/33/lint/格式通过,JSON/selection/bytes/revision严格断言保持。 +- 新建会话冻结manifest d581ea83…/adoption patch b56508ba…、4986 before/3补充依赖/78变更/35suite最新收据已root复核;三capture omissions如实保留。正式78源采用、9删除,AppPageContent及其新测试按hunk组合SettingsPage,16旁路owner原字节保留,收据 .lime/qc/new-conversation/formal-adoption.json(a82be2c8…)。唯一startNewConversation/committed request/fresh CAS直接替换random/event/prewarm/materialized/general strip;架构4.3/4.5由root于2026-10-15确认。候选逐文件438/438、strict3187零诊断、53 Node/15 contract/lint/语法通过;最终因果red混含API缺失,不冒称纯行为证明。正式最新逐文件46suite540/540(非单次全绿),初轮538通过/2失败为Stop夹具缺visible local提交,修oracle后17/17+EditorView5/5,原log保留;lint/格式通过,标准GUI新构建进行中。 +- readiness 已正式采用:唯一 binding.ready + exact visible scope;正式最新逐文件46suite540/540,初轮538通过/2夹具失败保留。Stop fixture迁真实visible local提交,17/17+EditorView5/5通过,恢复守卫未放宽。标准typecheck/contracts/lint/格式/legacy/docs与GUI smoke(codex-navigation-readiness-20261015-1)通过。freeze8为7884输入/1673目录产物零漂移,不代表全部执行。local12三rich/拒绝/Undo/隔离/磁盘通过,旧oracle误要求jsonrpc字段;迁正确current omitted-version envelope,26通过/1行为失败→27/27。local13 trace通过,但直接evaluate(app.quit)缺Node debugger释放而等待超时;原Main/helper已退出,失败保留。公共Application.close生命周期回归21通过/1行为失败→22/22,不增加timeout或kill fallback。 +- local14使用freeze11:Main48987/52369均正常退出,首次进入冷恢复但A的route被删、revision27变28。根因是catalog空时fallback执行key与namespaced mention id比较;真实Inputbar先18通过/2行为失败,复用能力身份owner修复后四suite40/40,标准typecheck/lint/docs与新GUI smoke24/24(codex-skill-cold-20261015-1)通过。freeze12为7884输入/1778产物,packet76445a7e…;只有认领三源变化。 +- local15第一次cold三workspace完整JSON/DOM/资源/选区/identity与revision32一致;后段完整相等已通过,但test-only合法无capability摘要hash(undefined)而失败。nullable摘要先27通过/1失败→78/78;rich必需route/完整相等保持。freeze13仅两fixture delta、产物零漂移,packet651b552b…。local16全部exit0:三rich、拒绝保稿、原选区Undo、接受consume/new-edit/异workspace interrupt与两正常cold均过;Main34272/38556/40802均code0/signalnull,后验7884source/1778artifact零漂移。 +- Home1语言准备漏项已迁真实picker;Home2发现目录autoLoad=false而Home漏传onSkillSuggestionsNeeded,已贯通既有shared bindings/EmptyState/landing/scene回调,无新owner。两回归16通过/2行为失败→最新逐文件5suite140/140;组合138/140和首次隔离68/70的原5秒timeout均保留,最后隔离70/70,未增timeout/skip。标准typecheck、lint、renderer新构建和壳smoke24/24(codex-home-skill-demand-20261015-1)exit0;final-directed-receipt独立记录,不改冻结原收据。Home3/4完整rich已保存但首次正常quit15秒超时,Home4 teardown捕获code0仍不计pass;Home5正常退出,失败为oracle误要求直接preload helper出现在GUI safeInvoke trace。移除仅此错误要求,新回归先红后Node62/62、collector Vitest69/69,输入禁令/原生完整采集/冷零写不变;一次误用Node跑Vitest的失败log保留。freeze16仅两fixture delta、1883产物不变,packet41cf5bfb…。Home6已exit0:完整rich/资源/原选区/identity、两正常Main17922/18120、cold零写/无canonical会话回合、原文件bytes/revision不变;freeze16后验7884source/1883artifact零漂移;settings首轮freshness启动前拦截;最新15key/24首屏状态、157原生read、零console/page/trace error及Main22039正常退出已通过。首发1/2/3 Home失败:真实thread已建但无Turn,directDispatch rejected且null Composer恢复中;第3诊断确认displayContent覆盖canonical Markdown/hidden paste,使UTF8 atom remap正确拒绝。root窄写集为inputCapabilityRouting及test,正文优先原source、display仅回放/无正文fallback;真实回归旧实现2通过/1行为失败,保持remap守卫,五suite165/165、最终窄lint/格式通过,标准typecheck进行中;最新Stop在default会话侧栏恢复前因只读失败;root真实Wiring+Coordinator回归确认业务workspace与可见workspace不一致时两场均留home,2行为失败后composition改为把当前可见workspace传给既有commit handler,epoch/ready守卫保持。窄写集Wiring与新integration;root2/2/public11/11/navigation7/7通过,visible-ready4/5遇原5秒timeout待隔离重跑;最终root2/2、visible-ready隔离5/5与typecheck/lint/格式/renderer+Host构建/contracts/legacy/shell全exit0,原timeout保留。freeze19为7885source/1988artifact,较16仅三改一新增认领文件;packet5d23f655…,pre-GUI零漂移。first-send4 Home完整canonical富输入/资源/consume/new-edit全过,但Main85269正常quit15秒超时,失败保留且显式清理不计normal;first-send5 Home完整接受/消费/新稿及Main96352正常通过,search-new富稿保存冲突超时、Main96982正常teardown;旧probe tombstone存在、新rich未存。Coordinator真实connect/queued put/延迟clear回归110ms纯行为红,窄修pending discard期间禁止旧read重绑、完成失效在途read,Store CAS与retired守卫不改;认领Coordinator及binding test,七suite93项92过/1原5秒Home Stop timeout,隔离Stop20项14过/6原5秒timeout,均无新值/identity断言失败;binding15/15、Coordinator31/31、fresh7/7、navigation7/7、visible-ready5/5、send8/8、lint/格式通过。保留失败,Stop稳定性未关闭;标准typecheck/新renderer20m10s/全segment freshness/壳codex-queued-discard-20261015-1均exit0,收据queued-discard-directed-final-receipt.json。freeze20为7885source/2093artifact,较19仅Coordinator源/test两改,packet c060c3dd…。first6 Home与search全部富输入/资源/消费/新稿及Main85420/85963正常通过;项目click超时,原phase错误显示launch,实际已启动且Main86752正常teardown。fixture只补project header真实hover和source phase,Node53/53/lint/格式通过;freeze21仅该fixture delta、产物不变,packet86b048bc…。first7三入口完整业务断言均过,Home/Main93044与search/Main93526正常,project/Main94143初次quit原15秒超时后teardown正常,整轮fail。first8仅加被动退出计时/失败时自有Main采样,原期限/断言不改,三入口完整Gate exit0,Main99739/655/1318均正常,12.282/1.026/12.636秒;旧退出超时原因仍unknown,不写已修复。rich Stop原场景exit0,零console/page errors;Home7两Main6249/6729正常、完整富稿/选区/资源/identity与cold零写通过。local17三工作区拒绝保稿/原选区Undo/消费新稿/隔离与两正常cold全通过,Main7882/10853/11390均code0/signalnull。packet21前后7885source/2093artifact零漂移,产品收据queued-discard-product-final-receipt.json(2cafe26c…);原Stop文件在原5秒限制单次20/20(29.02秒/最长816ms);前两次timeout保留且不推断根因,async-tabs原筛选0/187被guard正确拒绝,进一步清已删ChatSidebar路径/旧标签筛选,迁canonical queue3+current侧栏/专用工作台11,强制完整计数,原入口14/14、lint/格式通过。仅test-only脚本变化,无产品/产物变化;current聚合前三组31/34/3过,guard293/294和collector隔离68/69原5秒超时保留;root仅认领scripts/lib/invoke-trace-collector.mjs及证据/文档,复用已比较相等窗口的摘要、独立oracle逐项重算不变,69/69、溢出用例1571ms;聚合第3轮第15场Skills完成后Composer禁用失败,前14场通过;非Composer接受回执已接既有可见topic commit,最终61/61与typecheck/lint/格式过;新renderer/Host完成,标准wrapper在V8缓存直连超时前未启壳,已用系统现有代理的子进程环境补齐锁定缓存并核官方SHA,sidecar准备及同一标准末端壳smoke均exit0;freshness全ready,freeze22为7885source/2198artifact、零源码漂移。分段恢复不冒称初次wrapper通过;packet22原Skills三路径及全部断言通过,收据imperative-session-skills-product-receipt.json;current第4轮exit0:四组定向31/34/3/294和22实际Electron场景均过,68份固定前缀产物/日志冻结到current-aggregate-4-complete、前后7885source/2198artifact零漂移;default第1轮删除五语言dead placeholder及迁loader/type夹具,42/42;第2轮严格lint揭示Wiring依赖warning,改捕获稳定函数,61/61及零warning;第3轮89批historyWindow原5秒2超时,隔离复现,CPU定位每文案重复合并namespace,改复用既有limeI18nResources,先3过1行为红→六suite81/81,原historyWindow14/14且用例/timeout未改。第4轮全src零warning lint/五语言/typecheck通过,89批147/147后通过至111批;112批122过1旧turn/start夹具失败,迁canonical id/inProgress及同thread identity,定向60/60与严格lint过,freeze27仅该测试delta;resume112通过至122批,123批90过2旧boolean/void夹具失败;复用既有typed accepted fixture并断言完整结果,原dispatch锁/富稿/新编辑/finalize覆盖保留,三suite24/24及严格lint过,freeze28仅该测试delta;resume123至124批通过,125批旧reset integration缺root callback且混同fresh/Home,删除重复旧文件并迁风险至现有root导航/Reset unit;本地富稿编辑保存/完整恢复及null/project返回委托16/16、lint/格式过;freeze29两测试改一删除、7884源;resume125—126通过,127批189过1旧curated usage fixture失败;Inputbar六success/一reject/一pending布尔夹具迁typed,完整Inputbar92+send hook21合计113/113及严格lint过,freeze30仅该测试delta,resume127待验。既有5231行测试仅做fixture窄改、不加业务分支,职责拆分另刀。contracts/legacy已exit0,新标准GUI构建及新产物current22待验。旧wrapper失败保留,按组合门禁记录;packet22旧current22不覆盖后续15源变化与1删除。packet19保留旧身份;整体partial,避让release/local-conversation/tsbuildinfo及媒体热区。 diff --git a/internal/exec-plans/release-v1.157.0-plan.md b/internal/exec-plans/release-v1.157.0-plan.md index 580b47b2b..1096aa8c7 100644 --- a/internal/exec-plans/release-v1.157.0-plan.md +++ b/internal/exec-plans/release-v1.157.0-plan.md @@ -93,3 +93,25 @@ - 第一份草稿by-tag REST读404、gh run view等待整个run结束才可读logs;改用release GraphQL view和completed job logs REST,未伪造质量通过。所有失败和取消日志保留。 - 精确fix回归39项与dictation draft纯逻辑10项通过;完整typecheck、ESLint、Prettier、38项预算通过。独立固定源码按 `--from-batch 70` 续跑到82/127无失败(运行中),后续全量Quality再次验证新source。准备正常提交测试修正和最新并行文档、推送main;旧tag仅在旧Release已取消、草稿未发布条件下按expected-old lease更新,重新生成所有产物。 + +- 修正commit `e61d6e87fbecd343d60e9d48c2b81a63caa7f11b`、tree `341719502027072321c09bb87ff850fed6cf414a`已正常hook(5文件/0失败)并推送main。final snapshot与独立index更新,产品源码和既有真实交互binary不变,test-only和最新文档变化已单独复验。 +- 旧Release最终completed/cancelled,所有publish job取消;再次确认draft=true、assets=[]、publishedAt=null。用exact expected-old update-ref及 `--force-with-lease=refs/tags/v1.157.0:73ccc7f484ae6dd4b823305cdfa74df2a7291240` 把未发布草稿tag更新到e61d6e87f,远端实读一致。 +- 新 [Quality 38543400684](https://github.com/limecloud/lime/actions/runs/38543400684) 与 [Release 38543774892](https://github.com/limecloud/lime/actions/runs/38543774892)的headSha均为修正commit;旧产物不会复用。最终审计以这两次run为准。 + +- 固定源码 `npm test -- --from-batch 70` 最终exit0,70–127共58批passed,1–69是显式跳过而非本轮通过;新guard/renderer另以39项定向覆盖。状态JSON保留 `frontend-ci-fix-resume-state.json`。不把续跑状态写成127/127全量,最终完整覆盖等待新Quality。 + +- 新Windows build已success;下载的installed packaged identity Gate B确认Lime.exe、App Server、CodeMode、desktop resource manifest和native host指向同一1.157.0/e61d6e87f/run38543774892安装,4类检查passed。Windows release日志实读无signing secrets,按现有合同产出unsigned Squirrel安装包,不声明Windows已签名。 +- 第一份Quality的Windows Shell Runtime已success,唯一failure为旧inputRestore guard;前后产品源码不变,5路径test/doc delta机器复核记录 `ci-fix-source-equivalence.json`。新Quality的Rust Full和GUI smoke均success,Frontend Full仍进行。新macOS arm64 build已success,Intel build等待完成。 + +- macOS arm64 packaged Gate B下载实审通过:candidate SHA/version/run ID匹配新源码,8类native检查passed,Electron检查passed,Developer ID、Gatekeeper accepted及stapleValid均true。Windows安装4类identity检查passed。 +- 新Quality的完整Frontend Full已success,Rust Full、GUI Smoke和Integrity均success,Quality results汇总进行中。Architecture Confirmation为PR专用,在本次push正常skipped;测试/文档修正没有Rust/Bridge产品变化,Windows/Bridge owner验证复用首轮同产品source结果,不冒充新run执行。 + +- 新Quality 38543400684最终completed/success,source为e61d6e87f;完整Frontend Full 126/126批通过(以远端日志为准,独立本地环境127批的分批数不替代远端)。Rust Full、GUI Smoke、Integrity及Quality results均success。发布工作流仍待Intel构建与全部资产/npm收口。 + +## Intel 信任检查重试与最终发布审计 + +- Release 38543774892 第1次尝试 completed/failure:macOS Intel 构建及资源检查通过,packaged Gate B 在 `xcrun stapler validate` 失败;前置 codesign 与 Gatekeeper 检查已执行。构建日志显示 ticket=stapled,但失败入口未输出 stapler stdout/stderr,暂不把原因归为 Apple 临时故障。失败日志保留,Intel 没有通过证据。 +- 失败后 GitHub Release 实读仍 draft=true、assets=[]、publishedAt=null,未对外发布该版本。按既有完整授权重跑同一源码的失败任务(attempt 2),保留所有签名、公证、Gatekeeper、staple 和 Gate B 门禁,不移动 tag。 +- 当前只认领本文和 `.lime/release/v1.157.0/` 忽略证据。tag 固定后的并行 GUI 研究、执行记录、ShellBackgroundTerminalList 测试及本地缓存保留,收尾证据提交不纳入这些后续改动。平台审计按实际产物所属 attempt 精确匹配 source/version/run identity,不把旧失败算通过。 +- 同一源码重跑的 Intel Gate B 已通过,Developer ID、Gatekeeper、stapleValid 均 true,8 项 native 检查与 Electron 检查 passed。三平台证据下载实审通过:Intel run ID 为 `release-38543774892-2-darwin-x64`,Apple Silicon 与 Windows 沿用第1次成功产物;source/version/各平台 run ID 精确匹配,原始失败证据保留。首次失败具体原因因缺少 stapler 输出仍未确认,不用后续成功倒推根因。 +- 11:04 UTC 左右检测到整个 `.lime/release/v1.157.0/` 忽略目录不在原路径,原因未确认;原本地日志/快照不能继续声称留存在该路径。固定源码临时副本仍存在,既有验证结论已记录于本文。后续审计改用独立临时目录 `lime-release-v1.157.0-audit-4w7u6u7s`,从 GitHub 重新下载三平台摘要和失败/成功日志,再以公开 Release/R2/npm 实读收口;不重跑已通过的重型本地构建,不修改并行写集。 diff --git a/internal/exec-plans/release-v1.158.0-plan.md b/internal/exec-plans/release-v1.158.0-plan.md new file mode 100644 index 000000000..56e6aa4bf --- /dev/null +++ b/internal/exec-plans/release-v1.158.0-plan.md @@ -0,0 +1,69 @@ +# v1.158.0 发布执行计划 + +状态:active。发布完成度:40%。目标:完成 v1.158.0 候选验证、提交、tag/main 推送及远端发布审计。 + +## 授权与候选 + +- 用户要求「发布 v1.158.0」,随后明确「不要让我确认,全部递交,并完成发布」。完整候选、必要修复、commit/tag/push 和发布复核均已授权,不再请求确认。 +- 起点 main/origin/main 和 v1.157.0 均为 `e61d6e87fbecd343d60e9d48c2b81a63caa7f11b`;远端 v1.158.0 不存在。 +- 初始工作树 347 路径:214 modified、8 deleted、125 untracked。全部产品、测试、脚本、文档和锁文件改动进入候选。 +- 沿用上版明确排除:`local-conversation-page-a83a1af2c24b.js` 为外部分析产物,`tsconfig.node.tsbuildinfo` 为增量缓存;保留本地,不删除、不提交。 +- 本轮直接写集:桌面/CLI/Rust 版本事实源、Cargo.lock、双语单页发布说明、本文。产品源码先只读检查与验证,必要修复记录精确路径。 +- pnpm lock 无 root release version,现有依赖变化一并纳入;Forge 与 App Server 继承版本,不引入第二份版本事实源。 + +## 发布内容与边界 + +- Composer 草稿通过 Coordinator -> Electron Host persisted atom Store/File 保存,覆盖会话隔离、新建导航、接受提交和停止恢复。 +- 代码块编辑支持语言选择、自动识别和高亮,结构化输入按接受结果消费或保留。 +- 设置章节、侧栏和媒体设置复用现有 owner,移除重复名单、旧草稿事件与已替代实现。 +- current:既有 Electron Host/renderer/Composer 与 Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item 主链;新增 compat/deprecated 为零;deleted 项由当前语义回归承接;受控 provider/fixture 仅 test-only。 +- 重大架构图的责任确认沿用 current GUI 计划所记录的 2026-10-14/15 root 确认及 architecture 4.3/4.4/4.5;本轮 metadata 不改变业务 authority。整体 GUI 对齐仍 partial。 +- KISS/DRY:复用版本、构建和发布入口;YAGNI/SOLID:不引入发布包装、兼容层或第二业务后端。 + +## 验证与退出条件 + +1. 版本统一 1.158.0,双语单页 notes;`verify:app-version` 与完整 `typecheck` 通过。 +2. contracts、发布/CLI/依赖及受影响 owner 回归按风险通过;已有默认前端 132 批证据按 lineage 记录,不冒充本轮全量重跑。 +3. 先 current Agent fixture,再标准 GUI smoke;版本变化后重建真实 Electron/sidecar 产物。 +4. 复核固定候选清单和源码漂移,正常 pre-commit,不绕过门禁;连续 commit/tag/main push/tag push,核对远端引用。 +5. GitHub Quality/Release、签名/公证、三平台安装/updater、CLI npm 实读收口;只触发 workflow 不算发布完成。 + +## 证据与进度 + +- 既有默认前端状态 `passed`,完成时间 2026-10-15T07:39:42.985Z;完整 lineage 与 current22 证据见 `codex-gui-alignment-current-plan.md`。仅证明原候选,版本变化后重新验证发布门禁与受影响路径。 +- 本轮命令日志、候选清单和远端审计材料放在忽略目录 `.lime/release/v1.158.0/`。 +- live provider、系统 IME/麦克风、Windows 本地 GUI 和安装包等价不以 macOS fixture 替代;远端平台结果分别审计。 + +## 固定候选与初轮验证 + +- 检测到并行导航/设置写集继续变化,停止本轮旧工作树 typecheck/contracts/current 聚合,仅终止本轮自有进程。不中断其它 Agent 或用户进程。 +- 独立索引固定 tree `cc30c9c3f50f95a57368834d3e4172f6a5d135e2`,358 改动路径;独立 detached worktree `lime-release-v1.158.0-source-4xw7dr7n` 验证,原工作区及索引不覆盖。冻结后的新改动属于后续候选,按差异保留。 +- 初轮版本、metadata 格式、CLI npm 9/9 通过。重点 owner 106/111、发布护栏 51/53;所有失败均为既有 5 秒超时,尚无业务断言失败,按单 worker 复验,不提高超时、不改断言。 +- 初轮 current 聚合仅历史/缓存组 31 项通过,后续因切换固定源码主动停止,不计完整聚合通过。 +- 固定源码的 pnpm 首次启动受本机旧 Corepack 签名 key 缺失阻断;改用 npm 临时缓存中的精确 pnpm@9.15.9,不改全局工具或跳过签名配置。 + +- 精确 pnpm@9.15.9 已可运行;为避免链接的共享 node_modules 被重装,改在仅含 manifests/lockfile 的临时目录执行离线 frozen lockfile 验证,exit0。 +- 固定候选首轮单 worker 201 项:200 passed、1 failed。旧超时全部消失;唯一失败是新增设置切章测试预期 undefined,而 current sections 明确以 `focus={context.executionPolicyFocus ?? null}` 清理执行策略焦点。仅在固定源码修正该测试为 null,保留 focus 清理、Provider 和同章保留全部行为断言,补跑 SettingsPage 与 useAppNavigation。 +- 本轮追加窄写集仅 `src/components/settings-v2/_layout/index.test.tsx` 的该单行断言;原工作区并行写集不夹写,提交固定候选后将后续 diff 保留。 + +- 固定源码完整 `test:contracts` exit0;protocol 1036 types 无漂移、IPC/command/harness/modality/script/release/desktop-reference/CLI/docs 所有门禁通过。 +- 设置单行断言修正后 SettingsPage/useAppNavigation 51/51 passed;首轮其它 13 文件通过,发布护栏 53/53、五语言代码块和编辑器均已在原 5 秒时限内通过。既有201项收据中的失败保留,不改写为全绿。 +- 固定候选已经包含设置 canonical 参数、返回实际会话与 rail Home 分离的并行实施,双语 notes 按实际候选同步;后续 source diff 保留,不直接夹入未经验证的版本。 + +- 发现其它进程也在构建桌面壳,已把本轮 Cargo target 改为独立 APFS clone `lime-v1.158.0-cargo-8ngd3xsm/target`,仅复用编译缓存字节,不共享 sidecar/rpath 可变产物。构建与 GUI 产物仍在独立候选目录。 + +- 固定 current 聚合前3组 31/34/3项通过;第4组299/300通过,唯一失败为trace collector的302次append覆盖超过既有5秒。单独重试仍68/69通过、同项超时;不改超时、不减append/断言。原聚合exit1,不声称完整wrapper绿。 +- 从原聚合源码提取未执行的Electron continuation,保留原build owner、全部子场景参数与断言;静态组由独立收据证明,临时runner只在忽略证据目录,不进入产品构建图。正在构建独立1.158.0 Renderer/Host/sidecar。 + +- 首次固定候选完整typecheck实际exit2:App.tsx两处仍调用已移除的setActiveAgentSessionId。导航已由useAppNavigation.agentLocation及reportAgentLocation持有,旧route->local session状态同步成为重复且失效owner。 +- 在固定源码窄修复App.tsx:删除旧session同步及仅供其使用的route变量/import,保留离开Agent时清理streaming;实际会话继续来自既有导航位置投影。不新增state或fallback。已停止本轮未完成的旧Renderer,只重建修复后的产物,重新运行完整npm run typecheck。 + +- App修复写回原工作区前以冻结blob逐字节确认未漂移;设置测试已出现并行变化,仅在独立候选保留修正,不覆盖原文件。两个修复文件的严格ESLint和Prettier通过。 + +- 本机两个重型编译同时运行推进过慢,已对本轮独立Renderer编译PID27407发送SIGSTOP,保留进度;先完成完整typecheck,再发送SIGCONT接续。未暂停其它进程,构建入口无超时放宽。 + +- 暂挂本轮Renderer、降低并发负载后,原trace超时用例按原5秒时限精确复验通过:1项执行、68项由过滤显式跳过,实际1585ms;未改测试、append数量、collector实现或断言。与前轮其余68项通过收据组成完整69项覆盖,原两份超时失败保留。 + +- 修复后完整 `npm run typecheck` exit0,Renderer与Node两部分均通过,日志`typecheck-frozen-fixed.log`。已向本轮Renderer PID27407发送SIGCONT恢复构建;不存在仍挂起的本轮编译任务。 + +- 修复后Renderer构建exit0(7081 modules,耗时包含暂挂等待),App Server client与Electron Host typecheck/build及桌面资源构建通过;独立Cargo target正在编译v1.158.0 sidecar。 diff --git a/internal/research/codex-desktop-rea/composer-code-highlight-audit.md b/internal/research/codex-desktop-rea/composer-code-highlight-audit.md new file mode 100644 index 000000000..a294e6364 --- /dev/null +++ b/internal/research/codex-desktop-rea/composer-code-highlight-audit.md @@ -0,0 +1,61 @@ +# Composer Code Block 高亮与自动语言 + +校准日期:2026-10-14。状态:有边界的静态链已确认,正式语言、高亮与 Auto plugin 已接线;定向回归通过,新 Electron 富输入 Gate74/74、current聚合22/22通过,默认前端127/127批与标准GUI smoke已通过;原wrapper失败保留,组合续跑步骤关闭。 + +参考 Codex Desktop `26.1007.30912` / build `20156`、bundle `com.openai.codex`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。完整审计、81 个原成员 UTF16 锚点及语言目录曾保存在 `.lime/qc/composer-code-highlight-audit/`。2026-10-14 用户释放空间时删除该目录,原件现已缺失;15 项产物的旧 manifest SHA-256 `35c932d1fb359d22320772c90587c429fadceb5cf94fe1b8f52c5efd46580cd7` 仅为历史记录,恢复版必须独立取摘要和验证。 + +REA Evidence `ev_32f87b5f925c87d58fbcfc530fbc03bc33536ab477882f98e9e2a9a70bbb150f` 只覆盖三个 inert 声明文件、13043 bytes、3233 AST nodes,parse failures 0,structural complete / semantic unknown。具体分支由原成员普通 Acorn AST 补证,五个成员摘要与 ASAR header integrity 一致。未执行参考应用、lazy loader 或 grammar,不能将该证据提升为运行准确率或全应用 reachability。 + +## 两个独立 plugin + +```mermaid +flowchart LR + Controller[唯一 ComposerController / EditorState] --> Highlight[高亮 plugin] + Controller --> Auto[Auto plugin] + Highlight --> Parser[Lazy parser] + Parser --> Decorations[UTF16 inline Decorations] + Decorations --> View[PM EditorView / code contentDOM] + Auto --> Detector[语言检测] + Detector -->|防迟到校验后 no-history attrs transaction| Controller + Registry[NodeView registry / toolbar Portal] -->|显式语言 transaction| Controller +``` + +`rich && code` 创建高亮与 Auto 两个 plugin;有效 plain mode 移除两者。NodeView 只更新 metadata、class 和 toolbar,代码正文仍属于 PM contentDOM,不使用 HTML 或 React 语法渲染器替换其子树。 + +高亮 cache 为 `Map`。事务映射删除失效位置,正文或 attrs 改变使 `node.eq` 不成立而清缓存;未变化但移动的块映射原 Decorations。parser 输出 HAST 后转为 inline Decoration,范围使用 UTF16 与 `nodePosition + 1`。plaintext 的空结果也缓存。 + +原成员补查区分了底层 parser 与主 Composer 的输入:`gRc`(initial `[10242007,10242330)`)采用 `BVr`(`[3561285,3561361)`)作为 languageExtractor,先按 UI canonical/alias 目录过滤,再处理 math/mermaid;plaintext 提前返回空 Decorations。`dRc` 默认接受 raw attrs,`SRc` 的 raw parser 才拥有 53 grammar 和 5 组专用别名。完整注册 raw parser 不意味着主 Composer 也放开额外语言。HAST 到 PM 的参考投影只把 root element 的完整范围转为 inline Decoration,不递归生成内层 scope;grammar 的嵌套 token 能力与主编辑器的显示投影分别验证。上述结论来自同身份原成员普通 AST,不新增 REA semantic 或运行结论。 + +异步 parser 完成后刷新当前 EditorState;不将捕获的旧文档 Decorations 写回。pending Set 与 `view.isDestroyed` 约束 dispatch。上游 parser loader 失败固定使用无高亮 fallback;detector loader 失败会重置后允许再次请求。两者恢复策略不同,不能概括成统一自动重试。 + +## Auto 事务与失效 + +每个 View 的 `WeakMap>` 按节点 identity 缓存检测。初始化调度,文档变化后延迟 150ms;捕获 generation 和文档对象。等待 detector,以及必要时等待 parser 后,均重新确认 generation、同一文档对象和 View 未销毁。 + +仅处理 `spec.code === true && autoDetectLanguage !== false` 的节点。回写前重新检查当前位置与属性,以 `setNodeMarkup` 保留正文及其余 attrs,并设 `addToHistory=false`。新节点 seed 检测缓存,避免自动 metadata 回写重复检测。当前 selection 所在代码块从非空清为空时,映射旧文档确认身份后清 `language`;不无条件清扫全部空块。 + +自动 attrs 会改变文档;高亮 Decorations/meta 本身不会。Lime 必须分别验证 draft revision、发送 receipt 和 undo,不能由无历史标记推断这些接线已正确。 + +## 语言事实源 + +| 集合 | 数量 | 用途 | +| ------------------------- | ---- | ----------------------------- | +| Picker languages | 41 | 工具栏显示,保留源顺序 | +| UI aliases | 21 | trim/lowercase 后归 canonical | +| Auto subset | 39 | Picker 排除 math / mermaid | +| Parser grammars | 53 | 高亮能力,不自动扩大 Picker | +| Parser extra alias groups | 5 | parser 专用别名 | + +Auto 决策顺序:空白返回 null;URL、headers、log 形态返回 plaintext;JSON/XML 和 15 条有序语言启发式优先;不足 12 UTF16 字符且无命中返回 plaintext;其余 `highlightAuto` 限定 subset,只有 relevance ≥2 接受,异常或未识别回退 plaintext。具体目录与正则保存在本机 `catalog.json`,不能把静态规则例子当实际执行输出。 + +Auto 选择写 `language=null, autoDetectLanguage=true`;Plain text 写 `language=plaintext, autoDetectLanguage=false`;显式语言关闭 Auto。math 高亮归 latex,mermaid 没有高亮映射;不据此增加公式或图表预览。math / plaintext display label 与语言选择器一起覆盖五语言。 + +## 实施与验证边界 + +唯一 owner 仍为 `src/components/input-kit/`。语言目录与规范化复用一个 owner;高亮和 Auto 保持独立 plugin;Registry / NodeView / Portal 只消费同一 Controller 与 View。新增描述性类名属于 Lime 实现命名,上游原开发名称仍 unknown。 + +验证需要真实 parser token spans、UTF16 范围、移动块映射、正文与 attrs 失效、plain mode、manual false、150ms debounce、迟到结果、销毁、restore、undo、资源与 receipt。明确 disabled / IME 策略,这是 Lime 的保护选择,上游外围保护未确认。真实 Electron 必须显示 token、操作 Picker、继续编辑、发送同一 canonical 内容并恢复 attrs;仅 `language-*` class 或 label 不足。 + +参考实时检测质量、core tie-break、全部 grammar 语义、实际 loader 恢复、系统 IME 和 Windows 仍 unknown / not-run。静态研究完成不等于产品对齐完成。继续执行见[当前计划](../../exec-plans/codex-gui-alignment-current-plan.md)。 + +正式迁移的直接依赖锁为pnpm-lock.yaml(pnpm9.15.9);lowlight3.3.0仅提供HAST,highlight.js11.11.1独立core检测,jsonc-parser3.3.1校验JSONC。新fresh rich-1 Gate实测Auto检测JavaScript→Plain无token→explicit TypeScript真实const token,语言动作不发input;后续编辑/Send/cancel/history保留正文和typed范围。构建护栏现追踪真实pnpm lock/workspace输入,先红2项后30/30,不能复用锁变化前产物。证据及完整覆盖限制见执行计划;53 grammar的全部参考语义仍未逐项实测。 diff --git a/internal/research/codex-desktop-rea/composer-code-nodeview-audit.md b/internal/research/codex-desktop-rea/composer-code-nodeview-audit.md new file mode 100644 index 000000000..1683f9ca7 --- /dev/null +++ b/internal/research/codex-desktop-rea/composer-code-nodeview-audit.md @@ -0,0 +1,68 @@ +# Composer Code Block 的 NodeView 与语言选择器 + +校准日期:2026-10-14。状态:参考静态合同已确认;Lime 正式 NodeView、Registry、Portal 与两项 plugin 已迁入,定向验证通过,新的真实 Electron 富输入 Gate 74/74、current 聚合22/22;默认前端127/127批与标准GUI smoke已通过;原wrapper失败保留,组合续跑步骤关闭。基础 schema 历史 Gate 不能替代本切片。 + +参考身份复用[主输入审计](./main-composer-audit.md):Codex Desktop `26.1007.30912` / build `20156`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。本次重新核对三个原成员的摘要: + +| 成员 | SHA-256 | +| --------------------------------------- | ---------------------------------------------------------------- | +| app-initial-6350d04672d0.js | 604b5d2de4fbb41f3d0dc5b3662129c558e1c7b8aac8cc87225286f58dcfaf8f | +| app-primary-6f60e9ac4103.js | 7fe9c00b03cb1de3ebc3a4bf298fc44b13a7fe67ca9cc335a9e2ecda969a1321 | +| local-conversation-page-a83a1af2c24b.js | f6275448cc603026ca3567e60a8c48d77d2b24cdc431c882280bbaa84b6010a0 | + +REA Evidence `ev_bf209739980b2025edc530e2be3a7758959de28623928c7cb1af33d1ce38308f` 分析三个 inert 声明文件,15761 UTF8 bytes、4935 AST nodes、parse failures 0。structural complete,semantic unknown,1005 个保守 unknown;无完整模块 bootstrap/imports、原应用 reachability 或运行证明。具体调用分支由完整原成员的普通 Acorn AST 补证,不冒充 REA semantic 结论。压缩局部名不是上游原始开发名称。 + +完整审计、86 个原 UTF16 半开区间锚点、slice identity 和测试矩阵曾保存在 `.lime/qc/composer-code-nodeview-audit/`。2026-10-14 用户释放空间时删除该目录,原件现已缺失;旧 manifest SHA `4900789d…` 仅为历史记录。本页保存继续实施所需的仓库事实;恢复候选重新取摘要和验证,原成员不复制到生产源码。 + +## 主路径与 owner + +```mermaid +flowchart LR + Provider[主 Composer Provider] --> Controller[唯一 Controller] + Controller --> Registry[Code Block NodeView registry] + Registry --> NodeView[toolbar + pre + code contentDOM] + Registry --> Portal[React Portal 语言选择器] + Portal -->|setNodeMarkup transaction| Controller + Plugin[PM highlight plugin] -->|DecorationSet| NodeView +``` + +主 Provider 调用(primary `[1426982,1427148)`)只有 composerController/defaultText/defaultTextKind/initialDocument/onDocumentChange/onDocumentDispose/children,不含 controllerOptions 或 spread。自建分支(initial `[11203980,11204238)`)启用 rich/code/selected-text-links,plain text 来自 setting。外部 Controller 分支提前返回,自建 defaults 不适用于它;外部初始 options 仍 unknown。 + +Controller 的 `cBc` 条件为 `enableRichText && enableCodeBlocks && !plainTextMode`;两处 nodeViews 装配在 PMU entries 后覆盖 `code_block`。registry 构造为 `new jRc()`,没有 options。PMU fallback、用户 setting 的真实取值和参考应用运行效果未验证。 + +registry(initial `[10245611,10246515)`)持有 NodeView entries、稳定 cached snapshot 和 listeners。createNodeView 登记;update 发布新的 entry;destroy/unregister 注销并通知 Portal 清理。UI 仅消费 registry,不持有第二份代码正文。 + +## 编辑 DOM 与事务 + +NodeView class(initial `[10246520,10251404)`)的结构为: + +```text +div[data-composer-code-block="true"][data-composer-code-block-wrap="false"] + div[data-composer-code-block-toolbar="true"][contenteditable="false"] + pre[dir="ltr"] + code[spellcheck="false"].hljs[.language-*] +``` + +code 是 PM `contentDOM`;toolbar 的 mutation 和事件被隔离,代码编辑仍交给 PM。update 对 foreign node type 返回 false;同 type 更新 attrs/entry/外层展示。NodeView 只更新 class/dataset,不通过 innerHTML、textContent 或 React syntax renderer 替换 contentDOM 子树。 + +`selectLanguage`(initial `[10250586,10250955)`)重新查询 getPos 和当前 node,失效位置/非 code node 直接返回;用 setNodeMarkup 保留其余 attrs、正文和 selection。Auto 写 `language=null, autoDetectLanguage=true`;选择 null 的 Plain text 且 schema 支持 auto attr 时写 `language="plaintext", autoDetectLanguage=false`,不支持 auto attr 才保留 null;显式语言写该语言并关闭 auto。不从 submission 的省略规则推导 UI label。 + +主 Portal 只观测到语言选择器:搜索、Auto、Plain text 和语言项。`getWordWrap` 默认 false,缺少 wordWrapAttribute 时 setter 无效。本主路径没有 wrap/copy/preview 控件证据,不将其他 surface 的配置搬入主 toolbar。语言 catalog/alias 和 Auto 回写的静态链已在[高亮补证](./composer-code-highlight-audit.md)闭合,实际运行效果未验证。 + +## 高亮与命名边界 + +`dRc`(initial `[10239875,10240828)`)由 PM plugin state/cache/decorations/promises/refresh meta 承接高亮,props.decorations 返回 PM DecorationSet。NodeView class 的 hljs/language-\* 只表示展示类别,不能单独证明 parser 已高亮。[高亮补证](./composer-code-highlight-audit.md)已确认按位置与节点相等性失效的 cache、UTF16 Decorations、独立 Auto plugin 和防迟到事务;正式产品已接线,实际 token、revision/receipt 和五语言回归已通过;新富输入/current进程证据与组合默认步骤已关闭,限制见执行计划。 + +`CodeBlockNodeViewRegistry`、`CodeBlockNodeView`、`CodeBlockToolbar` 是 Lime 的实现命名;上游原文件/类 descriptive names unknown。可对齐原成员可确认的方法属性:createNodeView/getSnapshot/subscribe/unregisterNodeView/publishNodeViewUpdate/destroy/ignoreMutation/stopEvent/update/selectLanguage/getAutoDetectLanguage/updateCodeElement/focusEditor/getContent。不得迁入 jRc/MRc/XBc 等压缩符号。 + +## Lime current 实现与验证 + +继续使用唯一 input-kit Controller/EditorView/Transaction owner。registry 放在 Controller 生命周期;Portal 绑定实际 View 的 toolbar entries,View 销毁后移除;禁第二编辑器或资源 store。语言事务不得推进无变化 revision、丢失富草稿、使迟到 receipt 清掉新输入。 + +基础 schema 的 pre/code DOM 可服务 serialization/clipboard;主编辑器的旧 `pre[data-composer-code-block]>code[data-language]` 观察必须迁移到上述 NodeView DOM。测试不为旧结构保留兼容分支。共享样式采用 Lime theme tokens,迁移时删除重复规则;多模型与首页 Banner 继续既有 owner。 + +验收覆盖 registry 稳定 snapshot/通知/注销、两 code blocks、语言事务与 undo/redo、stale getPos、disabled、toolbar 与 content mutation 隔离、正文/selection/IME/paste、五语言可访问名称、Portal 与 listener 清理。高亮若接线必须独立证明 token range、Decoration mapping、language 切换、异步过期结果和销毁后无 dispatch;Auto 检测结果不能由静态 label 代替。 + +真实 Electron 用新 DOM oracle 验证语言切换、继续编辑、Send、canonical readback、history/cancel 和无额外 input,重新冻结源码与实际 renderer/Host/sidecar。主题、多模型、Banner 是保留差异;Windows、系统 IME、原生录音和安装后的应用验收分别登记,不以 DOM synthetic events 替代。 + +2026-10-14 正式接线:`src/components/input-kit/` 的 Controller 唯一Registry、PM contentDOM、React Portal和独立highlight/Auto已应用;旧主DOM观察与历史fixture直接迁移,无compat双轨。真实五语言资源测试先红5项,修正为agent namespace后5/5;Toolbar与主Editor接线合计41/41,相邻消费者141/141、fixture189/189。fresh rich-1 Electron Gate74/74确认可信语言选择、正文高亮、单次发送、cancel/history与同canonical输入,证据与45项源/464项renderer/Host产物摘要关联。该结果属于controlled external backend与macOS Electron,不证明参考应用运行、OS IME或Windows;current聚合及default关闭状态以执行计划为准。 diff --git a/internal/research/codex-desktop-rea/composer-draft-persistence-audit.md b/internal/research/codex-desktop-rea/composer-draft-persistence-audit.md index 8c8cd0464..0f8edfa4c 100644 --- a/internal/research/codex-desktop-rea/composer-draft-persistence-audit.md +++ b/internal/research/codex-desktop-rea/composer-draft-persistence-audit.md @@ -1,6 +1,8 @@ # Composer 草稿保存补证 -状态:current / 静态结构已查证,Lime 产品迁移未实施。日期:2026-10-14。 +最新修复(2026-10-15):current 聚合第15场发现 Skills 试用完成但 Composer 仍禁用,程序化初始发送未提交真实新 session 绑定。既有 CommandWiring 现将 canonical accepted 回执交回可见 topic commit;真实集成先红后修,61/61、标准 typecheck/lint/格式通过。新产物的原 Skills 场景三条路径及 Electron 壳均通过,完整 current 聚合22/22及4组定向全部通过,默认门禁正按原失败批次续跑。源码/产物身份见 packet22;不能以 packet21 四合同覆盖新修复。定向与构建收据分别为 `.lime/qc/composer-persistence/imperative-session-scope-repair-directed-receipt.json`、`imperative-session-build-recovery-receipt.json`。 + +状态:current / Host、同源索引、Coordinator、Controller、typed acceptance 与公开新建导航已采用。packet21 的三入口完整富输入首发、rich Stop、Home 冷零写、三工作区两次正常冷恢复均通过;后续 packet22 接线修复按上段单独验收。旧 general 随机 draftTab/discovery 已删除,参考应用是否有全草稿 picker 仍 unknown。整体 GUI 为 partial。下文迁移前缺口和逐轮失败保留为历史证据,不作为当前待办。状态校准日期:2026-10-15。 沿用 [证据索引](evidence-index.json) 的 Codex Desktop 版本、ASAR 摘要及已提取 primary/initial/main 成员。本次仅从同一 ASAR 提取 `app-shared-8bd9d0399038.js`,SHA-256 为 `607ad0866dd25ed3764711b4467a9b60708353a599485b5249ff4cbe0e870245`;没有运行参考应用或 bootstrap。 @@ -57,26 +59,132 @@ main 原成员 `.vite/build/main-CqEoQDOU.js` 的 SHA-256 为 `09d19386e1f23cf4c 这两个 key 未直接写图片数组、路径资源数组或完整 attachment view state。相关资源有独立的 `DXr/NV/TXr` 等 owner,未追踪其完整保存链;不能据此宣称 Codex 附件不能持久化。 -## Lime 缺口与接点 +## 迁移前缺口与接点(历史基线) -当前 workspace 内唯一 Controller 由 `useAgentChatWorkspaceLocalDisplayState` 创建,跨 Home/Inputbar 共享,但无 scope key、keyed draft map 或持久保存。导航通常复用同一 workspace mount;离开 Agent 页和重启会销毁实例。`TaskCenterDraftTab` 仅有 ID、标题、时间和状态,是导航 metadata。`agentSessionScopedStorage` 存 transcript/read model,`promptHistory` 存已提交正文;两者均不能充当未发草稿 owner。 +本节保留迁移前问题与接点,不能作为当前缺失清单;正式采用状态见文末。 -首发转绑定有两条 current 路径: +研究初始 workspace 内唯一 Controller 由 `useAgentChatWorkspaceLocalDisplayState` 创建,跨 Home/Inputbar 共享,但无 scope key、keyed draft map 或持久保存。导航通常复用同一 workspace mount;离开 Agent 页和重启会销毁实例。`TaskCenterDraftTab` 仅有 ID、标题、时间和状态,是导航 metadata。`agentSessionScopedStorage` 存 transcript/read model,`promptHistory` 存已提交正文;两者均不能充当未发草稿 owner。 + +迁移前首发转绑定曾有两条路径;materialization已删除,以下仅为历史基线: - 预热/显式 materialization:`useTaskCenterDraftMaterializationRuntime` 记录 draftTabId→newSessionId,`useTaskCenterDraftSendDispatchRuntime` 在 dispatch 前提交导航转换;有 acceptance callback 时 preserveInput 延续 revision。 - 直接首发:`useTaskCenterDraftSendRuntime` 将 sourceDraftTabId 与正式 session 交给 `useWorkspaceTaskCenterSendRuntime.handleNonMaterializedSessionReady`;无 draft tab 的首页分支也必须覆盖 home→session。 sessionId 与 canonical threadId 必须分别处理。`appServerSessionClient` 的 `thread/start` 返回 sessionId,`useAgentSession` 从 read model 建立 thread_id 映射。首发持久 scope 转绑定不能调用 restoreDraft 改动尚待 acceptance 的 revision。 -最小现有 JSON 封装为 `agentChatStorage.loadPersisted/savePersisted`;Chromium sessionData 根由 `electron/appDataPaths.resolveHostSessionData` 统一处理 macOS/Windows。当前没有通用 DesktopHost JSON KV 合同;如对齐 main persisted store,应扩展既有 Host 边界并同步 IPC/preload/gateway/catalog/tests,不能借用 model-provider 的 UI state 或建立第二业务 runtime。`savePersisted` 当前在 try 外 stringify、无成功结果且 quota 失败会删除旧值重试,不能直接用作已保存证明。若选用该封装,最小合同应为 try 内序列化、拒绝 undefined、单次 setItem 返回成功状态、写入失败保留旧值;草稿 owner 必须消费返回值。`isStorageQuotaError` 仍服务 transient storage,不能随此迁移删除。 +最小现有 JSON 封装为 `agentChatStorage.loadPersisted/savePersisted`;Chromium sessionData 根由 `electron/appDataPaths.resolveHostSessionData` 统一处理 macOS/Windows。研究初始没有通用 DesktopHost JSON KV 合同;本轮已采用 PersistedAtomHost/Store/File、纯 JSON protocol 与唯一 persistedAtoms gateway,以下是迁移前选型约束:如对齐 main persisted store,应扩展既有 Host 边界并同步 IPC/preload/gateway/catalog/tests,不能借用 model-provider 的 UI state 或建立第二业务 runtime。`savePersisted` 当前在 try 外 stringify、无成功结果且 quota 失败会删除旧值重试,不能直接用作已保存证明。若选用该封装,最小合同应为 try 内序列化、拒绝 undefined、单次 setItem 返回成功状态、写入失败保留旧值;草稿 owner 必须消费返回值。`isStorageQuotaError` 仍服务 transient storage,不能随此迁移删除。 候选清理:scope 导航的 reset+setInput 改为唯一 owner 的保存旧 scope、切绑定、恢复新 scope;显式 clear 和 acceptance 后条件 commit 仍须分别处理,避免无条件写空覆盖旧 scope。具体实施必须先登记 Verification Contract 与架构图。 -接受结果还需要按发送时 scope/version 消费已接受的旧草稿:导航后 `Controller.commit` 因 revision 或 scope 变化返回 false 时,不能让已发草稿在下次启动复活。首发 scope promotion 仅转绑定,不推进等待接受的 receipt revision;materialized/direct/home 无 draft tab 三条消费者路径分别回归。session identity 与 canonical Thread identity 分开记录,不用已有 provider UI state 承接草稿。 +接受结果还需要按发送时 scope/version 消费已接受的旧草稿:导航后 `Controller.commit` 因 revision 或 scope 变化返回 false 时,不能让已发草稿在下次启动复活。首发 scope promotion 仅转绑定,不推进等待接受的 receipt revision;现有Home/搜索新建/项目新建均沿唯一发送链分别回归。session identity 与 canonical Thread identity 分开记录,不用已有 provider UI state 承接草稿。 + +## Renderer 接线设计(2026-10-14) + +本节是 Lime 的实施合同与 current 源码审查,未声称参考应用运行行为或 GUI 已完成。迁移前只读映射覆盖55个源码文件、134个锚点,全部摘要零漂移;43个验收场景。隔离设计manifest为 `7085719e0e5982acf7dfe25385cb307de06ed9d8ab3b137bcc660568b5addcc4`。Host13文件121项、gateway/fixture4文件70项、Main/IPC/诊断接线16文件80项属于迁前隔离验证记录,不覆盖后续正式源码身份。Host、索引、typed acceptance、coordinator 与 root 导航现已正式采用,后续验证见文末。 + +保存 owner 固定为 Main 同一 `composer-drafts-v1` atom;renderer coordinator只持有投影、绑定generation和待发mutation队列,唯一可编辑文档仍归Controller。renderer250ms、Main500ms分别合并;发送不await保存、无checkpoint或Quit握手。Main广播仅metadata,update应用成功与savedRevision覆盖必须分开。 + +### 冷启动的同源索引 + +2026-10-15 公开入口复核:以下索引能发现草稿 metadata,但目前唯一选择 handler 经 TaskCenterTabStrip,general Workspace 的 ThreadHeader/utility toolbar 条件排除该 strip;Home/new-task 与侧栏新建也不保留 strip 条件。因此“索引接线”不等于用户可以选择重启后的草稿。原 cold fixture 的 Home → +tab 路线不可达,不采用、不恢复旧 strip;下一刀依据同版本 REA 确定公开恢复导航。三首发改用 Home、搜索新建和项目侧栏新建,通过同 atom index 与 scope read 唯一关联实际 identity,单独验收逻辑草稿发送,不声称冷选择或标签 UI。 + +同版本新增 REA `ev_8109ba0f2d675fb08989285ee7408e675b5a2d4953513f757c11e959c4934ab5`(5 member / 51,330 bytes)及 `ev_5872c498b099311cff8f5d0c5d7ca8b99dae35efcceb3f42eeabd3eb526e9c47`(27 inert declarations / 14,506 bytes)均 parse failures 0、structural complete、semantic unknown。可确认导出名 `startNewConversation`,主侧栏 New chat 调用该领域动作;`draft-thread-identities-v1` 仅 home/panel 当前 client identity,`client-thread-bindings-v1` 绑定 canonical 后清掉匹配旧 identity。恢复 gate 来自主窗口 persistence owner,不能扩展成所有窗口无条件恢复。所有历史未发送草稿的侧栏 picker 仍 unknown,不据此新增 Drafts 列表。 + +下一步产品迁移应统一公开 `startNewConversation` 入口并清理隐藏随机 draftTabs 双轨;当前最小真实验收复用既有稳定 home scope、Main/Coordinator/Controller,验证正常重启后公开 Home 恢复完整当前草稿。此方案与未发送多草稿 picker 的缺口分开记录,不能仅凭索引测试关闭后者。 + +迁移前草稿标签仅 `useState([])`,重启后不知道旧draftTabId;按已知scope单读不足以完成冷恢复。当前 `useWorkspaceComposerDraftIndex` 已消费同一 atom 的 workspace-index query,发现 metadata 并投影草稿标签。read 合同已直接迁移为判别 query/data,不保留 scope-only 兼容入口: + +- `{key, query:{kind:"scope",scope}}` 返回 `{outcome:"ready",data:{queryKind:"scope",view},...metadata}`。 +- `{key, query:{kind:"workspace-index",workspaceId}}` 返回 `{outcome:"ready",data:{queryKind:"workspace-index",workspaceId,items},...metadata}`。 +- item只有 `scope/identity/updatedAt/sessionId?/threadId?`;无正文、附件或标题摘要。invalid/unavailable返回data:null,不能解释为空存储。 +- Main按workspace精确匹配(含null)、当前binding指向state=draft过滤,排除consumed/tombstone/孤儿。确定性排序,沿10,000记录/绑定预算返回完整metadata集合,不静默截断。 +- 索引读取不改storeRevision或updatedAt,不做CAS或占record预算;它只用于发现。选中后再读scope并复核identity/version,后续put/promote/consume仍执行原CAS。 +- 唯一React持久hook投影draft标签,标题使用五语言默认文案;活动选择与关闭收起状态不被迟到索引强制覆盖。不另建localStorage标签索引。 + +### 明确接受结果与导航 + +| 迁移前缺口 | 最小迁移合同 | +| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| threadClient丢弃startTurn真实response,adapter/stream/chat多层Promise,Workspace将await包装为true | API保留真实response,在小execution边界投影typed runtime acceptance,透传实际captured sessionId/threadId/turnId | +| queue已有QueuedSubmission,InputbarScene丢弃后返回true | 保留真实queuedSubmissionId/clientUserMessageId及session/thread身份,不伪造turnId | +| steer响应已接受,后续refresh失败仍整体报发送失败 | receipt在post-processing前结算,projection错误独立上报,不撤销接受事实 | +| slash review/diff/init发送prompt后丢receipt;本地help/confirmation/shell也以true/void表示 | prompt分支透传真receipt,本地handled独立分类;true、void、unknown、仅dispatched不能消费草稿 | +| Dispatch卸载先settle false,真实sendPromise之后仍可能accepted | 请求closure中的onRuntimeAccepted先通知存活coordinator,UI卸载只解绑可见更新;callback/return重复结算幂等 | +| direct/home迟到回调重读activeSessionIdRef | promotion使用请求捕获source和response实际目标,不使用当前global ref | +| TopicNavigation/ResetRuntime/Materialization/DraftState导航前无条件reset | before只capture/enqueue旧scope,导航实际committed后bind/restore;blocked/deferred/error保留旧binding | +| interrupted restore仅WeakMap防重复,缺scope和编辑guard | UI恢复envelope携带captured logical identity/scope,核实当前编辑后恢复,不放进AppServer wire | + +上表左列是迁移前快照;typed acceptance、opaque restore token 和导航 owner 已直接采用,不能继续列为当前源码缺失。existing local和稳定Home正常冷恢复已通过,公开三首发仍pending。发送同步捕获logical draftId/version、scope、Controller receipt和未发布payload;后台队列固定captured put在consume之前,业务请求立即继续。明确accepted后跨导航消费旧identity,即使当前Controller.commit因新scope/revision返回false;当前UI仅同logical binding与receipt匹配时清理。promotion只移动绑定,不改draftId/version或receipt revision。materialized、direct draft、无draftTab的Home三条分别验收。 + +hydrate/navigation restore、accepted commit及clear导致的Controller通知不生成普通put,避免程序清空又创建空草稿。明确用户clear写tombstone,关闭标签只收起导航;消费记录不自动驱逐,迟到put不能在absent恢复旧draftId。容量、无效codec或IO失败整次拒绝保旧,不阻塞业务发送;只有实际savedRevision覆盖后才宣称已接受版本不会在冷启动复活。 + +实施顺序为Host基础接线与正常重启 → 同atom索引与typed acceptance → coordinator/codec及导航 → 发送三路径与冷恢复GUI Gate。typed acceptance core与surface已正式迁入;coordinator、codec、导航及发送restore token也已正式采用。root React与项目导航组合owner已正式接线;existing local与稳定Home正常冷恢复已通过,公开三首发仍在验证,不新增void-compatible结果双轨。 ## 未知与边界 - Codex 真实退出/重启效果、main500ms与renderer250ms待保存窗口的强杀耐久、账户切换清理未验证。globalState文件及静态原子替换链已补证。 - 两个 key 的静态形状不能证明跨重启图片、外部路径和完整附件恢复。 -- Lime 尚未实施 scoped persistent draft;现有内存共享与 history Gate 不关闭这个缺口。 +- Lime existing local与稳定Home正常冷恢复已取得独立Desktop Gate B,local含发送消费;公开三首发、最新产物Stop和默认门禁仍待验。 - 原始 Evidence、切片 manifest 和 AST observations 留在本机 REA 分析目录;仓库只保存身份、合同、映射和限制,不纳入参考应用源码。 + +## 基础 Host 正式迁入(2026-10-14) + +37源已直接迁入:`PersistedAtomHost/Store/File`、纯JSON protocol/types、唯一`persistedAtoms` gateway、Main/IPC/preload/event policy、safeInvoke/HTTP诊断、catalog/ESLint/contract guard与公开fixture/driver。参考100/200/400ms rename retry和独立test-root两个overlay一并采用,迁前后before/final SHA全部一致。原隔离包保留冻结,正式证据单独记录。架构与命令事实源同批更新,由root确认Host UI保存与canonical业务链分离;Main只组装/委托/dispose,无新增CAS或落盘业务。 + +基础迁入阶段曾使用 scope-only read;后续同源索引已直接替换为 query/data,旧 wire 删除。基础 Host 与索引的正式回归、公开IPC及 saved 后正常新PID重启已通过,详情见下一节。coordinator、codec、typed acceptance、metadata冷标签与root导航现已正式采用;这些基础或领域回归不能计为 GUI 冷恢复产品验收。不存在生产mock、第二runtime或compat包装;dispose不等待flush,pending立即退出与强杀仍未验证。 + +## 基础 Host 真实验证与索引直接替换 + +基础正式定向组合307项、contracts/typecheck/窄lint/格式通过,标准Electron GUI smoke exit0。公开IPC修后真实15场景通过,36次调用/7原生metadata通知,revision7;PID96100和96356分别正常code0/signalnull退出,新PID读回scope/identity/version/正文摘要一致,tombstone跨重启防复活。fixture起初遗漏parseEnvelope required limits,实际parser回归先红后绿后修复,旧失败证据保留。最终receipt `.lime/qc/composer-persistence/foundation-final-receipt.json` SHA `ef8ed5e08e98557448897860736088a55da3b7afb32ca680715855836ac9f268`。本场为unavailable backend,不证明AppServer业务/runtime accepted或GUI hydrate;保存完成后退出,不证明pending立即退出、强杀、Windows或断电。 + +随后索引与消费者23源已成套直接迁入;旧scope-only/top-level view删除,read现在仅query/data。File retry、自有test-root与真实parser driver修复保留;正式索引20文件439/439、contracts/typecheck/lint/格式、标准Electron smoke均通过;新query/data公开IPC21场景/46调用/10原生事件、revision10,PID24613与25004均正常退出。current/other/null三个workspace的index快照在保存前后与正常重启后同revision/同items摘要,consumed不回流。receipt SHA `411ba2f4f4d59805b635780f9f2ba89ee177f8a8bd9e070766da21f3c48ef941`。索引只读metadata、预算完整10,000回归通过;底层通过仍不等于GUI冷标签恢复。 + +## GUI current owner 与本轮验证 + +`ComposerDraftCoordinator` / codec / `useWorkspaceComposerDraftRuntime` / metadata index / `ComposerDraftStatus` 已进入正式源码。原 boolean/void 接受链直接迁移为 typed result,post-processing、slash、本地处理与queue分开;原capture及opaque恢复令牌透传至ActiveStream/stop恢复。TaskCenter send-driven materialization与迟到ready守卫已完成正式接线和分批定向验证。原导航无条件clear直接替换为prepare旧scope与实际committed目标bind;`useWorkspaceComposerDraftNavigation` 组合项目 picker 与待切topic,区分合法 topic-switch 和显式目标,并在真实session/workspace匹配后才清activation。唯一bind留在root runtime,不新增兼容入口。 + +正式owner组合48/48、root导航/reset组合45/45、TopicSwitch来源组合45/45通过,各自收据不相加为唯一case总数。草稿保存文案覆盖五语言并只根据实际savedRevision显示。root发现并修复参数层级丢coordinator、旧topic完成清新draft、初始化new-task误清、同scope误取消请求与layout前迟到promotion反抢目标;回归复用真实Controller/Store。真实Electron完整富JSON/资源、冷标签、三路径accepted与正常重启及默认门禁仍pending,基础Host与静态REA均不替代产品证据。 + +新增项目导航领域组合6/6通过:真实ProjectSelection、TopicSwitch、DraftState、Controller、gateway/parser/Store与React root runtime覆盖跨workspace合法切题、匹配前保留activation、旧epoch失效、picker保存旧scope/恢复新home,以及同事件显式draft目标;renderer strict两入口708解析文件零诊断。该结果与前述组合重叠,不累计为唯一case总数,也不构成Electron或重启Gate。 + +命名边界:`ComposerDraftCoordinator`、`useWorkspaceComposerDraftRuntime/Navigation/Index` 和 `ComposerDraftStatus` 是Lime current owner命名;参考安装包只确认atom key、成员身份、字段/方法属性与有界调用锚点。`pYr/hYr/BYr/Put/Vr`等压缩局部名不是上游原开发名称,REA semantic仍unknown,无source map或参考实时运行证据。合同对齐不等于恢复了上游完整源码命名或全GUI实现。 + +本轮 TaskCenter 73 项不同测试分批通过,send/flow 283、页面消费者 41、owner 守卫 15、pending 新编辑 runtime 9 项分别通过,组合不相加为唯一case。新标准 GUI smoke 通过,仅证明真实 Electron 壳/bridge/重载/三尺寸;本轮 rich/current/default 验收运行中。真实富草稿 fixture 首两轮失败均正常退出,修正 opaque 字符串版本、消费解绑与 installed skill 执行 key 的测试合同,Node evidence 24/24;保留失败收据,不以静态 fixture tests 计产品 Gate。 + +正式第二轮 Stop 生命周期修复:自动 refresh 空投影从 restoreDraft({}) 改为 replaceDocument({}),保留同一空正文 revision;人工 restore/history 与严格 scope/generation/identity/revision 令牌不变。真实 Workspace local/home/draft 回归先3失败/14通过→17/17;正式组合269通过/1trace超时,原5秒上限独立69/69通过。fixture 先验完整 GUI Stop,再通过公开资源移除/Skill clear/键盘清空独立验 history,不能把用户 history 编辑后的旧token拒绝误写为产品缺陷。 + +侧栏第二 opened project 真实失败已由产品回归确认并修复:查询与过滤统一 conversationDisplayProjects,实际两个返回会话及第二 row 点击先红后71/71。首发四叶与Home cold三叶正式采用,Node84/84/lint通过;前者验完整canonical User Item与media/read字节,后者仅稳定home当前scope,不证明随机多草稿picker。正式发送结算helper遗留临时软链接已按采用收据同SHA落成普通文件,源码行为未变;renderer-only build 与新标准 GUI smoke exit0,仅证明标准壳/bridge 场景。 + +### 当前真实失败与验证身份(2026-10-15) + +`composer-stop-lifecycle-20261015-1` 原 rich 场景 exit1:session/thread `01a13bb8-ca6e-7b72-b943-c789f3847033`、turn `turn_65b39477aea54ce3b0d70cba4de012b0` 经真实 runtime accepted、Stop interrupt 与 canonical canceled。GUI 正文/path/image/skill 全空、无 assistant 输出、编辑器可编辑。日志有 inputRestorePlan、dispatch 与 Workspace received,没有 Inputbar apply;token 现场完整性与具体拒绝原因尚 unknown,不能以 hook integration 通过推断此 Gate 通过。 + +local 第七轮已点击第二项目会话并出现同 session Composer,随后 code picker 准备超时。只增强 fixture 的同 session/唯一 editor/可编辑 ready 前提,不改产品或恢复 oracle;Node evidence24/24、lint exit0。第八轮三个项目的rich编辑保存与拒绝保稿检查后,在Undo的键盘选区oracle失败,未达冷重启;正常quit收据失败,之后只读确认Main75321已不存在,不计正常重启证据。freeze3 包含 7,885 source / 1,358 artifact 摘要,packet SHA `6a530f8b5b0188a6bcfdb10bb68d4f3d1e825f12eadc252a3983da95c8f482fd`;launch 前后独立核摘要零漂移。它不证明全部模块执行,保留旧 chunk 可能存在。 + +默认第87/131批两次分别211通过/1首例5秒超时,超时位置由 references 改到 EmptyState slogan;references 原5秒隔离9/9。未改 timeout、skip 或 resume 状态,资源压力只作为观察,不代替缺陷判断。默认门禁与持久化产品 Gate 继续 pending;公开新建会话的下一迁移见 [导航审计](./new-conversation-navigation-audit.md)。 + +Stop拒绝诊断已正式采用,仅在既有AgentDebug开关下console记录revision/generation/owner/scope是否匹配和失败原因,无正文或资源路径;guard谓词未变。诊断renderer构建exit0,rich2仍exit1;freeze4前后7,885 source / 1,463 artifact零漂移。完整context确认token、Controller、Coordinator、revision22、generation1和scope均匹配,唯消费后identity由null变为存在,原因为binding_changed。 + +领域回归复现消费前在途refresh读到消费后的absent并误建空identity:两时序先1失败/1通过,#retire递增read使旧读失效后,五文件94/94通过。保留严格恢复守卫与原富输入oracle;新renderer及真实rich复验结果见下文。发送全链候选两叶已独立核冻结并正式采用,受控transport的3项含真实EditorView,正式3/3、四叶lint/格式通过;不证明Electron Gate已关闭。 + +随后标准typecheck、新renderer构建均exit0;原rich场景第三轮 `composer-stop-retire-read-20261015-3` exit0,common34/34、scenario21/21,actionable console/page errors均0。同一canonical session/thread `01a13cd5-0f79-7c32-9150-1fefd750b006` 的接受、Stop取消、可见富草稿/资源/marks、history召回及长正文折叠通过。freeze5的7,887 source / 1,568 artifact前后独立核零漂移,packet SHA `989a12818eb5ad891700089721657fd2baad2d038bab1056b94a4ba0420078f9`。收据 `.lime/qc/composer-persistence/retire-read-rich-final-receipt.json`;这是受控backend的Desktop Gate B,不含live、冷重启、Windows或隐藏paste source的DOM召回证明。local9未达冷恢复,后续定位见下段。 + +local10在导航后已严格验证原rich JSON和DOM;拒绝前后entry精确相同,无canonical Turn。trusted Undo后的payload/document/hidden paste/资源摘要与原baseline一致、selection恢复[143,146]。随后fixture额外的Meta+End及三次ShiftLeft使最终selection为[143,143],正文与marks仍在;未观察逐键默认行为,不能归因为产品Undo破坏。Main74494正常code0/signalnull退出,freeze6前后零漂移。fixture删除Undo后marker补写与重选,直接要求clean.draft与入口baseline.draft深相等,再验rich,Node24/24、lint/格式通过;scopeVersion不参与原稿比较。local11使用freeze7(仅该fixture变化、产品/产物零delta),packet SHA `4fa24dfa20c8d849cac836d85c0d80fad086cc56200e4f96da40830512403c41`,第三workspace代码picker trigger超时,截图编辑器为空,未达Undo/cold;Main82377正常退出,前后零漂移。尚需定位hydrate/后续清空时序。 + +local14(freeze11)已通过完整 native request trace 和正常退出:Main48987 code0/signalnull后启动新Main52369,首次进入完整GUI冷恢复。A恢复后原revision27变28、installed_skill route被删除,未打开B/C的route保留;正文长度按JS UTF16比较,不能把Python Unicode计数差异算作丢字。Inputbar在Skills catalog尚空时将fallback执行key与namespaced mention id直接比较,误清路由;真实Inputbar回归20项中18通过/2纯行为失败。修复复用能力身份owner的namespace/execution匹配,完整富JSON/资源/选区/identity和零额外写入要求不变;新产品Gate待重建与独立冻结后复跑。本次产品缺陷不计参考应用行为或全GUI完成。 + +local15修后首次cold严格恢复三个workspaces的完整JSON/DOM/资源/选区/identity,同revision32无额外mutation;后段合法无capability新稿在完整相等通过后,test-only摘要hash(undefined)失败。nullable摘要两叶修复,先27通过/1行为失败→组合78/78,rich route必需及完整相等不变。freeze13只两fixture delta、产品产物零漂移。local16已exit0:三rich、拒绝保稿、原选区Undo、接受consume/new-edit、异workspace interrupt及两次正常cold全部通过;Main34272/38556/40802均code0/signalnull。summary位于 `.lime/qc/composer-persistence/gui-navigation-readiness-evidence/composer-navigation-readiness-local16/composer-draft-gui-persistence.json`。这是受控backend Desktop Gate B,仅关闭existing local conversations,不含Home/首发/live/Windows。 + +Home6已在新Home callback构建上exit0,Main17922/18120均正常code0/signalnull。公开Home编辑完整富稿、同atom metadata索引、磁盘原字节与revision在退出和cold后不变,第二进程恢复完整JSON/DOM/资源/原选区/identity,无canonical Thread/Turn,冷trace零atom mutation。Home3/4首次正常quit期限失败保留;Home5误要求直接preload workspace helper出现在GUI safeInvoke记录,fixture最小迁移回归先红后Node62/62及collector69/69,未放宽禁止input/原生完整采集/冷零写。packet16仅两fixture变化、产品产物不变,后验7884source/1883artifact零漂移;收据 `.lime/qc/composer-persistence/home-cold-final-receipt.json`。这是受控backend、saved后正常退出的稳定null Home切片,不证明多草稿picker/live/Windows/强杀耐久。 + +packet19已验证Home完整首发/资源/consume/new-edit与正常退出;三入口整体仍失败于search-new富稿保存冲突。真实Coordinator/Store回归复现queued clear前序put通知恢复已退休identity;current修复只增加discard在途read保护和完成时read失效,Store CAS、retired identity及Stop exact token不放宽。非Stop定向通过,Stop原5秒timeout尚未关闭;新产物与当前验证进度以执行计划/证据索引为准,不把旧local16/Home6升级为新产物通过。 + +新修复标准typecheck/renderer/freshness/桌面壳均exit0;packet20 first6 Home与search-new完整富输入/资源/consume/new-edit及正常退出通过,证明原搜索保存冲突已在真实路径消失。项目按钮fixture补真实header hover后Node53/53/lint/格式通过,packet21 first7三个入口业务断言全部通过,但第三Main退出超过原15秒期限;随后正常teardown不计整轮pass。first8被动记录退出计时,失败时仅采样自有Main;原Stop测试5秒timeout、current/default仍未关闭。 + +packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮及default续跑待关闭。 + +## 最终验证收尾护栏(2026-10-15) + +默认验证已按实际前置检查、原生132批续跑、contracts、legacy与标准GUI24/24的组合关闭,原verify:local失败不改写为单次wrapper绿。续跑1761为历史登记路径,1756现存、5项已删除;修改过的早期owner单独回归,新增退出测试不冒充进入原状态。最新产品与2303目录产物来自freeze30;第5轮current短问候接受1978ms超过原1800ms预算,原场景隔离78/78、921ms及第6轮973ms通过,但超预算根因仍unknown。 + +第6轮前7场子进程正常结束,第8场审批完成所有业务断言后卡在finally。诊断明确pending BrowserContext.close、Node连接已关闭、无Node待响应调用、renderer/helper已退,仅Main存活;原生sample符号不完整,底层根因unknown。该轮已独立冻结并中断,SIGTERM清理不算正常退出。新fixture复用既有正常退出owner,仍限15秒,只有Main实际code0/signalnull及全部资源释放后才写最终ok并打印pass;失败后强制回收不会修复Gate结果。独立teardown行为回归6项、组合110/110通过;原审批隔离66/66、Main34547正常退出且无强制回收。freeze31仅4项fixture变化,产品产物不变,完整第7轮聚合已exit0、22/22通过,四组31/34/3/300,21个Claw正常Main回执且强制回收0;Coding仅保留既有子进程证据,不虚构Main回执。7886源/2303产物及产物路径集后验零漂移,当前summary/显式ledger/截图与log共67份独立冻结。旧第4轮manifest包含的遗留Skills失败截图保持历史原件,不混入新轮证据;最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭,整体GUI仍partial。收据与失败身份见evidence-index的current_gui_verification_20261015。 diff --git a/internal/research/codex-desktop-rea/composer-marks-css-audit.md b/internal/research/codex-desktop-rea/composer-marks-css-audit.md new file mode 100644 index 000000000..9701a390c --- /dev/null +++ b/internal/research/codex-desktop-rea/composer-marks-css-audit.md @@ -0,0 +1,15 @@ +# Composer link/code 样式补证 + +日期:2026-10-14。复用同一 Codex Desktop 版本与 ASAR 摘要,扫描安装包内317个 packed CSS成员、2687091 bytes,逐成员核对inventory integrity。这里是有界文本观察,不是新的REA semantic或参考应用运行证据;原始CSS与观察记录只留本机分析目录。 + +| 成员与SHA-256 | 可观察选择器与规则 | 边界 | +| --- | --- | --- | +| `app-initial-99e8b4b455fa.css` / `1a75f9180b89d2457dc09e4efea288436c9cf6d44e5ba59b2c03d3d571b7bf61` | 生成选择器 `_RichTextInput_juri1_1 .ProseMirror [data-composer-code-block]` 约UTF16位置189069:container宽度/最小宽度、圆角、surface背景与clip;直接子块margin为0,内部 `>pre` 有mono字体、padding、tab-size4与横向滚动;`>pre>code`为block,另有toolbar背景 | CSS存在不证明当前主surface有效NodeView。生成CSS名不作为原始源码类名 | +| `app-shared-9a94b907ed47.css` / `69f929f71947824fed18365d54c8aa0c975a856649339e585a50f6f547e215d7` | 约UTF16位置68979的mention样式对breakable URL做ltr/isolate与换行,对text-link-href移除inline padding | 需要追踪实际mark NodeView与生成class的绑定,不能把plain span自动判定为视觉等价 | +| `content-bc0b899e44c2.css` / `3875ca200b50e2b5f3868d3091610a5250db34e8f4e05f2957efcd484ec249cb` | 约UTF16位置18290:`data-composer-code-block-wrap=true` 时内部pre/code采用pre-wrap/anywhere | wrap状态来源、默认值与toolbar交互仍unknown | + +Lime本批对齐基础schema、link/code语义、Markdown发送和完整draft恢复;schema当前生成 `pre[data-composer-code-block]>code`,与以上container `>pre>code` 的CSS选择器形状不同。这个观察不能证明Codex当前主surface始终采用后一形状,但基础schema的通过不关闭完整NodeView/toolbar/样式合同。 + +后续在既有ComposerEditor/View插件owner追踪NodeView选择与有效选项,确认后复用Lime主题token实现;不得为CSS推断增加第二Controller。主动链接导航/编辑、code toolbar、wrap与完整rich PMU继续单列待办,当前富输入Gate仅证明其声明的基础文档、键盘、发送与恢复合同。 + +实施进度与具体产品证据见[当前计划](../../exec-plans/codex-gui-alignment-current-plan.md);迁移前语义证据见[marks/voice审计](marks-voice-audit.md)。 diff --git a/internal/research/codex-desktop-rea/evidence-index.json b/internal/research/codex-desktop-rea/evidence-index.json index 48f42c222..5d85de46b 100644 --- a/internal/research/codex-desktop-rea/evidence-index.json +++ b/internal/research/codex-desktop-rea/evidence-index.json @@ -91,6 +91,20 @@ "server_name": "rea", "server_version": "6.4.0", "tools_list_count": 170 + }, + "registrationRecovery": { + "date": "2026-10-14", + "reason": "Codex MCP stanza missing after external config cleanup", + "status": "healthy", + "explicitNode": "24.21.0", + "setupBackup": "/Users/coso/.codex/config.toml.rea.backup", + "node24Backup": "/Users/coso/.codex/config.toml.rea-node24-20261014-220306.backup", + "nonReaSettingsPreserved": true, + "mcpProbe": { + "server": "rea/6.4.0", + "tools": 170 + }, + "currentAgentTableRefreshed": false } }, "artifact_selection": { @@ -5825,5 +5839,1078 @@ "Bare $skill/@name auto-detection, lower plain-text decoration branches, all escaped syntax, URL normalization and other surface behavior not exhausted.", "Clipboard attribute preservation across selection/HTML transforms, reinsertion, historical typed arrays and runtime skill identity are not proven." ] + }, + "composer_code_nodeview_focused_analysis": { + "date": "2026-10-14", + "document": "internal/research/codex-desktop-rea/composer-code-nodeview-audit.md", + "localEvidenceDirectory": ".lime/qc/composer-code-nodeview-audit", + "localArtifactStatus": "historical-originals-missing; user removed .lime on 2026-10-14", + "manifestSha256": "4900789dbf397cb72fdc01dd727aa85e05de4456b9a4852f49380f987501bf41", + "originalUtf16Anchors": 86, + "artifacts": 23, + "reaEvidenceIds": [ + "ev_bf209739980b2025edc530e2be3a7758959de28623928c7cb1af33d1ce38308f" + ], + "evidenceLevel": "shipped-artifact structural + original-member ordinary AST", + "reaSemantic": "unknown", + "runtimeValidation": "not-run", + "productStatus": "formal input-kit source applied; directed tests, fresh rich Electron Gate, current aggregate 22/22 and combined frontend/default GUI smoke passed for the recorded source lineage; later persistence source requires separate pending product gates", + "referenceIdentity": "target and ASAR identity unchanged" + }, + "composer_code_highlight_focused_analysis": { + "date": "2026-10-14", + "document": "internal/research/codex-desktop-rea/composer-code-highlight-audit.md", + "localEvidenceDirectory": ".lime/qc/composer-code-highlight-audit", + "localArtifactStatus": "historical-originals-missing; user removed .lime on 2026-10-14", + "manifestSha256": "35c932d1fb359d22320772c90587c429fadceb5cf94fe1b8f52c5efd46580cd7", + "originalUtf16Anchors": 81, + "artifacts": 15, + "reaEvidenceIds": [ + "ev_32f87b5f925c87d58fbcfc530fbc03bc33536ab477882f98e9e2a9a70bbb150f" + ], + "evidenceLevel": "shipped-artifact structural + original-member ordinary AST", + "reaSemantic": "unknown", + "runtimeValidation": "not-run", + "productStatus": "formal input-kit source applied; directed tests, fresh rich Electron Gate, current aggregate 22/22 and combined frontend/default GUI smoke passed for the recorded source lineage; later persistence source requires separate pending product gates", + "referenceIdentity": "target and ASAR identity unchanged" + }, + "local_artifact_retention": { + "checked_date": "2026-10-14", + "event": "User removed repository .lime directory to reclaim disk space and authorized continuing.", + "deleted_root": "/Users/coso/Documents/dev/ai/aiclientproxy/lime/.lime", + "status": "historical-originals-missing", + "affected": [ + "Composer focused manifests", + "isolated candidates", + "renderer/Host freeze manifests", + "screenshots", + "test resume state" + ], + "remaining_logs": [ + "/tmp/lime-composer-marks-dictation-current-fixture.log", + "/tmp/lime-composer-marks-dictation-verify-local-node24-locale-resume.log" + ], + "recovery_root": "/var/folders/87/s6cpr7hd1_v43cs833x4s_900000gn/T/lime-code-nodeview-recovery-oulq764w", + "recovery_policy": "Recover only required owned source from structured tool writes; take new baseline hashes and rerun checks. Historical hashes do not attest restored files." + }, + "composer_code_formal_migration": { + "date": "2026-10-14", + "sourceManifest": ".lime/qc/composer-code-nodeview/formal-source-before-gate.json", + "sourceManifestSha256": "d954ae8af04b5779f7c8344222b1a5571ce4918d46f2b168600d51d94fc4eda7", + "sourcePaths": 45, + "dependencyLock": "pnpm-lock.yaml", + "exactDependencies": { + "lowlight": "3.3.0", + "highlight.js": "11.11.1", + "jsonc-parser": "3.3.1" + }, + "fixtureTests": { + "files": 6, + "passed": 189 + }, + "toolbarAndActualEditorTests": { + "files": 4, + "passed": 41, + "realLocales": 5 + }, + "consumerTests": { + "files": 6, + "passed": 141 + }, + "buildFreshnessGuard": { + "red": 2, + "green": 30 + }, + "freshElectronGate": { + "status": "passed", + "scenario": "inputbar-rich-restore", + "assertions": 74, + "falseAssertions": 0, + "summary": ".lime/qc/composer-code-nodeview/rich-1/composer-code-nodeview-rich-1-summary.json", + "summarySha256": "4b2835ea164dd9d3a90850091e8b507fe9325edda9a89bbe69c50a0d5d3a45a8", + "receipt": ".lime/qc/composer-code-nodeview/rich-1-receipt.json", + "receiptSha256": "16dfd94468a396c3663763a5743d44b4ef0be82b68d299d8265ab5062cf2b106", + "artifactManifest": { + "path": ".lime/qc/composer-code-nodeview/renderer-host-artifacts-rich-1.json", + "files": 464, + "sha256": "f80031ae9984bd3e88395adf8840e362e9f8bc107159f5e2a1090d5509467c9f" + }, + "controlledBackend": "external", + "liveProvider": false, + "systemIme": "not-run", + "windows": "not-run" + }, + "defaultVerification": "passed combined prechecks + frontend resume 127/127 batches + standard GUI smoke; original verify:local wrappers remain exit1", + "referenceRuntime": "not-run", + "currentFixtureAggregate": { + "status": "passed", + "exitCode": 0, + "electronScenes": 22, + "assertions": 1476, + "receipt": ".lime/qc/composer-code-nodeview/current-aggregate-final-receipt.json", + "receiptSha256": "a678fc058cc0fa4cc9e2aa57c35ed7ebfe5693004afcca5339211c999d67e86b", + "artifactFilesVerified": 464, + "artifactDrift": 0, + "identitySource": "Each common gateBContract.identity.sources; dedicated coding-workbench accepted/recovery identity. Top-level initial session is not substituted.", + "limitations": "Controlled external/runtime fixture scenes only; dedicated coding summary has no common legacy/mock count fields. No reference runtime, live provider, Windows, OS IME or microphone verification." + }, + "sourceLineage": { + "manifest": ".lime/qc/composer-code-nodeview/formal-source-before-resume-2.json", + "sha256": "f1a4c8cf4c9484519aae998881c5d0e21c65c07b350d5bf17414ba40afa5353d", + "files": 47, + "original45Drift": 0, + "additionalPaths": [ + "scripts/agent-runtime/claw-chat-current-fixture-composer-marks-renderer.test.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.test.mjs" + ] + }, + "latestSourceLineage": { + "manifest": ".lime/qc/composer-code-nodeview/formal-source-before-resume-4.json", + "sha256": "e6a7d7f65395ca778b995a6f1afc4c26fab1df1f77c36b7800b962e3f7660381", + "files": 54, + "deletedPaths": [ + "src/components/settings-v2/agent/voice/useAutoDismissMessage.ts" + ], + "nodeView47SourceDrift": 0, + "exception": "Settings auto-dismiss lifecycle defect fixed in one shared owner; test-only hoisted React imports made local to mock factories." + }, + "standardGuiSmoke": { + "status": "passed", + "command": "npm run verify:gui-smoke", + "exitCode": 0, + "runId": "standalone-nodeview-timer-20261014-2130", + "receipt": ".lime/qc/composer-code-nodeview/gui-smoke-timer-final-receipt.json", + "receiptSha256": "695cb593848ccba3d8085b04f5f54af6e6de0cb9a54fc343f9422e9c59a5e8a1", + "artifactFiles": 464, + "boundary": "Real Electron shell, reload, preload IPC, App Server, three sizes and memory settings; no provider turn or reference pixel parity. Independent build identity from earlier rich/current evidence." + }, + "defaultCombinedReceipt": { + "path": ".lime/qc/composer-code-nodeview/default-combined-final-receipt.json", + "sha256": "7078735e6eeb6550608256d965113ed7271ef63c9fc092bdac63d73f19e8228b", + "frontendBatches": 127, + "frontendFiles": 1713, + "sourceFilesVerified": 54, + "artifactFilesVerified": 464, + "drift": 0 + }, + "sliceStatus": "implemented and verified; whole GUI alignment remains partial" + }, + "composer_persistence_formal_migration": { + "date": "2026-10-14", + "status": "foundation and same-atom index formally verified; GUI owner/root scope navigation source adopted with directed regression; GUI cold restoration and three-send product gates pending", + "statusCheckedDate": "2026-10-15", + "sourceCount": 37, + "migrationManifestSha256": "c897066cd966bca4c817031b0909a06ec743dca75213a625ef780f3a6a9098a5", + "patchSha256": "d60359b76a609ebec3258dd4a82de1c76cfcb732a1f765b5874b0a8da4547f2c", + "overlays": [ + "reference rename retry 100/200/400ms", + "driver self-owned test temp roots" + ], + "compatibilityPaths": 0, + "rootArchitectureConfirmation": "2026-10-14; Host UI persistence separated from App Server canonical facts", + "foundationReceipt": { + "path": ".lime/qc/composer-persistence/foundation-final-receipt.json", + "sha256": "ef8ed5e08e98557448897860736088a55da3b7afb32ca680715855836ac9f268", + "normalExitPids": [96100, 96356], + "savedRevision": 7, + "cases": 15, + "ipcCalls": 36, + "nativeMetadataEvents": 7, + "runtimeGate": false, + "guiDraftRestoration": false + }, + "indexMigration": { + "sourcesChanged": 23, + "manifestSha256": "4488863763d3eb060862b375e2d72693d2153bd45abe2d0eb7eaa39702da29db", + "sourceManifest": ".lime/qc/composer-persistence/index-formal-sources.json", + "sourceManifestSha256": "919b5998951a343c6dba5b77ae6fa2a7249c93b61e9dd93aff17ecaa5ae873c6", + "sourceCount": 42, + "status": "formal directional/contracts/typecheck/lint/standard Electron smoke/public IPC restart passed", + "oldReadWire": "deleted, no compatibility branch" + }, + "indexReceipt": { + "path": ".lime/qc/composer-persistence/index-final-receipt.json", + "sha256": "411ba2f4f4d59805b635780f9f2ba89ee177f8a8bd9e070766da21f3c48ef941", + "tests": { + "files": 20, + "passed": 439 + }, + "normalExitPids": [24613, 25004], + "cases": 21, + "savedRevision": 10, + "ipcInvocations": 46, + "nativeMetadataEvents": 10, + "workspaceIndexSnapshots": "current/other/null before and after restart equal at same revision", + "runtimeGate": false, + "guiDraftRestoration": false + } + }, + "composer_gui_draft_formal_wiring": { + "date": "2026-10-14", + "status": "formal root navigation and TaskCenter send wiring verified with directed regressions and fresh desktop shell smoke; real rich draft cold restoration/current/default product gates in progress", + "statusCheckedDate": "2026-10-15", + "namingBoundary": "Coordinator/Runtime/Navigation/Index/Status are Lime owner names. Reference atom keys, member identities and bounded field/method anchors are confirmed; original developer symbols, full GUI semantic equivalence and reference runtime remain unknown.", + "referenceEvidenceId": "ev_6ac3324c94242434149d9b820ee6cecdc0165021adadac0bdca91254ab787c29", + "sendAdoption": ".lime/qc/composer-persistence/send-coordinator-formal-adoption.json", + "restoreAdoption": ".lime/qc/composer-persistence/restore-token-formal-adoption.json", + "navigationAdoption": ".lime/qc/composer-persistence/navigation-formal-adoption.json", + "formalTests": [ + { + "path": ".lime/qc/composer-persistence/gui-owner-formal-tests.json", + "passed": 48, + "sha256": "f07f7420afbe8ee13bf5beaf5c485700213c5e5f36ad3d23230093d4da70598f" + }, + { + "path": ".lime/qc/composer-persistence/gui-navigation-reset-formal-tests.json", + "passed": 45, + "sha256": "6a8874b5bfeb2f14b707ce1076219d47994eea4917532ec9f70fb58e137d87e4" + }, + { + "path": ".lime/qc/composer-persistence/gui-send-flow-formal-tests.json", + "passed": 283, + "sha256": "5bc79acbeaa329ae34a8fd8a74ff0dfb2760f32daa4f654ac3f8f7a4242d9a69" + }, + { + "path": ".lime/qc/composer-persistence/gui-consumer-page-first-tests.json", + "passed": 41, + "sha256": "3dcbaa05f2a7ebe1e9d6b2ab0b97a25ccdcc91cb0d923a2099597e6858ede865" + }, + { + "path": ".lime/qc/composer-persistence/gui-owner-guard-formal-tests.json", + "passed": 15, + "sha256": "6e3dddd72a97141d1796bcd9ef36115014810de92fcdd8c7141162be62129b51" + }, + { + "path": ".lime/qc/composer-persistence/gui-pending-lane-final-tests.json", + "passed": 9, + "sha256": "4f1aecfe90790e772eddc276d8b2ab334dd153ccabb7785ccc4bf13dd83650e8" + } + ], + "countsAreOverlapping": true, + "guiGate": "pending", + "defaultGate": "resume5 passed26–54, batch55 controlled fixture exact registration fixed; resume6 passed55–64, batch65 obsolete session alias prompt-history guard migrated to canonical accepted receipt; resume7 running", + "architectureConfirmedBy": "root", + "classification": { + "current": [ + "Controller document", + "Workspace committed scope runtime", + "Workspace project/topic draft navigation", + "Same-atom workspace-index metadata discovery", + "Coordinator renderer save queue", + "PersistedAtomHost/Store/File", + "typed runtime acceptance", + "opaque UI restore token" + ], + "compat": [], + "deleted": [ + "boolean acceptance guessing", + "navigation eager document clear", + "scope-only read contract" + ] + }, + "directedAndShellReceipt": { + "path": ".lime/qc/composer-persistence/gui-directed-shell-receipt.json", + "sha256": "3bf8e272135c9257000714ec13c4c9e45191a78dea3a29ba41ced9d7b957231b" + }, + "taskCenterSendRegression": { + "uniqueCases": 73, + "singleFullRun": false + }, + "standardGuiSmoke": { + "status": "pass", + "runId": "standalone-composer-stop-tab-20261015-1", + "path": ".lime/qc/composer-persistence/gui-stop-tab-standard/summary.json", + "sha256": "2957baed7bdfebe069e4b5332cf5b695ed7b72b30e7e50f477b3558bf69dc82c", + "boundary": "real shell/preload/IPC/AppServer/reload/three sizes/settings; no rich restore, first-send or cold draft selection claim" + }, + "realDraftGuiFailures": [ + { + "runId": "composer-draft-local-20261015-1", + "normalExitPid": 99745, + "result": "fail", + "reason": "fixture contract investigation; not counted as Gate B" + }, + { + "runId": "composer-draft-local-20261015-2", + "normalExitPid": 13174, + "result": "fail", + "reason": "catalog mention key incorrectly used for installed skill execution key" + }, + { + "runId": "composer-draft-local-20261015-3", + "normalExitPid": 19701, + "result": "fail", + "reason": "sidebar fixture navigation; later exact-row investigation" + }, + { + "runId": "composer-draft-local-20261015-4", + "normalExitPid": 29741, + "result": "fail", + "reason": "workspace/session name collision selected project header; exact native sidebar row migration" + }, + { + "runId": "composer-draft-local-20261015-5", + "result": "preflight-fail", + "electronLaunched": false, + "reason": "freshness fail closed after typed page fixture update" + }, + { + "runId": "composer-draft-local-20261015-6", + "result": "fail", + "normalExitPid": 75925, + "reason": "other opened project group visible but canonical conversations filtered to active project; actual product scope fix directed 71/71, fresh build/rerun pending" + } + ], + "fixtureContractCorrections": [ + "opaque scopeVersion string; no ordering", + "scope read null after consume; real disk parsed tombstone for captured identity", + "installed execution key and mention catalog ID remain distinct" + ], + "currentRichGate": { + "status": "second lifecycle fix formally adopted with runtime red-green regression; fresh real GUI rerun pending", + "reason": "previous failure: Stop advanced Controller revision and invalidated strict restore token", + "path": ".lime/qc/composer-persistence/current-rich/claw-chat-current-fixture-summary.json", + "regression": "actual Inputbar/EmptyState receipt/revision red→green; both production Stop handlers now only invoke onStop", + "fix": "remove unused Controller interrupt submit from Inputbar and EmptyState; preserve strict identity/revision/scope restore guard", + "formalReceipt": { + "path": ".lime/qc/composer-persistence/stop-and-runtime-fixture-formal-receipt.json", + "sha256": "3b74ad732cedb736c294cb80164f8bf5270840a8c24818efe62451fcbb50f66f" + }, + "latestFailure": { + "path": ".lime/qc/composer-persistence/current-rich-stop-fixed/composer-stop-fixed-20261015-1-summary.json", + "sha256": "d37836ba90db48c120d887cbe6f346b1a1ec3c07b12ccae31c12e3a1cccabca6", + "result": "fail", + "reason": "real GUI Stop still leaves rich document/path/image/skill empty; component fix alone did not close product gate", + "log": "/tmp/lime-composer-stop-fixed-current-rich.log" + }, + "lifecycleFix": { + "owner": "ComposerDraftCoordinator.refresh", + "change": "replaceDocument({}) for unedited automatic empty projection; preserve revision/identity guards and manual restore semantics", + "formalAdoption": ".lime/qc/composer-persistence/stop-lifecycle-formal-adoption.json", + "formalAdoptionSha256": "c58a4834609e92272eaf0f395402f2c4d8c0f87402b7b156380db136430c3226", + "formalCombined": "269 passed; one collector test timeout; isolated original 5000ms limit 69/69 passed", + "fixtureOrdering": "validate real GUI Stop rich restore first; user-cleared independent history recall afterwards" + } + }, + "tabCreationRegression": { + "status": "component callback formally fixed; general GUI strip is currently unreachable, so no public +tab route Gate claim", + "manifestSha256": "cea27458ee5a649a54d96e333c661a1f8bbff8087f88f17cde4ed7bd010146b0", + "directedCases": 11, + "beforeFix": "real DOM click supplied React SyntheticBaseEvent to a zero-argument task-creation callback", + "fix": "zero-argument callback invocation", + "desktopGateB": false, + "receipt": ".lime/qc/composer-persistence/tab-and-page-fixture-formal-receipt.json" + }, + "coldDraftPublicNavigation": { + "status": "product-gap", + "publicSelectionAvailable": false, + "metadataDiscoveryAvailable": true, + "reason": "TaskCenterTabStrip is excluded by current ThreadHeader/utility toolbar; persisted draft has no sidebar selection consumer", + "rejectedFixtureRoute": "Home→hidden +tab", + "next": "REA same-artifact public draft restoration navigation; migrate current owner rather than expose retired tab UI", + "firstSendContract": "Home/search-new/project-new public UI; fail-closed same-atom index plus scope read identifies the logical rich draft; no cold selection or tab UI claim" + }, + "sharedFixtureAdoption": { + "path": ".lime/qc/composer-persistence/shared-and-guard-formal-adoption.json", + "sources": 4, + "coldScenarioAdopted": false, + "boundary": "one shared launch/quit/parse/trace owner, default local three-generation lifecycle retained", + "sha256": "b101f15e17deb70beb229f635b62f132f94899cb42bf53d27acbdef82d8be08c" + }, + "fullInputFreeze": { + "directory": ".lime/qc/composer-persistence/gui-stop-shared-full-freeze-1", + "sourceCount": 7877, + "artifactCount": 1148, + "preLaunchDrift": 0, + "boundary": "freshness-declared input directory superset including tests/resources and current fixtures, not loaded-module/execution proof", + "localPacket": ".lime/qc/composer-persistence/gui-verification-packet-stop-shared-full-1.json" + }, + "sidebarOpenedProjectFix": { + "receipt": ".lime/qc/composer-persistence/sidebar-opened-project-and-backend-guard-receipt.json", + "sha256": "ff90091a044b307a68fb77cc2752dd64d3a8a1c9b09b9783f70d8711798eab0a", + "source": "AppSidebar uses displayed opened projects for both canonical cwd queries and filters", + "formalTests": 71, + "desktopGateB": false + }, + "sidebarOpenedProjects": { + "owner": "AppSidebar", + "change": "display/query/filter all opened conversation projects", + "regression": "real second project session row red -> 71/71 combined", + "receipt": ".lime/qc/composer-persistence/sidebar-opened-project-and-backend-guard-receipt.json", + "guiRerun": "pending" + }, + "newScenarioAdoption": { + "paths": [ + "scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs", + "scripts/electron/lib/composer-home-draft-fixture-scenario.mjs" + ], + "receipt": ".lime/qc/composer-persistence/first-send-home-cold-formal-adoption.json", + "pureTests": 84, + "formalLeaves": 7, + "realGui": "pending", + "claims": "three logical first-send routes and stable Home cold recovery separately; no top-tab or all-draft picker claim" + }, + "formalHelperRegularFile": { + "path": "src/components/agent/chat/components/composerSubmissionSettlement.ts", + "sha256": "173a9c96682c405a3c319908d45ce5bce55e30d6d06ba64b0e3d49eed31fa772", + "receipt": ".lime/qc/composer-persistence/formal-helper-regular-file-receipt.json", + "change": "temporary source symlink replaced by identical ordinary source; candidate target untouched", + "freshBuild": "renderer-only build and standard GUI smoke exit0; rich Stop still fails" + }, + "currentRealValidation": { + "date": "2026-10-15", + "richStop": { + "runId": "composer-stop-lifecycle-20261015-1", + "exitCode": 1, + "result": "runtime accepted, interrupt and canonical canceled; restore request received by Workspace but not applied, visible rich draft empty", + "tokenCompleteness": "unknown", + "summary": ".lime/qc/composer-persistence/current-rich-stop-lifecycle-1/composer-stop-lifecycle-20261015-1-summary.json" + }, + "localDrafts": { + "run7": "second project navigation passed; picker preparation timed out", + "fixtureChange": "wait for one visible same-session editable Composer after hydration; original restore oracle retained", + "nodeEvidenceTests": "24/24", + "run8": "exit1 after three rich draft edits/saves and rejection checks; Undo then noncollapsed keyboard selection oracle failed; cold restart not reached", + "teardown": "run8 normal quit receipt failed; later ps confirmed PID75321 absent; no normal restart claim", + "run9": "exit1 after three rich preparations and selection passed; after trusted Undo literal code/language/link assertion failed, disk first scope only marker plus image/path", + "run9Teardown": "Main59037 public normal quit code0/signalnull; cold generations not reached", + "run9Identity": "freeze5 independent after check zero drift", + "nextDiagnosis": "Removed Undo-after marker repair and reselection; require deep equality of clean.draft and entry baseline.draft, including original selection. Node24/24/lint/format passed. No product change.", + "run10": "exit1 after original-rich JSON/DOM baseline passed, rejection preserved exact entry and trusted Undo restored exact original payload plus selection; subsequent fixture selection setup collapsed the restored selection", + "run10Teardown": "Main74494 public normal quit code0/signalnull; cold generations not reached", + "run10Identity": "freeze6 independent before/after zero drift, 7887 sources /1568 artifacts", + "run11": "exit1 at third workspace code picker trigger; screenshot empty editor, new Undo oracle and cold phase not reached; Main82377 normal code0/signalnull; freeze7 before/after zero drift", + "run11PacketSha256": "4fa24dfa20c8d849cac836d85c0d80fad086cc56200e4f96da40830512403c41" + }, + "defaultGate": { + "batch": "87/131", + "resume13": "211 pass, references first-case 5s timeout", + "resume14": "211 pass, EmptyState slogan first-case 5s timeout", + "isolatedReferences": "9/9 original 5s", + "timeoutSkipOrResumeStateChanged": false + }, + "freeze3": { + "packet": ".lime/qc/composer-persistence/gui-verification-packet-stop-sidebar-3.json", + "sha256": "6a530f8b5b0188a6bcfdb10bb68d4f3d1e825f12eadc252a3983da95c8f482fd", + "sources": 7885, + "artifacts": 1358, + "beforeLocal8Drift": 0, + "limitation": "identity only; retained old chunks possible; not proof every module executed", + "afterLocal8Drift": 0 + }, + "persistenceGate": "pending", + "homeColdAndFirstSend": "not-run", + "restoreDiagnostics": { + "source": "src/components/agent/chat/components/composerSubmissionSettlement.ts", + "sha256": "25590d1b75a6edac6b04bfb41b647481bb2acf1788dc5733741753546a208ed8", + "scope": "existing AgentDebug console-only numeric revisions, generations, owner/scope matches and rejection reason; no content; restore predicate unchanged", + "formalRelated": "47/47", + "lint": "exit0", + "rendererBuild": "exit0; predates retire read invalidation fix", + "richDiagnostic2": "exit1; token/controller/coordinator/revision/generation/scope match, but identity recreated after consume; binding_changed", + "freeze4": "7885 sources / 1463 artifacts, independent before/after rich2 hashes show zero drift" + }, + "retireReadRace": { + "owner": "src/components/input-kit/ComposerDraftCoordinator.ts#retire", + "regression": "src/components/agent/chat/components/composerSubmissionSettlement.test.ts", + "red": "1 failed / 1 passed; refresh crossing consume could recreate an empty identity", + "fix": "retirement invalidates reads started before consumption; strict restore predicate retained", + "related": "5 files, 94/94 exit0", + "greenReceipt": ".lime/qc/composer-persistence/retire-read-race-green.json", + "typecheck": "standard npm run typecheck exit0", + "realGui": "original rich third run exit0; common34/34 and scenario21/21; actionable console/page errors0", + "runId": "composer-stop-retire-read-20261015-3", + "gateBReceipt": ".lime/qc/composer-persistence/retire-read-rich-final-receipt.json", + "packetSha256": "989a12818eb5ad891700089721657fd2baad2d038bab1056b94a4ba0420078f9", + "freeze5": "7887 source / 1568 artifact, independent before/after zero drift", + "limitation": "controlled backend Desktop Gate B; no live provider, cold restart, hidden paste source DOM recovery or Windows claim" + }, + "stopSendChain": { + "formalAdoption": ".lime/qc/composer-persistence/stop-send-chain-formal-adoption.json", + "patchSha256": "dc572aaae57dddc43b50156c9a36f17049531bbdef18528151a6536b9f0eb7f4", + "candidate": "3/3, one real EditorView; strict TS/lint/format/apply-check exit0", + "formalTests": "3/3 exit0; four changed source/test leaves lint and format exit0", + "boundary": "controlled transport, not Electron Gate B or cross-consume stale-refresh coverage" + } + } + }, + "composer_draft_public_navigation": { + "date": "2026-10-15", + "artifactSha256": "75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af", + "evidenceIds": [ + "ev_8109ba0f2d675fb08989285ee7408e675b5a2d4953513f757c11e959c4934ab5", + "ev_5872c498b099311cff8f5d0c5d7ca8b99dae35efcceb3f42eeabd3eb526e9c47" + ], + "confirmedExport": "startNewConversation", + "currentIdentityKeys": ["home", "panel"], + "atoms": ["draft-thread-identities-v1", "client-thread-bindings-v1"], + "restorationGate": "primary window persistence owner", + "referenceRuntimeObserved": false, + "allDraftSidebarPicker": "unknown", + "semanticCoverage": "unknown; static package observation only", + "audit": "internal/research/codex-desktop-rea/new-conversation-navigation-audit.md", + "recoveryManifest": "/var/folders/87/s6cpr7hd1_v43cs833x4s_900000gn/T/lime-code-nodeview-recovery-oulq764w/rea-draft-public-navigation/manifest.json", + "recoveryManifestSha256": "90f5dbb3381e84a6a7a5caef05b1abf8908265515614f0d3d19af3800c4e3c6e", + "nextMigration": "new-renderer real GUI validation, then canonical settings route/back/search/dirty authority", + "formalMigration": "78 sources formally adopted; unique public action, stable home scope and committed fresh CAS; nine retired files deleted", + "productDecision": "Home freshDraft=false resumes stable current scope; explicit New chat freshDraft=true. Ordinary reference sidebar fresh runtime behavior unknown." + }, + "settings_navigation_alignment": { + "checked_date": "2026-10-15", + "full_evidence_id": "ev_4bf8bfc7ccb14fb058096dd32864ae7f9ab83a8f0ad9acb46cfd79b5379ab36c", + "summary_evidence_id": "ev_6d2230454a838f113f4e61d100fb41d47ea6b241100765d84eb35674dcd71478", + "corpus_sha256": "2c618ae2c6acb241a854e3f71191626c857485ed664d43dc91a887d7f1730c35", + "candidate_manifest_sha256": "eebf60cd4d0846129c97051bbe92b8ab368ce015eef7714eca9f9a8b917ac3d1", + "report": "settings-navigation-audit.md", + "members": 7, + "original_byte_anchors": 20, + "parse_failures": 0, + "semantic_status": "unknown", + "source_dependencies_verified": 15, + "confirmed_exports": [ + "SettingsPage", + "VisibleSettingsCommandMenuSectionItems", + "SettingsExternalSection" + ], + "current_gaps": [ + "canonical PageParams selection", + "previous navigation context", + "row target search", + "dirty navigation authority" + ], + "source_candidate": "nine sources formally adopted; single sections owner and SettingsPage; directed78/78 and oracle10/10, real GUI pending", + "target_executed": false, + "gui_verified": false, + "limitation": "seven shipped members only; full registry/default page, search-documents contents and external dirty authority unknown" + }, + "new_conversation_formal_migration": { + "date": "2026-10-15", + "receipt": ".lime/qc/new-conversation/formal-adoption.json", + "receiptSha256": "a82be2c8d2a9efcf5ae2bd410d896a2598b67802ce0796e9c7438c4e8a93005e", + "sources": 78, + "deletedFiles": 9, + "upstreamConfirmedName": "startNewConversation", + "candidateManifestSha256": "d581ea834c035ac3cbe5cd0c455c27ed219c3acc4418b5aef94503b7922d3a8b", + "candidateValidation": "latest per-file35suite438/438; strict3187files, no diagnostics or product source leak; causal red contains API/legacy errors", + "architectureConfirmation": "root, architecture4.3/4.5, 2026-10-15", + "formalCombined": "latest per-file46suite540/540; initial538pass/2fail retained; fixture visible projection migrated, Stop17/17 plus EditorView5/5", + "realGui": "existing-local slice pass; Home/first-send/settings/current/default not closed", + "freshSemantics": "Home=false/New=true Lime decision; ordinary upstream sidebar runtime remains unknown", + "wholeGui": "partial", + "formalValidationReceipt": ".lime/qc/new-conversation/formal-latest-directed-receipt.json", + "standardChecks": ".lime/qc/new-conversation/formal-standard-checks-receipt.json", + "guiShell": "codex-navigation-readiness-20261015-1, standard verify:gui-smoke exit0", + "verificationPacket": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-13.json", + "guiScope": "local16 existing local and Home6 stable null scope passed; first-send/settings/current/default pending", + "nativeEnvelopeMigration": ".lime/qc/composer-persistence/native-request-envelope-migration-receipt.json", + "normalCloseMigration": ".lime/qc/composer-persistence/normal-close-debugger-migration-receipt.json" + }, + "composer_visible_readiness_formal_migration": { + "receipt": ".lime/qc/composer-persistence/visible-ready-formal-adoption.json", + "products": 2, + "typedFixture": 1, + "invariant": "single binding.ready fact + exact visible scope; strict restore guards retained", + "originalFormalRed": 5, + "newNavigationFormalRed": 5, + "combinedDomainGreen": 5, + "strictTestClosureFiles": 686, + "formalCombined": "latest per-file46suite540/540; initial538pass/2fail retained; fixture visible projection migrated, Stop17/17 plus EditorView5/5", + "realGui": "pending", + "local11Causation": "static window confirmed; actual timeline absent", + "formalValidationReceipt": ".lime/qc/new-conversation/formal-latest-directed-receipt.json", + "stopProjectionOracle": "Home/draft remains disabled after binding promotion until actual visible local session commits; strict restore unchanged" + }, + "composer_home_null_oracle_migration": { + "receipt": ".lime/qc/composer-persistence/home-null-oracle-receipt.json", + "leaves": 3, + "nodePassed": 33, + "lintFormat": "passed", + "scope": "public Home explicit null is separate from App Server default workspace", + "realGate": "Home6 Desktop Gate B pass; receipt .lime/qc/composer-persistence/home-cold-final-receipt.json" + }, + "composer_skill_catalog_hydration_fix": { + "date": "2026-10-15", + "owner": "src/components/agent/chat/skill-selection/inputCapabilitySelection.ts", + "rootCause": "Transient empty catalog produces fallback execution key; strict mention.id === Skill.key cleared namespaced persisted Skill route.", + "fix": "Inputbar delegates mention identity matching to existing installed Skill route matcher; no second normalization or authority.", + "receipt": ".lime/qc/composer-persistence/skill-catalog-hydration-directed-receipt.json", + "causalRed": "18pass/2behaviorfail", + "directedGreen": "four suites40/40; standard typecheck/lint/docs and new GUI smoke24/24 exit0", + "newProductGui": "local16 existing-local three workspaces/two normal cold restarts passed, Main34272/38556/40802 code0/signalnull; Home/first-send/settings pending", + "wholeGui": "partial" + }, + "composer_plain_draft_summary_migration": { + "receipt": ".lime/qc/composer-persistence/plain-draft-summary-migration-receipt.json", + "scope": "test-only nullable optional route hash; full equality/rich route guard retained", + "productArtifactDrift": 0, + "green": "78/78", + "wholeGui": "partial" + }, + "composer_local_persistence_gate": { + "run": "composer-navigation-readiness-local16", + "summary": ".lime/qc/composer-persistence/gui-navigation-readiness-evidence/composer-navigation-readiness-local16/composer-draft-gui-persistence.json", + "result": "pass", + "scope": "three existing canonical local conversations/two normal cold restarts/rejection Undo/accepted new-edit/foreign workspace isolation", + "mainPids": [34272, 38556, 40802], + "packet": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-13.json", + "wholeGui": "partial", + "limitations": "controlled external backend, saved before normal quit; no live/Windows/abrupt pending exit" + }, + "composer_home_skill_demand_fix": { + "scope": "Home lazy Skill catalog request to existing useWorkspaceSkillDirectoryRuntime", + "cause": "directory autoLoad=false; Home CharacterMention lacked onSkillSuggestionsNeeded while session composer has it", + "causalRed": "two suites18 total16pass/2behaviorfail", + "implementation": "shared SkillSelectionSourceProps/bindings -> EmptyState -> landing/chat props -> existing Scene callback", + "newAuthority": false, + "status": "latest per-file5suites140/140; standard typecheck/lint/renderer build and shell24/24 exit0; prior timeout logs retained", + "wholeGui": "partial", + "receipt": ".lime/qc/composer-persistence/home-skill-demand-final-directed-receipt.json" + }, + "composer_home_trace_oracle_migration": { + "cause": "ensureDefaultWorkspace directly invokes preload, outside GUI safeInvoke diagnostic ledger; not a cache hit", + "scope": "require observed GUI thread/list and native atom behavior; scenario separately verifies direct-preload default workspace response and canonical inventory", + "red": "new GUI trace regression fails before oracle migration", + "green": "Node62/62; collector Vitest69/69; initial mixed-runner collection failure retained", + "packet": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-16.json", + "artifactDrift": 0, + "realGate": "Home6 pass; Main17922/18120 normal code0/signalnull; full rich/resources/selection/identity and unchanged bytes/revision; cold zero atom mutation/no canonical Thread/Turn; post-freeze zero drift", + "wholeGui": "partial" + }, + "composer_home_cold_gate": { + "result": "pass", + "receipt": ".lime/qc/composer-persistence/home-cold-final-receipt.json", + "run": "composer-navigation-home-cold-6", + "mainPids": [17922, 18120], + "sourceArtifactDrift": 0, + "wholeGui": "partial" + }, + "settings_current_native_gate": { + "result": "pass", + "run": "settings-current-native-20261015-2", + "summary": ".lime/qc/settings-sections/settings-current-native-20261015-2/summary.json", + "sections": 15, + "visibleStates": 24, + "nativeReadCount": 157, + "mainPid": 22039, + "scope": "navigation/first-screen reads only; no write/canonical-route/back/search/dirty/live/Windows claim", + "wholeGui": "partial" + }, + "composer_first_submission_current_gate": { + "result": "pass", + "runs": [ + "composer-navigation-first-send-1", + "composer-navigation-first-send-2", + "composer-navigation-first-send-3", + "composer-navigation-first-send-4", + "composer-navigation-first-send-5", + "composer-navigation-first-send-6", + "composer-navigation-first-send-7", + "composer-navigation-first-send-8" + ], + "observation": "Early runs1-3: Home click Send creates one real thread with no turns; directDispatch result rejected; null-session Composer remains disabled/restoring; trace no turn/start or promote/consume", + "next": "Stop5s unit stability, current aggregate, async-tabs and default gates remain pending.", + "wholeGui": "partial", + "latestObservation": "Run8 with passive lifecycle diagnostic wrapper: all3 routes complete canonical rich input/order/resources/media bytes, promotion-before-consume, saved consumption, new edits and foreign old-turn settlement passed; Main99739/655/1318 normal code0/signalnull at12.282/1.026/12.636s within unchanged15s limit; console/page errors0. Prior lifecycle failures retained; their root cause unknown.", + "previousObservation": "first-send6 Home and search-new complete rich canonical input/resources/consume/new-edit passed, Main85420/85963 normal; project click missed required header hover, normal teardown86752. Fixture hover migrated. first-send7 all3 business assertions passed; Main93044/93526 normal, project Main94143 exceeded original15s before normal teardown; whole run remains fail.", + "latestPassedRun": "composer-navigation-first-send-8", + "summary": ".lime/qc/composer-persistence/gui-navigation-readiness-evidence/composer-navigation-first-send-8/composer-composer-first-submissions-gui.json" + }, + "composer_rich_capability_body_fix": { + "cause": "Skill routing prefers displayContent over canonical Composer source; markdown/hidden paste rewritten, strict UTF8 atomic remap rejects before turn/start", + "sources": [ + "src/components/agent/chat/workspace/inputCapabilityRouting.ts", + "src/components/agent/chat/workspace/inputCapabilityRouting.test.ts" + ], + "fix": "prefer exact canonical source body; display stays replay/empty-source fallback; no remap guard relaxation", + "causalRed": "2pass/1behaviorfail: Cannot map Composer text elements through this text transformation", + "status": "directed5suites165/165; final typecheck/lint/format/renderer+Host builds/contracts/legacy/shell all exit0; first-send4 Home rich acceptance assertions pass, lifecycle15s failed; whole Gate not passed", + "classification": "current sole capability routing; no new compat or authority", + "wholeGui": "partial" + }, + "composer_topic_visible_workspace_fix": { + "cause": "topic hydration passes runtime/default workspace while root Composer binds visible taskCenterWorkspaceId; exact visible scope guard correctly keeps editor disabled when these differ", + "sources": [ + "src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts", + "src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.navigation.integration.test.tsx" + ], + "fix": "composition callback commits current visible workspace to the existing draft navigation owner, retaining captured navigation epoch and exact ready guards", + "causalRed": "real wiring + Coordinator/Store integration:2/2 behavior failures; business unresolved vs visible project and business default vs visible null", + "green": "final root2/2; public navigation11/11; draft navigation7/7; original visible-ready5/5 isolated at unchanged5s after combined4/5 timeout", + "classification": "current root composition; no new authority, compat, or fallback", + "productGate": "first-send4 Home canonical full input/resources/consume/new-edit passed; generation1 normal quit exceeded15s, run failed; standard first-send5 and rich Stop pending", + "wholeGui": "partial" + }, + "composer_current_fixes_verification": { + "receipt": ".lime/qc/composer-persistence/canonical-body-visible-workspace-directed-final-receipt.json", + "receiptSha256": "da7494bb49ef810cc278e908718f88df6588d085cf8f129f0450cceab37c1193", + "packet": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-19.json", + "packetSha256": "5d23f655d2d1cb50290fdcf6d4119aa094a5db1c4bde3dcb3cbf813a19989075", + "freeze": { + "sources": 7885, + "artifacts": 1988, + "deltaFrom16": "three changed and one added owned source/test paths only", + "preGuiDrift": 0 + }, + "shellRun": "codex-canonical-body-visible-workspace-20261015-1", + "shellResult": "pass", + "classification": "current; new compat0; restored dead0", + "wholeGui": "partial" + }, + "composer_queued_discard_notification_fix": { + "sources": [ + "src/components/input-kit/ComposerDraftCoordinator.ts", + "src/components/input-kit/ComposerDraftCoordinator.binding.test.ts" + ], + "observation": "search-new save conflict after replacing an already-saved scope probe with rich input; native old home identity consumed(clear), visible input retained", + "causalRed": "real connect + preceding queued put + delayed clear: old probe restored into cleared Controller; single behavior regression failed in110ms, not a timeout", + "fix": "binding tracks current in-flight discard lane; refresh cannot rehydrate while discard pending; completion invalidates reads started in that interval, without changing Store CAS/retired identity rules", + "status": "binding15/15, Coordinator31/31, fresh7/7, navigation7/7, visible-ready5/5, send8/8 passed; combined93 total92pass/1original5s Home Stop timeout; isolated Stop20 total14pass/6original5s timeouts, no value/identity assertion failure; lint and test formatting pass; standard typecheck/new renderer20m10s/all-segment freshness/standard shell smoke exit0", + "classification": "current sole Coordinator; new compat0; new persistence authority0", + "limitation": "Run5 generation2 native timeline absent; real run6 and run7 search-new full rich save/acceptance/resources/consume/new-edit now passed. Original Stop timeouts and lifecycle failures remain retained.", + "wholeGui": "partial", + "logs": { + "red": "/tmp/lime-composer-queued-discard-red.log", + "combined": "/tmp/lime-composer-queued-discard-green.log", + "stopIsolated": "/tmp/lime-composer-queued-discard-stop-isolated.log", + "typecheck": "/tmp/lime-composer-queued-discard-typecheck.log", + "rendererBuild": "/tmp/lime-composer-queued-discard-renderer-build.log" + }, + "stableStopGate": "Unchanged original file, latest single command20/20 in29.02s; tests8.98s, max case816ms; previous combined1 and isolated6 original5s timeout failures retained, cause not established.", + "receipt": ".lime/qc/composer-persistence/queued-discard-directed-final-receipt.json", + "receiptSha256": "8413931c85dffc5b5b0041061ceba0018ed37008e1ee84c82fb35957c0cce586" + }, + "composer_project_new_hover_fixture_migration": { + "source": "scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs", + "scope": "test-only real project-header hover before click and correct source phase after launch", + "existingNodeEvidenceTests": "53/53", + "lintFormat": "pass", + "productArtifactChanges": 0, + "packet": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-21.json", + "packetSha256": "86b048bca1084a5338e2b675adac72aa6154685d7691fda53003d09c416ce491", + "freeze": { + "sources": 7885, + "artifacts": 2093, + "preGuiDrift": 0, + "deltaFrom20": "only first-send fixture" + }, + "wholeGate": "first8 pass all3 routes, original15s normal quits; passive diagnostic only, previous failures retained", + "wholeGui": "partial", + "priorWholeGate": "first7 fail at third normal quit original15s; all3 business assertions passed" + }, + "composer_current_product_scenarios": { + "packet": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-21.json", + "packetSha256": "86b048bca1084a5338e2b675adac72aa6154685d7691fda53003d09c416ce491", + "firstSubmission": { + "result": "pass", + "run": "composer-navigation-first-send-8", + "normalMainPids": [99739, 655, 1318], + "passiveDiagnosticOnly": true + }, + "richStop": { + "result": "pass", + "run": "composer-queued-discard-rich-stop-20261015-1", + "consolePageErrors": 0 + }, + "homeCold": { + "result": "pass", + "run": "composer-navigation-home-7", + "normalMainPids": [6249, 6729], + "coldMutationCount": 0 + }, + "existingLocalCold": { + "result": "pass", + "run": "composer-navigation-existing-local-17", + "normalMainPids": [7882, 10853, 11390], + "workspaces": 3, + "coldRestarts": 2 + }, + "postFreezeVerification": { + "sourceAndArtifactDrift": 0, + "sources": 7885, + "artifacts": 2093, + "summary": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-21/post-first8-stop-home7-local17.json" + }, + "boundedGuiScenarios": "4/4 passed,100% only for these four contracts", + "notClosed": [ + "current aggregate rerun after collector test-helper optimization", + "default verify:local resume" + ], + "wholeGui": "partial", + "limitations": "Controlled fixture, no live-provider/Windows/OS IME/mic/whole-GUI parity; previous normal-quit timeout cause unknown.", + "receipt": ".lime/qc/composer-persistence/queued-discard-product-final-receipt.json", + "receiptSha256": "2cafe26c77fe9b0b58d530e3f78a3ae05a5b4b592645551fc73a870b275f17d2", + "stopDirected": { + "result": "pass", + "tests": 20, + "originalTimeoutMs": 5000, + "log": "/tmp/lime-composer-queued-discard-stop-after-gui.log" + }, + "asyncTabs": { + "result": "pass", + "executed": [3, 11], + "initial": "stale selectors,187 skipped/0 executed, fail retained", + "migration": "current canonical hydration/sidebar test paths and execution count guards", + "receipt": ".lime/qc/composer-persistence/async-tabs-canonical-migration-receipt.json", + "proof": "directed Vitest, not GUI" + } + }, + "composer_current_fixture_aggregate_followup": { + "priorRun": { + "runId": "codex-composer-current-20261015-2", + "result": "fail", + "directedGroupsPassed": [31, 34, 3], + "guardGroup": { + "passed": 293, + "failed": 1 + }, + "electronScenariosStarted": 0, + "log": "/tmp/lime-composer-current-aggregate-final.log" + }, + "collector": { + "receipt": ".lime/qc/composer-persistence/invoke-trace-collector-optimization-receipt.json", + "sha256": "ce65777eb5a90a7104fd117dc356467e949c4b3c17740f879e8c6accafda6dbc", + "result": "69/69 pass, original5000ms, no changed assertions", + "classification": "test-only helper, product source/artifacts unchanged" + }, + "rerun": { + "runId": "codex-composer-current-20261015-3", + "result": "failed at15th Skills manual-enable, previous14Electron scenes passed; directed31/34/3/294 passed", + "log": "/tmp/lime-composer-current-aggregate-optimized.log", + "failureReceipt": ".lime/qc/composer-persistence/imperative-session-scope-repair-directed-receipt.json" + }, + "wholeGui": "partial", + "finalRun": { + "runId": "codex-composer-current-20261015-4", + "result": "pass", + "exitCode": 0, + "directedGroupsPassed": [31, 34, 3, 294], + "electronScenesPassed": 22, + "manifest": { + "path": ".lime/qc/composer-persistence/current-aggregate-4-complete/manifest.json", + "sha256": "b8990f3979f4b386e4ec367f809f5cc4bb93855d0cf64c94170db8321fe5fbbf" + }, + "identityAfter": "7885 sources/2198 artifacts; zero drift", + "limitation": "Individual controlled fixture contracts; not live-provider/Windows/whole-GUI parity" + } + }, + "composer_imperative_session_scope_repair": { + "receipt": ".lime/qc/composer-persistence/imperative-session-scope-repair-directed-receipt.json", + "source": "current CommandWiring sendMessage -> canonical accepted -> existing visible topic commit", + "red": "home vs local assertion,1fail/2pass", + "directed": "final6suites61/61, includes Stop20 and visible-ready5", + "gui": "renderer/Host built; exact locked V8/Cargo cache restored via child environment using existing proxy; sidecar prepare and terminal standard smoke.mjs exit0; original standard wrapper failure retained", + "wholeGui": "partial", + "checks": "standard typecheck/format passed before stable capture; original narrow lint exited0 with1warning, superseded by stable capture61/61 and strict max-warnings0 pass; fresh full default in progress", + "sourceFreeze": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-22/sources.json", + "sourceFreezeSha256": "add364fa81833f26571459c1f2e5e4f95bf0157543ce95d6d0b05ca059fe654e", + "executedAggregateEvidence": ".lime/qc/composer-persistence/current-aggregate-3-executed/manifest.json", + "buildRecoveryReceipt": { + "path": ".lime/qc/composer-persistence/imperative-session-build-recovery-receipt.json", + "sha256": "4ccbef850613cbc5fda09f701cd44e6fd5a7720b007cc2a99795fad3f5f6cf5b" + }, + "packet22": { + "path": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-22.json", + "sha256": "ccbea0f14e55a53799a67b0c084e5a5bfcfe3d15a7e3973765ba21de06a25368" + }, + "pending": [ + "default run4 from original batch89 after shared resource hot-path repair", + "final new build/full current22 including Skills", + "final source/artifact identity" + ], + "skillsProductReceipt": { + "path": ".lime/qc/composer-persistence/imperative-session-skills-product-receipt.json", + "sha256": "fe9e08b7ca85594dc81b33ede8d677c0a21c82ec07be7688364d7cecd61292ba" + }, + "currentAggregate": { + "path": ".lime/qc/composer-persistence/current-aggregate-4-complete/manifest.json", + "sha256": "b8990f3979f4b386e4ec367f809f5cc4bb93855d0cf64c94170db8321fe5fbbf" + }, + "strictLintCorrection": { + "path": ".lime/qc/composer-persistence/imperative-session-stable-dependencies-receipt.json", + "sha256": "2b142d976edfcbf64a3ebd05c200b1036effc260d2ded5abb538cad14c687657" + }, + "sourceFreeze24": { + "path": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-24/sources.json", + "sha256": "ae72a4539cef476cecbb31d1040109a1f0616f48b559f7f17fab63096d86d633" + }, + "sourceFreeze25": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-25/sources.json", + "formatOnlyReceipt": ".lime/qc/composer-persistence/imperative-session-dependencies-format-receipt.json" + }, + "structure_alignment_ledger": { + "path": "internal/research/codex-desktop-rea/structure-alignment-ledger.md", + "sha256": "b7d687a9899d5eb0d10f6e5bf6298d99cfe4812bbf17fb640a20d395a99cf98a", + "scope": "Known shipped exports, observed method names, Lime current owners and remaining mappings; existing REA evidence reused", + "status": "partial", + "limitations": "Not a complete source symbol inventory or name equivalence proof; minified original names remain unknown" + }, + "settings_dead_placeholder_cleanup": { + "path": ".lime/qc/composer-persistence/settings-dead-placeholder-cleanup-receipt.json", + "sha256": "8ec721eaebca721cf46def748669dba8b91f6a533f049eaf5a3a2fcbfc304084" + }, + "agent_copy_bundled_resource_reuse": { + "receipt": { + "path": ".lime/qc/composer-persistence/agent-copy-resource-reuse-directed-receipt.json", + "sha256": "0d362424a9e58b97545f1708ce1f5bf92a61514419bb8c063411751e6fc760b4" + }, + "source": "existing createI18n limeI18nResources; no per-key merge or new cache", + "red": "3pass/1fail,12 repeated namespace loads", + "green": "6suites81/81 including unchanged historyWindow14/14", + "checks": "strict lint passed; standard default run4 in progress from original batch89", + "scope": "Current Agent copy hot-path repair, not all-timeout root-cause claim", + "pending": ["default run4", "final build and full current aggregate"] + }, + "runtime_sync_canonical_acceptance_fixture": { + "receipt": ".lime/qc/composer-persistence/runtime-sync-canonical-acceptance-fixture-receipt.json", + "receiptSha256": "0bae1633a301ef55123e9f47f28a92b4b460fb1f2cb1fe1d437ccedb8a3923db", + "status": "60/60 directed, strict lint; default112 passed", + "classification": "test-only current fixture; production guards unchanged", + "wholeGui": "partial" + }, + "workspace_structured_typed_acceptance_fixture": { + "receipt": ".lime/qc/composer-persistence/workspace-structured-typed-acceptance-fixture-receipt.json", + "receiptSha256": "cbb4c3c88baadaba031a7130ba56ead19b4c10f51eb0e9646bb6af9a20f1e321", + "status": "24/24 directed, strict lint; default123 passed", + "classification": "test-only; boolean/void fixture deleted, no compat", + "wholeGui": "partial" + }, + "home_navigation_current_test_consolidation": { + "receipt": ".lime/qc/composer-persistence/home-navigation-current-test-consolidation-receipt.json", + "receiptSha256": "f951ee2dd4dd4a7674af3b2c9b040af3b693dbb88eff872837b6f9b7f7ec0621", + "status": "16/16 directed, strict lint/format; default125 and132 passed", + "classification": "current coverage consolidated; dead duplicate test deleted", + "wholeGui": "partial" + }, + "inputbar_typed_acceptance_fixture": { + "receipt": ".lime/qc/composer-persistence/inputbar-typed-acceptance-fixture-receipt.json", + "receiptSha256": "ebbe3e7ae0bc8bb14401d4914bafe00280332a0853f108974b4eec714a16b887", + "status": "113/113 directed, strict lint; default127 passed", + "classification": "test-only current fixture; explicit boolean results deleted", + "wholeGui": "partial" + }, + "execution_plan_consolidation": { + "recordedAt": "2026-10-15T07:43:33.410041+00:00", + "currentPlan": "internal/exec-plans/codex-gui-alignment-current-plan.md", + "history": "internal/exec-plans/codex-gui-alignment-history-20261015.md", + "original": { + "copy": ".lime/qc/composer-persistence/default-combined-current-20261015/current-plan-before-consolidation.md", + "sha256": "a41d0cfdea7dc5c1a703ce5e5c9fb7aae8f7d26825067dfdaf8822de2dcb4211", + "bytes": 222752, + "lines": 799 + }, + "historyBoundary": "Same-directory historical snapshot preserves relative links; title/status explicitly archived, no active checklist authority. Raw original frozen separately. Current plan retains status, contracts, owners, cleanup, verification and pending work.", + "wholeGui": "partial", + "historySha256": "b831a3ca269839171d6eda12f8c4632b2cd11360eb0fde514cb8a8fa2ed9199e" + }, + "settings_current_dirty_owner_preflight": { + "receipt": ".lime/qc/settings-sections/current-dirty-owner-preflight-20261015.json", + "sha256": "0ad87b8d9af9599523d0da30107c6e41edc0741f7cac32a46b5f494bdf8ef19c", + "status": "read-only source audit; product implementation pending", + "report": "internal/research/codex-desktop-rea/settings-navigation-audit.md", + "wholeGui": "partial" + }, + "current_gui_verification_20261015": { + "sourceFreeze": { + "path": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-30/sources.json", + "sha256": "727cc715a2b6af374ec59d991f8ac436927df53465cf40be48203f4c3be038bd" + }, + "artifactManifest": { + "path": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-30/artifacts.json", + "sha256": "66b35be4039008350a0d2c3d6d75703d9b787461040f29809325ca38b6cf2c9b" + }, + "packet": { + "path": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-30.json", + "sha256": "f3c3ab312f246481046bb121a29a1c848deed4c68843990220eaa4672a9dfd41" + }, + "defaultCombined": { + "path": ".lime/qc/composer-persistence/default-combined-current-20261015/final-receipt.json", + "sha256": "bfde376b9359ccef7a4ebea5c1a62148d1fc7706cd2a6326d761952807f73b29" + }, + "frontend": "132/132 passed; state1761registered paths,1756existing/5retired; not all batches newly run this turn", + "shell": "standard verify:gui-smoke wrapper exit0,24/24", + "current22": { + "runId": "codex-composer-current-20261015-7", + "result": "pass", + "sourceFreeze": { + "path": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-31/sources.json", + "sha256": "c00e5bb6c36a166fd2fd822a6b61b81a5e012015d1301ff0af38eced9bba627c" + }, + "artifactManifest": { + "path": ".lime/qc/composer-persistence/gui-navigation-readiness-freeze-31/artifacts.json", + "sha256": "66b35be4039008350a0d2c3d6d75703d9b787461040f29809325ca38b6cf2c9b" + }, + "boundary": "fixture-only4path delta;production unchanged from packet30", + "exitCode": 0, + "sceneCount": 22, + "directedGroupsPassed": [31, 34, 3, 300], + "manifest": { + "path": ".lime/qc/composer-persistence/current-aggregate-7-complete/manifest.json", + "sha256": "69340c52a05833c450ad6f0d8667add345fa61d262b0aec3a6d8485674c9973c" + }, + "sourceCount": 7886, + "artifactCount": 2303, + "postRunSourceDrift": 0, + "postRunArtifactDrift": 0, + "artifactPathSetDrift": 0, + "clawNormalMainExitReceipts": 21, + "forcedMainReaps": 0, + "evidenceFiles": 67, + "selection": "Current summary + explicitly referenced ledger/screenshot;not prefix leftovers. Prior aggregate4 has one retained old skills failure PNG,not current7 evidence.", + "codingBoundary": "No runId/backendMode/proofLevel/Main receipt invented;linked by exact new sessionId and summary path in completed aggregate log." + }, + "wholeGui": "partial", + "failedCurrentRuns": [ + { + "runId": "codex-composer-current-20261015-5", + "result": "failed at third home-hotpath-greeting:1978ms acceptance exceeds original1800ms budget; first2 passed,19not-run", + "manifest": { + "path": ".lime/qc/composer-persistence/current-aggregate-5-failed/manifest.json", + "sha256": "ac99c015de47f08b2cac1eca346a8cfd502dd5eb87d57ed260c2913fcff78adb" + }, + "identity": "7884source/2303artifact zero drift", + "diagnostic": "isolated original scene78/78 exit0,acceptance921ms; aggregate6 same scene973ms. Original budget failure cause unknown.", + "isolatedReceipt": { + "path": ".lime/qc/composer-persistence/home-greeting-latency-isolated-1/receipt.json", + "sha256": "29af3c5a17804c721923efb1fab80ed5372ed4e65b1b5826f4cfb8450c15716e" + } + }, + { + "runId": "codex-composer-current-20261015-6", + "result": "interrupted after Main teardown hang;7 scenes processes complete,8th business assertions printed pass before cleanup;not a full Gate pass", + "manifest": { + "path": ".lime/qc/composer-persistence/current-aggregate-6-hung/manifest.json", + "sha256": "c02dcd43584aca1ad4caacb12a24af5ae0eb3923bcc8c42f829d5be1f24c0fed" + }, + "diagnosis": { + "path": ".lime/qc/composer-persistence/current-aggregate-6-hung/diagnosis-and-interruption.json", + "sha256": "5055fcb3ad8d43d34a5960e04efe6bd865d17113d05b2e63f80f4c05a3a48213" + }, + "rootCause": "unknown;pending BrowserContext.close,nodeConnectionClosed=true,no Node pending calls,Main alive", + "cleanup": "task-owned aggregate/fixture/Main SIGTERM;never normal-exit evidence" + } + ], + "fixtureTeardown": { + "classification": "current test-only guard;no product change or new compatibility", + "directed": { + "path": ".lime/qc/composer-persistence/fixture-teardown-directed-2.json", + "sha256": "c05869e60a513d4dc9129d4b667211fa3740e634babd16b4028942bd354fc434" + }, + "directedTestsPassed": 110, + "newBehaviorCases": 6, + "packet": { + "path": ".lime/qc/composer-persistence/gui-verification-packet-navigation-readiness-31.json", + "sha256": "8dcf4bddbb89e8b6d57f8502f6ec06e18718a14dfc00e8b317fa349fd660e57e" + }, + "normalExitLimitMs": 15000, + "isolatedApproval": { + "path": ".lime/qc/composer-persistence/approval-normal-exit-isolated-1/receipt.json", + "sha256": "0884ff3ea85e01952ace237caf4a7d403ed8eec321a9eecf1d10ed1f4f33cb1e" + }, + "semantics": "Business pass only after actual Main code0/signalnull and resource cleanup;forced reap follows failure only,does not repair outcome." + }, + "validationSlice": "closed:current22,post-run hashes,freshness and final documentation checks passed;whole GUI partial", + "finalReceipt": { + "path": ".lime/qc/composer-persistence/fixture-teardown-final-20261015/receipt.json", + "sha256": "acb2e430f2c01a67b6b5edf59fed92b7c9279ec2dd2b82ef6e14c15c8b29c718" + } } } diff --git a/internal/research/codex-desktop-rea/new-conversation-navigation-audit.md b/internal/research/codex-desktop-rea/new-conversation-navigation-audit.md new file mode 100644 index 000000000..4c3fa8de7 --- /dev/null +++ b/internal/research/codex-desktop-rea/new-conversation-navigation-audit.md @@ -0,0 +1,71 @@ +# Codex Desktop 公开新建会话与草稿导航审计 + +状态:设计与源码迁移已完成;packet21 的三入口完整富输入首发、rich Stop、Home 冷零写及三工作区两次正常冷恢复四项真实 GUI 合同全部通过。后续 packet22 的程序化发送绑定修复已通过原 Skills 三路径和桌面壳,完整 current 聚合22/22已通过,默认门禁正按原失败批次续跑。整体 GUI 继续 partial;下文逐轮失败属于原身份历史证据,不能覆盖新产物。 + +目标为 `/Applications/ChatGPT.app` 中的 `com.openai.codex`,版本 `26.1007.30912`、build `20156`;ASAR SHA-256 为 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。安装路径不能作为消费者 ChatGPT Desktop 的身份依据。 + +## 证据与边界 + +复用 REA Evidence `ev_8109ba0f2d675fb08989285ee7408e675b5a2d4953513f757c11e959c4934ab5` 的五个完整 member,以及 `ev_5872c498b099311cff8f5d0c5d7ca8b99dae35efcceb3f42eeabd3eb526e9c47` 的 27 个 focused inert 声明。恢复候选核对了 46 个证据文件,摘要一致;没有新增分析 chunk、执行 bootstrap 或参考应用。 + +已确认公开导出名 `startNewConversation`。initial member 的 `wH [4012522,4013720)` 返回首页、聚焦 Composer,仅在 freshDraft/task metadata 等分支重置身份;`h7r [4013720,4013868)` 是相邻导航调用。侧栏调用链见 `les [7997002,7998775)`、`ses [7996562,7996734)`、`Ati [4062791,4063226)`。这些压缩局部变量不能当作上游开发源码名。 + +shared 的 `fRn [3640958,3641256)`、`sRn [3640457,3640611)` 与 `TRn [3643120,3643439)` 支持 home/panel 的 current client identity 和 canonical materialization 后解绑的静态映射。是否存在所有未发送草稿的侧栏 picker、普通 New chat 的运行时 fresh 行为、预热时机和全部窗口状态仍 unknown。 + +## 迁移前差距(保留原始审计事实) + +| current 路径 | 已观察问题 | 迁移目标 | +| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | +| `useAgentChatWorkspaceCommandWiring` / `useWorkspaceResetRuntime` | 初始 Home 已使用稳定 `home/new-conversation` scope | 保持唯一 root bind owner | +| `useAppSidebarConversationActions` → `taskCenterDraftTaskEvents` → `useTaskCenterNewTaskPageRuntime` | Home 与 New chat 共用 cancelable event,进入随机 `task-draft-*` | 统一公开 `startNewConversation` 动作和显式 intent | +| `useTaskCenterDraftMaterializationRuntime` | 输入触发 1 ms 预热,维护 direct/materialized 首发双轨 | 必要 session 创建只在既有发送主链承担 | +| `useWorkspaceComposerDraftIndex` / draftTabs / tab chrome | 恢复 metadata,但 general GUI 没有公开选择 consumer | 清理随机 UI 状态,不据此新增 picker 或恢复 strip | +| 新建事件的项目参数 | 显式 null 被兜底为当前项目;请求其他项目被 ignore,listener 仍 preventDefault | null 保真,按真正提交的 route/scope 执行 | + +canonical session tabs、sidebar Thread 投影、后台运行/queue、Canvas 和 image workbench 有真实消费者,需按引用迁参;不能根据名字包含 tab 批量删除。标题 helper 先迁到既有 header owner,再清理 dead general chrome。 + +## 实施合同 + +Lime 决策为 Home 使用 `freshDraft:false` 恢复当前稳定草稿;明确 New chat 使用 `freshDraft:true` 开始空稿。普通 Codex sidebar 的静态参数未强制 fresh,因此不能把这项严格 fresh 决策写成参考运行事实。 + +```mermaid +flowchart TD + H[Home: 恢复当前草稿] --> N[startNewConversation] + C[New chat: 新空稿意图] --> N + N --> A[既有 App navigation 提交 route] + A --> R[Workspace root 提交 scope 与 epoch] + R --> D[唯一 ComposerDraftCoordinator] + D --> P[Main persisted atom Host / Store] + D --> E[唯一 Controller / EditorView] + E --> S[既有 typed submission] + S --> T[App Server canonical Thread / Turn / Item] +``` + +动作由现有 `useAppNavigation` 持有,经 App props 传给真实调用者;不另建 event bus、context store 或业务后端。`buildHomeAgentParams` 保持纯 route 参数构造职责。进入 owner 后 projectId 必须显式,undefined 与 null 不混用;Logo 的无项目行为明确传 null。 + +1. 导航请求只 flush source。路由被阻塞、延迟或拒绝时保留旧正文、资源和身份,不能把 void onNavigate 当作成功确认。 +2. 读取真实 committed route/request identity;连续请求有独立 epoch,不能只依赖 Date.now。fresh 同 scope 也递增 epoch,重复 effect 只消费一次请求。 +3. 目标 scope committed 且 bind ready 后,由同一 Coordinator 执行受保护的 fresh CAS;CAS applied 后才清空正文。现有乐观 `clear()` 不能直接充当成功的新建确认。 +4. 检查 request、epoch、scope、generation、opaque binding、draftId/scopeVersion 与 Controller revision。更新期间编辑、再次导航、冲突或不可用结果不能清除新编辑;Stop exact revision token 不放宽。 +5. 空稿 identity 允许为 null,下一次有效编辑分配新逻辑 draftId;需证明旧 identity 已退休,不用随机 tab ID 伪造空稿身份。 +6. 首发沿已有 typed acceptance → canonical receipt → home/local promotion/consume。晚到 receipt 只处理其 captured source,不接管最新可见 target。 + +## 写集、清理与验证 + +按公开动作/intent → 随机草稿与预热删除 → dead chrome 与测试护栏迁移三步实施,完整迁移后才关闭切片。Host/IPC/schema 暂不夹写;`draft` wire kind 是否删除,待全部真实消费者清点后另同步协议、消费者、fixture 和 contracts。 + +迁移旧 event receivedDetails、跨项目 ignore/null 沿用、禁止 onNavigate、warmup 预算、random index、第二层 tabs 正向断言与强制 draftTabs 字符串护栏。继续覆盖唯一 root bind/no parallel store、失败保稿、迟到 receipt、canonical session/Canvas、Stop 恢复、workspace 隔离与后台 queue。 + +必要证据包括公开点击 → 真实 route commit → root bind → Main/IPC/store → 完整 PM JSON/selection/resources → canonical receipt 与用户可见结果。定向真实 owner 回归、typecheck、相关 contracts/脚本治理、GUI smoke 与对应 Electron Gate B 分别报告;source-only 设计不能替代运行证据。 + +current:startNewConversation、committed route/request、稳定 home/local scope、Main/Coordinator/Controller、typed acceptance 与 canonical 消费者。compat:不新增。deleted:general 随机草稿、discovery、新建事件、新页/预热/materialized owner 和旧 general chrome,9个文件直接删除,调用者/测试/护栏同步迁移。 + +设计与源码迁移完成度100%;本切片local16三工作区/两正常cold与Home6稳定Home冷恢复已分别通过,三首发尚未关闭,整体GUI继续partial。root复核78项before/after与冻结证据后正式采用,收据 .lime/qc/new-conversation/formal-adoption.json;保留Settings及旁路增量,未提交代码。架构4.3/4.5由root于2026-10-15确认。正式最新逐文件46suite/540项已通过(formal-latest-directed-receipt.json),非单次全绿:初轮538通过/2失败,Stop夹具未提交可见local会话,按真实产品投影迁移后17/17与EditorView5/5;产品与严格恢复门禁未改,原失败保留。候选438/438与strict3187零诊断独立记录。 + +可见目标就绪门禁已窄组合:status.ready只投影真实binding.ready,Runtime要求精确visible scope匹配;首次读取被替代、失败/模式不匹配时禁止编辑。相同EditorView回归在正式新导航上5项行为红,隔离两产品叶修复后5/5,正式5/5与Stop17/17已通过;严格scope/generation/revision/token守卫保持。local11静态窗口成立,是否实际命中仍缺timeline,不倒推为已证明根因。 + +packet19的Home首发已取得完整canonical富输入、资源字节、consume/new-edit和Main96352正常退出证据;search-new在替换已保存probe后出现保存冲突,项目新建未达,三首发整体仍fail。queued discard通知重绑已在既有Coordinator真实回归复现并窄修,严格CAS不变;新构建、原三入口与Stop/冷恢复/默认门禁按current计划继续验收。 + +queued discard修复的新renderer、标准typecheck、freshness与桌面壳通过。packet20 first6 Home/search-new全部业务及正常退出通过;项目按钮补真实header hover的fixture后Node53/53/lint/格式通过,packet21 first7三个入口完整canonical输入/资源/消费/新稿保护均过。第三次正常quit超过原15秒期限,随后normal teardown仍保留整轮fail;first8只补被动退出诊断,所有原断言/期限保持。三首发与Stop/冷恢复/current/default不据业务断言局部通过宣称整体关闭。 + +packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮Skills接线缺陷已另修复;packet22第4轮22/22全部通过、前后源码/产物零漂移,default按原状态续跑中。 diff --git a/internal/research/codex-desktop-rea/report.md b/internal/research/codex-desktop-rea/report.md index 2e3dadcdf..9e6247753 100644 --- a/internal/research/codex-desktop-rea/report.md +++ b/internal/research/codex-desktop-rea/report.md @@ -1,16 +1,24 @@ # Lime 与 Codex Desktop 的差距:REA 对照与实施 -研究基线:2026-10-13;实施状态校准:2026-10-14。对照对象:本机 `/Applications/ChatGPT.app`,实际为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`。下表记录当前差距,详细迁移和验证以 [对齐执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) 为准。 +研究基线:2026-10-13;实施状态校准:2026-10-15。对照对象:本机 `/Applications/ChatGPT.app`,实际为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`。下表记录当前差距,详细迁移和验证以 [对齐执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) 为准。 -Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构化主输入和 Browser tab 恢复。基础 link/code 已实施并通过实际源码与真实桌面富输入验证;听写生命周期和取消回滚已完成接线及组件回归。本批默认门禁仍在关闭。持久富草稿、完整 Code NodeView、专业文件的排版与批注、原生应用操作,以及远端执行的产品入口仍有明确差距。 +Lime 已采用长历史虚拟列表、ApprovalCard、Files/diff、单一结构化 Composer/NodeView、持久草稿、typed acceptance、新建会话和单一 SettingsPage 章节 owner。packet21 的三入口首发、rich Stop、Home 冷恢复及三工作区两次正常冷恢复四项合同均通过;设置15章节/24首屏读取也有独立真实 Electron 证据。它们各自绑定原源码和产物,不能覆盖后续改动。 -本报告依据真实安装包的静态代码、Lime 源码和官方功能文档。没有取得 Codex 实时截图与点击证据,因此不评价两者实际流畅度、成功率和模型质量,不给整体“对齐百分比”。Lime 后续真实 Electron 验证独立记录在执行计划,不反推参考应用的运行表现。 +本轮修复了两项问题:Skills 工作台试用完成后 Composer 仍绑定 Home 而不可编辑;工具状态文案每次查询都重新合并整份 Agent 翻译资源。前者由既有 CommandWiring 将非 Composer 的真实 accepted 回执交回可见 topic commit,继续保留 epoch/ready/scope 与草稿消费 authority;后者直接复用 createI18n 已加载的五语言表,保留实时语言选择、插值与缺失键处理。两项均复用 current owner。 + +验证分别绑定身份:packet22 的 Skills 三路径、四组定向31/34/3/294及22个真实 Electron 场景通过,前后7885源码/2198产物零漂移。随后删除五语言一个废弃设置键、迁移两个旧测试,42/42通过;稳定函数引用修正后61/61与零警告lint通过;文案资源回归先3过1红(12次重复合并),修复后6套件81/81,包含未改的历史窗口14/14及原5秒期限。默认第4轮已过严格全src lint、文案扫描、标准typecheck及89—111批,historyWindow原失败两例在89批147/147中通过;112批发现旧turn/start回执夹具,仅迁canonical id/inProgress及同thread identity,60/60定向与严格lint通过。续跑已通过至122批,123批另一旧boolean/void发送夹具迁为完整typed result,三suite24/24与严格lint过;125批旧reset测试缺root callback且混同Home/fresh语义,重复文件删除,风险收回现有导航与reset tests,16/16通过;127批另一个Inputbar布尔接受夹具阻断recent usage;同文件显式布尔发送全部迁typed,完整92+21共113/113通过。freeze30仅该测试增量。前端132/132批、contracts与legacy扫描均通过;新标准verify:gui-smoke wrapper exit0、24/24真实壳断言通过,7884源码/2303目录产物已固定。默认按组合步骤关闭,原wrapper失败保留;新产物current第5轮前两场通过,第3场短问候接受1978ms超过原1800ms预算而失败,完成态已达、7884源码/2303产物零漂移;原短问候场景隔离78/78通过、接受921ms;第6轮同场973ms,但第8场审批在业务pass后Main退出挂起(前7场进程完成),Node连接已关闭、pending BrowserContext.close,底层根因unknown。原挂起证据冻结并中断,不计完整通过。fixture改为既有15秒正常退出合同及全部资源释放后才打印pass,失败后回收不会修复结果;110项定向回归与原审批隔离66/66、Main34547正常code0/signalnull均通过。仅4项fixture输入变化,产品/2303产物不变,freeze31共7886源;第7轮完整聚合exit0、22/22通过,四组31/34/3/300;21个Claw正常Main退出回执、强制回收0,Coding保留既有子进程证据而不补造Main回执。后验7886源/2303产物及产物路径集零漂移。当前summary显式引用的ledger/截图共67份独立冻结;旧第4轮目录遗留的一张Skills失败截图不计本轮证据。最终格式、docs边界、脚本治理、62本地链接与diff检查均通过,本验证切片已关闭,整体GUI仍partial。续跑状态1761为历史登记路径,其中1756现存、5项已删除,不能写成1761当前测试文件均执行。初次构建缓存失败、窄lint曾带warning与原超时均保留在索引及收据中,不改写成单次全绿,也不将文案修复归因为所有旧超时。 + +仍有明确缺口:设置 canonical 章节路由与返回、内容搜索和 dirty authority;专业文件的排版与批注;原生应用操作与远端执行产品入口;结构/命名的完整符号覆盖、OS输入与Windows验收。保留差异仅主题、多模型、首页Banner。整体状态为 partial,没有足够分母计算全GUI对齐百分比。下文带日期的实现记录为阶段证据;当前身份和待办以执行计划及索引最新条目为准。 + +[结构与命名账本](structure-alignment-ledger.md)集中列出确认导出、方法属性、Lime current owner、删除项和待办;上游原类名/目录未恢复的项目明确保留 unknown。它是已取证范围的映射,完整符号覆盖仍待继续。 + +参考应用未取得实时截图或点击证据。本报告以安装包REA、公开源码与已引用官方材料区分静态事实;Lime真实Electron验证独立记录,不反推参考应用的流畅度、成功率或模型质量。已完成的历史默认门禁只覆盖各自切片,不代表当前新产品修复已验收。 2026-10-14 实施校准:`ComposerUtilityBar` 已直接替换旧项目上下文组件,343 项定向回归及真实项目/Git/冷重启 Gate 通过。公共 pending Queue 的历史/分页隔离完成 74 项 Rust/JSONL 回归,搜索排除完成 17 项回归;真实 pending Gate 已通过历史/分页/搜索排除、富输入 mutation 与首图 media 读回。Right Surface 的 73 项生命周期回归、Browser 完整 live tab 恢复的 82 项回归及双 tab/关闭 primary 不复活的真实 Gate 已通过。rollout 空库重建缺陷已修复,23 项回归通过。 -结构化EditorView、唯一Controller、typed submission和共享surface草稿已实施,历史atom提及抢方向键及receipt恢复route缺陷已修。rich Electron Gate68/68、新版22场景聚合和完整默认门禁均通过,既有Composer实施合同关闭;external backend绕过live provider/runtime prompt lowering,IME/paste为synthetic DOM事件,本地runtime场景使用provider fixture,不是live。长UserMessage两入口折叠、精确canonical去重、主窗口原生复制权限和虚拟列表动态测量已实施;真实macOS长列表与冷启动通过,全文9682字符复制一致,后继阅读锚点漂移0.203125px。权限owner回流守卫39/39通过。视口竞争修复后的rich第五轮69/69、long-list第六轮/冷启动及live-tail65/65通过;页头consumer placement与横向滚动已修复,resize-fixed5三尺寸66/66通过。新默认门禁发现并修复复制反馈卸载泄漏,生命周期10/10、消息回归69/69、默认127/127批及fresh复制Gate69/69通过,1705/9682字符全文复制一致。各场景按其renderer摘要分别记录;较早22/22聚合仍属修复前基线。见[主输入审计](./main-composer-audit.md)、[用户正文审计](./user-message-collapse-audit.md)及[保存补证](./composer-draft-persistence-audit.md)。测试、fixture、文档和dead回流守卫随owner迁移;全产品仍partial。 +结构化EditorView、唯一Controller、typed submission和共享surface草稿已实施,历史atom提及抢方向键及receipt恢复route缺陷已修。历史主输入切片的rich Electron Gate68/68、新版22场景聚合和该切片完整默认门禁均通过,既有Composer实施合同关闭;external backend绕过live provider/runtime prompt lowering,IME/paste为synthetic DOM事件,本地runtime场景使用provider fixture,不是live。长UserMessage两入口折叠、精确canonical去重、主窗口原生复制权限和虚拟列表动态测量已实施;真实macOS长列表与冷启动通过,全文9682字符复制一致,后继阅读锚点漂移0.203125px。权限owner回流守卫39/39通过。视口竞争修复后的rich第五轮69/69、long-list第六轮/冷启动及live-tail65/65通过;页头consumer placement与横向滚动已修复,resize-fixed5三尺寸66/66通过。新默认门禁发现并修复复制反馈卸载泄漏,生命周期10/10、消息回归69/69、默认127/127批及fresh复制Gate69/69通过,1705/9682字符全文复制一致。各场景按其renderer摘要分别记录;较早22/22聚合仍属修复前基线。见[主输入审计](./main-composer-audit.md)、[用户正文审计](./user-message-collapse-audit.md)及[保存补证](./composer-draft-persistence-audit.md)。测试、fixture、文档和dead回流守卫随owner迁移;全产品仍partial。 -本批link/code依据[迁移前marks/voice补证](./marks-voice-audit.md)实施到唯一input-kit:实际src先22红/8绿,迁移后20文件224/224通过;annotation事务拆分后5文件92/92通过,Controller由803行降为762行。Markdown submission与display UTF16、typed UTF8范围分离,安全link/code属性、原文草稿和编辑生命周期同步回归。听写hook与Core单路径接线72/72、fixture312/312、最终标准typecheck和renderer构建通过。新增实际MarkdownRenderer与fixture回归6文件189/189,fresh Electron富输入Gate73/73、源码/fixture零漂移和console/page errors0;首轮fixture版本问题及默认69批中断恢复guard失败单独登记,默认正在resume,旧127批绿不覆盖本批源码。[样式补证](./composer-marks-css-audit.md)区分基础schema与完整NodeView/toolbar合同。完整rich PMU、主动链接导航/编辑、系统mic/ASR、persistent draft、专业文件、原生操作和remote分别保留unknown或待办。 +本批link/code依据[迁移前marks/voice补证](./marks-voice-audit.md)实施到唯一input-kit:实际src先22红/8绿,迁移后20文件224/224通过;annotation事务拆分后5文件92/92通过,Controller由803行降为762行。Markdown submission与display UTF16、typed UTF8范围分离,安全link/code属性、原文草稿和编辑生命周期同步回归。听写hook与Core单路径接线72/72、fixture312/312、最终标准typecheck和renderer构建通过。新增实际MarkdownRenderer与fixture回归6文件189/189,fresh Electron富输入Gate73/73;新current聚合22个真实Electron子场景通过,54项产品、14项fixture及277项renderer/Host/sidecar逐项零漂移。首轮fixture版本问题及默认69批中断恢复guard失败单独登记。默认续跑94批出现同步render超时,未修改生产或扩大原5秒上限,显式Node24完整文件14/14通过;95批发现测试继承英文locale,只修测试语言隔离,原批16文件164/164通过,本批完整默认已exit0,真实标准Electron smoke通过。旧127批绿不覆盖随后NodeView源码,旧进程runtime未记录,不声称全部续跑批次都由Node24执行。[样式补证](./composer-marks-css-audit.md)区分基础schema与完整NodeView/toolbar合同。[Code NodeView补证](./composer-code-nodeview-audit.md)与[高亮/Auto补证](./composer-code-highlight-audit.md)已闭合静态合同,Controller下的registry、PM contentDOM、Portal语言选择与两个plugin已正式接线;新rich Gate74/74,实际Auto→Plain→TypeScript、正文高亮、单次发送、cancel/history与canonical富输入均通过。新current聚合22/22、前端127/127批与标准GUI smoke已通过;默认按续跑组合步骤关闭,未改写原wrapper失败。完整rich PMU、主动链接导航/编辑、系统mic/ASR、persistent draft的三首发与最新产物Stop产品Gate、专业文件、原生操作和remote分别保留unknown或待办。 ## 1. 已完成安装 @@ -22,6 +30,7 @@ Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构 - `rea doctor --client codex --skill --json`:`healthy=true`,Node、Codex 注册、skill identity 三项通过。 - 实际 MCP stdio 初始化和 `tools/list`:返回服务器 `rea/6.4.0`、170 个工具。 - 本轮已通过 CLI 使用 Rea;当前 Agent 会话的 MCP 工具表不会因注册自动刷新,后续会话加载注册需要重启客户端。 +- 2026-10-14 晚间复核发现注册条目缺失,已重新按 scoped setup 备份并恢复,显式使用独立 Node24;当前非REA配置语义保持相同。doctor Node/注册/skill 三项healthy,真实stdio初始化与tools/list再次返回rea/6.4.0和170工具。本Agent工具表仍未刷新,继续使用已安装CLI。 ## 2. 差距矩阵 @@ -93,6 +102,8 @@ Lime 已迁移长历史虚拟列表、审批详情、Files/diff 导航、结构 ## 4. Rea 证据与边界 +2026-10-14 用户为释放空间删除仓库 `.lime/`。该目录中后续 Composer focused audit 的本机 manifest、候选与产品截图现为缺失的历史原件;仓库 audit 文字和 Evidence ID 仍保留,不能将旧摘要用于恢复版身份或运行验收。用户级 REA 原始成员按实际存留情况复用;必要候选已迁至独立临时目录并重新验证,详见 current 执行计划。此清理不改变静态观察的证据等级,也不代表新候选已经产品验收。 + 原始包:`/Applications/ChatGPT.app/Contents/Resources/app.asar`,567,757,587 字节。 SHA-256:`75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。 @@ -145,3 +156,15 @@ ASAR 目录含 21,089 个文件、15,208 个 JavaScript/TypeScript 路径。选 完成度按本轮范围记录:安装 **100%**;上述有边界的静态对照 **100%**;实时交互对照 **0%**。不能据此宣称全产品能力已穷尽或 Lime 对齐完成。 治理分类:研究阶段只增加本报告、精简证据和计划。后续实现复用 `current` 业务 owner,已替代的 compact approval、path-only Files 和旧尾窗进入 `dead / deleted / forbidden-to-restore`,不保留 compat 包装;删除清单、护栏与验证结果统一维护在 current 执行计划。已有 App Server/agent/tool-runtime/命令及未知未跟踪改动继续避让。 + +[设置导航审计](./settings-navigation-audit.md) 已校准七 member 与20原字节锚点:下一刀先收敛单一章节 owner 并迁公开 SettingsPage 名称,再处理 canonical 路由、返回、内容目标搜索与 dirty authority。九源章节owner候选已正式采用,公开SettingsPage与调用者同步,重复名单和dead定义直接删除;正式六suite78/78及设置oracle10/10、lint/格式通过,真实Electron15key/24可见页面状态、157原生read、零console/page/trace error及Main22039正常退出已通过;路由/返回/search/dirty仍后置。 + +[公开新建会话审计](./new-conversation-navigation-audit.md) 已确认 `startNewConversation` 导出与稳定 home/panel 身份职责;唯一 App navigation 动作、committed fresh CAS 及随机草稿/预热旧路径的直接替换已采用。Home 恢复、New chat 新空稿是明确的 Lime 决策,参考普通侧栏的运行时 fresh 行为仍 unknown。78项正式采用、9个旧文件删除;公开action/committed route/同一Coordinator fresh CAS已收敛,架构由root确认。正式最新逐文件46suite540/540、lint/格式通过;初轮两Stop夹具缺可见会话提交,迁移后原恢复断言通过,失败收据保留。标准typecheck/contracts/legacy/docs和新GUI烟测已通过;local16富草稿/两正常cold、Home6与15设置首屏分别通过;三首发、最新产物Stop和current/default门禁仍待关闭。 + +2026-10-15 富输入 Stop 的消费后旧读取竞态已修复,严格恢复守卫保留。原rich第三轮Desktop Gate B(common34/34、scenario21/21)通过;后续Skills冷加载身份误判已在既有owner修复,定向40/40和新GUI烟测24/24通过。local16三工作区完整富稿、拒绝保稿、原选区Undo、accepted consume/new-edit、异workspace隔离及两次正常冷重启全部通过,Main34272/38556/40802均code0/signalnull;该证据属于packet13产物。随后Home漏传Skill按需目录回调已贯通,最新逐文件5suite140/140、标准typecheck/lint/新构建与壳烟测24/24通过,旧timeout记录保留。Home3/4正常退出期限失败、Home5 trace oracle误要求直接preload请求进入GUI safeInvoke记录;最小fixture迁移后Node62/62与collector69/69,packet16产物未变,Home6完整rich/资源/原选区/identity、两正常Main17922/18120、cold零写/无canonical会话回合及原文件bytes/revision均通过,后验零漂移。15设置导航/首屏read已通过;三首发Home创建thread后未发Turn而rejected;诊断确认能力路由覆盖canonical正文,已保留真实source并以五suite165/165回归;另有业务workspace覆盖可见草稿scope,root真实Wiring/Coordinator回归先2/2失败、修后2/2通过,严格ready/epoch守卫保持。两处产品修复已在packet19完成构建、标准typecheck/contracts/legacy与桌面壳验收;最新产物Stop和current/default门禁尚未关闭;无live-provider/Windows/强杀耐久或全GUI完成声明。 + +最新packet19首发:Home完整canonical富输入/资源字节、promotion/consume/new-edit与Main96352正常退出通过;search-new富稿保存冲突,项目新建未达。已在真实Controller/Coordinator/Store复现queued discard前序put通知把旧probe恢复:纯行为红后,由既有Coordinator阻止discard在途旧read重绑,Store CAS/退休守卫保持。binding及相关非Stop套件通过;组合Stop有1项、隔离Stop有6项原5秒timeout,稳定性仍未关闭。新renderer、标准typecheck、freshness与壳smoke均exit0。packet20 first6的Home与search-new完整业务和正常退出通过;项目入口fixture补真实header hover,Node53/53/lint/格式通过。packet21 first7三入口canonical富输入/资源/consume/new-edit均过,但第三Main正常退出原15秒超时,随后normal teardown不改整轮fail;first8仅加被动退出计时与失败采样。原packet19与旧cold/settings结果分开保留,详见current计划与证据索引。 + +packet21 新产物的四项真实合同均已通过:first8 三入口完整canonical富输入/资源字节/consume/new-edit与三次原15秒内正常退出;rich Stop原场景恢复/历史/marks且零console/page errors;Home7完整富稿/原选区/identity与cold零写、两正常Main;local17三工作区拒绝保稿/Undo/消费新稿/隔离与两正常cold、三Main正常退出。first8只加被动生命周期计时,旧退出超时根因仍unknown且失败保留。此4/4(100%)仅指四项合同,整体GUI仍partial;原Stop文件在原5秒期限单次20/20已通过,前次timeout仍保留;async-tabs已迁canonical筛选、current侧栏路径与完整计数,3+11通过;current聚合guard293/294、collector隔离68/69的原5秒超时保留。仅优化测试采集器重复工作后69/69、溢出用例1571ms,原断言/timeout与独立oracle不变;聚合第3轮及default续跑待关闭。 + +本批四项产品收据:`.lime/qc/composer-persistence/queued-discard-product-final-receipt.json`,SHA `2cafe26c77fe9b0b58d530e3f78a3ae05a5b4b592645551fc73a870b275f17d2`。packet21验收前后7885source/2093artifact零漂移,只证明身份一致,不宣称所有模块执行。 diff --git a/internal/research/codex-desktop-rea/settings-navigation-audit.md b/internal/research/codex-desktop-rea/settings-navigation-audit.md new file mode 100644 index 000000000..2af76f340 --- /dev/null +++ b/internal/research/codex-desktop-rea/settings-navigation-audit.md @@ -0,0 +1,77 @@ +# Codex Desktop 设置导航对齐审计 + +七 member 静态审计及第一切片的正式采用记录;章节 owner 与公开 SettingsPage 已迁移,真实 Electron 15 章节/24 首屏状态已通过。canonical 路由、返回、内容搜索和 dirty authority 仍待交付。 + +## 证据范围 + +目标实际位于 `/Applications/ChatGPT.app`,displayName 为 ChatGPT,但 bundleId 是 `com.openai.codex`,version `26.1007.30912`、build `20156`。此证据归 Codex Desktop,不归消费者 ChatGPT Desktop。ASAR SHA `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`,bytes `567757587`。下文 `evidence/` 路径相对恢复目录 `/var/folders/87/s6cpr7hd1_v43cs833x4s_900000gn/T/lime-code-nodeview-recovery-oulq764w/settings-navigation-audit-candidate/`;七 member 的原字节、hash、ASAR offset 见其中 `evidence/member-manifest.json`;格式化副本仅供阅读,定位以原压缩 JS byte offsets 为准。 + +REA full Evidence `ev_4bf8bfc7ccb14fb058096dd32864ae7f9ab83a8f0ad9acb46cfd79b5379ab36c`;summary Evidence `ev_6d2230454a838f113f4e61d100fb41d47ea6b241100765d84eb35674dcd71478`;输入 corpus SHA `2c618ae2c6acb241a854e3f71191626c857485ed664d43dc91a887d7f1730c35`。七 JS 全部解析,101646 bytes、25710 AST nodes、670 findings、0 parse failures。application complete 仅对这七个输入成立;semantic unknown,5265 unknowns,未执行 bootstrap 或包内代码。 + +仅确认公开导出原名 `SettingsPage`、`VisibleSettingsCommandMenuSectionItems`、`SettingsExternalSection`。`use-visible-settings-sections` 的导出只有压缩 alias `a/i/n/o/r/s/t`,不得声称已恢复内部 hook 或 sidebar 的原源码名。registry 来源是 app-initial,未提取完整 registry;不得使用 app-shared icon alias 做命名映射。 + +可下结论的范围:page shell、可见性/navigation 投影、section route/state、命令菜单共享消费、host filter、搜索入口/结果模型与未保存变更对话框。search-documents 内容 chunk、完整 registry/default page、外部 dirty authority、所有 mode/auth/host 的实际运行可见性仍 unknown。包内字符串不能当作当前帐号已启用功能,也不能据七 member 宣称整个设置应用或全量 dirty enforcement 完整。 + +以下映射和下一切片由 root 正式采用;全部15个 current 依赖、冻结摘要和 byte anchors 已核。source-only 审计完成度 100%;第一切片首屏读取已有真实 Electron 证据,设置完整交互完成度不量化。 + +## 迁移前映射(原始事实) + +Lime 已有唯一 current 设置入口与真实页面;主要差距是导航事实分散、路由与局部选择脱节、搜索只过滤导航标题。目标证据是 bundle `com.openai.codex` 的 shipped artifact,不是消费者 ChatGPT Desktop。置信范围与 Evidence ID 见上文。 + +| 维度 | Codex Desktop 七 member 可确认事实 | Lime current 事实 | 差距与含义 | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| 页面入口/命名 | settings-page 原 byte 48070 公开导出 `SettingsPage` | `src/components/settings-v2/index.ts:7` 将 `SettingsLayoutV2` alias 为 `SettingsPageV2`,`AppPageContent.tsx:298` 唯一生产入口 | current 可以直接导出短领域名 `SettingsPage`,不继续增加 V2/compat 外壳;内部目标原名未恢复的不强行对齐 | +| 可见性与导航投影 | use-visible 原 byte 38628 的返回对象包含 visible/active/redirect/error/render/navigation 与 enterprise label;page/command menu 共享消费 | `_layout/index.tsx:244` ACTIVE_SETTINGS_TABS、`:272` preload switch、`:321` render switch,category hook 另维护导航 | 先由一个章节 owner 同时派生可用页、导航、预加载和渲染;目前新增/删除页有多份名单漂移风险 | +| 分组 | settings-page 包内分组为 Personal、无 heading 的 Teams、Integrations、Coding;成员受 mode/auth/host 可见性过滤 | `hooks/useSettingsCategory.ts:52` 固定 Overview/Account/General/Agent/System 五组 | 分组机制可借鉴;具体章节不可照搬为已启用功能。Lime 多模型/媒体等业务继续保留其 owner | +| 章节路由 | page 从 `/settings/:section/*` 解析章节,同投影提供 active section 和 redirect;local-environments 可映射 `worktrees?tab=environments`,cloud-settings 可映射 `cloud-environments?tab=preferences` | `AppPageContent.tsx:300` 消费 PageParams 初始 tab;`_layout/index.tsx:475` local activeTab,`:487` 内部切页只 setState | 内部导航应更新 canonical PageParams,统一直达、点击和重进恢复,避免 shell 路由仍指向旧章节 | +| 返回语义 | settings-route-state 有 `returnToPreviousHistoryEntry`,page `function si` 原 byte 47746 优先 history -1,其次 return path,最后 `/` | `_layout/index.tsx:515` handleBackHome 固定 agent Home | 当前返回不能恢复来处;下一刀要保存真实进入上下文并由现有 navigation owner 处理,不另建第二套 history | +| 搜索模型 | settings-page messageTextGroups/terms/message IDs、section/path/hash、scrollText;键盘 highlight/select/Escape;搜索目标可落到具体内容 | `SettingsSidebar.tsx:340` title/group substring filter,`:410` Ctrl/Meta+F focus;没有 row target 文档模型 | 导航过滤只能定位页,无法定位具体设置。先收敛 owner,再建立可翻译的 row/section target,不猜未提取 search-documents 的覆盖内容 | +| 预加载 | page/命令导航存在 section prefetch 消费 | Lime `_layout/index.tsx:497` prefetchedTabsRef + preloadSettingsTab;失败会移除缓存重试 | Lime 已具备页面预加载机制;缺口主要是清单收敛,不重复造 cache/loader | +| 未保存导航 | page 包含 requestNavigation gateway、pendingNavigation 与 unsaved dialog;external dirty authority 未在七 member 中完整展开 | current shell handleTabChange/handleBackHome 未提供统一 pending dirty 导航网关 | shell 未具备可确认的统一保护,但不能推断所有具体页面都丢数据;需后续逐 owner 确认实际 dirty authority,再接统一导航入口 | +| host/帐号状态 | visibility projection 消费 local/remote host 和 mode/auth,存在 cloudPreferencesAccessError、shouldRenderRouteContent 等结果 | current category 固定;profile render 内有 OEM managed account 条件 | 能确定机制差距,不能声称所有 target host/auth 功能可用;未来只把已有实际 readiness/capability 投影到设置 owner | + +所有 byte 值均为原 member 内 offset;绝对 ASAR offset 与验证过的 marker 见 `evidence/byte-anchors.json`。可读副本行号不用于原字节身份。 + +迁移前分类:`settings-v2`、`SettingsSidebar`、category hook、PageParams 入口和 preload/render 是 current,尽管名字仍有 V2。`Experimental -> Developer` 是现存 compat 直达委托,不新增第二个实验页。`SETTINGS_GROUPS` 在 `src/types/settings.ts:70` 只有定义、无生产消费者,是重复 dead 候选;`ExecutionTracker` 只剩 enum 与旧 fallback 测试,是 dead 候选。旧 `components/settings/**` 导入有负向 guard,继续禁止回流。上述为迁移前快照;SETTINGS_GROUPS、ExecutionTracker、V2 导出别名现已删除,详见下方正式采用收据。Experimental 仍仅委托 Developer。 + +不能下结论的范围:默认页是否应该永久保留 Home、完整章节 registry、搜索索引内容覆盖、所有 host/auth 分支和 dirty enforcement。无需为了本轮映射继续提取其他 chunk。后续实施涉及这些行为时再定向补证。 + +## 迁移合同与后续切片 + +主目标是让同一设置章节事实驱动导航、可用性、预加载、渲染与 canonical PageParams。章节收敛已正式采用并完成定向与真实首屏读取验证;后续推进 canonical 路由、返回与内容搜索。 + +已采用的 first slice 保持现有业务章节和默认页,通过短的领域模块承载 current section 清单;将 ACTIVE_SETTINGS_TABS、category hook 与 preload/render 的重复定义收敛为同一 owner 的派生结果。`SettingsPageV2` 直接迁移为公开 `SettingsPage`,同步唯一生产消费者与测试;不保留无消费者 alias。分组/页面中文文案不在本刀重做,也不扩展 MCP/Skills/模型业务。 + +窄写集应限定 `src/components/settings-v2/index.ts`、`_layout/index.tsx`、`hooks/useSettingsCategory.ts`、`src/types/settings.ts`、`src/components/AppPageContent.tsx` 和对应定向测试。新增领域文件进入现有 settings owner;若实际写集涉及 `_layout/SettingsSidebar.tsx` 或 navigation/PageParams,先在正式执行计划声明并避让脏区。当前 source hash 观察值见 manifest,不是未来实施可覆盖他人改动的授权。 + +完成单一 owner 后,second slice 再把当前内部切页接入现有 navigation/PageParams,保存返回上下文,统一直达、click、back 和重进投影。真实 dirty authority 尚未确认,不提前新造全局 dirty state;搜索 row target 也后置,需按当前已存在设置内容与五语言翻译定义索引,不能直接复制包内隐藏功能清单。 + +退出条件:所有 current 设置页只登记一次;导航、可用页、prefetch/render 无孤儿条目;现有 providerView/providerFocus/executionPolicyFocus 直达保真;Experimental 仅委托 Developer;删除无消费者 SETTINGS_GROUPS 与 ExecutionTracker 时同步测试;保留 legacy import 负向 guard。未知 tab/default Home 断言跟随明确决策改写,不能把旧测试当永久产品合同。 + +第一切片的定向验证覆盖 `_layout/index.test.tsx`、`_layout/SettingsSidebar.test.tsx`、`hooks/useSettingsCategory.test.tsx`、`legacy-import-guard.test.ts` 及 AppPageContent 消费测试。实施触及 GUI 后按仓库规则跑真实 current `npm run verify:gui-smoke`,并对受影响的 settings current smoke 使用真实 Electron/preload/App Server。已读可复用 consumers 为 `scripts/settings-all-pages-current-smoke.mjs`、`scripts/agent-qc/project-gate-settings-a.mjs`、`scripts/electron/settings-about-fixture-smoke.mjs`、`scripts/electron/mcp-config-fixture-smoke.mjs`;各真实验收入口结果单独记录,单凭静态审计或定向测试不宣称 Gate A/B。 + +KISS/DRY 通过单一登记避免四份名单;YAGNI 通过先收敛已有页、推迟未知 registry/dirty/search 行为;SOLID 通过章节 owner 负责事实、页面继续负责业务、现有 navigation owner 负责路由。source-only 审计与 first slice 源码迁移均 100%,该切片首屏读取已通过;后续路由、返回、搜索与 dirty authority 尚未交付。 + +## 第一切片正式采用(2026-10-15) + +root核对冻结manifest/patch/29项证据与九源before/after后,正式采用单一sections owner、SettingsPage公开名和调用者迁移;删除重复可用性/preload/render名单、SETTINGS_GROUPS/ExecutionTracker与V2别名。业务页(含媒体脏区)未夹写。收据 `.lime/qc/settings-sections/formal-adoption.json` SHA `84a53337dac5103e9df8c5a28e0457a2b7c54b8eb2fc297949ab4543a4bbd1c8`;架构4.4由root确认。上文映射表保留迁移前事实,正式source采用100%,正式六suite78/78、九源lint/格式通过;receipt `.lime/qc/settings-sections/formal-directed-receipt.json` SHA `d9e7c32d6ad0a2c473e4fe55f356c910fe7544e2b1cfd289f07a8f034699df10`。设置验收清单已迁15current keys,补Skills删旧连接器/自动化,用唯一testid与active确认;core/states定向10/10 exit0,收据 `.lime/qc/settings-sections/formal-oracle-receipt.json`,真实首屏读取验收settings-current-native-20261015-2 exit0:15章节key/24可见状态、157原生bridge read、零console/page/trace errors,Main22039正常code0/signalnull;证据`.lime/qc/settings-sections/settings-current-native-20261015-2/summary.json`。这不证明写入、canonical内部路由、返回、内容搜索、dirty authority、live或Windows。 + +## 第二切片只读预检(2026-10-15) + +本轮尚未修改设置产品源码。`useAppNavigation` 已有 requested/committed 与 requestId,设置进入上下文应由它承接;不新增全局 history owner。`SettingsPage` 的 activeTab/providerView/developerLabTab 目前从 initial props 复制为局部状态,内部点击不更新 PageParams。下一刀应让同一 canonical 参数驱动章节与焦点,保留 providerView/providerFocus/executionPolicyFocus 和 Experimental 委托语义;离开章节后不重放旧焦点。 + +`SettingsNavigation` 当前将 rail Home 与页头返回共用 onBackHome;对齐返回来处时必须分开两种用户动作,Home 图标仍进入首页,返回按钮恢复进入设置前的已提交目标。恢复目标不能意外重放 newConversationRequest、初始发送或其他一次性 intent。精确返回会话应与现有 reload 最小会话参数投影配合,不能仅保存旧 params 后再次触发发送。 + +另一个必要合同:Home 首发 materialize 后,根 PageParams 未必已有真实会话 identity;App 已消费 `onAgentSessionChange` 的实际可见会话投影。单纯保存进入设置前的 PageParams 可能返回 Home 或重放旧初始输入。实施前须沿该 current 回调及真实 workspace 确认返回目标,覆盖“Home 首发后 → 设置 → 返回同一会话”和“已有会话 → 设置内部切章 → 返回”两条真实路径;不得把旧初始参数当成实际可见会话 authority。 + +该切片需要同步 `_layout/SettingsNavigation.tsx`、layout、useAppNavigation、App/AppPageContent 接线、五语言返回文案及稳定测试;现有 `settings-home-button` 消费者包括 settings-about Electron fixture、project-gate-settings-a 与 tool-surface-page smoke,必须按真实返回语义迁移,而非强制保留“永远回Home”的旧正向断言。当前 native15章节/24首屏证据仍只证明读取,不覆盖该后续导航合同。search 与 dirty authority 仍待实际 owner 调查。 + +进一步只读核实:`useWorkspaceContextSurfaceRuntime` 已同时拥有实际 `sessionId` 与 `runtimeWorkspaceId`,但现有 `onSessionChange` 只发布 sessionId;App 的 `activeAgentSessionId` 同样没有 workspace。第二切片应沿这条既有投影回调携带可见 workspace,或在同一 owner 提供等价的只读位置快照,避免用根 PageParams 的旧 projectId 代替当前 workspace。它属于导航显示上下文,不扩展为业务状态或第二 read model;实现与定向/真实路径验证尚未开始。 + +## Current dirty owner 只读预检(2026-10-15) + +本次仅检索 `src/components/settings-v2` 与 `useAppNavigation` 中的 dirty/unsaved 命名并追踪命中的实际消费者,未修改设置产品代码,也不是完整表单清单。已确认两个 current 章节具有自己的未保存状态:Memory 比较本地 draft 与 snapshot,Cancel 恢复 snapshot,Save 经既有 saveConfig 成功后同步两者;Web Search 比较本地 draft 字段与已加载配置,Save 构造同一配置 owner 的更新。两者分别由 `sections.tsx` 直接渲染,当前章节回调未传递 dirty 导航接口。 + +`ChannelsDebugWorkbench` 也有 channels/gateway 与 initialJson 的比较,但唯一生产消费者位于 `components/channels/ImConfigPage`,不在这次15章节 registry 内。不能因文件位于 settings-v2 下就将其登记成当前设置章节,或用其 dirty 状态替代 Memory/Web Search。 + +当前已检查的 SettingsPage handleTabChange/handleBackHome 没有 pending-navigation/dirty gateway。后续保护应让现有表单 owner 报告当前编辑状态,并由唯一导航入口消费;不要在 shell 复制配置或重算业务 dirty。其他表单、弹窗、独立页面和参考应用外部 dirty authority 未穷举,完整覆盖仍 unknown。此预检不证明实际导航丢稿,真实失败/保存/取消路径仍须验证。 diff --git a/internal/research/codex-desktop-rea/structure-alignment-ledger.md b/internal/research/codex-desktop-rea/structure-alignment-ledger.md new file mode 100644 index 000000000..12c3f797d --- /dev/null +++ b/internal/research/codex-desktop-rea/structure-alignment-ledger.md @@ -0,0 +1,43 @@ +# GUI 结构与命名对齐账本 + +状态:current / partial。校准日期:2026-10-15。本页归并已有 REA 审计与正式实现映射,不新增参考应用运行结论,也不是全量符号清单。 + +参考为 Codex Desktop `26.1007.30912` / build `20156`,Bundle ID `com.openai.codex`,ASAR SHA-256 `75e397e2280dde93b70a85aa926f4282c728eacc84dfeb15ba7fb6ddd84628af`。版本、Evidence ID、原成员摘要和锚点分别由链接的领域审计及[证据索引](evidence-index.json)持有。公开 Rust checkout 只用于共享协议/runtime;不能补出 Desktop 前端原文件名。 + +## 已确认名称与 current owner + +| 参考名称及证据性质 | Lime current owner / 调用者 | 已迁移内容与边界 | 待办 | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `QueuedMessageList`,确认公开导出 | [QueuedMessageList](../../../src/components/agent/chat/components/QueuedMessageList.tsx),Workspace queue projection / actions | 直接替换旧 `ThreadQueueStatus`;列表呈现与同一 App Server queue authority 分工;见[导航审计](navigation-composer-audit.md)及[Steer 审计](queue-steer-audit.md) | 暂停、送达未知、拖拽与完整交互须逐合同验收,不能以同名认定全部等价 | +| `ComposerUtilityBar`,确认公开导出 | [ComposerUtilityBar](../../../src/components/agent/chat/components/Inputbar/components/ComposerUtilityBar.tsx),Home/Inputbar 共享消费 | 项目、分支与运行位置组合;既有 workspace/Git owner 继续执行;旧项目上下文组件删除,见[导航审计](navigation-composer-audit.md) | staged worktree / remote | +| `ReviewThreadSidePanelTab`,确认公开导出 | [ReviewThreadSidePanelTab](../../../src/components/agent/chat/workspace/ReviewThreadSidePanelTab.tsx),RightSurface → 同次 changeView → ChangesPanel | 删除 path-only surface,复用既有 diff/review/file-preview owner;映射及身份见[执行计划](../../exec-plans/codex-gui-alignment-current-plan.md) | 完整 diff/editor 与视觉逐项核对 | +| `SettingsPage`,确认公开导出 | [SettingsPage](../../../src/components/settings-v2/_layout/index.tsx),AppPageContent 唯一产品消费者 | `SettingsPageV2`/`SettingsLayoutV2` 别名删除;[sections](../../../src/components/settings-v2/sections.tsx)统一登记、导航、preload/render,见[设置审计](settings-navigation-audit.md) | canonical 章节参数、返回、内容搜索、dirty authority;目录仍为既有 `settings-v2`,未声称目录名已对齐 | +| `VisibleSettingsCommandMenuSectionItems`、`SettingsExternalSection`,确认公开导出 | 目前没有登记为同义 owner 的 Lime 对应符号 | [设置审计](settings-navigation-audit.md)仅证明参考模块职责与导出,未据字符串建立空壳或 alias | 现有命令菜单/外部设置消费者需按实际能力映射 | +| `startNewConversation`,确认公开导出 | [useAppNavigation](../../../src/hooks/useAppNavigation.ts) → App/Sidebar → Workspace root navigation | 唯一动作、committed request、稳定 Home scope 与 fresh CAS;删除随机草稿事件、预热/materialized 双轨和 dead general tab owner,见[新建导航审计](new-conversation-navigation-audit.md) | 普通 Codex 侧栏的运行时 fresh 行为、完整冷草稿 picker 仍 unknown | + +## 已确认结构与方法,原类名未知 + +| 参考职责 / 可确认成员 | Lime current owner | 对齐合同与限制 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 主 Composer provider / Controller / EditorView;原类与 hook 名 unknown | [ComposerController](../../../src/components/input-kit/ComposerController.ts)、[ComposerEditor](../../../src/components/input-kit/ComposerEditor.tsx)、`useComposerController`,Workspace 共享给 Home/Inputbar | 唯一 EditorState、Transaction、revision 与 typed submission,见[主输入审计](main-composer-audit.md)。`RichTextInput`/`CodexComposerAdapter` 仅为样式或诊断名,不据此迁名 | +| registry:`createNodeView`、`getSnapshot`、`subscribe`、`unregisterNodeView`、`publishNodeViewUpdate` | [CodeBlockNodeViewRegistry](../../../src/components/input-kit/CodeBlockNodeViewRegistry.ts) | Controller 生命周期持有,稳定 snapshot/listeners,Portal 只消费同一 registry。类名为 Lime 实现命名,方法名可按原成员核对 | +| NodeView:`destroy`、`ignoreMutation`、`stopEvent`、`update`、`selectLanguage`、`getAutoDetectLanguage`、`updateCodeElement`、`focusEditor`、`getContent` | [CodeBlockNodeView](../../../src/components/input-kit/CodeBlockNodeView.ts)、[CodeBlockToolbar](../../../src/components/input-kit/CodeBlockToolbar.tsx) | 保留 PM contentDOM,toolbar 与正文事件分工、语言事务保 attrs/selection;见[NodeView 审计](composer-code-nodeview-audit.md)。类名与 React 组件名不是已恢复的上游原名 | +| highlight / Auto detection 两个 PM plugin;原开发名称 unknown | [composerCodeBlockHighlight](../../../src/components/input-kit/composerCodeBlockHighlight.ts)、[composerCodeBlockAutoDetect](../../../src/components/input-kit/composerCodeBlockAutoDetect.ts),同一 Controller/View 下的独立 plugin | 位置/节点 cache、UTF16 Decoration、异步失效与销毁保护;见[高亮审计](composer-code-highlight-audit.md),不能把 NodeView CSS class 当作高亮实现 | +| retained/prompt documents → renderer persisted atom → Main file store;原类名 unknown | [ComposerDraftCoordinator](../../../src/components/input-kit/ComposerDraftCoordinator.ts) → `persistedAtomHost` / `persistedAtomStore` / `persistedAtomFile` | 唯一 keyed 草稿 owner,Home/local promotion、CAS、scope/generation/revision/token 与正常退出;见[保存审计](composer-draft-persistence-audit.md)。不搬压缩类名,不建第二 history store | +| `thread-virtualizer` / `virtualized-turn-list` 是包模块名,公开符号多为压缩 alias | [threadVirtualizer](../../../src/components/agent/chat/projection/threadVirtualizer.ts)、[VirtualizedTurnList](../../../src/components/agent/chat/components/VirtualizedTurnList.tsx) | 纯几何与 React 消费分工;canonical paging 与 viewport DOM range 分离。删除旧渐进尾窗/全量展开,不声称原目录或类名已恢复 | +| pending request card / panel 组合;完整原命名未在本账本确认 | [PendingRequestItemPanel](../../../src/components/agent/chat/components/PendingRequestItemPanel.tsx) → [ApprovalCard](../../../src/components/agent/chat/components/ApprovalCard.tsx) | same server request/decision authority,替换单行审批提示;实现与验证见[执行计划](../../exec-plans/codex-gui-alignment-current-plan.md),不由组件形状推断所有自动审查变体已完成 | +| browser/artifact/terminal/subagent 右侧 surface 组合;完整原目录和 registry 名 unknown | [RightSurfaceHost](../../../src/components/agent/chat/workspace/right-surface/RightSurfaceHost.tsx)、既有 registry、BrowserTabHost | 单一 host/scoped display state,既有原生 owner;Browser 生命周期已有相应 Gate,不能扩大到全部 surface | + +以上命名 unknown 不妨碍对齐已观察的职责、调用关系和数据合同;获得新可确认导出或 source map 后再直接迁移调用者。不要以压缩局部名、chunk hash、概念同义或同名文件数量推导源码等价。 + +## 仍需映射或实现的范围 + +- 设置后续导航/返回、搜索 target 与实际 dirty owner;不能复制未知 registry、隐藏功能或建立第二 history。 +- 完整 thread shell/header/overflow 与命令菜单消费;现有各局部审计不是全包清单。 +- Office/PDF 的真实排版、批注与版本 owner;见[专业文件审计](professional-files-audit.md)。目前文本抽取不能替代排版预览。 +- 原生应用工具、授权/执行/反馈,以及环境连接/remote 的产品入口;共享 App Server 边界继续有效,不能另造 runtime。 +- 目录、函数、类型、类与所有调用者的完整可追溯覆盖。当前分母未完整枚举,不给出全产品命名或结构百分比。 + +current 为表中的既有 owner;compat 只保留设置 `Experimental → Developer` 的现存委托;未完成能力按 gap/unknown 登记。已替换的别名、随机 general 草稿 owner 和旧组件属于 dead/deleted,禁止为旧测试恢复。单个切片的行为、结构、清理和进程验证分别由执行计划记录。 + +Lime 保留主题、多模型与首页 Banner,分别走原 token/skin、catalog/provider 和 Home presentation;三项均不扩展为布局、导航或状态机的额外保留例外。 diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock index da8308c72..5deabcd5a 100644 --- a/lime-rs/Cargo.lock +++ b/lime-rs/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "agent-protocol" -version = "1.157.0" +version = "1.158.0" dependencies = [ "schemars", "serde", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "agent-runtime" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "anyhow", @@ -170,7 +170,7 @@ dependencies = [ [[package]] name = "ansi-escape" -version = "1.157.0" +version = "1.158.0" dependencies = [ "ansi-to-tui", "ratatui", @@ -248,7 +248,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-server" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "app-server-client" -version = "1.157.0" +version = "1.158.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -330,7 +330,7 @@ dependencies = [ [[package]] name = "app-server-daemon" -version = "1.157.0" +version = "1.158.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "app-server-protocol" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "schemars", @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "app-server-test-client" -version = "1.157.0" +version = "1.158.0" dependencies = [ "anyhow", "app-server-client", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "app-server-transport" -version = "1.157.0" +version = "1.158.0" dependencies = [ "app-server-protocol", "axum", @@ -1010,7 +1010,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cli" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "anyhow", @@ -1066,7 +1066,7 @@ dependencies = [ [[package]] name = "code-mode" -version = "1.157.0" +version = "1.158.0" dependencies = [ "anyhow", "code-mode-host", @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "code-mode-host" -version = "1.157.0" +version = "1.158.0" dependencies = [ "axum", "code-mode-protocol", @@ -1100,7 +1100,7 @@ dependencies = [ [[package]] name = "code-mode-protocol" -version = "1.157.0" +version = "1.158.0" dependencies = [ "prost", "protoc-bin-vendored", @@ -1114,7 +1114,7 @@ dependencies = [ [[package]] name = "code-mode-runtime" -version = "1.157.0" +version = "1.158.0" dependencies = [ "base64 0.22.1", "code-mode-protocol", @@ -1939,7 +1939,7 @@ dependencies = [ [[package]] name = "document-preview" -version = "1.157.0" +version = "1.158.0" dependencies = [ "flate2", "tempfile", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "execpolicy" -version = "1.157.0" +version = "1.158.0" dependencies = [ "anyhow", "clap", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -3664,7 +3664,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.157.0" +version = "1.158.0" dependencies = [ "async-trait", "lime-core", @@ -3680,7 +3680,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "async-trait", @@ -3737,7 +3737,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.157.0" +version = "1.158.0" dependencies = [ "aes", "async-trait", @@ -3765,7 +3765,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.157.0" +version = "1.158.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -3787,7 +3787,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.157.0" +version = "1.158.0" dependencies = [ "chrono", "hex", @@ -3800,7 +3800,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.157.0" +version = "1.158.0" dependencies = [ "async-trait", "axum", @@ -3826,7 +3826,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.157.0" +version = "1.158.0" dependencies = [ "axum", "base64 0.22.1", @@ -3845,7 +3845,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.157.0" +version = "1.158.0" dependencies = [ "async-trait", "lime-core", @@ -3885,7 +3885,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "async-stream", @@ -3928,7 +3928,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.157.0" +version = "1.158.0" dependencies = [ "axum", "futures", @@ -3943,7 +3943,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.157.0" +version = "1.158.0" dependencies = [ "anyhow", "async-trait", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.157.0" +version = "1.158.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -4008,7 +4008,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.157.0" +version = "1.158.0" dependencies = [ "axum", "chrono", @@ -4342,7 +4342,7 @@ dependencies = [ [[package]] name = "model-provider" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "anyhow", @@ -5169,7 +5169,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "patch-apply" -version = "1.157.0" +version = "1.158.0" dependencies = [ "tempfile", "thiserror 1.0.69", @@ -6277,7 +6277,7 @@ dependencies = [ [[package]] name = "runtime-core" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -7554,7 +7554,7 @@ dependencies = [ [[package]] name = "thread-store" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "chrono", @@ -7881,7 +7881,7 @@ dependencies = [ [[package]] name = "tool-runtime" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -8130,7 +8130,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "1.157.0" +version = "1.158.0" dependencies = [ "agent-protocol", "ansi-escape", diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml index bbbccdf77..3b0f35fba 100644 --- a/lime-rs/Cargo.toml +++ b/lime-rs/Cargo.toml @@ -6,7 +6,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.157.0" +version = "1.158.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" diff --git a/package.json b/package.json index ca66412e5..30c77300a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": [ "packages/*" ], - "version": "1.157.0", + "version": "1.158.0", "description": "Open-source full-stack desktop AI agent for global users: coding, research, content, files, terminals, tools, multimodal work, and multi-agent workflows.", "keywords": [ "ai", @@ -363,9 +363,12 @@ "dayjs": "^1.11.19", "fabric": "^5.5.2", "hast-util-to-html": "^9.0.5", + "highlight.js": "11.11.1", "i18next": "^25.7.3", "jotai": "^2.16.1", + "jsonc-parser": "3.3.1", "lodash-es": "^4.17.22", + "lowlight": "3.3.0", "lucide-react": "^0.460.0", "marked": "^17.0.1", "mermaid": "^11.12.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d244f8a3..edaaa2faf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime", - "version": "1.157.0", + "version": "1.158.0", "packageManager": "pnpm@9.15.9", "description": "Lime CLI is an agent that runs in your terminal.", "bin": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6deab3c54..fa4b673cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,6 +248,15 @@ importers: zustand: specifier: ^5.0.10 version: 5.0.11(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + highlight.js: + specifier: 11.11.1 + version: 11.11.1 + jsonc-parser: + specifier: 3.3.1 + version: 3.3.1 + lowlight: + specifier: 3.3.0 + version: 3.3.0 devDependencies: '@babel/plugin-transform-react-jsx-source': specifier: ^7.27.1 @@ -6491,6 +6500,21 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + "@types/hast@3.0.5": + resolution: + integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== + highlight.js@11.11.1: + resolution: + integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w== + engines: + node: ">=12.0.0" + jsonc-parser@3.3.1: + resolution: + integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + lowlight@3.3.0: + resolution: + integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ== + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -13398,3 +13422,14 @@ snapshots: use-sync-external-store: 1.6.0(react@18.3.1) zwitch@2.0.4: {} + + "@types/hast@3.0.5": + dependencies: + "@types/unist": 3.0.3 + highlight.js@11.11.1: {} + jsonc-parser@3.3.1: {} + lowlight@3.3.0: + dependencies: + "@types/hast": 3.0.5 + devlop: 1.1.0 + highlight.js: 11.11.1 diff --git a/scripts/agent-qc/project-gate-settings-a.mjs b/scripts/agent-qc/project-gate-settings-a.mjs index 5f6c2f6de..50d307900 100644 --- a/scripts/agent-qc/project-gate-settings-a.mjs +++ b/scripts/agent-qc/project-gate-settings-a.mjs @@ -377,7 +377,7 @@ async function runTabMatrix(page, { locale, viewport, tabs, observations }) { } async function verifyNavigationRecovery(page) { - await page.locator('[data-testid="settings-home-button"]').click(); + await page.locator('[data-testid="app-sidebar-rail-home"]').click(); await page .locator('[data-testid="app-sidebar-account-button"]') .waitFor({ state: "visible", timeout: 15_000 }); diff --git a/scripts/agent-runtime/async-tabs-smoke.mjs b/scripts/agent-runtime/async-tabs-smoke.mjs index 5c5862cae..cf2b0b8cf 100644 --- a/scripts/agent-runtime/async-tabs-smoke.mjs +++ b/scripts/agent-runtime/async-tabs-smoke.mjs @@ -20,23 +20,30 @@ function runVitest(label, args) { } function main() { - runVitest("刷新恢复与后台队列自动续跑", [ + const hydration = runVitest("刷新与切换的 canonical 队列水合", [ "src/components/agent/chat/hooks/useAgentChat.test.tsx", "-t", - "页面刷新恢复到排队会话时应自动恢复后台执行|切换到 running thread_read 时只恢复线程|切换话题时应恢复后端返回的排队项", + "页面刷新恢复到排队会话时只水合运行态,不自动调用 legacy resume|切换到 running thread_read 时重新建立 canonical Thread 订阅|切换话题时应消费 session list 的 canonical queue count", ]); + if (hydration.executedTests !== 3) { + throw new Error("async-tabs 必须执行全部三项 canonical 队列水合测试"); + } - runVitest("任务标题与 tab 运行态提示", [ - "src/components/agent/chat/components/ChatSidebar.test.tsx", + const navigation = runVitest("侧栏任务状态与专用工作台标签", [ + "src/components/AppSidebar.conversations.test.tsx", "src/components/agent/chat/components/TaskCenterTabStrip.test.tsx", "src/components/agent/chat/utils/taskCenterTabs.test.ts", "src/components/agent/chat/workspace/taskCenterTabProjection.test.ts", + "src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx", "-t", - "当前运行中的任务标题旁应显示加载状态|任务中心侧栏应使用对话与归档分组标题|应渲染第二层会话 tabs 和加号入口|默认标签应纳入非当前运行中任务|应把草稿与可见任务投影为第二层标签", + "回到首页时应按后台未完成会话状态显示侧栏运行图标|回到首页时应按后台排队会话状态显示侧栏排队图标|后台状态不会覆盖已完成的侧栏会话终态|reconcile 当前任务应保留本地追平目标,并跳过 detached 预览|切换中的目标会话尚未成为当前会话时,应标记为待恢复态|应渲染第二层会话 tabs 和加号入口|应清理任务标签里的运行时错误标题|初始 Thread 标题|general home=|specialized workbench keeps canonical", ]); + if (navigation.executedTests !== 11) { + throw new Error("async-tabs 必须执行全部十一项侧栏与专用工作台测试"); + } console.log( - `[${LOG_PREFIX}] async-tabs.summary: hydration_resume=covered; background_running_tabs=covered; title_loading=covered`, + `[${LOG_PREFIX}] async-tabs.summary: canonical_queue_hydration=covered; running_thread_subscription=covered; title_loading=covered; general_second_strip=absent; specialized_navigation=covered`, ); console.log(`\n[${LOG_PREFIX}] 通过`); } diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-code-dom.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-dom.test.mjs new file mode 100644 index 000000000..187725d85 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-dom.test.mjs @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readComposerHistorySnapshot } from "./claw-chat-current-fixture-composer-history.mjs"; +const BODY = " const value = '中😀';\n````\n\n"; +let host; +function mount() { + host = document.createElement("div"); + host.innerHTML = + '

前 中😀

Auto JavaScript
  const value = \'中😀\';\n````\n\n

[Pasted text] /user:capability-report 尾

'; + document.body.append(host); + const editor = host.firstChild; + vi.spyOn(editor, "getClientRects").mockReturnValue([{}]); + return editor; +} +const read = () => + readComposerHistorySnapshot( + { evaluate: (fn, arg) => fn(arg) }, + "modern-history", + ); +afterEach(() => { + host?.remove(); + vi.restoreAllMocks(); +}); +describe("modern code history document projection", () => { + it("captures canonical body and atoms while excluding toolbar and highlight arrangement", async () => { + const editor = mount(); + const before = await read(); + expect(before.text).toBe( + `前 中😀\n${BODY}\n[Pasted text] /user:capability-report 尾 `, + ); + expect(before.documentProjection[1]).toEqual({ + type: "code_block", + language: "typescript", + content: [{ type: "text", text: BODY }], + }); + expect(before.tokens).toHaveLength(2); + expect(before.tokens.map((token) => token.id)).toEqual([ + "paste-1", + "user:capability-report", + ]); + expect(before.marks.links).toEqual([ + { + label: "中😀", + href: "https://example.invalid/marks", + title: "标题", + role: "link", + disabled: false, + isAutolink: false, + isGeneratedAutolink: false, + }, + ]); + editor.querySelector( + '[data-composer-code-block-toolbar="true"]', + ).textContent = "TypeScript picker search"; + editor.querySelector("code").replaceChildren(document.createTextNode(BODY)); + const after = await read(); + expect(after.text).toBe(before.text); + expect(after.documentProjection).toEqual(before.documentProjection); + expect(after.tokens).toEqual(before.tokens); + expect(after.marks.codeBlocks[0].keywordTokens).toEqual([]); + expect(after.marks).not.toEqual(before.marks); + }); + it.each([ + "body", + "language", + "old-dom", + "lost-paste", + "lost-skill", + "link-href", + "link-title", + "toolbar-body", + ])("exposes %s drift through the independent views", async (mode) => { + const editor = mount(); + const before = await read(); + const code = editor.querySelector("code"); + if (mode === "body") code.textContent = BODY.trim(); + if (mode === "language") code.className = "hljs language-javascript"; + if (mode === "old-dom") { + const pre = code.parentElement; + pre.setAttribute("data-composer-code-block", "true"); + code.setAttribute("data-language", "typescript"); + pre.parentElement.replaceWith(pre); + } + if (mode === "lost-paste") + editor.querySelector('[data-composer-token="paste"]').remove(); + if (mode === "lost-skill") + editor.querySelector('[data-composer-token="skill"]').remove(); + if (mode === "link-href") + editor + .querySelector("[text-link-href]") + .setAttribute("text-link-href", "wrong"); + if (mode === "link-title") + editor + .querySelector("[text-link-title]") + .setAttribute("text-link-title", "wrong"); + if (mode === "toolbar-body") + code.prepend(document.createTextNode("TypeScript")); + const after = await read(); + if (mode.startsWith("lost-")) expect(after.text).not.toBe(before.text); + else expect(after.marks).not.toEqual(before.marks); + if ( + ["body", "old-dom", "lost-paste", "lost-skill", "toolbar-body"].includes( + mode, + ) + ) + expect(after.documentProjection).not.toEqual(before.documentProjection); + if (mode.startsWith("lost-")) expect(after.tokens).toHaveLength(1); + }); +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.mjs new file mode 100644 index 000000000..1180a9776 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.mjs @@ -0,0 +1,423 @@ +import { isDeepStrictEqual } from "node:util"; +import { + composerEditorSelector, + withComposerEditorDom, +} from "../lib/composer-editor-dom.mjs"; +import { appServerCallFromPage } from "../electron/mcp-config-fixture-smoke.mjs"; +import { + isObservedPreloadAppServerResponse, + parseJsonRpcRequestsFromInvokeTrace, +} from "../electron/lib/mcp-config-fixture-evidence.mjs"; +import { assert } from "./claw-chat-current-fixture-utils.mjs"; + +const INPUT_METHODS = new Set([ + "turn/start", + "turn/steer", + "thread/queue/add", + "thread/queue/update", + "thread/queue/start", +]); +const CHOICES = ["auto", "plaintext", "typescript"]; + +// Check expresses the current language choice; aria-selected is cmdk keyboard focus. +export function readComposerCodePickerDom({ sessionId }) { + const editors = findVisibleComposerEditorsDom(sessionId); + const editor = editors.length === 1 ? editors[0] : null; + const blocks = editor?.querySelectorAll( + 'div[data-composer-code-block="true"]', + ); + const block = blocks?.length === 1 ? blocks[0] : null; + const trigger = block?.querySelector( + '[data-composer-code-block-toolbar="true"] .composer-code-language-trigger', + ); + const code = block?.querySelector(":scope > pre > code.hljs"); + const menus = Array.from( + document.querySelectorAll(".composer-code-language-menu"), + ).filter( + (node) => + node.isConnected && + node.getClientRects().length > 0 && + window.getComputedStyle(node).visibility !== "hidden" && + window.getComputedStyle(node).display !== "none", + ); + const menu = menus.length === 1 ? menus[0] : null; + const searches = menu?.querySelectorAll('input[role="combobox"]') ?? []; + const search = searches.length === 1 ? searches[0] : null; + const languages = Array.from(code?.classList ?? []).filter((value) => + value.startsWith("language-"), + ); + return { + electron: window.__LIME_ELECTRON__ === true, + preload: typeof window.electronAPI?.invoke === "function", + editorCount: editors.length, + sessionId: editor?.dataset.sessionId ?? null, + disabled: + !editor || + editor.getAttribute("contenteditable") !== "true" || + editor.getAttribute("aria-disabled") === "true", + editorFocused: document.activeElement === editor, + text: editor ? readComposerEditorTextDom(editor) : null, + blockCount: blocks?.length ?? 0, + language: + languages.length === 1 ? languages[0].slice("language-".length) : null, + body: code?.textContent ?? null, + keywordTokens: code + ? Array.from(code.querySelectorAll("span.hljs-keyword")).map( + (node) => node.textContent, + ) + : [], + triggerLabel: trigger?.textContent?.trim() ?? null, + triggerAriaLabel: trigger?.getAttribute("aria-label") ?? null, + expanded: trigger?.getAttribute("aria-expanded") === "true", + menuCount: menus.length, + menuOwned: + !!menu && !!menu.id && trigger?.getAttribute("aria-controls") === menu.id, + searchCount: searches.length, + searchQuery: search?.value ?? null, + searchFocused: !!search && document.activeElement === search, + choices: menu + ? Array.from(menu.querySelectorAll("[data-code-language]")).map( + (item) => ({ + value: item.getAttribute("data-code-language"), + label: item.textContent?.trim() ?? null, + checked: !!item.querySelector("svg:not(.invisible)"), + highlighted: item.getAttribute("aria-selected") === "true", + }), + ) + : [], + traceRaw: localStorage.getItem("lime_invoke_trace_buffer_v1"), + }; +} +export function withComposerCodePickerDom(callback) { + return withComposerEditorDom( + new Function( + "arg", + `${readComposerCodePickerDom.toString()}\nreturn (${callback.toString()})(arg);`, + ), + ); +} +export function installComposerCodePickerObserverDom(editor) { + const events = []; + const handler = (event) => { + const target = event.target instanceof Element ? event.target : null; + const trigger = editor.querySelector(".composer-code-language-trigger"); + if ( + event.type === "click" && + target?.closest(".composer-code-language-trigger") === trigger + ) { + events.push({ + kind: "open", + trusted: event.isTrusted, + sessionId: editor.dataset.sessionId, + }); + return; + } + const menu = target?.closest(".composer-code-language-menu"); + if (!menu?.id || menu.id !== trigger?.getAttribute("aria-controls")) return; + if (event.type === "click") { + const choice = target.closest("[data-code-language]"); + if (choice) + events.push({ + kind: "select", + language: choice.getAttribute("data-code-language"), + trusted: event.isTrusted, + sessionId: editor.dataset.sessionId, + }); + } else if ( + event.type === "input" && + target.matches('input[role="combobox"]') + ) { + events.push({ + kind: "search", + query: target.value, + trusted: event.isTrusted, + sessionId: editor.dataset.sessionId, + }); + } else if (event.key === "Escape") { + events.push({ + kind: "close", + key: "Escape", + trusted: event.isTrusted, + sessionId: editor.dataset.sessionId, + }); + } + }; + for (const type of ["click", "keydown", "input"]) + document.addEventListener(type, handler, true); + window.__composerCodePickerObserver = { handler, events }; +} +export function takeComposerCodePickerEventsDom() { + const observer = window.__composerCodePickerObserver; + for (const type of ["click", "keydown", "input"]) + document.removeEventListener(type, observer.handler, true); + delete window.__composerCodePickerObserver; + return observer.events; +} +export function summarizeComposerCodePickerBoundary({ + stages, + events, + beforeRead, + afterRead, + sessionId, + threadId, + body, + expectedText, +}) { + const live = (snapshot) => + snapshot?.electron === true && + snapshot.preload === true && + snapshot.editorCount === 1 && + snapshot.sessionId === sessionId && + snapshot.disabled === false && + snapshot.blockCount === 1 && + snapshot.body === body && + snapshot.text === expectedText; + const liveStages = + Array.isArray(stages) && + stages.length === 3 && + stages.every( + (stage, index) => + stage.selection === CHOICES[index] && + [stage.searched, stage.selected, stage.inspected, stage.closed].every( + live, + ), + ); + const searchObserved = + liveStages && + stages.every( + (stage) => + stage.searched.menuCount === 1 && + stage.searched.menuOwned === true && + stage.searched.expanded === true && + stage.searched.searchCount === 1 && + stage.searched.searchFocused === true && + stage.searched.searchQuery === stage.selection && + isDeepStrictEqual( + stage.searched.choices.map((item) => item.value), + [stage.selection], + ) && + stage.inspected.searchQuery === "", + ); + const choicesObserved = + liveStages && + stages.every((stage) => { + const inspected = stage.inspected; + const checked = inspected.choices.filter((item) => item.checked); + return ( + inspected.menuCount === 1 && + inspected.menuOwned === true && + inspected.expanded === true && + checked.length === 1 && + checked[0].value === stage.selection && + !!inspected.triggerAriaLabel && + (stage.selection === "auto" + ? /^(自动|自動|Auto|자동)/i.test(inspected.triggerLabel) && + inspected.triggerLabel.includes("JavaScript") + : inspected.triggerLabel === checked[0].label) + ); + }); + const languageAndHighlight = + liveStages && + stages.every((stage) => + [stage.selected, stage.inspected, stage.closed].every( + (snapshot) => + snapshot.language === + (stage.selection === "auto" ? "javascript" : stage.selection) && + isDeepStrictEqual( + snapshot.keywordTokens, + stage.selection === "plaintext" ? [] : ["const"], + ), + ), + ); + const closeFocusObserved = + liveStages && + stages.every((stage) => + [stage.selected, stage.closed].every( + (snapshot) => + snapshot.menuCount === 0 && + snapshot.expanded === false && + snapshot.editorFocused === true, + ), + ); + const expectedEvents = CHOICES.flatMap((language) => [ + { kind: "open", trusted: true, sessionId }, + { kind: "search", query: language, trusted: true, sessionId }, + { kind: "select", language, trusted: true, sessionId }, + { kind: "open", trusted: true, sessionId }, + { kind: "close", key: "Escape", trusted: true, sessionId }, + ]); + const trustedActionsObserved = isDeepStrictEqual(events, expectedEvents); + let noInputRequest = false; + try { + noInputRequest = + liveStages && + stages + .flatMap((stage) => [ + stage.searched, + stage.selected, + stage.inspected, + stage.closed, + ]) + .every( + (snapshot) => + Array.isArray(JSON.parse(snapshot.traceRaw)) && + parseJsonRpcRequestsFromInvokeTrace(snapshot.traceRaw).filter( + (request) => + request.params.threadId === threadId && + INPUT_METHODS.has(request.method), + ).length === 0, + ); + } catch { + // Missing trace evidence cannot establish the negative input claim. + } + const noCanonicalTurn = [beforeRead, afterRead].every( + (read) => + isObservedPreloadAppServerResponse(read?.ipcObservation, "thread/read") && + read.ipcObservation.params?.threadId === threadId && + read.result?.thread?.id === threadId && + Array.isArray(read.result.thread.turns) && + read.result.thread.turns.length === 0, + ); + const checks = { + liveStages, + searchObserved, + choicesObserved, + languageAndHighlight, + closeFocusObserved, + trustedActionsObserved, + noInputRequest, + noCanonicalTurn, + }; + return { + ok: Object.values(checks).every(Boolean), + ...checks, + sessionId, + threadId, + stages, + events, + actionSource: + "Electron/CDP locator.click, search.fill and menu.press(Escape); passive document observations", + attributeBoundary: + "visible Check choice + canonical code class; private PM attrs are not read", + }; +} +export async function verifyComposerMarksCodePicker({ + page, + options, + sessionId, + threadId, + body, + expectedText, +}) { + const beforeRead = await appServerCallFromPage(page, "thread/read", { + threadId, + includeTurns: true, + }); + const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`); + const trigger = editor.locator( + '[data-composer-code-block-toolbar="true"] .composer-code-language-trigger', + ); + const read = () => + page.evaluate(withComposerCodePickerDom(readComposerCodePickerDom), { + sessionId, + }); + const stages = []; + await editor.evaluate(installComposerCodePickerObserverDom); + let events; + try { + for (const selection of CHOICES) { + await trigger.click(); + const menu = page.locator(".composer-code-language-menu:visible"); + await menu.locator('input[role="combobox"]').fill(selection); + await page.waitForFunction( + withComposerCodePickerDom(({ sessionId, selection }) => { + const snapshot = readComposerCodePickerDom({ sessionId }); + return ( + snapshot.menuOwned && + snapshot.searchFocused && + snapshot.searchQuery === selection && + snapshot.choices.length === 1 && + snapshot.choices[0].value === selection + ); + }), + { sessionId, selection }, + { timeout: Math.min(options.timeoutMs, 10_000) }, + ); + const searched = await read(); + await menu.locator(`[data-code-language="${selection}"]`).click(); + await page.waitForFunction( + withComposerCodePickerDom(({ sessionId, language, body }) => { + const snapshot = readComposerCodePickerDom({ sessionId }); + return ( + snapshot.menuCount === 0 && + snapshot.editorFocused && + snapshot.language === language && + snapshot.body === body && + (language === "plaintext" + ? snapshot.keywordTokens.length === 0 + : snapshot.keywordTokens.join("\u0000") === "const") + ); + }), + { + sessionId, + language: selection === "auto" ? "javascript" : selection, + body, + }, + { timeout: Math.min(options.timeoutMs, 10_000) }, + ); + const selected = await read(); + await trigger.click(); + await page.waitForFunction( + withComposerCodePickerDom(({ sessionId }) => { + const snapshot = readComposerCodePickerDom({ sessionId }); + return ( + snapshot.menuCount === 1 && + snapshot.menuOwned && + snapshot.expanded && + snapshot.searchQuery === "" + ); + }), + { sessionId }, + { timeout: Math.min(options.timeoutMs, 10_000) }, + ); + const inspected = await read(); + await menu.press("Escape"); + await page.waitForFunction( + withComposerCodePickerDom(({ sessionId }) => { + const snapshot = readComposerCodePickerDom({ sessionId }); + return snapshot.menuCount === 0 && snapshot.editorFocused; + }), + { sessionId }, + { timeout: Math.min(options.timeoutMs, 10_000) }, + ); + stages.push({ + selection, + searched, + selected, + inspected, + closed: await read(), + }); + } + } finally { + events = await page.evaluate(takeComposerCodePickerEventsDom); + } + const afterRead = await appServerCallFromPage(page, "thread/read", { + threadId, + includeTurns: true, + }); + const summary = summarizeComposerCodePickerBoundary({ + stages, + events, + beforeRead, + afterRead, + sessionId, + threadId, + body, + expectedText, + }); + assert( + summary.ok, + `Composer language Picker 编辑边界失败: ${JSON.stringify(summary)}`, + ); + return summary; +} diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.test.mjs new file mode 100644 index 000000000..65666aa7f --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-picker.test.mjs @@ -0,0 +1,360 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + installComposerCodePickerObserverDom, + readComposerCodePickerDom, + summarizeComposerCodePickerBoundary, + takeComposerCodePickerEventsDom, + withComposerCodePickerDom, +} from "./claw-chat-current-fixture-composer-code-picker.mjs"; +const BODY = " const value = '中😀';\n````"; +const TEXT = `prompt\n${BODY}\n/user:capability-report [Pasted text]`; +const SESSION = "picker-session", + THREAD = "picker-thread"; +const CHOICES = ["auto", "plaintext", "typescript"]; +function emptyRead() { + return { + ipcObservation: { + electron: true, + fallback: false, + transport: "electron-ipc", + command: "app_server_handle_json_lines", + status: "success", + method: "thread/read", + requestId: "read", + responseId: "read", + params: { threadId: THREAD }, + }, + result: { thread: { id: THREAD, turns: [] } }, + }; +} +// Manufactured unit evidence tests the oracle, never a native Gate. +function boundary() { + const stages = CHOICES.map((selection) => { + const language = selection === "auto" ? "javascript" : selection; + const label = + selection === "auto" + ? "Auto · JavaScript" + : selection === "plaintext" + ? "Plain" + : "TypeScript"; + const live = { + electron: true, + preload: true, + editorCount: 1, + sessionId: SESSION, + disabled: false, + blockCount: 1, + body: BODY, + text: TEXT, + language, + keywordTokens: selection === "plaintext" ? [] : ["const"], + traceRaw: "[]", + triggerLabel: label, + triggerAriaLabel: `Language: ${label}`, + }; + const closed = { + ...live, + menuCount: 0, + menuOwned: false, + expanded: false, + editorFocused: true, + }; + const inspected = { + ...live, + menuCount: 1, + menuOwned: true, + expanded: true, + searchCount: 1, + searchQuery: "", + searchFocused: true, + choices: CHOICES.map((value) => ({ + value, + label: + value === "auto" + ? "Auto" + : value === "plaintext" + ? "Plain" + : "TypeScript", + checked: value === selection, + highlighted: value === "auto", + })), + }; + const searched = { + ...inspected, + searchQuery: selection, + choices: inspected.choices.filter((item) => item.value === selection), + }; + return { + selection, + searched, + selected: structuredClone(closed), + inspected, + closed, + }; + }); + const events = CHOICES.flatMap((language) => [ + { kind: "open", trusted: true, sessionId: SESSION }, + { kind: "search", query: language, trusted: true, sessionId: SESSION }, + { kind: "select", language, trusted: true, sessionId: SESSION }, + { kind: "open", trusted: true, sessionId: SESSION }, + { kind: "close", key: "Escape", trusted: true, sessionId: SESSION }, + ]); + return { + stages, + events, + beforeRead: emptyRead(), + afterRead: emptyRead(), + sessionId: SESSION, + threadId: THREAD, + body: BODY, + expectedText: TEXT, + }; +} +function dom() { + const editor = document.createElement("div"); + editor.setAttribute("data-testid", "composer-editor"); + editor.setAttribute("role", "textbox"); + editor.setAttribute("contenteditable", "true"); + editor.dataset.sessionId = SESSION; + editor.innerHTML = + '

prompt

  const value = \'中😀\';\n````

/user:capability-report [Pasted text]

'; + const menu = document.createElement("div"); + menu.className = "composer-code-language-menu"; + menu.id = "language-menu"; + menu.innerHTML = + '
Auto
Plain
TypeScript
'; + document.body.append(editor, menu); + for (const node of [editor, menu]) + vi.spyOn(node, "getClientRects").mockReturnValue([{}]); + return { + editor, + menu, + trigger: editor.querySelector("button"), + search: menu.querySelector("input"), + }; +} +afterEach(() => { + if (window.__composerCodePickerObserver) takeComposerCodePickerEventsDom(); + document.body.replaceChildren(); + localStorage.clear(); + vi.restoreAllMocks(); +}); +describe("independent code Picker DOM observations", () => { + it("observes owned Portal, actual code spans and Check rather than cmdk highlight", () => { + const { search } = dom(); + search.focus(); + const reader = withComposerCodePickerDom(readComposerCodePickerDom); + const snapshot = new Function(`return (${reader.toString()});`)()({ + sessionId: SESSION, + }); + expect(snapshot).toMatchObject({ + body: BODY, + text: TEXT, + language: "typescript", + keywordTokens: ["const"], + menuCount: 1, + menuOwned: true, + searchCount: 1, + searchFocused: true, + searchQuery: "", + }); + expect( + snapshot.choices.filter((item) => item.checked).map((item) => item.value), + ).toEqual(["typescript"]); + expect( + snapshot.choices + .filter((item) => item.highlighted) + .map((item) => item.value), + ).toEqual(["auto"]); + }); + it("serializes a wait predicate with Picker and DOM helpers in the same lexical chain", () => { + dom(); + const predicate = withComposerCodePickerDom( + new Function( + "arg", + "const snapshot = readComposerCodePickerDom(arg); return snapshot.menuOwned && snapshot.body === arg.body && snapshot.text === arg.text;", + ), + ); + const serialized = new Function(`return (${predicate.toString()});`)(); + expect(serialized({ sessionId: SESSION, body: BODY, text: TEXT })).toBe( + true, + ); + expect(serialized({ sessionId: "foreign", body: BODY, text: TEXT })).toBe( + false, + ); + }); + it.each([ + "foreign-menu", + "duplicate-menu", + "old-dom", + "duplicate-language", + "lost-atom", + "toolbar-body", + "fake-highlight", + ])("exposes %s instead of inventing valid evidence", (mode) => { + const { editor, menu } = dom(); + const code = editor.querySelector("code"); + if (mode === "foreign-menu") menu.id = "foreign"; + if (mode === "duplicate-menu") { + const second = menu.cloneNode(true); + document.body.append(second); + vi.spyOn(second, "getClientRects").mockReturnValue([{}]); + } + if (mode === "old-dom") { + const pre = code.parentElement; + pre.setAttribute("data-composer-code-block", "true"); + pre.parentElement.replaceWith(pre); + } + if (mode === "duplicate-language") + code.classList.add("language-javascript"); + if (mode === "lost-atom") + editor.querySelector('[data-composer-token="paste"]').remove(); + if (mode === "toolbar-body") + code.prepend(document.createTextNode("TypeScript")); + if (mode === "fake-highlight") + code.querySelector(".hljs-keyword").className = "hljs-string"; + const snapshot = withComposerCodePickerDom(readComposerCodePickerDom)({ + sessionId: SESSION, + }); + if (mode.includes("menu")) expect(snapshot.menuOwned).toBe(false); + if (mode === "old-dom") expect(snapshot.blockCount).toBe(0); + if (mode === "duplicate-language") expect(snapshot.language).toBeNull(); + if (mode === "lost-atom") expect(snapshot.text).not.toBe(TEXT); + if (mode === "toolbar-body") expect(snapshot.body).not.toBe(BODY); + if (mode === "fake-highlight") expect(snapshot.keywordTokens).toEqual([]); + }); + it("passively observes synthetic search/click/Escape with trust false and ignores foreign Portal", () => { + const { editor, menu, trigger, search } = dom(); + installComposerCodePickerObserverDom(editor); + trigger.click(); + search.value = "typescript"; + search.dispatchEvent(new InputEvent("input", { bubbles: true })); + menu.querySelector('[data-code-language="typescript"]').click(); + search.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + menu.id = "foreign"; + menu.querySelector('[data-code-language="auto"]').click(); + expect(takeComposerCodePickerEventsDom()).toEqual([ + { kind: "open", trusted: false, sessionId: SESSION }, + { + kind: "search", + query: "typescript", + trusted: false, + sessionId: SESSION, + }, + { + kind: "select", + language: "typescript", + trusted: false, + sessionId: SESSION, + }, + { kind: "close", key: "Escape", trusted: false, sessionId: SESSION }, + ]); + expect(window.__composerCodePickerObserver).toBeUndefined(); + }); +}); +describe("Picker fail-closed editing-only oracle", () => { + it("requires Auto JavaScript, Plain and searched explicit TypeScript before sending", () => + expect(summarizeComposerCodePickerBoundary(boundary())).toMatchObject({ + ok: true, + searchObserved: true, + noInputRequest: true, + noCanonicalTurn: true, + })); + it.each([ + "missing-stage", + "order", + "synthetic", + "foreign-action", + "missing-search", + "wrong-query", + "unfiltered", + "unfocused-search", + "foreign-menu", + "wrong-check", + "highlight-as-check", + "wrong-auto", + "wrong-final-language", + "fake-highlight", + "plain-highlight", + "lost-atom", + "toolbar-body", + "lost-focus", + "missing-trace", + "malformed-trace", + "canonical-turn", + "foreign-read", + "unobserved-read", + ])("rejects %s", (mode) => { + const input = boundary(); + if (mode === "missing-stage") input.stages.pop(); + if (mode === "order") input.stages.reverse(); + if (mode === "synthetic") input.events[1].trusted = false; + if (mode === "foreign-action") input.events[2].sessionId = "foreign"; + if (mode === "missing-search") input.events.splice(1, 1); + if (mode === "wrong-query") input.stages[2].searched.searchQuery = "java"; + if (mode === "unfiltered") + input.stages[2].searched.choices.push({ value: "javascript" }); + if (mode === "unfocused-search") + input.stages[2].searched.searchFocused = false; + if (mode === "foreign-menu") input.stages[0].inspected.menuOwned = false; + if (mode === "wrong-check") + input.stages[2].inspected.choices[0].checked = true; + if (mode === "highlight-as-check") + input.stages[2].inspected.choices.forEach((item) => { + item.checked = item.highlighted; + }); + if (mode === "wrong-auto") input.stages[0].selected.language = "typescript"; + if (mode === "wrong-final-language") input.stages[2].closed.language = "ts"; + if (mode === "fake-highlight") input.stages[2].closed.keywordTokens = []; + if (mode === "plain-highlight") + input.stages[1].selected.keywordTokens = ["const"]; + if (mode === "lost-atom") + input.stages[1].inspected.text = `prompt\n${BODY}`; + if (mode === "toolbar-body") + input.stages[2].closed.body = `TypeScript${BODY}`; + if (mode === "lost-focus") input.stages[2].closed.editorFocused = false; + if (mode === "missing-trace") input.stages[1].searched.traceRaw = null; + if (mode === "malformed-trace") input.stages[1].closed.traceRaw = "{}"; + if (mode === "canonical-turn") + input.afterRead.result.thread.turns.push({ id: "unexpected" }); + if (mode === "foreign-read") + input.afterRead.ipcObservation.params.threadId = "foreign"; + if (mode === "unobserved-read") delete input.afterRead.ipcObservation; + expect(summarizeComposerCodePickerBoundary(input).ok).toBe(false); + }); + it.each([ + "turn/start", + "turn/steer", + "thread/queue/add", + "thread/queue/update", + "thread/queue/start", + ])("rejects additional %s while searching", (method) => { + const input = boundary(); + input.stages[2].searched.traceRaw = JSON.stringify([ + { + command: "app_server_handle_json_lines", + transport: "electron-ipc", + status: "success", + args_preview: { + request: { + lines: [ + JSON.stringify({ + id: "extra", + method, + params: { + threadId: THREAD, + input: [{ type: "text", text: BODY }], + }, + }), + ], + }, + }, + }, + ]); + expect(summarizeComposerCodePickerBoundary(input).noInputRequest).toBe( + false, + ); + }); +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-code-pm.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-pm.test.mjs new file mode 100644 index 000000000..2aa149aff --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-code-pm.test.mjs @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorState } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import { composerSchema } from "@/components/input-kit/composerSchema"; +import { CodeBlockNodeViewRegistry } from "@/components/input-kit/CodeBlockNodeViewRegistry"; +import { + createCodeBlockHighlight, + loadCodeBlockParser, +} from "@/components/input-kit/composerCodeBlockHighlight"; +import { detectCodeBlockLanguage } from "@/components/input-kit/composerCodeBlockLanguage"; +import { readComposerEditorTextDom } from "../lib/composer-editor-dom.mjs"; +import { + placeComposerCodeCaretAtEndDom, + readComposerMarksDom, +} from "./claw-chat-current-fixture-composer-marks.mjs"; +const BODY = " const value = '中😀';\n````"; +const cleanups = []; +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())); +// Actual PM, NodeView and grammar output in JSDOM; not trusted native input/Gate B. +async function mount(language) { + const parser = await loadCodeBlockParser(); + const doc = composerSchema.node("doc", null, [ + composerSchema.node("paragraph", null, composerSchema.text("before中😀")), + composerSchema.node( + "code_block", + { language, autoDetectLanguage: false }, + composerSchema.text(BODY), + ), + composerSchema.node("paragraph", null, composerSchema.text("after")), + ]); + const host = document.createElement("div"); + document.body.append(host); + const registry = new CodeBlockNodeViewRegistry(); + const view = new EditorView(host, { + state: EditorState.create({ + doc, + plugins: [createCodeBlockHighlight({ parser })], + }), + nodeViews: { code_block: registry.createNodeView }, + }); + registry.getSnapshot()[0].toolbarPortalContainer.textContent = + "TypeScript toolbar search"; + cleanups.push(() => { + view.destroy(); + registry.destroy(); + host.remove(); + }); + return view; +} +describe("actual parser to modern NodeView fixture observations", () => { + it.each(["javascript", "typescript"])( + "reads real %s spans, preserving source, emoji, whitespace and fences", + async (language) => { + const view = await mount(language); + const marks = readComposerMarksDom(view.dom); + expect(marks.codeBlocks).toEqual([ + { + language, + body: BODY, + validStructure: true, + hardBreakCount: 0, + keywordTokens: ["const"], + }, + ]); + expect(marks.blockTags).toEqual(["P", "DIV", "P"]); + expect(view.dom.querySelector(".hljs-keyword")?.textContent).toBe( + "const", + ); + expect(view.dom.querySelector(".hljs-string")?.textContent).toContain( + "中😀", + ); + expect(readComposerEditorTextDom(view.dom)).toBe( + `before中😀\n${BODY}\nafter`, + ); + expect(view.state.doc.textContent).toBe(`before中😀${BODY}after`); + placeComposerCodeCaretAtEndDom(view.dom); + const leaf = window.getSelection().anchorNode; + expect(leaf.nodeType).toBe(Node.TEXT_NODE); + expect(leaf.parentElement.closest("code.hljs")).not.toBeNull(); + expect(window.getSelection().anchorOffset).toBe(leaf.nodeValue.length); + }, + ); + it("the immutable literal detects JavaScript independently of final TypeScript selection", async () => { + expect( + await detectCodeBlockLanguage(BODY, () => { + throw new Error("syntax hint should answer"); + }), + ).toBe("javascript"); + const view = await mount("plaintext"); + expect(readComposerMarksDom(view.dom).codeBlocks).toEqual([ + { + language: "plaintext", + body: BODY, + validStructure: true, + hardBreakCount: 0, + keywordTokens: [], + }, + ]); + expect(view.dom.querySelectorAll("code span.hljs-keyword")).toHaveLength(0); + }); +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs index 688c25ba6..c6a16f60f 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs @@ -5,6 +5,11 @@ import { isExpectedSkillIdentity, } from "./claw-chat-current-fixture-skill-identity.mjs"; import { composerEditorSelector } from "../lib/composer-editor-dom.mjs"; +import { + captureInvokeTraceCollector, + validateInvokeTraceCollectorCapture, + sameInvokeTraceCollectorProvenance, +} from "../lib/invoke-trace-collector.mjs"; import { appServerCallFromPage } from "../electron/mcp-config-fixture-smoke.mjs"; import { isObservedPreloadAppServerResponse, @@ -34,6 +39,22 @@ function readHistorySnapshotDom({ sessionId }) { if (node.nodeType === Node.TEXT_NODE) return { type: "text", text: node.nodeValue }; if (!(node instanceof HTMLElement)) return null; + if (node.getAttribute("data-composer-code-block-toolbar") === "true") + return null; + if (node.matches('div[data-composer-code-block="true"]')) { + const code = node.querySelector(":scope > pre > code.hljs"); + const languages = Array.from(code?.classList ?? []).filter((name) => + name.startsWith("language-"), + ); + return { + type: "code_block", + language: + languages.length === 1 + ? languages[0].slice("language-".length) + : null, + content: [{ type: "text", text: code?.textContent ?? null }], + }; + } if (node.matches("br.ProseMirror-trailingBreak")) return null; if (node.tagName === "BR") return { type: "hard_break" }; const kind = node.getAttribute("data-composer-token"); @@ -543,9 +564,61 @@ export async function captureComposerHistoryDraft( return snapshot; } +/** History runs after Stop recovery; clear only through actual public GUI actions. */ +export async function clearComposerDraftForHistory({ + page, + options, + sessionId, +}) { + const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`); + const core = editor.locator( + 'xpath=ancestor::*[@data-testid="inputbar-core-container"][1]', + ); + const image = core.locator('img[src^="data:image/"]'); + const path = core.locator('[data-testid="inputbar-path-reference-chip"]'); + assert( + (await image.count()) === 1 && (await path.count()) === 1, + "history 起点必须是完整 Stop 恢复的图片与路径", + ); + await image.locator("..").getByRole("button").click(); + await path.getByRole("button").last().click(); + const trigger = core.locator('[data-testid="inputbar-plus-trigger"]'); + await trigger.click(); + await page.locator('[data-testid="inputbar-plus-skills"]:visible').click(); + const panel = page.locator( + '[data-testid="inputbar-plus-panel-skills"]:visible', + ); + await panel.locator('[data-testid="skill-selector-clear"]').click(); + await trigger.click(); + await panel.waitFor({ + state: "hidden", + timeout: Math.min(options.timeoutMs, 10_000), + }); + await editor.press("ControlOrMeta+A"); + await editor.press("Backspace"); + const cleared = await waitForSnapshot( + page, + sessionId, + options, + isEmptySnapshot, + "history 独立验收未通过公开 GUI 清空完整草稿", + ); + return { + origin: "user_clear_after_verified_stop_restore", + sessionId, + actionSource: + "Electron/CDP public image/path remove, Skill clear and editor keyboard", + emptyText: cleared.text === "", + tokenCount: cleared.tokens.length, + imageCount: cleared.images.length, + pathCount: cleared.paths.length, + }; +} + export async function verifyComposerHistoryRecall({ page, options, + traceCollector, prepared, threadId, sessionId, @@ -555,12 +628,33 @@ export async function verifyComposerHistoryRecall({ imageDataUrl, pathReference, }) { + const captureTrace = async () => { + if (!traceCollector) + return { + raw: await page.evaluate(() => + localStorage.getItem("lime_invoke_trace_buffer_v1"), + ), + provenance: null, + }; + const observed = validateInvokeTraceCollectorCapture( + await captureInvokeTraceCollector(page, traceCollector), + traceCollector, + ); + assert( + observed.complete, + "history invoke trace collector provenance 不完整", + ); + return { + raw: JSON.stringify(observed.ledgerEntries), + provenance: observed.provenance, + }; + }; const cleared = await waitForSnapshot( page, sessionId, options, isEmptySnapshot, - "accepted submission 未清空完整 Composer draft", + "history 验收起点未清空完整 Composer draft", ); cleared.capability = await observeHistorySkillCapability( page, @@ -569,6 +663,8 @@ export async function verifyComposerHistoryRecall({ false, options.timeoutMs, ); + const beforeTrace = await captureTrace(); + cleared.traceRaw = beforeTrace.raw; const beforeRead = await appServerCallFromPage(page, "thread/read", { threadId, includeTurns: true, @@ -623,9 +719,16 @@ export async function verifyComposerHistoryRecall({ includeTurns: true, }); // Include all menu actions and the second read in the final transport boundary. - returned.traceRaw = await page.evaluate(() => - localStorage.getItem("lime_invoke_trace_buffer_v1"), - ); + const afterTrace = await captureTrace(); + returned.traceRaw = afterTrace.raw; + if (traceCollector) + assert( + sameInvokeTraceCollectorProvenance( + beforeTrace.provenance, + afterTrace.provenance, + ), + "history invoke trace collector 必须连续且归属同一次观察", + ); const keyboardEvents = await page.evaluate( takeComposerHistoryKeyboardEventsDom, ); @@ -652,6 +755,10 @@ export async function verifyComposerHistoryRecall({ ); return { ...evidence, + traceMode: traceCollector ? "continuous-storage-append" : "bounded-ring", + traceProvenance: traceCollector + ? { before: beforeTrace.provenance, after: afterTrace.provenance } + : null, cleared, recalled, returned, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-history.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-history.test.mjs index 48399c82f..fdd1f1694 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-composer-history.test.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-history.test.mjs @@ -305,7 +305,7 @@ describe("real keyboard Composer history evidence", () => { node.setAttribute("data-session-id", "history-session"); node.setAttribute("contenteditable", "true"); node.innerHTML = - '

first

中😀

  中😀\n````\n\n

tail

'; + '

first

中😀

TypeScript
  const value = \'中😀\';\n````\n\n

tail

'; document.body.append(node); vi.spyOn(node, "getClientRects").mockReturnValue([{}]); try { @@ -324,15 +324,18 @@ describe("real keyboard Composer history evidence", () => { isAutolink: false, }); expect(prepared.marks.codeBlocks[0]).toEqual({ - language: "ts", - body: " 中😀\n````\n\n", + language: "typescript", + body: " const value = '中😀';\n````\n\n", validStructure: true, hardBreakCount: 0, + keywordTokens: ["const"], }); node .querySelector("span") .setAttribute("text-link-href", "https://example.invalid/wrong"); - node.querySelector("code").setAttribute("data-language", "js"); + node.querySelector( + '[data-composer-code-block-toolbar="true"]', + ).textContent = "Language picker"; const recalled = await readComposerHistorySnapshot( page, "history-session", diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks-renderer.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks-renderer.test.mjs index 71c318bcf..c5a4fd544 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks-renderer.test.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks-renderer.test.mjs @@ -45,7 +45,7 @@ describe("marks fixture against the current Markdown renderer", () => { ); expect(container.querySelector("a")?.getAttribute("title")).toBe("标题"); expect(container.querySelector("code")?.textContent).toBe( - " 中😀\n````\n\n", + " const value = '中😀';\n````\n\n", ); }); @@ -88,6 +88,8 @@ describe("marks fixture against the current Markdown renderer", () => { expect(expected.text).toContain( '[中😀](https://example.invalid/marks "标题")', ); - expect(expected.text).toContain("`````ts\n 中😀\n````\n\n\n`````"); + expect(expected.text).toContain( + "`````typescript\n const value = '中😀';\n````\n\n\n`````", + ); }); }); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs index f03451e2f..a3fb62bda 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs @@ -10,6 +10,7 @@ import { parseJsonRpcRequestsFromInvokeTrace, } from "../electron/lib/mcp-config-fixture-evidence.mjs"; import { assert } from "./claw-chat-current-fixture-utils.mjs"; +import { verifyComposerMarksCodePicker } from "./claw-chat-current-fixture-composer-code-picker.mjs"; const INPUT_METHODS = new Set([ "turn/start", @@ -27,16 +28,22 @@ const LINK = { isAutolink: false, isGeneratedAutolink: false, }; -const CODE = " 中😀\n````"; +const CODE = " const value = '中😀';\n````"; const TAIL = "尾中😀"; function expectedMarks(body) { return { links: [{ ...LINK }], codeBlocks: [ - { language: "ts", body, validStructure: true, hardBreakCount: 0 }, + { + language: "typescript", + body, + validStructure: true, + hardBreakCount: 0, + keywordTokens: ["const"], + }, ], - blockTags: ["P", "P", "PRE", "P"], + blockTags: ["P", "P", "DIV", "P"], }; } @@ -58,7 +65,7 @@ export function composerMarksFixture({ prompt, skillIdentity, pasteText }) { const pasteLabel = `[Pasted text 1: ${pasteText.length} characters]`; const skillLabel = `/${skillIdentity.key}`; const displayPrefix = `${prompt}\n${LINK.label}\n${body}\n${TAIL} `; - const markdownPrefix = `/${skillIdentity.name} ${prompt}\n[中😀](https://example.invalid/marks "标题")\n\`\`\`\`\`ts\n${body}\n\`\`\`\`\`\n${TAIL} `; + const markdownPrefix = `/${skillIdentity.name} ${prompt}\n[中😀](https://example.invalid/marks "标题")\n\`\`\`\`\`typescript\n${body}\n\`\`\`\`\`\n${TAIL} `; const pasteStart = Buffer.byteLength(markdownPrefix, "utf8"); const pasteEnd = pasteStart + Buffer.byteLength(pasteText, "utf8"); return { @@ -107,24 +114,51 @@ export function readComposerMarksDom(editor) { isGeneratedAutolink: flag(node, "data-rich-text-generated-autolink"), }), ), - codeBlocks: Array.from(editor.querySelectorAll("pre")).map((pre) => { - const code = pre.querySelector(":scope > code"); + codeBlocks: Array.from( + editor.querySelectorAll('div[data-composer-code-block="true"]'), + ).map((block) => { + const toolbar = block.children[0]; + const pre = block.children[1]; + const code = pre?.querySelector(":scope > code"); + const languages = Array.from(code?.classList ?? []).filter((name) => + name.startsWith("language-"), + ); + const validCodeNode = (node) => + node.nodeType === 3 || + (node instanceof HTMLElement && + (node.matches("br.ProseMirror-trailingBreak") || + (node.tagName === "SPAN" && + node.classList.length > 0 && + Array.from(node.classList).every((name) => + name.startsWith("hljs-"), + ) && + Array.from(node.childNodes).every(validCodeNode)))); return { - language: code?.getAttribute("data-language") ?? null, + language: + languages.length === 1 + ? languages[0].slice("language-".length) + : null, body: code?.textContent ?? null, validStructure: - pre.hasAttribute("data-composer-code-block") && + block.children.length === 2 && + toolbar?.tagName === "DIV" && + toolbar.getAttribute("data-composer-code-block-toolbar") === "true" && + toolbar.getAttribute("contenteditable") === "false" && + pre?.tagName === "PRE" && + pre.getAttribute("dir") === "ltr" && pre.childNodes.length === 1 && !!code && - Array.from(code.childNodes).every( - (node) => - node.nodeType === 3 || - (node instanceof HTMLElement && - node.matches("br.ProseMirror-trailingBreak")), - ), + code.classList.contains("hljs") && + !code.hasAttribute("data-language") && + Array.from(code.childNodes).every(validCodeNode), hardBreakCount: code ? code.querySelectorAll("br:not(.ProseMirror-trailingBreak)").length : null, + keywordTokens: code + ? Array.from(code.querySelectorAll("span.hljs-keyword")).map( + (node) => node.textContent, + ) + : [], }; }), blockTags: Array.from(editor.children).map((node) => node.tagName), @@ -188,8 +222,9 @@ export function installComposerCodeKeyboardObserverDom(node) { sessionId: node.dataset.sessionId, defaultPrevented: event.defaultPrevented, insideCode: - !!element?.closest("pre[data-composer-code-block] > code") && - node.contains(anchor), + !!element?.closest( + 'div[data-composer-code-block="true"] > pre > code', + ) && node.contains(anchor), }); }; // Observe after EditorView's handler, before a later ancestor could consume it. @@ -283,27 +318,46 @@ export function summarizeComposerCodeBoundary({ }; } -export async function prepareComposerMarksDraft({ +export function placeComposerCodeCaretAtEndDom(node) { + const code = node.querySelector( + 'div[data-composer-code-block="true"] > pre > code', + ); + if (!code) throw new Error("code selection 缺少现代 NodeView contentDOM"); + const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); + let text = null; + while (walker.nextNode()) text = walker.currentNode; + if (!text) throw new Error("code selection 缺少实际 text leaf"); + node.focus(); + const range = document.createRange(); + range.setStart(text, text.nodeValue.length); + range.collapse(true); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** Synthetic clipboard transport into the real EditorView; no model or Controller injection. */ +export async function pasteComposerMarksDraft({ page, - options, sessionId, - threadId, prompt, + body = CODE, }) { - const beforeRead = await appServerCallFromPage(page, "thread/read", { - threadId, - includeTurns: true, - }); const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`); await editor.fill(""); const pasteObservation = await editor.evaluate( - (node, { prompt }) => { + (node, { prompt, body }) => { const data = new DataTransfer(); const paragraph = document.createElement("p"); paragraph.textContent = prompt; + const code = document.createElement("pre"); + const content = document.createElement("code"); + content.className = "language-typescript"; + content.textContent = body; + code.append(content); data.setData( "text/html", - `${paragraph.outerHTML}

中😀

  中😀\n\`\`\`\`

尾中😀

`, + `${paragraph.outerHTML}

中😀

${code.outerHTML}

尾中😀

`, ); data.setData("text/plain", "PLAIN FALLBACK MUST NOT WIN"); const event = new ClipboardEvent("paste", { @@ -320,8 +374,36 @@ export async function prepareComposerMarksDraft({ "synthetic DataTransfer/ClipboardEvent in real Electron; OS clipboard not covered", }; }, - { prompt }, + { prompt, body }, ); + return pasteObservation; +} + +export async function prepareComposerMarksDraft({ + page, + options, + sessionId, + threadId, + prompt, +}) { + const beforeRead = await appServerCallFromPage(page, "thread/read", { + threadId, + includeTurns: true, + }); + const editor = page.locator(`${composerEditorSelector(sessionId)}:visible`); + const pasteObservation = await pasteComposerMarksDraft({ + page, + sessionId, + prompt, + }); + const pickerBoundary = await verifyComposerMarksCodePicker({ + page, + options, + sessionId, + threadId, + body: CODE, + expectedText: `${prompt}\n中😀\n${CODE}\n尾中😀`, + }); const initial = await readComposerMarksSnapshot(page, sessionId); const preparation = { ok: @@ -337,28 +419,14 @@ export async function prepareComposerMarksDraft({ preparation.ok, `Composer HTML marks preparation 失败: ${JSON.stringify(preparation)}`, ); - await editor.evaluate((node) => { - const code = node.querySelector("pre[data-composer-code-block] > code"); - const texts = Array.from(code?.childNodes ?? []).filter( - (child) => child.nodeType === 3, - ); - if (!texts.length) throw new Error("code selection 缺少实际 text node"); - const text = texts.at(-1); - node.focus(); - const range = document.createRange(); - range.setStart(text, text.nodeValue.length); - range.collapse(true); - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); + await editor.evaluate(placeComposerCodeCaretAtEndDom); await page.waitForFunction( ({ selector }) => { const editor = document.querySelector(selector); const selection = window.getSelection(); const anchor = selection?.anchorNode; const code = anchor?.parentElement?.closest( - "pre[data-composer-code-block] > code", + 'div[data-composer-code-block="true"] > pre > code', ); return ( !!code && @@ -431,7 +499,7 @@ export async function prepareComposerMarksDraft({ { selector: composerEditorSelector(sessionId) }, { timeout: Math.min(options.timeoutMs, 10_000) }, ); - return { preparation, codeBoundary }; + return { preparation, pickerBoundary, codeBoundary }; } export function summarizeComposerMarksCancel({ @@ -470,6 +538,9 @@ export function buildComposerMarksScenarioAssertions(summary) { return { inputbarRichRestoreMarksPrepared: summary.inputbarRichRestoreDraftPrepared?.marks?.preparation?.ok === true, + inputbarRichRestoreCodePickerEditingOnly: + summary.inputbarRichRestoreDraftPrepared?.marks?.pickerBoundary?.ok === + true, inputbarRichRestoreCodeEditingOnly: summary.inputbarRichRestoreDraftPrepared?.marks?.codeBoundary?.ok === true, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.test.mjs index f0751a037..97301f869 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.test.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-composer-marks.test.mjs @@ -10,12 +10,14 @@ import { takeComposerCodeKeyboardEventsDom, summarizeComposerCodeBoundary, summarizeComposerMarksCancel, + placeComposerCodeCaretAtEndDom, + buildComposerMarksScenarioAssertions, } from "./claw-chat-current-fixture-composer-marks.mjs"; const PROMPT = "请结合这个截图、文件和 Capability Report 技能,先不要输出正文。"; const PASTE = "历史粘贴验证😀\n".repeat(180); -const BODY = " 中😀\n````"; +const BODY = " const value = '中😀';\n````"; const EXPECTED_MARKS = { links: [ { @@ -30,13 +32,14 @@ const EXPECTED_MARKS = { ], codeBlocks: [ { - language: "ts", + language: "typescript", body: `${BODY}\n\n`, validStructure: true, hardBreakCount: 0, + keywordTokens: ["const"], }, ], - blockTags: ["P", "P", "PRE", "P"], + blockTags: ["P", "P", "DIV", "P"], }; function oracle() { @@ -104,8 +107,17 @@ function editor() { node.setAttribute("data-session-id", "marks-session"); node.setAttribute("contenteditable", "true"); node.innerHTML = - '

prompt

中😀

tail

'; - node.querySelector("code").textContent = BODY + "\n\n"; + '

prompt

中😀

TypeScript

tail

'; + const code = node.querySelector("code"); + code.append(document.createTextNode(" ")); + const keyword = document.createElement("span"); + keyword.className = "hljs-keyword"; + keyword.textContent = "const"; + code.append(keyword, document.createTextNode(" value = ")); + const string = document.createElement("span"); + string.className = "hljs-string"; + string.textContent = "'中😀'"; + code.append(string, document.createTextNode(";\n````\n\n")); document.body.append(node); vi.spyOn(node, "getClientRects").mockReturnValue([{}]); return node; @@ -123,27 +135,29 @@ describe("independent marks Markdown/UTF8 fixture", () => { const expected = oracle(); expect(expected.marks).toEqual(EXPECTED_MARKS); expect(expected.text).toContain( - '[中😀](https://example.invalid/marks "标题")\n`````ts\n 中😀\n````\n\n\n`````\n尾中😀 ', + "[中😀](https://example.invalid/marks \"标题\")\n`````typescript\n const value = '中😀';\n````\n\n\n`````\n尾中😀 ", ); const bytes = Buffer.from(expected.text, "utf8"); - expect(bytes.length).toBe(4361); + expect(bytes.length).toBe(4386); expect(expected.textElements).toEqual([ { - byteRange: { start: 197, end: 4337 }, + byteRange: { start: 222, end: 4362 }, placeholder: "[Pasted text 1: 1620 characters]", }, { - byteRange: { start: 4338, end: 4361 }, + byteRange: { start: 4363, end: 4386 }, placeholder: "/user:capability-report", }, ]); expect( - createHash("sha256").update(bytes.subarray(197, 4337)).digest("hex"), + createHash("sha256").update(bytes.subarray(222, 4362)).digest("hex"), ).toBe("efe7ffb00ece5294d1cd2b7cac4533861af4b728d7988212acc3a9551b98cb0a"); - expect(bytes.subarray(4338, 4361).toString("utf8")).toBe( + expect(bytes.subarray(4363, 4386).toString("utf8")).toBe( "/user:capability-report", ); - expect(expected.displayText).toContain("中😀\n 中😀\n````\n\n\n尾中😀"); + expect(expected.displayText).toContain( + "中😀\n const value = '中😀';\n````\n\n\n尾中😀", + ); expect(expected.displayText).not.toContain("https://"); }); }); @@ -171,6 +185,10 @@ describe("actual rendered marks semantics", () => { "wrapper", "missing-link", "missing-code", + "old-dom", + "multiple-languages", + "fake-highlight", + "toolbar-body", ])("observes %s drift that plain text would hide", (mode) => { const node = editor(); const link = node.querySelector("span"); @@ -182,7 +200,19 @@ describe("actual rendered marks semantics", () => { if (mode === "generated") link.setAttribute("data-rich-text-generated-autolink", "true"); if (mode === "disabled") link.setAttribute("aria-disabled", "true"); - if (mode === "language") code.setAttribute("data-language", "js"); + if (mode === "language") code.className = "hljs language-javascript"; + if (mode === "multiple-languages") + code.classList.add("language-javascript"); + if (mode === "fake-highlight") + code.querySelector(".hljs-keyword").className = "hljs-string"; + if (mode === "toolbar-body") + code.prepend(document.createTextNode("TypeScript")); + if (mode === "old-dom") { + const pre = code.parentElement; + pre.setAttribute("data-composer-code-block", "true"); + code.setAttribute("data-language", "typescript"); + pre.parentElement.replaceWith(pre); + } if (mode === "body") code.textContent = code.textContent.trim(); if (mode === "hard_break") code.append(document.createElement("br")); if (mode === "wrapper") code.innerHTML = " 中😀\n````\n\n"; @@ -221,15 +251,28 @@ describe("actual rendered marks semantics", () => { }); describe("passive code keyboard evidence", () => { + it("places caret at the final actual text leaf across nested highlight spans", () => { + const node = editor(); + const code = node.querySelector("code"); + const nested = document.createElement("span"); + nested.className = "hljs-string"; + nested.innerHTML = ''; + nested.firstChild.textContent = code.lastChild.nodeValue; + code.lastChild.replaceWith(nested); + placeComposerCodeCaretAtEndDom(node); + expect(window.getSelection().anchorNode).toBe(nested.firstChild.firstChild); + expect(window.getSelection().anchorOffset).toBe( + nested.firstChild.textContent.length, + ); + expect(readComposerMarksDom(node)).toEqual(EXPECTED_MARKS); + }); it("observes consumed keys after the EditorView handler and keeps synthetic trust false", () => { const node = editor(); node.focus(); - const text = node.querySelector("code").firstChild; - const range = document.createRange(); - range.setStart(text, text.length); - range.collapse(true); - window.getSelection().removeAllRanges(); - window.getSelection().addRange(range); + placeComposerCodeCaretAtEndDom(node); + const text = node.querySelector("code").lastChild; + expect(window.getSelection().anchorNode).toBe(text); + expect(window.getSelection().anchorOffset).toBe(text.nodeValue.length); node.addEventListener("keydown", (event) => event.preventDefault()); installComposerCodeKeyboardObserverDom(node); for (const shiftKey of [false, true]) @@ -380,3 +423,24 @@ describe("cancel restoration independent semantics", () => { }, ); }); + +describe("rich fixture language Picker summary", () => { + it("cannot accept marks preparation without the editing-only Picker boundary", () => { + const summary = { + inputbarRichRestoreDraftPrepared: { + marks: { preparation: { ok: true }, codeBoundary: { ok: true } }, + }, + }; + expect( + buildComposerMarksScenarioAssertions(summary) + .inputbarRichRestoreCodePickerEditingOnly, + ).toBe(false); + summary.inputbarRichRestoreDraftPrepared.marks.pickerBoundary = { + ok: true, + }; + expect( + buildComposerMarksScenarioAssertions(summary) + .inputbarRichRestoreCodePickerEditingOnly, + ).toBe(true); + }); +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs b/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs index 07b32796b..acbf5218f 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-constants.mjs @@ -148,14 +148,12 @@ export const PLAN_PROMPT = "先给我一个修复计划,不要直接改代码" export const GOAL_PROMPT = "本周完成 Goal E2E 修复"; export const INPUTBAR_RICH_RESTORE_PROMPT = "请结合这个截图、文件和 Capability Report 技能,先不要输出正文。"; -export const INPUTBAR_RICH_RESTORE_PATH_NAME = - "clawstream-rich-restore-fixture.md"; +export const INPUTBAR_RICH_RESTORE_PATH_NAME = "composer-reference.md"; export const INPUTBAR_RICH_RESTORE_PATH = path.join( process.cwd(), - "internal", - "roadmap", - "test", - "clawstream", + "scripts", + "agent-runtime", + "fixtures", INPUTBAR_RICH_RESTORE_PATH_NAME, ); export const INPUTBAR_RICH_RESTORE_SKILL_NAME = "Capability Report"; diff --git a/scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs b/scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs new file mode 100644 index 000000000..25be3a501 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs @@ -0,0 +1,739 @@ +import { isDeepStrictEqual } from "node:util"; +import { Buffer } from "node:buffer"; +import { + atomKey, + assertRestoredDom, + assertRestoredDraft, + assertRichDraft, + assertWorkspaceIndex, + requireDraftProof, + savedDraft, + sha256, + summarizeDraft, +} from "../electron/lib/composer-draft-fixture-evidence.mjs"; + +export const markers = ["GUI工作区一号", "GUI工作区二号", "GUI工作区三号"]; +export const nextEdit = "Accepted 后的新编辑,旧回合不得清除。"; + +export async function until(context, read, ready, phase) { + const started = Date.now(); + while (Date.now() - started < context.options.timeoutMs) { + const value = await read(); + if (ready(value)) return value; + await new Promise((resolve) => + setTimeout(resolve, context.options.intervalMs), + ); + } + throw new Error(`Composer draft GUI fixture timed out: ${phase}`); +} + +export function editor(context, record) { + return context.page.locator( + `${context.modules.dom.composerEditorSelector(record.sessionId)}:visible`, + ); +} + +export async function readScope(context, scope) { + const observed = await context.modules.atom.observePersistedAtomCommand( + context.page, + context.protocol, + "persisted_atom_read", + { key: atomKey, query: { kind: "scope", scope } }, + { timeoutMs: context.options.timeoutMs }, + ); + return observed.result; +} + +export async function waitSaved(context, record) { + await context.page + .locator( + '[data-testid="composer-draft-status"][data-state="saved"]:visible', + ) + .waitFor({ timeout: context.options.timeoutMs }); + return until( + context, + () => readScope(context, record.scope), + (result) => { + try { + savedDraft(result, record.scope); + return true; + } catch { + return false; + } + }, + "native savedRevision and GUI saved status", + ); +} + +async function readThread(context, record) { + const read = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "thread/read", + { threadId: record.threadId, includeTurns: true }, + ); + requireDraftProof( + read.result?.thread?.id === record.threadId && + read.result.thread.sessionId === record.sessionId && + Array.isArray(read.result.thread.turns), + "canonical Thread/session read model identity", + ); + return read.result.thread; +} + +/** Preferences assist navigation only; no Controller, draft or fake workspace state. */ +async function selectWorkspacePreferences(context, record) { + await context.page.evaluate( + ({ workspaceId, sessionId, provider, model }) => { + const storage = window.localStorage; + const projects = JSON.parse( + storage.getItem("agent_opened_project_ids") || "[]", + ); + const opened = [...new Set([...projects, workspaceId])]; + storage.setItem("agent_last_project_id", JSON.stringify(workspaceId)); + storage.setItem("agent_opened_project_ids", JSON.stringify(opened)); + for (const suffix of [workspaceId, "global"]) { + storage.setItem( + `agent_pref_provider_${suffix}`, + JSON.stringify(provider), + ); + storage.setItem(`agent_pref_model_${suffix}`, JSON.stringify(model)); + storage.setItem(`agent_pref_migrated_${suffix}`, "true"); + storage.setItem( + `agent_topic_model_pref_${suffix}_${sessionId}`, + JSON.stringify({ providerType: provider, model }), + ); + } + storage.setItem( + `agent_session_workspace_${sessionId}`, + JSON.stringify(workspaceId), + ); + storage.setItem( + `agent_execution_strategy_${workspaceId}`, + JSON.stringify("react"), + ); + storage.setItem( + `agent_access_mode_${workspaceId}`, + JSON.stringify("full-access"), + ); + window.dispatchEvent( + new CustomEvent("agent-persisted-project-id-changed", { + detail: { projectId: workspaceId, key: "agent_last_project_id" }, + }), + ); + window.dispatchEvent( + new CustomEvent("agent-opened-project-ids-changed", { + detail: { projectIds: opened }, + }), + ); + window.dispatchEvent( + new CustomEvent("lime:agent-runtime-sessions-changed", { + detail: { reason: "external", sessionId, workspaceId }, + }), + ); + window.dispatchEvent(new Event("focus")); + }, + { + ...record, + provider: context.modules.constants.FIXTURE_PROVIDER, + model: context.modules.constants.FIXTURE_MODEL, + }, + ); +} + +export async function openRecord(context, record) { + await selectWorkspacePreferences(context, record); + await until( + context, + () => + context.page.evaluate(({ sessionId, workspaceName }) => { + const headers = Array.from( + document.querySelectorAll( + '[data-testid="app-sidebar-project-conversation-group"] button[aria-expanded]', + ), + ); + const header = headers.find( + (button) => button.getAttribute("title") === workspaceName, + ); + if (header?.getAttribute("aria-expanded") === "false") header.click(); + return Array.from( + document.querySelectorAll( + '[data-testid="app-sidebar-conversation-open"]', + ), + ).some( + (button) => button.getAttribute("data-session-id") === sessionId, + ); + }, record), + (visible) => visible === true, + "exact real sidebar conversation row; project header is not a conversation", + ); + await context.modules.sessions.openSessionFromSidebar( + context.page, + context.options, + undefined, + record, + ); + requireDraftProof( + (await editor(context, record).count()) === 1, + "real sidebar committed one same-session visible Composer", + ); + await until( + context, + () => + context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ), + (snapshot) => + snapshot.editorCount === 1 && + snapshot.sessionId === record.sessionId && + !snapshot.disabled, + "same-session Composer is editable after committed draft hydration", + ); + return readThread(context, record); +} + +export async function createRecord(context, { rootPath, title, marker }) { + const workspaceName = `Draft workspace ${marker}`; + const workspace = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "workspace/ensure", + { name: workspaceName, rootPath }, + ); + const item = workspace.result?.workspace; + requireDraftProof( + typeof item?.id === "string" && item.id.length > 0, + "workspace/ensure current directory owner returns real identity", + ); + const start = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "thread/start", + { + cwd: rootPath, + runtimeWorkspaceRoots: [rootPath], + historyMode: "paginated", + serviceName: title, + threadSource: "appServer", + model: context.modules.constants.FIXTURE_MODEL, + modelProvider: context.modules.constants.FIXTURE_PROVIDER, + }, + ); + const thread = start.result?.thread; + requireDraftProof( + typeof thread?.id === "string" && + thread.id.length > 0 && + typeof thread.sessionId === "string" && + thread.sessionId.length > 0, + "thread/start current canonical identity", + ); + return { + title, + workspaceName, + marker, + rootPath, + workspaceId: item.id, + sessionId: thread.sessionId, + threadId: thread.id, + scope: { workspaceId: item.id, kind: "local", id: thread.sessionId }, + }; +} + +async function selectMarker(context, record) { + const locator = editor(context, record); + await until( + context, + () => + context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ), + (snapshot) => + snapshot.editorCount === 1 && + snapshot.sessionId === record.sessionId && + !snapshot.disabled, + "same-session Composer is editable before trusted selection keys", + ); + await locator.focus(); + await locator.press("ControlOrMeta+End"); + for (let index = 0; index < 3; index += 1) + await locator.press("Shift+ArrowLeft"); + const selection = await locator.evaluate((node) => { + const current = window.getSelection(); + return { + text: current?.toString(), + inside: + node.contains(current?.anchorNode) && node.contains(current?.focusNode), + }; + }); + requireDraftProof( + selection.inside && selection.text === record.marker.slice(-3), + "trusted keyboard creates noncollapsed same-editor selection", + ); +} + +export async function prepareRecord(context, record) { + context.setPhase?.("open-existing-local-session"); + await openRecord(context, record); + const original = await prepareVisibleDraft(context, record, async () => { + await context.modules.marks.prepareComposerMarksDraft({ + page: context.page, + options: context.options, + sessionId: record.sessionId, + threadId: record.threadId, + prompt: context.expected(record).prompt, + }); + }); + const thread = await readThread(context, record); + requireDraftProof( + thread.turns.length === 0, + "editing creates no canonical turn", + ); + return original; +} + +async function prepareVisibleMarks(context, record) { + const codeBody = " const value = '中😀';\n````"; + const paste = await context.modules.marks.pasteComposerMarksDraft({ + page: context.page, + sessionId: record.sessionId, + prompt: context.expected(record).prompt, + }); + requireDraftProof( + paste.trusted === false && + paste.defaultPrevented === true && + JSON.stringify(paste.formats) === + JSON.stringify(["text/html", "text/plain"]), + "real HTML paste ingress, no OS clipboard claim", + ); + const locator = editor(context, record); + await locator.locator(".composer-code-language-trigger").click(); + const menu = context.page.locator(".composer-code-language-menu:visible"); + await menu.getByRole("combobox").fill("TypeScript"); + await menu.locator('[data-code-language="typescript"]').click(); + // Public DOM Selection plus trusted keys; no EditorState or transaction injection. + await locator.evaluate(context.modules.marks.placeComposerCodeCaretAtEndDom); + for (const [index, key] of ["Enter", "Shift+Enter"].entries()) { + await locator.press(key); + await until( + context, + () => + context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ), + (snapshot) => + snapshot.editorCount === 1 && + snapshot.sessionId === record.sessionId && + snapshot.marks?.codeBlocks?.length === 1 && + snapshot.marks.codeBlocks[0].validStructure && + snapshot.marks.codeBlocks[0].language === "typescript" && + snapshot.marks.codeBlocks[0].body === codeBody + "\n".repeat(index + 1), + "trusted code editing retains same-identity rich document", + ); + } + await locator.locator("p").last().click(); +} + +/** Caller verifies public surface identity; this helper touches only visible GUI ingress. */ +export async function prepareVisibleDraft( + context, + record, + prepareMarks = null, +) { + requireDraftProof( + (await editor(context, record).count()) === 1, + "one identity-verified visible Composer before rich editing", + ); + const expected = context.expected(record); + context.setPhase?.("prepare-rich-code-and-marks"); + await (prepareMarks ? prepareMarks() : prepareVisibleMarks(context, record)); + await context.modules.history.prepareHistoryPaste( + context.page, + record.sessionId, + expected.pasteText, + ); + context.setPhase?.("select-installed-skill"); + await editor(context, record).focus(); + await editor(context, record).press("ControlOrMeta+End"); + await context.page.keyboard.insertText(" /capability-report"); + await until( + context, + () => + context.page.evaluate(() => { + const popover = document.querySelector( + '[data-testid="mention-popover-content"]', + ); + const target = Array.from( + popover?.querySelectorAll('[cmdk-item], [role="option"]') ?? [], + ).find( + (node) => + node instanceof HTMLElement && + node.getClientRects().length > 0 && + node.getAttribute("aria-disabled") !== "true" && + String(node.textContent) + .toLocaleLowerCase() + .replace(/[\s_-]+/g, "") + .includes("capabilityreport"), + ); + if (!(target instanceof HTMLElement)) return false; + target.click(); + return true; + }), + (selected) => selected === true, + "real installed-skill popup selection", + ); + context.setPhase?.("attach-image-through-filechooser"); + await context.modules.image.attachComposerImage(context.page, { + sessionId: record.sessionId, + imageButtonName: "添加图片", + timeoutMs: context.options.timeoutMs, + image: { + name: "composer-persistence.png", + mimeType: "image/png", + buffer: Buffer.from(expected.imageBase64, "base64"), + }, + }); + context.setPhase?.("drop-temporary-workspace-file"); + const dropped = await editor(context, record).evaluate( + (node, reference) => { + const target = node.closest('[data-testid="inputbar-core-container"]'); + if (!(target instanceof HTMLElement)) return false; + const dataTransfer = new DataTransfer(); + dataTransfer.setData( + "application/x-lime-path-reference", + JSON.stringify([reference]), + ); + dataTransfer.setData("text/plain", reference.path); + target.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + const event = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + }); + target.dispatchEvent(event); + return event.defaultPrevented; + }, + { + id: `file:${record.path}`, + path: record.path, + name: record.pathName, + isDir: false, + size: 128, + mimeType: "text/markdown", + }, + ); + requireDraftProof( + dropped === true, + "temporary real file enters through production path drop", + ); + await until( + context, + () => + context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ), + (snapshot) => + snapshot.editorCount === 1 && + snapshot.sessionId === record.sessionId && + snapshot.images.length === 1 && + snapshot.images[0].decoded && + snapshot.paths.length === 1 && + snapshot.paths[0].path === record.path && + snapshot.tokens.some((token) => token.id === "user:capability-report"), + "rich GUI resources and installed-skill token visible", + ); + await editor(context, record).focus(); + await editor(context, record).press("ControlOrMeta+End"); + await context.page.keyboard.insertText(` ${record.marker}`); + context.setPhase?.("save-complete-rich-draft-and-selection"); + await selectMarker(context, record); + const result = await waitSaved(context, record); + const entry = savedDraft(result, record.scope); + assertRichDraft(entry, context.expected(record)); + const dom = await context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ); + assertRestoredDom(dom, dom, record.sessionId); + return { entry, dom, revision: result.storeRevision }; +} + +export async function verifyRecord(context, record, original) { + await openRecord(context, record); + return verifyVisibleDraft(context, record, original); +} + +export async function verifyVisibleDraft(context, record, original) { + requireDraftProof( + (await editor(context, record).count()) === 1, + "one identity-verified visible Composer before restore observation", + ); + const result = await waitSaved(context, record); + const json = assertRestoredDraft(result, record.scope, original.entry); + // Focusing an already restored PM selection preserves the selection; no state write. + await editor(context, record).focus(); + const selected = await editor(context, record).evaluate(() => + window.getSelection()?.toString(), + ); + const expectedSelection = original.entry.draft.document; + requireDraftProof( + selected === + expectedSelection.text.slice( + expectedSelection.selectionStart, + expectedSelection.selectionEnd, + ), + "DOM selection restored from durable JSON", + ); + const dom = await context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ); + const rendered = assertRestoredDom(dom, original.dom, record.sessionId); + return { json, rendered, revision: result.storeRevision }; +} + +export async function readIndex(context, record, entry) { + const observed = await context.modules.atom.observePersistedAtomCommand( + context.page, + context.protocol, + "persisted_atom_read", + { + key: atomKey, + query: { kind: "workspace-index", workspaceId: record.workspaceId }, + }, + ); + return assertWorkspaceIndex( + observed.result, + record.workspaceId, + record.scope, + entry, + ); +} + +async function paste(context, record, text) { + await editor(context, record).focus(); + await editor(context, record).press("ControlOrMeta+End"); + await editor(context, record).evaluate((node, text) => { + const data = new DataTransfer(); + data.setData("text/plain", text); + const event = new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData: data, + }); + node.dispatchEvent(event); + if (!event.defaultPrevented) + throw new Error("real EditorView paste was not handled"); + }, text); +} + +export async function verifyRejectedSubmission(context, record, recordStage) { + const baseline = savedDraft(await waitSaved(context, record), record.scope); + assertRichDraft(baseline, context.expected(record)); + const input = "x".repeat((1 << 20) + 1); + await paste(context, record, input); + const before = savedDraft(await waitSaved(context, record), record.scope); + requireDraftProof( + before.draft.document.pendingPastes.some((paste) => paste.text === input), + "oversize input is a real saved hidden paste", + ); + await editor(context, record) + .locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]') + .locator('[data-testid="send-btn"]') + .click(); + await until( + context, + () => context.page.evaluate(() => document.body.innerText), + (text) => + /Input exceeds|maximum length|输入.{0,20}(最大|长度|限制)|輸入.{0,20}(最大|長度|限制)/u.test( + text, + ), + "user-visible oversized input rejection", + ); + const thread = await readThread(context, record); + requireDraftProof( + thread.turns.length === 0, + "rejected GUI input creates no canonical Turn", + ); + const after = savedDraft(await waitSaved(context, record), record.scope); + requireDraftProof( + isDeepStrictEqual(after, before), + "failure preserves exact rich draft and all resources", + ); + recordStage?.("rejection-exact-preservation-before-undo", { + noCanonicalTurn: true, + fullRichDraftPreserved: true, + before: summarizeDraft(before), + after: summarizeDraft(after), + }); + await editor(context, record).press("ControlOrMeta+z"); + await until( + context, + () => readScope(context, record.scope), + (result) => + result.data?.view?.entry?.state === "draft" && + !result.data.view.entry.draft.document.pendingPastes.some( + (paste) => paste.text === input, + ), + "trusted Undo removes only the oversized source", + ); + const undoDom = await context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ); + recordStage?.("rejection-after-trusted-undo", { + nativeDraft: summarizeDraft( + savedDraft(await waitSaved(context, record), record.scope), + ), + dom: { + editorCount: undoDom.editorCount, + sameSession: undoDom.sessionId === record.sessionId, + disabled: undoDom.disabled, + textLength: undoDom.text?.length ?? null, + codeBlocks: undoDom.marks?.codeBlocks?.length ?? 0, + links: undoDom.marks?.links?.length ?? 0, + tokenKinds: undoDom.tokens?.map((token) => token.type), + images: undoDom.images?.length ?? 0, + paths: undoDom.paths?.length ?? 0, + }, + }); + const clean = savedDraft(await waitSaved(context, record), record.scope); + requireDraftProof( + isDeepStrictEqual(clean.draft, baseline.draft), + "trusted Undo restores the exact original rich draft and selection", + ); + assertRichDraft(clean, context.expected(record)); + const dom = await context.modules.history.readComposerHistorySnapshot( + context.page, + record.sessionId, + ); + return { + evidence: { + noCanonicalTurn: true, + fullRichDraftPreserved: true, + failedDraftSha256: sha256(before.draft), + inputCharacters: input.length, + rejectionLayer: + "GUI send with canonical no-Turn proof; RPC admission requires trace review", + undoUsesTrustedKeyboard: true, + }, + original: { entry: clean, dom }, + }; +} + +export async function verifyAcceptedNewEdit(context, records, originals) { + const [first, second] = records; + await openRecord(context, first); + await editor(context, first) + .locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]') + .locator('[data-testid="send-btn"]') + .click(); + const thread = await until( + context, + () => readThread(context, first), + (thread) => thread.turns.length === 1, + "canonical accepted Turn", + ); + const turn = thread.turns[0]; + requireDraftProof( + typeof turn.id === "string" && + turn.id.length > 0 && + ["inProgress", "in_progress"].includes(turn.status), + "active canonical accepted receipt", + ); + const users = (turn.items ?? []).filter( + (item) => item.type === "userMessage", + ); + requireDraftProof( + users.length === 1 && + typeof users[0].id === "string" && + users[0].id.length > 0 && + Array.isArray(users[0].content), + "accepted Thread/Turn has exactly one canonical user Item", + ); + const text = users[0].content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + const expected = context.expected(first); + requireDraftProof( + text.includes(expected.prompt) && + text.includes(expected.pasteText) && + text.includes(first.marker) && + text.includes("const value = '中😀';"), + "canonical user Item preserves literal rich input and expanded hidden paste", + ); + await until( + context, + () => readScope(context, first.scope), + (result) => + result.outcome === "ready" && + result.data?.queryKind === "scope" && + result.data.view.entry === null && + result.persistence?.state === "saved" && + result.persistence.savedRevision >= result.storeRevision, + "accepted receipt consumes only submitted persistent identity", + ); + const consumedIdentity = { + draftId: originals[0].entry.draftId, + scopeVersion: originals[0].entry.scopeVersion, + }; + const consumption = await context.readConsumedDraft(consumedIdentity); + await editor(context, first).fill(nextEdit); + const newResult = await waitSaved(context, first); + const newEntry = savedDraft(newResult, first.scope); + requireDraftProof( + newEntry.draft.document.text === nextEdit && + (newEntry.draftId !== originals[0].entry.draftId || + newEntry.scopeVersion !== originals[0].entry.scopeVersion), + "new edit owns a newer saved identity after accepted consume", + ); + const newDom = await context.modules.history.readComposerHistorySnapshot( + context.page, + first.sessionId, + ); + await verifyRecord(context, second, originals[1]); + // Public canonical interrupt while another workspace is visible. It must not + // restore the old submitted body over either current workspace's newer draft. + await context.modules.rpc.invokeAppServerFromPage( + context.page, + "turn/interrupt", + { + threadId: first.threadId, + turnId: turn.id, + }, + ); + const terminal = await until( + context, + () => readThread(context, first), + (value) => + ["interrupted", "canceled", "cancelled"].includes(value.turns[0]?.status), + "canonical interrupted Turn under foreign GUI scope", + ); + await verifyRecord(context, second, originals[1]); + const firstRead = await readScope(context, first.scope); + assertRestoredDraft(firstRead, first.scope, newEntry); + await verifyRecord(context, first, { entry: newEntry, dom: newDom }); + originals[0] = { entry: newEntry, dom: newDom }; + return { + threadId: first.threadId, + sessionId: first.sessionId, + turnId: turn.id, + terminalStatus: terminal.turns[0].status, + newDraft: summarizeDraft(newEntry), + consumption, + userItemId: users[0].id, + canonicalUserItemSha256: sha256(users[0]), + newerEditPreserved: true, + foreignWorkspacePreserved: true, + exactPendingTokenRace: false, + interruptSurface: "preload -> App Server turn/interrupt", + canonicalTurnSha256: sha256(terminal.turns[0]), + }; +} diff --git a/scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs b/scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs new file mode 100644 index 000000000..49fa36b67 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs @@ -0,0 +1,685 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { + assertFirstSendConsumed, + assertFirstSendNewEdit, + assertFirstSendReceipt, + assertFirstSendResourceReceipt, + assertFirstSendSavedFiles, + assertFirstSendScopeObservation, + assertFirstSendSource, + assertFirstSendRouteCoverage, + assertFirstSendTrace, + assertUnboundScope, + captureFirstSendResources, + firstSendRoutes, + newEditText, + requireFirstSend, +} from "../electron/lib/composer-first-send-evidence.mjs"; + +const markerByRoute = { + home: "Home首发甲一号", + "search-new": "搜索新建首发乙二号", + "project-new": "项目新建首发丙三号", +}; + +async function until(context, read, ready, phase) { + const started = Date.now(); + while (Date.now() - started < context.options.timeoutMs) { + const value = await read(); + if (ready(value)) return value; + await new Promise((resolve) => + setTimeout(resolve, context.options.intervalMs), + ); + } + throw new Error("Composer first-send fixture timed out: " + phase); +} + +async function canonicalThreads(context) { + const response = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "thread/list", + { archived: false, limit: 100 }, + ); + requireFirstSend( + Array.isArray(response.result?.data) && !response.result.nextCursor, + "bounded complete canonical inventory through public RPC", + ); + return response.result.data; +} + +async function readThread(context, threadId) { + const response = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "thread/read", + { threadId, includeTurns: true }, + ); + requireFirstSend( + response.result?.thread?.id === threadId, + "canonical read identity", + ); + return response.result.thread; +} + +async function visibleSurface(context) { + return context.page.evaluate( + context.modules.dom.withComposerEditorDom(() => { + const editors = findVisibleComposerEditorsDom(); + const editor = editors.length === 1 ? editors[0] : null; + return { + electron: window.__LIME_ELECTRON__ === true, + preload: + typeof window.electronAPI?.invoke === "function" && + window.electronAPI.devBridgeFallback === false, + editorCount: editors.length, + text: editor ? readComposerEditorTextDom(editor) : null, + sessionId: editor?.dataset.sessionId || null, + disabled: editor + ? editor.getAttribute("aria-disabled") === "true" || + editor.getAttribute("contenteditable") !== "true" + : true, + emptyState: Boolean( + document.querySelector('[data-testid="empty-state-first-screen"]'), + ), + }; + }), + ); +} + +async function openRealHome(context, record) { + await context.modules.rpc.bindGuiWorkspaceAndModelPreferences( + context.page, + record.workspaceId, + ); + // Public Home delegates to startNewConversation with explicit projectless scope. + await context.page.getByTestId("app-sidebar-home-button").click(); + await until( + context, + () => visibleSurface(context), + (dom) => + dom.editorCount === 1 && + dom.sessionId === null && + dom.disabled === false && + dom.emptyState === true, + "real Home button committed the new-task null-session surface", + ); +} + +async function openRealDraft(context, record, projectNew) { + if (projectNew) { + const bootstrap = await context.modules.drafts.openRecord(context, record); + requireFirstSend( + bootstrap.turns.length === 0, + "navigation-only bootstrap thread has never been submitted", + ); + const group = context.page + .getByTestId("app-sidebar-project-conversation-group") + .filter({ + has: context.page + .locator("button[aria-expanded]") + .and(context.page.getByTitle(record.workspaceName, { exact: true })), + }); + requireFirstSend( + (await group.count()) === 1, + "exactly one genuine project group matches the canonical workspace name", + ); + await group.locator("button[aria-expanded]").hover(); + await group.getByTestId("app-sidebar-project-new-conversation").click(); + } else { + await openRealHome(context, record); + // The search creation entry delegates to the same public fresh action. + await context.page.getByTestId("app-sidebar-search-button").click(); + await context.page + .getByTestId("app-sidebar-search-new-conversation") + .click(); + } + await until( + context, + () => visibleSurface(context), + (value) => + value.editorCount === 1 && + value.sessionId === null && + value.disabled === false, + "real public create entry exposes one null-session Composer", + ); + return { + ...record, + sessionId: null, + threadId: null, + workspaceId: projectNew ? record.workspaceId : null, + scope: { + workspaceId: projectNew ? record.workspaceId : null, + kind: "home", + id: "new-conversation", + }, + navigationOnlyBootstrapSessionId: record.sessionId, + }; +} + +async function createSourceRecord(context, route) { + const title = "Composer first-send " + context.options.runId + " " + route; + const marker = markerByRoute[route]; + let record; + if (route === "home" || route === "search-new") { + const workspace = await context.modules.rpc.ensureDefaultWorkspace( + context.page, + ); + record = { + title, + marker, + rootPath: workspace.rootPath, + workspaceId: workspace.workspaceId, + sessionId: null, + threadId: null, + scope: { + workspaceId: workspace.workspaceId, + kind: "home", + id: "new-conversation", + }, + }; + if (route === "home") { + await openRealHome(context, record); + record.workspaceId = null; + record.scope = { + workspaceId: null, + kind: "home", + id: "new-conversation", + }; + } else { + record = await openRealDraft(context, record, false); + } + } else { + const rootPath = path.join( + context.runtimeEnv.tempRoot, + "first-send-" + route, + ); + await fs.mkdir(rootPath); + const bootstrap = await context.modules.drafts.createRecord(context, { + rootPath, + title, + marker, + }); + record = await openRealDraft(context, bootstrap, route === "project-new"); + } + record.path = path.join( + context.runtimeEnv.tempRoot, + "first-send-" + route + ".md", + ); + record.pathName = path.basename(record.path); + await fs.writeFile( + record.path, + "# First-send path reference\n" + marker + "\n", + ); + return record; +} + +async function readWorkspaceIndex(context, workspaceId) { + const observed = await context.modules.atom.observePersistedAtomCommand( + context.page, + context.protocol, + "persisted_atom_read", + { + key: "composer-drafts-v1", + query: { kind: "workspace-index", workspaceId }, + }, + { timeoutMs: context.options.timeoutMs }, + ); + return observed.result; +} + +async function observeNativeSource(context, route, record, expectedMarker) { + return until( + context, + async () => { + const workspaceIndex = await readWorkspaceIndex( + context, + record.workspaceId, + ); + const kind = "home"; + const items = workspaceIndex.data?.items?.filter( + (item) => item.scope?.kind === kind, + ); + // Fail closed on absent or ambiguous metadata. No fallback scope/Thread. + if (items?.length !== 1) return null; + const scopeRead = await context.modules.drafts.readScope( + context, + items[0].scope, + ); + return { + workspaceId: record.workspaceId, + workspaceIndex, + scopeRead, + expectedMarker, + dom: await visibleSurface(context), + }; + }, + (value) => { + try { + assertFirstSendScopeObservation(route, value ?? {}); + return true; + } catch { + return false; + } + }, + "unique same-atom metadata and saved payload match the sole real Composer", + ); +} + +async function sourceBeforeSend(context, route, record) { + // Typing must not create a canonical session before the public Send action. + const beforeThreads = await canonicalThreads(context); + const probe = "Scope probe " + context.options.runId + " " + route; + await context.modules.dom.fillComposerEditor(context.page, probe, null); + const discovered = await observeNativeSource(context, route, record, probe); + requireFirstSend( + discovered.dom.text === probe && + discovered.scopeRead.data.view.entry.draft.document.text === probe && + (record.scope === null || + isDeepStrictEqual(record.scope, discovered.scopeRead.data.view.scope)), + "real probe matches exact visible and saved text before scope binding", + ); + record.scope = discovered.scopeRead.data.view.scope; + const original = await context.modules.drafts.prepareVisibleDraft( + context, + record, + ); + await context.modules.drafts.verifyVisibleDraft(context, record, original); + const observed = await observeNativeSource( + context, + route, + record, + record.marker, + ); + const expected = context.expected(record); + const skillPath = await fs.realpath( + path.join( + context.runtimeEnv.env.HOME, + ".agents", + "skills", + "capability-report", + "SKILL.md", + ), + ); + requireFirstSend( + (await fs.stat(skillPath)).isFile(), + "installed Skill identity points to the real fixture SKILL.md", + ); + const skillIdentity = { + name: "capability-report", + key: "user:capability-report", + path: skillPath, + }; + const literal = context.modules.marks.composerMarksFixture({ + prompt: expected.prompt, + skillIdentity, + pasteText: expected.pasteText, + }); + const expectedSubmission = { + ...literal, + text: literal.text + " " + record.marker, + displayText: literal.displayText + " " + record.marker, + }; + requireFirstSend( + original.dom.text === expectedSubmission.displayText, + "independent fixed Markdown fixture matches the current rendered rich draft", + ); + const resources = captureFirstSendResources(original.entry, { + ...expected, + skillIdentity, + expectedSubmission, + skillFileSha256: createHash("sha256") + .update(await fs.readFile(skillPath)) + .digest("hex"), + }); + const preparedThreads = await canonicalThreads(context); + return { + proof: { + ...assertFirstSendSource(route, { + ...observed, + scope: record.scope, + entry: original.entry, + beforeThreads, + preparedThreads, + }), + scopeDiscovery: assertFirstSendScopeObservation(route, discovered), + resources, + }, + // Raw body/media stay local to this run; only resource metadata is logged. + receiptExpected: { + expectedSubmission, + prompt: expected.prompt, + editorText: original.dom.text, + skillIdentity, + skillToken: original.dom.tokens.find((token) => token.type === "skill"), + pasteToken: original.dom.tokens.find((token) => token.type === "paste"), + pasteText: expected.pasteText, + imageBase64: expected.imageBase64, + pathReference: { name: record.pathName, path: record.path }, + }, + }; +} + +async function clickSendAndReadReceipt( + context, + record, + source, + receiptExpected, +) { + await context.page + .locator(context.modules.dom.composerEditorSelector() + ":visible") + .locator('xpath=ancestor::*[@data-testid="inputbar-core-container"][1]') + .getByTestId("send-btn") + .click(); + const dom = await until( + context, + () => visibleSurface(context), + (value) => value.editorCount === 1 && value.sessionId && !value.disabled, + "accepted ready callback exposes the genuine local Composer", + ); + const threads = await canonicalThreads(context); + const thread = threads.find((item) => item.sessionId === dom.sessionId); + requireFirstSend(thread, "visible accepted session has a canonical thread"); + const expected = context.expected(record); + const fragments = [ + expected.prompt, + record.marker, + expected.pasteText, + "const value = '中😀';", + ]; + const accepted = await until( + context, + () => readThread(context, thread.id), + (value) => { + try { + assertFirstSendReceipt(value, source, fragments); + return true; + } catch { + return false; + } + }, + "accepted first-send canonical Turn and independently expected body fragments", + ); + const receipt = assertFirstSendReceipt(accepted, source, fragments); + const mediaRead = await context.modules.launcher.appServerCallFromPage( + context.page, + "media/read", + { threadId: receipt.threadId, uri: source.resources.image.canonicalUrl }, + ); + const { summarizeRichInputSubmission } = await import( + pathToFileURL( + path.join( + context.options.repoRoot, + "scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.mjs", + ), + ).href + ); + const richInput = summarizeRichInputSubmission({ + ...receiptExpected, + canonical: { thread: accepted }, + threadId: receipt.threadId, + turnId: receipt.turnId, + traceRaw: ( + await context.modules.history.readComposerHistorySnapshot( + context.page, + receipt.sessionId, + ) + ).traceRaw, + mediaRead, + }); + return { + ...receipt, + richInput: assertFirstSendResourceReceipt(source, receipt, { + richInput, + mediaRead, + expectedSubmission: receiptExpected.expectedSubmission, + }), + }; +} + +async function waitUnbound(context, scope) { + return until( + context, + () => context.modules.drafts.readScope(context, scope), + (result) => { + try { + assertUnboundScope(result, scope); + return true; + } catch { + return false; + } + }, + "native exact scope unbound after accepted consumption", + ); +} + +async function acceptedNewEdit(fixture, context, record, source, receipt) { + const local = { + ...record, + sessionId: receipt.sessionId, + threadId: receipt.threadId, + scope: { + workspaceId: record.workspaceId, + kind: "local", + id: receipt.sessionId, + }, + }; + const oldScope = await waitUnbound(context, record.scope); + const localScope = await waitUnbound(context, local.scope); + requireFirstSend( + oldScope.storeRevision === localScope.storeRevision, + "old and accepted local scopes acknowledge the same saved consumption", + ); + const consumed = await fixture.inspectSavedFiles( + localScope.storeRevision, + (envelope) => assertFirstSendConsumed(envelope, source, receipt), + ); + assertFirstSendSavedFiles(consumed, { + ...source.identity, + sameCapturedIdentity: true, + sessionId: receipt.sessionId, + threadId: receipt.threadId, + }); + // Real contenteditable input; no Controller, PM-state or native-atom injection. + await context.modules.dom.fillComposerEditor( + context.page, + newEditText, + receipt.sessionId, + ); + const saved = await context.modules.drafts.waitSaved(context, local); + const entry = saved.data.view.entry; + const newEdit = assertFirstSendNewEdit(entry, source, receipt); + const original = { + entry, + dom: await context.modules.history.readComposerHistorySnapshot( + context.page, + receipt.sessionId, + ), + }; + await context.modules.rpc.invokeAppServerFromPage( + context.page, + "turn/interrupt", + { + threadId: receipt.threadId, + turnId: receipt.turnId, + }, + ); + const terminal = await until( + context, + () => readThread(context, receipt.threadId), + (thread) => + thread.turns?.length === 1 && + thread.turns[0].id === receipt.turnId && + ["interrupted", "canceled", "cancelled"].includes(thread.turns[0].status), + "public canonical interrupt settles the accepted old Turn", + ); + await context.modules.drafts.verifyVisibleDraft(context, local, original); + const final = await context.modules.drafts.waitSaved(context, local); + requireFirstSend( + isDeepStrictEqual(final.data.view.entry, entry), + "old accepted Turn completion preserves exact new document and identity", + ); + await waitUnbound(context, record.scope); + const newFiles = await fixture.inspectSavedFiles( + final.storeRevision, + (envelope) => { + const disk = envelope.atoms["composer-drafts-v1"].entries[entry.draftId]; + requireFirstSend( + isDeepStrictEqual(disk, entry), + "new local draft equals actual saved bytes", + ); + return assertFirstSendNewEdit(disk, source, receipt); + }, + ); + assertFirstSendSavedFiles(newFiles, { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + newEditPreserved: true, + documentSha256: newEdit.documentSha256, + }); + return { + oldScope: assertUnboundScope(oldScope, record.scope), + acceptedLocalScope: assertUnboundScope(localScope, local.scope), + consumedFiles: consumed, + newEdit, + savedNewEditFiles: newFiles, + terminalStatus: terminal.turns[0].status, + exactPendingAcceptanceRace: false, + interruptSurface: "preload -> App Server turn/interrupt", + }; +} + +/** Inject into the sole shared launcher/lifecycle driver after root adoption. */ +export const firstSendScenario = Object.freeze({ + id: "composer-first-submissions", + scope: + "Home restore, projectless search New and project New first submissions through startNewConversation", + generationCount: 3, + sourcePaths: [ + "scripts/agent-runtime/claw-chat-current-fixture-first-send.mjs", + "scripts/electron/lib/composer-first-send-evidence.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs", + "scripts/electron/lib/composer-draft-fixture-evidence.mjs", + "src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts", + "src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts", + "src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts", + "src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts", + "src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts", + "src/components/input-kit/ComposerDraftCoordinator.ts", + "src/lib/workspace/navigation.ts", + "src/hooks/useAppNavigation.ts", + "src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts", + "src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts", + "src/components/AppSidebar.tsx", + "src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx", + "src/components/agent/chat/workspace/taskCenterTabProjection.ts", + "src/components/app-sidebar/AppSidebarSearchDialog.tsx", + "src/components/app-sidebar/AppSidebarProjectConversationGroups.tsx", + "src/components/app-sidebar/useAppSidebarConversationActions.ts", + "src/lib/api/persistedAtomTypes.ts", + "src/lib/api/persistedAtomProtocol.ts", + "scripts/lib/composer-editor-dom.mjs", + "scripts/electron/mcp-config-fixture-smoke.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-skill-identity.mjs", + "scripts/electron/lib/mcp-config-fixture-evidence.mjs", + "src/components/input-kit/composerSerialization.ts", + "src/components/input-kit/composerLinks.ts", + "src/components/agent/chat/utils/buildUserInputSubmitOp.ts", + "src/components/agent/chat/workspace/inputCapabilityRouting.ts", + "lime-rs/crates/app-server/src/runtime/input_media.rs", + "lime-rs/crates/app-server/src/processor/thread/projection/item.rs", + "lime-rs/crates/app-server-protocol/src/protocol/v2/media.rs", + ], + limitations: [ + "Requires root-adopted scenario driver and shared visible-editor helpers; no product build or Electron launch was performed while preparing this candidate.", + "Navigation-only bootstrap threads are never submitted or counted as first-send coverage.", + "Public Home/search/project-new-conversation clicks and exact same-atom stable home metadata identify the editing source; no top strip or draft picker is involved.", + "All three entries use the same typed first-send path; canonical inventory must show no session created by editor input before Send.", + "New edit follows disk-acknowledged consume; exact pending old-token acceptance timing is not claimed.", + "Public RPC interrupt is used; GUI Stop, queue acceptance and rejection routes are not covered.", + "Normal process lifecycle is checked by the shared driver; this scenario does not claim cold rich-draft restoration.", + "Shared rich-input ingress uses synthetic ClipboardEvent/DataTransfer/DragEvent; OS clipboard and OS drag are not covered.", + ], + async run(fixture) { + requireFirstSend( + [ + "launch", + "quit", + "stopTrace", + "inspectSavedFiles", + "recordStage", + "setPhase", + ].every((name) => typeof fixture?.[name] === "function"), + "root-adopted shared scenario driver contract", + ); + const proofs = []; + for (const [index, route] of firstSendRoutes.entries()) { + const context = await fixture.launch(index + 1); + fixture.setPhase("first-send-" + route + "-source"); + requireFirstSend( + [ + "createRecord", + "openRecord", + "readScope", + "waitSaved", + "prepareVisibleDraft", + "verifyVisibleDraft", + ].every((name) => typeof context.modules.drafts?.[name] === "function"), + "root-adopted shared visible-editor action contract", + ); + const record = await createSourceRecord(context, route); + const source = await sourceBeforeSend(context, route, record); + fixture.recordStage(route + "-actual-first-send-source", source.proof); + fixture.setPhase("first-send-" + route + "-accepted"); + const receipt = await clickSendAndReadReceipt( + context, + record, + source.proof, + source.receiptExpected, + ); + const settled = await acceptedNewEdit( + fixture, + context, + record, + source.proof, + receipt, + ); + const trace = assertFirstSendTrace(await fixture.stopTrace()); + const proof = { + route, + workspaceId: record.workspaceId, + source: source.proof, + receipt, + ...settled, + trace, + }; + fixture.recordStage( + route + "-accepted-promotion-consumption-new-edit", + proof, + ); + proofs.push(proof); + await fixture.quit(); + } + assertFirstSendRouteCoverage(proofs); + return { + guiDraftRestoration: false, + firstSubmissionRoutesVerified: true, + logicalDraftScopeOnly: true, + visibleTabIdentityVerified: false, + firstSubmissionRoutes: proofs, + records: proofs.map((proof) => ({ + route: proof.route, + workspaceId: proof.workspaceId, + ...proof.receipt, + sourceIdentity: proof.source.identity, + newEditIdentity: { + draftId: proof.newEdit.draftId, + scopeVersion: proof.newEdit.scopeVersion, + }, + })), + }; + }, +}); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs b/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs index b3f2ac1ff..dcfe683af 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-inputbar-rich-restore.mjs @@ -40,6 +40,7 @@ import { withInvokeTraceCollector } from "../lib/invoke-trace-collector.mjs"; import { prepareHistoryPaste, captureComposerHistoryDraft, + clearComposerDraftForHistory, verifyComposerHistoryRecall, } from "./claw-chat-current-fixture-composer-history.mjs"; import { @@ -608,6 +609,12 @@ export async function prepareRichDraft( prompt: INPUTBAR_RICH_RESTORE_PROMPT, }) : null; + if (marksEvidence) + recordRichRestoreStep( + summary, + "marks-language-picker", + marksEvidence.pickerBoundary, + ); const pasteToken = await prepareHistoryPaste( page, richRestoreSessionId, @@ -800,24 +807,6 @@ async function runObservedInputbarRichRestoreScenario({ const threadId = summary?.threadId; summary.inputbarRichRestoreBackendTurnStart = summarizeBackendTurnStart(backendTurnStart); - summary.inputbarRichRestoreHistoryBoundary = - await verifyComposerHistoryRecall({ - page, - options, - prepared: historyPrepared, - threadId, - sessionId: richRestoreSessionId, - skillIdentity, - expectedSubmission, - backendText: backendTurnStart.entry.inputText, - imageDataUrl: `data:image/png;base64,${RICH_RESTORE_IMAGE_BASE64}`, - pathReference: { - id: `file:${INPUTBAR_RICH_RESTORE_PATH}`, - path: INPUTBAR_RICH_RESTORE_PATH, - name: INPUTBAR_RICH_RESTORE_PATH_NAME, - isDir: "false", - }, - }); summary.inputbarRichRestoreStopClick = sanitizeJson( await waitForStopButtonVisibleAndClick(page, options), @@ -896,6 +885,33 @@ async function runObservedInputbarRichRestoreScenario({ }, ); + // History navigation is a new editing operation and invalidates old restore tokens. + // Verify Stop first, then independently recall history from a user-cleared draft. + summary.inputbarRichRestoreHistoryStart = await clearComposerDraftForHistory({ + page, + options, + sessionId: richRestoreSessionId, + }); + summary.inputbarRichRestoreHistoryBoundary = + await verifyComposerHistoryRecall({ + page, + options, + traceCollector, + prepared: historyPrepared, + threadId, + sessionId: richRestoreSessionId, + skillIdentity, + expectedSubmission, + backendText: backendTurnStart.entry.inputText, + imageDataUrl: `data:image/png;base64,${RICH_RESTORE_IMAGE_BASE64}`, + pathReference: { + id: `file:${INPUTBAR_RICH_RESTORE_PATH}`, + path: INPUTBAR_RICH_RESTORE_PATH, + name: INPUTBAR_RICH_RESTORE_PATH_NAME, + isDir: "false", + }, + }); + summary.inputbarRichRestoreUserMessageCollapse = await runRichUserMessageCollapseGate({ page, diff --git a/scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.test.mjs index a5de334b4..c5e2eb9ef 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.test.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-rich-input-evidence.test.mjs @@ -2,6 +2,7 @@ import { Buffer } from "node:buffer"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import process from "node:process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { ensureUserVisibleCapabilityReportSkill } from "./claw-chat-current-fixture-skills-workspace.mjs"; import { @@ -9,6 +10,30 @@ import { summarizeRichInputSubmission, } from "./claw-chat-current-fixture-rich-input-evidence.mjs"; import { composerMarksFixture } from "./claw-chat-current-fixture-composer-marks.mjs"; +import { + INPUTBAR_RICH_RESTORE_PATH, + INPUTBAR_RICH_RESTORE_PATH_NAME, +} from "./claw-chat-current-fixture-constants.mjs"; + +describe("rich input current file reference fixture", () => { + it("references a real readable file owned by the current fixture domain", () => { + expect(path.basename(INPUTBAR_RICH_RESTORE_PATH)).toBe( + INPUTBAR_RICH_RESTORE_PATH_NAME, + ); + expect(path.relative(process.cwd(), INPUTBAR_RICH_RESTORE_PATH)).toBe( + path.join( + "scripts", + "agent-runtime", + "fixtures", + INPUTBAR_RICH_RESTORE_PATH_NAME, + ), + ); + expect(fs.statSync(INPUTBAR_RICH_RESTORE_PATH).isFile()).toBe(true); + expect(fs.readFileSync(INPUTBAR_RICH_RESTORE_PATH, "utf8").trim()).not.toBe( + "", + ); + }); +}); let root; let skillIdentity; @@ -205,6 +230,7 @@ describe("rich Composer submission evidence", () => { (mode) => { const input = marksFixture(); const part = input.request.params.input[0]; + const before = part.text; if (mode === "display") part.text = `/capability-report ${input.evidence.editorText}`; if (mode === "href") @@ -215,9 +241,10 @@ describe("rich Composer submission evidence", () => { if (mode === "title") part.text = part.text.replace(' "标题"', ""); if (mode === "fence") part.text = part.text.replaceAll("`````", "```"); if (mode === "code-space") - part.text = part.text.replace(" 中😀", "中😀"); + part.text = part.text.replace(" const value", "const value"); if (mode === "code-lf") part.text = part.text.replace("````\n\n\n`````", "````\n`````"); + expect(part.text).not.toBe(before); input.evidence.canonical.thread.turns[0].items[0].content[0] = structuredClone(part); expect(summarize(input).ok).toBe(false); diff --git a/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs b/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs index 2bebcf5ad..4b8219583 100644 --- a/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs +++ b/scripts/agent-runtime/claw-chat-current-fixture-smoke.mjs @@ -9,6 +9,8 @@ import { _electron as electron, chromium } from "playwright"; import { resolveElectronAppServerRuntimeEnv } from "../lib/electron-app-server-assets.mjs"; import { resolveDevAppServerBinary } from "../lib/electron-dev-sidecar.mjs"; import { ensureElectronFixtureBuild } from "../lib/electron-fixture-build.mjs"; +import { captureMainProcessExit } from "../electron/lib/persisted-atom-fixture-driver.mjs"; +import { settleFixtureResources } from "./claw-chat-current-fixture-teardown.mjs"; import { APP_SERVER_METHOD_SESSION_LIST, APPROVAL_REQUEST_CANCEL_SCENARIO, @@ -938,6 +940,7 @@ async function run() { }; let app = null; + let mainExit = null; let cdpBrowser = null; let page = null; let imageProviderFixtureServer = null; @@ -952,13 +955,32 @@ async function run() { const actionableConsoleErrors = []; const pageErrors = []; const agentDebugLogs = []; + const composerDiagnosticReads = []; const pageLifecycleEvents = []; const fixtureConfigSoulOverrides = createSoulStyleFixtureOverrides(soulStyleSelection); const collectConsoleMessage = (message) => { const text = sanitizeText(message.text()); if (text.includes("[AgentDebug]")) { - agentDebugLogs.push({ type: message.type(), text }); + const entry = { type: message.type(), text }; + agentDebugLogs.push(entry); + if (text.startsWith("[AgentDebug] ComposerSubmission.")) { + // CDP's console text previews only a few object fields; retain this + // owner's numeric/boolean diagnostics before its handle is released. + composerDiagnosticReads.push( + message + .args()[1] + ?.jsonValue() + .then( + (context) => { + entry.context = sanitizeJson(context); + }, + () => { + entry.contextUnavailable = true; + }, + ), + ); + } } if (message.type() === "error") { consoleErrors.push(text); @@ -1044,6 +1066,7 @@ async function run() { timeout: options.timeoutMs, }); + mainExit = captureMainProcessExit(app.process()); app.on("console", collectConsoleMessage); app.on("close", () => { pageLifecycleEvents.push({ @@ -1501,6 +1524,7 @@ async function run() { summary.consoleErrors = consoleErrors; summary.actionableConsoleErrors = actionableConsoleErrors; + await Promise.allSettled(composerDiagnosticReads); summary.agentDebugLogs = agentDebugLogs.slice(-200); summary.agentStreamDebugLogs = agentDebugLogs .filter((entry) => entry.text.includes("[AgentDebug] AgentStream.")) @@ -1531,11 +1555,7 @@ async function run() { summary.commonAssertions = assertionReport.commonAssertions; summary.scenarioAssertions = assertionReport.scenarioAssertions; summary.notApplicableAssertions = assertionReport.notApplicableAssertions; - summary.ok = true; - summary.completedAt = new Date().toISOString(); - writeJsonFile(summaryPath, summary); - console.log(LOG_PREFIX + " summary=" + summaryPath); - console.log(LOG_PREFIX + " pass session=" + summary.sessionId); + summary.businessAssertionsPassed = true; } catch (error) { try { if (page) { @@ -1606,6 +1626,7 @@ async function run() { applySoulStyleProviderMarkerSummary(summary, textProviderRequests); } } + await Promise.allSettled(composerDiagnosticReads); summary.agentDebugLogs = agentDebugLogs.slice(-200); summary.agentStreamDebugLogs = agentDebugLogs .filter((entry) => entry.text.includes("[AgentDebug] AgentStream.")) @@ -1628,23 +1649,28 @@ async function run() { console.error(LOG_PREFIX + " failureSummary=" + summaryPath); process.exitCode = 1; } finally { - if (invokeTraceCollector) { - await invokeTraceCollector.stop().catch(() => undefined); - } - if (cdpBrowser) { - await cdpBrowser.close().catch(() => undefined); - } - if (app) { - await app.close().catch(() => undefined); - } - if (imageProviderFixtureServer) { - await imageProviderFixtureServer.close().catch(() => undefined); - } - if (textProviderFixtureServer) { - await textProviderFixtureServer.close().catch(() => undefined); - } - if (!options.keepTemp) { - cleanupTempRoot(runtimeEnv.tempRoot); + summary.teardown = await settleFixtureResources({ + app, + mainExit, + cdpBrowser, + invokeTraceCollector, + imageProviderFixtureServer, + textProviderFixtureServer, + cleanupTemp: options.keepTemp + ? null + : () => cleanupTempRoot(runtimeEnv.tempRoot), + }); + summary.pageLifecycleEvents = pageLifecycleEvents; + summary.ok = + summary.businessAssertionsPassed === true && summary.teardown.ok; + summary.completedAt = new Date().toISOString(); + writeJsonFile(summaryPath, summary); + if (summary.ok) { + console.log(LOG_PREFIX + " summary=" + summaryPath); + console.log(LOG_PREFIX + " pass session=" + summary.sessionId); + } else { + console.error(LOG_PREFIX + " failureSummary=" + summaryPath); + process.exitCode = 1; } } } diff --git a/scripts/agent-runtime/claw-chat-current-fixture-teardown.mjs b/scripts/agent-runtime/claw-chat-current-fixture-teardown.mjs new file mode 100644 index 000000000..e227dd126 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-teardown.mjs @@ -0,0 +1,97 @@ +import { quitMainProcessNormally } from "../electron/lib/persisted-atom-fixture-driver.mjs"; + +function bounded(callback, timeoutMs, phase) { + let timer; + return Promise.race([ + Promise.resolve().then(callback), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`fixture teardown timeout: ${phase}`)), + timeoutMs, + ); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Resource failure is a failed Gate, even when all business assertions passed. */ +export async function settleFixtureResources({ + app, + mainExit, + cdpBrowser, + invokeTraceCollector, + imageProviderFixtureServer, + textProviderFixtureServer, + cleanupTemp, + timeoutMs = 15_000, + failureCleanupTimeoutMs = 5_000, +}) { + const result = { + ok: true, + steps: [], + normalMainExit: null, + forcedCleanup: null, + }; + const settle = async (phase, callback) => { + const startedAt = new Date().toISOString(); + try { + const value = await callback(); + result.steps.push({ + phase, + startedAt, + completedAt: new Date().toISOString(), + ok: true, + }); + return value; + } catch (error) { + result.ok = false; + result.steps.push({ + phase, + startedAt, + completedAt: new Date().toISOString(), + ok: false, + error: String(error), + }); + return null; + } + }; + if (invokeTraceCollector) + await settle("invoke-trace", () => + bounded(() => invokeTraceCollector.stop(), timeoutMs, "invoke-trace"), + ); + if (cdpBrowser) + await settle("cdp", () => + bounded(() => cdpBrowser.close(), timeoutMs, "cdp"), + ); + if (app) { + result.normalMainExit = await settle("normal-main-exit", () => + quitMainProcessNormally({ app, exit: mainExit }, timeoutMs), + ); + if (!result.normalMainExit && mainExit?.getExitRecord() === null) { + // Reap only this launch's Main after the Gate has already failed. A signal + // receipt never repairs normalMainExit or the failed Gate result. + result.forcedCleanup = await settle("failed-main-reap", async () => { + const child = app.process(); + if (child.pid !== mainExit.pid) + throw new Error("fixture Main ownership changed; refusing to signal"); + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGKILL"); + return bounded( + () => mainExit.exitPromise, + failureCleanupTimeoutMs, + "failed-main-reap", + ); + }); + } + } + for (const [phase, server] of [ + ["image-provider", imageProviderFixtureServer], + ["text-provider", textProviderFixtureServer], + ]) { + if (server) + await settle(phase, () => + bounded(() => server.close(), timeoutMs, phase), + ); + } + if (cleanupTemp) await settle("temp-root", cleanupTemp); + return result; +} diff --git a/scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs b/scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs new file mode 100644 index 000000000..546e51f18 --- /dev/null +++ b/scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs @@ -0,0 +1,129 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { captureMainProcessExit } from "../electron/lib/persisted-atom-fixture-driver.mjs"; +import { settleFixtureResources } from "./claw-chat-current-fixture-teardown.mjs"; + +function fixture() { + const child = Object.assign(new EventEmitter(), { + pid: 719, + exitCode: null, + signalCode: null, + kill: vi.fn((signal) => exit(null, signal)), + }); + function exit(code = 0, signal = null) { + child.exitCode = code; + child.signalCode = signal; + child.emit("exit", code, signal); + } + const app = { process: () => child, close: vi.fn(async () => exit()) }; + const cleanupTemp = vi.fn(); + const imageProviderFixtureServer = { close: vi.fn() }; + const textProviderFixtureServer = { close: vi.fn() }; + return { + app, + child, + exit, + cleanupTemp, + mainExit: captureMainProcessExit(child), + imageProviderFixtureServer, + textProviderFixtureServer, + timeoutMs: 50, + failureCleanupTimeoutMs: 50, + }; +} + +describe("current fixture resource settlement (explicit fake processes)", () => { + it("cannot report success before Main actually exits, even when close resolved", async () => { + const f = fixture(); + f.app.close.mockResolvedValue(undefined); + let settled = false; + const pending = settleFixtureResources(f).then((value) => { + settled = true; + return value; + }); + await Promise.resolve(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(f.cleanupTemp).not.toHaveBeenCalled(); + f.exit(); + expect(await pending).toMatchObject({ + ok: true, + normalMainExit: { exitCode: 0, signal: null, mode: "normal" }, + forcedCleanup: null, + }); + expect(f.child.kill).not.toHaveBeenCalled(); + expect(f.cleanupTemp).toHaveBeenCalledOnce(); + }); + + it("bounds a hung close, marks failure and reaps only the owned failed Main", async () => { + const f = fixture(); + f.timeoutMs = 2; + f.app.close.mockImplementation(() => new Promise(() => {})); + const result = await settleFixtureResources(f); + expect(result).toMatchObject({ + ok: false, + normalMainExit: null, + forcedCleanup: { pid: 719, exitCode: null, signal: "SIGKILL" }, + }); + expect(result.steps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "normal-main-exit", + ok: false, + error: expect.stringContaining("normal Main exit timeout"), + }), + ]), + ); + expect(f.child.kill).toHaveBeenCalledExactlyOnceWith("SIGKILL"); + expect(f.imageProviderFixtureServer.close).toHaveBeenCalledOnce(); + expect(f.textProviderFixtureServer.close).toHaveBeenCalledOnce(); + expect(f.cleanupTemp).toHaveBeenCalledOnce(); + }); + + it.each([ + { code: 1, signal: null }, + { code: null, signal: "SIGTERM" }, + ])("rejects an abnormal Main exit %j", async ({ code, signal }) => { + const f = fixture(); + f.app.close.mockImplementation(async () => f.exit(code, signal)); + const result = await settleFixtureResources(f); + expect(result.ok).toBe(false); + expect(result.normalMainExit).toBeNull(); + expect(f.child.kill).not.toHaveBeenCalled(); + expect(f.cleanupTemp).toHaveBeenCalledOnce(); + }); + + it("retains collector and provider failures while releasing every remaining resource", async () => { + const f = fixture(); + f.invokeTraceCollector = { + stop: vi.fn(() => { + throw new Error("trace failure"); + }), + }; + f.cdpBrowser = { close: vi.fn() }; + f.imageProviderFixtureServer.close.mockRejectedValue( + new Error("provider failure"), + ); + const result = await settleFixtureResources(f); + expect(result.ok).toBe(false); + expect(result.normalMainExit).toMatchObject({ exitCode: 0, signal: null }); + expect( + result.steps.filter((step) => !step.ok).map((step) => step.phase), + ).toEqual(["invoke-trace", "image-provider"]); + expect(f.cdpBrowser.close).toHaveBeenCalledOnce(); + expect(f.textProviderFixtureServer.close).toHaveBeenCalledOnce(); + expect(f.cleanupTemp).toHaveBeenCalledOnce(); + }); + + it("does not call a Main that exited before requested normal quit a success", async () => { + const f = fixture(); + f.exit(); + expect(await settleFixtureResources(f)).toMatchObject({ + ok: false, + normalMainExit: null, + forcedCleanup: null, + }); + expect(f.app.close).not.toHaveBeenCalled(); + expect(f.child.kill).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/agent-runtime/current-fixture-regression-smoke.mjs b/scripts/agent-runtime/current-fixture-regression-smoke.mjs index 7306103ba..106ad63e9 100644 --- a/scripts/agent-runtime/current-fixture-regression-smoke.mjs +++ b/scripts/agent-runtime/current-fixture-regression-smoke.mjs @@ -147,6 +147,7 @@ function main() { "scripts/electron/session-history-fixture-smoke.test.mjs", "scripts/electron/code-artifact-workbench-fixture-smoke.test.mjs", "scripts/agent-runtime/claw-chat-current-fixture-smoke.test.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-teardown.test.mjs", "scripts/agent-runtime/claw-chat-current-fixture-unknown-item.test.mjs", "scripts/agent-runtime/claw-chat-current-fixture-user-message-collapse.test.mjs", "scripts/agent-runtime/claw-chat-current-fixture-user-message-collapse-evidence.unit.test.mjs", diff --git a/scripts/agent-runtime/fixtures/composer-reference.md b/scripts/agent-runtime/fixtures/composer-reference.md new file mode 100644 index 000000000..5b8381824 --- /dev/null +++ b/scripts/agent-runtime/fixtures/composer-reference.md @@ -0,0 +1,5 @@ +# Composer 引用测试夹具 + +此文件用于验证桌面输入器的文件引用、发送历史和中断恢复。 + +测试内容:保留正文、图片与文件路径。 diff --git a/scripts/agent-runtime/tool-surface-page-smoke.mjs b/scripts/agent-runtime/tool-surface-page-smoke.mjs index f84f4f412..7446ca476 100644 --- a/scripts/agent-runtime/tool-surface-page-smoke.mjs +++ b/scripts/agent-runtime/tool-surface-page-smoke.mjs @@ -339,7 +339,7 @@ function buildEnsureAgentHomeScript() { } const settingsHomeButton = document.querySelector( - '[data-testid="settings-home-button"]', + '[data-testid="app-sidebar-rail-home"]', ); const fallbackHomeButton = settingsHomeButton || diff --git a/scripts/check-command-contracts.mjs b/scripts/check-command-contracts.mjs index 34b541971..05c3b58e9 100644 --- a/scripts/check-command-contracts.mjs +++ b/scripts/check-command-contracts.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { collectPersistedAtomContractFailures } from "./lib/persisted-atom-contract.mjs"; const repoRoot = process.cwd(); @@ -4597,7 +4598,10 @@ function main() { ).filter((sectionName) => retiredMemoryRuntimeCatalogSections.has(sectionName), ); - const productionBridgeGuardFailures = collectProductionBridgeGuardFailures(); + const productionBridgeGuardFailures = [ + ...collectProductionBridgeGuardFailures(), + ...collectPersistedAtomContractFailures(readSource), + ]; const retiredFileBrowserFacadeSourceFailures = collectRetiredFileBrowserFacadeSourceFailures(); const retiredAutomationFacadeSourceFailures = diff --git a/scripts/electron/composer-draft-fixture-smoke.mjs b/scripts/electron/composer-draft-fixture-smoke.mjs new file mode 100644 index 000000000..23434e78c --- /dev/null +++ b/scripts/electron/composer-draft-fixture-smoke.mjs @@ -0,0 +1,35 @@ +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + parseComposerDraftFixtureArgs, + runComposerDraftGuiFixture, +} from "./lib/composer-draft-fixture-driver.mjs"; + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const options = parseComposerDraftFixtureArgs(process.argv.slice(2)); + if (options.help) { + console.log(`Composer rich draft real Electron GUI fixture + +node scripts/electron/composer-draft-fixture-smoke.mjs \\ + --run-id --evidence-dir \\ + --verification-packet + +Requires fresh formal product artifacts; never builds product assets. +Uses real GUI edits, native preload IPC, App Server, three workspaces, +and three same-profile Main processes with public app.quit. +Pure protocol/records parsing owners are compiled into the evidence directory. +No browser Gate substitution, Host-only GUI claim or renderer mock fallback.`); + } else { + runComposerDraftGuiFixture(options).then( + ({ summaryPath }) => + console.log(`[composer-draft-gui] pass evidence=${summaryPath}`), + (error) => { + console.error(error.message); + process.exitCode = 1; + }, + ); + } +} diff --git a/scripts/electron/current-entrypoints.test.mjs b/scripts/electron/current-entrypoints.test.mjs index 349729337..81c71646e 100644 --- a/scripts/electron/current-entrypoints.test.mjs +++ b/scripts/electron/current-entrypoints.test.mjs @@ -189,12 +189,18 @@ function currentElectronEntrypointFiles() { ]; } +function isElectronTestOnlyPath(filePath) { + return /\.(?:test|testHarness|test-support)(?:\.(?:ts|mjs|js))?$/i.test( + filePath, + ); +} + function isElectronProductionSourceFile(filePath) { const normalized = filePath.replace(/\\/g, "/"); return ( /\.(?:mjs|js|ts)$/i.test(normalized) && !normalized.endsWith(".d.ts") && - !/\.(?:test|testHarness)\.(?:ts|mjs|js)$/i.test(normalized) + !isElectronTestOnlyPath(normalized) ); } @@ -211,13 +217,11 @@ function productionTestImports(filePath, content) { if (!isElectronProductionSourceFile(filePath)) return []; return [ ...content.matchAll( - /(?:from\s+|import\s*\(|require\s*\()(["'])([^"']+)\1/g, + /\b(?:from\s+|import\s*(?:\(\s*)?|require\s*\(\s*)(["'])([^"']+)\1/g, ), ] .map((match) => match[2]) - .filter((specifier) => - /\.(?:test|testHarness)(?:\.(?:ts|mjs|js))?$/i.test(specifier), - ); + .filter(isElectronTestOnlyPath); } function electronProductionSourceFiles() { @@ -399,6 +403,98 @@ describe("Electron current package entrypoints", () => { ).toEqual([]); }); + it.each(["ts", "mjs", "js"])( + "classifies .test-support.%s as a test-only leaf without exempting production siblings", + (extension) => { + const runtimeDependencies = new Set(["electron"]); + const content = 'import { vi } from "vitest";'; + for (const filePath of [ + `electron/persistedAtomStore.test-support.${extension}`, + `electron\\persistedAtomStore.test-support.${extension}`, + ]) { + expect( + unpackagedRuntimeImports(filePath, content, runtimeDependencies), + filePath, + ).toEqual([]); + expect( + productionTestImports( + filePath, + 'import { put } from "./persistedAtomStore.test-support";', + ), + filePath, + ).toEqual([]); + } + for (const filePath of [ + `electron/persistedAtomStore.${extension}`, + `electron/test-supportOwner.${extension}`, + `electron/persistedAtomStore.test-supporting.${extension}`, + ]) { + expect( + unpackagedRuntimeImports(filePath, content, runtimeDependencies), + filePath, + ).toEqual(["vitest"]); + } + }, + ); + + it.each([ + [ + "static import", + 'import { put } from "./persistedAtomStore.test-support";', + "./persistedAtomStore.test-support", + ], + [ + "type import", + 'import type { Fixture } from "./persistedAtomStore.test-support.ts";', + "./persistedAtomStore.test-support.ts", + ], + [ + "side-effect import", + 'import "./persistedAtomStore.test-support.js";', + "./persistedAtomStore.test-support.js", + ], + [ + "re-export", + 'export { put } from "./persistedAtomStore.test-support.mjs";', + "./persistedAtomStore.test-support.mjs", + ], + [ + "dynamic import", + 'const support = await import( "./persistedAtomStore.test-support" );', + "./persistedAtomStore.test-support", + ], + [ + "require", + 'const support = require( "./persistedAtomStore.test-support.ts" );', + "./persistedAtomStore.test-support.ts", + ], + ])( + "rejects production %s of test-only support", + (_form, content, specifier) => { + expect(productionTestImports("electron/main.ts", content)).toEqual([ + specifier, + ]); + expect( + productionTestImports("electron/persistedAtomStore.test.ts", content), + ).toEqual([]); + expect( + productionTestImports( + "electron/persistedAtomStore.test-support.ts", + content, + ), + ).toEqual([]); + }, + ); + + it("does not mistake a production import for a test-only support leaf", () => { + const content = [ + 'import { PersistedAtomStore } from "./persistedAtomStore";', + 'import { support } from "./test-supportOwner";', + 'import "./persistedAtomStore.test-supporting";', + ].join("\n"); + expect(productionTestImports("electron/main.ts", content)).toEqual([]); + }); + it("legacy verify-gui-smoke script delegates to Electron smoke", () => { const content = readFile("scripts/verify-gui-smoke.mjs"); diff --git a/scripts/electron/lib/composer-draft-fixture-driver.mjs b/scripts/electron/lib/composer-draft-fixture-driver.mjs new file mode 100644 index 000000000..e61b2739e --- /dev/null +++ b/scripts/electron/lib/composer-draft-fixture-driver.mjs @@ -0,0 +1,658 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { Buffer } from "node:buffer"; +import { pathToFileURL } from "node:url"; +import { + createRecord, + markers, + openRecord, + prepareRecord, + readIndex, + readScope, + verifyAcceptedNewEdit, + verifyRecord, + verifyRejectedSubmission, +} from "../../agent-runtime/claw-chat-current-fixture-draft-persistence.mjs"; +import { + assertAppServerRequestMethods, + assertRestartLifecycle, + assertConsumedDraft, + requireDraftProof, + summarizeDraft, +} from "./composer-draft-fixture-evidence.mjs"; + +const dependencies = { + drafts: + "scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs", + launcher: "scripts/electron/mcp-config-fixture-smoke.mjs", + lifecycle: "scripts/electron/lib/persisted-atom-fixture-driver.mjs", + owners: "scripts/electron/lib/persisted-atom-fixture-standalone.mjs", + atom: "scripts/electron/lib/persisted-atom-fixture.mjs", + freshness: "scripts/lib/electron-fixture-build.mjs", + assets: "scripts/lib/electron-app-server-assets.mjs", + trace: "scripts/lib/invoke-trace-collector.mjs", + runtime: "scripts/agent-runtime/claw-chat-current-fixture-backend-file.mjs", + constants: "scripts/agent-runtime/claw-chat-current-fixture-constants.mjs", + rpc: "scripts/agent-runtime/claw-chat-current-fixture-rpc.mjs", + sessions: "scripts/agent-runtime/claw-chat-current-fixture-session.mjs", + marks: "scripts/agent-runtime/claw-chat-current-fixture-composer-marks.mjs", + image: + "scripts/agent-runtime/claw-chat-current-fixture-composer-image-upload.mjs", + history: + "scripts/agent-runtime/claw-chat-current-fixture-composer-history.mjs", + dom: "scripts/lib/composer-editor-dom.mjs", + skills: + "scripts/agent-runtime/claw-chat-current-fixture-skills-workspace.mjs", +}; +const hashBytes = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const fixtureSources = [ + "scripts/electron/composer-draft-fixture-smoke.mjs", + "scripts/electron/lib/composer-draft-fixture-driver.mjs", + "scripts/electron/lib/composer-draft-fixture-evidence.mjs", + "scripts/agent-runtime/claw-chat-current-fixture-draft-persistence.mjs", +]; + +async function sourceFingerprint(repoRoot, relative) { + const file = path.join(repoRoot, relative); + return { path: file, sha256: hashBytes(await fs.readFile(file)) }; +} + +async function loadModules(repoRoot) { + const result = await Promise.allSettled( + Object.entries(dependencies).map(async ([key, relative]) => [ + key, + await import(pathToFileURL(path.join(repoRoot, relative)).href), + ]), + ); + const loaded = {}; + for (const [index, entry] of result.entries()) { + requireDraftProof( + entry.status === "fulfilled", + `load current dependency ${Object.keys(dependencies)[index]}`, + ); + loaded[entry.value[0]] = entry.value[1]; + } + return loaded; +} + +export function parseComposerDraftFixtureArgs(argv, cwd = process.cwd()) { + const options = { + repoRoot: cwd, + evidenceDir: null, + packetPath: null, + runId: null, + timeoutMs: 60_000, + intervalMs: 200, + quitTimeoutMs: 15_000, + help: false, + }; + const fields = { + "--repo": "repoRoot", + "--evidence-dir": "evidenceDir", + "--verification-packet": "packetPath", + "--run-id": "runId", + "--timeout-ms": "timeoutMs", + "--quit-timeout-ms": "quitTimeoutMs", + }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help" || argument === "-h") { + options.help = true; + continue; + } + const field = fields[argument]; + requireDraftProof( + field && argv[index + 1], + "known CLI argument with value", + ); + options[field] = argv[++index]; + } + if (options.help) return options; + requireDraftProof( + options.evidenceDir && + options.packetPath && + /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? ""), + "explicit verification packet, fresh evidence directory and run ID", + ); + for (const field of ["repoRoot", "evidenceDir", "packetPath"]) + options[field] = path.resolve(cwd, options[field]); + for (const field of ["timeoutMs", "quitTimeoutMs"]) { + options[field] = Number(options[field]); + requireDraftProof( + Number.isSafeInteger(options[field]) && + options[field] > 0 && + options[field] <= 60_000, + "bounded CLI timeout", + ); + } + return options; +} + +async function runLocalDraftScenario({ + options, + runtimeEnv, + launch, + quit, + stopTrace, + inspectSavedFiles, + recordStage, + setPhase, +}) { + let context = await launch(1); + setPhase("three-real-workspaces-and-rich-gui-edits"); + const records = [], + originals = []; + for (const [index, marker] of markers.entries()) { + const rootPath = path.join(runtimeEnv.tempRoot, `workspace-${index + 1}`); + await fs.mkdir(rootPath); + const fixturePath = path.join(rootPath, "composer-persistence.md"); + const fixtureBytes = Buffer.alloc(128, " "); + fixtureBytes.write(`# Composer persistence\n${marker}\n`, "utf8"); + await fs.writeFile(fixturePath, fixtureBytes); + const record = await createRecord(context, { + rootPath, + marker, + title: `Composer persistence ${options.runId} ${index + 1}`, + }); + record.path = fixturePath; + record.pathName = path.basename(fixturePath); + records.push(record); + originals.push(await prepareRecord(context, record)); + } + requireDraftProof( + new Set(records.map((record) => record.workspaceId)).size === 3 && + new Set(records.map((record) => record.threadId)).size === 3, + "three genuine isolated workspaces and canonical threads", + ); + setPhase("real-gui-rejected-submission-preserves-rich-draft"); + const rejectionBaseline = await verifyRecord( + context, + records[0], + originals[0], + ); + recordStage("rejection-complete-rich-baseline", rejectionBaseline); + const rejected = await verifyRejectedSubmission( + context, + records[0], + recordStage, + ); + originals[0] = rejected.original; + recordStage("gui-failure-preserves-rich-draft", rejected.evidence); + for (const [index, record] of records.entries()) { + await verifyRecord(context, record, originals[index]); + recordStage( + `workspace-${index + 1}-isolation`, + await readIndex(context, record, originals[index].entry), + ); + } + const savedBeforeQuit = await readScope(context, records[2].scope); + recordStage( + "generation-1-saved-files", + await inspectSavedFiles(savedBeforeQuit.storeRevision), + ); + await quit(); + context = await launch(2); + setPhase("new-main-pid-cold-gui-restoration"); + const second = []; + for (const [index, record] of records.entries()) + second.push(await verifyRecord(context, record, originals[index])); + requireDraftProof( + second.every((proof) => proof.revision === savedBeforeQuit.storeRevision), + "cold GUI hydration performs no extra atom mutation", + ); + recordStage("generation-2-cold-gui-restoration", second); + setPhase("accepted-current-turn-new-edit-and-foreign-workspace"); + recordStage( + "accepted-new-edit-cross-scope", + await verifyAcceptedNewEdit(context, records, originals), + ); + for (const [index, record] of records.entries()) { + await verifyRecord(context, record, originals[index]); + await readIndex(context, record, originals[index].entry); + } + const acceptedTrace = await stopTrace(); + requireDraftProof( + acceptedTrace.atomOperations.some( + (operation) => + operation.op === "consume" && operation.status === "success", + ) && + acceptedTrace.atomOperations.some( + (operation) => operation.op === "put" && operation.status === "success", + ), + "production GUI performs accepted consume and newer-edit put through native IPC", + ); + const secondSaved = await readScope(context, records[2].scope); + recordStage( + "generation-2-saved-files", + await inspectSavedFiles(secondSaved.storeRevision), + ); + await quit(); + context = await launch(3); + setPhase("normal-restart-restores-new-edit-and-other-workspaces"); + const third = []; + for (const [index, record] of records.entries()) + third.push(await verifyRecord(context, record, originals[index])); + requireDraftProof( + third.every((proof) => proof.revision === secondSaved.storeRevision), + "second cold restart hydrates without additional mutation", + ); + recordStage("generation-3-cold-gui-restoration", third); + await quit(); + return { + guiDraftRestoration: true, + records: records.map((record, index) => ({ + ...record, + draft: summarizeDraft(originals[index].entry), + })), + }; +} + +/** Real GUI driver. Native atom mutation is exclusively owned by production UI. */ +export async function runComposerDraftGuiFixture(options, scenario = null) { + requireDraftProof( + path.resolve(process.cwd()) === options.repoRoot, + "existing Electron launcher uses the formal repository cwd", + ); + const runRoot = path.join(options.evidenceDir, options.runId); + await fs.mkdir(options.evidenceDir, { recursive: true }); + await fs.mkdir(runRoot); // Never overwrite an existing run or reuse an unknown profile. + const scope = + scenario?.scope ?? + "existing canonical local conversations and their rich persistent drafts"; + const generationCount = scenario?.generationCount ?? 3; + requireDraftProof( + scenario === null || + (/^[a-z][a-z0-9-]{0,60}$/.test(scenario.id ?? "") && + typeof scenario.run === "function" && + typeof scope === "string" && + scope.length > 0 && + Number.isSafeInteger(generationCount) && + generationCount >= 2 && + Array.isArray(scenario.sourcePaths) && + scenario.sourcePaths.every( + (relative) => + typeof relative === "string" && + !path.isAbsolute(relative) && + !relative.split(/[\\/]/).includes(".."), + )), + "explicit current scenario, identity and relative source lineage", + ); + const summaryPath = path.join( + runRoot, + scenario + ? `composer-${scenario.id}-gui.json` + : "composer-draft-gui-persistence.json", + ); + const generations = [], + traceEvidence = [], + stageEvidence = []; + const consoleErrors = [], + pageErrors = []; + let active = null, + context = null, + phase = "current-dependencies"; + let modules, runtimeEnv, sourceRecords, owners, packetBytes; + const recordStage = (name, proof) => stageEvidence.push({ name, proof }); + try { + sourceRecords = await Promise.all( + [ + ...new Set([ + ...Object.values(dependencies), + ...fixtureSources, + ...(scenario?.sourcePaths ?? []), + ]), + ].map((relative) => sourceFingerprint(options.repoRoot, relative)), + ); + modules = await loadModules(options.repoRoot); + const freshness = modules.freshness.inspectElectronFixtureBuildFreshness({ + rootDir: options.repoRoot, + }); + requireDraftProof( + freshness.ready === true, + "fresh formal renderer, Main, preload and App Server artifacts; build forbidden", + ); + recordStage("current-build-freshness", { + ready: true, + reason: freshness.reason, + }); + packetBytes = await fs.readFile(options.packetPath); + const packet = JSON.parse(packetBytes.toString("utf8")); + phase = "current-pure-protocol-and-records"; + // This only compiles pure protocol/records/appDataPaths for strict parsing; + // it does not build renderer, Electron Host, sidecar or product runtime. + owners = await modules.owners.bundlePersistedAtomOwners({ + repoRoot: options.repoRoot, + workDir: runRoot, + packet, + }); + const eventGuard = await modules.lifecycle.verifyPersistedAtomEventGuard({ + repoRoot: options.repoRoot, + packet: packet.eventGuard, + }); + recordStage("main-event-authority", eventGuard); + runtimeEnv = modules.runtime.createTempRuntimeEnv(); + modules.skills.ensureUserVisibleCapabilityReportSkill(runtimeEnv); + const profile = runtimeEnv.electronUserDataDir; + const roots = owners.resolveDesktopStorageRoots({ + platform: process.platform, + hostUserData: profile, + e2eMode: true, + e2eUserDataDir: profile, + localAppData: runtimeEnv.env.LOCALAPPDATA, + }); + requireDraftProof( + roots.appDataRoot === profile && roots.agentRoot === runtimeEnv.agentRoot, + "real platform appDataPaths map the same isolated profile", + ); + const binaryRecords = await Promise.all( + ["dist-electron/main/main.js", "dist-electron/preload/preload.cjs"].map( + (relative) => sourceFingerprint(options.repoRoot, relative), + ), + ); + const appServerBinary = modules.assets.electronAppServerBinaryDestination({ + outputRoot: modules.assets.electronAppServerResourcesRoot( + options.repoRoot, + ), + }); + const sidecarRecord = { + path: appServerBinary, + sha256: hashBytes(await fs.readFile(appServerBinary)), + }; + binaryRecords.push(sidecarRecord); + const appServerEnv = { + APP_SERVER_BIN: appServerBinary, + APP_SERVER_BACKEND_COMMAND: process.execPath, + APP_SERVER_BACKEND_ARGS: JSON.stringify([ + runtimeEnv.backendPath, + runtimeEnv.backendLedgerPath, + runtimeEnv.cancelSignalPath, + runtimeEnv.typedErrorSignalPath, + ]), + APP_SERVER_BACKEND_TIMEOUT_MS: "130000", + CLAW_CHAT_FIXTURE_SCENARIO: "inputbar-rich-restore", + }; + async function launch(generation) { + phase = `generation-${generation}-real-electron-launch`; + requireDraftProof(active === null, "sequential Main processes only"); + const current = modules.freshness.inspectElectronFixtureBuildFreshness({ + rootDir: options.repoRoot, + }); + requireDraftProof( + current.ready === true, + "fresh build before each launch; no implicit build", + ); + for (const record of [...sourceRecords, ...binaryRecords]) + requireDraftProof( + hashBytes(await fs.readFile(record.path)) === record.sha256, + "fixture dependencies and real Main/preload unchanged", + ); + const launched = await modules.launcher.launchElectronFixture({ + options, + runtimeEnv, + appServerEnv, + consoleErrors, + pageErrors, + backendMode: "external", + }); + active = { + ...launched, + exit: modules.lifecycle.captureMainProcessExit(launched.app.process()), + generation, + }; + context = { + page: launched.page, + options, + modules, + runtimeEnv, + protocol: owners.protocol, + setPhase: (value) => { + phase = value; + process.stdout.write(`[composer-draft-fixture] stage=${value}\n`); + }, + readConsumedDraft: async (identity) => { + const bytes = await fs.readFile( + path.join(roots.appDataRoot, "host-state.json"), + ); + requireDraftProof( + bytes.length <= owners.limits.maxFileBytes, + "saved consumption file budget", + ); + return assertConsumedDraft( + owners.parseEnvelope( + JSON.parse(bytes.toString("utf8")), + owners.limits, + ), + identity, + ); + }, + expected: (record) => ({ + marker: record.marker, + prompt: modules.constants.INPUTBAR_RICH_RESTORE_PROMPT, + pasteText: "历史粘贴验证😀\n".repeat(180), + imageBase64: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + path: record.path, + pathName: record.pathName, + }), + }; + await modules.rpc.initializeAppServer(launched.page); + active.trace = await modules.trace.installInvokeTraceCollector( + launched.page, + ); + return context; + } + async function stopTrace() { + const stopped = await modules.trace.stopInvokeTraceCollector( + active.page, + active.trace, + ); + const observed = modules.trace.validateInvokeTraceCollectorCapture( + stopped, + active.trace, + ); + requireDraftProof( + observed.complete && stopped.nativeRestored === true, + "complete native Storage trace observation without missing ring entries", + ); + const atoms = observed.ledgerEntries.filter( + (entry) => entry.command === "persisted_atom_update", + ); + requireDraftProof( + atoms.every( + (entry) => + entry.transport === "electron-ipc" && + Object.keys(entry.args_preview ?? {}).every((key) => + ["key", "op"].includes(key), + ), + ), + "native GUI atom calls and metadata-only diagnostics", + ); + const rpcEntries = observed.ledgerEntries.filter( + (entry) => entry.command === "app_server_handle_json_lines", + ); + const appServerRequestMethods = assertAppServerRequestMethods(rpcEntries); + const evidence = { + generation: active.generation, + appServerRequestMethods, + provenance: observed.provenance, + atomOperations: atoms.map((entry) => ({ + op: entry.args_preview?.op, + status: entry.status, + transport: entry.transport, + })), + appServerInvokeCount: observed.ledgerEntries.filter( + (entry) => entry.command === "app_server_handle_json_lines", + ).length, + }; + traceEvidence.push(evidence); + active.trace = null; + return evidence; + } + async function quit() { + phase = `generation-${active.generation}-public-normal-quit`; + if (active.trace) await stopTrace(); + const receipt = await modules.lifecycle.quitMainProcessNormally( + active, + options.quitTimeoutMs, + ); + generations.push({ ...receipt, profile, generation: active.generation }); + active = null; + } + async function inspectSavedFiles(revision, inspectEnvelope = null) { + const records = []; + for (const name of ["host-state.json", "host-state.json.bak"]) { + const file = path.join(roots.appDataRoot, name); + const bytes = await fs.readFile(file); + requireDraftProof( + bytes.length <= owners.limits.maxFileBytes, + "native saved file budget", + ); + const envelope = owners.parseEnvelope( + JSON.parse(bytes.toString("utf8")), + owners.limits, + ); + requireDraftProof( + envelope.storeRevision === revision, + "both native saved envelopes acknowledge exact revision", + ); + const entryEvidence = inspectEnvelope + ? await inspectEnvelope(envelope) + : null; + records.push({ + ...(entryEvidence === null ? {} : { entryEvidence }), + path: file, + sha256: hashBytes(bytes), + storeRevision: envelope.storeRevision, + validatedBy: "persistedAtomRecords.parseEnvelope", + }); + } + requireDraftProof( + records[0].sha256 === records[1].sha256, + "same saved primary and backup snapshot", + ); + return records; + } + const scenarioResult = await (scenario?.run ?? runLocalDraftScenario)({ + options, + runtimeEnv, + launch, + quit, + stopTrace, + inspectSavedFiles, + recordStage, + setPhase: (next) => { + context?.setPhase(next); + }, + getContext: () => context, + }); + requireDraftProof( + active === null, + "scenario completed by public normal quit", + ); + assertRestartLifecycle(generations, profile, generationCount); + for (const record of [...sourceRecords, ...binaryRecords]) + requireDraftProof( + hashBytes(await fs.readFile(record.path)) === record.sha256, + "current sources and product binaries unchanged through final normal exit", + ); + requireDraftProof( + pageErrors.length === 0, + "no uncaught renderer exception", + ); + requireDraftProof( + hashBytes(await fs.readFile(options.packetPath)) === + hashBytes(packetBytes), + "verification packet unchanged throughout real run", + ); + const summary = { + schemaVersion: 1, + result: "pass", + runId: options.runId, + guiDraftRestoration: scenarioResult.guiDraftRestoration === true, + desktopGateB: true, + desktopGateScope: scope, + allGuiSlicesClosed: false, + firstSubmissionRoutesVerified: + scenarioResult.firstSubmissionRoutesVerified === true, + firstSubmissionRoutes: scenarioResult.firstSubmissionRoutes ?? [], + sameCanonicalThreadTurnItemOwner: true, + normalExitMustNotBeReplacedByKill: true, + generations, + stageEvidence, + traceEvidence, + sourceRecords, + binaryRecords, + pureOwnerCompilation: owners.receipt, + verificationPacketSha256: hashBytes(packetBytes), + isolatedProfile: profile, + fixtureRuntimeRoot: runtimeEnv.tempRoot, + records: scenarioResult.records, + consoleErrorCount: consoleErrors.length, + pageErrorCount: pageErrors.length, + limitations: scenario?.limitations ?? [ + "Home, direct draftTab and materialized draftTab first submissions and cold tab discovery are not covered.", + "Synthetic DataTransfer/ClipboardEvent and DragEvent use production ingress; OS clipboard and OS drag are not covered.", + "Saved is awaited before app.quit; pending-save abrupt exit is not covered.", + "Public RPC interrupts the accepted Turn under a foreign GUI scope; GUI Stop button is not covered here.", + "New edit follows accepted consume; pending old-token race is covered by behavior tests, not claimed here.", + "Submission failure proves visible rejection, no canonical Turn, and exact saved-draft preservation; RPC rejection layer needs trace review.", + ], + }; + await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n"); + return { summaryPath, summary }; + } catch (error) { + // Teardown still uses public normal quit. A failed quit stays a failed run; + // do not kill the process and then present that as normal-restart evidence. + let teardown = null; + if (active && modules) { + await active.page + .screenshot({ path: path.join(runRoot, "failed-gui.png") }) + .catch(() => undefined); + if (active.trace) + await modules.trace + .stopInvokeTraceCollector(active.page, active.trace) + .catch(() => undefined); + try { + teardown = await modules.lifecycle.quitMainProcessNormally( + active, + options.quitTimeoutMs, + ); + } catch { + teardown = { pid: active.exit.pid, normalQuitFailed: true }; + } + } + const failure = { + schemaVersion: 1, + result: "fail", + runId: options.runId, + phase, + guiDraftRestoration: false, + desktopGateB: false, + desktopGateScope: scope, + allGuiSlicesClosed: false, + generations, + stageEvidence, + traceEvidence, + teardown, + errorClass: error instanceof Error ? error.name : "unknown", + errorLocations: String(error?.stack ?? "") + .split("\n") + .filter((line) => /^\s+at /.test(line)) + .slice(0, 5), + failureReason: /^Composer draft GUI fixture (failed|timed out):/.test( + error?.message ?? "", + ) + ? error.message + : "Current dependency or GUI action failed; raw error omitted", + diagnostic: + "Inspect the failed phase; request bodies and rich draft data are intentionally omitted.", + sourceRecords, + isolatedProfile: runtimeEnv?.electronUserDataDir ?? null, + }; + await fs.writeFile(summaryPath, JSON.stringify(failure, null, 2) + "\n"); + throw new Error( + `Composer draft GUI fixture failed at ${phase}; evidence=${summaryPath}`, + ); + } +} diff --git a/scripts/electron/lib/composer-draft-fixture-evidence.mjs b/scripts/electron/lib/composer-draft-fixture-evidence.mjs new file mode 100644 index 000000000..98d9cb97f --- /dev/null +++ b/scripts/electron/lib/composer-draft-fixture-evidence.mjs @@ -0,0 +1,255 @@ +import { createHash } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; + +export const atomKey = "composer-drafts-v1"; +export const sha256 = (value) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +export function requireDraftProof(condition, phase) { + if (!condition) + throw new Error(`Composer draft GUI fixture failed: ${phase}`); +} + +export function assertAppServerRequestMethods(entries) { + return entries.flatMap((entry) => { + const lines = entry.args_preview?.request?.lines; + requireDraftProof( + entry.transport === "electron-ipc" && + Array.isArray(lines) && + lines.length > 0 && + lines.every((line) => typeof line === "string" && line.trim()), + "complete native App Server request-method projection; no silent parse gaps", + ); + const requests = lines.map((line) => JSON.parse(line)); + // Codex's current envelopes omit the JSON-RPC version field. + requireDraftProof( + requests.every( + (request) => + request && + !Array.isArray(request) && + (request.jsonrpc === undefined || request.jsonrpc === "2.0") && + typeof request.method === "string" && + request.method.length > 0, + ), + "complete native App Server request-method projection; no silent parse gaps", + ); + return requests.map((request) => request.method); + }); +} + +export function savedDraft(result, scope) { + requireDraftProof( + result?.outcome === "ready" && + result.key === atomKey && + result.data?.queryKind === "scope" && + isDeepStrictEqual(result.data.view?.scope, scope), + "same native atom and exact scope", + ); + const entry = result.data.view.entry; + requireDraftProof( + entry?.state === "draft" && + typeof entry.draftId === "string" && + entry.draftId.length > 0 && + typeof entry.scopeVersion === "string" && + entry.scopeVersion.trim().length > 0 && + Number.isSafeInteger(result.storeRevision) && + result.persistence?.state === "saved" && + result.persistence.savedRevision >= result.storeRevision, + "draft identity applied and acknowledged saved on disk", + ); + return entry; +} + +function walk(node, result = []) { + result.push(node); + for (const child of node?.content ?? []) walk(child, result); + return result; +} + +// Fixed literals are independent of the product serializer and observed DOM. +export function assertRichDraft(entry, expected) { + const payload = entry?.draft; + const document = payload?.document; + requireDraftProof( + payload?.format === "composer-draft" && + payload.version === 1 && + payload.plainTextMode === false && + document?.editorDocument?.type === "doc", + "current rich document codec", + ); + const nodes = walk(document.editorDocument); + const code = nodes.filter((node) => node?.type === "code_block"); + const links = nodes.flatMap((node) => + (node?.marks ?? []).filter((mark) => mark.type === "link"), + ); + requireDraftProof( + code.length === 1 && + code[0].attrs?.language === "typescript" && + code[0].content.map((node) => node.text).join("") === + " const value = '中😀';\n````\n\n" && + links.length === 1 && + links[0].attrs?.href === "https://example.invalid/marks" && + links[0].attrs.title === "标题", + "literal code body, language and link mark", + ); + const pastes = nodes.filter((node) => node?.type === "paste"); + requireDraftProof( + pastes.length === 1 && + pastes[0].attrs.text === expected.pasteText && + Object.values(document.pendingPastes ?? {}).some( + (paste) => paste?.text === expected.pasteText, + ) && + document.text.includes(expected.prompt) && + document.text.includes(expected.marker), + "hidden paste source and independent workspace marker", + ); + requireDraftProof( + document.attachments?.length === 1 && + document.attachments[0].mediaType === "image/png" && + document.attachments[0].data === expected.imageBase64 && + !Object.hasOwn(document.attachments[0], "previewUrl") && + document.pathReferences?.length === 1 && + document.pathReferences[0].path === expected.path && + document.pathReferences[0].name === expected.pathName && + document.pathReferences[0].isDir === false, + "durable image and complete path resource", + ); + requireDraftProof( + document.inputCapabilityRoute?.kind === "installed_skill" && + document.inputCapabilityRoute.skillKey === "capability-report" && + String(document.inputCapabilityRoute.skillName) + .toLocaleLowerCase() + .replace(/[\s_-]+/g, "") === "capabilityreport" && + document.mentionBindings?.some( + (binding) => binding.id === "user:capability-report", + ), + "installed capability route and mention binding", + ); + requireDraftProof( + Number.isSafeInteger(document.selectionStart) && + document.selectionEnd - document.selectionStart === 3 && + document.text.slice(document.selectionStart, document.selectionEnd) === + expected.marker.slice(-3), + "persisted noncollapsed user selection", + ); + return summarizeDraft(entry); +} + +export function summarizeDraft(entry) { + return { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + payloadSha256: sha256(entry.draft), + documentSha256: sha256(entry.draft.document.editorDocument), + textLength: entry.draft.document.text.length, + pasteSourceSha256: sha256(entry.draft.document.pendingPastes), + attachmentsSha256: sha256(entry.draft.document.attachments), + pathsSha256: sha256(entry.draft.document.pathReferences), + capabilitySha256: sha256(entry.draft.document.inputCapabilityRoute ?? null), + selection: [ + entry.draft.document.selectionStart, + entry.draft.document.selectionEnd, + ], + }; +} + +/** The caller parses actual disk bytes with the current records owner first. */ +export function assertConsumedDraft(envelope, identity) { + const atom = envelope.atoms[atomKey]; + const entry = atom.entries[identity.draftId]; + requireDraftProof( + entry?.state === "consumed" && + entry.consumption === "accepted" && + entry.draftId === identity.draftId && + entry.scopeVersion === identity.scopeVersion && + !Object.values(atom.bindings).includes(identity.draftId), + "saved accepted tombstone has exact captured identity and no scope binding", + ); + return { + ...identity, + storeRevision: envelope.storeRevision, + consumption: entry.consumption, + noRemainingBinding: true, + }; +} + +export function assertRestoredDraft(result, scope, original) { + const entry = savedDraft(result, scope); + requireDraftProof( + entry.draftId === original.draftId && + entry.scopeVersion === original.scopeVersion && + isDeepStrictEqual(entry.draft, original.draft), + "cold restore preserves exact rich JSON, resources, selection and identity", + ); + return summarizeDraft(entry); +} + +export function assertRestoredDom(actual, original, sessionId) { + const projection = (snapshot) => ({ + text: snapshot.text, + documentProjection: snapshot.documentProjection, + marks: snapshot.marks, + tokens: snapshot.tokens, + images: snapshot.images, + paths: snapshot.paths, + }); + requireDraftProof( + actual?.electron === true && + actual.preload === true && + actual.editorCount === 1 && + actual.sessionId === sessionId && + actual.disabled === false && + actual.images?.every((image) => image.decoded === true) && + isDeepStrictEqual(projection(actual), projection(original)), + "same-session real Composer rendered semantics restored", + ); + return { + sessionId, + projectionSha256: sha256(projection(actual)), + editorCount: actual.editorCount, + imageCount: actual.images.length, + pathCount: actual.paths.length, + tokenCount: actual.tokens.length, + }; +} + +export function assertWorkspaceIndex(result, workspaceId, scope, entry) { + requireDraftProof( + result?.outcome === "ready" && + result.key === atomKey && + result.data?.queryKind === "workspace-index" && + result.data.workspaceId === workspaceId && + result.data.items?.length === 1 && + isDeepStrictEqual(result.data.items[0].scope, scope) && + isDeepStrictEqual(result.data.items[0].identity, { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + }), + "three workspace indexes contain only their own exact identity", + ); + return { workspaceId, itemsSha256: sha256(result.data.items) }; +} + +export function assertRestartLifecycle( + generations, + expectedProfile, + expectedCount = 3, +) { + requireDraftProof( + Number.isSafeInteger(expectedCount) && + expectedCount >= 2 && + generations.length === expectedCount && + new Set(generations.map((entry) => entry.pid)).size === expectedCount && + generations.every( + (entry) => + Number.isSafeInteger(entry.pid) && + entry.pid > 0 && + entry.profile === expectedProfile && + entry.mode === "normal" && + entry.exitCode === 0 && + entry.signal === null && + entry.exited === true, + ), + "exact distinct real Main PIDs, same profile and public normal quit", + ); +} diff --git a/scripts/electron/lib/composer-draft-fixture-evidence.test.mjs b/scripts/electron/lib/composer-draft-fixture-evidence.test.mjs new file mode 100644 index 000000000..c55752787 --- /dev/null +++ b/scripts/electron/lib/composer-draft-fixture-evidence.test.mjs @@ -0,0 +1,512 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + atomKey, + assertAppServerRequestMethods, + assertRestartLifecycle, + assertConsumedDraft, + assertRestoredDom, + assertRestoredDraft, + assertRichDraft, + assertWorkspaceIndex, + savedDraft, + sha256, +} from "./composer-draft-fixture-evidence.mjs"; +import { parseComposerDraftFixtureArgs } from "./composer-draft-fixture-driver.mjs"; + +const scope = { workspaceId: "workspace-a", kind: "local", id: "session-a" }; + +const nativeTrace = (lines) => [ + { transport: "electron-ipc", args_preview: { request: { lines } } }, +]; + +test("native method proof accepts current Codex request and notification envelopes", () => { + assert.deepEqual( + assertAppServerRequestMethods( + nativeTrace([ + '{"id":1,"method":"thread/list","params":{"limit":100}}', + '{"method":"$/cancelRequest","params":{"id":2}}', + ]), + ), + ["thread/list", "$/cancelRequest"], + ); +}); + +test("native method proof accepts explicit JSON-RPC 2.0 and rejects other versions", () => { + assert.deepEqual( + assertAppServerRequestMethods( + nativeTrace(['{"jsonrpc":"2.0","id":1,"method":"thread/read"}']), + ), + ["thread/read"], + ); + assert.throws(() => + assertAppServerRequestMethods( + nativeTrace(['{"jsonrpc":"1.0","id":1,"method":"thread/read"}']), + ), + ); +}); + +test("native method proof rejects missing, empty, malformed, response-only and non-native trace", () => { + for (const lines of [ + undefined, + [], + [""], + ["null"], + ["[]"], + ["{"], + ['{"id":1,"result":{}}'], + ['{"method":""}'], + ['{"id":1,"method":"thread/list"}', "malformed"], + ]) + assert.throws(() => assertAppServerRequestMethods(nativeTrace(lines))); + const trace = nativeTrace(['{"id":1,"method":"thread/list"}']); + trace[0].transport = "http"; + assert.throws(() => assertAppServerRequestMethods(trace)); +}); +const expected = { + prompt: "请结合截图", + marker: "GUI工作区一号", + pasteText: "独立隐藏源\n".repeat(200), + imageBase64: "aGVsbG8=", + path: "/isolated/file.md", + pathName: "file.md", +}; + +function fixture() { + const text = `${expected.prompt} ${expected.marker}`; + const document = { + editorDocument: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "中😀", + marks: [ + { + type: "link", + attrs: { + href: "https://example.invalid/marks", + title: "标题", + }, + }, + ], + }, + { + type: "paste", + attrs: { id: "paste-1", text: expected.pasteText }, + }, + ], + }, + { + type: "code_block", + attrs: { language: "typescript" }, + content: [ + { type: "text", text: " const value = '中😀';\n````\n\n" }, + ], + }, + ], + }, + text, + selectionStart: text.length - 3, + selectionEnd: text.length, + pendingPastes: [{ placeholder: "paste", text: expected.pasteText }], + attachments: [{ mediaType: "image/png", data: expected.imageBase64 }], + pathReferences: [ + { path: expected.path, name: expected.pathName, isDir: false }, + ], + mentionBindings: [{ id: "user:capability-report" }], + inputCapabilityRoute: { + kind: "installed_skill", + skillKey: "capability-report", + skillName: "Capability Report", + }, + }; + return { + outcome: "ready", + key: atomKey, + storeRevision: 7, + persistence: { state: "saved", savedRevision: 7 }, + data: { + queryKind: "scope", + view: { + scope, + entry: { + state: "draft", + draftId: "draft-a", + scopeVersion: "version-a", + draft: { + format: "composer-draft", + version: 1, + plainTextMode: false, + document, + }, + }, + }, + }, + }; +} + +const dom = { + electron: true, + preload: true, + editorCount: 1, + sessionId: "session-a", + disabled: false, + text: "rendered", + documentProjection: [{ type: "p" }], + marks: { links: ["fixture"] }, + tokens: [{ type: "paste", id: "1" }], + images: [{ src: "data:image/png;base64,aGVsbG8=", decoded: true }], + paths: [{ path: expected.path }], +}; + +test("accepts independently literal rich resources and exact native saved identity", () => { + const result = fixture(); + assert.equal(savedDraft(result, scope).draftId, "draft-a"); + assert.match( + assertRichDraft(result.data.view.entry, expected).payloadSha256, + /^[a-f0-9]{64}$/, + ); + assertRestoredDraft(result, scope, structuredClone(result.data.view.entry)); + assertRestoredDom(dom, structuredClone(dom), "session-a"); +}); + +test("summarizes an exact restored plain draft without an optional capability route", () => { + const result = fixture(); + delete result.data.view.entry.draft.document.inputCapabilityRoute; + const original = structuredClone(result.data.view.entry); + const restored = assertRestoredDraft(result, scope, original); + assert.equal(restored.capabilitySha256, sha256(null)); + assert.equal(restored.payloadSha256, sha256(original.draft)); + assert.throws(() => assertRichDraft(original, expected), /fixture failed/); + result.data.view.entry.draft.document.inputCapabilityRoute = { + kind: "installed_skill", + skillKey: "other", + skillName: "Other", + }; + assert.throws( + () => assertRestoredDraft(result, scope, original), + /fixture failed/, + ); +}); + +test("disk consumption requires the exact accepted identity and no remaining binding", () => { + const identity = { draftId: "draft-a", scopeVersion: "version-a" }; + const envelope = { + storeRevision: 8, + atoms: { + [atomKey]: { + bindings: {}, + entries: { + "draft-a": { + ...identity, + state: "consumed", + consumption: "accepted", + }, + }, + }, + }, + }; + assert.deepEqual(assertConsumedDraft(envelope, identity), { + ...identity, + storeRevision: 8, + consumption: "accepted", + noRemainingBinding: true, + }); + for (const change of [ + (value) => { + value.atoms[atomKey].entries[identity.draftId].scopeVersion = "other"; + }, + (value) => { + value.atoms[atomKey].entries[identity.draftId].state = "draft"; + }, + (value) => { + value.atoms[atomKey].entries[identity.draftId].consumption = "clear"; + }, + (value) => { + value.atoms[atomKey].bindings.scope = identity.draftId; + }, + ]) { + const changed = structuredClone(envelope); + change(changed); + assert.throws( + () => assertConsumedDraft(changed, identity), + /fixture failed/, + ); + } +}); + +for (const [name, change] of [ + [ + "foreign scope", + (result) => { + result.data.view.scope = { ...scope, workspaceId: "other" }; + }, + ], + [ + "applied memory without disk save", + (result) => { + result.persistence = { state: "pending", savedRevision: 6 }; + }, + ], + [ + "stale saved revision", + (result) => { + result.persistence.savedRevision = 6; + }, + ], + [ + "consumed tombstone", + (result) => { + result.data.view.entry.state = "consumed"; + }, + ], + [ + "foreign native atom", + (result) => { + result.key = "other"; + }, + ], + [ + "numeric version", + (result) => { + result.data.view.entry.scopeVersion = 2; + }, + ], + [ + "empty opaque version", + (result) => { + result.data.view.entry.scopeVersion = ""; + }, + ], +]) + test(`rejects ${name} as GUI saved evidence`, () => { + const result = fixture(); + change(result); + assert.throws(() => savedDraft(result, scope), /fixture failed/); + }); + +for (const [name, change] of [ + [ + "flattened link", + (draft) => { + delete draft.document.editorDocument.content[0].content[0].marks; + }, + ], + [ + "lost code language", + (draft) => { + draft.document.editorDocument.content[1].attrs.language = null; + }, + ], + [ + "lost hidden paste source", + (draft) => { + draft.document.pendingPastes[0].text = "placeholder only"; + }, + ], + [ + "ephemeral blob preview", + (draft) => { + draft.document.attachments[0].previewUrl = "blob:dead"; + }, + ], + [ + "altered durable image", + (draft) => { + draft.document.attachments[0].data = "other"; + }, + ], + [ + "foreign path", + (draft) => { + draft.document.pathReferences[0].path = "/other"; + }, + ], + [ + "missing capability route", + (draft) => { + delete draft.document.inputCapabilityRoute; + }, + ], + [ + "catalog key used as execution key", + (draft) => { + draft.document.inputCapabilityRoute.skillKey = "user:capability-report"; + }, + ], + [ + "missing mention binding", + (draft) => { + draft.document.mentionBindings = []; + }, + ], + [ + "collapsed selection", + (draft) => { + draft.document.selectionStart = draft.document.selectionEnd; + }, + ], +]) + test(`rejects ${name} despite identical visible plain text`, () => { + const result = fixture(); + change(result.data.view.entry.draft); + assert.throws( + () => assertRichDraft(result.data.view.entry, expected), + /fixture failed/, + ); + }); + +test("cold restore rejects matching plaintext with changed identity or hidden resource", () => { + const before = fixture().data.view.entry; + for (const change of [ + (entry) => { + entry.draftId = "new"; + }, + (entry) => { + entry.scopeVersion = "version-b"; + }, + (entry) => { + entry.draft.document.pendingPastes[0].text = "changed hidden source"; + }, + ]) { + const after = fixture(); + change(after.data.view.entry); + assert.throws( + () => assertRestoredDraft(after, scope, before), + /fixture failed/, + ); + } +}); + +test("DOM gate rejects a hidden or foreign editor and undecoded image", () => { + for (const change of [ + (snapshot) => { + snapshot.editorCount = 2; + }, + (snapshot) => { + snapshot.sessionId = "other"; + }, + (snapshot) => { + snapshot.disabled = true; + }, + (snapshot) => { + snapshot.electron = false; + }, + (snapshot) => { + snapshot.images[0].decoded = false; + }, + (snapshot) => { + snapshot.tokens[0].id = "foreign"; + }, + ]) { + const snapshot = structuredClone(dom); + change(snapshot); + assert.throws( + () => assertRestoredDom(snapshot, dom, "session-a"), + /fixture failed/, + ); + } +}); + +test("workspace index rejects foreign or duplicate scopes", () => { + const entry = fixture().data.view.entry; + const index = { + key: atomKey, + outcome: "ready", + data: { + queryKind: "workspace-index", + workspaceId: scope.workspaceId, + items: [ + { + scope, + identity: { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + }, + }, + ], + }, + }; + assertWorkspaceIndex(index, scope.workspaceId, scope, entry); + for (const change of [ + (result) => { + result.data.items.push(structuredClone(result.data.items[0])); + }, + (result) => { + result.data.items[0].scope.workspaceId = "foreign"; + }, + (result) => { + result.data.items[0].identity.scopeVersion = "version-b"; + }, + ]) { + const result = structuredClone(index); + change(result); + assert.throws( + () => assertWorkspaceIndex(result, scope.workspaceId, scope, entry), + /fixture failed/, + ); + } +}); + +test("normal restart proof rejects window-only closure, kill, duplicate PID or foreign profile", () => { + const profile = "/isolated/profile"; + const generations = [1, 2, 3].map((pid) => ({ + pid, + profile, + mode: "normal", + exitCode: 0, + signal: null, + exited: true, + })); + assertRestartLifecycle(generations, profile); + for (const change of [ + (records) => { + records[0].exited = false; + }, + (records) => { + records[0].signal = "SIGKILL"; + }, + (records) => { + records[1].pid = records[0].pid; + }, + (records) => { + records[2].profile = "/foreign"; + }, + (records) => { + records[2].mode = "window-closed"; + }, + ]) { + const records = structuredClone(generations); + change(records); + assert.throws( + () => assertRestartLifecycle(records, profile), + /fixture failed/, + ); + } +}); + +test("CLI refuses implicit evidence, unsafe run IDs and oversized waits", () => { + assert.throws(() => parseComposerDraftFixtureArgs([]), /fixture failed/); + const args = [ + "--evidence-dir", + "evidence", + "--verification-packet", + "packet.json", + "--run-id", + "fixture", + ]; + assert.equal(parseComposerDraftFixtureArgs(args, "/repo").repoRoot, "/repo"); + assert.throws( + () => parseComposerDraftFixtureArgs([...args, "--run-id", "../reuse"]), + /fixture failed/, + ); + assert.throws( + () => parseComposerDraftFixtureArgs([...args, "--timeout-ms", "60001"]), + /fixture failed/, + ); + assert.equal(parseComposerDraftFixtureArgs(["--help"]).help, true); +}); diff --git a/scripts/electron/lib/composer-first-send-evidence.mjs b/scripts/electron/lib/composer-first-send-evidence.mjs new file mode 100644 index 000000000..0c1888b88 --- /dev/null +++ b/scripts/electron/lib/composer-first-send-evidence.mjs @@ -0,0 +1,473 @@ +import { createHash } from "node:crypto"; +import { Buffer } from "node:buffer"; +import { isDeepStrictEqual } from "node:util"; + +const atomKey = "composer-drafts-v1"; +export const firstSendRoutes = Object.freeze([ + "home", + "search-new", + "project-new", +]); +/** Three public entries share one Home scope contract and create independent receipts. */ +export function assertFirstSendRouteCoverage(proofs) { + requireFirstSend( + proofs.length === 3 && + firstSendRoutes.every((route, index) => proofs[index]?.route === route), + "all three public first-send entries are verified once", + ); + requireFirstSend( + proofs.every((proof) => { + const scope = proof.source?.sourceScope; + return ( + scope?.kind === "home" && + scope.id === "new-conversation" && + (proof.route === "project-new" + ? nonempty(scope.workspaceId) + : scope.workspaceId === null) && + scope.workspaceId === proof.workspaceId + ); + }), + "public entries prove the exact stable Home and explicit workspace scopes", + ); + for (const key of ["sessionId", "threadId"]) + requireFirstSend( + new Set(proofs.map((proof) => proof.receipt?.[key])).size === 3 && + proofs.every((proof) => nonempty(proof.receipt?.[key])), + "each public entry creates an independent canonical " + key, + ); + return true; +} + +export const newEditText = "首发接受后的新编辑,中😀;旧发送不得清除。"; + +export function requireFirstSend(condition, phase) { + if (!condition) + throw new Error("Composer first-send fixture failed: " + phase); +} + +const digest = (value) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); +const nonempty = (value) => + typeof value === "string" && value.trim().length > 0; +const hashBytes = (value) => createHash("sha256").update(value).digest("hex"); + +function strictBase64(value) { + requireFirstSend(nonempty(value), "image source bytes exist"); + const bytes = Buffer.from(value, "base64"); + requireFirstSend( + bytes.length > 0 && bytes.toString("base64") === value, + "image source is complete canonical base64", + ); + return bytes; +} + +/** Retain resource identity/digests only; source body and image data stay local. */ +export function captureFirstSendResources(entry, expected) { + capturedIdentity(entry); + const doc = entry.draft?.document; + const bytes = strictBase64(expected.imageBase64); + const image = doc?.attachments?.[0]; + const reference = doc?.pathReferences?.[0]; + const binding = doc?.mentionBindings?.[0]; + const skill = expected.skillIdentity; + const submission = expected.expectedSubmission; + requireFirstSend( + doc?.attachments?.length === 1 && + image.mediaType === "image/png" && + strictBase64(image.data).equals(bytes) && + doc.pathReferences?.length === 1 && + reference.path === expected.path && + reference.name === expected.pathName && + reference.id === "file:" + expected.path && + reference.isDir === false && + doc.mentionBindings?.length === 1 && + binding.kind === "skill" && + binding.id === skill?.key && + binding.name === skill.name && + binding.path === skill.path && + nonempty(skill.path) && + doc.inputCapabilityRoute?.kind === "installed_skill" && + doc.inputCapabilityRoute.skillKey === skill.name && + /^[a-f0-9]{64}$/.test(expected.skillFileSha256 ?? "") && + Object.values(doc.pendingPastes ?? {}).length === 1 && + Object.values(doc.pendingPastes)[0].text === expected.pasteText && + Object.values(doc.pendingPastes)[0].placeholder === + submission?.textElements?.[0]?.placeholder && + nonempty(expected.pasteText) && + nonempty(submission?.text) && + doc.text === submission.displayText && + submission.text.includes(expected.prompt) && + submission.text.includes(expected.marker) && + submission.text.includes(expected.pasteText) && + Array.isArray(submission.textElements) && + submission.textElements.length === 2 && + submission.marks?.codeBlocks?.length === 1 && + submission.marks?.links?.length === 1, + "captured native image, file and installed Skill match independent fixture resources", + ); + const imageSha256 = hashBytes(bytes); + return { + image: { + mimeType: image.mediaType, + bytes: bytes.length, + sha256: imageSha256, + canonicalUrl: "sidecar://media/input-" + imageSha256 + ".png", + }, + path: { path: reference.path, name: reference.name, isDir: false }, + skill: { + name: skill.name, + path: skill.path, + key: skill.key, + fileSha256: expected.skillFileSha256, + }, + expandedText: { + length: Buffer.byteLength(submission.text, "utf8"), + sha256: hashBytes(submission.text), + textElementsSha256: digest(submission.textElements), + }, + hiddenPaste: { + bytes: Buffer.byteLength(expected.pasteText, "utf8"), + sha256: hashBytes(expected.pasteText), + }, + code: { + language: submission.marks.codeBlocks[0].language, + bodySha256: hashBytes(submission.marks.codeBlocks[0].body), + }, + link: { + href: submission.marks.links[0].href, + title: submission.marks.links[0].title, + labelSha256: hashBytes(submission.marks.links[0].label), + }, + }; +} + +/** Reuse the current full-input oracle, then bind real media bytes to source. */ +export function assertFirstSendResourceReceipt( + source, + receipt, + { richInput, mediaRead, expectedSubmission }, +) { + const resources = source.resources; + const media = mediaRead?.result; + requireFirstSend( + resources && + receipt.canonicalAccepted === true && + richInput?.ok === true && + richInput.threadId === receipt.threadId && + richInput.itemId === receipt.userItemId && + richInput.imageUrl === resources.image.canonicalUrl && + richInput.canonicalFullInputPreserved === true && + richInput.canonicalSkillPreserved === true && + richInput.pathIdentityPreserved === true && + richInput.pasteExpanded === true && + richInput.textElementsPreserved === true && + richInput.canonicalImageBytesPreserved === true && + hashBytes(expectedSubmission.text) === resources.expandedText.sha256 && + digest(expectedSubmission.textElements) === + resources.expandedText.textElementsSha256 && + media?.threadId === receipt.threadId && + media.uri === resources.image.canonicalUrl && + media.mimeType === resources.image.mimeType && + media.offset === 0 && + media.bytes === resources.image.bytes && + media.totalBytes === resources.image.bytes && + media.length === resources.image.bytes && + media.hasMore === false && + media.sha256 === "sha256:" + resources.image.sha256 && + hashBytes(strictBase64(media.contentBase64)) === resources.image.sha256, + "complete canonical input and actual thread-scoped media bytes match the captured rich source", + ); + return { + completeCanonicalInputVerified: true, + sourceResourcesSha256: digest(resources), + imageSha256: resources.image.sha256, + mediaReadSha256: media.sha256, + ...richInput, + }; +} + +export function capturedIdentity(entry) { + requireFirstSend( + entry?.state === "draft" && + nonempty(entry.draftId) && + nonempty(entry.scopeVersion), + "captured native identity uses opaque nonempty strings", + ); + return { draftId: entry.draftId, scopeVersion: entry.scopeVersion }; +} + +/** Public metadata and an exact saved payload bind the sole visible Composer. */ +export function assertFirstSendScopeObservation(route, proof) { + const { workspaceId, workspaceIndex, scopeRead, dom, expectedMarker } = proof; + const kind = "home"; + requireFirstSend(firstSendRoutes.includes(route), "known first-send route"); + requireFirstSend( + (workspaceId === null || nonempty(workspaceId)) && + workspaceIndex?.key === atomKey && + workspaceIndex.outcome === "ready" && + workspaceIndex.data?.queryKind === "workspace-index" && + workspaceIndex.data.workspaceId === workspaceId && + Array.isArray(workspaceIndex.data.items) && + workspaceIndex.data.items.every( + (item) => item.scope?.workspaceId === workspaceId, + ), + "same atom public workspace index contains no foreign workspace", + ); + const matches = workspaceIndex.data.items.filter( + (item) => item.scope?.kind === kind, + ); + requireFirstSend( + matches.length === 1 && + nonempty(matches[0].scope.id) && + !matches[0].sessionId && + !matches[0].threadId && + matches[0].scope.id === "new-conversation", + "exactly one actual unpromoted source scope; no fallback identity", + ); + const item = matches[0]; + requireFirstSend( + scopeRead?.key === atomKey && + scopeRead.outcome === "ready" && + scopeRead.data?.queryKind === "scope" && + isDeepStrictEqual(scopeRead.data.view?.scope, item.scope) && + [workspaceIndex, scopeRead].every( + (result) => + Number.isSafeInteger(result.storeRevision) && + result.persistence?.state === "saved" && + result.persistence.savedRevision === result.storeRevision, + ) && + scopeRead.storeRevision === workspaceIndex.storeRevision, + "public index and exact scope read acknowledge the same saved revision", + ); + const entry = scopeRead.data.view.entry; + const identity = capturedIdentity(entry); + requireFirstSend( + isDeepStrictEqual(item.identity, identity) && + !entry.sessionId && + !entry.threadId && + entry.originScopeKey === + JSON.stringify([workspaceId, kind, item.scope.id]) && + dom?.electron === true && + dom.preload === true && + dom.editorCount === 1 && + dom.disabled === false && + dom.sessionId === null && + nonempty(expectedMarker) && + dom.text?.includes(expectedMarker) && + entry.draft?.document?.text?.includes(expectedMarker), + "one native null-session Composer and saved payload share the unique input marker", + ); + return { + scope: item.scope, + identity, + storeRevision: scopeRead.storeRevision, + workspaceIndexSha256: digest(workspaceIndex.data.items), + payloadSha256: digest(entry.draft), + markerSha256: digest(expectedMarker), + logicalDraftScopeOnly: true, + visibleTabIdentityVerified: false, + }; +} + +/** The actual saved logical scope is captured before a real Send button click. */ +export function assertFirstSendSource(route, proof) { + const { scope, entry, dom, beforeThreads, preparedThreads } = proof; + const observation = assertFirstSendScopeObservation(route, proof); + requireFirstSend( + isDeepStrictEqual(scope, observation.scope) && + isDeepStrictEqual(entry, proof.scopeRead.data.view.entry) && + (route !== "home" || dom.emptyState === true), + "captured source is the exact current native payload and logical scope", + ); + const identity = capturedIdentity(entry); + requireFirstSend( + !entry.sessionId && + nonempty(entry.originScopeKey) && + Array.isArray(beforeThreads) && + Array.isArray(preparedThreads) && + [...beforeThreads, ...preparedThreads].every( + (thread) => nonempty(thread.id) && nonempty(thread.sessionId), + ), + "source remains unpromoted and canonical pre-send inventory exists", + ); + const priorIds = new Set(beforeThreads.map((thread) => thread.id)); + const created = preparedThreads.filter((thread) => !priorIds.has(thread.id)); + requireFirstSend( + new Set(beforeThreads.map((thread) => thread.id)).size === + beforeThreads.length && + new Set(preparedThreads.map((thread) => thread.id)).size === + preparedThreads.length, + "canonical inventory has unique thread identities", + ); + requireFirstSend( + created.length === 0, + "editor input creates no extra canonical thread before Send", + ); + return { + route, + sourceScope: scope, + identity, + originScopeKey: entry.originScopeKey, + preexistingThreadIds: [...priorIds], + preexistingSessionIds: beforeThreads.map((thread) => thread.sessionId), + sourceDocumentSha256: digest(entry.draft), + actualNullSessionComposer: true, + nativeScopeObservation: observation, + }; +} + +/** Receipt identity and text fragments; the resource oracle proves full input. */ +export function assertFirstSendReceipt(thread, source, expectedFragments) { + requireFirstSend( + nonempty(thread?.id) && + nonempty(thread.sessionId) && + Array.isArray(thread.turns) && + thread.turns.length === 1 && + !source.preexistingThreadIds.includes(thread.id) && + !source.preexistingSessionIds.includes(thread.sessionId), + "accepted canonical thread matches this first-send route", + ); + const turn = thread.turns[0]; + requireFirstSend( + nonempty(turn.id) && ["inProgress", "in_progress"].includes(turn.status), + "canonical receipt is an active accepted Turn", + ); + const users = (turn.items ?? []).filter( + (item) => item.type === "userMessage", + ); + requireFirstSend( + users.length === 1 && + nonempty(users[0].id) && + Array.isArray(users[0].content), + "one canonical user Item for the submitted draft", + ); + const text = users[0].content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + requireFirstSend( + expectedFragments.length > 0 && + expectedFragments.every( + (fragment) => nonempty(fragment) && text.includes(fragment), + ), + "canonical user text contains independent body fragments", + ); + return { + sessionId: thread.sessionId, + threadId: thread.id, + turnId: turn.id, + userItemId: users[0].id, + userItemSha256: digest(users[0]), + canonicalAccepted: true, + bodyFragmentsVerified: true, + }; +} + +export function assertUnboundScope(result, scope) { + requireFirstSend( + result?.key === atomKey && + result.outcome === "ready" && + result.data?.queryKind === "scope" && + isDeepStrictEqual(result.data.view.scope, scope) && + result.data.view.entry === null && + result.persistence?.state === "saved" && + result.persistence.savedRevision === result.storeRevision, + "exact old or local scope is unbound and acknowledged saved", + ); + return { scope, storeRevision: result.storeRevision, unbound: true }; +} + +/** The shared driver parses actual primary/backup bytes with the current owner. */ +export function assertFirstSendConsumed(envelope, source, receipt) { + const atom = envelope.atoms[atomKey]; + const entry = atom.entries[source.identity.draftId]; + requireFirstSend( + entry?.state === "consumed" && + entry.consumption === "accepted" && + entry.draftId === source.identity.draftId && + entry.scopeVersion === source.identity.scopeVersion && + entry.originScopeKey === source.originScopeKey && + entry.sessionId === receipt.sessionId && + entry.threadId === receipt.threadId && + !Object.hasOwn(entry, "draft") && + !Object.values(atom.bindings).includes(source.identity.draftId), + "exact submitted identity was promoted to receipt metadata and consumed", + ); + return { + ...source.identity, + sessionId: entry.sessionId, + threadId: entry.threadId, + storeRevision: envelope.storeRevision, + consumption: entry.consumption, + sameCapturedIdentity: true, + noRemainingBinding: true, + }; +} + +export function assertFirstSendNewEdit(entry, source, receipt) { + const identity = capturedIdentity(entry); + requireFirstSend( + entry.sessionId === receipt.sessionId && + entry.draft?.document?.text === newEditText && + (identity.draftId !== source.identity.draftId || + identity.scopeVersion !== source.identity.scopeVersion), + "new local edit has a different opaque identity and exact user text", + ); + return { + ...identity, + documentSha256: digest(entry.draft), + newEditPreserved: true, + }; +} + +/** Reject an old driver that silently ignores the envelope inspector callback. */ +export function assertFirstSendSavedFiles(files, expected) { + requireFirstSend( + nonempty(expected.draftId) && + nonempty(expected.scopeVersion) && + Array.isArray(files) && + files.length === 2 && + files.every( + (file) => + file.entryEvidence && + Object.entries(expected).every( + ([key, value]) => file.entryEvidence[key] === value, + ), + ), + "shared driver inspected the exact identity in both actual disk files", + ); +} + +export function assertFirstSendTrace(trace) { + const operations = trace.atomOperations; + requireFirstSend( + Array.isArray(operations) && + operations.every( + (item) => + item.transport === "electron-ipc" && item.status === "success", + ) && + trace.appServerInvokeCount > 0, + "complete shared-driver ledger uses native IPC and current App Server", + ); + const promotions = operations.flatMap((item, index) => + item.op === "promote" ? [index] : [], + ); + const consumptions = operations.flatMap((item, index) => + item.op === "consume" ? [index] : [], + ); + requireFirstSend( + promotions.length === 1 && + consumptions.length === 1 && + promotions[0] < consumptions[0] && + operations.some( + (item, index) => item.op === "put" && index > consumptions[0], + ), + "one production promotion precedes consume and a later new-edit put", + ); + return { + promotionIndex: promotions[0], + consumeIndex: consumptions[0], + productionPromotionBeforeConsume: true, + newEditPutAfterConsume: true, + }; +} diff --git a/scripts/electron/lib/composer-first-send-evidence.test.mjs b/scripts/electron/lib/composer-first-send-evidence.test.mjs new file mode 100644 index 000000000..5bd52a89a --- /dev/null +++ b/scripts/electron/lib/composer-first-send-evidence.test.mjs @@ -0,0 +1,608 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertFirstSendConsumed, + assertFirstSendNewEdit, + assertFirstSendReceipt, + assertFirstSendSavedFiles, + assertFirstSendScopeObservation, + assertFirstSendSource, + assertFirstSendRouteCoverage, + assertFirstSendTrace, + assertUnboundScope, + capturedIdentity, + newEditText, +} from "./composer-first-send-evidence.mjs"; + +function sourceInput(route) { + const project = route === "project-new"; + const scope = { + workspaceId: project ? "workspace-a" : null, + kind: "home", + id: "new-conversation", + }; + const baseline = { id: "bootstrap-thread", sessionId: "bootstrap-session" }; + const entry = { + state: "draft", + draftId: "captured-draft", + scopeVersion: "z-opaque-original", + originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]), + draft: { document: { text: "独立的提交正文" } }, + }; + const metadata = { + key: "composer-drafts-v1", + outcome: "ready", + storeRevision: 8, + persistence: { state: "saved", savedRevision: 8 }, + }; + return { + scope, + workspaceId: scope.workspaceId, + entry, + expectedMarker: "独立的提交正文", + workspaceIndex: { + ...structuredClone(metadata), + data: { + queryKind: "workspace-index", + workspaceId: scope.workspaceId, + items: [ + { + scope: structuredClone(scope), + identity: { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + }, + updatedAt: 123, + }, + ], + }, + }, + scopeRead: { + ...structuredClone(metadata), + data: { + queryKind: "scope", + view: { + scope: structuredClone(scope), + entry: structuredClone(entry), + }, + }, + }, + dom: { + electron: true, + preload: true, + editorCount: 1, + text: "独立的提交正文", + sessionId: null, + disabled: false, + emptyState: true, + }, + beforeThreads: [baseline], + preparedThreads: [baseline], + }; +} + +const fragments = [ + "独立首发请求", + "隐藏源一行\n隐藏源二行", + "const value = '中😀';", +]; +function receiptThread(source) { + return { + id: "accepted-new-thread", + sessionId: "accepted-new-session", + turns: [ + { + id: "accepted-turn", + status: "inProgress", + items: [ + { + id: "canonical-user-item", + type: "userMessage", + content: [{ type: "text", text: fragments.join("\n") }], + }, + ], + }, + ], + }; +} + +function accepted(route = "search-new") { + const input = sourceInput(route); + const source = assertFirstSendSource(route, input); + const receipt = assertFirstSendReceipt( + receiptThread(source), + source, + fragments, + ); + return { input, source, receipt }; +} + +function consumedEnvelope(source, receipt) { + return { + storeRevision: 13, + atoms: { + "composer-drafts-v1": { + bindings: {}, + entries: { + [source.identity.draftId]: { + ...source.identity, + originScopeKey: source.originScopeKey, + sessionId: receipt.sessionId, + threadId: receipt.threadId, + state: "consumed", + consumption: "accepted", + }, + }, + }, + }, + }; +} + +for (const route of ["home", "search-new", "project-new"]) { + test( + "accepts actual native " + + route + + " source and its canonical first receipt", + () => { + const { source, receipt } = accepted(route); + assert.equal(source.route, route); + assert.equal(source.identity.scopeVersion, "z-opaque-original"); + assert.equal(receipt.canonicalAccepted, true); + assert.match(receipt.userItemSha256, /^[a-f0-9]{64}$/); + assert.equal(JSON.stringify(receipt).includes(fragments[1]), false); + }, + ); +} + +test("never classifies an existing local send or foreign visible Composer as first submission", () => { + for (const mutate of [ + (value) => { + value.scope.kind = "local"; + }, + (value) => { + value.dom.sessionId = "already-local"; + }, + (value) => { + value.dom.editorCount = 2; + }, + (value) => { + value.dom.preload = false; + }, + (value) => { + value.dom.electron = false; + }, + (value) => { + value.dom.disabled = true; + }, + (value) => { + value.scope.workspaceId = "foreign-workspace"; + }, + (value) => { + value.entry.sessionId = "promoted-before-send"; + }, + ]) { + const input = sourceInput("search-new"); + mutate(input); + assert.throws(() => assertFirstSendSource("search-new", input)); + } +}); + +test("Home requires the exact native Home scope and its visible empty surface", () => { + const input = sourceInput("home"); + input.scope.id = "another-home-scope"; + assert.throws(() => assertFirstSendSource("home", input)); + input.scope.id = "new-conversation"; + input.dom.emptyState = false; + assert.throws(() => assertFirstSendSource("home", input)); +}); + +test("public index and payload prove the stable Home scope without top tabs", () => { + const input = sourceInput("search-new"); + input.workspaceIndex.data.items.push({ + scope: { workspaceId: null, kind: "local", id: "earlier-session" }, + identity: { draftId: "earlier-edit", scopeVersion: "earlier-version" }, + sessionId: "earlier-session", + updatedAt: 122, + }); + const proof = assertFirstSendSource("search-new", input); + assert.equal(proof.nativeScopeObservation.logicalDraftScopeOnly, true); + assert.equal(proof.nativeScopeObservation.visibleTabIdentityVerified, false); + assert.equal(proof.sourceScope.id, "new-conversation"); +}); + +test("native scope lookup fails closed for ambiguity, wrong workspace, identity, revision or payload", () => { + for (const mutate of [ + (value) => { + value.workspaceIndex.key = "other-atom"; + }, + (value) => { + value.workspaceIndex.outcome = "unavailable"; + }, + (value) => { + value.workspaceIndex.data.queryKind = "scope"; + }, + (value) => { + value.workspaceIndex.data.workspaceId = "foreign"; + }, + (value) => { + value.workspaceIndex.data.items = []; + }, + (value) => { + value.workspaceIndex.data.items.push( + structuredClone(value.workspaceIndex.data.items[0]), + ); + }, + (value) => { + value.workspaceIndex.data.items[0].scope.workspaceId = "foreign"; + }, + (value) => { + value.workspaceIndex.data.items[0].scope.kind = "local"; + }, + (value) => { + value.workspaceIndex.data.items[0].scope.id = ""; + }, + (value) => { + value.workspaceIndex.data.items[0].sessionId = "already-promoted"; + }, + (value) => { + value.workspaceIndex.data.items[0].threadId = "already-promoted"; + }, + (value) => { + value.workspaceIndex.data.items[0].identity.scopeVersion = + "other-version"; + }, + (value) => { + value.scopeRead.key = "other-atom"; + }, + (value) => { + value.scopeRead.outcome = "invalid"; + }, + (value) => { + value.scopeRead.data.queryKind = "workspace-index"; + }, + (value) => { + value.scopeRead.data.view.scope.id = "foreign-scope"; + }, + (value) => { + value.scopeRead.data.view.entry = null; + }, + (value) => { + value.scopeRead.persistence.state = "pending"; + }, + (value) => { + value.scopeRead.persistence.savedRevision = 7; + }, + (value) => { + value.workspaceIndex.persistence.state = "pending"; + }, + (value) => { + value.workspaceIndex.persistence.savedRevision = 7; + }, + (value) => { + value.workspaceIndex.storeRevision = 7; + value.workspaceIndex.persistence.savedRevision = 7; + }, + (value) => { + value.scopeRead.data.view.entry.originScopeKey = "foreign-origin"; + }, + (value) => { + value.scopeRead.data.view.entry.sessionId = "already-promoted"; + }, + (value) => { + value.scopeRead.data.view.entry.threadId = "already-promoted"; + }, + (value) => { + value.scopeRead.data.view.entry.draft.document.text = "foreign-payload"; + }, + (value) => { + value.dom.text = "foreign-visible-input"; + }, + (value) => { + value.expectedMarker = ""; + }, + (value) => { + value.workspaceId = "foreign-project"; + }, + ]) { + const input = sourceInput("search-new"); + mutate(input); + assert.throws(() => assertFirstSendScopeObservation("search-new", input)); + assert.throws(() => assertFirstSendSource("search-new", input)); + } +}); + +test("captured rich source cannot diverge from the current saved scope payload", () => { + const input = sourceInput("search-new"); + input.entry.draft.document.text += " stale captured payload"; + assert.throws(() => assertFirstSendSource("search-new", input)); +}); + +test("each public entry rejects canonical sessions created by input before Send", () => { + for (const route of ["home", "search-new", "project-new"]) { + const input = sourceInput(route); + input.preparedThreads.push({ + id: "unexpected-thread", + sessionId: "unexpected-session", + }); + assert.throws(() => assertFirstSendSource(route, input)); + } +}); + +test("identity version is opaque text, not numeric or ordered", () => { + for (const version of [1, "", " ", null, undefined]) { + assert.throws(() => + capturedIdentity({ + state: "draft", + draftId: "draft", + scopeVersion: version, + }), + ); + } + const { source, receipt } = accepted(); + const entry = { + state: "draft", + draftId: source.identity.draftId, + scopeVersion: "a-opaque-next", + sessionId: receipt.sessionId, + draft: { document: { text: newEditText } }, + }; + assert.equal( + assertFirstSendNewEdit(entry, source, receipt).newEditPreserved, + true, + ); +}); + +test("preexisting local or preexisting canonical thread cannot provide the first-send receipt", () => { + for (const route of ["home", "search-new", "project-new"]) { + const { source } = accepted(route); + for (const mutate of [ + (thread) => { + thread.id = "bootstrap-thread"; + }, + (thread) => { + thread.sessionId = "bootstrap-session"; + }, + (thread) => { + thread.turns.push({ id: "second-turn" }); + }, + (thread) => { + thread.turns[0].status = "completed"; + }, + (thread) => { + thread.turns[0].items[0].id = ""; + }, + (thread) => { + thread.turns[0].items[0].content = [ + { type: "text", text: fragments[0] }, + ]; + }, + ]) { + const thread = receiptThread(source); + mutate(thread); + assert.throws(() => assertFirstSendReceipt(thread, source, fragments)); + } + } +}); + +test("scope read proves saved unbinding rather than exposing a consumed entry", () => { + const { source } = accepted(); + const result = { + key: "composer-drafts-v1", + outcome: "ready", + storeRevision: 13, + persistence: { state: "saved", savedRevision: 13 }, + data: { + queryKind: "scope", + view: { scope: source.sourceScope, entry: null }, + }, + }; + assert.equal(assertUnboundScope(result, source.sourceScope).unbound, true); + for (const mutate of [ + (value) => { + value.data.view.entry = { state: "consumed" }; + }, + (value) => { + value.data.view.scope.id = "another-scope"; + }, + (value) => { + value.persistence.state = "pending"; + }, + (value) => { + value.persistence.savedRevision = 12; + }, + ]) { + const changed = structuredClone(result); + mutate(changed); + assert.throws(() => assertUnboundScope(changed, source.sourceScope)); + } +}); + +test("saved tombstone preserves exact original identity and accepted local metadata", () => { + const { source, receipt } = accepted(); + const envelope = consumedEnvelope(source, receipt); + const proof = assertFirstSendConsumed(envelope, source, receipt); + assert.equal(proof.sameCapturedIdentity, true); + assert.equal(proof.sessionId, receipt.sessionId); + assert.equal(proof.threadId, receipt.threadId); + assert.equal(proof.noRemainingBinding, true); + for (const mutate of [ + (entry) => { + entry.scopeVersion = "another-version"; + }, + (entry) => { + entry.draftId = "another-draft"; + }, + (entry) => { + entry.sessionId = "another-session"; + }, + (entry) => { + entry.threadId = "another-thread"; + }, + (entry) => { + entry.originScopeKey = "another-origin"; + }, + (entry) => { + entry.consumption = "clear"; + }, + (entry) => { + entry.state = "draft"; + }, + (entry) => { + entry.draft = { document: { text: "must-be-absent" } }; + }, + ]) { + const changed = structuredClone(envelope); + mutate( + changed.atoms["composer-drafts-v1"].entries[source.identity.draftId], + ); + assert.throws(() => assertFirstSendConsumed(changed, source, receipt)); + } + envelope.atoms["composer-drafts-v1"].bindings.oldScope = + source.identity.draftId; + assert.throws(() => assertFirstSendConsumed(envelope, source, receipt)); +}); + +test("new edit cannot reuse the consumed identity or silently replace current text", () => { + const { source, receipt } = accepted(); + const entry = { + state: "draft", + draftId: "genuine-new-draft", + scopeVersion: "independent-version", + sessionId: receipt.sessionId, + draft: { document: { text: newEditText } }, + }; + assert.equal( + assertFirstSendNewEdit(entry, source, receipt).newEditPreserved, + true, + ); + for (const mutate of [ + (value) => { + Object.assign(value, source.identity); + }, + (value) => { + value.draft.document.text = "restored old submitted text"; + }, + (value) => { + value.sessionId = "foreign-session"; + }, + ]) { + const changed = structuredClone(entry); + mutate(changed); + assert.throws(() => assertFirstSendNewEdit(changed, source, receipt)); + } +}); + +test("an old driver that ignores the disk callback cannot satisfy saved identity evidence", () => { + const expected = { + draftId: "actual-draft", + scopeVersion: "opaque-version", + sameCapturedIdentity: true, + sessionId: "actual-session", + threadId: "actual-thread", + }; + const files = [0, 1].map(() => ({ entryEvidence: { ...expected } })); + assert.doesNotThrow(() => assertFirstSendSavedFiles(files, expected)); + for (const mutate of [ + (value) => { + value.pop(); + }, + (value) => { + delete value[0].entryEvidence; + }, + (value) => { + value[1].entryEvidence.scopeVersion = "different"; + }, + (value) => { + value[1].entryEvidence.sameCapturedIdentity = false; + }, + (value) => { + value[1].entryEvidence.threadId = "different"; + }, + ]) { + const changed = structuredClone(files); + mutate(changed); + assert.throws(() => assertFirstSendSavedFiles(changed, expected)); + } +}); + +test("native production promotion precedes one consume and a later edit put", () => { + const trace = { + appServerInvokeCount: 5, + atomOperations: ["put", "promote", "consume", "put"].map((op) => ({ + op, + status: "success", + transport: "electron-ipc", + })), + }; + assert.deepEqual(assertFirstSendTrace(trace), { + promotionIndex: 1, + consumeIndex: 2, + productionPromotionBeforeConsume: true, + newEditPutAfterConsume: true, + }); + for (const mutate of [ + (value) => { + value.atomOperations.reverse(); + }, + (value) => { + value.atomOperations.pop(); + }, + (value) => { + value.atomOperations.push({ ...value.atomOperations[2] }); + }, + (value) => { + value.atomOperations[1].transport = "browser-mirror"; + }, + (value) => { + value.atomOperations[2].status = "error"; + }, + (value) => { + value.appServerInvokeCount = 0; + }, + ]) { + const changed = structuredClone(trace); + mutate(changed); + assert.throws(() => assertFirstSendTrace(changed)); + } +}); + +test("aggregate coverage accepts shared stable Home scopes and independent canonical receipts", () => { + const proofs = ["home", "search-new", "project-new"].map((route, index) => ({ + route, + workspaceId: index === 2 ? "workspace-a" : null, + source: { + sourceScope: { + kind: "home", + id: "new-conversation", + workspaceId: index === 2 ? "workspace-a" : null, + }, + }, + receipt: { sessionId: "session-" + index, threadId: "thread-" + index }, + })); + assert.equal(assertFirstSendRouteCoverage(proofs), true); + for (const edit of [ + (value) => value.pop(), + (value) => { + value[1].route = "materialized-draft"; + }, + (value) => { + value[1].source.sourceScope.kind = "draft"; + }, + (value) => { + value[0].source.sourceScope.workspaceId = "inherited"; + }, + (value) => { + value[2].source.sourceScope.workspaceId = null; + }, + (value) => { + value[1].receipt.sessionId = value[0].receipt.sessionId; + }, + (value) => { + value[1].receipt.threadId = value[0].receipt.threadId; + }, + ]) { + const bad = structuredClone(proofs); + edit(bad); + assert.throws(() => assertFirstSendRouteCoverage(bad)); + } +}); diff --git a/scripts/electron/lib/composer-first-send-resource-evidence.test.mjs b/scripts/electron/lib/composer-first-send-resource-evidence.test.mjs new file mode 100644 index 000000000..ba579b487 --- /dev/null +++ b/scripts/electron/lib/composer-first-send-resource-evidence.test.mjs @@ -0,0 +1,333 @@ +import { strict as assert } from "node:assert"; +import { Buffer } from "node:buffer"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { after, before, test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { + assertFirstSendReceipt, + assertFirstSendResourceReceipt, + captureFirstSendResources, +} from "./composer-first-send-evidence.mjs"; + +// Load the current independent fixture/oracle; do not reproduce lowering here. +const fromFormal = (name) => + import( + pathToFileURL(path.join(process.cwd(), "scripts/agent-runtime", name)) + ); +const { summarizeRichInputSubmission, richInputImageUrl } = await fromFormal( + "claw-chat-current-fixture-rich-input-evidence.mjs", +); +const { composerMarksFixture } = await fromFormal( + "claw-chat-current-fixture-composer-marks.mjs", +); +const { ensureUserVisibleCapabilityReportSkill } = await fromFormal( + "claw-chat-current-fixture-skills-workspace.mjs", +); +const sha = (value) => createHash("sha256").update(value).digest("hex"); +let fixtureRoot; +let skillIdentity; +let skillFileSha256; +before(() => { + fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "first-send-resource-")); + skillIdentity = ensureUserVisibleCapabilityReportSkill({ + env: { HOME: fixtureRoot }, + }).skillIdentity; + skillFileSha256 = sha(fs.readFileSync(skillIdentity.path)); +}); +after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + +function fixture() { + const prompt = "首发资源完整保留。"; + const marker = "Home首发甲一号"; + const pasteText = "历史粘贴验证😀\n".repeat(180); + const imageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const literal = composerMarksFixture({ prompt, skillIdentity, pasteText }); + const expectedSubmission = { + ...literal, + text: literal.text + " " + marker, + displayText: literal.displayText + " " + marker, + }; + const expected = { + prompt, + marker, + pasteText, + imageBase64, + path: path.join(fixtureRoot, "reference.md"), + pathName: "reference.md", + skillIdentity, + skillFileSha256, + expectedSubmission, + }; + const skillToken = { + type: "skill", + id: skillIdentity.key, + name: skillIdentity.name, + path: skillIdentity.path, + label: "/" + skillIdentity.key, + editable: "false", + }; + const pasteToken = { + type: "paste", + id: "paste-1", + label: literal.textElements[0].placeholder, + editable: "false", + }; + const entry = { + state: "draft", + draftId: "native-draft-opaque", + scopeVersion: "native-scope-opaque", + draft: { + document: { + text: expectedSubmission.displayText, + attachments: [{ mediaType: "image/png", data: imageBase64 }], + pathReferences: [ + { + id: "file:" + expected.path, + path: expected.path, + name: expected.pathName, + isDir: false, + }, + ], + mentionBindings: [{ ...skillToken, kind: "skill" }], + inputCapabilityRoute: { + kind: "installed_skill", + skillKey: skillIdentity.name, + }, + pendingPastes: [ + { text: pasteText, placeholder: literal.textElements[0].placeholder }, + ], + }, + }, + }; + const input = [ + { + type: "text", + text: expectedSubmission.text, + text_elements: structuredClone(expectedSubmission.textElements), + }, + { type: "skill", name: skillIdentity.name, path: skillIdentity.path }, + { type: "mention", name: expected.pathName, path: expected.path }, + { type: "image", url: "data:image/png;base64," + imageBase64 }, + ]; + const request = { + jsonrpc: "2.0", + id: "start-first-send", + method: "turn/start", + params: { + threadId: "thread-first-send", + clientUserMessageId: "client-first-send", + input, + }, + }; + const thread = { + id: request.params.threadId, + sessionId: "session-first-send", + turns: [ + { + id: "turn-first-send", + status: "inProgress", + items: [ + { + type: "userMessage", + id: "item-first-send", + clientId: request.params.clientUserMessageId, + content: structuredClone(input), + }, + ], + }, + ], + }; + const imageUrl = richInputImageUrl(imageBase64); + thread.turns[0].items[0].content[3].url = imageUrl; + const imageBytes = Buffer.from(imageBase64, "base64"); + const mediaRead = { + ipcObservation: { + electron: true, + fallback: false, + transport: "electron-ipc", + command: "app_server_handle_json_lines", + status: "success", + method: "media/read", + requestId: "read-first-send", + responseId: "read-first-send", + params: { threadId: thread.id, uri: imageUrl }, + }, + result: { + threadId: thread.id, + uri: imageUrl, + mimeType: "image/png", + offset: 0, + bytes: imageBytes.length, + totalBytes: imageBytes.length, + length: imageBytes.length, + hasMore: false, + sha256: "sha256:" + sha(imageBytes), + contentBase64: imageBase64, + }, + }; + const source = { + preexistingThreadIds: [], + preexistingSessionIds: [], + resources: captureFirstSendResources(entry, expected), + }; + const receipt = assertFirstSendReceipt(thread, source, [prompt, marker]); + return { + entry, + expected, + source, + receipt, + request, + oracle: { + canonical: { thread }, + threadId: thread.id, + turnId: thread.turns[0].id, + prompt, + editorText: expectedSubmission.displayText, + expectedSubmission, + skillIdentity, + skillToken, + pasteToken, + pasteText, + imageBase64, + pathReference: { name: expected.pathName, path: expected.path }, + mediaRead, + }, + }; +} + +function verify(value) { + const trace = [ + { + command: "app_server_handle_json_lines", + transport: "electron-ipc", + status: "success", + args_preview: { request: { lines: [JSON.stringify(value.request)] } }, + }, + ]; + const richInput = summarizeRichInputSubmission({ + ...value.oracle, + traceRaw: JSON.stringify(trace), + }); + return assertFirstSendResourceReceipt(value.source, value.receipt, { + richInput, + mediaRead: value.oracle.mediaRead, + expectedSubmission: value.oracle.expectedSubmission, + }); +} + +test("binds canonical ordered resources and actual media bytes to the source", () => { + const value = fixture(); + const result = verify(value); + assert.equal(result.ok, true); + assert.equal(result.completeCanonicalInputVerified, true); + assert.equal(result.imageSha256, value.source.resources.image.sha256); + const logged = JSON.stringify({ resources: value.source.resources, result }); + assert.equal(logged.includes(value.expected.imageBase64), false); + assert.equal(logged.includes(value.expected.pasteText), false); + assert.equal(logged.includes(value.oracle.expectedSubmission.text), false); +}); + +const sourceMutations = { + "missing image": (doc) => (doc.attachments = []), + "foreign source image bytes": (doc) => + (doc.attachments[0].data = Buffer.from("foreign").toString("base64")), + "noncanonical source base64": (doc) => (doc.attachments[0].data += "\n"), + "wrong image mime": (doc) => (doc.attachments[0].mediaType = "image/jpeg"), + "missing path": (doc) => (doc.pathReferences = []), + "foreign path": (doc) => (doc.pathReferences[0].path += ".other"), + "foreign path name": (doc) => (doc.pathReferences[0].name = "other.md"), + "directory in place of file": (doc) => (doc.pathReferences[0].isDir = true), + "missing Skill": (doc) => (doc.mentionBindings = []), + "foreign Skill": (doc) => (doc.mentionBindings[0].path += ".other"), + "wrong Skill route": (doc) => + (doc.inputCapabilityRoute.skillKey = "other-skill"), + "missing hidden paste": (doc) => (doc.pendingPastes = []), + "hidden paste drift": (doc) => (doc.pendingPastes[0].text += "drift"), + "paste placeholder drift": (doc) => + (doc.pendingPastes[0].placeholder = "other-placeholder"), + "native display drift": (doc) => (doc.text += "drift"), +}; +for (const [name, mutate] of Object.entries(sourceMutations)) { + test("rejects captured " + name, () => { + const value = fixture(); + mutate(value.entry.draft.document); + assert.throws(() => captureFirstSendResources(value.entry, value.expected)); + }); +} + +const receiptMutations = { + "canonical image omission": (value) => + value.oracle.canonical.thread.turns[0].items[0].content.pop(), + "canonical Skill omission": (value) => + value.oracle.canonical.thread.turns[0].items[0].content.splice(1, 1), + "canonical path omission": (value) => + value.oracle.canonical.thread.turns[0].items[0].content.splice(2, 1), + "foreign canonical Skill": (value) => + (value.oracle.canonical.thread.turns[0].items[0].content[1].path += + ".other"), + "foreign canonical path": (value) => + (value.oracle.canonical.thread.turns[0].items[0].content[2].path += + ".other"), + "foreign canonical Thread": (value) => + (value.oracle.canonical.thread.id = "foreign-thread"), + "foreign canonical User Item": (value) => + (value.oracle.canonical.thread.turns[0].items[0].id = "foreign-item"), + "missing observed media IPC": (value) => + delete value.oracle.mediaRead.ipcObservation, + "foreign media read Thread": (value) => + (value.oracle.mediaRead.result.threadId = "foreign-thread"), + "foreign actual media bytes": (value) => + (value.oracle.mediaRead.result.contentBase64 = + Buffer.from("foreign").toString("base64")), + "foreign response SHA": (value) => + (value.oracle.mediaRead.result.sha256 = "sha256:" + "0".repeat(64)), + "noncanonical response base64": (value) => + (value.oracle.mediaRead.result.contentBase64 += "\n"), + "partial image read": (value) => + (value.oracle.mediaRead.result.hasMore = true), + "different source text SHA": (value) => + (value.source.resources.expandedText.sha256 = "0".repeat(64)), + "different source text-element SHA": (value) => + (value.source.resources.expandedText.textElementsSha256 = "0".repeat(64)), +}; +for (const [name, mutate] of Object.entries(receiptMutations)) { + test("rejects receipt " + name, () => { + const value = fixture(); + mutate(value); + assert.throws(() => verify(value)); + }); +} + +for (const mode of [ + "hidden-paste", + "code-space", + "code-lf", + "link-href", + "link-title", +]) { + test("rejects self-consistent wire/canonical " + mode + " drift", () => { + const value = fixture(); + const text = value.request.params.input[0]; + if (mode === "hidden-paste") + text.text = text.text.replace(value.expected.pasteText, "short paste"); + if (mode === "code-space") + text.text = text.text.replace(" const value", "const value"); + if (mode === "code-lf") + text.text = text.text.replace("````\n\n\n`````", "````\n`````"); + if (mode === "link-href") + text.text = text.text.replace( + "https://example.invalid/marks", + "https://example.invalid/other", + ); + if (mode === "link-title") text.text = text.text.replace(' "标题"', ""); + assert.notEqual(text.text, value.oracle.expectedSubmission.text); + value.oracle.canonical.thread.turns[0].items[0].content[0] = + structuredClone(text); + assert.throws(() => verify(value)); + }); +} diff --git a/scripts/electron/lib/composer-home-draft-fixture-evidence.mjs b/scripts/electron/lib/composer-home-draft-fixture-evidence.mjs new file mode 100644 index 000000000..a1aa077cd --- /dev/null +++ b/scripts/electron/lib/composer-home-draft-fixture-evidence.mjs @@ -0,0 +1,280 @@ +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + atomKey, + assertWorkspaceIndex, + requireDraftProof, + savedDraft, + sha256, +} from "./composer-draft-fixture-evidence.mjs"; + +const inputMethods = new Set([ + "thread/start", + "turn/start", + "turn/steer", + "thread/queue/add", + "thread/queue/update", + "thread/queue/start", +]); + +/** Only public visible DOM; no router, React, Controller or PM state. */ +export function readHomeDraftSurfaceDom() { + const visible = (element) => + element instanceof HTMLElement && + element.isConnected && + element.getClientRects().length > 0 && + window.getComputedStyle(element).display !== "none" && + !["hidden", "collapse"].includes( + window.getComputedStyle(element).visibility, + ); + const editors = Array.from( + document.querySelectorAll( + '[data-testid="composer-editor"][role="textbox"]', + ), + ).filter(visible); + const editor = editors.length === 1 ? editors[0] : null; + return { + electron: window.__LIME_ELECTRON__ === true, + preload: typeof window.electronAPI?.invoke === "function", + homeButtonCount: Array.from( + document.querySelectorAll('[data-testid="app-sidebar-home-button"]'), + ).filter(visible).length, + editorCount: editors.length, + sessionId: editor?.dataset.sessionId ?? null, + editorText: editor?.textContent ?? null, + disabled: + !editor || + editor.getAttribute("aria-disabled") === "true" || + editor.getAttribute("contenteditable") !== "true", + }; +} + +export function assertHomeDraftSurface(snapshot) { + requireDraftProof( + snapshot?.electron === true && + snapshot.preload === true && + snapshot.homeButtonCount === 1 && + snapshot.editorCount === 1 && + snapshot.sessionId === null && + snapshot.disabled === false && + typeof snapshot.editorText === "string", + "public Home has one real unmaterialized editable Composer", + ); + return { editorCount: 1, sessionId: null, homeButtonCount: 1 }; +} + +function assertHomeScope(scope, workspaceId) { + requireDraftProof( + (workspaceId === null || + (typeof workspaceId === "string" && workspaceId.trim().length > 0)) && + isDeepStrictEqual(scope, { + workspaceId, + kind: "home", + id: "new-conversation", + }), + "actual saved scope exactly matches the public Home workspace, including null", + ); +} + +function assertIndex(index, workspaceId, scope, entry) { + assertHomeScope(scope, workspaceId); + assertWorkspaceIndex(index, workspaceId, scope, entry); + requireDraftProof( + Number.isSafeInteger(index.storeRevision) && + index.storeRevision >= 0 && + index.persistence?.state === "saved" && + index.persistence.savedRevision >= index.storeRevision && + typeof index.data.items[0].updatedAt === "number" && + Number.isSafeInteger(index.data.items[0].updatedAt) && + index.data.items[0].updatedAt >= 0 && + index.data.items[0].sessionId == null && + index.data.items[0].threadId == null, + "saved home index is unmaterialized metadata with acknowledged revision", + ); +} + +/** Scope comes from the native index after a unique public GUI edit. */ +export function assertHomeDraftDiscovery({ + index, + scopeRead, + workspaceId, + probe, + surface, +}) { + assertHomeDraftSurface(surface); + requireDraftProof( + typeof probe === "string" && + probe.length > 0 && + surface.editorText === probe && + index?.data?.items?.length === 1, + "one actual native index item corresponds to the unique visible probe", + ); + const scope = index.data.items[0].scope; + assertHomeScope(scope, workspaceId); + const entry = savedDraft(scopeRead, scope); + assertIndex(index, workspaceId, scope, entry); + requireDraftProof( + scopeRead.storeRevision === index.storeRevision && + entry.sessionId == null && + entry.threadId == null && + entry.draft?.format === "composer-draft" && + entry.draft.version === 1 && + entry.draft.document?.text?.trim() === probe, + "native home scope payload and index identity match public probe exactly", + ); + return { + scope, + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + storeRevision: index.storeRevision, + payloadSha256: sha256(entry.draft), + }; +} + +export function assertHomeIndexUnchanged(before, after, scope, original) { + assertIndex(before, scope.workspaceId, scope, original); + assertIndex(after, scope.workspaceId, scope, original); + requireDraftProof( + before.storeRevision === after.storeRevision && + isDeepStrictEqual(before.data.items, after.data.items), + "home index reads preserve exact scope, identity, metadata and revision", + ); + return { + scope, + storeRevision: after.storeRevision, + itemsSha256: sha256(after.data.items), + }; +} + +export function assertEmptyHomeCanonicalInventory(result) { + requireDraftProof( + Array.isArray(result?.data) && + result.data.length === 0 && + Object.hasOwn(result, "nextCursor") && + result.nextCursor === null, + "complete public thread/list inventory remains empty before submission", + ); + return { threadCount: 0, turnCount: 0, paginationComplete: true }; +} + +export function assertHomeDraftTrace(trace, editing = false) { + // This ledger observes GUI safeInvoke diagnostics. The workspace helper uses + // preload directly; its returned identity is checked separately by the scenario. + requireDraftProof( + Array.isArray(trace?.atomOperations) && + trace.atomOperations.every( + (operation) => + operation.transport === "electron-ipc" && + operation.status === "success", + ) && + (editing + ? trace.atomOperations.some((operation) => operation.op === "put") + : trace.atomOperations.length === 0) && + Array.isArray(trace.appServerRequestMethods) && + trace.appServerRequestMethods.includes("thread/list") && + trace.appServerRequestMethods.every( + (method) => typeof method === "string" && !inputMethods.has(method), + ) && + Number.isSafeInteger(trace.appServerInvokeCount) && + trace.appServerInvokeCount > 0 && + trace.provenance?.mode === "continuous-storage-append" && + typeof trace.provenance.runId === "string" && + trace.provenance.runId.length > 0, + editing + ? "real GUI owns native home put without canonical materialization or input" + : "cold Home and native index reads perform no atom update or input request", + ); + return { + atomOperationCount: trace.atomOperations.length, + inputRequestCount: 0, + traceProvenance: trace.provenance, + }; +} + +export function assertHomeColdHydration({ + beforeRevision, + indexBeforeRevision, + indexAfterRevision, + restoredRevision, + trace, +}) { + requireDraftProof( + Number.isSafeInteger(beforeRevision) && + beforeRevision >= 0 && + indexBeforeRevision === beforeRevision && + indexAfterRevision === beforeRevision && + restoredRevision === beforeRevision, + "cold Home reads and rich restoration retain original global revision", + ); + return { + storeRevision: beforeRevision, + ...assertHomeDraftTrace(trace), + }; +} + +/** Main owner already parsed actual bytes and enforced the native file budget. */ +export function assertHomeSavedEntry(envelope, scope, original) { + assertHomeScope(scope, scope?.workspaceId); + const atom = envelope?.atoms?.[atomKey]; + const bindings = Object.entries(atom?.bindings ?? {}).filter( + ([, id]) => id === original?.draftId, + ); + requireDraftProof( + Number.isSafeInteger(envelope?.storeRevision) && + envelope.storeRevision >= 0 && + original?.state === "draft" && + original.sessionId == null && + original.threadId == null && + bindings.length === 1 && + bindings[0][0] === + JSON.stringify([scope.workspaceId, scope.kind, scope.id]) && + isDeepStrictEqual(atom.entries?.[original.draftId], original), + "actual saved bytes retain exact unmaterialized home entry and one binding", + ); + return { + draftId: original.draftId, + scopeVersion: original.scopeVersion, + storeRevision: envelope.storeRevision, + scope, + payloadSha256: sha256(original.draft), + }; +} + +export function assertHomeSavedFiles(receipts, scope, original, revision) { + const names = ["host-state.json", "host-state.json.bak"]; + requireDraftProof( + Array.isArray(receipts) && + receipts.length === names.length && + receipts.every( + (receipt, index) => + typeof receipt.path === "string" && + path.basename(receipt.path) === names[index] && + /^[a-f0-9]{64}$/.test(receipt.sha256 ?? "") && + receipt.validatedBy === "persistedAtomRecords.parseEnvelope" && + receipt.storeRevision === revision && + receipt.entryEvidence?.storeRevision === revision && + receipt.entryEvidence.draftId === original.draftId && + receipt.entryEvidence.scopeVersion === original.scopeVersion && + isDeepStrictEqual(receipt.entryEvidence.scope, scope) && + receipt.entryEvidence.payloadSha256 === sha256(original.draft), + ) && + receipts[0].sha256 === receipts[1].sha256, + "both real parsed primary and backup files ran the full home-entry callback", + ); + return receipts; +} + +export function assertHomeSavedBytesUnchanged(before, after) { + requireDraftProof( + Array.isArray(before) && + before.length === 2 && + isDeepStrictEqual(after, before), + "cold Home, index reads and normal quits preserve exact saved file receipts", + ); + return { + files: after.map(({ sha256, storeRevision }) => ({ + sha256, + storeRevision, + })), + }; +} diff --git a/scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs b/scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs new file mode 100644 index 000000000..66a1409ea --- /dev/null +++ b/scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs @@ -0,0 +1,497 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertEmptyHomeCanonicalInventory, + assertHomeColdHydration, + assertHomeDraftDiscovery, + assertHomeDraftSurface, + assertHomeDraftTrace, + assertHomeIndexUnchanged, + assertHomeSavedBytesUnchanged, + assertHomeSavedEntry, + assertHomeSavedFiles, +} from "./composer-home-draft-fixture-evidence.mjs"; +import { + atomKey, + assertRestartLifecycle, +} from "./composer-draft-fixture-evidence.mjs"; + +const scope = { + workspaceId: null, + kind: "home", + id: "new-conversation", +}; +const probe = "首页唯一公开探针"; +const entry = { + state: "draft", + draftId: "actual-home-draft", + scopeVersion: "opaque-version-a", + originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]), + updatedAt: 123, + draft: { + format: "composer-draft", + version: 1, + document: { + text: probe, + editorDocument: { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: probe }] }, + ], + }, + selectionStart: 1, + selectionEnd: 4, + attachments: [{ mediaType: "image/png", data: "durable-png" }], + pendingPastes: [{ text: "full private paste" }], + pathReferences: [{ path: "/isolated/file.md", isDir: false }], + }, + }, +}; +const clone = (value) => structuredClone(value); +function surface() { + return { + electron: true, + preload: true, + homeButtonCount: 1, + editorCount: 1, + sessionId: null, + editorText: probe, + disabled: false, + }; +} +function index() { + return { + key: atomKey, + outcome: "ready", + storeRevision: 7, + persistence: { state: "saved", savedRevision: 7 }, + data: { + queryKind: "workspace-index", + workspaceId: scope.workspaceId, + items: [ + { + scope, + identity: { + draftId: entry.draftId, + scopeVersion: entry.scopeVersion, + }, + updatedAt: entry.updatedAt, + }, + ], + }, + }; +} +function scopeRead() { + return { + key: atomKey, + outcome: "ready", + storeRevision: 7, + persistence: { state: "saved", savedRevision: 7 }, + data: { queryKind: "scope", view: { scope, entry } }, + }; +} +function discovery() { + return clone({ + index: index(), + scopeRead: scopeRead(), + workspaceId: scope.workspaceId, + probe, + surface: surface(), + }); +} +function trace() { + return { + atomOperations: [], + appServerRequestMethods: ["workspace/default/ensure", "thread/list"], + appServerInvokeCount: 2, + provenance: { + mode: "continuous-storage-append", + runId: "real-native-observer", + }, + }; +} +function hydration() { + return { + beforeRevision: 7, + indexBeforeRevision: 7, + indexAfterRevision: 7, + restoredRevision: 7, + trace: trace(), + }; +} +function envelope() { + return clone({ + storeRevision: 7, + atoms: { + [atomKey]: { + bindings: { [entry.originScopeKey]: entry.draftId }, + entries: { [entry.draftId]: entry }, + }, + }, + }); +} +function files() { + return ["host-state.json", "host-state.json.bak"].map((name) => ({ + path: `/isolated/${name}`, + sha256: "a".repeat(64), + storeRevision: 7, + validatedBy: "persistedAtomRecords.parseEnvelope", + entryEvidence: assertHomeSavedEntry(envelope(), scope, entry), + })); +} + +test("public Home keeps explicit null scope instead of the ensured default workspace", () => { + const value = discovery(); + assert.equal(assertHomeDraftDiscovery(value).scope.workspaceId, null); + value.workspaceId = "default-real-workspace"; + assert.throws(() => assertHomeDraftDiscovery(value)); +}); + +test("public GUI probe is associated with actual current home scope and native identity", () => { + assert.equal(assertHomeDraftDiscovery(discovery()).draftId, entry.draftId); + assert.deepEqual(assertHomeDraftSurface(surface()), { + editorCount: 1, + sessionId: null, + homeButtonCount: 1, + }); +}); + +for (const [name, mutate] of [ + [ + "browser mirror", + (v) => { + v.surface.electron = false; + }, + ], + [ + "missing preload", + (v) => { + v.surface.preload = false; + }, + ], + [ + "duplicate Home buttons", + (v) => { + v.surface.homeButtonCount = 2; + }, + ], + [ + "duplicate editors", + (v) => { + v.surface.editorCount = 2; + }, + ], + [ + "disabled editor", + (v) => { + v.surface.disabled = true; + }, + ], + [ + "materialized editor", + (v) => { + v.surface.sessionId = "canonical-session"; + }, + ], + [ + "foreign visible probe", + (v) => { + v.surface.editorText = "other"; + }, + ], + [ + "ambiguous index", + (v) => { + v.index.data.items.push(clone(v.index.data.items[0])); + }, + ], + [ + "random draftTab scope", + (v) => { + v.index.data.items[0].scope.kind = "draft"; + v.index.data.items[0].scope.id = "task-draft-other"; + }, + ], + [ + "foreign workspace", + (v) => { + v.workspaceId = "other-default"; + }, + ], + [ + "index and payload identity mismatch", + (v) => { + v.index.data.items[0].identity.draftId = "other"; + }, + ], + [ + "different index revision", + (v) => { + v.index.storeRevision = 6; + }, + ], + [ + "unsaved index", + (v) => { + v.index.persistence.state = "pending"; + }, + ], + [ + "unacknowledged index", + (v) => { + v.index.persistence.savedRevision = 6; + }, + ], + [ + "materialized index metadata", + (v) => { + v.index.data.items[0].sessionId = "new-session"; + }, + ], + [ + "materialized scope entry", + (v) => { + v.scopeRead.data.view.entry.threadId = "new-thread"; + }, + ], + [ + "different saved probe", + (v) => { + v.scopeRead.data.view.entry.draft.document.text = "other"; + }, + ], +]) { + test(`discovery rejects ${name}`, () => { + const value = discovery(); + mutate(value); + assert.throws(() => assertHomeDraftDiscovery(value)); + }); +} + +test("same-atom home index survives reads with exact metadata", () => { + assert.equal( + assertHomeIndexUnchanged(index(), clone(index()), scope, entry) + .storeRevision, + 7, + ); + for (const mutate of [ + (v) => { + v.storeRevision = 8; + v.persistence.savedRevision = 8; + }, + (v) => { + v.data.items[0].updatedAt = 124; + }, + (v) => { + v.data.items[0].identity.scopeVersion = "new-version"; + }, + (v) => { + v.data.items = []; + }, + ]) { + const value = clone(index()); + mutate(value); + assert.throws(() => assertHomeIndexUnchanged(index(), value, scope, entry)); + } +}); + +test("empty canonical inventory proves no thread or Turn and complete pagination", () => { + assert.equal( + assertEmptyHomeCanonicalInventory({ data: [], nextCursor: null }).turnCount, + 0, + ); + for (const result of [ + { data: [] }, + { data: [], nextCursor: "more" }, + { data: [{ id: "new-thread", turns: [] }], nextCursor: null }, + { items: [], nextCursor: null }, + ]) + assert.throws(() => assertEmptyHomeCanonicalInventory(result)); +}); + +test("cold restoration requires unchanged revision, native trace and no atom mutation", () => { + assert.equal(assertHomeColdHydration(hydration()).atomOperationCount, 0); + for (const field of [ + "beforeRevision", + "indexBeforeRevision", + "indexAfterRevision", + "restoredRevision", + ]) { + const value = hydration(); + value[field] += 1; + assert.throws(() => assertHomeColdHydration(value)); + } +}); + +for (const method of [ + "thread/start", + "turn/start", + "turn/steer", + "thread/queue/add", + "thread/queue/update", + "thread/queue/start", +]) { + test(`home cold trace rejects ${method}`, () => { + const value = trace(); + value.appServerRequestMethods.push(method); + assert.throws(() => assertHomeDraftTrace(value)); + }); +} + +test("native edit put is required and cannot substitute for mutation-free cold restore", () => { + const value = trace(); + assert.throws(() => assertHomeDraftTrace(value, true)); + value.atomOperations.push({ + op: "put", + status: "success", + transport: "electron-ipc", + }); + assert.equal(assertHomeDraftTrace(value, true).atomOperationCount, 1); + assert.throws(() => assertHomeDraftTrace(value)); + value.atomOperations[0].transport = "mock"; + assert.throws(() => assertHomeDraftTrace(value, true)); +}); + +test("GUI trace accepts native reads without the direct-preload workspace helper", () => { + const value = trace(); + value.appServerRequestMethods = ["workspace/ensureReady", "thread/list"]; + assert.equal(assertHomeDraftTrace(value).atomOperationCount, 0); + value.atomOperations.push({ + op: "put", + status: "success", + transport: "electron-ipc", + }); + assert.equal(assertHomeDraftTrace(value, true).atomOperationCount, 1); +}); + +test("incomplete trace or absent current App Server read cannot pass", () => { + for (const mutate of [ + (v) => { + delete v.atomOperations; + }, + (v) => { + v.appServerRequestMethods = ["workspace/default/ensure"]; + }, + (v) => { + v.appServerInvokeCount = 0; + }, + (v) => { + v.provenance.mode = "ring-buffer"; + }, + (v) => { + v.provenance.runId = ""; + }, + ]) { + const value = trace(); + mutate(value); + assert.throws(() => assertHomeDraftTrace(value)); + } +}); + +test("actual parsed saved envelope preserves full PM JSON, selection and resources", () => { + assert.equal( + assertHomeSavedEntry(envelope(), scope, entry).scopeVersion, + entry.scopeVersion, + ); + for (const mutate of [ + (v) => { + v.atoms[atomKey].entries[ + entry.draftId + ].draft.document.editorDocument.content = []; + }, + (v) => { + v.atoms[atomKey].entries[entry.draftId].draft.document.selectionStart = 0; + }, + (v) => { + v.atoms[atomKey].entries[ + entry.draftId + ].draft.document.attachments[0].data = "truncated"; + }, + (v) => { + v.atoms[atomKey].entries[entry.draftId].draft.document.pendingPastes = []; + }, + (v) => { + v.atoms[atomKey].entries[ + entry.draftId + ].draft.document.pathReferences[0].path = "/foreign.md"; + }, + (v) => { + v.atoms[atomKey].bindings.other = entry.draftId; + }, + (v) => { + v.atoms[atomKey].bindings = {}; + }, + (v) => { + v.atoms[atomKey].entries[entry.draftId].state = "consumed"; + }, + ]) { + const value = envelope(); + mutate(value); + assert.throws(() => assertHomeSavedEntry(value, scope, entry)); + } +}); + +test("both native parsed files need exact entry evidence and unchanged raw bytes", () => { + const before = files(); + assert.equal(assertHomeSavedFiles(before, scope, entry, 7).length, 2); + assert.equal( + assertHomeSavedBytesUnchanged(before, clone(before)).files.length, + 2, + ); + for (const mutate of [ + (v) => { + v[1].validatedBy = "fixture-parser"; + }, + (v) => { + delete v[1].entryEvidence; + }, + (v) => { + v[1].entryEvidence.scope.workspaceId = "foreign"; + }, + (v) => { + v[1].entryEvidence.payloadSha256 = "changed"; + }, + (v) => { + v[1].sha256 = "b".repeat(64); + }, + (v) => { + v[1].path = v[0].path; + }, + ]) { + const value = clone(before); + mutate(value); + assert.throws(() => assertHomeSavedFiles(value, scope, entry, 7)); + assert.throws(() => assertHomeSavedBytesUnchanged(before, value)); + } +}); + +test("two generations need distinct real Main PIDs and the same normally exited profile", () => { + const entries = [1, 2].map((generation) => ({ + generation, + pid: generation + 100, + profile: "/isolated/profile", + mode: "normal", + exitCode: 0, + signal: null, + exited: true, + })); + assertRestartLifecycle(entries, "/isolated/profile", 2); + for (const mutate of [ + (v) => { + v[1].pid = v[0].pid; + }, + (v) => { + v[1].profile = "/other/profile"; + }, + (v) => { + v[1].signal = "SIGKILL"; + }, + (v) => { + v[1].exited = false; + }, + ]) { + const value = clone(entries); + mutate(value); + assert.throws(() => assertRestartLifecycle(value, "/isolated/profile", 2)); + } +}); diff --git a/scripts/electron/lib/composer-home-draft-fixture-scenario.mjs b/scripts/electron/lib/composer-home-draft-fixture-scenario.mjs new file mode 100644 index 000000000..8dc55292a --- /dev/null +++ b/scripts/electron/lib/composer-home-draft-fixture-scenario.mjs @@ -0,0 +1,288 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Buffer } from "node:buffer"; +import { + atomKey, + assertWorkspaceIndex, + requireDraftProof, + summarizeDraft, +} from "./composer-draft-fixture-evidence.mjs"; +import { + assertEmptyHomeCanonicalInventory, + assertHomeColdHydration, + assertHomeDraftDiscovery, + assertHomeDraftSurface, + assertHomeDraftTrace, + assertHomeIndexUnchanged, + assertHomeSavedBytesUnchanged, + assertHomeSavedEntry, + assertHomeSavedFiles, + readHomeDraftSurfaceDom, +} from "./composer-home-draft-fixture-evidence.mjs"; + +const marker = "首页真实冷恢复草稿一号"; + +async function readIndex(context, workspaceId) { + const observed = await context.modules.atom.observePersistedAtomCommand( + context.page, + context.protocol, + "persisted_atom_read", + { key: atomKey, query: { kind: "workspace-index", workspaceId } }, + { timeoutMs: context.options.timeoutMs }, + ); + return observed.result; +} + +async function openHome(context) { + const home = context.page + .getByTestId("app-sidebar-home-button") + .filter({ visible: true }); + requireDraftProof((await home.count()) === 1, "one public Home button"); + await home.click(); + const snapshot = await context.modules.drafts.until( + context, + () => context.page.evaluate(readHomeDraftSurfaceDom), + (value) => { + try { + assertHomeDraftSurface(value); + return true; + } catch { + return false; + } + }, + "public Home click commits one editable null-session Composer", + ); + return assertHomeDraftSurface(snapshot); +} + +async function readEmptyInventory(context) { + const observed = await context.modules.rpc.invokeAppServerFromPage( + context.page, + "thread/list", + { limit: 100 }, + ); + return assertEmptyHomeCanonicalInventory(observed.result); +} + +async function discoverEditedScope(context, workspaceId, probe) { + return context.modules.drafts.until( + context, + async () => { + const index = await readIndex(context, workspaceId); + if (index.data?.items?.length !== 1) return null; + const scope = index.data.items[0].scope; + const scopeRead = await context.modules.drafts.readScope(context, scope); + const surface = await context.page.evaluate(readHomeDraftSurfaceDom); + try { + return assertHomeDraftDiscovery({ + index, + scopeRead, + workspaceId, + probe, + surface, + }); + } catch { + return null; + } + }, + (value) => value !== null, + "public GUI probe uniquely identifies native default-workspace home scope", + ); +} + +export const homeDraftColdScenario = { + id: "home-draft-cold", + scope: + "stable null-workspace home/new-conversation draft, restored through public Home after normal process restart", + generationCount: 2, + sourcePaths: [ + "scripts/electron/lib/composer-home-draft-fixture-scenario.mjs", + "scripts/electron/lib/composer-home-draft-fixture-evidence.mjs", + "scripts/electron/lib/composer-home-draft-fixture-evidence.test.mjs", + ], + limitations: [ + "Only the public Home null-workspace scope is covered; project New and first submission are separate slices.", + "No all-draft sidebar picker or TaskCenterTabStrip is introduced or exercised.", + "First submission, materialization, queue and GUI Stop are separate slices; this draft stays unmaterialized.", + "Synthetic clipboard/drop events use production GUI ingress; OS clipboard and OS drag are not covered.", + "Saved is awaited before public normal quit; abrupt exit with pending save is not covered.", + "Invoke observation begins after each launcher returns; unchanged full bytes/revision and empty canonical inventory additionally cover committed startup mutation.", + "External backend proves current App Server/read-model transport, not production model execution or live provider.", + ], + async run(fixture) { + const { options, runtimeEnv, recordStage, setPhase } = fixture; + let context = await fixture.launch(1); + setPhase("public-home-with-explicit-null-workspace"); + const workspace = await context.modules.rpc.ensureDefaultWorkspace( + context.page, + ); + recordStage("generation-1-public-home", await openHome(context)); + recordStage( + "generation-1-no-canonical-thread", + await readEmptyInventory(context), + ); + recordStage("generation-1-default-workspace-fact", workspace); + const empty = await readIndex(context, null); + requireDraftProof( + empty.outcome === "ready" && + empty.key === atomKey && + empty.data?.queryKind === "workspace-index" && + empty.data.workspaceId === null && + empty.data.items.length === 0, + "fresh isolated public Home null workspace has no persisted draft index item", + ); + const probe = `首页草稿范围探针 ${options.runId}`; + await context.modules.dom.fillComposerEditor(context.page, probe); + const discovered = await discoverEditedScope(context, null, probe); + recordStage("public-edit-native-scope-identity-discovery", discovered); + const rootPath = path.join(runtimeEnv.tempRoot, "home-draft-resources"); + await fs.mkdir(rootPath); + const filePath = path.join(rootPath, "composer-home-draft.md"); + const bytes = Buffer.alloc(128, " "); + bytes.write(`# Current Home draft\n${marker}\n`, "utf8"); + await fs.writeFile(filePath, bytes); + const record = { + workspaceId: discovered.scope.workspaceId, + scope: discovered.scope, + sessionId: null, + marker, + rootPath, + path: filePath, + pathName: path.basename(filePath), + }; + setPhase("public-rich-home-draft-edit-and-save"); + const original = await context.modules.drafts.prepareVisibleDraft( + context, + record, + ); + recordStage( + "generation-1-rich-home-surface", + assertHomeDraftSurface( + await context.page.evaluate(readHomeDraftSurfaceDom), + ), + ); + const originalIndex = await readIndex(context, record.workspaceId); + recordStage( + "generation-1-same-atom-index", + assertWorkspaceIndex( + originalIndex, + record.workspaceId, + record.scope, + original.entry, + ), + ); + const rereadIndex = await readIndex(context, record.workspaceId); + recordStage( + "generation-1-index-read-is-idempotent", + assertHomeIndexUnchanged( + originalIndex, + rereadIndex, + record.scope, + original.entry, + ), + ); + requireDraftProof( + originalIndex.storeRevision === original.revision, + "rich scope and native index agree on original revision", + ); + recordStage( + "generation-1-unmaterialized-inventory", + await readEmptyInventory(context), + ); + const inspect = async () => + assertHomeSavedFiles( + await fixture.inspectSavedFiles(original.revision, (envelope) => + assertHomeSavedEntry(envelope, record.scope, original.entry), + ), + record.scope, + original.entry, + original.revision, + ); + const savedFiles = await inspect(); + recordStage("generation-1-real-saved-files", savedFiles); + recordStage( + "generation-1-native-edit-trace", + assertHomeDraftTrace(await fixture.stopTrace(), true), + ); + await fixture.quit(); + recordStage( + "generation-1-normal-quit-files-unchanged", + assertHomeSavedBytesUnchanged(savedFiles, await inspect()), + ); + context = await fixture.launch(2); + setPhase("same-profile-new-pid-current-home-cold-restore"); + const secondWorkspace = await context.modules.rpc.ensureDefaultWorkspace( + context.page, + ); + requireDraftProof( + secondWorkspace.workspaceId === workspace.workspaceId, + "same real default workspace identity after cold restart", + ); + const coldIndexBeforeHome = await readIndex(context, record.workspaceId); + recordStage( + "generation-2-pre-home-index-unchanged", + assertHomeIndexUnchanged( + originalIndex, + coldIndexBeforeHome, + record.scope, + original.entry, + ), + ); + recordStage("generation-2-true-public-home-click", await openHome(context)); + const restored = await context.modules.drafts.verifyVisibleDraft( + context, + record, + original, + ); + recordStage( + "generation-2-full-json-dom-resources-selection-restored", + restored, + ); + const coldIndexAfterHome = await readIndex(context, record.workspaceId); + recordStage( + "generation-2-post-home-index-unchanged", + assertHomeIndexUnchanged( + coldIndexBeforeHome, + coldIndexAfterHome, + record.scope, + original.entry, + ), + ); + const secondScopeRead = await context.modules.drafts.readScope( + context, + record.scope, + ); + requireDraftProof( + secondScopeRead.storeRevision === original.revision, + "scope observation keeps original global revision", + ); + recordStage( + "generation-2-no-canonical-thread-or-turn", + await readEmptyInventory(context), + ); + recordStage( + "generation-2-real-saved-files-unchanged", + assertHomeSavedBytesUnchanged(savedFiles, await inspect()), + ); + recordStage( + "generation-2-cold-hydration-no-update", + assertHomeColdHydration({ + beforeRevision: original.revision, + indexBeforeRevision: coldIndexBeforeHome.storeRevision, + indexAfterRevision: coldIndexAfterHome.storeRevision, + restoredRevision: restored.revision, + trace: await fixture.stopTrace(), + }), + ); + await fixture.quit(); + recordStage( + "generation-2-normal-quit-files-unchanged", + assertHomeSavedBytesUnchanged(savedFiles, await inspect()), + ); + return { + guiDraftRestoration: true, + firstSubmissionRoutesVerified: false, + records: [{ ...record, draft: summarizeDraft(original.entry) }], + }; + }, +}; diff --git a/scripts/electron/lib/persisted-atom-fixture-driver.mjs b/scripts/electron/lib/persisted-atom-fixture-driver.mjs new file mode 100644 index 000000000..6148da08a --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture-driver.mjs @@ -0,0 +1,385 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; + +const SHA = /^[a-f0-9]{64}$/; +const POLICY_CASES = [ + "allows existing renderer events and rejects Main persistence notifications", + "does not coerce malformed IPC names or execute their getters", + "rejects forged saved acknowledgements at the Main IPC handler", +]; + +export function assertDriver(condition, phase) { + if (!condition) + throw new Error(`persisted atom Electron driver failed: ${phase}`); +} + +export const hashBytes = (bytes) => + createHash("sha256").update(bytes).digest("hex"); + +export async function assertSourceFile(record, phase) { + assertDriver( + path.isAbsolute(record?.path ?? "") && SHA.test(record?.sha256 ?? ""), + phase, + ); + let bytes; + try { + bytes = await fs.readFile(record.path); + } catch { + assertDriver(false, phase); + } + assertDriver(hashBytes(bytes) === record.sha256, phase); + return bytes; +} + +/** Static source/test evidence supplements native listen; it is never a live spoof test. */ +export async function verifyPersistedAtomEventGuard({ repoRoot, packet }) { + const source = { + path: path.join(repoRoot, "electron/rendererEventPolicy.ts"), + sha256: packet.sourceSha256, + }; + await assertSourceFile(source, "current Main renderer event policy source"); + await assertSourceFile( + { path: packet.testPath, sha256: packet.testSha256 }, + "frozen event policy test source", + ); + const runtimeTestPath = path.join( + repoRoot, + "electron/rendererEventPolicy.test.ts", + ); + await assertSourceFile( + { path: runtimeTestPath, sha256: packet.testSha256 }, + "same formal and frozen event policy test source", + ); + const bytes = await assertSourceFile( + { path: packet.receiptPath, sha256: packet.receiptSha256 }, + "frozen event policy test receipt", + ); + let report; + try { + report = JSON.parse(bytes.toString("utf8")); + } catch { + assertDriver(false, "event policy test JSON receipt"); + } + const testPath = await fs.realpath(packet.testPath); + const suites = []; + for (const suite of report.testResults ?? []) { + try { + if ((await fs.realpath(suite.name)) === testPath) suites.push(suite); + } catch { + /* Unrelated suites are not policy evidence. */ + } + } + assertDriver( + report.numFailedTests === 0 && suites.length === 1, + "unambiguous passing event policy test suite", + ); + const tests = suites[0].assertionResults; + assertDriver( + Array.isArray(tests) && + tests.length === POLICY_CASES.length && + POLICY_CASES.every((title) => + tests.some((test) => test.title === title && test.status === "passed"), + ), + "all required Main IPC authority cases passed", + ); + return { + validated: true, + sourceSha256: source.sha256, + proofClass: "static-main-ipc-policy", + sourcePath: source.path, + testPath, + runtimeTestPath, + testSha256: packet.testSha256, + receiptPath: packet.receiptPath, + receiptSha256: packet.receiptSha256, + passedCases: POLICY_CASES.length, + liveRendererSpoofAttempted: false, + }; +} + +function bounded(promise, timeoutMs, phase) { + let timer; + return Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject(new Error(`persisted atom Electron driver failed: ${phase}`)), + timeoutMs, + ); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Capture the actual child exit from launch; do not infer it from window closure. */ +export function captureMainProcessExit(child) { + assertDriver( + Number.isSafeInteger(child?.pid) && + child.pid > 0 && + child.exitCode === null && + child.signalCode === null, + "live public Main child process", + ); + const pid = child.pid; + let exitRecord = null; + const exitPromise = new Promise((resolve) => { + child.once("exit", (exitCode, signal) => { + exitRecord = { pid, exited: true, exitCode, signal }; + resolve(exitRecord); + }); + }); + return { pid, exitPromise, getExitRecord: () => exitRecord }; +} + +export async function quitMainProcessNormally(handle, timeoutMs) { + assertDriver( + Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= 60_000, + "bounded normal quit timeout", + ); + assertDriver( + handle.exit.getExitRecord() === null, + "Main did not exit before requested normal quit", + ); + // Playwright's public close calls app.quit and releases its Node debugger. + // Exit evidence still requires the actual Main child to return code 0, no signal. + void handle.app.close().catch(() => undefined); + const receipt = await bounded( + handle.exit.exitPromise, + timeoutMs, + "normal Main exit timeout; no kill fallback", + ); + assertDriver( + receipt.exitCode === 0 && receipt.signal === null, + "normal Main exit status", + ); + return { ...receipt, mode: "normal" }; +} + +async function defaultLauncher(repoRoot) { + assertDriver( + path.resolve(process.cwd()) === path.resolve(repoRoot), + "standalone must run with repository cwd", + ); + const module = await import( + pathToFileURL( + path.join(repoRoot, "scripts/electron/mcp-config-fixture-smoke.mjs"), + ).href + ); + return module.launchElectronFixture; +} + +async function defaultFreshnessInspector(repoRoot) { + const module = await import( + pathToFileURL(path.join(repoRoot, "scripts/lib/electron-fixture-build.mjs")) + .href + ); + // Read-only inspection only; never ensureElectronFixtureBuild or rebuild. + return module.inspectElectronFixtureBuildFreshness({ rootDir: repoRoot }); +} + +/** Uses an existing launcher; all business mutations stay in the frozen public IPC fixture. */ +export async function createPersistedAtomElectronDriver({ + repoRoot, + appDataRoot, + ownerBundle, + packet, + launchFixture, + inspectFreshness = defaultFreshnessInspector, + env = process.env, + launchTimeoutMs = 60_000, + quitTimeoutMs = 15_000, +}) { + const root = path.resolve(repoRoot); + assertDriver(path.isAbsolute(appDataRoot), "isolated absolute profile"); + assertDriver( + launchTimeoutMs > 0 && launchTimeoutMs <= 60_000, + "bounded launch timeout", + ); + const roots = ownerBundle.resolveDesktopStorageRoots({ + platform: process.platform, + hostUserData: appDataRoot, + e2eMode: true, + e2eUserDataDir: appDataRoot, + localAppData: env.LOCALAPPDATA, + }); + assertDriver( + roots.appDataRoot === appDataRoot && + roots.agentRoot === path.join(appDataRoot, "app-server"), + "real appDataPaths E2E mapping", + ); + const policy = await verifyPersistedAtomEventGuard({ + repoRoot: root, + packet: packet.eventGuard, + }); + let active = null; + let generation = 0; + const lifecycle = []; + const consoleErrors = []; + const pageErrors = []; + const currentFingerprints = async () => { + for (const [label, record] of Object.entries(ownerBundle.receipt.sources)) + await assertSourceFile(record, `frozen owner source ${label}`); + await assertSourceFile( + { + path: path.join(root, "scripts/lib/electron-fixture-build.mjs"), + sha256: packet.freshnessInspectorSha256, + }, + "current build freshness owner", + ); + const freshness = await inspectFreshness(root); + assertDriver( + freshness.ready === true && freshness.segments?.host?.ready === true, + "current renderer/Host/sidecar build freshness", + ); + const guard = await verifyPersistedAtomEventGuard({ + repoRoot: root, + packet: packet.eventGuard, + }); + const mainPath = path.join(root, "dist-electron/main/main.js"); + const preloadPath = path.join(root, "dist-electron/preload/preload.cjs"); + const [main, preload] = await Promise.all([ + fs.readFile(mainPath), + fs.readFile(preloadPath), + ]); + assertDriver( + main.length > 0 && preload.length > 0, + "current nonempty Host/preload artifacts", + ); + return { + hostSha256: hashBytes(main), + preloadSha256: hashBytes(preload), + eventEmissionGuard: guard, + freshness: { + reason: freshness.reason, + host: freshness.segments.host.reason, + }, + }; + }; + return { + async launch({ + appDataRoot: requestedRoot, + generation: requestedGeneration, + }) { + assertDriver( + active === null && + requestedRoot === appDataRoot && + requestedGeneration === generation + 1, + "same profile and sequential generation after complete exit", + ); + const fingerprints = await currentFingerprints(); + const launcher = launchFixture ?? (await defaultLauncher(root)); + let launched; + try { + launched = await launcher({ + options: { timeoutMs: launchTimeoutMs, intervalMs: 100 }, + runtimeEnv: { + electronUserDataDir: appDataRoot, + appServerDataDir: roots.agentRoot, + env: { + ...env, + LIME_ALLOW_LIVE_PROVIDER_SMOKE: "0", + LIME_REAL_API_TEST: "0", + }, + }, + appServerEnv: env.APP_SERVER_BIN + ? { APP_SERVER_BIN: env.APP_SERVER_BIN } + : {}, + consoleErrors, + pageErrors, + backendMode: "unavailable", + }); + } catch { + throw new Error( + "persisted atom Electron driver failed: existing launcher unavailable", + ); + } + const child = launched.app.process(); + const exit = captureMainProcessExit(child); + generation = requestedGeneration; + active = { + app: launched.app, + page: launched.page, + child, + exit, + identity: { pid: exit.pid, appDataRoot, ...fingerprints }, + closePromise: null, + }; + lifecycle.push({ + phase: "launch", + pid: exit.pid, + generation, + appDataRoot, + }); + return active; + }, + async close(handle) { + assertDriver( + handle === active, + "close only the current owned Main process", + ); + handle.closePromise ??= quitMainProcessNormally(handle, quitTimeoutMs); + const receipt = await handle.closePromise; + lifecycle.push({ phase: "normal-exit", ...receipt }); + active = null; + return receipt; + }, + async inspectFiles({ appDataRoot: requestedRoot }) { + assertDriver( + requestedRoot === appDataRoot, + "inspect isolated profile only", + ); + const receipts = {}; + for (const [kind, name] of [ + ["primary", "host-state.json"], + ["backup", "host-state.json.bak"], + ]) { + const filePath = path.join(appDataRoot, name); + try { + const stat = await fs.stat(filePath); + assertDriver( + stat.isFile() && stat.size <= ownerBundle.limits.maxFileBytes, + "bounded persisted atom file", + ); + const bytes = await fs.readFile(filePath); + const envelope = ownerBundle.parseEnvelope( + JSON.parse(bytes.toString("utf8")), + ownerBundle.limits, + ); + receipts[kind] = { + path: filePath, + validatedBy: "persistedAtomRecords.parseEnvelope", + storeRevision: envelope.storeRevision, + sha256: hashBytes(bytes), + }; + } catch { + throw new Error( + `persisted atom Electron driver failed: invalid saved ${kind} file`, + ); + } + } + return receipts; + }, + getEvidence() { + return { + staticEventAuthorityCompanion: policy, + ownerBundle: ownerBundle.receipt, + appDataRoots: roots, + lifecycle: structuredClone(lifecycle), + outstandingMainPids: + active && active.exit.getExitRecord() === null + ? [active.identity.pid] + : [], + // Existing launcher errors can contain content; report counts only. + consoleErrorCount: consoleErrors.length, + pageErrorCount: pageErrors.length, + backendMode: "unavailable", + productionProviderUsed: false, + normalQuitUses: + "public Electron app.quit + public app.process child exit", + forcedKillFallback: false, + }; + }, + }; +} diff --git a/scripts/electron/lib/persisted-atom-fixture-driver.test.mjs b/scripts/electron/lib/persisted-atom-fixture-driver.test.mjs new file mode 100644 index 000000000..1db9cc48d --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture-driver.test.mjs @@ -0,0 +1,414 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseEnvelope } from "../../../electron/persistedAtomRecords"; +import { PERSISTED_ATOM_LIMITS } from "../../../src/lib/api/persistedAtomProtocol"; +import { + captureMainProcessExit, + createPersistedAtomElectronDriver, + hashBytes, + quitMainProcessNormally, + verifyPersistedAtomEventGuard, +} from "./persisted-atom-fixture-driver.mjs"; + +const ownedTestRoots = new Set(); +async function createTestRoot(prefix) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + ownedTestRoots.add(root); + return root; +} +afterEach(async () => { + for (const root of ownedTestRoots) { + await fs.rm(root, { recursive: true, force: true }); + ownedTestRoots.delete(root); + } +}); +const cases = [ + "allows existing renderer events and rejects Main persistence notifications", + "does not coerce malformed IPC names or execute their getters", + "rejects forged saved acknowledgements at the Main IPC handler", +]; + +async function project({ reportMutator } = {}) { + const repoRoot = await createTestRoot("lime-persisted-atom-driver-unit-"); + const write = async (relative, text) => { + const filePath = path.join(repoRoot, relative); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, text); + return { path: filePath, sha256: hashBytes(Buffer.from(text)) }; + }; + const policy = await write( + "electron/rendererEventPolicy.ts", + "explicit fake policy for unit testing", + ); + const formalTest = await write( + "electron/rendererEventPolicy.test.ts", + "explicit fake test source for unit testing", + ); + const test = await write( + "frozen/rendererEventPolicy.test.ts", + "explicit fake test source for unit testing", + ); + const report = { + numFailedTests: 0, + testResults: [ + { + name: test.path, + assertionResults: cases.map((title) => ({ title, status: "passed" })), + }, + ], + }; + reportMutator?.(report); + const receipt = await write("policy-test.json", JSON.stringify(report)); + const freshness = await write( + "scripts/lib/electron-fixture-build.mjs", + "unit freshness inspector marker", + ); + await write("dist-electron/main/main.js", "unit host artifact marker"); + await write( + "dist-electron/preload/preload.cjs", + "unit preload artifact marker", + ); + const source = await write( + "src/lib/api/persistedAtomProtocol.ts", + "unit frozen source marker", + ); + const appDataRoot = path.join(repoRoot, "profile"); + await fs.mkdir(appDataRoot); + const packet = { + freshnessInspectorSha256: freshness.sha256, + eventGuard: { + sourceSha256: policy.sha256, + testPath: test.path, + testSha256: test.sha256, + receiptPath: receipt.path, + receiptSha256: receipt.sha256, + }, + }; + const parseEnvelope = vi.fn((value) => { + if (value.format !== "electron-persisted-atom-state") + throw new Error("private body rejected"); + return value; + }); + const ownerBundle = { + receipt: { sources: { protocol: source } }, + resolveDesktopStorageRoots: vi.fn(({ e2eUserDataDir }) => ({ + appDataRoot: e2eUserDataDir, + agentRoot: path.join(e2eUserDataDir, "app-server"), + hostSessionData: e2eUserDataDir, + })), + parseEnvelope, + limits: { maxFileBytes: 1000 }, + }; + return { + repoRoot, + appDataRoot, + packet, + ownerBundle, + write, + policy, + test, + formalTest, + receipt, + }; +} + +function fakeMain( + pid, + { exitCode = 0, signal = null, noExit = false, quitRejects = false } = {}, +) { + const child = Object.assign(new EventEmitter(), { + pid, + exitCode: null, + signalCode: null, + kill: vi.fn(), + }); + const quit = vi.fn(() => { + if (!noExit) + setTimeout(() => { + child.exitCode = exitCode; + child.signalCode = signal; + child.emit("exit", exitCode, signal); + }, 1); + }); + const app = { + process: vi.fn(() => child), + close: vi.fn(async () => { + quit(); + if (quitRejects) throw new Error("Target closed private body"); + }), + evaluate: vi.fn(async (callback) => { + callback({ app: { quit } }); + if (quitRejects) throw new Error("Target closed private body"); + }), + }; + return { app, child, quit }; +} + +describe("static event authority companion (unit receipt validation only)", () => { + it("pins current source, test source, report SHA and all Main IPC authority cases", async () => { + const p = await project(); + const guard = await verifyPersistedAtomEventGuard({ + repoRoot: p.repoRoot, + packet: p.packet.eventGuard, + }); + expect(guard).toMatchObject({ + validated: true, + sourceSha256: p.policy.sha256, + proofClass: "static-main-ipc-policy", + passedCases: 3, + liveRendererSpoofAttempted: false, + }); + }); + + it.each(["source", "test", "receipt"])("rejects %s drift", async (fault) => { + const p = await project(); + await fs.appendFile(p[fault === "source" ? "policy" : fault].path, "drift"); + await expect( + verifyPersistedAtomEventGuard({ + repoRoot: p.repoRoot, + packet: p.packet.eventGuard, + }), + ).rejects.toThrow(/driver failed/); + }); + + it("requires the formal test to match the frozen candidate receipt's exact test source", async () => { + const p = await project(); + await fs.appendFile(p.formalTest.path, "drift"); + await expect( + verifyPersistedAtomEventGuard({ + repoRoot: p.repoRoot, + packet: p.packet.eventGuard, + }), + ).rejects.toThrow(/same formal and frozen/); + }); + + it.each(["failed", "missing", "ambiguous", "unrelated"])( + "cannot declare validated for a %s policy receipt", + async (fault) => { + const p = await project({ + reportMutator: (report) => { + if (fault === "failed") + report.testResults[0].assertionResults[2].status = "failed"; + if (fault === "missing") report.testResults[0].assertionResults.pop(); + if (fault === "ambiguous") + report.testResults.push(report.testResults[0]); + if (fault === "unrelated") + report.testResults[0].name = "/unrelated.test.ts"; + }, + }); + await expect( + verifyPersistedAtomEventGuard({ + repoRoot: p.repoRoot, + packet: p.packet.eventGuard, + }), + ).rejects.toThrow(/suite|cases/); + }, + ); +}); + +describe("normal process exit receipt (fake child process, no Electron)", () => { + it("uses public application close to release the debugger before Main exit", async () => { + const { app, child } = fakeMain(505, { noExit: true }); + app.close.mockImplementation(async () => { + child.exitCode = 0; + child.emit("exit", 0, null); + }); + await expect( + quitMainProcessNormally({ app, exit: captureMainProcessExit(child) }, 5), + ).resolves.toMatchObject({ exitCode: 0, signal: null, mode: "normal" }); + expect(app.close).toHaveBeenCalledOnce(); + expect(child.kill).not.toHaveBeenCalled(); + }); + it("captures the public Main exit and tolerates app.quit evaluation closing its connection", async () => { + const { app, child, quit } = fakeMain(501, { quitRejects: true }); + const exit = captureMainProcessExit(child); + await expect(quitMainProcessNormally({ app, exit }, 100)).resolves.toEqual({ + pid: 501, + exited: true, + exitCode: 0, + signal: null, + mode: "normal", + }); + expect(quit).toHaveBeenCalledTimes(1); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it.each([ + { exitCode: 1 }, + { exitCode: null, signal: "SIGKILL" }, + { exitCode: 0, signal: "SIGTERM" }, + ])("rejects a non-normal exit", async (options) => { + const { app, child } = fakeMain(502, options); + await expect( + quitMainProcessNormally( + { app, exit: captureMainProcessExit(child) }, + 100, + ), + ).rejects.toThrow(/normal Main exit status/); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("fails a bounded timeout without killing or inventing a normal exit", async () => { + const { app, child } = fakeMain(503, { noExit: true }); + await expect( + quitMainProcessNormally({ app, exit: captureMainProcessExit(child) }, 2), + ).rejects.toThrow(/timeout; no kill/); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("cannot count an already exited Main as requested normal Quit", async () => { + const { app, child } = fakeMain(504); + const exit = captureMainProcessExit(child); + child.emit("exit", 0, null); + await expect(quitMainProcessNormally({ app, exit }, 100)).rejects.toThrow( + /before requested normal quit/, + ); + expect(app.evaluate).not.toHaveBeenCalled(); + expect(app.close).not.toHaveBeenCalled(); + }); +}); + +describe("real-driver orchestration with explicit fake launcher", () => { + async function configured({ mainOptions, freshness, roots } = {}) { + const p = await project(); + const apps = []; + const launch = vi.fn(async (options) => { + const main = fakeMain(700 + apps.length, mainOptions); + apps.push(main); + options.consoleErrors.push("private draft error"); + return { app: main.app, page: { evaluate: vi.fn() } }; + }); + if (roots) p.ownerBundle.resolveDesktopStorageRoots.mockReturnValue(roots); + const inspect = vi.fn( + async () => + freshness ?? { + ready: true, + reason: "fresh-artifacts", + segments: { host: { ready: true, reason: "fresh-artifacts" } }, + }, + ); + const driver = await createPersistedAtomElectronDriver({ + ...p, + ownerBundle: p.ownerBundle, + packet: p.packet, + launchFixture: launch, + inspectFreshness: inspect, + env: { LOCALAPPDATA: "/local" }, + quitTimeoutMs: 100, + }); + return { ...p, driver, launch, inspect, apps }; + } + + it("reuses the current launcher in isolated unavailable mode and starts generation 2 only after exit", async () => { + const p = await configured(); + const first = await p.driver.launch({ + appDataRoot: p.appDataRoot, + generation: 1, + }); + await expect( + p.driver.launch({ appDataRoot: p.appDataRoot, generation: 2 }), + ).rejects.toThrow(/complete exit/); + const receipt = await p.driver.close(first); + expect(receipt.mode).toBe("normal"); + const second = await p.driver.launch({ + appDataRoot: p.appDataRoot, + generation: 2, + }); + expect(second.identity.pid).not.toBe(first.identity.pid); + expect(second.identity.hostSha256).toBe(first.identity.hostSha256); + await p.driver.close(second); + const args = p.launch.mock.calls[0][0]; + expect(args).toMatchObject({ + backendMode: "unavailable", + runtimeEnv: { + electronUserDataDir: p.appDataRoot, + appServerDataDir: path.join(p.appDataRoot, "app-server"), + env: { LIME_ALLOW_LIVE_PROVIDER_SMOKE: "0", LIME_REAL_API_TEST: "0" }, + }, + }); + expect(args.runtimeEnv.env).not.toHaveProperty("HOME"); + const evidence = p.driver.getEvidence(); + expect(evidence.outstandingMainPids).toEqual([]); + expect(evidence.lifecycle.map((record) => record.phase)).toEqual([ + "launch", + "normal-exit", + "launch", + "normal-exit", + ]); + expect(evidence.consoleErrorCount).toBe(2); + expect(JSON.stringify(evidence)).not.toContain("private draft error"); + expect(p.inspect).toHaveBeenCalledTimes(2); + }); + + it("does not launch stale source artifacts or implicitly build them", async () => { + const p = await configured({ + freshness: { ready: false, segments: { host: { ready: false } } }, + }); + await expect( + p.driver.launch({ appDataRoot: p.appDataRoot, generation: 1 }), + ).rejects.toThrow(/build freshness/); + expect(p.launch).not.toHaveBeenCalled(); + }); + + it("rejects a freshness-owner source drift before launching", async () => { + const p = await configured(); + await p.write("scripts/lib/electron-fixture-build.mjs", "drift"); + await expect( + p.driver.launch({ appDataRoot: p.appDataRoot, generation: 1 }), + ).rejects.toThrow(/freshness owner/); + expect(p.launch).not.toHaveBeenCalled(); + }); + + it("does not read persisted atoms through a second file validator", async () => { + const p = await configured(); + const bytes = JSON.stringify({ + format: "electron-persisted-atom-state", + storeRevision: 9, + privateDraft: "not in receipt", + }); + for (const name of ["host-state.json", "host-state.json.bak"]) + await fs.writeFile(path.join(p.appDataRoot, name), bytes); + const files = await p.driver.inspectFiles({ appDataRoot: p.appDataRoot }); + expect(p.ownerBundle.parseEnvelope).toHaveBeenCalledTimes(2); + expect(files.primary).toMatchObject({ + storeRevision: 9, + validatedBy: "persistedAtomRecords.parseEnvelope", + sha256: hashBytes(Buffer.from(bytes)), + }); + expect(JSON.stringify(files)).not.toContain("not in receipt"); + await expect( + p.driver.inspectFiles({ appDataRoot: "/other" }), + ).rejects.toThrow(/isolated profile/); + }); + + it("validates saved files with the real owner and its declared limits", async () => { + const p = await configured(); + p.ownerBundle.parseEnvelope = parseEnvelope; + p.ownerBundle.limits = PERSISTED_ATOM_LIMITS; + const bytes = JSON.stringify({ + format: "electron-persisted-atom-state", + version: 1, + storeRevision: 9, + atoms: { "composer-drafts-v1": { entries: {}, bindings: {} } }, + }); + for (const name of ["host-state.json", "host-state.json.bak"]) + await fs.writeFile(path.join(p.appDataRoot, name), bytes); + const files = await p.driver.inspectFiles({ appDataRoot: p.appDataRoot }); + expect(files.primary.storeRevision).toBe(9); + expect(files.backup.sha256).toBe(files.primary.sha256); + }); + + it("redacts parse rejection text and cannot produce a file receipt", async () => { + const p = await configured(); + await fs.writeFile( + path.join(p.appDataRoot, "host-state.json"), + JSON.stringify({ privateDraft: "private body rejected" }), + ); + await expect( + p.driver.inspectFiles({ appDataRoot: p.appDataRoot }), + ).rejects.toThrow("invalid saved primary file"); + }); +}); diff --git a/scripts/electron/lib/persisted-atom-fixture-standalone.mjs b/scripts/electron/lib/persisted-atom-fixture-standalone.mjs new file mode 100644 index 000000000..f88ae6e9a --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture-standalone.mjs @@ -0,0 +1,309 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { + assertDriver, + assertSourceFile, + createPersistedAtomElectronDriver, + hashBytes, +} from "./persisted-atom-fixture-driver.mjs"; + +const OWNER_PATHS = { + protocol: "src/lib/api/persistedAtomProtocol.ts", + types: "src/lib/api/persistedAtomTypes.ts", + records: "electron/persistedAtomRecords.ts", + appDataPaths: "electron/appDataPaths.ts", +}; + +/** Compile only the unique pure owners into an isolated diagnostic module. */ +export async function bundlePersistedAtomOwners({ + repoRoot, + workDir, + packet, + build, +}) { + const sources = Object.fromEntries( + Object.entries(OWNER_PATHS).map(([label, relative]) => [ + label, + { + path: path.join(repoRoot, relative), + sha256: packet.ownerSha256[label], + }, + ]), + ); + for (const [label, record] of Object.entries(sources)) + await assertSourceFile(record, `current pure owner ${label}`); + const outfile = path.join(workDir, "persisted-atom-pure-owners.mjs"); + const builder = + build ?? + createRequire(path.join(repoRoot, "package.json"))("esbuild").build; + const exportFrom = (names, source) => + `export { ${names} } from ${JSON.stringify(source)};`; + const contents = [ + exportFrom( + "parsePersistedAtomReadRequest, parsePersistedAtomReadResult, parsePersistedAtomUpdateResult, parsePersistedAtomUpdateEvent, PERSISTED_ATOM_LIMITS", + sources.protocol.path, + ), + exportFrom("parseEnvelope", sources.records.path), + exportFrom("resolveDesktopStorageRoots", sources.appDataPaths.path), + ].join("\n"); + const result = await builder({ + stdin: { + contents, + sourcefile: "persisted-atom-owner-entry.mjs", + resolveDir: repoRoot, + }, + absWorkingDir: repoRoot, + bundle: true, + platform: "node", + format: "esm", + target: "node24", + outfile, + write: true, + metafile: true, + logLevel: "silent", + }); + for (const [label, record] of Object.entries(sources)) + await assertSourceFile(record, `unchanged pure owner ${label}`); + const dependencies = {}; + for (const input of Object.keys(result.metafile.inputs)) { + if (input === "persisted-atom-owner-entry.mjs") continue; + const inputPath = path.resolve(repoRoot, input); + dependencies[inputPath] = hashBytes(await fs.readFile(inputPath)); + } + assertDriver( + Object.keys(dependencies).includes(sources.protocol.path) && + Object.keys(dependencies).includes(sources.records.path) && + Object.keys(dependencies).includes(sources.appDataPaths.path), + "bundle contains the unique current owner inputs", + ); + const bytes = await fs.readFile(outfile); + const module = await import( + `${pathToFileURL(outfile).href}?sha256=${hashBytes(bytes)}` + ); + for (const name of [ + "parsePersistedAtomReadRequest", + "parsePersistedAtomReadResult", + "parsePersistedAtomUpdateResult", + "parsePersistedAtomUpdateEvent", + "parseEnvelope", + "resolveDesktopStorageRoots", + ]) + assertDriver( + typeof module[name] === "function", + "pure owner module exports", + ); + assertDriver( + Number.isSafeInteger(module.PERSISTED_ATOM_LIMITS?.maxFileBytes), + "single owner file budget", + ); + return { + protocol: { + parseReadRequest: module.parsePersistedAtomReadRequest, + parseReadResult: module.parsePersistedAtomReadResult, + parseUpdateResult: module.parsePersistedAtomUpdateResult, + parseUpdateEvent: module.parsePersistedAtomUpdateEvent, + }, + parseEnvelope: module.parseEnvelope, + resolveDesktopStorageRoots: module.resolveDesktopStorageRoots, + limits: module.PERSISTED_ATOM_LIMITS, + receipt: { + sources, + dependencies, + bundlePath: outfile, + bundleSha256: hashBytes(bytes), + compilationScope: + "pure protocol + records + appDataPaths only; no product build", + }, + }; +} + +export function parsePersistedAtomStandaloneArgs(argv, cwd = process.cwd()) { + const options = { + repoRoot: cwd, + evidenceDir: null, + packetPath: null, + runId: null, + timeoutMs: 10_000, + quitTimeoutMs: 15_000, + help: false, + }; + const names = { + "--repo": "repoRoot", + "--evidence-dir": "evidenceDir", + "--verification-packet": "packetPath", + "--run-id": "runId", + "--timeout-ms": "timeoutMs", + "--quit-timeout-ms": "quitTimeoutMs", + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + const field = names[arg]; + assertDriver( + field && argv[index + 1], + "known standalone argument with value", + ); + options[field] = argv[++index]; + } + if (options.help) return options; + assertDriver( + options.evidenceDir && + options.packetPath && + /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? ""), + "explicit evidence directory, verification packet and run id", + ); + for (const name of ["repoRoot", "evidenceDir", "packetPath"]) + options[name] = path.resolve(cwd, options[name]); + for (const name of ["timeoutMs", "quitTimeoutMs"]) { + options[name] = Number(options[name]); + assertDriver( + Number.isSafeInteger(options[name]) && + options[name] > 0 && + options[name] <= 60_000, + "bounded standalone timeout", + ); + } + return options; +} + +export async function runPersistedAtomStandalone( + options, + { build, makeDriver = createPersistedAtomElectronDriver } = {}, +) { + assertDriver( + /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(options.runId ?? "") && + [options.repoRoot, options.evidenceDir, options.packetPath].every( + (value) => typeof value === "string" && path.isAbsolute(value), + ), + "standalone absolute paths and safe run identity", + ); + let phase = "verification-packet"; + let driver; + const runRoot = path.join(options.evidenceDir, options.runId); + await fs.mkdir(options.evidenceDir, { recursive: true }); + // A fresh directory is mandatory; do not overwrite a profile from another run. + await fs.mkdir(runRoot); + const summaryPath = path.join( + runRoot, + "persisted-atom-desktop-persistence.json", + ); + try { + const packetBytes = await fs.readFile(options.packetPath); + const packetSha256 = hashBytes(packetBytes); + const packet = JSON.parse(packetBytes.toString("utf8")); + phase = "current-pure-owners"; + const ownerBundle = await bundlePersistedAtomOwners({ + repoRoot: options.repoRoot, + workDir: runRoot, + packet, + build, + }); + phase = "current-fixture-source"; + const fixtureRecord = { + path: path.join( + options.repoRoot, + "scripts/electron/lib/persisted-atom-fixture.mjs", + ), + sha256: packet.fixtureSha256, + }; + await assertSourceFile(fixtureRecord, "frozen current IPC fixture helper"); + const fixture = await import( + `${pathToFileURL(fixtureRecord.path).href}?sha256=${fixtureRecord.sha256}` + ); + phase = "guarded-driver"; + const appDataRoot = path.join(runRoot, "electron-user-data"); + await fs.mkdir(appDataRoot); + driver = await makeDriver({ + repoRoot: options.repoRoot, + appDataRoot, + ownerBundle, + packet, + quitTimeoutMs: options.quitTimeoutMs, + }); + phase = "real-ipc-and-restart"; + const evidence = await fixture.runPersistedAtomFixture({ + driver, + protocol: ownerBundle.protocol, + appDataRoot, + runId: options.runId, + timeoutMs: options.timeoutMs, + }); + const driverEvidence = driver.getEvidence(); + assertDriver( + driverEvidence.outstandingMainPids.length === 0, + "no Main process remains after successful fixture", + ); + assertDriver( + hashBytes(await fs.readFile(options.packetPath)) === packetSha256, + "verification packet unchanged during the scenario", + ); + const summary = { + ...evidence, + driverEvidence, + verificationPacketPath: options.packetPath, + verificationPacketSha256: packetSha256, + fixtureSource: fixtureRecord, + strongClaimBoundary: + "Desktop Host IPC/CAS/persistence with normal restart; no GUI hydrate or App Server acceptance Gate", + }; + await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2) + "\n"); + return { summaryPath, summary }; + } catch { + await fs.writeFile( + summaryPath, + JSON.stringify( + { + schemaVersion: 1, + result: "fail", + phase, + runtimeGate: false, + guiDraftRestoration: false, + driverEvidence: driver?.getEvidence() ?? null, + verificationPacketPath: options.packetPath, + normalExitMustNotBeReplacedByKill: true, + }, + null, + 2, + ) + "\n", + ); + throw new Error( + `Desktop persistence standalone failed; see ${summaryPath}`, + ); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + try { + const options = parsePersistedAtomStandaloneArgs(process.argv.slice(2)); + if (options.help) { + console.log( + "Usage: node scripts/electron/lib/persisted-atom-fixture-standalone.mjs --run-id --evidence-dir --verification-packet [--timeout-ms 10000] [--quit-timeout-ms 15000]", + ); + } else { + const result = await runPersistedAtomStandalone(options); + console.log( + JSON.stringify({ + result: "pass", + proofLevel: "Desktop persistence", + runtimeGate: false, + summaryPath: result.summaryPath, + }), + ); + } + } catch { + console.error( + "Desktop persistence standalone failed; inspect the content-free evidence receipt.", + ); + process.exitCode = 1; + } +} diff --git a/scripts/electron/lib/persisted-atom-fixture-standalone.test.mjs b/scripts/electron/lib/persisted-atom-fixture-standalone.test.mjs new file mode 100644 index 000000000..5651b8d97 --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture-standalone.test.mjs @@ -0,0 +1,248 @@ +// @vitest-environment node +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { hashBytes } from "./persisted-atom-fixture-driver.mjs"; +import { + bundlePersistedAtomOwners, + parsePersistedAtomStandaloneArgs, + runPersistedAtomStandalone, +} from "./persisted-atom-fixture-standalone.mjs"; + +const ownedTestRoots = new Set(); +async function createTestRoot(prefix) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + ownedTestRoots.add(root); + return root; +} +afterEach(async () => { + for (const root of ownedTestRoots) { + await fs.rm(root, { recursive: true, force: true }); + ownedTestRoots.delete(root); + } +}); +const relativeSources = { + protocol: "src/lib/api/persistedAtomProtocol.ts", + types: "src/lib/api/persistedAtomTypes.ts", + records: "electron/persistedAtomRecords.ts", + appDataPaths: "electron/appDataPaths.ts", +}; + +async function sourceProject() { + const repoRoot = await createTestRoot("lime-persisted-atom-standalone-unit-"); + const ownerSha256 = {}; + for (const [label, relative] of Object.entries(relativeSources)) { + const filePath = path.join(repoRoot, relative); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const content = `explicit fake ${label} owner source; unit orchestration only`; + await fs.writeFile(filePath, content); + ownerSha256[label] = hashBytes(Buffer.from(content)); + } + const packet = { ownerSha256 }; + const build = vi.fn(async ({ outfile }) => { + // This is a fake compiler result, not a copied production validator schema. + await fs.writeFile( + outfile, + ` +export function parsePersistedAtomReadRequest(value) { return value; } +export function parsePersistedAtomReadResult(value) { return value; } +export function parsePersistedAtomUpdateResult(value) { return value; } +export function parsePersistedAtomUpdateEvent(value) { return value; } +export function parseEnvelope(value) { return value; } +export function resolveDesktopStorageRoots() { return {}; } +export const PERSISTED_ATOM_LIMITS = { maxFileBytes: 1000 }; +`, + ); + return { + metafile: { + inputs: Object.fromEntries( + [ + "persisted-atom-owner-entry.mjs", + ...Object.values(relativeSources), + ].map((name) => [name, {}]), + ), + }, + }; + }); + return { repoRoot, workDir: repoRoot, packet, build }; +} + +describe("standalone pure-owner compiler orchestration (fake compiler, no product build)", () => { + it("points esbuild to only the unique current source owners and records their hashes", async () => { + const p = await sourceProject(); + const bundle = await bundlePersistedAtomOwners(p); + const options = p.build.mock.calls[0][0]; + expect(options).toMatchObject({ + bundle: true, + platform: "node", + format: "esm", + target: "node24", + metafile: true, + logLevel: "silent", + }); + for (const label of ["protocol", "records", "appDataPaths"]) + expect(options.stdin.contents).toContain( + JSON.stringify(path.join(p.repoRoot, relativeSources[label])), + ); + expect(options.stdin.contents).not.toContain("PersistedAtomStore"); + expect(options.stdin.contents).toContain("parsePersistedAtomReadRequest"); + expect(typeof bundle.protocol.parseReadRequest).toBe("function"); + expect(bundle.receipt.sources.protocol.sha256).toBe( + p.packet.ownerSha256.protocol, + ); + expect(Object.keys(bundle.receipt.dependencies)).toContain( + path.join(p.repoRoot, relativeSources.protocol), + ); + expect(bundle.receipt.compilationScope).toContain("no product build"); + }); + + it.each(["protocol", "types", "records", "appDataPaths"])( + "rejects %s source drift before compilation", + async (label) => { + const p = await sourceProject(); + await fs.appendFile( + path.join(p.repoRoot, relativeSources[label]), + "drift", + ); + await expect(bundlePersistedAtomOwners(p)).rejects.toThrow( + /current pure owner/, + ); + expect(p.build).not.toHaveBeenCalled(); + }, + ); + + it("rejects source mutation while the isolated compiler is running", async () => { + const p = await sourceProject(); + const original = p.build.getMockImplementation(); + p.build.mockImplementation(async (options) => { + const result = await original(options); + await fs.appendFile( + path.join(p.repoRoot, relativeSources.protocol), + "drift", + ); + return result; + }); + await expect(bundlePersistedAtomOwners(p)).rejects.toThrow( + /unchanged pure owner/, + ); + }); + + it("cannot treat a module built from a different schema input as current owner evidence", async () => { + const p = await sourceProject(); + const original = p.build.getMockImplementation(); + p.build.mockImplementation(async (options) => { + const result = await original(options); + delete result.metafile.inputs[relativeSources.protocol]; + return result; + }); + await expect(bundlePersistedAtomOwners(p)).rejects.toThrow( + /unique current owner inputs/, + ); + }); +}); + +describe("standalone argument and failure boundary", () => { + const argv = [ + "--run-id", + "persistence-test", + "--evidence-dir", + "./evidence", + "--verification-packet", + "./packet.json", + ]; + + it("requires explicit packet/evidence identity and bounded timeouts", () => { + expect(parsePersistedAtomStandaloneArgs(argv, "/repo")).toMatchObject({ + repoRoot: "/repo", + evidenceDir: "/repo/evidence", + packetPath: "/repo/packet.json", + runId: "persistence-test", + timeoutMs: 10000, + quitTimeoutMs: 15000, + }); + expect(parsePersistedAtomStandaloneArgs(["--help"], "/repo").help).toBe( + true, + ); + }); + + it.each( + [ + [], + ["--unknown", "true"], + [...argv, "--timeout-ms", "Infinity"], + [...argv, "--quit-timeout-ms", "60001"], + [...argv, "--run-id", "../other-profile"], + ].map((input) => ({ input })), + )("rejects invalid or unbounded standalone input", ({ input }) => { + expect(() => parsePersistedAtomStandaloneArgs(input, "/repo")).toThrow( + /driver failed/, + ); + }); + + it("fails before launching when the unique current fixture source is unavailable", async () => { + const p = await sourceProject(); + const packetPath = path.join(p.repoRoot, "packet.json"); + await fs.writeFile( + packetPath, + JSON.stringify({ ...p.packet, fixtureSha256: "a".repeat(64) }), + ); + const makeDriver = vi.fn(); + const options = { + repoRoot: p.repoRoot, + evidenceDir: path.join(p.repoRoot, "evidence"), + packetPath, + runId: "missing-fixture", + timeoutMs: 100, + quitTimeoutMs: 100, + }; + await expect( + runPersistedAtomStandalone(options, { build: p.build, makeDriver }), + ).rejects.toThrow(/standalone failed/); + expect(makeDriver).not.toHaveBeenCalled(); + const summary = JSON.parse( + await fs.readFile( + path.join( + options.evidenceDir, + options.runId, + "persisted-atom-desktop-persistence.json", + ), + "utf8", + ), + ); + expect(summary).toMatchObject({ + result: "fail", + phase: "current-fixture-source", + runtimeGate: false, + guiDraftRestoration: false, + normalExitMustNotBeReplacedByKill: true, + }); + }); + + it("does not overwrite a previous run profile", async () => { + const p = await sourceProject(); + const options = { + repoRoot: p.repoRoot, + evidenceDir: path.join(p.repoRoot, "evidence"), + packetPath: path.join(p.repoRoot, "packet.json"), + runId: "existing-run", + }; + await fs.mkdir(path.join(options.evidenceDir, options.runId), { + recursive: true, + }); + await fs.writeFile( + path.join(options.evidenceDir, options.runId, "keep.json"), + "retain", + ); + await expect( + runPersistedAtomStandalone(options, { build: p.build }), + ).rejects.toThrow(); + expect( + await fs.readFile( + path.join(options.evidenceDir, options.runId, "keep.json"), + "utf8", + ), + ).toBe("retain"); + expect(p.build).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/electron/lib/persisted-atom-fixture.mjs b/scripts/electron/lib/persisted-atom-fixture.mjs new file mode 100644 index 000000000..fc97676fc --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture.mjs @@ -0,0 +1,736 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; + +export const PERSISTED_ATOM_FIXTURE_KEY = "composer-drafts-v1"; +export const PERSISTED_ATOM_FIXTURE_COMMANDS = Object.freeze([ + "persisted_atom_read", + "persisted_atom_update", +]); +const EVENT = "persisted-atom-update"; +const SHA256 = /^[a-f0-9]{64}$/; + +class FixtureProofError extends Error {} + +function requireProof(condition, name) { + if (!condition) + throw new FixtureProofError(`persisted atom fixture failed: ${name}`); +} + +function digest(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function parseProtocol(parser, value, name, request) { + try { + const result = parser(value, request); + requireProof(result !== null && result !== undefined, name); + return result; + } catch { + throw new FixtureProofError(`persisted atom fixture failed: ${name}`); + } +} + +function scopeView(result, name) { + requireProof( + result?.outcome === "ready" && result.data?.queryKind === "scope", + name, + ); + return result.data.view; +} + +function expectDraft(result, identity, draft, name) { + const entry = + result.outcome === "ready" ? scopeView(result, name).entry : result.entry; + requireProof( + entry?.state === "draft" && + entry.draftId === identity.draftId && + entry.scopeVersion === identity.scopeVersion && + digest(entry.draft) === digest(draft), + name, + ); +} + +function expectEmpty(result, name) { + requireProof(scopeView(result, name).entry === null, name); +} + +function expectIndex(result, workspaceId, expected, name) { + requireProof( + result?.outcome === "ready" && + result.data?.queryKind === "workspace-index" && + result.data.workspaceId === workspaceId, + name, + ); + const items = result.data.items; + requireProof( + items.length === expected.length && + expected.every(([scope, identity]) => + items.some( + (item) => + digest(item.scope) === digest(scope) && + digest(item.identity) === digest(identity), + ), + ), + name, + ); + return { + workspaceId, + storeRevision: result.storeRevision, + itemCount: items.length, + itemsSha256: digest(items), + }; +} + +function expectOutcome(result, outcome, changed, reason, name) { + requireProof( + result.outcome === outcome && + result.changed === changed && + (reason === undefined || result.reason === reason), + name, + ); +} + +function verifyIdentity(handle, appDataRoot) { + const identity = handle.identity; + requireProof( + Number.isSafeInteger(identity?.pid) && + identity.pid > 0 && + identity.appDataRoot === appDataRoot && + SHA256.test(identity.hostSha256 ?? "") && + SHA256.test(identity.preloadSha256 ?? "") && + identity.eventEmissionGuard?.validated === true && + SHA256.test(identity.eventEmissionGuard.sourceSha256 ?? ""), + "current Host/preload and renderer-emission source guard", + ); + return identity; +} + +/** Only the public preload IPC surface is evaluated; no store or Controller access. */ +export async function observePersistedAtomCommand( + page, + protocol, + command, + args, + { timeoutMs = 10_000 } = {}, +) { + requireProof( + PERSISTED_ATOM_FIXTURE_COMMANDS.includes(command), + "known command", + ); + const request = + command === "persisted_atom_read" + ? parseProtocol( + protocol.parseReadRequest, + args, + "strict read request protocol", + ) + : args; + let observation; + try { + observation = await page.evaluate( + async ({ command: name, args: request, eventName, timeout }) => { + const api = window.electronAPI; + if ( + window.__LIME_ELECTRON__ !== true || + api?.devBridgeFallback !== false || + typeof api.invoke !== "function" || + typeof api.listen !== "function" || + typeof api.supportsCommand !== "function" || + !api.supportsCommand(name) + ) { + throw new Error("native persisted atom preload unavailable"); + } + const events = []; + let resolveEvent; + let expectedRevision; + const eventSeen = new Promise((resolve) => { + resolveEvent = resolve; + }); + const unlisten = api.listen(eventName, (envelope) => { + events.push(envelope); + if (envelope?.payload?.storeRevision === expectedRevision) + resolveEvent(); + }); + if (typeof unlisten !== "function") { + throw new Error("native persisted atom listener unavailable"); + } + let timer; + try { + const result = await api.invoke(name, request); + if (name === "persisted_atom_update" && result?.changed === true) { + expectedRevision = result.storeRevision; + if ( + events.some( + (event) => event?.payload?.storeRevision === expectedRevision, + ) + ) + resolveEvent(); + await Promise.race([ + eventSeen, + new Promise((resolve) => { + timer = setTimeout(resolve, timeout); + }), + ]); + } + return { result, events, transport: "electron-ipc" }; + } finally { + clearTimeout(timer); + unlisten(); + } + }, + { command, args: request, eventName: EVENT, timeout: timeoutMs }, + ); + } catch { + // Transport errors may include request data. Do not echo them into evidence. + throw new Error(`persisted atom fixture transport failed: ${command}`); + } + requireProof( + observation?.transport === "electron-ipc", + "native IPC transport", + ); + const result = parseProtocol( + command === "persisted_atom_read" + ? protocol.parseReadResult + : protocol.parseUpdateResult, + observation.result, + "strict response protocol", + request, + ); + const events = observation.events.map((envelope) => { + requireProof(envelope?.event === EVENT, "native event envelope"); + return parseProtocol( + protocol.parseUpdateEvent, + envelope.payload, + "strict metadata-only event protocol", + ); + }); + if (command === "persisted_atom_update" && result.changed) { + requireProof( + events.some((event) => event.storeRevision === result.storeRevision), + "Main mutation broadcast revision", + ); + } + return { result, events }; +} + +function verifyFiles(files, appDataRoot, revision) { + for (const [kind, filename] of [ + ["primary", "host-state.json"], + ["backup", "host-state.json.bak"], + ]) { + const file = files?.[kind]; + requireProof( + file?.path === path.join(appDataRoot, filename) && + file.validatedBy === "persistedAtomRecords.parseEnvelope" && + file.storeRevision === revision && + SHA256.test(file.sha256 ?? ""), + `saved ${kind} envelope`, + ); + } + requireProof( + files.primary.sha256 === files.backup.sha256, + "same saved snapshot", + ); + return Object.fromEntries( + ["primary", "backup"].map((kind) => { + const { + path: filePath, + storeRevision, + sha256, + validatedBy, + } = files[kind]; + return [kind, { path: filePath, storeRevision, sha256, validatedBy }]; + }), + ); +} + +function verifyExit(receipt, pid) { + requireProof( + receipt?.pid === pid && + receipt.exited === true && + receipt.exitCode === 0 && + receipt.signal === null && + receipt.mode === "normal", + "complete normal Main process exit", + ); +} + +/** + * Driver owns launch/close and isolated-profile file IO. Shared wire parsers and + * the existing file envelope owner are mandatory; fake drivers are unit evidence. + */ +export async function runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + timeoutMs = 10_000, + intervalMs = 50, +}) { + requireProof(path.isAbsolute(appDataRoot), "isolated absolute AppDataRoot"); + requireProof(/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(runId), "run identity"); + requireProof(timeoutMs > 0 && timeoutMs <= 60_000, "bounded timeout"); + requireProof(intervalMs > 0 && intervalMs <= timeoutMs, "bounded polling"); + const scope = (kind, id) => ({ workspaceId: `fixture-${runId}`, kind, id }); + const home = scope("home", "new-conversation"); + const local = scope("local", `session-${runId}`); + const draftScope = scope("draft", `draft-tab-${runId}`); + const otherScope = { ...draftScope, workspaceId: `other-${runId}` }; + const nullScope = { ...home, workspaceId: null }; + const identity = (suffix, version) => ({ + draftId: `draft-${runId}-${suffix}`, + scopeVersion: `version-${suffix}-${version}`, + }); + const a1 = identity("a", 1); + const a2 = identity("a", 2); + const b1 = identity("b", 1); + const c1 = identity("c", 1); + const d1 = identity("d", 1); + const e1 = identity("e", 1); + const f1 = identity("f", 1); + const firstDraft = { + document: { type: "doc", content: [] }, + text: "fixture 中😀", + }; + const finalDraft = { + document: { + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { language: "typescript" }, + content: [{ type: "text", text: "const n = 1;" }], + }, + ], + }, + text: "const n = 1;", + selection: { from: 2, to: 4 }, + annotations: [{ kind: "fixture", label: "中😀" }], + images: [{ source: "fixture-only.png", unavailable: true }], + }; + let active; + const commands = []; + let mutationEvents = 0; + const invoke = async (command, request) => { + const observation = await observePersistedAtomCommand( + active.page, + protocol, + command, + request, + { timeoutMs }, + ); + commands.push(command); + mutationEvents += observation.events.length; + return observation.result; + }; + const read = (target) => + invoke("persisted_atom_read", { + key: PERSISTED_ATOM_FIXTURE_KEY, + query: { kind: "scope", scope: target }, + }); + const index = (workspaceId) => + invoke("persisted_atom_read", { + key: PERSISTED_ATOM_FIXTURE_KEY, + query: { kind: "workspace-index", workspaceId }, + }); + const update = (mutation) => + invoke("persisted_atom_update", { + key: PERSISTED_ATOM_FIXTURE_KEY, + mutation, + }); + const put = (target, token, expectedVersion, draft) => + update({ + op: "put", + scope: target, + draftId: token.draftId, + expectedVersion, + nextVersion: token.scopeVersion, + draft, + }); + const readReady = async (target) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const result = await read(target); + if (result.persistence.state !== "loading") return result; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new FixtureProofError( + "persisted atom fixture failed: initial load timeout", + ); + }; + try { + active = await driver.launch({ appDataRoot, generation: 1 }); + const before = verifyIdentity(active, appDataRoot); + expectEmpty(await readReady(home), "fresh Home scope after initial load"); + expectIndex( + await index(home.workspaceId), + home.workspaceId, + [], + "fresh workspace index", + ); + expectOutcome( + await put(home, a1, null, firstDraft), + "applied", + true, + undefined, + "create", + ); + expectDraft(await read(home), a1, firstDraft, "create readback"); + expectIndex( + await index(home.workspaceId), + home.workspaceId, + [[home, a1]], + "Home current binding discoverable", + ); + expectOutcome( + await put(home, a2, a1.scopeVersion, finalDraft), + "applied", + true, + undefined, + "version update", + ); + const stale = await put( + home, + identity("a", 3), + a1.scopeVersion, + firstDraft, + ); + expectOutcome( + stale, + "conflict", + false, + "version_conflict", + "stale writer CAS", + ); + expectDraft(stale, a2, finalDraft, "CAS preserves latest draft"); + expectOutcome( + await put(local, b1, null, firstDraft), + "applied", + true, + undefined, + "occupied target", + ); + expectIndex( + await index(home.workspaceId), + home.workspaceId, + [ + [home, a2], + [local, b1], + ], + "Home and local bindings isolated to workspace", + ); + const promotion = { + op: "promote", + source: home, + target: local, + identity: a2, + expectedTarget: null, + sessionId: local.id, + threadId: `thread-${runId}`, + }; + expectOutcome( + await update(promotion), + "conflict", + false, + "target_conflict", + "promotion target CAS", + ); + expectDraft(await read(home), a2, finalDraft, "conflicted source retained"); + expectDraft( + await read(local), + b1, + firstDraft, + "conflicted target retained", + ); + expectOutcome( + await update({ op: "clear", identity: b1 }), + "applied", + true, + undefined, + "explicit clear", + ); + expectOutcome( + await update(promotion), + "applied", + true, + undefined, + "scope promotion", + ); + expectEmpty(await read(home), "promoted source unbound"); + const promoted = await read(local); + expectDraft( + promoted, + a2, + finalDraft, + "promotion keeps identity and payload", + ); + requireProof( + scopeView(promoted, "promoted scope").entry.sessionId === local.id && + scopeView(promoted, "promoted scope").entry.threadId === + promotion.threadId, + "session/thread metadata remain distinct", + ); + expectOutcome( + await update({ op: "consume", identity: a1 }), + "conflict", + false, + "version_conflict", + "old acceptance preserves newer version", + ); + expectDraft(await read(local), a2, finalDraft, "old acceptance unchanged"); + const consumed = await update({ op: "consume", identity: a2 }); + expectOutcome(consumed, "applied", true, undefined, "exact acceptance"); + requireProof( + consumed.entry?.state === "consumed" && + !Object.hasOwn(consumed.entry, "draft"), + "tombstone contains no body", + ); + expectEmpty(await read(local), "consumed scope unbound"); + expectIndex( + await index(home.workspaceId), + home.workspaceId, + [], + "cleared and consumed drafts absent from index", + ); + expectOutcome( + await put(local, identity("a", 3), null, firstDraft), + "conflict", + false, + "retired_draft", + "late put cannot resurrect", + ); + expectOutcome( + await put(local, c1, null, finalDraft), + "applied", + true, + undefined, + "new logical draft", + ); + expectOutcome( + await update({ op: "consume", identity: a2 }), + "applied", + false, + undefined, + "idempotent old acceptance", + ); + expectDraft( + await read(local), + c1, + finalDraft, + "new logical draft survives old acceptance", + ); + for (const [target, token] of [ + [draftScope, d1], + [otherScope, e1], + [nullScope, f1], + ]) { + expectOutcome( + await put(target, token, null, firstDraft), + "applied", + true, + undefined, + "isolated workspace draft creation", + ); + } + const startedAt = Date.now(); + let saved; + while (Date.now() - startedAt < timeoutMs) { + const candidate = await read(local); + expectDraft( + candidate, + c1, + finalDraft, + "saved polling keeps current draft", + ); + if ( + candidate.persistence.state === "error" || + candidate.persistence.state === "unavailable" + ) { + throw new FixtureProofError( + "persisted atom fixture failed: disk save unavailable", + ); + } + if ( + candidate.persistence.state === "saved" && + candidate.persistence.savedRevision === candidate.storeRevision + ) { + saved = candidate; + break; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + requireProof(saved !== undefined, "real saved revision receipt"); + const indexSnapshots = []; + for (const [workspaceId, expected] of [ + [ + home.workspaceId, + [ + [local, c1], + [draftScope, d1], + ], + ], + [otherScope.workspaceId, [[otherScope, e1]]], + [null, [[nullScope, f1]]], + ]) { + const result = await index(workspaceId); + indexSnapshots.push( + expectIndex( + result, + workspaceId, + expected, + "saved workspace index isolation", + ), + ); + requireProof( + result.storeRevision === saved.storeRevision && + result.persistence.state === "saved" && + result.persistence.savedRevision === saved.storeRevision, + "index and saved file metadata revision", + ); + } + const files = verifyFiles( + await driver.inspectFiles({ appDataRoot }), + appDataRoot, + saved.storeRevision, + ); + const firstExit = await driver.close(active); + verifyExit(firstExit, before.pid); + active = null; + active = await driver.launch({ appDataRoot, generation: 2 }); + const after = verifyIdentity(active, appDataRoot); + requireProof(after.pid !== before.pid, "new Main PID after full exit"); + requireProof( + after.hostSha256 === before.hostSha256 && + after.preloadSha256 === before.preloadSha256 && + after.eventEmissionGuard.sourceSha256 === + before.eventEmissionGuard.sourceSha256, + "same Host/preload artifacts across restart", + ); + const restored = await readReady(local); + expectDraft( + restored, + c1, + finalDraft, + "same scope/version after real restart", + ); + requireProof( + restored.storeRevision === saved.storeRevision && + restored.persistence.state === "saved" && + restored.persistence.savedRevision === saved.storeRevision, + "restart saved revision unchanged", + ); + const restoredIndexSnapshots = []; + for (const [workspaceId, expected] of [ + [ + home.workspaceId, + [ + [local, c1], + [draftScope, d1], + ], + ], + [otherScope.workspaceId, [[otherScope, e1]]], + [null, [[nullScope, f1]]], + ]) { + const result = await index(workspaceId); + restoredIndexSnapshots.push( + expectIndex( + result, + workspaceId, + expected, + "workspace index discovered after normal restart", + ), + ); + requireProof( + result.storeRevision === saved.storeRevision && + result.persistence.state === "saved" && + result.persistence.savedRevision === saved.storeRevision, + "restart index saved revision unchanged", + ); + } + requireProof( + digest(indexSnapshots) === digest(restoredIndexSnapshots), + "same detached index metadata across restart", + ); + expectEmpty( + await read(home), + "promoted/consumed Home remains absent after restart", + ); + expectOutcome( + await put(local, identity("a", 4), null, firstDraft), + "conflict", + false, + "retired_draft", + "tombstone survives restart", + ); + expectDraft( + await read(local), + c1, + finalDraft, + "restored new draft protected", + ); + const secondExit = await driver.close(active); + verifyExit(secondExit, after.pid); + active = null; + return { + schemaVersion: 1, + scenario: "persisted-atom-host-ipc-restart", + proofLevel: "Desktop persistence", + runtimeGate: false, + guiDraftRestoration: false, + realRuntimeAcceptance: false, + result: "pass", + runId, + appDataRoot, + pids: [before.pid, after.pid], + hostSha256: before.hostSha256, + preloadSha256: before.preloadSha256, + rendererEventEmissionGuardSha256: before.eventEmissionGuard.sourceSha256, + scope: local, + identity: c1, + draftSha256: digest(finalDraft), + savedRevision: saved.storeRevision, + workspaceIndexSnapshots: indexSnapshots, + restoredWorkspaceIndexSnapshots: restoredIndexSnapshots, + files, + commands: [...new Set(commands)], + ipcInvocationCount: commands.length, + nativeMetadataEventCount: mutationEvents, + normalExitCount: 2, + cases: [ + "create", + "update", + "CAS-conflict", + "promotion-target-conflict", + "clear", + "promote", + "old-version-consume-conflict", + "consume", + "late-put", + "new-draft-protection", + "saved-receipt", + "primary-backup", + "new-PID-restart", + "same-scope-version-read", + "restart-tombstone", + "same-atom-workspace-index", + "Home-draft-local-current-binding", + "workspace-and-null-isolation", + "consumed-index-removal", + "index-file-revision", + "restart-index-discovery", + ], + unknown: [ + "GUI rich-draft hydration", + "App Server acceptance", + "Windows process", + "force kill/power loss durability", + ], + }; + } catch (error) { + // Never serialize Host errors, rejected payloads, or user draft data. + if (error instanceof FixtureProofError) throw error; + throw new Error("persisted atom Host IPC/restart fixture did not complete"); + } finally { + if (active) await driver.close(active).catch(() => undefined); + } +} diff --git a/scripts/electron/lib/persisted-atom-fixture.test.mjs b/scripts/electron/lib/persisted-atom-fixture.test.mjs new file mode 100644 index 000000000..ea380ff30 --- /dev/null +++ b/scripts/electron/lib/persisted-atom-fixture.test.mjs @@ -0,0 +1,775 @@ +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parsePersistedAtomReadRequest, + parsePersistedAtomReadResult, + parsePersistedAtomUpdateEvent, + parsePersistedAtomUpdateResult, +} from "@/lib/api/persistedAtomProtocol"; +import { + observePersistedAtomCommand, + runPersistedAtomFixture, +} from "./persisted-atom-fixture.mjs"; + +const protocol = { + parseReadRequest: parsePersistedAtomReadRequest, + parseReadResult: parsePersistedAtomReadResult, + parseUpdateResult: parsePersistedAtomUpdateResult, + parseUpdateEvent: parsePersistedAtomUpdateEvent, +}; +const atomKey = "composer-drafts-v1"; +const eventName = "persisted-atom-update"; +const runId = "unit-test"; +const appDataRoot = "/isolated/unit-test"; +const sha = "a".repeat(64); +const home = { + workspaceId: `fixture-${runId}`, + kind: "home", + id: "new-conversation", +}; +const local = { + workspaceId: `fixture-${runId}`, + kind: "local", + id: `session-${runId}`, +}; +const token = (suffix, version) => ({ + draftId: `draft-${runId}-${suffix}`, + scopeVersion: `version-${suffix}-${version}`, +}); +const a1 = token("a", 1), + a2 = token("a", 2), + b1 = token("b", 1), + c1 = token("c", 1), + d1 = token("d", 1), + e1 = token("e", 1), + f1 = token("f", 1); +const draftScope = { ...home, kind: "draft", id: `draft-tab-${runId}` }; +const otherScope = { ...draftScope, workspaceId: `other-${runId}` }; +const nullScope = { ...home, workspaceId: null }; +const firstDraft = { + document: { type: "doc", content: [] }, + text: "fixture 中😀", +}; +const finalDraft = { + document: { + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { language: "typescript" }, + content: [{ type: "text", text: "const n = 1;" }], + }, + ], + }, + text: "const n = 1;", + selection: { from: 2, to: 4 }, + annotations: [{ kind: "fixture", label: "中😀" }], + images: [{ source: "fixture-only.png", unavailable: true }], +}; +const entry = (identity, draft, metadata = {}) => { + const origin = [b1.draftId, c1.draftId].includes(identity.draftId) + ? local + : home; + return { + ...identity, + originScopeKey: JSON.stringify([ + origin.workspaceId, + origin.kind, + origin.id, + ]), + updatedAt: 100, + state: "draft", + draft, + ...(origin.kind === "local" ? { sessionId: origin.id } : {}), + ...metadata, + }; +}; +const retired = (identity, consumption = "accepted") => ({ + ...identity, + originScopeKey: JSON.stringify([home.workspaceId, home.kind, home.id]), + updatedAt: 100, + state: "consumed", + consumption, + consumedAt: 100, +}); +const meta = (revision, state = "pending") => ({ + key: atomKey, + storeRevision: revision, + persistence: { state, savedRevision: state === "saved" ? revision : 0 }, +}); +const read = (scope, entry, revision, state) => ({ + ...meta(revision, state), + outcome: "ready", + data: { queryKind: "scope", view: { scope, entry } }, +}); +const indexRead = (workspaceId, rows, revision, state) => ({ + ...meta(revision, state), + outcome: "ready", + data: { + queryKind: "workspace-index", + workspaceId, + items: rows.map(([scope, identity]) => ({ + scope, + identity, + updatedAt: 100, + ...(scope.kind === "local" ? { sessionId: scope.id } : {}), + })), + }, +}); +const scopedEntry = (scope, identity, draft) => + entry(identity, draft, { + originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]), + }); +const update = ( + entry, + revision, + outcome = "applied", + changed = true, + reason, +) => ({ + ...meta(revision), + outcome, + changed, + ...(reason ? { reason } : {}), + entry, +}); +const event = (revision) => ({ ...meta(revision), event: eventName }); +const envelope = (revision) => ({ event: eventName, payload: event(revision) }); +const observation = (result) => ({ + result, + events: result.changed ? [envelope(result.storeRevision)] : [], + transport: "electron-ipc", +}); + +const fileReceipts = () => + Object.fromEntries( + ["primary", "backup"].map((kind) => [ + kind, + { + path: path.join( + appDataRoot, + kind === "primary" ? "host-state.json" : "host-state.json.bak", + ), + validatedBy: "persistedAtomRecords.parseEnvelope", + storeRevision: 10, + sha256: sha, + }, + ]), + ); + +function scriptedDriver({ + mutate, + firstIdentity, + secondIdentity, + exitReceipt, + files, + neverSaved = false, + loadingReads = 0, +} = {}) { + const promoted = entry(a2, finalDraft, { + sessionId: local.id, + threadId: `thread-${runId}`, + }); + const steps = [ + ["read", read(home, null, 0, "saved")], + ["index", indexRead(home.workspaceId, [], 0, "saved")], + ["put", update(entry(a1, firstDraft), 1)], + ["read", read(home, entry(a1, firstDraft), 1)], + ["index", indexRead(home.workspaceId, [[home, a1]], 1)], + ["put", update(entry(a2, finalDraft), 2)], + [ + "put", + update(entry(a2, finalDraft), 2, "conflict", false, "version_conflict"), + ], + ["put", update(entry(b1, firstDraft), 3)], + [ + "index", + indexRead( + home.workspaceId, + [ + [home, a2], + [local, b1], + ], + 3, + ), + ], + [ + "promote", + update(entry(a2, finalDraft), 3, "conflict", false, "target_conflict"), + ], + ["read", read(home, entry(a2, finalDraft), 3)], + ["read", read(local, entry(b1, firstDraft), 3)], + ["clear", update(retired(b1, "clear"), 4)], + ["promote", update(promoted, 5)], + ["read", read(home, null, 5)], + ["read", read(local, promoted, 5)], + ["consume", update(promoted, 5, "conflict", false, "version_conflict")], + ["read", read(local, promoted, 5)], + ["consume", update(retired(a2), 6)], + ["read", read(local, null, 6)], + ["index", indexRead(home.workspaceId, [], 6)], + ["put", update(retired(a2), 6, "conflict", false, "retired_draft")], + ["put", update(entry(c1, finalDraft), 7)], + ["consume", update(retired(a2), 7, "applied", false)], + ["read", read(local, entry(c1, finalDraft), 7)], + ["put", update(scopedEntry(draftScope, d1, firstDraft), 8)], + ["put", update(scopedEntry(otherScope, e1, firstDraft), 9)], + ["put", update(scopedEntry(nullScope, f1, firstDraft), 10)], + ["read", read(local, entry(c1, finalDraft), 10, "saved")], + [ + "index", + indexRead( + home.workspaceId, + [ + [local, c1], + [draftScope, d1], + ], + 10, + "saved", + ), + ], + [ + "index", + indexRead(otherScope.workspaceId, [[otherScope, e1]], 10, "saved"), + ], + ["index", indexRead(null, [[nullScope, f1]], 10, "saved")], + ["read", read(local, entry(c1, finalDraft), 10, "saved")], + [ + "index", + indexRead( + home.workspaceId, + [ + [local, c1], + [draftScope, d1], + ], + 10, + "saved", + ), + ], + [ + "index", + indexRead(otherScope.workspaceId, [[otherScope, e1]], 10, "saved"), + ], + ["index", indexRead(null, [[nullScope, f1]], 10, "saved")], + ["read", read(home, null, 10, "saved")], + ["put", update(retired(a2), 10, "conflict", false, "retired_draft")], + ["read", read(local, entry(c1, finalDraft), 10, "saved")], + ]; + const order = []; + let index = 0; + const evaluate = vi.fn(async (_callback, args) => { + if (index === 0 && loadingReads-- > 0) { + expect(args.command).toBe("persisted_atom_read"); + return observation({ + key: atomKey, + storeRevision: 0, + persistence: { state: "loading", savedRevision: null }, + outcome: "unavailable", + data: null, + }); + } + if (neverSaved && index >= 28) + return observation(read(local, entry(c1, finalDraft), 10)); + const stepIndex = index++; + const [op, result] = steps[stepIndex]; + expect(args.command).toBe( + ["read", "index"].includes(op) + ? "persisted_atom_read" + : "persisted_atom_update", + ); + if (!["read", "index"].includes(op)) expect(args.args.mutation.op).toBe(op); + else { + expect(args.args.query).toEqual( + op === "read" + ? { kind: "scope", scope: result.data.view.scope } + : { kind: "workspace-index", workspaceId: result.data.workspaceId }, + ); + expect(args.args).not.toHaveProperty("scope"); + } + expect(args.args.key).toBe(atomKey); + const value = observation(structuredClone(result)); + mutate?.(value, stepIndex, args); + order.push(`ipc:${stepIndex}`); + return value; + }); + const identity = (generation) => ({ + pid: generation === 1 ? 100 : 101, + appDataRoot, + hostSha256: sha, + preloadSha256: sha, + eventEmissionGuard: { validated: true, sourceSha256: sha }, + ...(generation === 1 ? firstIdentity : secondIdentity), + }); + const driver = { + launch: vi.fn(async ({ generation, appDataRoot: root }) => { + order.push(`launch:${generation}`); + expect(root).toBe(appDataRoot); + return { page: { evaluate }, identity: identity(generation) }; + }), + inspectFiles: vi.fn(async () => { + order.push("files"); + return files ?? fileReceipts(); + }), + close: vi.fn(async (handle) => { + order.push(`exit:${handle.identity.pid}`); + return { + pid: handle.identity.pid, + exited: true, + exitCode: 0, + signal: null, + mode: "normal", + ...exitReceipt, + }; + }), + }; + return { driver, order, evaluate }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("persisted atom fixture lifecycle (scripted fake bridge; not Electron evidence)", () => { + it("requires saved files, complete exit, new PID and same scope/version after restart", async () => { + const { driver, order, evaluate } = scriptedDriver(); + const evidence = await runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + }); + expect(evidence).toMatchObject({ + result: "pass", + proofLevel: "Desktop persistence", + runtimeGate: false, + guiDraftRestoration: false, + realRuntimeAcceptance: false, + pids: [100, 101], + savedRevision: 10, + normalExitCount: 2, + ipcInvocationCount: 39, + }); + expect(order.indexOf("files")).toBeLessThan(order.indexOf("exit:100")); + expect(order.indexOf("exit:100")).toBeLessThan(order.indexOf("launch:2")); + expect(evaluate).toHaveBeenCalledTimes(39); + expect(JSON.stringify(evidence)).not.toContain("const n = 1;"); + expect(JSON.stringify(evidence)).not.toContain("fixture 中😀"); + }); + + it("waits for initial Main load through read IPC before creating a draft", async () => { + const { driver, evaluate } = scriptedDriver({ loadingReads: 2 }); + await expect( + runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + intervalMs: 1, + }), + ).resolves.toMatchObject({ result: "pass", ipcInvocationCount: 41 }); + expect( + evaluate.mock.calls.slice(0, 3).map((call) => call[1].command), + ).toEqual(Array(3).fill("persisted_atom_read")); + }); + + it("projects only metadata from file receipts into evidence", async () => { + const files = fileReceipts(); + files.primary.parsedEnvelope = { draft: "private draft 中😀" }; + files.backup.debug = "private draft 中😀"; + const { driver } = scriptedDriver({ files }); + const evidence = await runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + }); + expect(evidence.files).toEqual(fileReceipts()); + expect(JSON.stringify(evidence)).not.toContain("private draft"); + }); + + it.each(["revision", "digest", "path", "parser-owner"])( + "rejects mismatched backup %s", + async (fault) => { + const files = fileReceipts(); + if (fault === "revision") files.backup.storeRevision = 6; + if (fault === "digest") files.backup.sha256 = "b".repeat(64); + if (fault === "path") files.backup.path = "/outside/host-state.json.bak"; + if (fault === "parser-owner") + files.backup.validatedBy = "unvalidated-json"; + const { driver } = scriptedDriver({ files }); + await expect( + runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }), + ).rejects.toThrow(/saved backup|same saved snapshot/); + expect(driver.launch).toHaveBeenCalledTimes(1); + }, + ); + + it("does not treat a saved revision carried by an error state as a success", async () => { + const { driver } = scriptedDriver({ + mutate: (value, index) => { + if (index === 28) + value.result.persistence = { + state: "error", + savedRevision: 10, + diagnostic: { phase: "backup", code: "EIO" }, + }; + }, + }); + await expect( + runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }), + ).rejects.toThrow(/disk save unavailable/); + expect(driver.inspectFiles).not.toHaveBeenCalled(); + expect(driver.launch).toHaveBeenCalledTimes(1); + }); + + it.each([ + [ + "missing current artifacts", + { firstIdentity: { hostSha256: "unknown" } }, + /Host\/preload/, + ], + [ + "renderer emission guard absent", + { + firstIdentity: { + eventEmissionGuard: { validated: false, sourceSha256: sha }, + }, + }, + /source guard/, + ], + [ + "different profile", + { secondIdentity: { appDataRoot: "/other" } }, + /Host\/preload/, + ], + ["same PID", { secondIdentity: { pid: 100 } }, /new Main PID/], + [ + "changed artifacts", + { secondIdentity: { hostSha256: "b".repeat(64) } }, + /same Host\/preload/, + ], + ["not exited", { exitReceipt: { exited: false } }, /complete normal/], + [ + "killed", + { exitReceipt: { signal: "SIGKILL", mode: "forced" } }, + /complete normal/, + ], + ["bad exit status", { exitReceipt: { exitCode: 1 } }, /complete normal/], + ["backup missing", { files: {} }, /saved primary/], + ])("rejects %s", async (_name, options, expected) => { + const { driver } = scriptedDriver(options); + await expect( + runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }), + ).rejects.toThrow(expected); + }); + + it.each([ + [ + "CAS overwrites body", + 6, + (value) => { + value.result.entry.draft = firstDraft; + }, + /CAS preserves/, + ], + [ + "promotion changes version", + 13, + (value) => { + value.result.entry.scopeVersion = "wrong"; + }, + /strict response/, + ], + [ + "consumed entry carries body", + 18, + (value) => { + value.result.entry.draft = firstDraft; + }, + /strict response/, + ], + [ + "late put accepted", + 21, + (value) => { + value.result.outcome = "applied"; + value.result.changed = true; + }, + /strict response/, + ], + [ + "restored version drift", + 32, + (value) => { + value.result.data.view.entry.scopeVersion = "wrong"; + }, + /same scope\/version/, + ], + [ + "missing mutation event", + 2, + (value) => { + value.events = []; + }, + /Main mutation broadcast/, + ], + [ + "renderer payload in event", + 2, + (value) => { + value.events[0].payload.draft = firstDraft; + }, + /metadata-only event/, + ], + [ + "wrong event envelope", + 2, + (value) => { + value.events[0].event = "other"; + }, + /native event envelope/, + ], + [ + "http fallback", + 0, + (value) => { + value.transport = "http-bridge"; + }, + /native IPC transport/, + ], + [ + "consumed index identity remains", + 20, + (value) => { + value.result.data.items = indexRead( + home.workspaceId, + [[local, a2]], + 6, + ).data.items; + }, + /cleared and consumed/, + ], + [ + "saved index omits draft binding", + 29, + (value) => { + value.result.data.items = value.result.data.items.slice(0, 1); + }, + /saved workspace index isolation/, + ], + [ + "saved index includes stale Home binding", + 29, + (value) => { + value.result.data.items[0].scope = home; + delete value.result.data.items[0].sessionId; + }, + /saved workspace index isolation/, + ], + [ + "foreign workspace in saved index", + 29, + (value) => { + value.result.data.items[0].scope.workspaceId = otherScope.workspaceId; + }, + /strict response/, + ], + [ + "saved index revision mismatches files", + 29, + (value) => { + value.result.storeRevision = 9; + value.result.persistence.savedRevision = 9; + }, + /index and saved file metadata/, + ], + [ + "restarted index metadata drifts", + 33, + (value) => { + value.result.data.items[0].updatedAt = 101; + }, + /same detached index metadata/, + ], + ])("rejects %s", async (_name, target, alter, expected) => { + const { driver } = scriptedDriver({ + mutate: (value, index) => { + if (index === target) alter(value); + }, + }); + await expect( + runPersistedAtomFixture({ driver, protocol, appDataRoot, runId }), + ).rejects.toThrow(expected); + expect(driver.close).toHaveBeenCalled(); + }); + + it("cannot use Map events as a saved receipt and never restarts unsaved state", async () => { + const { driver } = scriptedDriver({ neverSaved: true }); + await expect( + runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + timeoutMs: 5, + intervalMs: 1, + }), + ).rejects.toThrow(/real saved revision receipt/); + expect(driver.inspectFiles).not.toHaveBeenCalled(); + expect(driver.launch).toHaveBeenCalledTimes(1); + }); + + it("suppresses transport error text containing content while still cleaning up", async () => { + const { driver, evaluate } = scriptedDriver(); + evaluate.mockRejectedValueOnce(new Error("private draft 中😀")); + const error = await runPersistedAtomFixture({ + driver, + protocol, + appDataRoot, + runId, + }).catch((value) => value); + expect(String(error)).not.toContain("private draft"); + expect(driver.close).toHaveBeenCalledTimes(1); + }); +}); + +describe("public preload observation function (explicit fake native bridge)", () => { + function nativePage({ bridge = {}, invoke, listen } = {}) { + let receiver; + const remove = vi.fn(); + const api = { + devBridgeFallback: false, + supportsCommand: vi.fn(() => true), + listen: + listen ?? + vi.fn((_name, handler) => { + receiver = handler; + return remove; + }), + invoke: vi.fn(async () => { + if (invoke) return invoke((value) => receiver(value)); + receiver(envelope(1)); + return update(entry(a1, firstDraft), 1); + }), + emit: vi.fn(), + ...bridge, + }; + vi.stubGlobal("window", { __LIME_ELECTRON__: true, electronAPI: api }); + const page = { evaluate: (callback, arg) => callback(arg) }; + const request = { + key: atomKey, + mutation: { + op: "put", + scope: home, + draftId: a1.draftId, + expectedVersion: null, + nextVersion: a1.scopeVersion, + draft: firstDraft, + }, + }; + return { page, api, remove, request }; + } + + it("registers before invoke and uses invoke/listen only", async () => { + const { page, api, remove, request } = nativePage(); + const observed = await observePersistedAtomCommand( + page, + protocol, + "persisted_atom_update", + request, + ); + expect(observed.result.changed).toBe(true); + expect(api.listen).toHaveBeenCalledWith(eventName, expect.any(Function)); + expect(api.listen.mock.invocationCallOrder[0]).toBeLessThan( + api.invoke.mock.invocationCallOrder[0], + ); + expect(api.invoke).toHaveBeenCalledWith("persisted_atom_update", request); + expect(api.emit).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it("waits for the mutation revision even when an older save event arrives first", async () => { + const { page, remove, request } = nativePage({ + invoke: (send) => { + send(envelope(0)); + setTimeout(() => send(envelope(1)), 5); + return update(entry(a1, firstDraft), 1); + }, + }); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_update", + request, + { timeoutMs: 100 }, + ), + ).resolves.toMatchObject({ events: [event(0), event(1)] }); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it.each([ + { devBridgeFallback: true }, + { supportsCommand: () => false }, + { invoke: undefined }, + { listen: undefined }, + ])("rejects an unavailable or fallback native bridge", async (bridge) => { + const { page, request } = nativePage({ bridge }); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_update", + request, + ), + ).rejects.toThrow(/transport failed/); + }); + + it("rejects no-op mutation broadcast and removes its listener", async () => { + const { page, remove, request } = nativePage({ + invoke: () => update(entry(a1, firstDraft), 1), + }); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_update", + request, + { timeoutMs: 2 }, + ), + ).rejects.toThrow(/Main mutation broadcast/); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it("never invokes an unregistered command", async () => { + const { page, api, request } = nativePage(); + await expect( + observePersistedAtomCommand( + page, + protocol, + "app_server_handle_json_lines", + request, + ), + ).rejects.toThrow(/known command/); + expect(api.invoke).not.toHaveBeenCalled(); + }); + + it("redacts a parser exception rather than echoing rejected input", async () => { + const { page, request } = nativePage(); + const badProtocol = { + ...protocol, + parseUpdateResult: () => { + throw new Error("private draft 中😀"); + }, + }; + await expect( + observePersistedAtomCommand( + page, + badProtocol, + "persisted_atom_update", + request, + ), + ).rejects.toThrow("strict response protocol"); + }); +}); diff --git a/scripts/electron/lib/persisted-atom-index-consumer.test.mjs b/scripts/electron/lib/persisted-atom-index-consumer.test.mjs new file mode 100644 index 000000000..4abf064db --- /dev/null +++ b/scripts/electron/lib/persisted-atom-index-consumer.test.mjs @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parsePersistedAtomReadRequest, + parsePersistedAtomReadResult, + parsePersistedAtomUpdateEvent, + parsePersistedAtomUpdateResult, +} from "@/lib/api/persistedAtomProtocol"; +import { observePersistedAtomCommand } from "./persisted-atom-fixture.mjs"; + +const protocol = { + parseReadRequest: parsePersistedAtomReadRequest, + parseReadResult: parsePersistedAtomReadResult, + parseUpdateResult: parsePersistedAtomUpdateResult, + parseUpdateEvent: parsePersistedAtomUpdateEvent, +}; +const key = "composer-drafts-v1"; +const metadata = { + key, + storeRevision: 4, + persistence: { state: "saved", savedRevision: 4 }, +}; +const scope = { workspaceId: "workspace", kind: "draft", id: "draft-tab" }; +const item = { + scope, + identity: { draftId: "draft", scopeVersion: "v1" }, + updatedAt: 100, +}; +const scopeRequest = { key, query: { kind: "scope", scope } }; +const indexRequest = { + key, + query: { kind: "workspace-index", workspaceId: "workspace" }, +}; +const scopeResponse = { + ...metadata, + outcome: "ready", + data: { queryKind: "scope", view: { scope, entry: null } }, +}; +const indexResponse = { + ...metadata, + outcome: "ready", + data: { + queryKind: "workspace-index", + workspaceId: "workspace", + items: [item], + }, +}; + +function publicBridge(response) { + const remove = vi.fn(); + const api = { + devBridgeFallback: false, + supportsCommand: vi.fn(() => true), + invoke: vi.fn(async () => response), + listen: vi.fn(() => remove), + emit: vi.fn(), + send: vi.fn(), + }; + vi.stubGlobal("window", { __LIME_ELECTRON__: true, electronAPI: api }); + const page = { evaluate: vi.fn((callback, args) => callback(args)) }; + return { page, api, remove }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("index consumer public IPC contract (fake native surface; real shared parsers)", () => { + it.each([ + [scopeRequest, scopeResponse], + [indexRequest, indexResponse], + [ + { key, query: { kind: "workspace-index", workspaceId: null } }, + { + ...indexResponse, + data: { queryKind: "workspace-index", workspaceId: null, items: [] }, + }, + ], + ])( + "uses only public invoke/listen with the exact discriminated query", + async (request, response) => { + const { page, api, remove } = publicBridge(response); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_read", + request, + ), + ).resolves.toEqual({ result: response, events: [] }); + expect(api.invoke).toHaveBeenCalledExactlyOnceWith( + "persisted_atom_read", + request, + ); + expect(api.listen).toHaveBeenCalledExactlyOnceWith( + "persisted-atom-update", + expect.any(Function), + ); + expect(api.emit).not.toHaveBeenCalled(); + expect(api.send).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + { ...metadata, outcome: "ready", view: { scope, entry: null } }, + scopeResponse, + { ...indexResponse, data: { ...indexResponse.data, workspaceId: null } }, + { ...indexResponse, data: { ...indexResponse.data, workspaceId: "other" } }, + { + ...indexResponse, + data: { + ...indexResponse.data, + items: [{ ...item, scope: { ...scope, workspaceId: "other" } }], + }, + }, + { + ...indexResponse, + data: { + ...indexResponse.data, + items: [ + item, + { ...item, identity: { draftId: "other", scopeVersion: "v2" } }, + ], + }, + }, + { + ...indexResponse, + data: { + ...indexResponse.data, + items: [item, { ...item, scope: { ...scope, id: "other" } }], + }, + }, + { + ...indexResponse, + data: { + ...indexResponse.data, + items: [{ ...item, draft: { text: "private content" } }], + }, + }, + { + ...indexResponse, + data: { + ...indexResponse.data, + items: [{ ...item, title: "private title" }], + }, + }, + ])( + "rejects legacy, uncorrelated or body-bearing receipts after public IPC", + async (response) => { + const { page, api, remove } = publicBridge(response); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_read", + indexRequest, + ), + ).rejects.toThrow("strict response protocol"); + expect(api.invoke).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + { key, scope }, + { key, query: { kind: "workspace-index" } }, + { + key, + query: { kind: "workspace-index", workspaceId: "workspace", scope }, + }, + { key, query: { kind: "scope", scope: { ...scope, route: "/private" } } }, + ])( + "rejects invalid read query before evaluating any browser surface", + async (request) => { + const { page, api } = publicBridge(indexResponse); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_read", + request, + ), + ).rejects.toThrow("strict read request protocol"); + expect(page.evaluate).not.toHaveBeenCalled(); + expect(api.invoke).not.toHaveBeenCalled(); + }, + ); + + it.each([scopeRequest, indexRequest])( + "preserves unavailable data as null", + async (request) => { + const response = { + ...metadata, + persistence: { state: "loading", savedRevision: null }, + outcome: "unavailable", + data: null, + }; + const { page } = publicBridge(response); + await expect( + observePersistedAtomCommand( + page, + protocol, + "persisted_atom_read", + request, + ), + ).resolves.toEqual({ result: response, events: [] }); + }, + ); + + it("rejects an accessor without executing it or exposing its error", async () => { + const get = vi.fn(() => { + throw new Error("private content"); + }); + const request = Object.defineProperty({ key }, "query", { + enumerable: true, + get, + }); + const { page } = publicBridge(indexResponse); + const error = await observePersistedAtomCommand( + page, + protocol, + "persisted_atom_read", + request, + ).catch((value) => value); + expect(String(error)).toContain("strict read request protocol"); + expect(String(error)).not.toContain("private content"); + expect(get).not.toHaveBeenCalled(); + expect(page.evaluate).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/electron/settings-about-fixture-smoke.mjs b/scripts/electron/settings-about-fixture-smoke.mjs index 48772766c..cc72ed452 100644 --- a/scripts/electron/settings-about-fixture-smoke.mjs +++ b/scripts/electron/settings-about-fixture-smoke.mjs @@ -258,7 +258,7 @@ async function run() { }; writeJsonFile(summaryPath, summary); - await page.locator('[data-testid="settings-home-button"]').click(); + await page.locator('[data-testid="app-sidebar-rail-home"]').click(); const homeState = await readHomeState(page, options); const homeTrace = summarizeSettingsAboutTrace(homeState.traceRaw); await page.screenshot({ path: homeScreenshotPath, fullPage: true }); diff --git a/scripts/lib/composer-editor-dom.mjs b/scripts/lib/composer-editor-dom.mjs index ef086de4b..e7f2997cc 100644 --- a/scripts/lib/composer-editor-dom.mjs +++ b/scripts/lib/composer-editor-dom.mjs @@ -12,6 +12,8 @@ export function readComposerEditorTextDom(element) { const readNode = (node) => { if (node.nodeType === 3) return node.nodeValue || ""; if (node.nodeType !== 1) return ""; + if (node.getAttribute("data-composer-code-block-toolbar") === "true") + return ""; if (node.tagName === "BR") { return node.classList.contains("ProseMirror-trailingBreak") ? "" : "\n"; } diff --git a/scripts/lib/composer-editor-dom.test.mjs b/scripts/lib/composer-editor-dom.test.mjs index 82259d241..7f46b1ffd 100644 --- a/scripts/lib/composer-editor-dom.test.mjs +++ b/scripts/lib/composer-editor-dom.test.mjs @@ -26,9 +26,11 @@ function editor(html, attributes = {}) { describe("Composer fixture DOM projection", () => { it.each([" 中😀\n````\n\n", ""])( - "separates P/PRE/P without flattening code whitespace %j", + "separates P/NodeView/P without flattening code whitespace %j", (body) => { - const element = editor("

first

last

"); + const element = editor( + '

first

Auto TypeScript

last

', + ); try { element.querySelector("code").textContent = body; expect(readComposerEditorTextDom(element)).toBe(`first\n${body}\nlast`); @@ -37,6 +39,15 @@ describe("Composer fixture DOM projection", () => { } }, ); + it("skips toolbar text while retaining nested syntax leaves and visible atoms", () => { + const element = editor( + '

lead /user:capability-report

Auto TypeScript search
  const value = 中😀;\n````\n\n

[Pasted text] tail

', + ); + expect(readComposerEditorTextDom(element)).toBe( + "lead /user:capability-report\n const value = 中😀;\n````\n\n\n[Pasted text] tail", + ); + element.remove(); + }); it("preserves whitespace and explicit hard breaks without the view trailing break", () => { const element = editor( '

lead
line

', diff --git a/scripts/lib/electron-fixture-build.mjs b/scripts/lib/electron-fixture-build.mjs index d7850ec11..3078eba3a 100644 --- a/scripts/lib/electron-fixture-build.mjs +++ b/scripts/lib/electron-fixture-build.mjs @@ -32,7 +32,8 @@ const RENDERER_SOURCE_DIRS = [ ]; const RENDERER_SOURCE_FILES = [ "index.html", - "package-lock.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", "package.json", "tsconfig.json", "tsconfig.renderer.json", @@ -47,7 +48,8 @@ const HOST_SOURCE_DIRS = [ "assets/plugins", ]; const HOST_SOURCE_FILES = [ - "package-lock.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", "package.json", "tsconfig.electron.json", "tsconfig.json", diff --git a/scripts/lib/electron-fixture-build.test.mjs b/scripts/lib/electron-fixture-build.test.mjs index 824baacb7..ebf06f082 100644 --- a/scripts/lib/electron-fixture-build.test.mjs +++ b/scripts/lib/electron-fixture-build.test.mjs @@ -380,6 +380,30 @@ describe("electron fixture build freshness", () => { ); }); + it.each(["pnpm-lock.yaml", "pnpm-workspace.yaml"])( + "invalidates renderer and host when %s changes without a manifest edit", + (dependencyInput) => { + const rootDir = createTempRoot(); + writeRequiredFiles(rootDir, new Date("2026-01-01T00:10:00.000Z")); + writeFile( + path.join(rootDir, dependencyInput), + "changed dependency input", + new Date("2026-01-01T00:20:00.000Z"), + ); + + expect(inspectElectronFixtureBuildFreshness({ rootDir })).toMatchObject({ + ready: false, + reason: "stale-source", + staleSegments: ["renderer", "host"], + segments: { + renderer: { newestSource: { relativePath: dependencyInput } }, + host: { newestSource: { relativePath: dependencyInput } }, + appServer: { ready: true }, + }, + }); + }, + ); + it("does not mark renderer stale when only Rust app-server sources are newer", () => { const rootDir = createTempRoot(); writeRequiredFiles(rootDir, new Date("2026-01-01T00:10:00.000Z")); diff --git a/scripts/lib/invoke-trace-collector.mjs b/scripts/lib/invoke-trace-collector.mjs index b28dd6f11..e52d5537a 100644 --- a/scripts/lib/invoke-trace-collector.mjs +++ b/scripts/lib/invoke-trace-collector.mjs @@ -48,7 +48,7 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { }; const readRaw = () => Reflect.apply(getItem, storage, [key]); const seedEntries = parseRing(readRaw()); - let knownRing = seedEntries; + let knownRingJson = JSON.stringify(seedEntries); const ledgerEntries = []; const observations = []; const invalidations = []; @@ -61,8 +61,8 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { sequence: observations.length + 1, }); }; - const hash = (value) => { - const bytes = new TextEncoder().encode(JSON.stringify(value)); + const hash = (serialized) => { + const bytes = new TextEncoder().encode(serialized); return globalThis.crypto.subtle .digest("SHA-256", bytes) .then((buffer) => @@ -75,7 +75,8 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { return null; }); }; - const seedDigestPromise = hash(seedEntries); + const seedDigestPromise = hash(knownRingJson); + let knownRingDigestPromise = seedDigestPromise; const observe = (operation, args, beforeRaw, afterRaw) => { const targetKey = operation === "clear" || args[0] === key; if (operation === "clear" || (operation === "removeItem" && targetKey)) { @@ -94,20 +95,21 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { try { const before = parseRing(beforeRaw); const after = parseRing(afterRaw); - if (JSON.stringify(before) !== JSON.stringify(knownRing)) { + if (JSON.stringify(before) !== knownRingJson) { invalidate("lost-observation-window", operation); return; } const appended = after.at(-1); + const afterJson = JSON.stringify(after); if ( !appended || - JSON.stringify(after) !== - JSON.stringify([...before, appended].slice(-limit)) + afterJson !== JSON.stringify([...before, appended].slice(-limit)) ) { invalidate("non-continuous-append", operation); return; } const sequence = observations.length + 1; + const afterDigestPromise = hash(afterJson); ledgerEntries.push(appended); observations.push({ sequence, @@ -115,11 +117,13 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { nativeSucceeded: true, beforeCount: before.length, afterCount: after.length, - beforeDigestPromise: hash(before), - afterDigestPromise: hash(after), - entryDigestPromise: hash(appended), + // Reuse only after the native before-window matched the known bytes. + beforeDigestPromise: knownRingDigestPromise, + afterDigestPromise, + entryDigestPromise: hash(JSON.stringify(appended)), }); - knownRing = after; + knownRingJson = afterJson; + knownRingDigestPromise = afterDigestPromise; } catch { invalidate("invalid-trace-write", operation); } @@ -182,12 +186,13 @@ export function installInvokeTraceCollectorDom({ runId, startedAt }) { invalidate("native-read-invalid", "capture"); } if (!intact) invalidate("collector-hook-lost", "capture"); - if (JSON.stringify(currentEntries) !== JSON.stringify(knownRing)) + const currentJson = JSON.stringify(currentEntries); + if (currentJson !== knownRingJson) invalidate("lost-observation-window", "capture"); // Snapshot before the first await. Later appends belong to a later capture. const ledger = ledgerEntries.slice(); const records = observations.slice(); - const currentDigestPromise = hash(currentEntries); + const currentDigestPromise = hash(currentJson); const transitions = await Promise.all( records.map(async (record) => ({ sequence: record.sequence, @@ -313,25 +318,28 @@ export function validateInvokeTraceCollectorCapture(capture, handle) { ) throw new Error("invalid collector provenance"); let ring = capture.seedEntries; + let ringDigest = hash(ring); for (const [index, entry] of capture.ledgerEntries.entries()) { const record = capture.observations[index]; const next = [...ring, entry].slice(-handle.traceLimit); + const nextDigest = hash(next); if ( record?.sequence !== index + 1 || record.operation !== "append" || record.nativeSucceeded !== true || record.beforeCount !== ring.length || record.afterCount !== next.length || - record.beforeDigest !== hash(ring) || - record.afterDigest !== hash(next) || + record.beforeDigest !== ringDigest || + record.afterDigest !== nextDigest || record.entryDigest !== hash(entry) ) throw new Error("non-continuous collector provenance"); ring = next; + ringDigest = nextDigest; } if ( capture.currentRingCount !== ring.length || - capture.currentRingDigest !== hash(ring) + capture.currentRingDigest !== ringDigest ) throw new Error("collector final window mismatch"); return { diff --git a/scripts/lib/persisted-atom-contract.mjs b/scripts/lib/persisted-atom-contract.mjs new file mode 100644 index 000000000..573961f7d --- /dev/null +++ b/scripts/lib/persisted-atom-contract.mjs @@ -0,0 +1,152 @@ +const commands = ["persisted_atom_read", "persisted_atom_update"]; + +/** Static ownership checks complement, but never replace, actual IPC evidence. */ +export function collectPersistedAtomContractFailures(readSource) { + const failures = []; + const sources = new Map(); + const read = (file) => { + if (!sources.has(file)) sources.set(file, readSource(file)); + return sources.get(file); + }; + const require = (file, pattern, message) => { + if (!pattern.test(read(file))) failures.push({ file, message }); + }; + const forbid = (file, pattern, message) => { + if (pattern.test(read(file))) failures.push({ file, message }); + }; + const catalogFile = "src/lib/governance/agentCommandCatalog.json"; + const catalog = JSON.parse(read(catalogFile)); + if ( + JSON.stringify(catalog.persistedAtomCommands) !== JSON.stringify(commands) + ) + failures.push({ + file: catalogFile, + message: + "Persisted atom catalog must register only the two Desktop UI commands", + }); + for (const command of commands) { + for (const section of ["runtimeGatewayCommands", "capabilityDraftCommands"]) + if (catalog[section]?.includes(command)) + failures.push({ + file: catalogFile, + message: + "Desktop UI persistence cannot become an Agent runtime command", + }); + if (Object.hasOwn(catalog.deprecatedCommandReplacements ?? {}, command)) + failures.push({ + file: catalogFile, + message: "Current persisted atom commands cannot be deprecated aliases", + }); + require("electron/ipcChannels.ts", new RegExp( + 'export const ELECTRON_HOST_COMMANDS = \\[([^\\]]*"' + command + '")', + ), "Preload Host allowlist must contain " + command); + for (const file of [ + "src/lib/dev-bridge/mockPriorityCommands.ts", + "src/lib/dev-bridge/commandPolicy.ts", + "src/lib/desktop-host/core.ts", + ]) + forbid( + file, + new RegExp("[\"'\x60]" + command + "[\"'\x60]"), + "Desktop draft persistence cannot fall back to a mock or legacy command policy", + ); + } + const main = "electron/main.ts"; + require(main, /new PersistedAtomHost\(\s*desktopStorageRoots\.appDataRoot,\s*broadcast,?\s*\)/, "Persisted UI files must use the resolved platform AppDataRoot and Main broadcast owner"); + require(main, /if\s*\(command === "persisted_atom_read"\)\s*\{\s*return persistedAtomHost\.sendPersistedAtomState\(args\);\s*\}/, "Read IPC must delegate to the unique persisted atom Host"); + require(main, /if\s*\(command === "persisted_atom_update"\)\s*\{\s*return persistedAtomHost\.updatePersistedAtomState\(args\);\s*\}/, "Update IPC must delegate to the unique persisted atom Host"); + require(main, /registerRendererEventHandler\(ipcMain, broadcast\)/, "Main must register the tested renderer event authority"); + forbid( + main, + /ipcMain\.on\(\s*IPC_EMIT_CHANNEL/, + "Generic renderer emission cannot bypass the Main event authority", + ); + require("electron/rendererEventPolicy.ts", /event !== "persisted-atom-update"/, "Renderer cannot forge persisted-state acknowledgements"); + require("electron/rendererEventPolicy.ts", /if\s*\(canBroadcastRendererEvent\(event\)\)\s*broadcast\(event, payload\)/, "The actual IPC listener must enforce renderer event authority"); + require("electron/preload.ts", /emit:[\s\S]*?canBroadcastRendererEvent\(event\)[\s\S]*?send:[\s\S]*?canBroadcastRendererEvent\(event\)/, "Both preload event entry points must reject Main notifications"); + const gateway = "src/lib/api/persistedAtoms.ts"; + require("eslint.config.js", /agentCommandCatalog\.persistedAtomCommands/, "Persisted Host commands must stay behind their typed API gateway"); + require("eslint.config.js", /\.\.\.persistedAtomGatewayCommandSelectors/, "Business code must enforce the persisted atom gateway restriction"); + require(gateway, /invokePersistedAtom\("persisted_atom_read"/, "Typed read gateway must use the current Host command"); + require(gateway, /invokePersistedAtom\("persisted_atom_update"/, "Typed update gateway must use the current Host command"); + for (const parser of [ + "parsePersistedAtomReadRequest", + "parsePersistedAtomReadResult", + "parsePersistedAtomUpdateResult", + "parsePersistedAtomUpdateEvent", + ]) + require(gateway, new RegExp( + parser + "\\(", + ), "Typed gateway must consume the shared " + parser); + require(gateway, /query:\s*parsedRequest\.query/, "Read IPC must carry the shared parsed query"); + forbid( + gateway, + /scope:\s*request\.scope/, + "The scope-only read wire has been replaced", + ); + const types = "src/lib/api/persistedAtomTypes.ts"; + require(types, /readonly query:\s*PersistedAtomReadQuery/, "Read requests must use the discriminated query owner"); + require(types, /kind:\s*"workspace-index"/, "The existing read command must support workspace discovery"); + const fixture = "scripts/electron/lib/persisted-atom-fixture.mjs"; + require(fixture, /protocol\.parseReadRequest/, "IPC observations must validate read requests with the shared owner"); + require(fixture, /query:\s*\{\s*kind:\s*"scope",\s*scope:\s*target/, "Scope fixture reads must use the current query wire"); + require(fixture, /data\?\.queryKind === "scope"/, "Scope readbacks must discriminate ready data"); + forbid( + fixture, + /(?:result|promoted)\.view\b/, + "IPC readbacks cannot consume the removed top-level view", + ); + require(gateway, /safeInvoke\(command, args\)/, "Persisted gateway must preserve real bridge diagnostics and fail closed"); + require("src/lib/dev-bridge/safeInvoke.ts", /event === "persisted-atom-update"/, "Native persisted-state subscriptions must fail closed"); + require("src/lib/dev-bridge/safeInvoke.ts", /args_preview: invokeArgsPreview\(command, args\)/, "Draft diagnostics must use the unique content-free preview owner"); + require("src/lib/dev-bridge/safeInvoke.ts", /error: invokeDiagnosticError\(command, error\)/, "Draft errors must not echo user content into diagnostic storage"); + const http = "src/lib/dev-bridge/http-client.ts"; + require(http, /isPersistedAtomCommand\(cmd\)\s*\? invokeArgsPreview\(cmd, args as Record \| undefined\)\s*: args/, "HTTP draft logs must preserve the content-free preview boundary"); + require(http, /console\.error\([\s\S]*?invokeConsoleError\(cmd, (?:error|e)\)/, "HTTP draft failures cannot echo content to the console"); + forbid( + http, + /console\.(?:debug|error)\(\s*`\[DevBridge\] HTTP [^`]*`\s*,\s*(?:error|e)\s*,?\s*\)/, + "HTTP invoke diagnostics must use the shared command-aware error owner", + ); + for (const file of [ + "electron/persistedAtomHost.ts", + "electron/persistedAtomStore.ts", + "electron/persistedAtomFile.ts", + "electron/persistedAtomRecords.ts", + "src/lib/api/persistedAtomTypes.ts", + "src/lib/api/persistedAtomProtocol.ts", + ]) + forbid( + file, + /from\s+["'][^"']*(?:react|prosemirror|components\/input-kit)[^"']*["']/, + "Host transport must remain independent of React and the editor owner", + ); + const draftRuntime = + "src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts"; + require(draftRuntime, /new ComposerDraftCoordinator\(/, "Workspace must assemble the single renderer draft coordinator"); + require(draftRuntime, /\.bindCommitted\(/, "The React editing-target owner must bind committed scopes"); + for (const file of [ + "src/components/agent/chat/components/EmptyState.tsx", + "src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts", + "src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts", + "src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts", + "src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts", + ]) { + forbid( + file, + /new ComposerDraftCoordinator\(|\.bindCommitted\(/, + "Presentation and navigation must delegate draft binding to the React editing-target owner", + ); + forbid( + file, + /(?:localStorage|sessionStorage)\.(?:setItem|getItem)\([\s\S]{0,100}["']composer-drafts/, + "Draft scopes cannot introduce a parallel browser store", + ); + } + forbid( + "src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts", + /composerDraftCoordinator\??\.clear\(/, + "Home and new-task navigation must preserve the source draft instead of discard", + ); + return failures; +} diff --git a/scripts/lib/persisted-atom-contract.test.mjs b/scripts/lib/persisted-atom-contract.test.mjs new file mode 100644 index 000000000..e7a9ec4fc --- /dev/null +++ b/scripts/lib/persisted-atom-contract.test.mjs @@ -0,0 +1,240 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { ESLint } from "eslint"; +import { collectPersistedAtomContractFailures } from "./persisted-atom-contract.mjs"; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const source = (file) => fs.readFileSync(path.join(root, file), "utf8"); +const lint = new ESLint({ + cwd: root, + overrideConfigFile: path.join(root, "eslint.config.js"), +}); +const businessPath = path.join( + root, + "src/components/agent/chat/PersistedAtomLeak.ts", +); +const gatewayPath = path.join(root, "src/lib/api/persistedAtoms.ts"); +// Load the repository configuration once; each assertion still runs real lintText. +await lint.calculateConfigForFile(businessPath); +await lint.calculateConfigForFile(gatewayPath); + +function mutated(file, edit) { + const before = source(file); + const after = edit(before); + expect(after).not.toBe(before); + return collectPersistedAtomContractFailures((current) => + current === file ? after : source(current), + ); +} + +describe("Desktop persisted atom ownership guard", () => { + it("accepts the current Host/gateway/IPC and renderer draft owners", () => { + expect(collectPersistedAtomContractFailures(source)).toEqual([]); + }); + + it.each([ + [ + "src/components/agent/chat/components/EmptyState.tsx", + "new ComposerDraftCoordinator(controller);", + ], + [ + "src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts", + "coordinator.bindCommitted(scope);", + ], + [ + "src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts", + 'localStorage.setItem("composer-drafts-v1", JSON.stringify(draft));', + ], + [ + "src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts", + "composerDraftCoordinator.clear();", + ], + ])( + "rejects a parallel draft owner or navigation discard in %s", + (file, injected) => { + expect(mutated(file, (text) => text + "\n" + injected + "\n")).toEqual( + expect.arrayContaining([expect.objectContaining({ file })]), + ); + }, + ); + + it("rejects missing Host registration", () => { + expect( + mutated("electron/ipcChannels.ts", (text) => + text.replace(' "persisted_atom_update",', ""), + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "electron/ipcChannels.ts" }), + ]), + ); + }); + + it("rejects a command misclassified as an Agent runtime method", () => { + expect( + mutated("src/lib/governance/agentCommandCatalog.json", (text) => { + const catalog = JSON.parse(text); + catalog.runtimeGatewayCommands.push("persisted_atom_read"); + return JSON.stringify(catalog); + }), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: + "Desktop UI persistence cannot become an Agent runtime command", + }), + ]), + ); + }); + + it("rejects missing Host delegation and an unresolved platform path", () => { + for (const edit of [ + (text) => + text.replace( + "persistedAtomHost.updatePersistedAtomState(args)", + "hostCommands.invoke(command, args)", + ), + (text) => + text.replace( + "new PersistedAtomHost(\n desktopStorageRoots.appDataRoot,", + 'new PersistedAtomHost(\n "/tmp/drafts",', + ), + ]) + expect(mutated("electron/main.ts", edit)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "electron/main.ts" }), + ]), + ); + }); + + it("rejects Main or preload bypass of the reserved notification", () => { + expect( + mutated("electron/main.ts", (text) => + text.replace( + "registerRendererEventHandler(ipcMain, broadcast)", + "ipcMain.on(IPC_EMIT_CHANNEL, (_sender, event, payload) => broadcast(event, payload))", + ), + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "electron/main.ts" }), + ]), + ); + expect( + mutated("electron/preload.ts", (text) => + text.replaceAll("!canBroadcastRendererEvent(event)", "false"), + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "electron/preload.ts" }), + ]), + ); + }); + + it("rejects a content-bearing diagnostic or fake successful event subscription", () => { + for (const edit of [ + (text) => text.replaceAll("invokeArgsPreview(command, args)", "args"), + (text) => text.replace('event === "persisted-atom-update"', "false"), + ]) + expect(mutated("src/lib/dev-bridge/safeInvoke.ts", edit)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "src/lib/dev-bridge/safeInvoke.ts" }), + ]), + ); + }); + + it("rejects schema drift, editor dependency and legacy mock restoration", () => { + const cases = [ + [ + "src/lib/api/persistedAtoms.ts", + (text) => + text.replaceAll("parsePersistedAtomReadResult", "parseOwnShape"), + ], + [ + "electron/persistedAtomHost.ts", + (text) => text + '\nimport React from "react";\n', + ], + [ + "src/lib/dev-bridge/mockPriorityCommands.ts", + (text) => text + '\nconst fallback = "persisted_atom_update";\n', + ], + ]; + for (const [file, edit] of cases) + expect(mutated(file, edit)).toEqual( + expect.arrayContaining([expect.objectContaining({ file })]), + ); + }); + + it("rejects HTTP logging that restores full draft arguments or failures", () => { + for (const edit of [ + (text) => + text.replaceAll( + "invokeArgsPreview(cmd, args as Record | undefined)", + "args", + ), + (text) => text.replaceAll("invokeConsoleError(cmd, error)", "error"), + ]) + expect(mutated("src/lib/dev-bridge/http-client.ts", edit)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "src/lib/dev-bridge/http-client.ts", + }), + ]), + ); + }); + + it("rejects restoring a scope-only wire or dropping the shared read request parser", () => { + for (const edit of [ + (text) => + text.replaceAll("parsePersistedAtomReadRequest", "parseOwnRead"), + (text) => + text.replace("query: parsedRequest.query", "scope: request.scope"), + ]) + expect(mutated("src/lib/api/persistedAtoms.ts", edit)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: "src/lib/api/persistedAtoms.ts" }), + ]), + ); + }); + + it("rejects a fixture that consumes old view data or invents its own request validation", () => { + for (const edit of [ + (text) => text.replace("return result.data.view", "return result.view"), + (text) => + text.replace("protocol.parseReadRequest", "parseOwnReadRequest"), + ]) + expect( + mutated("scripts/electron/lib/persisted-atom-fixture.mjs", edit), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + file: "scripts/electron/lib/persisted-atom-fixture.mjs", + }), + ]), + ); + }); + + it("the actual ESLint config rejects business calls and admits the typed gateway", async () => { + const code = + 'import { safeInvoke } from "@/lib/dev-bridge"; export async function save() { await safeInvoke("persisted_atom_update", {}); }'; + const [business] = await lint.lintText(code, { + filePath: businessPath, + }); + expect(business.messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ ruleId: "no-restricted-syntax" }), + ]), + ); + const [gateway] = await lint.lintText(code, { + filePath: gatewayPath, + }); + expect(gateway.messages.filter((entry) => entry.severity === 2)).toEqual( + [], + ); + }); +}); diff --git a/scripts/lib/project-gate-settings-a-core.mjs b/scripts/lib/project-gate-settings-a-core.mjs index 77369e87b..2796aafcd 100644 --- a/scripts/lib/project-gate-settings-a-core.mjs +++ b/scripts/lib/project-gate-settings-a-core.mjs @@ -20,13 +20,12 @@ export const SETTINGS_GATE_A_TABS = Object.freeze([ "memory", "archived-conversations", "providers", + "skills", "media-services", "mcp-server", "web-search", "environment", "execution-policy", - "chrome-relay", - "automation", "developer", "about", ]); diff --git a/scripts/lib/project-gate-settings-a-core.test.mjs b/scripts/lib/project-gate-settings-a-core.test.mjs index 43bf81966..6fb6d3dea 100644 --- a/scripts/lib/project-gate-settings-a-core.test.mjs +++ b/scripts/lib/project-gate-settings-a-core.test.mjs @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { settingsSections } from "../../src/components/settings-v2/sections"; import { SETTINGS_GATE_A_CRITICAL_TABS, @@ -90,6 +91,11 @@ function build(overrides = {}) { } describe("project Gate SETTINGS-01 Gate A evidence", () => { + it("covers every current section without retired navigation targets", () => { + expect(SETTINGS_GATE_A_TABS).toEqual( + settingsSections.map((section) => section.key), + ); + }); it("fails closed when component-state evidence is missing", () => { const evidence = build({ stateObservations: [] }); diff --git a/scripts/settings-all-pages-current-smoke.mjs b/scripts/settings-all-pages-current-smoke.mjs index f47cd6eda..9a3a5c6f4 100644 --- a/scripts/settings-all-pages-current-smoke.mjs +++ b/scripts/settings-all-pages-current-smoke.mjs @@ -4,7 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; +import { SETTINGS_GATE_A_TABS } from "./lib/project-gate-settings-a-core.mjs"; const DEFAULTS = { appUrl: "http://127.0.0.1:1420/", @@ -23,25 +25,6 @@ const DEFAULTS = { headless: true, }; -const SETTINGS_TABS = [ - "设置首页", - "个人资料", - "数据统计", - "外观", - "记忆", - "已归档对话", - "AI 服务商", - "服务模型", - "MCP 服务器", - "网络搜索", - "环境变量", - "执行策略", - "连接器", - "自动化设置", - "开发者与实验功能", - "关于", -]; - const PROBLEM_PATTERNS = [ /无法连接后端桥接/, /Desktop Host 尚未支持命令/, @@ -465,82 +448,19 @@ function clearInvokeBuffers() { } } -async function clickByButtonText(page, label) { - const result = await page.evaluate((targetLabel) => { - const findVisibleButton = (scope) => - [...scope.querySelectorAll("button")].find((candidate) => { - const rect = candidate.getBoundingClientRect(); - if ( - rect.width <= 0 || - rect.height <= 0 || - candidate.hasAttribute("disabled") || - candidate.getAttribute("aria-disabled") === "true" - ) { - return false; - } - const text = candidate.textContent?.replace(/\s+/g, " ").trim(); - const aria = candidate.getAttribute("aria-label"); - return ( - text === targetLabel || - text === `${targetLabel}进入配置` || - text?.startsWith(targetLabel) || - text?.startsWith(`${targetLabel} `) || - aria === targetLabel - ); - }); - - const scopes = [ - document.querySelector('[data-testid="settings-sidebar"]'), - document.querySelector('[data-testid="settings-floating-nav-panel"]'), - document.body, - ].filter(Boolean); - const button = scopes.map(findVisibleButton).find(Boolean); - - if (!button) { - const visibleButtons = [...document.querySelectorAll("button")] - .map((candidate, index) => { - const rect = candidate.getBoundingClientRect(); - return { - index, - visible: rect.width > 0 && rect.height > 0, - text: candidate.textContent?.replace(/\s+/g, " ").trim() ?? "", - aria: candidate.getAttribute("aria-label") ?? "", - testId: candidate.getAttribute("data-testid") ?? "", - disabled: - candidate.hasAttribute("disabled") || - candidate.getAttribute("aria-disabled") === "true", - }; - }) - .filter((candidate) => candidate.visible && !candidate.disabled) - .slice(0, 40); - - return { - clicked: false, - url: window.location.href, - title: document.title, - settingsSidebarMounted: Boolean( - document.querySelector('[data-testid="settings-sidebar"]'), - ), - visibleButtons, - bodyStart: document.body.innerText - ?.replace(/\s+/g, " ") - .trim() - .slice(0, 500), - }; - } - - button.scrollIntoView({ block: "center", inline: "nearest" }); - button.click(); - return { clicked: true }; - }, label); - assert( - result.clicked, - `未找到设置按钮: ${label}; ${formatDiagnostics(result)}`, +async function clickSettingsSection(page, key) { + const button = page.getByTestId(`settings-sidebar-tab-${key}`); + assert((await button.count()) === 1, `设置章节入口必须唯一: ${key}`); + await button.click(); + await page.waitForFunction( + (key) => + document + .querySelector(`[data-testid="settings-sidebar-tab-${key}"]`) + ?.getAttribute("aria-current") === "page", + key, + { timeout: 12_000 }, ); - await page - .waitForLoadState("domcontentloaded", { timeout: 5_000 }) - .catch(() => {}); - await page.waitForTimeout(150); + return button.innerText(); } async function ensureSettingsGroupsExpanded(page) { @@ -635,6 +555,7 @@ async function snapshotSettingsPage(page, tabName, subTabName = null) { recentErrors: errors.slice(-10), traceErrorCount: errorTraces.length, recentTraceErrors: errorTraces.slice(-10), + traces, textStart: sanitizeTextInPage(text.slice(0, 400)), }; }, @@ -749,7 +670,9 @@ async function enterSettings(page, options) { const opened = await page.evaluate(() => { const settings = - document.querySelector('[data-testid="app-sidebar-account-model-settings"]') || + document.querySelector( + '[data-testid="app-sidebar-account-model-settings"]', + ) || [...document.querySelectorAll("button")].find( (button) => button.textContent?.trim() === "模型设置", ) || @@ -782,14 +705,15 @@ async function enterSettings(page, options) { ); } -async function clickSettingsTabs(page, options) { +export async function clickSettingsTabs(page, options) { const results = []; await page.evaluate(() => { localStorage.removeItem("lime_invoke_error_buffer_v1"); localStorage.removeItem("lime_invoke_trace_buffer_v1"); }); - for (const tab of SETTINGS_TABS) { + for (const key of SETTINGS_GATE_A_TABS) { + const tab = key; let settingsMounted = await waitForSettingsMounted(page, 8_000); if (!settingsMounted) { logStage(`recover-settings:${tab}`); @@ -804,7 +728,7 @@ async function clickSettingsTabs(page, options) { ); logStage(`tab:${tab}`); - await clickByButtonText(page, tab); + await clickSettingsSection(page, key); const stillMounted = await waitForSettingsMounted(page, 12_000); assert( stillMounted, @@ -812,7 +736,7 @@ async function clickSettingsTabs(page, options) { await collectPageDiagnostics(page), )}`, ); - await page.waitForTimeout(tab === "AI 服务商" ? 1_800 : 1_300); + await page.waitForTimeout(key === "providers" ? 1_800 : 1_300); results.push(await snapshotSettingsPage(page, tab)); @@ -917,7 +841,7 @@ async function main() { invokeResponseErrors.length === 0, checkedAt: new Date().toISOString(), appUrl: options.appUrl, - tabs: SETTINGS_TABS, + tabs: SETTINGS_GATE_A_TABS, pages, failedPages, consoleErrors, @@ -980,11 +904,16 @@ async function main() { ); } -main().catch((error) => { - console.error( - `[smoke:settings-current] failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - process.exitCode = 1; -}); +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((error) => { + console.error( + `[smoke:settings-current] failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exitCode = 1; + }); +} diff --git a/src/App.tsx b/src/App.tsx index cdc77336f..7f608242b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,7 +63,6 @@ import { listenOpenVoiceModelSettingsRequest, persistVoiceModelSettingsFocusRequest, } from "./lib/voiceModelSettingsNavigation"; -import type { AgentPageParams } from "./types/page"; const AppContainer = styled.div` display: flex; @@ -153,22 +152,17 @@ function AppContent() { requestedPage, requestedPageParams, handleNavigate, + startNewConversation, + returnFromSettings, + reportAgentLocation, + agentLocation, } = useAppNavigation(); const [agentHasMessages, setAgentHasMessages] = useState(false); - const [activeAgentSessionId, setActiveAgentSessionId] = useState< - string | null - >(null); + const activeAgentSessionId = agentLocation?.sessionId ?? null; const [activeAgentStreaming, setActiveAgentStreaming] = useState(false); const [backgroundAgentSessionRuntime, setBackgroundAgentSessionRuntime] = useState(null); const activeAgentPage = requestedPage ?? currentPage; - const activeAgentPageParams = requestedPageParams ?? pageParams; - const activeAgentRouteSessionId = - activeAgentPage === "agent" - ? (( - activeAgentPageParams as AgentPageParams | undefined - )?.initialSessionId?.trim() ?? null) - : null; const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [pendingRecommendation, setPendingRecommendation] = useState<{ @@ -415,13 +409,9 @@ function AppContent() { ); useEffect(() => { if (activeAgentPage !== "agent") { - setActiveAgentSessionId(null); setActiveAgentStreaming(false); - return; } - - setActiveAgentSessionId(activeAgentRouteSessionId); - }, [activeAgentPage, activeAgentRouteSessionId]); + }, [activeAgentPage]); const handleBackgroundSessionRuntimeChange = useCallback( (snapshot: AgentBackgroundSessionRuntimeSnapshot | null) => { @@ -474,6 +464,7 @@ function AppContent() { requestedPage={requestedPage} requestedPageParams={requestedPageParams} onNavigate={handleNavigate} + onStartNewConversation={startNewConversation} onStartWindowDrag={handleWindowDragStart} /> )} @@ -494,8 +485,10 @@ function AppContent() { requestedPage={requestedPage} requestedPageParams={requestedPageParams} onNavigate={handleNavigate} + onStartNewConversation={startNewConversation} onAgentHasMessagesChange={setAgentHasMessages} - onAgentSessionChange={setActiveAgentSessionId} + onAgentLocationChange={reportAgentLocation} + onSettingsBack={returnFromSettings} onAgentStreamingChange={setActiveAgentStreaming} onBackgroundSessionRuntimeChange={ handleBackgroundSessionRuntimeChange diff --git a/src/components/AppPageContent.newConversation.test.tsx b/src/components/AppPageContent.newConversation.test.tsx new file mode 100644 index 000000000..bc4aba2d3 --- /dev/null +++ b/src/components/AppPageContent.newConversation.test.tsx @@ -0,0 +1,88 @@ +import { Suspense, act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentPageParams } from "@/types/page"; +import { AppPageContent } from "./AppPageContent"; + +const page = vi.hoisted(() => ({ + props: null as Record | null, +})); +// Other page presentation is outside this committed Agent route contract. +vi.mock("./scheduled-tasks/ScheduledTasksPage", () => ({ + ScheduledTasksPage: () => null, +})); +vi.mock("./channels/ImConfigPage", () => ({ ImConfigPage: () => null })); +vi.mock("./settings-v2", () => ({ SettingsPage: () => null })); +vi.mock("@/features/plugin/ui/PluginWorkspaceTabs", () => ({ + PluginWorkspaceTabs: () => null, +})); +vi.mock("./agent/chat", () => ({ + AgentChatPage: (props: Record) => { + page.props = props; + return
; + }, +})); +const mounted: Array<() => void> = []; +afterEach(() => { + act(() => { + mounted.splice(0).forEach((close) => close()); + }); + vi.unstubAllGlobals(); +}); + +describe("AppPageContent committed New intent boundary", () => { + it("requested preview cannot forward fresh or reset time before the actual route commits", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + mounted.push(() => { + root.unmount(); + element.remove(); + }); + const startNewConversation = vi.fn(); + const before: AgentPageParams = { + agentEntry: "claw", + initialSessionId: "old-session", + newChatAt: 10, + }; + const requested: AgentPageParams = { + agentEntry: "new-task", + newChatAt: 11, + newConversationRequest: { id: 11, workspaceId: null, freshDraft: true }, + }; + const render = async (committed: AgentPageParams) => { + await act(async () => + root.render( + + + , + ), + ); + }; + await render(before); + expect(page.props?.newConversationRequest).toBeUndefined(); + expect(page.props?.newChatAt).toBeUndefined(); + await render({ + ...requested, + newConversationRequest: { ...requested.newConversationRequest!, id: 12 }, + }); + expect(page.props?.newConversationRequest).toBeUndefined(); + expect(page.props?.newChatAt).toBeUndefined(); + await render(requested); + expect(page.props?.newConversationRequest).toEqual( + requested.newConversationRequest, + ); + expect(page.props?.newChatAt).toBe(11); + expect(page.props?.projectId).toBeUndefined(); + expect(page.props?.onStartNewConversation).toBe(startNewConversation); + }); +}); diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index 5d79a4b56..19c6d7c3c 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -3,6 +3,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { + AgentLocation, AgentPageParams, Page, PageParams, @@ -53,7 +54,7 @@ vi.mock("./channels/ImConfigPage", () => ({ })); vi.mock("./settings-v2", () => ({ - SettingsPageV2: (props: Record) => { + SettingsPage: (props: Record) => { latestSettingsPageProps.value = props; return
; }, @@ -108,7 +109,7 @@ interface RenderContentOptions { requestedPage?: Page; requestedPageParams?: PageParams; onNavigate?: (page: Page, params?: PageParams) => void; - onAgentSessionChange?: (sessionId: string | null) => void; + onAgentLocationChange?: (location: AgentLocation) => void; } function renderContentWithNavigationState(options: RenderContentOptions) { @@ -127,7 +128,8 @@ function renderContentWithNavigationState(options: RenderContentOptions) { requestedPageParams={nextOptions.requestedPageParams} onNavigate={nextOptions.onNavigate ?? vi.fn()} onAgentHasMessagesChange={vi.fn()} - onAgentSessionChange={nextOptions.onAgentSessionChange} + onSettingsBack={vi.fn()} + onAgentLocationChange={nextOptions.onAgentLocationChange} /> , ); @@ -275,8 +277,8 @@ describe("AppPageContent", () => { }); }); - it("AgentChatPage 的 onSessionChange 应回调到上层 onAgentSessionChange", async () => { - const onAgentSessionChange = vi.fn(); + it("AgentChatPage 的 onLocationChange 应回调到上层 onAgentLocationChange", async () => { + const onAgentLocationChange = vi.fn(); renderContentWithNavigationState({ currentPage: "agent", @@ -285,20 +287,26 @@ describe("AppPageContent", () => { projectId: "project-1", initialSessionId: "session-route", } satisfies AgentPageParams, - onAgentSessionChange, + onAgentLocationChange, }); await flushEffects(); - const onSessionChange = latestAgentChatProps.value?.onSessionChange; - expect(typeof onSessionChange).toBe("function"); + const onLocationChange = latestAgentChatProps.value?.onLocationChange; + expect(typeof onLocationChange).toBe("function"); await act(async () => { - (onSessionChange as (sessionId: string | null) => void)(" session-live "); + (onLocationChange as (location: AgentLocation) => void)({ + sessionId: " session-live ", + workspaceId: " workspace-live ", + }); await Promise.resolve(); }); - expect(onAgentSessionChange).toHaveBeenCalledWith("session-live"); - expect(onAgentSessionChange).toHaveBeenCalledTimes(1); + expect(onAgentLocationChange).toHaveBeenCalledWith({ + sessionId: "session-live", + workspaceId: "workspace-live", + }); + expect(onAgentLocationChange).toHaveBeenCalledTimes(1); }); it("agent 页面应透传专家 Agent 身份,重复恢复同一专家不触发新 key", async () => { @@ -865,14 +873,7 @@ describe("AppPageContent", () => { await flushEffects(); expect(latestSettingsPageProps.value).toMatchObject({ - initialTab: "execution-policy", - initialExecutionPolicyFocus: { - section: "network", - ruleId: "download-block", - target: "url", - value: "https://example.com/install.sh", - reasonCode: "request_download_url", - }, + pageParams, }); }); @@ -892,14 +893,7 @@ describe("AppPageContent", () => { await flushEffects(); expect(latestSettingsPageProps.value).toMatchObject({ - initialTab: "providers", - initialProviderView: "settings", - initialProviderFocus: { - providerId: "custom-coder", - modelId: "coder-large", - reasonCode: "missing_enabled_api_key", - recoveryAction: "add_enabled_api_key", - }, + pageParams, }); }); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 0b7b8f9e6..06f7126d7 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -6,6 +6,7 @@ import { lazy, useCallback } from "react"; import type { + AgentLocation, AgentPageParams, PluginsPageParams, ExpertsPageParams, @@ -20,7 +21,7 @@ import type { import type { AgentBackgroundSessionRuntimeSnapshot } from "./agent/chat"; import { ScheduledTasksPage } from "./scheduled-tasks/ScheduledTasksPage"; import { ImConfigPage } from "./channels/ImConfigPage"; -import { SettingsPageV2 } from "./settings-v2"; +import { SettingsPage } from "./settings-v2"; import { PluginWorkspaceTabs } from "@/features/plugin/ui/PluginWorkspaceTabs"; const columnPageStyle = { @@ -69,8 +70,10 @@ interface AppPageContentProps { requestedPage?: Page; requestedPageParams?: PageParams; onNavigate: (page: Page, params?: PageParams) => void; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; onAgentHasMessagesChange: (hasMessages: boolean) => void; - onAgentSessionChange?: (sessionId: string | null) => void; + onAgentLocationChange?: (location: AgentLocation) => void; + onSettingsBack: () => void; onAgentStreamingChange?: (isStreaming: boolean) => void; onBackgroundSessionRuntimeChange?: ( snapshot: AgentBackgroundSessionRuntimeSnapshot | null, @@ -123,8 +126,10 @@ export function AppPageContent({ requestedPage, requestedPageParams, onNavigate, + onStartNewConversation, onAgentHasMessagesChange, - onAgentSessionChange, + onAgentLocationChange, + onSettingsBack, onAgentStreamingChange, onBackgroundSessionRuntimeChange, }: AppPageContentProps) { @@ -140,12 +145,14 @@ export function AppPageContent({ ), ); }; - const handleAgentSessionChange = useCallback( - (sessionId: string | null) => { - const normalizedSessionId = sessionId?.trim(); - onAgentSessionChange?.(normalizedSessionId || null); + const handleAgentLocationChange = useCallback( + (location: AgentLocation) => { + onAgentLocationChange?.({ + sessionId: location.sessionId?.trim() || null, + workspaceId: location.workspaceId?.trim() || null, + }); }, - [onAgentSessionChange], + [onAgentLocationChange], ); if (activePage === "scheduled-tasks") { @@ -173,6 +180,16 @@ export function AppPageContent({ if (activePage === "agent") { const agentPageParams = activePageParams as AgentPageParams; + const committedAgentParams = + currentPage === "agent" ? (pageParams as AgentPageParams) : null; + const committedHome = + committedAgentParams !== null && + committedAgentParams.newChatAt === agentPageParams.newChatAt && + committedAgentParams?.newConversationRequest?.id === + agentPageParams.newConversationRequest?.id; + const committedRequest = committedHome + ? committedAgentParams.newConversationRequest + : undefined; return (
@@ -217,10 +234,12 @@ export function AppPageContent({ agentPageParams.agentEntry !== "new-task" && !agentPageParams.immersiveHome } - newChatAt={agentPageParams.newChatAt} + newChatAt={committedHome ? agentPageParams.newChatAt : undefined} + newConversationRequest={committedRequest} + onStartNewConversation={onStartNewConversation} expertAgentLaunch={agentPageParams.expertAgentLaunch} onHasMessagesChange={onAgentHasMessagesChange} - onSessionChange={handleAgentSessionChange} + onLocationChange={handleAgentLocationChange} onAgentStreamingChange={onAgentStreamingChange} onBackgroundSessionRuntimeChange={onBackgroundSessionRuntimeChange} /> @@ -295,18 +314,10 @@ export function AppPageContent({ if (activePage === "settings") { return (
-
); diff --git a/src/components/AppSidebar.conversationProjects.test.tsx b/src/components/AppSidebar.conversationProjects.test.tsx index a4f91136e..4b9b43088 100644 --- a/src/components/AppSidebar.conversationProjects.test.tsx +++ b/src/components/AppSidebar.conversationProjects.test.tsx @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupAppSidebarTest, flushEffects, @@ -42,25 +43,34 @@ describe("AppSidebar conversation projects", () => { afterEach(cleanupAppSidebarTest); - it("进入项目对话后仍展示全部已打开项目,并查询全局与当前项目会话", async () => { + it("进入项目对话后仍可选择全部已打开项目的会话", async () => { mockListAgentRuntimeSessions.mockImplementation( async (options?: { limit?: number; cwd?: string | string[] }) => - options?.cwd === "/repo/project-1" + options?.cwd === "/repo/project-1" || options?.cwd === "/repo/project-2" ? [ { - id: "session-project-1", - name: "项目一会话", + id: + options.cwd === "/repo/project-1" + ? "session-project-1" + : "session-project-2", + name: + options.cwd === "/repo/project-1" + ? "项目一会话" + : "项目二会话", created_at: 1714000000, updated_at: 1714000600, archived_at: null, - workspace_id: "project-1", - working_dir: "/repo/project-1", + workspace_id: + options.cwd === "/repo/project-1" ? "project-1" : "project-2", + working_dir: options.cwd, }, ] : [], ); + const onNavigate = vi.fn(); const container = mountSidebarContainer({ + onNavigate, currentPage: "agent", currentPageParams: { agentEntry: "claw", @@ -74,6 +84,7 @@ describe("AppSidebar conversation projects", () => { expect(projectGroups[0]?.textContent).toContain("项目一"); expect(projectGroups[0]?.textContent).toContain("项目一会话"); expect(projectGroups[1]?.textContent).toContain("项目二"); + expect(projectGroups[1]?.textContent).toContain("项目二会话"); expect(projectGroups[1]?.textContent).not.toContain("项目一会话"); expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({ limit: 11, @@ -82,9 +93,23 @@ describe("AppSidebar conversation projects", () => { limit: 11, cwd: "/repo/project-1", }); - expect(mockListAgentRuntimeSessions).not.toHaveBeenCalledWith({ + expect(mockListAgentRuntimeSessions).toHaveBeenCalledWith({ limit: 11, cwd: "/repo/project-2", }); + const secondConversation = + projectGroups[1]?.querySelector( + '[data-testid="app-sidebar-conversation-open"][data-session-id="session-project-2"]', + ); + expect(secondConversation).not.toBeNull(); + act(() => secondConversation!.click()); + await flushEffects(); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ + projectId: "project-2", + initialSessionId: "session-project-2", + }), + ); }); }); diff --git a/src/components/AppSidebar.conversations.test.tsx b/src/components/AppSidebar.conversations.test.tsx index 684328b38..6c981cff0 100644 --- a/src/components/AppSidebar.conversations.test.tsx +++ b/src/components/AppSidebar.conversations.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SidebarOpenedProjectSummary } from "@/components/app-sidebar/sidebarConversationGroups"; import { AppSidebar, - TASK_CENTER_CREATE_DRAFT_TASK_EVENT, + mockStartNewConversation, act, buildMockConversationImportPreview, cleanupAppSidebarTest, @@ -139,23 +139,14 @@ describe("AppSidebar conversations", () => { } function captureTaskCenterDraftRequests() { - const receivedDetails: unknown[] = []; - const listener = (event: Event) => { - receivedDetails.push( - event instanceof CustomEvent ? event.detail : undefined, - ); - event.preventDefault(); - }; - window.addEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener); - + const first = mockStartNewConversation.mock.calls.length; return { - receivedDetails, - dispose: () => { - window.removeEventListener( - TASK_CENTER_CREATE_DRAFT_TASK_EVENT, - listener, - ); + get receivedDetails() { + return mockStartNewConversation.mock.calls + .slice(first) + .map(([request]) => request); }, + dispose() {}, }; } @@ -2569,7 +2560,7 @@ describe("AppSidebar conversations", () => { }); expect(taskCenterRequests.receivedDetails).toEqual([ - { source: "sidebar", projectId: "project-1" }, + { projectId: "project-1", freshDraft: false }, ]); } finally { taskCenterRequests.dispose(); @@ -2599,14 +2590,14 @@ describe("AppSidebar conversations", () => { }); expect(taskCenterRequests.receivedDetails).toEqual([ - { source: "sidebar", projectId: null }, + { projectId: null, freshDraft: true }, ]); } finally { taskCenterRequests.dispose(); } }); - it("任务中心不可拦截时,对话加号 fallback 也不应继承当前项目 ID", async () => { + it("公开对话加号路由应保留 explicit null 项目", async () => { mockListAgentRuntimeSessions.mockImplementation( async (options?: { limit?: number; cwd?: string | string[] }) => options?.cwd === "/repo/project-1" @@ -2672,7 +2663,7 @@ describe("AppSidebar conversations", () => { }); expect(taskCenterRequests.receivedDetails).toEqual([ - { source: "sidebar", projectId: "project-1" }, + { projectId: "project-1", freshDraft: true }, ]); } finally { taskCenterRequests.dispose(); diff --git a/src/components/AppSidebar.testFixtures.tsx b/src/components/AppSidebar.testFixtures.tsx index 6ca577e37..2b48d39d3 100644 --- a/src/components/AppSidebar.testFixtures.tsx +++ b/src/components/AppSidebar.testFixtures.tsx @@ -1,5 +1,9 @@ /* eslint-disable react-refresh/only-export-components */ import React from "react"; +import { + buildHomeAgentParams, + type StartNewConversation, +} from "@/lib/workspace/navigation"; import { act as reactAct } from "react"; import { createRoot, type Root } from "react-dom/client"; import { vi } from "vitest"; @@ -7,7 +11,6 @@ import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs as LimeSettingsTabs } from "@/types/settings"; import { AppSidebar as AppSidebarComponent } from "./AppSidebar"; import { changeLimeLocale as changeLimeLocaleImpl } from "@/i18n/createI18n"; -import { TASK_CENTER_CREATE_DRAFT_TASK_EVENT as TASK_CENTER_CREATE_DRAFT_TASK_EVENT_VALUE } from "@/components/agent/chat/taskCenterDraftTaskEvents"; import { LIME_COLOR_SCHEME_STORAGE_KEY as LIME_COLOR_SCHEME_STORAGE_KEY_VALUE } from "@/lib/appearance/colorSchemes"; import { LIME_THEME_STORAGE_KEY as LIME_THEME_STORAGE_KEY_VALUE } from "@/lib/appearance/themeMode"; import { @@ -25,13 +28,14 @@ import type { AgentBackgroundSessionRuntimeSnapshot } from "@/components/agent/c export const act = reactAct; export const AppSidebar = AppSidebarComponent; +export const mockStartNewConversation = vi.fn(); +let newConversationRequestId = 0; + export const changeLimeLocale = changeLimeLocaleImpl; export const SettingsTabs = LimeSettingsTabs; export const LIME_COLOR_SCHEME_STORAGE_KEY = LIME_COLOR_SCHEME_STORAGE_KEY_VALUE; export const LIME_THEME_STORAGE_KEY = LIME_THEME_STORAGE_KEY_VALUE; -export const TASK_CENTER_CREATE_DRAFT_TASK_EVENT = - TASK_CENTER_CREATE_DRAFT_TASK_EVENT_VALUE; export const getStoredOemCloudSessionState = getStoredOemCloudSessionStateImpl; export const setOemCloudBootstrapSnapshot = setOemCloudBootstrapSnapshotImpl; export const setStoredOemCloudSessionState = setStoredOemCloudSessionStateImpl; @@ -393,6 +397,22 @@ export function mountSidebar(options?: { requestedPage={options?.requestedPage} requestedPageParams={options?.requestedPageParams} onNavigate={options?.onNavigate ?? vi.fn()} + onStartNewConversation={(request) => { + mockStartNewConversation(request); + const id = ++newConversationRequestId; + options?.onNavigate?.( + "agent", + buildHomeAgentParams({ + projectId: request.projectId ?? undefined, + newChatAt: id, + newConversationRequest: { + id, + workspaceId: request.projectId, + freshDraft: request.freshDraft, + }, + }), + ); + }} />, ); }); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index fe171252b..f995158ba 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -30,11 +30,8 @@ import { PluginsPageParams, ScheduledTasksPageParams, } from "@/types/page"; -import { - getConfig, - subscribeAppConfigChanged, -} from "@/lib/api/appConfig"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import { getConfig, subscribeAppConfigChanged } from "@/lib/api/appConfig"; +import type { StartNewConversation } from "@/lib/workspace/navigation"; import type { AgentSessionInfo } from "@/lib/api/agentRuntime/sessionTypes"; import { DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, @@ -140,6 +137,7 @@ interface AppSidebarProps { requestedPage?: Page; requestedPageParams?: PageParams; onNavigate: (page: Page, params?: PageParams) => void; + onStartNewConversation?: StartNewConversation; onStartWindowDrag?: (event: ReactMouseEvent) => void; } @@ -154,6 +152,7 @@ export function AppSidebar({ requestedPage, requestedPageParams, onNavigate, + onStartNewConversation, onStartWindowDrag, }: AppSidebarProps) { const { t, i18n } = useTranslation("navigation"); @@ -295,10 +294,10 @@ export function AppSidebar({ }, [conversationScopeProjectId, conversationScopeProjects, openedProjects]); const conversationProjectCwds = useMemo( () => - conversationScopeProjects + conversationDisplayProjects .map((project) => project.rootPath?.trim()) .filter((rootPath): rootPath is string => Boolean(rootPath)), - [conversationScopeProjects], + [conversationDisplayProjects], ); const requireConversationProjectCwd = Boolean(activeAgentProjectId) && conversationProjectCwds.length > 0; @@ -734,7 +733,7 @@ export function AppSidebar({ visibleRecentSidebarSessions, } = useAppSidebarSessions({ currentSessionId, - activeProjectIds: conversationScopeProjects.map((project) => project.id), + activeProjectIds: conversationDisplayProjects.map((project) => project.id), openedProjectCwds: conversationProjectCwds, requireOpenedProjectCwd: requireConversationProjectCwd, shouldShowConversationList, @@ -757,11 +756,7 @@ export function AppSidebar({ protocolHits: threadSearch.hits, }) : sidebarSearchResultSessions, - [ - sidebarSearchHasQuery, - sidebarSearchResultSessions, - threadSearch.hits, - ], + [sidebarSearchHasQuery, sidebarSearchResultSessions, threadSearch.hits], ); const isActive = (item: SidebarNavItem): boolean => { @@ -784,6 +779,7 @@ export function AppSidebar({ projectScopedNavigationProjectId, requestedNavigationTargetRef, onNavigate, + onStartNewConversation, closeSidebarSearchDialog, deferConversationNavigation, beginSidebarSessionAction, @@ -1196,9 +1192,7 @@ export function AppSidebar({ onHome={() => handleNavigate(MAIN_SIDEBAR_NAV_ITEMS[0])} onNavigate={handleNavigate} onExpand={ - collapsed && !isContextlessPage - ? toggleSidebarCollapsed - : undefined + collapsed && !isContextlessPage ? toggleSidebarCollapsed : undefined } searchSlot={ @@ -1268,7 +1262,10 @@ export function AppSidebar({ - onNavigate("agent", buildHomeAgentParams()) + onStartNewConversation?.({ + projectId: null, + freshDraft: false, + }) } aria-label={homeAriaLabel} title={homeAriaLabel} @@ -1405,7 +1402,6 @@ export function AppSidebar({ )} - { it("首页首发等待态应保留 Skill route 与用户可见文本", () => { const request: TaskCenterDraftSendRequest = { id: "draft-send-1", - draftTabId: "session-1", + sessionId: "session-1", text: "帮我整理资料", images: [], sendExecutionStrategy: "react", @@ -35,7 +35,6 @@ describe("buildHomePendingPreviewMessages", () => { }, }, submittedAt: 1_710_000_000_000, - materializeDraft: false, source: "empty-state", }; @@ -69,11 +68,10 @@ describe("buildHomePendingPreviewMessages", () => { it("首页首发等待态应保持 neutral 文案并携带 memory.soul metadata", () => { const request: TaskCenterDraftSendRequest = { id: "draft-send-soul", - draftTabId: "session-soul", + sessionId: "session-soul", text: "帮我整理资料", images: [], submittedAt: 1_710_000_000_000, - materializeDraft: false, source: "empty-state", }; diff --git a/src/components/agent/chat/AgentChatWorkspace.inputRestoreGuard.test.ts b/src/components/agent/chat/AgentChatWorkspace.inputRestoreGuard.test.ts index c0f746d5a..684c13495 100644 --- a/src/components/agent/chat/AgentChatWorkspace.inputRestoreGuard.test.ts +++ b/src/components/agent/chat/AgentChatWorkspace.inputRestoreGuard.test.ts @@ -17,6 +17,7 @@ const INPUT_RESTORE_REQUEST_PRODUCTION_FILES = new Set([ "src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx", "src/components/agent/chat/components/EmptyState.tsx", "src/components/agent/chat/components/EmptyState.types.ts", + "src/components/agent/chat/components/composerSubmissionSettlement.ts", "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts", "src/components/agent/chat/components/Inputbar/index.tsx", "src/components/agent/chat/hooks/agentChatShared.ts", @@ -35,6 +36,12 @@ const INPUT_RESTORE_REQUEST_PRODUCTION_FILES = new Set([ type ProductionAgentChatSource = { relativePath: string; source: string }; +function isAgentChatTestSource(name: string): boolean { + return /\.(?:test|unit\.test|testFixtures|testHarness)\.(?:ts|tsx)$/.test( + name, + ); +} + function usesInterruptedInputRestoreRequest(source: string): boolean { return ( source.includes("inputRestoreRequest") || @@ -66,10 +73,13 @@ function readProductionAgentChatSources() { if (!/\.(ts|tsx)$/.test(entry.name)) { continue; } - if (entry.name.includes(".test.") || entry.name.includes(".unit.test.")) { + if (isAgentChatTestSource(entry.name)) { continue; } - const relativePath = relative(process.cwd(), absolutePath); + const relativePath = relative(process.cwd(), absolutePath).replaceAll( + "\\", + "/", + ); files.push({ relativePath, source: readFileSync(absolutePath, "utf8"), @@ -81,6 +91,17 @@ function readProductionAgentChatSources() { } describe("AgentChatWorkspace input restore boundary", () => { + it.each([ + ["agentStreamRestoreToken.testFixtures.ts", true], + ["composerRestore.testHarness.tsx", true], + ["composerRestore.integration.test.tsx", true], + ["composerSubmissionSettlement.ts", false], + ["testFixturesOwner.ts", false], + ["composerRestore.testFixturesOwner.ts", false], + ])("测试叶分类不排除同名生产文件:%s", (name, expected) => { + expect(isAgentChatTestSource(name)).toBe(expected); + }); + it("local-display owner 只转发恢复请求,不执行 text/path-only fallback", () => { const source = readFileSync( join( diff --git a/src/components/agent/chat/AgentChatWorkspace.taskCenterInteractionBoundaryGuard.test.ts b/src/components/agent/chat/AgentChatWorkspace.taskCenterInteractionBoundaryGuard.test.ts index 87ac7dd4c..02341ba8e 100644 --- a/src/components/agent/chat/AgentChatWorkspace.taskCenterInteractionBoundaryGuard.test.ts +++ b/src/components/agent/chat/AgentChatWorkspace.taskCenterInteractionBoundaryGuard.test.ts @@ -4,7 +4,7 @@ import process from "node:process"; import { describe, expect, it } from "vitest"; describe("AgentChatWorkspace Task Center interaction runtime boundary", () => { - it("草稿物化、切题和 Chrome 接线必须由 interaction runtime 组合", () => { + it("canonical 切题和 Chrome 接线必须由 interaction runtime 组合", () => { const workspaceSource = [ "src/components/agent/chat/useAgentChatWorkspaceRuntime.tsx", "src/components/agent/chat/workspace/useAgentChatWorkspaceEntryRuntime.ts", @@ -38,9 +38,11 @@ describe("AgentChatWorkspace Task Center interaction runtime boundary", () => { expect(shellInteractionOwnerSource).toContain( "useWorkspaceTaskCenterInteractionRuntime(", ); + expect(ownerSource).not.toContain( + "useTaskCenterDraftMaterializationRuntime(", + ); expect(ownerSource.split("\n").length).toBeLessThan(140); for (const retiredWorkspaceTaskCenterInteractionGlue of [ - "useTaskCenterDraftMaterializationRuntime(", "useTaskCenterTopicNavigationRuntime(", "useTaskCenterChromeNavigationRuntime(", ]) { diff --git a/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts b/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts index 0b29f1ff7..3db972fbc 100644 --- a/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts +++ b/src/components/agent/chat/AgentChatWorkspace.workspaceBoundaryGuard.test.ts @@ -69,9 +69,7 @@ describe("AgentChatWorkspace home input navigation boundary", () => { ), "utf8", ); - const handlerStart = ownerSource.indexOf( - "const handleNonMaterializedSessionReady", - ); + const handlerStart = ownerSource.indexOf("const handleSessionAccepted"); const handlerEnd = ownerSource.indexOf( "const handleSendFromEmptyState = useTaskCenterEmptyStateSendRuntime", handlerStart, @@ -80,8 +78,11 @@ describe("AgentChatWorkspace home input navigation boundary", () => { expect(handlerEnd).toBeGreaterThan(handlerStart); const handlerSource = ownerSource.slice(handlerStart, handlerEnd); + expect(handlerSource).toMatch( + /upsertTaskCenterOpenTab\(\s*readySessionId,\s*options\?\.sendSource\?\.workspaceId \?\? taskCenterWorkspaceId,?\s*\)/, + ); expect(handlerSource).toContain( - "upsertTaskCenterOpenTab(readySessionId, taskCenterWorkspaceId)", + "if (options && !isSendSourceCurrent(options, acceptedSessionId)) return", ); expect(handlerSource).toContain( "markTaskCenterLocalSessionOverride(readySessionId)", @@ -175,7 +176,7 @@ describe("AgentChatWorkspace task center draft state boundary", () => { expect(workspaceSource).not.toContain("const [taskCenterDraftSendRequest"); expect(workspaceSource).not.toContain("const [homePendingPreviewRequest"); expect(workspaceSource).not.toContain( - "const taskCenterDraftSurfaceActiveRef = useRef", + "const taskCenterHomeSurfaceActiveRef = useRef", ); expect(workspaceSource).not.toContain("const [taskCenterDraftTabs"); expect(workspaceSource).not.toContain("const [activeTaskCenterDraftTabId"); @@ -188,10 +189,10 @@ describe("AgentChatWorkspace task center draft state boundary", () => { expect(ownerSource).toContain("const [taskCenterDraftSendRequest"); expect(ownerSource).toContain("const [homePendingPreviewRequest"); expect(ownerSource).toContain( - "const taskCenterDraftSurfaceActiveRef = useRef", + "const taskCenterHomeSurfaceActiveRef = useRef", ); - expect(ownerSource).toContain("const [taskCenterDraftTabs"); - expect(ownerSource).toContain("const [activeTaskCenterDraftTabId"); + expect(ownerSource).not.toContain("const [taskCenterDraftTabs"); + expect(ownerSource).not.toContain("const [activeTaskCenterDraftTabId"); expect(ownerSource).toContain( "shouldPauseTaskCenterInitialSessionNavigation({", ); diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index 9bc2a0d25..4b4667b0d 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -1,7 +1,7 @@ import type { CreationMode } from "./components/types"; import type { MessageImage } from "./types"; import type { StepStatus } from "@/lib/workspace/workbenchContract"; -import type { Page, PageParams } from "@/types/page"; +import type { AgentLocation, Page, PageParams } from "@/types/page"; import type { AgentInitialInputCapabilityParams, AgentInitialKnowledgePackSelectionParams, @@ -58,10 +58,12 @@ export interface AgentChatWorkspaceProps { entryBannerMessage?: string; onInitialUserPromptConsumed?: () => void; newChatAt?: number; + newConversationRequest?: import("@/lib/workspace/navigation").NewConversationRequest; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; expertAgentLaunch?: ExpertAgentLaunchParams; onRecommendationClick?: (shortLabel: string, fullPrompt: string) => void; onHasMessagesChange?: (hasMessages: boolean) => void; - onSessionChange?: (sessionId: string | null) => void; + onLocationChange?: (location: AgentLocation) => void; onAgentStreamingChange?: (isStreaming: boolean) => void; onBackgroundSessionRuntimeChange?: ( snapshot: AgentBackgroundSessionRuntimeSnapshot | null, diff --git a/src/components/agent/chat/commands/executor.ts b/src/components/agent/chat/commands/executor.ts index 6aba080bc..fc511dd41 100644 --- a/src/components/agent/chat/commands/executor.ts +++ b/src/components/agent/chat/commands/executor.ts @@ -1,3 +1,8 @@ +import { + isRuntimeSubmissionAccepted, + runRuntimeSubmissionEffect, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; import i18n from "@/i18n/config"; import { cleanBackgroundTerminals, @@ -15,11 +20,15 @@ import type { ExecuteSlashCommandParams } from "./types"; async function buildBackgroundTerminalListMessage( sessionId: string | null, - listTerminals: (threadId: string) => Promise, + listTerminals: ( + threadId: string, + ) => Promise, ): Promise { const threadId = sessionId?.trim() || ""; if (!threadId) { - return shellText("agentChat.navbar.shell.backgroundTerminalsMissingSession"); + return shellText( + "agentChat.navbar.shell.backgroundTerminalsMissingSession", + ); } try { const terminals = await listTerminals(threadId); @@ -45,7 +54,9 @@ async function buildBackgroundTerminalStopMessage( ): Promise { const threadId = sessionId?.trim() || ""; if (!threadId) { - return shellText("agentChat.navbar.shell.backgroundTerminalsMissingSession"); + return shellText( + "agentChat.navbar.shell.backgroundTerminalsMissingSession", + ); } try { await cleanTerminals(threadId); @@ -76,7 +87,7 @@ function ensureSessionMutationAllowed( export async function executeSlashCommand( params: ExecuteSlashCommandParams, -): Promise { +): Promise { const { command, statusSnapshot, @@ -96,7 +107,7 @@ export async function executeSlashCommand( case "compact": await compactSession(); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "clear": if ( !ensureSessionMutationAllowed( @@ -105,11 +116,11 @@ export async function executeSlashCommand( notifyInfo, ) ) { - return true; + return { kind: "local-handled" }; } clearMessages({ toastMessage: "已清空当前任务" }); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "new": { if ( !ensureSessionMutationAllowed( @@ -118,35 +129,35 @@ export async function executeSlashCommand( notifyInfo, ) ) { - return true; + return { kind: "local-handled" }; } const sessionName = command.userInput.trim() || undefined; await createFreshSession(sessionName); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; } case "help": appendAssistantMessage(buildSlashCommandHelpMessage()); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "status": appendAssistantMessage(buildSlashCommandStatusMessage(statusSnapshot)); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "model": if (command.userInput.trim()) { notifyInfo( "当前暂不支持通过 /model 切换模型,请使用输入框右侧的模型选择器", ); - return true; + return { kind: "local-handled" }; } appendAssistantMessage(buildSlashCommandModelMessage(statusSnapshot)); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "subagents": onOpenSubagents?.(); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "ps": appendAssistantMessage( await buildBackgroundTerminalListMessage( @@ -155,7 +166,7 @@ export async function executeSlashCommand( ), ); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "stop": appendAssistantMessage( await buildBackgroundTerminalStopMessage( @@ -164,23 +175,27 @@ export async function executeSlashCommand( ), ); onExecutedCommand?.(command); - return true; + return { kind: "local-handled" }; case "review": case "diff": case "init": { const prompt = buildSlashCommandPrompt(command); if (prompt) { - await sendPrompt(prompt); - onExecutedCommand?.(command); - return true; + const result = await sendPrompt(prompt); + if (isRuntimeSubmissionAccepted(result)) { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + onExecutedCommand?.(command), + ); + } + return result; } - return false; + return null; } default: if (command.definition.support === "unsupported") { notifyInfo(buildUnsupportedSlashCommandMessage(command)); - return true; + return { kind: "local-handled" }; } - return false; + return null; } } diff --git a/src/components/agent/chat/commands/types.ts b/src/components/agent/chat/commands/types.ts index 6d7e337cc..ccfd83f82 100644 --- a/src/components/agent/chat/commands/types.ts +++ b/src/components/agent/chat/commands/types.ts @@ -1,3 +1,4 @@ +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import type { AgentExecutionStrategy } from "@/lib/api/agentExecutionRuntime"; import type { AppServerThreadBackgroundTerminal } from "@/lib/api/appServer"; @@ -36,7 +37,7 @@ export interface SlashCommandStatusSnapshot { export interface ExecuteSlashCommandParams { command: ParsedSlashCommand; statusSnapshot: SlashCommandStatusSnapshot; - sendPrompt: (prompt: string) => Promise; + sendPrompt: (prompt: string) => Promise; compactSession: () => Promise; clearMessages: (options?: { showToast?: boolean; diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 2a94c917f..c0dfe55be 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -1,6 +1,19 @@ +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + a, + b, + harness as coordinatorHarness, + deferred, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; +import { getInputbarSendOptions } from "./Inputbar/inputbarSendPayload"; +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; import { ComposerController } from "@/components/input-kit/ComposerController"; import { insertComposerSkill } from "../skill-selection/composerCapabilityMention"; -import { getComposerEditorText, setComposerEditorText } from "@/components/input-kit/composerEditor.testUtils"; +import { + getComposerEditorText, + setComposerEditorText, +} from "@/components/input-kit/composerEditor.testUtils"; import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -11,7 +24,10 @@ import type { ProjectGitStatus } from "@/lib/api/projectGit"; import type { Skill } from "@/lib/api/skills"; import type { ServiceSkillHomeItem } from "../service-skills/types"; import type { InputCapabilitySelection } from "../skill-selection/inputCapabilitySelection"; -import type { InputbarSendPayload } from "./Inputbar/inputbarSendPayload"; +import type { + InputbarSendHandler, + InputbarSendPayload, +} from "./Inputbar/inputbarSendPayload"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; import { buildCuratedTaskLaunchPrompt, @@ -122,6 +138,11 @@ const mockCharacterMention = vi.fn< }) => React.ReactNode >(); +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); vi.mock("@/lib/api/appConfig", () => ({ getConfig: mockGetConfig, })); @@ -340,6 +361,18 @@ vi.mock("sonner", () => ({ })); const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; +const coordinatorInstances: Awaited>[] = + []; +async function prepareCoordinator() { + const h = await coordinatorHarness({ safeInvoke, safeListen }); + coordinatorInstances.push(h); + await h.coordinator.bindCommitted(a); + h.controller.setText("EmptyState persistent draft"); + return h; +} +afterEach(async () => { + for (const h of coordinatorInstances.splice(0)) await h.close(); +}); beforeEach(async () => { await changeLimeLocale("zh-CN"); @@ -430,7 +463,7 @@ function renderEmptyState( const defaultProps: React.ComponentProps = { input: "", setInput: vi.fn(), - onSend: vi.fn(), + onSend: vi.fn(async () => acceptedSubmission()), providerType: "openai", setProviderType: vi.fn(), model: "gpt-4.1", @@ -481,16 +514,25 @@ function expectEmptyStateSend( expect(actual).toBeTruthy(); expect(actual?.images).toEqual(payload.images); expect(actual?.textOverride).toEqual(payload.textOverride); - expect(actual?.composerDraft?.editorDocument.type).toBe("doc"); - if (actual?.composerSubmission?.text) expect(actual.composerSubmission.text).toBe(actual.textOverride); + expect(actual?.composerDraft?.editorDocument?.type).toBe("doc"); + if (actual?.composerSubmission?.text) + expect(actual.composerSubmission.text).toBe(actual.textOverride); else expect(actual?.composerSubmission?.textElements).toEqual([]); - expect(actual?.sendOptions?.inputRestoreDraft).toEqual({ ...actual?.composerDraft, images: actual?.composerDraft?.attachments }); + expect(actual?.sendOptions?.inputRestoreDraft).toEqual({ + ...actual?.composerDraft, + images: actual?.composerDraft?.attachments, + }); if (payload.sendOptions && "asymmetricMatch" in payload.sendOptions) { expect(actual?.sendOptions).toEqual(payload.sendOptions); } else { - const { inputRestoreDraft: _actualRestore, ...actualOptions } = actual?.sendOptions ?? {}; - const { inputRestoreDraft: expectedRestore, ...expectedOptions } = payload.sendOptions ?? {}; - if (expectedRestore) expect(actual?.sendOptions?.inputRestoreDraft).toMatchObject(expectedRestore); + const { inputRestoreDraft: _actualRestore, ...actualOptions } = + actual?.sendOptions ?? {}; + const { inputRestoreDraft: expectedRestore, ...expectedOptions } = + payload.sendOptions ?? {}; + if (expectedRestore) + expect(actual?.sendOptions?.inputRestoreDraft).toMatchObject( + expectedRestore, + ); expect(actualOptions).toEqual(expectedOptions); } } @@ -504,7 +546,10 @@ function updateFieldValue( return; } - if (element.matches('[data-testid="composer-editor"]')) { setComposerEditorText(element, value); return; } + if (element.matches('[data-testid="composer-editor"]')) { + setComposerEditorText(element, value); + return; + } const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype @@ -1031,9 +1076,13 @@ describe("EmptyState", () => { expect(container.textContent).toContain( "How do I add and use project knowledge?", ); - expect(container.querySelector('[data-testid="composer-editor"][role="textbox"]')?.getAttribute("data-placeholder")).toContain( - "What would you like to learn?", - ); + expect( + container + .querySelector( + '[data-testid="composer-editor"][role="textbox"]', + ) + ?.getAttribute("data-placeholder"), + ).toContain("What would you like to learn?"); }); it("非通用首页快速启动面板应跟随 en-US 资源", async () => { @@ -1058,7 +1107,7 @@ describe("EmptyState", () => { it("仅发送路径引用时应使用当前 locale 的兜底 prompt", async () => { await changeLimeLocale("en-US"); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ activeTheme: "general", input: "", @@ -1109,7 +1158,7 @@ describe("EmptyState", () => { it("启用 pointerdown 发送时首页发送按钮不应在 click 重复发送", async () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_780_000_200_000); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ activeTheme: "general", input: "你好", @@ -1144,7 +1193,7 @@ describe("EmptyState", () => { }); it("从 Skills 页带回的技能应显示在首页输入框内的 @ 标签", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const skill = { key: "local:writer", name: "写作助手", @@ -1201,7 +1250,7 @@ describe("EmptyState", () => { }); it("首页中断恢复请求应由 EmptyState 完整恢复文本、图片、路径和技能 route", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const onInputRestoreRequestHandled = vi.fn(); const pathReference = { id: "file:/tmp/report.md", @@ -1227,7 +1276,12 @@ describe("EmptyState", () => { installed: true, sourceKind: "other", } as Skill; - const draftController = new ComposerController({ text: "继续生成提纲", attachments: [image], pathReferences: [pathReference], inputCapabilityRoute: capabilityRoute }); + const draftController = new ComposerController({ + text: "继续生成提纲", + attachments: [image], + pathReferences: [pathReference], + inputCapabilityRoute: capabilityRoute, + }); insertComposerSkill(draftController, skill); const originalDraft = draftController.captureDraft(); const container = document.createElement("div"); @@ -1285,7 +1339,13 @@ describe("EmptyState", () => { }); await flushAsyncEffects(); - expect(getComposerEditorText(container.querySelector('[data-testid="composer-editor"][role="textbox"]'))).toBe(originalDraft.text); + expect( + getComposerEditorText( + container.querySelector( + '[data-testid="composer-editor"][role="textbox"]', + ), + ), + ).toBe(originalDraft.text); expect( container.querySelector('[data-composer-token="skill"]'), ).toBeTruthy(); @@ -1402,9 +1462,13 @@ describe("EmptyState", () => { expect( container.querySelector('[data-testid="home-starter-chips"]'), ).toBeNull(); - expect(container.querySelector('[data-testid="composer-editor"][role="textbox"]')?.getAttribute("data-placeholder")).toContain( - "想了解什么?试试", - ); + expect( + container + .querySelector( + '[data-testid="composer-editor"][role="textbox"]', + ) + ?.getAttribute("data-placeholder"), + ).toContain("想了解什么?试试"); const closeGuide = container.querySelector( '[data-testid="home-guide-help-active-badge"] button', @@ -1952,13 +2016,11 @@ describe("EmptyState", () => { detail: "正在执行工具批次", supportingLines: [], batchDescriptor: null, - queuedTurnCount: 0, pendingRequestCount: 0, subtaskStats: null, }, onOpenMemoryWorkbench, onOpenChannels, - onOpenChromeRelay, }); await act(async () => { @@ -2309,7 +2371,7 @@ describe("EmptyState", () => { }, ]; const setInput = vi.fn<(value: string) => void>(); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ input: "@", @@ -2417,7 +2479,7 @@ describe("EmptyState", () => { }); it("首页安装技能选择应走统一 capability 回调,且发送后清除激活态", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const skill: Skill = { key: "canvas-design", name: "canvas-design", @@ -2480,7 +2542,12 @@ describe("EmptyState", () => { await Promise.resolve(); }); act(() => { - updateFieldValue(container.querySelector('[data-testid="composer-editor"][role="textbox"]'), "帮我设计封面"); + updateFieldValue( + container.querySelector( + '[data-testid="composer-editor"][role="textbox"]', + ), + "帮我设计封面", + ); }); await act(async () => { await Promise.resolve(); @@ -2503,11 +2570,13 @@ describe("EmptyState", () => { expect(secondPayload?.images).toBeUndefined(); expect(secondPayload?.sendOptions?.capabilityRoute).toBeUndefined(); expect(secondPayload?.composerSubmission?.inputMentions).toEqual([]); - expect(secondPayload?.sendOptions?.inputRestoreDraft?.editorDocument.type).toBe("doc"); + expect( + secondPayload?.sendOptions?.inputRestoreDraft?.editorDocument?.type, + ).toBe("doc"); }); it("首页选择 builtin command 后发送应透传结构化 capability route", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const command = { key: "image-compose", label: "配图", @@ -2632,7 +2701,7 @@ describe("EmptyState", () => { }); it("首页启用项目资料后发送应携带资料引用 metadata", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ input: "按项目资料写一版介绍", onSend, @@ -2693,7 +2762,7 @@ describe("EmptyState", () => { platform_region: "X 与 TikTok 北美区", }, }); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const setInput = vi.fn(); const container = renderEmptyState({ input: prompt, @@ -2795,7 +2864,7 @@ describe("EmptyState", () => { }, ], }); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const setInput = vi.fn(); const container = renderEmptyState({ input: promptWithReference, @@ -2948,7 +3017,7 @@ describe("EmptyState", () => { }); it("首页复盘模板启动时,应默认带入当前项目结果基线", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const setInput = vi.fn(); const container = renderEmptyState({ onSend, @@ -3308,7 +3377,9 @@ describe("EmptyState", () => { await Promise.resolve(); }); - const textarea = container.querySelector('[data-testid="composer-editor"][role="textbox"]'); + const textarea = container.querySelector( + '[data-testid="composer-editor"][role="textbox"]', + ); expect(textarea?.getAttribute("data-placeholder")).toBe( "先说这轮要做什么,目标、对象或限制都可以。", ); @@ -3459,7 +3530,7 @@ describe("EmptyState", () => { }); it("通用首页发送时不应自动注入任何历史默认 skill", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ input: "请输出一篇新品发布文案", activeTheme: "general", @@ -3484,7 +3555,7 @@ describe("EmptyState", () => { }); it("首页开启 plan 和 goal 后发送应绑定当前 thread goal", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ input: "请先规划再持续推进这个任务", activeTheme: "general", @@ -3541,7 +3612,7 @@ describe("EmptyState", () => { chat_appearance: {}, })); - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderEmptyState({ input: "请输出一篇用户访谈纪要", activeTheme: "general", @@ -3566,7 +3637,7 @@ describe("EmptyState", () => { }); it("首页手动选择安装技能后发送时仍应优先使用 capability route", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const skill: Skill = { key: "custom-writing-skill", name: "custom-writing-skill", @@ -3620,7 +3691,7 @@ describe("EmptyState", () => { }); it("首页先选 capability 再切到服务技能入口时,不应继续残留旧 capability route", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); const installedSkill: Skill = { key: "canvas-design", @@ -3792,4 +3863,223 @@ describe("EmptyState", () => { ); expect(onResumeRecentSession).not.toHaveBeenCalled(); }); + it.each([ + { kind: "unknown" as const }, + { kind: "local-handled" as const }, + { kind: "rejected" as const }, + ])("preserves draft for %j", async (result) => { + const controller = new ComposerController({ text: "保留首页富草稿😀" }); + const onSend = vi.fn(async () => result); + const setInput = vi.fn(); + const container = renderEmptyState({ + input: controller.getDocument().text, + composerController: controller, + onSend, + setInput, + activeTheme: "general", + }); + await flushAsyncEffects(); + const before = controller.captureDraft(); + const button = container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement; + expect(button).toBeTruthy(); + await act(async () => { + button.click(); + await Promise.resolve(); + }); + expect(onSend).toHaveBeenCalledOnce(); + expect(controller.captureDraft()).toEqual(before); + expect(setInput).not.toHaveBeenCalledWith(""); + }); + it("starts real consume from the early observer while final send completion is pending", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + const commit = vi.spyOn(h.controller, "commit"); + const setInput = vi.fn(); + const onSend = vi.fn((payload) => { + getInputbarSendOptions(payload!)?.observer?.onRuntimeAccepted?.( + acceptedSubmission().receipt, + ); + return gate.promise; + }); + const container = renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + setInput, + onSend, + }); + await flushAsyncEffects(); + await act(async () => { + ( + container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement + ).click(); + await h.coordinator.idle(); + }); + expect(onSend).toHaveBeenCalledOnce(); + expect( + onSend.mock.calls[0][0]?.composerDraftCapture?.receipt.submission.text, + ).toBe("EmptyState persistent draft"); + expect(commit).toHaveBeenCalledOnce(); + expect(setInput).toHaveBeenCalledWith(""); + await act(async () => { + gate.resolve({ kind: "unknown" }); + await Promise.resolve(); + }); + expect(commit).toHaveBeenCalledOnce(); + }); + + it("settles old capture after navigating and unmounting without clearing the new document", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + const setInput = vi.fn(); + const onSend = vi.fn(() => gate.promise); + const container = renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + setInput, + onSend, + }); + await flushAsyncEffects(); + await act(async () => { + ( + container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement + ).click(); + }); + await act(async () => { + await h.coordinator.bindCommitted(b); + h.controller.setText("new target draft"); + }); + const mounted = mountedRoots.pop()!; + act(() => mounted.root.unmount()); + mounted.container.remove(); + setInput.mockClear(); + await act(async () => { + gate.resolve(acceptedSubmission()); + await Promise.resolve(); + await h.coordinator.idle(); + }); + expect(await h.read(a)).toBeNull(); + expect(h.controller.getDocument().text).toBe("new target draft"); + expect(setInput).not.toHaveBeenCalledWith(""); + }); + + it("rejects persistent restore without the original capture token", async () => { + const h = await prepareCoordinator(); + const handled = vi.fn(); + renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + inputRestoreRequest: { + requestId: "old", + reason: "output_free_interrupted_turn", + draft: { text: "stale other scope" }, + }, + onInputRestoreRequestHandled: handled, + }); + await flushAsyncEffects(); + expect(h.controller.getDocument().text).toBe("EmptyState persistent draft"); + expect(handled).toHaveBeenCalledWith("old"); + }); + + it("restores the accepted draft with a matching original token after consume", async () => { + const h = await prepareCoordinator(); + const onSend = vi.fn(async () => acceptedSubmission()); + const container = renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + onSend, + }); + await flushAsyncEffects(); + await act(async () => { + ( + container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement + ).click(); + await Promise.resolve(); + await h.coordinator.idle(); + }); + const payload = onSend.mock.calls[0][0]!; + expect(h.controller.getDocument().text).toBe(""); + const first = mountedRoots.pop()!; + act(() => first.root.unmount()); + first.container.remove(); + const handled = vi.fn(); + renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: "", + inputRestoreRequest: { + requestId: "same", + reason: "output_free_interrupted_turn", + draft: payload.sendOptions!.inputRestoreDraft!, + composerRestoreToken: payload.composerRestoreToken, + }, + onInputRestoreRequestHandled: handled, + }); + await flushAsyncEffects(); + expect(h.controller.getDocument().text).toBe("EmptyState persistent draft"); + expect(handled).toHaveBeenCalledWith("same"); + }); + it("defers an early stop restore until the same token finishes consuming", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op: string }).op === "consume" + ? gate.promise.then(execute) + : execute(), + ); + const onSend = vi.fn(async () => acceptedSubmission()); + const container = renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + onSend, + }); + await flushAsyncEffects(); + await act(async () => { + ( + container.querySelector( + 'button[aria-label="发送"]', + ) as HTMLButtonElement + ).click(); + await Promise.resolve(); + }); + const payload = onSend.mock.calls[0][0]!; + const first = mountedRoots.pop()!; + act(() => first.root.unmount()); + first.container.remove(); + const handled = vi.fn(); + renderEmptyState({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + input: h.controller.getDocument().text, + inputRestoreRequest: { + requestId: "early", + reason: "output_free_interrupted_turn", + draft: payload.sendOptions!.inputRestoreDraft!, + composerRestoreToken: payload.composerRestoreToken, + }, + onInputRestoreRequestHandled: handled, + }); + await flushAsyncEffects(); + expect(handled).not.toHaveBeenCalled(); + await act(async () => { + gate.resolve(null); + await h.coordinator.idle(); + }); + await flushAsyncEffects(); + expect(handled).toHaveBeenCalledWith("early"); + expect(h.controller.getDocument().text).toBe("EmptyState persistent draft"); + }); }); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 8a182cb56..aa5b3ecbc 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -1,6 +1,15 @@ +import { + canRestoreComposerSubmission, + useComposerSubmissionRestoreState, + createComposerSubmissionSettlement, +} from "./composerSubmissionSettlement"; +import { runRuntimeSubmissionEffect } from "../hooks/agentSendResult"; import { restoreComposerDraft } from "../hooks/composerDraftRestore"; import type { ComposerDraftSnapshot } from "@/components/input-kit"; -import { claimComposerCapabilityInitialization, useComposerCapabilitySelection } from "../skill-selection/useComposerCapabilitySelection"; +import { + claimComposerCapabilityInitialization, + useComposerCapabilitySelection, +} from "../skill-selection/useComposerCapabilitySelection"; import { insertComposerSkill } from "../skill-selection/composerCapabilityMention"; import React, { useCallback, @@ -42,7 +51,10 @@ import { import { buildSkillSelectionProps } from "../skill-selection/skillSelectionBindings"; import { isGeneralResearchTheme } from "../utils/generalAgentPrompt"; import type { MessagePathReference } from "../types"; -import { mergePathReferences, buildPathReferenceRequestMetadata } from "../utils/pathReferences"; +import { + mergePathReferences, + buildPathReferenceRequestMetadata, +} from "../utils/pathReferences"; import { buildKnowledgeRequestMetadata } from "@/features/knowledge/agent/knowledgeMetadata"; import { resolveInputCapabilityDispatch, @@ -80,6 +92,8 @@ const CREATION_THEMES: string[] = []; export const EmptyState: React.FC = ({ composerController: externalComposerController, + composerDraftCoordinator, + onComposerDraftAccepted, input, setInput, onSend, @@ -117,6 +131,7 @@ export const EmptyState: React.FC = ({ onNavigateToSettings, onImportSkill, onRefreshSkills, + onSkillSuggestionsNeeded, onLaunchBrowserAssist, recentSessionTitle = null, recentSessionSummary = null, @@ -168,15 +183,52 @@ export const EmptyState: React.FC = ({ pathReferences: initialPathReferences, }, }); - const pathReferences = useMemo(() => [...composerSnapshot.document.pathReferences], [composerSnapshot.document.pathReferences]); - const handleAddPathReferences = useCallback((references: MessagePathReference[]) => { - composerController.setPathReferences(mergePathReferences([...composerController.getDocument().pathReferences], references)); - onAddPathReferences?.(references); - }, [composerController, onAddPathReferences]); - const handleRemovePathReference = useCallback((id: string) => { - composerController.setPathReferences(composerController.getDocument().pathReferences.filter((reference) => reference.id !== id)); - onRemovePathReference?.(id); - }, [composerController, onRemovePathReference]); + const mounted = useRef(true); + const surface = useRef({ + composerController, + composerDraftCoordinator, + projectId, + sessionId, + }); + surface.current = { + composerController, + composerDraftCoordinator, + projectId, + sessionId, + }; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const pathReferences = useMemo( + () => [...composerSnapshot.document.pathReferences], + [composerSnapshot.document.pathReferences], + ); + const handleAddPathReferences = useCallback( + (references: MessagePathReference[]) => { + composerController.setPathReferences( + mergePathReferences( + [...composerController.getDocument().pathReferences], + references, + ), + ); + onAddPathReferences?.(references); + }, + [composerController, onAddPathReferences], + ); + const handleRemovePathReference = useCallback( + (id: string) => { + composerController.setPathReferences( + composerController + .getDocument() + .pathReferences.filter((reference) => reference.id !== id), + ); + onRemovePathReference?.(id); + }, + [composerController, onRemovePathReference], + ); const promptHistoryClientRef = useRef(null); if (!promptHistoryClientRef.current) { promptHistoryClientRef.current = new AppServerClient(); @@ -208,7 +260,8 @@ export const EmptyState: React.FC = ({ }, [input, setComposerControllerText]); const emittedComposerTextRef = useRef(input); useEffect(() => { - if (emittedComposerTextRef.current === composerSnapshot.document.text) return; + if (emittedComposerTextRef.current === composerSnapshot.document.text) + return; emittedComposerTextRef.current = composerSnapshot.document.text; setInput(composerSnapshot.document.text); }, [composerSnapshot.document.text, setInput]); @@ -238,8 +291,12 @@ export const EmptyState: React.FC = ({ () => buildInputbarCoreCopy(translateAgentCopyKey), [translateAgentCopyKey], ); + const inputRestoreTokenState = useComposerSubmissionRestoreState( + inputRestoreRequest?.composerRestoreToken, + ); const inputRestoreEpochRef = useRef(0); - const [activeCapability, setActiveCapability] = useComposerCapabilitySelection(composerController, skills); + const [activeCapability, setActiveCapability] = + useComposerCapabilitySelection(composerController, skills); const [knowledgeHubOpenRequestKey, setKnowledgeHubOpenRequestKey] = useState(0); const [localObjectiveEnabled, setLocalObjectiveEnabled] = useState(false); @@ -296,7 +353,13 @@ export const EmptyState: React.FC = ({ useEffect(() => { const route = initialInputCapability?.capabilityRoute; - if (!route || !claimComposerCapabilityInitialization(composerController, initialInputCapabilitySignature)) { + if ( + !route || + !claimComposerCapabilityInitialization( + composerController, + initialInputCapabilitySignature, + ) + ) { return; } @@ -396,6 +459,7 @@ export const EmptyState: React.FC = ({ onNavigateToSettings, onImportSkill, onRefreshSkills, + onSkillSuggestionsNeeded, }); const [curatedTaskTemplatesVersion, setCuratedTaskTemplatesVersion] = useState(0); @@ -463,12 +527,23 @@ export const EmptyState: React.FC = ({ controller: composerController, }); - useEffect(() => { if (!inputRestoreRequest || isComposerBusy) { return; } + if (inputRestoreTokenState === "pending") return; + if ( + !canRestoreComposerSubmission({ + controller: composerController, + coordinator: composerDraftCoordinator, + token: inputRestoreRequest.composerRestoreToken, + scope: { projectId, sessionId }, + }) + ) { + onInputRestoreRequestHandled?.(inputRestoreRequest.requestId); + return; + } if (!restoreComposerDraft(composerController, inputRestoreRequest)) return; inputRestoreEpochRef.current += 1; const { requestId } = inputRestoreRequest; @@ -476,7 +551,11 @@ export const EmptyState: React.FC = ({ onInputRestoreRequestHandled?.(requestId); }, [ inputRestoreRequest, + inputRestoreTokenState, composerController, + composerDraftCoordinator, + projectId, + sessionId, setInput, isComposerBusy, onAddPathReferences, @@ -534,7 +613,7 @@ export const EmptyState: React.FC = ({ : normalized; }, [recommendationSelectedText]); - const handleSend = ( + const handleSend = async ( inputOverride = input, modeState?: { goalEnabled?: boolean; @@ -547,7 +626,10 @@ export const EmptyState: React.FC = ({ const pendingImages = [...currentDraft.attachments]; const pathReferences = [...currentDraft.pathReferences]; const submissionCapability = currentDraft.inputCapabilityRoute - ? resolveInputCapabilitySelectionFromRoute({ route: currentDraft.inputCapabilityRoute as InputCapabilitySendRoute, skills }) + ? resolveInputCapabilitySelectionFromRoute({ + route: currentDraft.inputCapabilityRoute as InputCapabilitySendRoute, + skills, + }) : null; const composerInputOverride = inputOverride === input @@ -572,30 +654,82 @@ export const EmptyState: React.FC = ({ workspaceId: projectId ?? null, }); const sendRestoreEpoch = inputRestoreEpochRef.current; - const hasPathReferences = pathReferences.length > 0; + const hasPathReferences = currentDraft.pathReferences.length > 0; if ( isComposerBusy || (!composerInputOverride.trim() && - pendingImages.length === 0 && + currentDraft.attachments.length === 0 && !hasPathReferences) ) { - return; + return { kind: "rejected" as const }; } - const imagesToSend = pendingImages.length > 0 ? pendingImages : undefined; const capabilityDispatch = { - ...resolveInputCapabilityDispatch(submissionCapability, composerInputOverride), - capabilityRoute: currentDraft.inputCapabilityRoute as InputCapabilitySendRoute | undefined, + ...resolveInputCapabilityDispatch( + submissionCapability, + composerInputOverride, + ), + capabilityRoute: currentDraft.inputCapabilityRoute as + | InputCapabilitySendRoute + | undefined, }; const submitTarget: ComposerSubmitTarget = triggerMetadata?.submitTarget ?? (isLoading ? "steer" : "start"); setComposerControllerText(composerInputOverride); - const composerReceipt = composerController.submit(submitTarget); + const settlement = createComposerSubmissionSettlement({ + controller: composerController, + coordinator: composerDraftCoordinator, + target: submitTarget, + onDraftAccepted: onComposerDraftAccepted, + scope: { projectId, sessionId }, + canApplyUi: () => + mounted.current && + surface.current.composerController === composerController && + surface.current.composerDraftCoordinator === composerDraftCoordinator && + (!!composerDraftCoordinator || + (surface.current.projectId === projectId && + surface.current.sessionId === sessionId)) && + inputRestoreEpochRef.current === sendRestoreEpoch, + onAccepted: (result, draft) => { + if (draft?.text.trim()) { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => { + void promptHistoryClientRef.current + ?.appendPromptHistory({ + threadId: result.receipt.threadId, + text: draft.text, + }) + .catch(() => undefined); + }); + } + }, + onCommitted: (result) => { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + setComposerText(""), + ); + runRuntimeSubmissionEffect( + result.receipt, + "accepted_submit", + clearPendingImages, + ); + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + onClearPathReferences?.(), + ); + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + clearSelectedSkill?.(), + ); + }, + }); + const composerReceipt = settlement.receipt; + if (!composerReceipt) return { kind: "rejected" as const }; if (composerReceipt.kind === "empty") { - return; + return { kind: "rejected" as const }; } + const imagesToSend = + composerReceipt.submission.attachments.length > 0 + ? [...composerReceipt.submission.attachments] + : undefined; const baseRequestMetadata = buildPathReferenceRequestMetadata( capabilityDispatch.requestMetadata, - pathReferences, + [...composerReceipt.submission.pathReferences], ); const knowledgeRequestMetadata = knowledgePackSelection?.enabled && @@ -617,7 +751,8 @@ export const EmptyState: React.FC = ({ }; const requestMetadata = knowledgeRequestMetadata; const threadGoal = - (modeState?.goalEnabled ?? objectiveEnabled) && composerInputOverride.trim() + (modeState?.goalEnabled ?? objectiveEnabled) && + composerInputOverride.trim() ? { objective: composerInputOverride.trim() } : undefined; const toolPreferencesOverride = @@ -633,7 +768,9 @@ export const EmptyState: React.FC = ({ const inputRestoreDraft = { ...composerReceipt.draft, images: [...composerReceipt.draft.attachments], - inputCapabilityRoute: composerReceipt.draft.inputCapabilityRoute as InputCapabilitySendRoute | undefined, + inputCapabilityRoute: composerReceipt.draft.inputCapabilityRoute as + | InputCapabilitySendRoute + | undefined, }; const shouldAttachInputRestoreDraft = true; const sendOptions = @@ -659,54 +796,29 @@ export const EmptyState: React.FC = ({ } : undefined; - const sendResult = onSend({ - images: imagesToSend, - textOverride: effectiveInput, - sendOptions, - composerIntent: composerReceipt.intent, - composerTarget: composerReceipt.target, - composerDraft: composerReceipt.draft, - composerSubmission: composerReceipt.submission, - ...(triggerMetadata - ? { - triggeredAt: triggerMetadata.triggeredAt, - triggerSource: triggerMetadata.triggerSource, - } - : {}), - }); - const clearAcceptedSubmissionState = () => { - if (inputRestoreEpochRef.current !== sendRestoreEpoch) { - return; - } - if (!composerController.commit(composerReceipt)) { - return; - } - if (sessionId && composerReceipt.submission.text.trim()) { - void promptHistoryClientRef.current - ?.appendPromptHistory({ - threadId: sessionId, - text: composerReceipt.submission.text, - }) - .catch(() => undefined); - } - setComposerText(""); - clearPendingImages(); - onClearPathReferences?.(); - clearSelectedSkill?.(); - }; - if (sendResult === false) { - return false; - } - if (sendResult && typeof sendResult === "object" && "then" in sendResult) { - return sendResult.then((accepted) => { - if (accepted !== false) { - clearAcceptedSubmissionState(); - } - return accepted; + try { + const result = await onSend({ + onRuntimeAccepted: settlement.onRuntimeAccepted, + composerRestoreToken: settlement.composerRestoreToken, + composerDraftCapture: settlement.composerDraftCapture, + images: imagesToSend, + textOverride: effectiveInput, + sendOptions, + composerIntent: composerReceipt.intent, + composerTarget: composerReceipt.target, + composerDraft: composerReceipt.draft, + composerSubmission: composerReceipt.submission, + ...(triggerMetadata + ? { + triggeredAt: triggerMetadata.triggeredAt, + triggerSource: triggerMetadata.triggerSource, + } + : {}), }); + return settlement.finish(result); + } catch (error) { + return settlement.reject(error); } - clearAcceptedSubmissionState(); - return sendResult; }; const handleComposerHistoryRecall = (draft: ComposerDraftSnapshot) => { @@ -921,9 +1033,8 @@ export const EmptyState: React.FC = ({ }); const handleStop = useCallback(() => { - composerController.submit("interrupt", { allowEmpty: true }); onStop?.(); - }, [composerController, onStop]); + }, [onStop]); const composerPanel = ( diff --git a/src/components/agent/chat/components/EmptyState.types.ts b/src/components/agent/chat/components/EmptyState.types.ts index eef57115d..b5cf59187 100644 --- a/src/components/agent/chat/components/EmptyState.types.ts +++ b/src/components/agent/chat/components/EmptyState.types.ts @@ -1,3 +1,8 @@ +import type { + ComposerDraftCoordinator, + ComposerDraftCapture, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "@/components/agent/chat/hooks/agentSendResult"; import type { ComposerController } from "@/components/input-kit"; import type { Character } from "@/lib/api/projectMemory"; import type { ModelReasoningEffortLevel } from "@/lib/types/modelRegistry"; @@ -5,7 +10,7 @@ import type { AgentI18nResource } from "@/i18n/agentResources"; import type { AgentInitialInputCapabilityParams } from "@/types/page"; import type { SkillSelectionSourceProps } from "../skill-selection/skillSelectionBindings"; import type { AgentAccessMode } from "../hooks/agentChatStorage"; -import type { InterruptedInputRestoreRequest } from "../hooks/agentStreamInputRestoreTypes"; +import type { ScopedComposerRestoreRequest } from "./composerSubmissionSettlement"; import type { CreationReplaySurfaceModel } from "../utils/creationReplaySurface"; import type { CuratedTaskReferenceEntry } from "../utils/curatedTaskReferenceSelection"; import type { AgentTaskRuntimeCardModel } from "../utils/agentTaskRuntime"; @@ -26,6 +31,11 @@ export type AgentI18nKey = keyof AgentI18nResource; export interface EmptyStateProps extends SkillSelectionSourceProps { composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; input: string; setInput: (value: string) => void; onSend: InputbarSendHandler; @@ -134,7 +144,7 @@ export interface EmptyStateProps extends SkillSelectionSourceProps { /** 输入框已添加的本地文件/文件夹引用 */ pathReferences?: MessagePathReference[]; onAddPathReferences?: (references: MessagePathReference[]) => void; - inputRestoreRequest?: InterruptedInputRestoreRequest | null; + inputRestoreRequest?: ScopedComposerRestoreRequest | null; onInputRestoreRequestHandled?: (requestId: string) => void; onImportPathReferenceAsKnowledge?: (reference: MessagePathReference) => void; onRemovePathReference?: (id: string) => void; diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.inputFlow.test.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.inputFlow.test.tsx index 4772f2916..c40442e8a 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.inputFlow.test.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.inputFlow.test.tsx @@ -3,14 +3,28 @@ import { join } from "node:path"; import process from "node:process"; import { act } from "react"; import { describe, expect, it, vi } from "vitest"; +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; import { mockEmptyStateCharacterMention, + createSkillSelection, renderPanel, updateComposerEditorValue, } from "./EmptyStateComposerPanel.testFixtures"; -import { COMPOSER_EDITOR_SELECTOR, getComposerEditorText } from "@/components/input-kit/composerEditor.testUtils"; +import { + COMPOSER_EDITOR_SELECTOR, + getComposerEditorText, +} from "@/components/input-kit/composerEditor.testUtils"; describe("EmptyStateComposerPanel", () => { + it("requests the shared lazy Skill catalog from the Home mention surface", () => { + const onSkillSuggestionsNeeded = vi.fn(); + renderPanel({ + skillSelection: createSkillSelection({ onSkillSuggestionsNeeded }), + }); + const mention = mockEmptyStateCharacterMention.mock.calls.at(-1)?.[0]; + act(() => mention?.onSkillSuggestionsNeeded?.()); + expect(onSkillSuggestionsNeeded).toHaveBeenCalledOnce(); + }); it("不应重新接入 input restore request 子面板 fallback", () => { const source = readFileSync( join( @@ -31,7 +45,9 @@ describe("EmptyStateComposerPanel", () => { container.querySelector('[data-testid="home-input-tab-suggestion"]'), ).toBeNull(); - const editor = container.querySelector(COMPOSER_EDITOR_SELECTOR); + const editor = container.querySelector( + COMPOSER_EDITOR_SELECTOR, + ); await act(async () => { editor?.dispatchEvent( new KeyboardEvent("keydown", { @@ -43,15 +59,15 @@ describe("EmptyStateComposerPanel", () => { await Promise.resolve(); }); - expect( - getComposerEditorText(editor), - ).toBe(""); + expect(getComposerEditorText(editor)).toBe(""); }, 15_000); it("Shift+Tab 保持焦点切换,不填入起手建议", () => { const container = renderPanel(); - const editor = container.querySelector(COMPOSER_EDITOR_SELECTOR); + const editor = container.querySelector( + COMPOSER_EDITOR_SELECTOR, + ); act(() => { editor?.dispatchEvent( new KeyboardEvent("keydown", { @@ -63,9 +79,7 @@ describe("EmptyStateComposerPanel", () => { ); }); - expect( - getComposerEditorText(editor), - ).toBe(""); + expect(getComposerEditorText(editor)).toBe(""); }); it("引导帮助模式应展示可关闭的上下文 badge 并隐藏 Tab 起手建议", () => { @@ -110,7 +124,9 @@ describe("EmptyStateComposerPanel", () => { it("应将 onPaste 绑定到输入框", () => { const onPaste = vi.fn(); const container = renderPanel({ onPaste }); - const editor = container.querySelector(COMPOSER_EDITOR_SELECTOR); + const editor = container.querySelector( + COMPOSER_EDITOR_SELECTOR, + ); expect(editor).toBeTruthy(); @@ -121,10 +137,12 @@ describe("EmptyStateComposerPanel", () => { expect(onPaste).toHaveBeenCalledTimes(1); }); - it("发送时应把本地草稿显式传给首页发送链", () => { - const onSend = vi.fn(); + it("发送时应把本地草稿显式传给首页发送链", async () => { + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderPanel({ onSend }); - const editor = container.querySelector(COMPOSER_EDITOR_SELECTOR); + const editor = container.querySelector( + COMPOSER_EDITOR_SELECTOR, + ); updateComposerEditorValue(editor, "帮我快速开一个新对话"); @@ -134,7 +152,7 @@ describe("EmptyStateComposerPanel", () => { expect(sendButton?.disabled).toBe(false); - act(() => { + await act(async () => { sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -150,13 +168,11 @@ describe("EmptyStateComposerPanel", () => { triggeredAt: expect.any(Number), }), ); - expect( - getComposerEditorText(editor), - ).toBe(""); + expect(getComposerEditorText(editor)).toBe(""); }); it("选择插件应在草稿插入 inline atom,并将显示文本发送", async () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const plugin = { pluginId: "content-factory-app", displayName: "写文章", @@ -179,20 +195,25 @@ describe("EmptyStateComposerPanel", () => { }); expect( - container.querySelector('[data-composer-token="plugin"]') - ?.textContent, + container.querySelector('[data-composer-token="plugin"]')?.textContent, ).toContain("写文章"); expect( getComposerEditorText(container.querySelector(COMPOSER_EDITOR_SELECTOR)), ).toBe("@写文章 帮我整理项目资料"); - expect(container.querySelector('[data-composer-token="plugin"]')?.getAttribute("contenteditable")).toBe("false"); - expect(container.querySelector('[data-testid="inputbar-plugin-badge"]')).toBeNull(); + expect( + container + .querySelector('[data-composer-token="plugin"]') + ?.getAttribute("contenteditable"), + ).toBe("false"); + expect( + container.querySelector('[data-testid="inputbar-plugin-badge"]'), + ).toBeNull(); const sendButton = container.querySelector( 'button[title="发送"]', ) as HTMLButtonElement | null; - act(() => { + await act(async () => { sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); @@ -211,7 +232,7 @@ describe("EmptyStateComposerPanel", () => { }); it("发送准备中应禁用首页发送入口并展示忙碌态", () => { - const onSend = vi.fn(); + const onSend = vi.fn(async () => acceptedSubmission()); const container = renderPanel({ input: "请帮我梳理首页首次发送链路", isLoading: true, @@ -219,7 +240,9 @@ describe("EmptyStateComposerPanel", () => { onSend, }); - const editor = container.querySelector(COMPOSER_EDITOR_SELECTOR); + const editor = container.querySelector( + COMPOSER_EDITOR_SELECTOR, + ); const runningButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("正在输出"), ) as HTMLButtonElement | undefined; @@ -286,5 +309,4 @@ describe("EmptyStateComposerPanel", () => { expect(onRemoveImage).toHaveBeenCalledWith(0); }); - }); diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.testFixtures.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.testFixtures.tsx index 80c9ada77..c65c43a1e 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.testFixtures.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.testFixtures.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, vi } from "vitest"; import { EmptyStateComposerPanel } from "./EmptyStateComposerPanel"; +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; import { buildSkillSelectionProps, type SkillSelectionProps, @@ -30,18 +31,21 @@ vi.mock("./ChatModelSelector", () => ({ ChatModelSelector: () =>
, })); -export const mockEmptyStateCharacterMention = vi.fn< - (props: { - onSelectPlugin?: ( - plugin: InputbarPluginCapability, - skill?: InputbarPluginSkillCapability, - options?: InputbarPluginSelectionOptions, - ) => void; - }) => React.ReactNode ->(); +export const mockEmptyStateCharacterMention = + vi.fn< + (props: { + onSkillSuggestionsNeeded?: () => void; + onSelectPlugin?: ( + plugin: InputbarPluginCapability, + skill?: InputbarPluginSkillCapability, + options?: InputbarPluginSelectionOptions, + ) => void; + }) => React.ReactNode + >(); vi.mock("../skill-selection/CharacterMention", () => ({ CharacterMention: (props: { + onSkillSuggestionsNeeded?: () => void; onSelectPlugin?: ( plugin: InputbarPluginCapability, skill?: InputbarPluginSkillCapability, @@ -208,7 +212,7 @@ export function renderPanel( input: "", setInput: vi.fn(), placeholder: "输入内容", - onSend: vi.fn(), + onSend: vi.fn(async () => acceptedSubmission()), activeTheme: "general", providerType: "openai", setProviderType: vi.fn(), @@ -277,7 +281,7 @@ export function renderStatefulPanel( input={input} setInput={setInput} placeholder="输入内容" - onSend={vi.fn()} + onSend={vi.fn(async () => acceptedSubmission())} activeTheme="general" providerType="openai" setProviderType={vi.fn()} diff --git a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx index 2362ed6b1..edf671b7f 100644 --- a/src/components/agent/chat/components/EmptyStateComposerPanel.tsx +++ b/src/components/agent/chat/components/EmptyStateComposerPanel.tsx @@ -1,5 +1,12 @@ +import { + isRuntimeSubmissionAccepted, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; import type { ComposerDraftSnapshot } from "@/components/input-kit"; -import { insertComposerSkill, insertComposerPlugin } from "../skill-selection/composerCapabilityMention"; +import { + insertComposerSkill, + insertComposerPlugin, +} from "../skill-selection/composerCapabilityMention"; import { ComposerController } from "@/components/input-kit"; import type { ComposerEditorHandle } from "@/components/input-kit"; import React, { useEffect, useRef, useState } from "react"; @@ -145,7 +152,7 @@ interface EmptyStateComposerPanelProps { subagentEnabled?: boolean; }, triggerMetadata?: BaseComposerSendMetadata, - ) => void | boolean | Promise; + ) => Promise; onStop?: () => void; sendOnPointerDown?: boolean; isLoading?: boolean; @@ -350,7 +357,9 @@ export function EmptyStateComposerPanel({ const { composerDecorations } = useHomeSkinPresentation(); const [activePluginSelection, setActivePluginSelection] = useState(null); - const [fallbackController] = useState(() => controller ?? new ComposerController({ text: input })); + const [fallbackController] = useState( + () => controller ?? new ComposerController({ text: input }), + ); const resolvedController = controller ?? fallbackController; const editorRef = useRef(null); const imageInputRef = useRef(null); @@ -369,10 +378,22 @@ export function EmptyStateComposerPanel({ } = activeCapabilityState; const { mentionProps: mentionSkillProps, selectorProps: skillSelectorProps } = buildSkillSelectionBindings(skillSelection); - useEffect(() => resolvedController.subscribe(() => { - const document = resolvedController.getDocument(); - if (activePluginSelection && !document.mentionBindings.some((binding) => binding.kind === "plugin" && binding.id === activePluginSelection.plugin.pluginId)) setActivePluginSelection(null); - }), [activePluginSelection, resolvedController]); + useEffect( + () => + resolvedController.subscribe(() => { + const document = resolvedController.getDocument(); + if ( + activePluginSelection && + !document.mentionBindings.some( + (binding) => + binding.kind === "plugin" && + binding.id === activePluginSelection.plugin.pluginId, + ) + ) + setActivePluginSelection(null); + }), + [activePluginSelection, resolvedController], + ); const handleSendDraft = (triggerMetadata?: BaseComposerSendMetadata) => { const submittedInput = resolvedController.getDocument().text; @@ -385,18 +406,10 @@ export function EmptyStateComposerPanel({ }, triggerMetadata, ); - if (result === false || controller) { - return; - } - if (result && typeof result === "object" && "then" in result) { - void result.then((accepted) => { - if (accepted !== false) { - setInput(""); - } - }); - return; - } - setInput(""); + if (controller) return; + void result.then((accepted) => { + if (isRuntimeSubmissionAccepted(accepted)) setInput(""); + }); }; const handleToggleSubagentMode = () => { @@ -429,13 +442,21 @@ export function EmptyStateComposerPanel({ if (blocked) { return; } - insertComposerPlugin(resolvedController, plugin, skill, { start: 0, end: 0 }); + insertComposerPlugin(resolvedController, plugin, skill, { + start: 0, + end: 0, + }); setInput(resolvedController.getDocument().text); - setActivePluginSelection({ plugin, skill, trigger: normalizeInputbarPluginTrigger(plugin, skill), text: resolvedController.getDocument().text, preserveInput: true }); + setActivePluginSelection({ + plugin, + skill, + trigger: normalizeInputbarPluginTrigger(plugin, skill), + text: resolvedController.getDocument().text, + preserveInput: true, + }); editorRef.current?.focus(); }; - const handleToolAction = (tool: string) => { switch (tool) { case "attach": @@ -532,10 +553,17 @@ export function EmptyStateComposerPanel({ /> ) : undefined; const plusMenuSkillsPanel = isGeneralTheme ? ( - { - if (capability.kind === "installed_skill") { insertComposerSkill(resolvedController, capability.skill); setInput(resolvedController.getDocument().text); } - skillSelectorProps.onSelectInputCapability(capability, options); - }} renderMode="inline" /> + { + if (capability.kind === "installed_skill") { + insertComposerSkill(resolvedController, capability.skill); + setInput(resolvedController.getDocument().text); + } + skillSelectorProps.onSelectInputCapability(capability, options); + }} + renderMode="inline" + /> ) : undefined; const plusMenuPluginsPanel = ( ({ + send: undefined as (() => Promise) | undefined, +})); + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/api/promptHistory", () => ({ + readPromptHistory: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/api/hintRoutes", () => ({ + listHintRoutes: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: "zh-CN" }, + }), +})); +vi.mock("@/lib/api/permissionProfiles", () => ({ + resolveAllowedPermissionProfile: vi.fn().mockResolvedValue({ allowed: true }), +})); +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: vi.fn(async () => [ + { + id: "fixture", + provider_id: "openai", + capabilities: { vision: true, reasoning: false }, + task_families: ["chat", "vision_understanding"], + input_modalities: ["text", "image"], + output_modalities: ["text"], + runtime_features: ["streaming"], + capability_provenance: "provider_explicit", + }, + ]), + }, +})); +vi.mock("@/components/agent/chat/components/CuratedTaskLauncherDialog", () => ({ + CuratedTaskLauncherDialog: () => null, +})); +vi.mock("@/components/agent/chat/skill-selection/CuratedTaskBadge", () => ({ + CuratedTaskBadge: () => null, +})); +vi.mock("@/lib/api/appConfig", () => ({ getConfig: vi.fn(async () => ({})) })); +vi.mock("@/components/agent/chat/components/useHomeSkillSurface", () => ({ + useHomeSkillSurface: () => ({ + galleryItems: [], + guideCards: [], + serviceSkillItems: [], + skillItems: [], + skillSections: [], + starterChips: [], + }), +})); +vi.mock("@/components/agent/chat/home/HomeStartSurface", () => ({ + HomeStartSurface: () => null, +})); +vi.mock("@/components/agent/chat/components/EmptyStateQuickActions", () => ({ + EmptyStateQuickActions: () => null, +})); +vi.mock("@/components/agent/chat/components/EmptyStateLayout", () => ({ + EmptyStateComposerFrame: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + EmptyStatePrimaryStack: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + EmptyStateLayout: ({ prioritySlot }: { prioritySlot: React.ReactNode }) => ( + <>{prioritySlot} + ), +})); +vi.mock("@/components/agent/chat/components/EmptyStateComposerPanel", () => ({ + // Only presentation is stubbed; EmptyState owns send/Stop/settlement/restore. + EmptyStateComposerPanel: ({ + onSend, + onStop, + }: { + onSend: () => Promise; + onStop: () => void; + }) => { + homePanel.send = onSend; + return ( + + ); + }, +})); + +type PersistenceHarness = Awaited>; +const mounted: { root: Root; element: HTMLElement; h: PersistenceHarness }[] = + []; +const skill: Skill = { + key: "user:capability-report", + name: "Capability Report", + description: "Report", + directory: "/tmp/capability-report", + installed: true, + sourceKind: "other", +}; +const skills = [skill]; +const noop = () => undefined; + +beforeEach(() => { + vi.useFakeTimers(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +}); +afterEach(async () => { + for (const item of mounted.splice(0)) { + act(() => item.root.unmount()); + item.element.remove(); + await item.h.close(); + } + vi.useRealTimers(); +}); + +async function setup({ + persistent = true, + delayedConsume = false, + home = false, + runtimeScope = null as DraftScope | null, + initialSkills = skills, +} = {}) { + let catalog = initialSkills; + const h = await harness({ safeInvoke, safeListen }); + if (runtimeScope) h.coordinator.detach(); + else if (persistent) await h.coordinator.bindCommitted(a); + let workspaceRuntime!: ReturnType; + const currentOwner = () => + runtimeScope ? workspaceRuntime.composerDraftCoordinator! : h.coordinator; + h.controller.setText("原始中😀 "); + h.controller.ingestHTML( + '

链接中😀

const value = "中😀";
', + ); + h.controller.ingestPaste("历史粘贴验证😀 ".repeat(180)); + insertComposerSkill(h.controller, skill); + h.controller.setInputCapabilityRoute( + resolveInputCapabilitySendRoute({ kind: "installed_skill", skill }), + ); + h.controller.setAttachments([ + { data: "cGljdHVyZQ==", mediaType: "image/png" }, + ]); + h.controller.setPathReferences([ + { + id: "file:/tmp/composer-reference.md", + path: "/tmp/composer-reference.md", + name: "composer-reference.md", + isDir: false, + source: "file_manager", + }, + ]); + h.controller.setSelection(1, 4); + const original = h.controller.captureDraft(); + const restoreDraft = vi.spyOn(h.controller, "restoreDraft"); + const gate = deferred(); + if (delayedConsume) + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op: string }).op === "consume" + ? gate.promise.then(execute) + : execute(), + ); + const stream = createRestoreTokenFixture(); + let latest!: ReturnType; + let setRequest!: React.Dispatch< + React.SetStateAction + >; + let lastPayload: InputbarSendPayload | undefined; + let stopping: Promise | undefined; + let stopBefore = -1; + let scope = { projectId: "w", sessionId: "s1" }; + const onSend = vi.fn(async (payload: InputbarSendPayload = {}) => { + lastPayload = payload; + const prepared = prepareAgentStreamUserInputSend({ + content: payload.textOverride ?? "", + images: payload.images ?? [], + skipUserMessage: false, + options: getInputbarSendOptions(payload), + env: stream.env, + }); + const receipt = await submitAgentStreamUserInput({ + preparedSend: prepared, + env: stream.env, + }); + // Test runtime projection anchors Stop to the actual accepted turn identity. + stream.state.currentTurnId = receipt.turnId; + return fromRuntimeAcceptanceReceipt(receipt); + }); + const stop = () => { + stopBefore = h.controller.getSnapshot().revision; + stopping = stopActiveAgentStream({ + ...stream.stopOptions(), + onRestoreInterruptedInput: (request) => { + stream.onRestoreInterruptedInput(request); + setRequest(request); + }, + }); + }; + const handled = vi.fn(); + function InputbarHarness(props: Parameters[0]) { + latest = useInputbarController(props); + return ( + + ); + } + function Harness({ + owner = h.coordinator, + onDraftAccepted, + }: { + owner?: ComposerDraftCoordinator; + onDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; + } = {}) { + const { snapshot } = useComposerController({ controller: h.controller }); + const [request, updateRequest] = + useState(null); + setRequest = updateRequest; + const setInput = useCallback( + (value: string) => h.controller.setText(value), + [], + ); + const onHandled = useCallback((id: string) => { + handled(id); + updateRequest((current) => (current?.requestId === id ? null : current)); + }, []); + const clearPathReferences = useCallback( + () => h.controller.setPathReferences([]), + [], + ); + const props = { + composerController: h.controller, + composerDraftCoordinator: persistent ? owner : undefined, + onComposerDraftAccepted: onDraftAccepted, + input: snapshot.document.text, + setInput, + onSend, + onStop: stop, + isLoading: !!stream.state.active, + projectId: scope.projectId, + sessionId: scope.sessionId, + inputRestoreRequest: request, + onInputRestoreRequestHandled: onHandled, + skills: catalog, + providerType: "openai", + setProviderType: noop, + model: "fixture", + setModel: noop, + onClearPathReferences: clearPathReferences, + }; + return home ? : ; + } + function RuntimeHarness() { + workspaceRuntime = useWorkspaceComposerDraftRuntime({ + controller: h.controller, + initialScope: runtimeScope, + }); + return ( + + ); + } + const surface = () => (runtimeScope ? : ); + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + mounted.push({ root, element, h }); + await act(async () => root.render(surface())); + const render = async () => act(async () => root.render(surface())); + return { + ...h, + get coordinator() { + return currentOwner(); + }, + get workspaceRuntime() { + return workspaceRuntime; + }, + stream, + original, + handled, + restoreDraft, + get payload() { + return lastPayload!; + }, + get stopBefore() { + return stopBefore; + }, + get current() { + return latest; + }, + scope, + gate, + async send() { + await act(async () => { + const result = await (home ? homePanel.send!() : latest.handleSend()); + if ( + typeof result !== "object" || + result === null || + !("kind" in result) + ) + throw new Error("Fixture submission was not classified"); + if (result?.kind === "rejected") + throw ( + ("error" in result ? result.error : undefined) ?? + new Error("Fixture submission rejected") + ); + expect(result).toMatchObject({ kind: "runtime-accepted" }); + }); + if (!delayedConsume && persistent) + await act(async () => { + await currentOwner().idle(); + }); + await render(); + }, + async stop() { + const beforeClick = h.controller.getSnapshot().revision; + await act(async () => { + element.querySelector("button")!.click(); + await stopping; + }); + return { beforeClick, atRuntimeStop: stopBefore }; + }, + async release() { + await act(async () => { + gate.resolve(null); + await currentOwner().idle(); + }); + await render(); + }, + async commitVisibleRuntimeScope(next: DraftScope) { + runtimeScope = next; + await render(); + }, + async loadSkills(next: Skill[]) { + catalog = next; + await render(); + }, + async foreignScope() { + if (persistent) + await act(async () => { + if (runtimeScope) workspaceRuntime.commitComposerDraftScope(b); + else await h.coordinator.bindCommitted(b); + }); + scope = { projectId: "other", sessionId: "other" }; + await render(); + }, + }; +} + +describe("actual Inputbar Stop across submission settlement, stream and draft owners", () => { + it("restores the persistent complete draft through the EmptyState public Stop callback", async () => { + const h = await setup({ home: true }); + await h.send(); + expect(h.controller.getDocument().text).toBe(""); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: h.payload.composerRestoreToken, + scope: h.scope, + }), + ).toBe(true); + const stopRevisions = await h.stop(); + expect(h.stream.runtime.interruptTurn).toHaveBeenCalledOnce(); + expect( + h.stream.onRestoreInterruptedInput.mock.calls[0][0].composerRestoreToken, + ).toBe(h.payload.composerRestoreToken); + expect(h.controller.captureDraft(), JSON.stringify(stopRevisions)).toEqual( + h.original, + ); + expect(h.restoreDraft).toHaveBeenCalledOnce(); + expect(h.handled).toHaveBeenCalledOnce(); + }); + it.each([false, true])( + "restores the complete output-free draft through the real Stop action (persistent=%s)", + async (persistent) => { + const h = await setup({ persistent }); + await h.send(); + expect(h.controller.getDocument().text).toBe(""); + const token = h.payload.composerRestoreToken; + expect(h.stream.state.active?.composerRestoreToken).toBe(token); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: persistent ? h.coordinator : undefined, + token, + scope: h.scope, + }), + ).toBe(true); + const stopRevisions = await h.stop(); + expect(h.stream.runtime.interruptTurn).toHaveBeenCalledOnce(); + expect(h.stream.onRestoreInterruptedInput).toHaveBeenCalledOnce(); + expect( + h.stream.onRestoreInterruptedInput.mock.calls[0][0] + .composerRestoreToken, + ).toBe(token); + expect( + h.controller.captureDraft(), + JSON.stringify(stopRevisions), + ).toEqual(h.original); + expect(h.restoreDraft).toHaveBeenCalledOnce(); + expect(h.handled).toHaveBeenCalledOnce(); + for (const wire of [ + ...h.stream.runtime.submitOp.mock.calls, + ...h.stream.runtime.interruptTurn.mock.calls, + ]) { + expect(JSON.stringify(wire)).not.toContain("composerRestoreToken"); + expect(JSON.stringify(wire)).not.toContain("composer-submission"); + } + }, + ); + + it("retains Stop restore while accepted local consumption is pending", async () => { + const h = await setup({ delayedConsume: true }); + await h.send(); + await h.stop(); + expect(h.stream.onRestoreInterruptedInput).toHaveBeenCalledOnce(); + expect(h.handled).not.toHaveBeenCalled(); + await h.release(); + expect(h.controller.captureDraft()).toEqual(h.original); + expect(h.restoreDraft).toHaveBeenCalledOnce(); + expect(h.handled).toHaveBeenCalledOnce(); + }); + + it.each(["text", "attachments", "path", "capability", "foreign"] as const)( + "keeps %s edits after acceptance safe from the previous Stop restore", + async (kind) => { + const h = await setup(); + await h.send(); + await act(async () => { + if (kind === "text") h.controller.setText("后续草稿😀"); + if (kind === "attachments") + h.controller.setAttachments([ + { data: "bmV3", mediaType: "image/jpeg" }, + ]); + if (kind === "path") + h.controller.setPathReferences([ + { + id: "new", + path: "/tmp/new.md", + name: "new.md", + isDir: false, + source: "file_manager", + }, + ]); + if (kind === "capability") + h.controller.setInputCapabilityRoute({ + kind: "builtin-command", + key: "image", + }); + }); + if (kind === "foreign") await h.foreignScope(); + const newer = h.controller.captureDraft(); + await h.stop(); + expect(h.controller.captureDraft()).toEqual(newer); + expect(h.stream.onRestoreInterruptedInput).toHaveBeenCalledOnce(); + expect(h.handled).toHaveBeenCalledOnce(); + }, + ); + + it("keeps a newer selection while local consumption was pending", async () => { + const h = await setup({ delayedConsume: true }); + await h.send(); + await act(async () => h.controller.setSelection(5, 7)); + const newer = h.controller.captureDraft(); + await h.stop(); + await h.release(); + expect(h.controller.captureDraft()).toEqual(newer); + expect(h.handled).toHaveBeenCalledOnce(); + }); +}); + +describe("actual Inputbar restored skill identity", () => { + it("keeps the exact rich draft while the skill catalog hydrates", async () => { + const h = await setup({ initialSkills: [] }); + expect(h.controller.captureDraft()).toEqual(h.original); + const revision = h.controller.getSnapshot().revision; + await h.loadSkills(skills); + expect(h.current.activeCapability).toEqual({ + kind: "installed_skill", + skill, + }); + expect(h.controller.captureDraft()).toEqual(h.original); + expect(h.controller.getSnapshot().revision).toBe(revision); + }); + + it.each([false, true])( + "clears the selected route when its token is deleted despite another skill token (catalog loaded=%s)", + async (loaded) => { + const h = await setup({ initialSkills: loaded ? skills : [] }); + expect(h.controller.captureDraft()).toEqual(h.original); + await act(async () => { + h.controller.setSelection(h.controller.getDocument().text.length); + insertComposerSkill(h.controller, { + ...skill, + key: "user:other-report", + directory: "/tmp/other-report", + }); + }); + expect(h.controller.getDocument().inputCapabilityRoute).toEqual( + h.original.inputCapabilityRoute, + ); + await act(async () => { + const selected = h.controller + .getDocument() + .mentionBindings.find((binding) => binding.id === skill.key)!; + h.controller.replaceRange( + { start: selected.from, end: selected.to }, + "", + ); + }); + expect(h.controller.getDocument().mentionBindings).toMatchObject([ + { kind: "skill", id: "user:other-report" }, + ]); + expect(h.controller.getDocument().inputCapabilityRoute).toBeUndefined(); + expect(h.current.activeCapability).toBeNull(); + }, + ); +}); + +describe("Stop oracle after the actual Workspace Runtime lifecycle", () => { + it.each(["local", "home", "draft"] as const)( + "restores without intervening editing after %s Runtime acceptance/promotion", + async (kind) => { + const initial: DraftScope = { + workspaceId: "w", + kind, + id: + kind === "local" + ? "session-1" + : kind === "home" + ? "new-conversation" + : "draft-a", + }; + const h = await setup({ runtimeScope: initial }); + await h.send(); + // Acceptance promotes the binding; the visible session must commit too. + expect(h.workspaceRuntime.composerDraftRestoring).toBe(kind !== "local"); + await h.commitVisibleRuntimeScope({ + workspaceId: "w", + kind: "local", + id: "session-1", + }); + expect(h.workspaceRuntime.composerDraftRestoring).toBe(false); + expect(h.coordinator.getSnapshot()).toMatchObject({ + scope: { workspaceId: "w", kind: "local", id: "session-1" }, + identity: null, + }); + expect(h.controller.getDocument().text).toBe(""); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: h.payload.composerRestoreToken, + scope: h.scope, + }), + ).toBe(true); + await h.stop(); + expect(h.controller.captureDraft()).toEqual(h.original); + expect(h.restoreDraft).toHaveBeenCalledOnce(); + expect(h.handled).toHaveBeenCalledOnce(); + }, + ); + + it("metadata refresh after acceptance does not create a user edit or invalidate Stop", async () => { + const h = await setup({ + runtimeScope: { workspaceId: "w", kind: "local", id: "session-1" }, + }); + await h.send(); + const revision = h.controller.getSnapshot().revision; + await act(async () => { + await h.coordinator.refresh(); + }); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(h.coordinator.getSnapshot().identity).toBeNull(); + await h.stop(); + expect(h.controller.captureDraft()).toEqual(h.original); + expect(h.restoreDraft).toHaveBeenCalledOnce(); + }); + + it.each(["history-previous", "history-roundtrip", "restoreDraft"] as const)( + "protects the newer revision from old Stop restoration after actual %s", + async (action) => { + const h = await setup({ + runtimeScope: { workspaceId: "w", kind: "local", id: "session-1" }, + }); + await h.send(); + const emptyRevision = h.controller.getSnapshot().revision; + expect(h.coordinator.getSnapshot().identity).toBeNull(); + await act(async () => { + if (action === "restoreDraft") + h.controller.restoreDraft({ + ...h.original, + attachments: [{ data: "later", mediaType: "image/jpeg" }], + }); + else { + expect(h.controller.recallHistory("previous")).not.toBeNull(); + if (action === "history-roundtrip") + expect(h.controller.recallHistory("next")).not.toBeNull(); + } + await h.coordinator.idle(); + }); + const newer = h.controller.captureDraft(); + const newerIdentity = h.coordinator.getSnapshot().identity; + expect(h.controller.getSnapshot().revision).toBeGreaterThan( + emptyRevision, + ); + expect(newerIdentity).not.toBeNull(); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: h.payload.composerRestoreToken, + scope: h.scope, + }), + ).toBe(false); + h.restoreDraft.mockClear(); + await h.stop(); + expect( + h.stream.onRestoreInterruptedInput.mock.calls[0][0] + .composerRestoreToken, + ).toBe(h.payload.composerRestoreToken); + expect(h.controller.captureDraft()).toEqual(newer); + expect(h.coordinator.getSnapshot().identity).toEqual(newerIdentity); + expect(h.restoreDraft).not.toHaveBeenCalled(); + expect(h.handled).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts index c9f0f8c9f..363c2536e 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts @@ -1,7 +1,21 @@ +import type { + ComposerDraftCoordinator, + ComposerDraftCapture, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "@/components/agent/chat/hooks/agentSendResult"; import { restoreComposerDraft } from "../../../hooks/composerDraftRestore"; -import type { ComposerController, ComposerDraftSnapshot } from "@/components/input-kit"; -import { claimComposerCapabilityInitialization, useComposerCapabilitySelection } from "../../../skill-selection/useComposerCapabilitySelection"; -import { insertComposerSkill, insertComposerPlugin } from "../../../skill-selection/composerCapabilityMention"; +import type { + ComposerController, + ComposerDraftSnapshot, +} from "@/components/input-kit"; +import { + claimComposerCapabilityInitialization, + useComposerCapabilitySelection, +} from "../../../skill-selection/useComposerCapabilitySelection"; +import { + insertComposerSkill, + insertComposerPlugin, +} from "../../../skill-selection/composerCapabilityMention"; import type { ComposerEditorHandle } from "@/components/input-kit"; import React, { useCallback, @@ -35,12 +49,17 @@ import { useWorkflowInputState } from "../../../utils/workflowInputState"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import type { MessagePathReference } from "../../../types"; import { + matchesInstalledSkillMention, resolveInputCapabilitySelectionFromRoute, type InputCapabilitySelection, } from "../../../skill-selection/inputCapabilitySelection"; import type { InputbarKnowledgePackSelection } from "../types"; import type { InputbarSendHandler } from "../inputbarSendPayload"; -import type { InterruptedInputRestoreRequest } from "../../../hooks/agentStreamInputRestoreTypes"; +import { + canRestoreComposerSubmission, + useComposerSubmissionRestoreState, + type ScopedComposerRestoreRequest, +} from "../../composerSubmissionSettlement"; import type { AgentInitialInputCapabilityParams } from "@/types/page"; import { buildCuratedTaskLaunchPrompt, @@ -78,6 +97,11 @@ import { interface UseInputbarControllerParams { composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; input: string; setInput: (value: string) => void; onSend: InputbarSendHandler; @@ -107,13 +131,15 @@ interface UseInputbarControllerParams { pathReferences?: MessagePathReference[]; onAddPathReferences?: (references: MessagePathReference[]) => void; onClearPathReferences?: () => void; - inputRestoreRequest?: InterruptedInputRestoreRequest | null; + inputRestoreRequest?: ScopedComposerRestoreRequest | null; onInputRestoreRequestHandled?: (requestId: string) => void; submitTarget?: ComposerSubmitTarget; } export function useInputbarController({ composerController: externalComposerController, + composerDraftCoordinator, + onComposerDraftAccepted, input, setInput, onSend, @@ -172,6 +198,9 @@ export function useInputbarController({ > | null>(null); const [curatedTaskEditorPrefillHint, setCuratedTaskEditorPrefillHint] = useState(null); + const inputRestoreTokenState = useComposerSubmissionRestoreState( + inputRestoreRequest?.composerRestoreToken, + ); const inputRestoreEpochRef = useRef(0); const { controller: composerController, @@ -184,8 +213,12 @@ export function useInputbarController({ pathReferences: initialPathReferences, }, }); - const pathReferences = useMemo(() => [...composerSnapshot.document.pathReferences], [composerSnapshot.document.pathReferences]); - const [activeCapability, setActiveCapability] = useComposerCapabilitySelection(composerController, skills); + const pathReferences = useMemo( + () => [...composerSnapshot.document.pathReferences], + [composerSnapshot.document.pathReferences], + ); + const [activeCapability, setActiveCapability] = + useComposerCapabilitySelection(composerController, skills); const { pendingImages, fileInputRef, @@ -197,9 +230,12 @@ export function useInputbarController({ clearPendingImages, openFileDialog, } = useImageAttachments({ controller: composerController }); - const handleComposerHistoryRecall = useCallback((draft: ComposerDraftSnapshot) => { - setInput(draft.text); - }, [setInput]); + const handleComposerHistoryRecall = useCallback( + (draft: ComposerDraftSnapshot) => { + setInput(draft.text); + }, + [setInput], + ); const promptHistoryClientRef = useRef(null); if (!promptHistoryClientRef.current) { @@ -234,7 +270,8 @@ export function useInputbarController({ }, [composerController, input, setComposerControllerText]); const emittedComposerTextRef = useRef(input); useEffect(() => { - if (emittedComposerTextRef.current === composerSnapshot.document.text) return; + if (emittedComposerTextRef.current === composerSnapshot.document.text) + return; emittedComposerTextRef.current = composerSnapshot.document.text; setInput(composerSnapshot.document.text); }, [composerSnapshot.document.text, setInput]); @@ -246,7 +283,12 @@ export function useInputbarController({ if (customReferences.length > 0) { event.preventDefault(); event.stopPropagation(); - composerController.setPathReferences(mergePathReferences([...composerController.getDocument().pathReferences], customReferences)); + composerController.setPathReferences( + mergePathReferences( + [...composerController.getDocument().pathReferences], + customReferences, + ), + ); onAddPathReferences?.(customReferences); return; } @@ -259,7 +301,12 @@ export function useInputbarController({ if (systemReferences.length > 0) { event.preventDefault(); event.stopPropagation(); - composerController.setPathReferences(mergePathReferences([...composerController.getDocument().pathReferences], systemReferences)); + composerController.setPathReferences( + mergePathReferences( + [...composerController.getDocument().pathReferences], + systemReferences, + ), + ); onAddPathReferences?.(systemReferences); return; } @@ -278,7 +325,12 @@ export function useInputbarController({ handleImageDrop(event); }, - [composerController, copy.systemPathDropUnsupported, handleImageDrop, onAddPathReferences], + [ + composerController, + copy.systemPathDropUnsupported, + handleImageDrop, + onAddPathReferences, + ], ); const editorRef = useRef(null); const isWorkspaceVariant = variant === "workspace"; @@ -337,6 +389,18 @@ export function useInputbarController({ return; } + if (inputRestoreTokenState === "pending") return; + if ( + !canRestoreComposerSubmission({ + controller: composerController, + coordinator: composerDraftCoordinator, + token: inputRestoreRequest.composerRestoreToken, + scope: { projectId, sessionId }, + }) + ) { + onInputRestoreRequestHandled?.(inputRestoreRequest.requestId); + return; + } if (!restoreComposerDraft(composerController, inputRestoreRequest)) return; inputRestoreEpochRef.current += 1; const restoreEpoch = inputRestoreEpochRef.current; @@ -353,7 +417,11 @@ export function useInputbarController({ onInputRestoreRequestHandled?.(requestId); }, [ inputRestoreRequest, + inputRestoreTokenState, composerController, + composerDraftCoordinator, + projectId, + sessionId, setInput, onAddPathReferences, onClearPathReferences, @@ -365,7 +433,13 @@ export function useInputbarController({ useEffect(() => { const route = initialInputCapability?.capabilityRoute; - if (!route || !claimComposerCapabilityInitialization(composerController, initialInputCapabilitySignature)) { + if ( + !route || + !claimComposerCapabilityInitialization( + composerController, + initialInputCapabilitySignature, + ) + ) { return; } @@ -447,23 +521,26 @@ export function useInputbarController({ clearActiveCapability: () => setActiveCapability(null), getInputRestoreEpoch: () => inputRestoreEpochRef.current, composerController, - onComposerCommitted: (text, draft) => { - setInput(text); - if (sessionId && draft?.text.trim()) { + composerDraftCoordinator, + onComposerDraftAccepted, + onComposerAccepted: (draft, result) => { + if (draft?.text.trim()) { void promptHistoryClientRef.current ?.appendPromptHistory({ - threadId: sessionId, + threadId: result.receipt.threadId, text: draft.text, }) .catch(() => undefined); } }, + onComposerCommitted: (text) => { + setInput(text); + }, submitTarget, }); const handleInterrupt = useCallback(() => { - composerController.submit("interrupt", { allowEmpty: true }); onStop?.(); - }, [composerController, onStop]); + }, [onStop]); const inputAdapter = useInputbarAdapter({ input, setInput: handleSetInput, @@ -500,10 +577,28 @@ export function useInputbarController({ }); useEffect(() => { - if (activePluginSelection && !composerSnapshot.document.mentionBindings.some((binding) => binding.kind === "plugin" && binding.id === activePluginSelection.plugin.pluginId)) setActivePluginSelection(null); - if (activeCapability?.kind === "installed_skill" && !composerSnapshot.document.mentionBindings.some((binding) => binding.kind === "skill" && binding.id === activeCapability.skill.key)) setActiveCapability(null); - }, [activeCapability, activePluginSelection, composerSnapshot.document.mentionBindings, setActiveCapability]); - + if ( + activePluginSelection && + !composerSnapshot.document.mentionBindings.some( + (binding) => + binding.kind === "plugin" && + binding.id === activePluginSelection.plugin.pluginId, + ) + ) + setActivePluginSelection(null); + if ( + activeCapability?.kind === "installed_skill" && + !composerSnapshot.document.mentionBindings.some((binding) => + matchesInstalledSkillMention(activeCapability.skill, binding), + ) + ) + setActiveCapability(null); + }, [ + activeCapability, + activePluginSelection, + composerSnapshot.document.mentionBindings, + setActiveCapability, + ]); const topExtra = activeSkill || @@ -561,7 +656,7 @@ export function useInputbarController({ referenceEntries: nextReferenceEntries, }); - setComposerText( + setComposerText( replaceCuratedTaskLaunchPromptInInput({ currentInput: input, previousPrompt: activeCapability.task.prompt, @@ -691,10 +786,18 @@ export function useInputbarController({ if (blocked) { return; } - insertComposerPlugin(composerController, plugin, skill, { start: 0, end: 0 }); + insertComposerPlugin(composerController, plugin, skill, { + start: 0, + end: 0, + }); setInput(composerController.getDocument().text); - setActivePluginSelection({ plugin, skill, trigger: normalizeInputbarPluginTrigger(plugin, skill), text: composerController.getDocument().text, preserveInput: true }); - + setActivePluginSelection({ + plugin, + skill, + trigger: normalizeInputbarPluginTrigger(plugin, skill), + text: composerController.getDocument().text, + preserveInput: true, + }); }; const handleSelectInputCapability = ( capability: InputCapabilitySelection, diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx index ad75c5818..873e33855 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.test.tsx @@ -1,3 +1,22 @@ +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + a, + b, + local, + harness as coordinatorHarness, + deferred, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { + ComposerDraftCoordinator, + ComposerDraftCapture, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "../../../hooks/agentSendResult"; +import { getInputbarSendOptions } from "../inputbarSendPayload"; +import { acceptedSubmission } from "../../../hooks/agentSendResult.testFixtures"; +import { + fromQueueAcceptanceResponse, + type RuntimeSubmissionResult, +} from "../../../hooks/agentSendResult"; import React, { useEffect } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -5,10 +24,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Skill } from "@/lib/api/skills"; import type { MessageImage, MessagePathReference } from "../../../types"; import type { InputCapabilitySelection } from "../../../skill-selection/inputCapabilitySelection"; -import type { - InputbarSendHandler, - InputbarSendTriggerSource, -} from "../inputbarSendPayload"; +import type { InputbarSendHandler } from "../inputbarSendPayload"; import { useInputbarSend } from "./useInputbarSend"; import { ComposerController } from "@/components/input-kit/ComposerController"; import type { ComposerDraftSnapshot } from "@/components/input-kit"; @@ -17,6 +33,11 @@ const { recordAgentUiPerformanceMetricMock } = vi.hoisted(() => ({ recordAgentUiPerformanceMetricMock: vi.fn(), })); +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); vi.mock("@/lib/agentUiPerformanceMetrics", () => ({ recordAgentUiPerformanceMetric: recordAgentUiPerformanceMetricMock, })); @@ -37,18 +58,29 @@ interface HarnessProps { projectId?: string | null; sessionId?: string | null; composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; + onComposerAccepted?: ( + draft: ComposerDraftSnapshot | undefined, + result: AcceptedRuntimeSubmissionResult, + ) => void; isLoading?: boolean; onComposerCommitted?: (text: string, draft?: ComposerDraftSnapshot) => void; } const mountedRoots: MountedHarness[] = []; -let latestSend: - | ((metadata?: { - triggeredAt?: number; - triggerSource?: InputbarSendTriggerSource; - submitTarget?: "start" | "queue" | "steer" | "interrupt" | "command"; - }) => Promise) - | null = null; +const coordinators: Awaited>[] = []; +async function prepareCoordinator() { + const h = await coordinatorHarness({ safeInvoke, safeListen }); + coordinators.push(h); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured from Inputbar"); + return h; +} +let latestSend: ReturnType | null = null; let clearPathReferencesMock: ReturnType; let clearActiveCapabilityMock: ReturnType; let clearPendingImagesMock: ReturnType; @@ -64,6 +96,9 @@ function Harness({ projectId = null, sessionId = null, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, + onComposerAccepted, isLoading = false, onComposerCommitted, }: HarnessProps) { @@ -77,6 +112,9 @@ function Harness({ projectId, sessionId, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, + onComposerAccepted, isLoading, onComposerCommitted, onSend, @@ -116,10 +154,9 @@ async function send() { }); } -async function sendWithMetadata(metadata: { - triggeredAt?: number; - triggerSource?: InputbarSendTriggerSource; -}) { +async function sendWithMetadata( + metadata: NonNullable>[0]>, +) { expect(latestSend).toBeTypeOf("function"); await act(async () => { await latestSend?.(metadata); @@ -158,7 +195,7 @@ function createInstalledSkillSelection(): InputCapabilitySelection { } describe("useInputbarSend", () => { - afterEach(() => { + afterEach(async () => { for (const { root, container } of mountedRoots.splice(0)) { act(() => { root.unmount(); @@ -166,6 +203,7 @@ describe("useInputbarSend", () => { container.remove(); } latestSend = null; + for (const h of coordinators.splice(0)) await h.close(); vi.clearAllMocks(); }); @@ -176,7 +214,7 @@ describe("useInputbarSend", () => { }); it("普通文本发送应显式下传当前输入文本", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHarness({ input: "继续优化 Skill runtime", onSend, @@ -185,6 +223,7 @@ describe("useInputbarSend", () => { await send(); expect(onSend).toHaveBeenCalledWith({ + onRuntimeAccepted: expect.any(Function), images: undefined, textOverride: "继续优化 Skill runtime", sendOptions: undefined, @@ -193,7 +232,7 @@ describe("useInputbarSend", () => { }); it("纯文本无附件和模式时应走最小发送快路径", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const triggeredAt = Date.now() - 12; renderHarness({ activeTools: { @@ -208,6 +247,7 @@ describe("useInputbarSend", () => { await sendWithMetadata({ triggeredAt, triggerSource: "button" }); expect(onSend).toHaveBeenCalledWith({ + onRuntimeAccepted: expect.any(Function), images: undefined, textOverride: "你好", sendOptions: undefined, @@ -225,7 +265,7 @@ describe("useInputbarSend", () => { }); it("Plugin 原子输入应只由 receipt.submission 提供文本、mentions 和范围", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const composerController = new ComposerController({ text: " 帮我整理项目资料", }); @@ -264,7 +304,7 @@ describe("useInputbarSend", () => { }); it("只有路径引用时仍应发送路径占位文本并保留 metadata", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const pathReferences = [createPathReference()]; renderHarness({ input: " ", @@ -279,6 +319,7 @@ describe("useInputbarSend", () => { pathReferences, ); expect(onSend).toHaveBeenCalledWith({ + onRuntimeAccepted: expect.any(Function), images: undefined, textOverride: "请查看这些文件或文件夹。", sendOptions: { @@ -335,7 +376,7 @@ describe("useInputbarSend", () => { }); it("富输入恢复草稿应使用发送瞬间的独立快照", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const image: MessageImage = { data: "image-data", mediaType: "image/png", @@ -373,7 +414,7 @@ describe("useInputbarSend", () => { }); it("同batch发送读取canonical资源和能力,旧React闭包不能回灌", async () => { - const onSend = vi.fn().mockResolvedValue(false); + const onSend = vi.fn().mockResolvedValue({ kind: "rejected" }); const composerController = new ComposerController(); const image: MessageImage = { data: "latest-image", @@ -409,32 +450,36 @@ describe("useInputbarSend", () => { expect(composerController.captureDraft()).toEqual(draft); }); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ - textOverride: "请用最新素材生成报告", - images: [image], - composerSubmission: expect.objectContaining({ - attachments: [image], - pathReferences: [reference], - inputCapabilityRoute: route, - }), - sendOptions: expect.objectContaining({ - capabilityRoute: route, - inputRestoreDraft: expect.objectContaining({ - images: [image], + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + textOverride: "请用最新素材生成报告", + images: [image], + composerSubmission: expect.objectContaining({ + attachments: [image], pathReferences: [reference], inputCapabilityRoute: route, }), - requestMetadata: expect.objectContaining({ - path_references: [expect.objectContaining({ path: "/tmp/latest.md" })], + sendOptions: expect.objectContaining({ + capabilityRoute: route, + inputRestoreDraft: expect.objectContaining({ + images: [image], + pathReferences: [reference], + inputCapabilityRoute: route, + }), + requestMetadata: expect.objectContaining({ + path_references: [ + expect.objectContaining({ path: "/tmp/latest.md" }), + ], + }), }), }), - })); + ); expect(clearPendingImagesMock).not.toHaveBeenCalled(); expect(clearPathReferencesMock).not.toHaveBeenCalled(); }); it("旧空props不能拦截只有canonical图片和路径的发送", async () => { - const onSend = vi.fn().mockResolvedValue(false); + const onSend = vi.fn().mockResolvedValue({ kind: "rejected" }); const composerController = new ComposerController(); const image: MessageImage = { data: "image-data", mediaType: "image/png" }; const reference = createPathReference(); @@ -446,18 +491,20 @@ describe("useInputbarSend", () => { await latestSend?.(); }); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ - images: [image], - textOverride: "请查看这些文件或文件夹。", - composerSubmission: expect.objectContaining({ - attachments: [image], - pathReferences: [reference], + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + images: [image], + textOverride: "请查看这些文件或文件夹。", + composerSubmission: expect.objectContaining({ + attachments: [image], + pathReferences: [reference], + }), }), - })); + ); }); it("canonical能力已清除时不能发送旧React能力", async () => { - const onSend = vi.fn().mockResolvedValue(false); + const onSend = vi.fn().mockResolvedValue({ kind: "rejected" }); const composerController = new ComposerController({ text: "普通文本" }); renderHarness({ composerController, @@ -468,10 +515,12 @@ describe("useInputbarSend", () => { await send(); - expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ - textOverride: "普通文本", - sendOptions: undefined, - })); + expect(onSend).toHaveBeenCalledWith( + expect.objectContaining({ + textOverride: "普通文本", + sendOptions: undefined, + }), + ); }); it("发送期间发生输入恢复时不应再清理刚恢复的富输入状态", async () => { @@ -484,7 +533,7 @@ describe("useInputbarSend", () => { const pathReferences = [createPathReference()]; const onSend = vi.fn(async () => { inputRestoreEpoch = 2; - return true; + return acceptedSubmission(); }); renderHarness({ @@ -505,7 +554,7 @@ describe("useInputbarSend", () => { }); it("技能路由和 typed 计划模式不应因显式文本发送丢失", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHarness({ activeCapability: createInstalledSkillSelection(), activeTools: { @@ -520,6 +569,7 @@ describe("useInputbarSend", () => { await send(); expect(onSend).toHaveBeenCalledWith({ + onRuntimeAccepted: expect.any(Function), images: undefined, textOverride: "生成一份能力报告", sendOptions: { @@ -551,7 +601,7 @@ describe("useInputbarSend", () => { }); it("目标模式应通过 typed threadGoal 发送同一文本", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHarness({ activeTools: { objective_mode: true, @@ -575,7 +625,7 @@ describe("useInputbarSend", () => { }); it("发送展开后的 paste 和 Skill,失败时保留折叠文档与选择区", async () => { - const onSend = vi.fn().mockResolvedValue(false); + const onSend = vi.fn().mockResolvedValue({ kind: "rejected" }); const composerController = new ComposerController({ text: "A😀 " }); composerController.setSelection(4); composerController.insertMention({ @@ -603,7 +653,19 @@ describe("useInputbarSend", () => { }); it("active turn 的显式 queue target 应生成 queue intent 并在成功后提交草稿", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const queueResult = fromQueueAcceptanceResponse( + { + sessionId: "queue-session", + threadId: "queue-thread", + clientUserMessageId: "queue-client", + }, + { + id: "queued-real", + clientUserMessageId: "queue-client", + input: [{ type: "text", text: "排队处理" }], + }, + ); + const onSend = vi.fn().mockResolvedValue(queueResult); const onComposerCommitted = vi.fn(); const composerController = new ComposerController({ text: "排队处理" }); renderHarness({ @@ -633,4 +695,189 @@ describe("useInputbarSend", () => { expect.objectContaining({ text: "排队处理" }), ); }); + it.each([ + { kind: "unknown" as const }, + { kind: "local-handled" as const }, + { kind: "rejected" as const }, + ])("keeps document and resources for %j", async (result) => { + const controller = new ComposerController({ text: "未发送😀" }); + controller.setAttachments([{ data: "image-data", mediaType: "image/png" }]); + const before = controller.captureDraft(); + const onSend = vi.fn(async () => result); + renderHarness({ composerController: controller, onSend }); + await send(); + expect(controller.captureDraft()).toEqual(before); + expect(clearPendingImagesMock).not.toHaveBeenCalled(); + expect(clearActiveCapabilityMock).not.toHaveBeenCalled(); + }); + it("late confirmed queue cannot clear newer composer document", async () => { + let resolve!: (value: RuntimeSubmissionResult) => void; + const onSend = vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ); + const controller = new ComposerController({ text: "原队列" }); + renderHarness({ composerController: controller, onSend, isLoading: true }); + let pending!: ReturnType>; + await act(async () => { + pending = latestSend!({ + triggeredAt: Date.now(), + triggerSource: "button", + submitTarget: "queue", + }); + await Promise.resolve(); + }); + controller.setText("新草稿"); + await act(async () => { + resolve( + fromQueueAcceptanceResponse( + { + sessionId: "session-q", + threadId: "thread-q", + clientUserMessageId: "client-q", + }, + { + id: "queued-q", + clientUserMessageId: "client-q", + input: [{ type: "text", text: "原队列" }], + }, + ), + ); + await pending; + }); + expect(controller.getDocument().text).toBe("新草稿"); + expect(clearPendingImagesMock).not.toHaveBeenCalled(); + }); + it("settles early observer acceptance with one capture while runtime completion is pending", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + const submit = vi.spyOn(h.controller, "submit"); + const commit = vi.spyOn(h.controller, "commit"); + const onComposerAccepted = vi.fn(); + const onSend = vi.fn((payload) => { + getInputbarSendOptions(payload!)?.observer?.onRuntimeAccepted?.( + acceptedSubmission().receipt, + ); + return gate.promise; + }); + renderHarness({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + onSend, + onComposerAccepted, + }); + let pending!: ReturnType>; + await act(async () => { + pending = latestSend!(); + }); + await h.coordinator.idle(); + expect(submit).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledOnce(); + expect(clearPendingImagesMock).toHaveBeenCalledOnce(); + expect(onComposerAccepted).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ text: "captured from Inputbar" }), + expect.objectContaining({ kind: "runtime-accepted" }), + ); + expect( + onSend.mock.calls[0][0]?.composerDraftCapture?.receipt.submission.text, + ).toBe("captured from Inputbar"); + await act(async () => { + gate.resolve({ kind: "unknown" }); + await pending; + }); + expect(commit).toHaveBeenCalledOnce(); + }); + + it("consumes an in-flight old capture after navigation while preserving the new scope", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + const onSend = vi.fn(() => gate.promise); + renderHarness({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + onSend, + }); + let pending!: ReturnType>; + await act(async () => { + pending = latestSend!(); + }); + await act(async () => { + await h.coordinator.bindCommitted(b); + h.controller.setText("new scope"); + }); + await act(async () => { + gate.resolve(acceptedSubmission()); + await pending; + await h.coordinator.idle(); + }); + expect(await h.read(a)).toBeNull(); + expect(h.controller.getDocument().text).toBe("new scope"); + expect(clearPendingImagesMock).not.toHaveBeenCalled(); + }); + + it("continues persistence after Inputbar unmount and avoids UI postprocessing", async () => { + const h = await prepareCoordinator(); + const gate = deferred(); + const onSend = vi.fn(() => gate.promise); + renderHarness({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + onSend, + }); + let pending!: ReturnType>; + await act(async () => { + pending = latestSend!(); + }); + const mounted = mountedRoots.pop()!; + act(() => mounted.root.unmount()); + mounted.container.remove(); + await act(async () => { + gate.resolve(acceptedSubmission()); + await pending; + await h.coordinator.idle(); + }); + expect(await h.read(a)).toBeNull(); + expect(clearPendingImagesMock).not.toHaveBeenCalled(); + }); + + it("uses the root promotion callback before consume, once for the same captured binding", async () => { + const h = await prepareCoordinator(); + let payloadCapture: ComposerDraftCapture | undefined; + const promote = vi.fn( + async ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => { + expect(capture).toBe(payloadCapture); + await h.coordinator.promote(capture.binding!, local, { + expectedTarget: null, + sessionId: result.receipt.sessionId, + threadId: result.receipt.threadId, + }); + }, + ); + const actual = acceptedSubmission("s1"); + const onSend = vi.fn(async (payload) => { + payloadCapture = payload?.composerDraftCapture; + getInputbarSendOptions(payload!)?.observer?.onRuntimeAccepted?.( + actual.receipt, + ); + return actual; + }); + renderHarness({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + onComposerDraftAccepted: promote, + onSend, + }); + await send(); + await h.coordinator.idle(); + expect(promote).toHaveBeenCalledOnce(); + expect(h.mutations.map((x) => x.op)).toEqual(["put", "promote", "consume"]); + expect(await h.read(a)).toBeNull(); + expect(await h.read(local)).toBeNull(); + expect(clearPendingImagesMock).toHaveBeenCalledOnce(); + }); }); diff --git a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts index 8509309ae..63a59ec0a 100644 --- a/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts +++ b/src/components/agent/chat/components/Inputbar/hooks/useInputbarSend.ts @@ -1,4 +1,11 @@ -import { useCallback } from "react"; +import { runRuntimeSubmissionEffect } from "../../../hooks/agentSendResult"; +import { useCallback, useEffect, useRef } from "react"; +import type { + ComposerDraftCoordinator, + ComposerDraftCapture, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "@/components/agent/chat/hooks/agentSendResult"; +import { createComposerSubmissionSettlement } from "../../composerSubmissionSettlement"; import type { MessageImage, MessagePathReference } from "../../../types"; import type { InputbarKnowledgePackSelection } from "../types"; import type { InputbarSendHandler } from "../inputbarSendPayload"; @@ -37,6 +44,15 @@ interface UseInputbarSendParams { clearActiveCapability: () => void; getInputRestoreEpoch?: () => number; composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; + onComposerAccepted?: ( + draft: ComposerDraftSnapshot | undefined, + result: AcceptedRuntimeSubmissionResult, + ) => void; onComposerCommitted?: (text: string, draft?: ComposerDraftSnapshot) => void; submitTarget?: ComposerSubmitTarget; } @@ -57,26 +73,129 @@ export function useInputbarSend({ clearActiveCapability, getInputRestoreEpoch, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, + onComposerAccepted, onComposerCommitted, submitTarget, }: UseInputbarSendParams) { + const mounted = useRef(true); + const surface = useRef({ + composerController, + composerDraftCoordinator, + projectId, + sessionId, + }); + surface.current = { + composerController, + composerDraftCoordinator, + projectId, + sessionId, + }; + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); return useCallback( async (triggerMetadata?: BaseComposerSendMetadata) => { const handlerEnteredAt = Date.now(); - const composerDocument = composerController?.getDocument(); - const submittedImages = composerDocument?.attachments ?? pendingImages; - const submittedReferences = composerDocument?.pathReferences ?? pathReferences; - const submittedRoute = composerDocument?.inputCapabilityRoute as - InputCapabilitySendRoute | undefined; - const submittedCapability = composerDocument + const sendRestoreEpoch = getInputRestoreEpoch?.() ?? 0; + const resolvedSubmitTarget: ComposerSubmitTarget = + triggerMetadata?.submitTarget ?? + submitTarget ?? + (isLoading ? "steer" : "start"); + const settlement = createComposerSubmissionSettlement({ + controller: composerController, + coordinator: composerDraftCoordinator, + target: resolvedSubmitTarget, + onDraftAccepted: onComposerDraftAccepted, + scope: { projectId, sessionId }, + canApplyUi: () => + mounted.current && + surface.current.composerController === composerController && + surface.current.composerDraftCoordinator === + composerDraftCoordinator && + (!!composerDraftCoordinator || + (surface.current.projectId === projectId && + surface.current.sessionId === sessionId)) && + (getInputRestoreEpoch?.() ?? sendRestoreEpoch) === sendRestoreEpoch, + onAccepted: (result, draft) => { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + onComposerAccepted?.(draft, result), + ); + if (submittedCapability?.kind === "curated_task") { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + recordCuratedTaskTemplateUsage({ + templateId: submittedCapability.task.id, + launchInputValues: submittedCapability.launchInputValues, + referenceMemoryIds: submittedCapability.referenceMemoryIds, + referenceEntries: submittedCapability.referenceEntries, + }), + ); + } + }, + onCommitted: (accepted) => { + // Controller has already been committed by its sole settlement owner. + runRuntimeSubmissionEffect( + accepted.receipt, + "accepted_submit", + clearPendingImages, + ); + runRuntimeSubmissionEffect(accepted.receipt, "accepted_submit", () => + clearPathReferences?.(), + ); + runRuntimeSubmissionEffect( + accepted.receipt, + "accepted_submit", + clearActiveCapability, + ); + if (composerReceipt?.kind === "accepted") { + runRuntimeSubmissionEffect( + accepted.receipt, + "accepted_submit", + () => + onComposerCommitted?.( + composerController?.getDocument().text ?? "", + composerDraft, + ), + ); + } + }, + }); + const composerReceipt = settlement.receipt; + if (composerReceipt?.kind === "empty") return; + const composerDraft = composerReceipt?.draft; + const submittedInput = composerReceipt?.submission.text ?? input; + const composerSubmission = composerReceipt?.submission; + const submittedImages = composerSubmission?.attachments ?? pendingImages; + const submittedReferences = + composerSubmission?.pathReferences ?? pathReferences; + const submittedRoute = composerDraft?.inputCapabilityRoute as + | InputCapabilitySendRoute + | undefined; + const submittedCapability = composerDraft ? submittedRoute ? activeCapability && - JSON.stringify(resolveInputCapabilitySendRoute(activeCapability)) === - JSON.stringify(submittedRoute) + JSON.stringify( + resolveInputCapabilitySendRoute(activeCapability), + ) === JSON.stringify(submittedRoute) ? activeCapability - : resolveInputCapabilitySelectionFromRoute({ route: submittedRoute }) + : resolveInputCapabilitySelectionFromRoute({ + route: submittedRoute, + }) : null : activeCapability; + const captureMetadata = { + onRuntimeAccepted: settlement.onRuntimeAccepted, + ...(settlement.composerRestoreToken + ? { composerRestoreToken: settlement.composerRestoreToken } + : {}), + ...(settlement.composerDraftCapture + ? { composerDraftCapture: settlement.composerDraftCapture } + : {}), + }; const triggeredAt = typeof triggerMetadata?.triggeredAt === "number" && Number.isFinite(triggerMetadata.triggeredAt) @@ -87,25 +206,13 @@ export function useInputbarSend({ durationMs: Math.max(0, handlerEnteredAt - triggeredAt), hasTriggerMetadata: Boolean(triggerMetadata), imageCount: submittedImages.length, - inputLength: (composerDocument?.text ?? input).trim().length, + inputLength: submittedInput.trim().length, pathReferenceCount: submittedReferences.length, sessionId: sessionId ?? null, source: "inputbar", triggerSource, workspaceId: projectId ?? null, }); - const sendRestoreEpoch = getInputRestoreEpoch?.() ?? 0; - const resolvedSubmitTarget: ComposerSubmitTarget = - triggerMetadata?.submitTarget ?? - submitTarget ?? - (isLoading ? "steer" : "start"); - const composerReceipt = composerController?.submit(resolvedSubmitTarget); - if (composerReceipt?.kind === "empty") { - return; - } - const composerDraft = composerReceipt?.draft; - const submittedInput = composerReceipt?.submission.text ?? input; - const composerSubmission = composerReceipt?.submission; const inputMentions = composerSubmission?.inputMentions ?? []; if ( !submittedInput.trim() && @@ -136,49 +243,31 @@ export function useInputbarSend({ triggerSource, workspaceId: projectId ?? null, }); - const result = await onSend({ - images: undefined, - textOverride: submittedInput, - sendOptions: undefined, - ...(composerReceipt?.kind === "accepted" - ? { - composerIntent: composerReceipt.intent, - composerTarget: composerReceipt.target, - composerDraft, - composerSubmission, - } - : {}), - ...(triggerMetadata - ? { - triggeredAt, - triggerSource, - } - : {}), - }); - if (result === false) { - return; + try { + const result = await onSend({ + ...captureMetadata, + images: undefined, + textOverride: submittedInput, + sendOptions: undefined, + ...(composerReceipt?.kind === "accepted" + ? { + composerIntent: composerReceipt.intent, + composerTarget: composerReceipt.target, + composerDraft, + composerSubmission, + } + : {}), + ...(triggerMetadata + ? { + triggeredAt, + triggerSource, + } + : {}), + }); + return settlement.finish(result); + } catch (error) { + return settlement.reject(error); } - if ( - (getInputRestoreEpoch?.() ?? sendRestoreEpoch) !== sendRestoreEpoch - ) { - return; - } - if ( - composerReceipt?.kind === "accepted" && - !composerController?.commit(composerReceipt) - ) { - return; - } - clearPendingImages(); - clearPathReferences?.(); - clearActiveCapability(); - if (composerReceipt?.kind === "accepted") { - onComposerCommitted?.( - composerController?.getDocument().text ?? "", - composerDraft, - ); - } - return; } const capabilityDispatch = resolveInputCapabilityDispatch( @@ -262,6 +351,7 @@ export function useInputbarSend({ try { const result = await onSend({ + ...captureMetadata, images: submittedImages.length > 0 ? [...submittedImages] : undefined, textOverride, sendOptions, @@ -280,39 +370,10 @@ export function useInputbarSend({ } : {}), }); - if (result === false) { - return; - } - if (submittedCapability?.kind === "curated_task") { - recordCuratedTaskTemplateUsage({ - templateId: submittedCapability.task.id, - launchInputValues: submittedCapability.launchInputValues, - referenceMemoryIds: submittedCapability.referenceMemoryIds, - referenceEntries: submittedCapability.referenceEntries, - }); - } - if ( - (getInputRestoreEpoch?.() ?? sendRestoreEpoch) !== sendRestoreEpoch - ) { - return; - } - if ( - composerReceipt?.kind === "accepted" && - !composerController?.commit(composerReceipt) - ) { - return; - } - clearPendingImages(); - clearPathReferences?.(); - clearActiveCapability(); - if (composerReceipt?.kind === "accepted") { - onComposerCommitted?.( - composerController?.getDocument().text ?? "", - composerDraft, - ); - } - } catch { + return settlement.finish(result); + } catch (error) { // 发送失败时保留图片与技能,交由上层 toast / 恢复逻辑处理。 + return settlement.reject(error); } }, [ @@ -320,12 +381,15 @@ export function useInputbarSend({ activeTools, clearActiveCapability, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, clearPendingImages, clearPathReferences, getInputRestoreEpoch, input, isLoading, knowledgePackSelection, + onComposerAccepted, onComposerCommitted, projectId, submitTarget, diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index eeda512a3..44e8403b4 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -19,6 +19,8 @@ import { resolveCuratedTaskTemplateLaunchPrefill, } from "../../utils/curatedTaskTemplates"; import type { InputbarSendPayload } from "./inputbarSendPayload"; +import type { RuntimeSubmissionResult } from "../../hooks/agentSendResult"; +import { acceptedSubmission } from "../../hooks/agentSendResult.testFixtures"; import { BaseComposer, ComposerController, ComposerEditor, type ComposerDraftSnapshot, type ComposerEditorHandle, type BaseComposerSendMetadata } from "@/components/input-kit"; import { COMPOSER_EDITOR_SELECTOR, getComposerEditorText, setComposerEditorText } from "@/components/input-kit/composerEditor.testUtils"; import type { @@ -1090,11 +1092,11 @@ describe("Inputbar", () => { installed: true, sourceKind: "other", }; - let resolveSend: ((value: boolean) => void) | null = null; + let resolveSend: ((value: RuntimeSubmissionResult) => void) | null = null; let triggerRestore: (() => void) | null = null; const onSend = vi.fn( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveSend = resolve; }), ); @@ -1187,7 +1189,7 @@ describe("Inputbar", () => { expect(container.querySelector('[data-composer-token="skill"]')?.getAttribute("data-composer-path")).toBe("draft/SKILL.md"); await act(async () => { - resolveSend?.(true); + resolveSend?.(acceptedSubmission()); await sendPromise; await Promise.resolve(); }); @@ -2204,7 +2206,7 @@ describe("Inputbar", () => { inputValues: launchInputValues, referenceEntries, }); - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderInputbar({ input: prompt, @@ -2265,7 +2267,7 @@ describe("Inputbar", () => { theme_target: "AI 内容创作", }, }); - const onSend = vi.fn().mockResolvedValue(false); + const onSend = vi.fn().mockResolvedValue({ kind: "rejected" }); renderInputbar({ input: prompt, @@ -3911,7 +3913,7 @@ describe("Inputbar", () => { }); it("移除执行策略选择后普通发送不再提交 execution strategy", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const { container } = renderInputbar({ input: "请联网检查这个依赖变更并修复编译错误", onSend, @@ -3960,7 +3962,7 @@ describe("Inputbar", () => { }); it("启用项目资料后发送应携带 knowledge_pack metadata", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const { container } = renderInputbar({ input: "基于项目资料写一段介绍", onSend, @@ -4002,7 +4004,7 @@ describe("Inputbar", () => { }); it("启用运营资料后发送应携带隐式 persona 协同 pack", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const { container } = renderInputbar({ input: "基于运营资料写一段社群预热文案", onSend, @@ -4067,7 +4069,7 @@ describe("Inputbar", () => { }); it("资料中枢应支持显式追加多份 data 协同资料", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const onToggleKnowledgeCompanionPack = vi.fn(); const { container, rerender } = renderInputbar({ input: "基于运营资料生成本周计划", @@ -4456,7 +4458,7 @@ describe("Inputbar", () => { }); it("可从输入区菜单切换具体项目资料并用选中资料发送", async () => { - const onSend = vi.fn().mockResolvedValue(true); + const onSend = vi.fn().mockResolvedValue(acceptedSubmission()); const onSelectKnowledgePack = vi.fn(); const onToggleKnowledgePack = vi.fn(); const { container, rerender } = renderInputbar({ diff --git a/src/components/agent/chat/components/Inputbar/index.tsx b/src/components/agent/chat/components/Inputbar/index.tsx index e2baa01e7..14154346a 100644 --- a/src/components/agent/chat/components/Inputbar/index.tsx +++ b/src/components/agent/chat/components/Inputbar/index.tsx @@ -1,3 +1,8 @@ +import type { + ComposerDraftCoordinator, + ComposerDraftCapture, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "@/components/agent/chat/hooks/agentSendResult"; import React from "react"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; @@ -26,10 +31,13 @@ import { buildInputbarComposerSectionCopy } from "./components/inputbarComposerS import { buildInputbarCoreCopy } from "./components/inputbarCoreCopy"; import { buildInputbarWorkflowPanelCopy } from "./inputbarWorkflowCopy"; import type { InputbarSendHandler } from "./inputbarSendPayload"; -import type { InterruptedInputRestoreRequest } from "../../hooks/agentStreamInputRestoreTypes"; +import type { ScopedComposerRestoreRequest } from "../composerSubmissionSettlement"; import type { ModelReasoningEffortLevel } from "@/lib/types/modelRegistry"; import type { ThreadGoal } from "@limecloud/app-server-client"; -import type { ComposerController, ComposerSubmitTarget } from "@/components/input-kit"; +import type { + ComposerController, + ComposerSubmitTarget, +} from "@/components/input-kit"; const SecondaryControlsRow = styled.div` position: absolute; @@ -53,6 +61,11 @@ const SecondaryControlsRow = styled.div` interface InputbarProps extends SkillSelectionSourceProps { composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; input: string; setInput: (value: string) => void; onSend: InputbarSendHandler; @@ -107,7 +120,7 @@ interface InputbarProps extends SkillSelectionSourceProps { threadGoalLoading?: boolean; pathReferences?: MessagePathReference[]; onAddPathReferences?: (references: MessagePathReference[]) => void; - inputRestoreRequest?: InterruptedInputRestoreRequest | null; + inputRestoreRequest?: ScopedComposerRestoreRequest | null; onInputRestoreRequestHandled?: (requestId: string) => void; onImportPathReferenceAsKnowledge?: (reference: MessagePathReference) => void; onRemovePathReference?: (id: string) => void; @@ -122,6 +135,8 @@ interface InputbarProps extends SkillSelectionSourceProps { export const Inputbar: React.FC = ({ composerController: externalComposerController, + composerDraftCoordinator, + onComposerDraftAccepted, input, setInput, onSend, @@ -257,6 +272,8 @@ export const Inputbar: React.FC = ({ knowledgeHubOpenRequestKey, } = useInputbarController({ composerController: externalComposerController, + composerDraftCoordinator, + onComposerDraftAccepted, input, setInput, onSend, @@ -380,7 +397,11 @@ export const Inputbar: React.FC = ({ pathReferences={composerPathReferences} onImportPathReferenceAsKnowledge={onImportPathReferenceAsKnowledge} onRemovePathReference={(id) => { - composerController.setPathReferences(composerController.getDocument().pathReferences.filter((reference) => reference.id !== id)); + composerController.setPathReferences( + composerController + .getDocument() + .pathReferences.filter((reference) => reference.id !== id), + ); onRemovePathReference?.(id); }} fileManagerOpen={fileManagerOpen} diff --git a/src/components/agent/chat/components/Inputbar/inputbarSendPayload.test.ts b/src/components/agent/chat/components/Inputbar/inputbarSendPayload.test.ts new file mode 100644 index 000000000..33849c9bc --- /dev/null +++ b/src/components/agent/chat/components/Inputbar/inputbarSendPayload.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { runtimeReceipt } from "../../hooks/agentSendResult.testFixtures"; +import { getInputbarSendOptions } from "./inputbarSendPayload"; + +describe("Inputbar runtime acceptance observer plumbing", () => { + it("composes the early callback with the original observer and preserves its fields", () => { + const onRuntimeAccepted = vi.fn(); + const original = vi.fn(); + const onComplete = vi.fn(); + const token = Object.freeze({ kind: "composer-submission" as const }); + const options = getInputbarSendOptions({ + onRuntimeAccepted, + composerRestoreToken: token, + sendOptions: { + observer: { onRuntimeAccepted: original, onComplete }, + targetSessionId: "s1", + }, + }); + const receipt = runtimeReceipt(); + options?.observer?.onRuntimeAccepted?.(receipt); + options?.observer?.onRuntimeAccepted?.(receipt); + expect(onRuntimeAccepted).toHaveBeenCalledExactlyOnceWith(receipt); + expect(original).toHaveBeenCalledExactlyOnceWith(receipt); + expect(options?.observer?.onComplete).toBe(onComplete); + expect(options?.composerRestoreToken).toBe(token); + expect(options?.targetSessionId).toBe("s1"); + }); + + it("keeps the original callback when the new callback fails", () => { + const original = vi.fn(); + const options = getInputbarSendOptions({ + onRuntimeAccepted: () => { + throw new Error("ui callback"); + }, + sendOptions: { observer: { onRuntimeAccepted: original } }, + }); + expect(() => + options?.observer?.onRuntimeAccepted?.(runtimeReceipt()), + ).not.toThrow(); + expect(original).toHaveBeenCalledOnce(); + }); + + it("keeps the existing options object for payloads without new composer metadata", () => { + const options = { targetSessionId: "s1" }; + expect(getInputbarSendOptions({ sendOptions: options })).toBe(options); + }); +}); diff --git a/src/components/agent/chat/components/Inputbar/inputbarSendPayload.ts b/src/components/agent/chat/components/Inputbar/inputbarSendPayload.ts index 8dbcbf055..772bf4a09 100644 --- a/src/components/agent/chat/components/Inputbar/inputbarSendPayload.ts +++ b/src/components/agent/chat/components/Inputbar/inputbarSendPayload.ts @@ -1,3 +1,10 @@ +import type { ComposerDraftCapture } from "@/components/input-kit/ComposerDraftCoordinator"; +import { + createRuntimeSubmissionCapture, + type RuntimeSubmissionResult, + type RuntimeSubmissionAcceptance, +} from "../../hooks/agentSendResult"; +import type { ComposerSubmissionRestoreToken } from "../composerSubmissionSettlement"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; import type { HandleSendOptions } from "../../hooks/handleSendTypes"; import type { MessageImage } from "../../types"; @@ -14,6 +21,9 @@ export type InputbarSendTriggerSource = | "adapter"; export interface InputbarSendPayload { + onRuntimeAccepted?: (receipt: RuntimeSubmissionAcceptance) => void; + composerRestoreToken?: ComposerSubmissionRestoreToken; + composerDraftCapture?: ComposerDraftCapture; images?: MessageImage[]; textOverride?: string; autoContinuePayload?: AutoContinueRequestPayload; @@ -28,21 +38,36 @@ export interface InputbarSendPayload { export type InputbarSendHandler = ( payload?: InputbarSendPayload, -) => void | Promise | boolean; +) => Promise; -export function getInputbarSendOptions( - payload: InputbarSendPayload, -): HandleSendOptions | undefined { +export function getInputbarSendOptions(payload: InputbarSendPayload): + | (HandleSendOptions & { + composerRestoreToken?: ComposerSubmissionRestoreToken; + }) + | undefined { if ( !payload.composerDraft && !payload.composerSubmission && !payload.composerIntent && - !payload.composerTarget + !payload.composerTarget && + !payload.onRuntimeAccepted && + !payload.composerRestoreToken ) { return payload.sendOptions; } return { ...payload.sendOptions, + ...(payload.onRuntimeAccepted + ? { + observer: createRuntimeSubmissionCapture( + payload.sendOptions?.observer, + (result) => payload.onRuntimeAccepted?.(result.receipt), + ).observer, + } + : {}), + ...(payload.composerRestoreToken + ? { composerRestoreToken: payload.composerRestoreToken } + : {}), ...(payload.composerDraft ? { composerDraft: payload.composerDraft } : {}), ...(payload.composerSubmission ? { composerSubmission: payload.composerSubmission } diff --git a/src/components/agent/chat/components/ShellBackgroundTerminalList.test.tsx b/src/components/agent/chat/components/ShellBackgroundTerminalList.test.tsx index 94868b07c..44f3eaa9c 100644 --- a/src/components/agent/chat/components/ShellBackgroundTerminalList.test.tsx +++ b/src/components/agent/chat/components/ShellBackgroundTerminalList.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; import type { BackgroundTerminalsAppServerClient } from "@/lib/api/backgroundTerminals"; import { ShellBackgroundTerminalList } from "./ShellBackgroundTerminalList"; @@ -18,12 +19,13 @@ function appServerResult(result: T) { const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; -beforeEach(() => { +beforeEach(async () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; } ).IS_REACT_ACT_ENVIRONMENT = true; + await changeLimeLocale("zh-CN"); }); function renderList(client: BackgroundTerminalsAppServerClient) { @@ -46,7 +48,7 @@ async function flush() { }); } -afterEach(() => { +afterEach(async () => { while (mountedRoots.length > 0) { const mounted = mountedRoots.pop(); if (!mounted) { @@ -57,6 +59,7 @@ afterEach(() => { }); mounted.container.remove(); } + await changeLimeLocale("en-US"); }); describe("ShellBackgroundTerminalList", () => { @@ -90,9 +93,7 @@ describe("ShellBackgroundTerminalList", () => { nextCursor: null, }), ) - .mockResolvedValueOnce( - appServerResult({ data: [], nextCursor: null }), - ), + .mockResolvedValueOnce(appServerResult({ data: [], nextCursor: null })), terminateThreadBackgroundTerminal: vi .fn() .mockResolvedValue(appServerResult({ terminated: true })), @@ -121,13 +122,17 @@ describe("ShellBackgroundTerminalList", () => { processId: "7", }); expect( - container.querySelector('[data-testid="shell-background-terminals-empty"]'), + container.querySelector( + '[data-testid="shell-background-terminals-empty"]', + ), ).not.toBeNull(); }); it("读取失败时显示错误", async () => { const client: BackgroundTerminalsAppServerClient = { - listThreadBackgroundTerminals: vi.fn().mockRejectedValue(new Error("down")), + listThreadBackgroundTerminals: vi + .fn() + .mockRejectedValue(new Error("down")), terminateThreadBackgroundTerminal: vi.fn(), cleanThreadBackgroundTerminals: vi.fn(), }; @@ -135,8 +140,9 @@ describe("ShellBackgroundTerminalList", () => { await flush(); expect( - container.querySelector('[data-testid="shell-background-terminals-error"]') - ?.textContent, + container.querySelector( + '[data-testid="shell-background-terminals-error"]', + )?.textContent, ).toContain("无法读取后台终端"); expect( container.querySelector( @@ -198,7 +204,9 @@ describe("ShellBackgroundTerminalList", () => { }); expect(client.terminateThreadBackgroundTerminal).not.toHaveBeenCalled(); expect( - container.querySelector('[data-testid="shell-background-terminals-empty"]'), + container.querySelector( + '[data-testid="shell-background-terminals-empty"]', + ), ).not.toBeNull(); }); }); diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx index a19e39afd..7a8f4a3fa 100644 --- a/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx +++ b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx @@ -241,7 +241,7 @@ describe("TaskCenterTabStrip", () => { ).toBeNull(); }); - it("点击加号应触发新建对话回调", () => { + it("点击加号应调用零参数的新建对话回调", () => { const onCreateTask = vi.fn(); const { container } = renderTabStrip({ onCreateTask }); @@ -254,6 +254,7 @@ describe("TaskCenterTabStrip", () => { }); expect(onCreateTask).toHaveBeenCalledTimes(1); + expect(onCreateTask).toHaveBeenCalledWith(); }); it("路径型会话标题应显示文件名并在 tooltip 保留完整路径", () => { diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.tsx index b3d67e5f9..0ec3516ca 100644 --- a/src/components/agent/chat/components/TaskCenterTabStrip.tsx +++ b/src/components/agent/chat/components/TaskCenterTabStrip.tsx @@ -269,7 +269,7 @@ export function TaskCenterTabStrip({ data-testid="task-center-tab-create-button" aria-label={createConversationLabel} title={createConversationLabel} - onClick={onCreateTask} + onClick={() => onCreateTask()} > diff --git a/src/components/agent/chat/components/composerSubmissionSettlement.test.ts b/src/components/agent/chat/components/composerSubmissionSettlement.test.ts new file mode 100644 index 000000000..da3235552 --- /dev/null +++ b/src/components/agent/chat/components/composerSubmissionSettlement.test.ts @@ -0,0 +1,399 @@ +import { decodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { + a, + b, + local, + accepted, + queueAccepted, + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import { + canRestoreComposerSubmission, + createComposerSubmissionSettlement, +} from "./composerSubmissionSettlement"; +import { isRuntimeSubmissionAccepted } from "../hooks/agentSendResult"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); + +async function persistent() { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured"); + const onCommitted = vi.fn(); + const onAccepted = vi.fn(); + const settlement = createComposerSubmissionSettlement({ + controller: h.controller, + coordinator: h.coordinator, + target: "start", + scope: { projectId: "w", sessionId: "s1" }, + canApplyUi: () => true, + onAccepted, + onCommitted, + }); + const restore = () => + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: settlement.composerRestoreToken, + scope: { projectId: "w", sessionId: "s1" }, + }); + return { ...h, settlement, onAccepted, onCommitted, restore }; +} + +describe("Composer submission settlement through real Coordinator/Store owners", () => { + it.each([false, true])( + "ignores a scope read crossing accepted retirement (snapshotBeforeConsume=%s)", + async (snapshotBeforeConsume) => { + const h = await persistent(); + await h.coordinator.idle(); + const gate = deferred(); + let once = true; + h.intercept((command, _args, execute) => { + if (command !== "persisted_atom_read" || !once) return execute(); + once = false; + const before = snapshotBeforeConsume ? execute() : undefined; + return gate.promise.then(() => + snapshotBeforeConsume ? before : execute(), + ); + }); + const oldRead = h.coordinator.refresh(); + h.settlement.finish(accepted()); + expect(await h.settlement.cleanup).toBe(true); + const revision = h.controller.getSnapshot().revision; + expect(h.coordinator.getSnapshot().identity).toBeNull(); + gate.resolve(null); + await oldRead; + await h.coordinator.idle(); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(h.controller.getDocument().text).toBe(""); + expect(h.coordinator.getSnapshot().identity).toBeNull(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "consume", + ]); + expect(h.restore()).toBe(true); + }, + ); + + it("captures once, accepts before delayed storage and restores only after exact consume", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured"); + const gate = deferred(); + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op: string }).op === "consume" + ? gate.promise.then(execute) + : execute(), + ); + const submit = vi.spyOn(h.controller, "submit"); + const commit = vi.spyOn(h.controller, "commit"); + const onCommitted = vi.fn(); + const settlement = createComposerSubmissionSettlement({ + controller: h.controller, + coordinator: h.coordinator, + target: "start", + scope: {}, + canApplyUi: () => true, + onCommitted, + }); + const result = accepted(); + if (!isRuntimeSubmissionAccepted(result)) + throw new Error("test acceptance invalid"); + settlement.onRuntimeAccepted(result.receipt); + expect(settlement.finish({ kind: "rejected" })).toEqual(result); + expect(submit).toHaveBeenCalledOnce(); + expect(commit).not.toHaveBeenCalled(); + expect(h.controller.getDocument().text).toBe("captured"); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: settlement.composerRestoreToken, + scope: {}, + }), + ).toBe(false); + gate.resolve(null); + await settlement.cleanup; + await h.coordinator.idle(); + expect(commit).toHaveBeenCalledOnce(); + expect(onCommitted).toHaveBeenCalledOnce(); + expect(h.mutations.map((x) => x.op)).toEqual(["put", "consume"]); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: settlement.composerRestoreToken, + scope: {}, + }), + ).toBe(true); + }); + + it("deduplicates early/returned runtime acceptance and keeps acceptance after rejection", async () => { + const h = await persistent(); + const result = accepted(); + if (!isRuntimeSubmissionAccepted(result)) + throw new Error("test acceptance invalid"); + h.settlement.onRuntimeAccepted(result.receipt); + h.settlement.onRuntimeAccepted(result.receipt); + expect(h.settlement.finish(result)).toEqual(result); + expect(h.settlement.reject(new Error("postprocessing"))).toEqual(result); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.onAccepted).toHaveBeenCalledOnce(); + expect(h.onCommitted).toHaveBeenCalledOnce(); + expect(h.mutations.filter((x) => x.op === "consume")).toHaveLength(1); + }); + + it("consumes queue acceptance with the original queued submission object", async () => { + const h = await persistent(); + const result = queueAccepted(); + const actual = h.settlement.finish(result); + expect(actual.kind).toBe("queue-accepted"); + if (actual.kind !== "queue-accepted" || result.kind !== "queue-accepted") + throw new Error("test queue invalid"); + expect(actual.receipt.submission).toBe(result.receipt.submission); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.onCommitted).toHaveBeenCalledOnce(); + }); + + it.each([ + undefined, + true, + false, + { kind: "unknown" }, + { kind: "local-handled" }, + { kind: "rejected" }, + { kind: "runtime-accepted", receipt: {} }, + ])( + "keeps the draft and disables restore for ambiguous/nonacceptance %j", + async (result) => { + const h = await persistent(); + h.settlement.finish(result); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.controller.getDocument().text).toBe("captured"); + expect(h.mutations.map((x) => x.op)).toEqual(["put"]); + expect(h.onCommitted).not.toHaveBeenCalled(); + expect(h.restore()).toBe(false); + }, + ); + + it("settles a late real observer after an unknown return", async () => { + const h = await persistent(); + expect(h.settlement.finish(undefined)).toEqual({ kind: "unknown" }); + const result = accepted(); + if (!isRuntimeSubmissionAccepted(result)) + throw new Error("test acceptance invalid"); + h.settlement.onRuntimeAccepted(result.receipt); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.restore()).toBe(true); + }); + + it("consumes the captured source after navigation and keeps the new scope", async () => { + const h = await persistent(); + await h.coordinator.bindCommitted(b); + h.controller.setText("editing b"); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(await h.read(a)).toBeNull(); + expect(h.controller.getDocument().text).toBe("editing b"); + expect(h.onCommitted).not.toHaveBeenCalled(); + expect(h.restore()).toBe(false); + }); + + it("continues captured consumption after detach without committing detached UI", async () => { + const h = await persistent(); + h.coordinator.detach(); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(await h.read(a)).toBeNull(); + expect(h.controller.getDocument().text).toBe("captured"); + expect(h.onCommitted).not.toHaveBeenCalled(); + expect(h.restore()).toBe(false); + }); + + it("does not consume a newer edit or provide its restore capability", async () => { + const h = await persistent(); + h.controller.setText("later edit"); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.controller.getDocument().text).toBe("later edit"); + expect((await h.read(a))?.state).toBe("draft"); + expect(h.onCommitted).not.toHaveBeenCalled(); + expect(h.restore()).toBe(false); + }); + + it("records the actual promoted target for restoration", async () => { + const h = await persistent(); + const binding = h.coordinator.captureBinding(); + if (!binding) throw new Error("missing test binding"); + await h.coordinator.promote(binding, local, { + expectedTarget: null, + sessionId: "s1", + threadId: "t1", + }); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.coordinator.getSnapshot().scope).toEqual(local); + expect(h.restore()).toBe(true); + await h.coordinator.bindCommitted(b); + await h.coordinator.bindCommitted(local); + expect(h.restore()).toBe(false); + }); + + it("rejects later edits and a cloned/foreign/missing token", async () => { + const h = await persistent(); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + expect(h.restore()).toBe(true); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + scope: {}, + }), + ).toBe(false); + expect( + canRestoreComposerSubmission({ + controller: h.controller, + coordinator: h.coordinator, + token: { kind: "composer-submission" }, + scope: {}, + }), + ).toBe(false); + expect( + canRestoreComposerSubmission({ + controller: new ComposerController(), + coordinator: h.coordinator, + token: h.settlement.composerRestoreToken, + scope: {}, + }), + ).toBe(false); + h.controller.setText("new edit"); + expect(h.restore()).toBe(false); + }); + + it("keeps acceptance when observers/UI postprocessing throw", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured"); + const settlement = createComposerSubmissionSettlement({ + controller: h.controller, + coordinator: h.coordinator, + target: "start", + scope: {}, + canApplyUi: () => true, + onAccepted: () => { + throw new Error("observer"); + }, + onCommitted: () => { + throw new Error("ui"); + }, + }); + const actual = settlement.finish(accepted()); + expect(actual.kind).toBe("runtime-accepted"); + await settlement.cleanup; + await h.coordinator.idle(); + expect(h.controller.getDocument().text).toBe(""); + expect(settlement.reject(new Error("later"))).toEqual(actual); + }); + + it("does not clear a synchronous newer edit after Controller commit", async () => { + const h = await persistent(); + const stop = h.controller.subscribe(() => { + if (h.controller.getDocument().text === "") + h.controller.setText("synchronous new edit"); + }); + h.settlement.finish(accepted()); + await h.settlement.cleanup; + await h.coordinator.idle(); + stop(); + expect(h.controller.getDocument().text).toBe("synchronous new edit"); + const newDraft = await h.read(a); + expect(newDraft?.state).toBe("draft"); + expect(newDraft?.draftId).not.toBe( + h.settlement.composerDraftCapture!.binding!.identity.draftId, + ); + if (newDraft?.state !== "draft") throw new Error("missing reentrant edit"); + expect(decodeComposerDraft(newDraft.draft).document.text).toBe( + "synchronous new edit", + ); + expect(h.onCommitted).not.toHaveBeenCalled(); + expect(h.restore()).toBe(false); + }); + + it("keeps transient restore bound to the same project/session and revision", () => { + const controller = new ComposerController({ text: "transient" }); + const onCommitted = vi.fn(); + const settlement = createComposerSubmissionSettlement({ + controller, + target: "start", + scope: { projectId: "p1", sessionId: "s1" }, + canApplyUi: () => true, + onCommitted, + }); + settlement.finish(accepted()); + expect(onCommitted).toHaveBeenCalledOnce(); + const restore = (projectId: string, sessionId: string) => + canRestoreComposerSubmission({ + controller, + token: settlement.composerRestoreToken, + scope: { projectId, sessionId }, + }); + expect(restore("p1", "s1")).toBe(true); + expect(restore("p2", "s1")).toBe(false); + expect(restore("p1", "s2")).toBe(false); + controller.setText("edited"); + expect(restore("p1", "s1")).toBe(false); + expect(canRestoreComposerSubmission({ controller, scope: {} })).toBe(true); + }); + it("waits for root promotion before consuming and keeps acceptance when promotion fails", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("capture"); + const gate = deferred(); + const onDraftAccepted = vi.fn(() => gate.promise); + const settlement = createComposerSubmissionSettlement({ + controller: h.controller, + coordinator: h.coordinator, + target: "start", + scope: {}, + canApplyUi: () => true, + onDraftAccepted, + onCommitted: vi.fn(), + }); + const result = accepted(); + const actual = settlement.finish(result); + await h.coordinator.idle(); + expect(actual.kind).toBe("runtime-accepted"); + expect(h.mutations.filter((x) => x.op === "consume")).toHaveLength(0); + expect(onDraftAccepted).toHaveBeenCalledExactlyOnceWith( + settlement.composerDraftCapture, + actual, + ); + gate.reject(new Error("promotion CAS failed")); + await settlement.cleanup; + await h.coordinator.idle(); + expect(h.mutations.map((x) => x.op)).toEqual(["put", "consume"]); + expect(h.controller.getDocument().text).toBe(""); + expect(settlement.finish({ kind: "rejected" })).toEqual(actual); + }); +}); diff --git a/src/components/agent/chat/components/composerSubmissionSettlement.ts b/src/components/agent/chat/components/composerSubmissionSettlement.ts new file mode 100644 index 000000000..c9ad3a8de --- /dev/null +++ b/src/components/agent/chat/components/composerSubmissionSettlement.ts @@ -0,0 +1,295 @@ +import { useSyncExternalStore } from "react"; +import { logAgentDebug } from "@/lib/agentDebug"; +import type { ComposerController } from "@/components/input-kit/ComposerController"; +import type { + ComposerDraftCapture, + ComposerDraftCoordinator, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { + ComposerDraftSnapshot, + ComposerSubmitTarget, +} from "@/components/input-kit/types"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import type { InterruptedInputRestoreRequest } from "../hooks/agentStreamInputRestoreTypes"; +import { + createRuntimeSubmissionCapture, + normalizeRuntimeSubmissionResult, + runRuntimeSubmissionEffect, + type AcceptedRuntimeSubmissionResult, +} from "../hooks/agentSendResult"; + +/** This object is an in-process capability; it must never enter a wire payload. */ +export interface ComposerSubmissionRestoreToken { + readonly kind: "composer-submission"; +} +export type ScopedComposerRestoreRequest = InterruptedInputRestoreRequest & { + composerRestoreToken?: ComposerSubmissionRestoreToken; +}; +type SurfaceScope = { + projectId?: string | null; + sessionId?: string | null; +}; +type RestoreContext = { + controller: ComposerController; + coordinator?: ComposerDraftCoordinator; + transientScope: SurfaceScope; + revision: number | null; + generation: number | null; + scope: DraftScope | null; + state: "pending" | "ready" | "blocked"; + listeners: Set<() => void>; +}; +const restores = new WeakMap(); + +function publishRestore( + context: RestoreContext, + state: RestoreContext["state"], + reason: string, +): void { + context.state = state; + const status = context.coordinator?.getSnapshot(); + logAgentDebug( + "ComposerSubmission", + "restoreState", + { + state, + reason, + expectedRevision: context.revision, + currentRevision: context.controller.getSnapshot().revision, + expectedGeneration: context.generation, + currentGeneration: status?.generation ?? null, + hasIdentity: status?.identity != null, + sameScope: sameScope(status?.scope ?? null, context.scope), + }, + { consoleOnly: true }, + ); + for (const listener of context.listeners) { + try { + listener(); + } catch { + /* UI listeners do not own settlement. */ + } + } +} + +/** Pending cleanup must retain an interrupted restore request until consumption settles. */ +export function useComposerSubmissionRestoreState( + token?: ComposerSubmissionRestoreToken, +) { + return useSyncExternalStore( + (listener) => { + const context = token && restores.get(token); + if (!context) return () => undefined; + context.listeners.add(listener); + return () => { + context.listeners.delete(listener); + }; + }, + () => (token ? (restores.get(token)?.state ?? "blocked") : "blocked"), + () => "blocked", + ); +} + +function sameScope(a: DraftScope | null, b: DraftScope | null): boolean { + return ( + !!a && + !!b && + a.workspaceId === b.workspaceId && + a.kind === b.kind && + a.id === b.id + ); +} + +export function canRestoreComposerSubmission({ + controller, + coordinator, + token, + scope, +}: { + controller: ComposerController; + coordinator?: ComposerDraftCoordinator; + token?: ComposerSubmissionRestoreToken; + scope: SurfaceScope; +}): boolean { + const context = token && restores.get(token); + const status = coordinator?.getSnapshot(); + const reject = (reason: string): false => { + logAgentDebug( + "ComposerSubmission", + "inputRestoreGuard.rejected", + { + reason, + hasToken: Boolean(token), + hasContext: Boolean(context), + state: context?.state ?? null, + sameController: context?.controller === controller, + sameCoordinator: context?.coordinator === coordinator, + expectedRevision: context?.revision ?? null, + currentRevision: controller.getSnapshot().revision, + expectedGeneration: context?.generation ?? null, + currentGeneration: status?.generation ?? null, + hasIdentity: status?.identity != null, + sameScope: sameScope(status?.scope ?? null, context?.scope ?? null), + }, + { consoleOnly: true }, + ); + return false; + }; + if (!token) return !coordinator || reject("missing_token"); + if (!context) return reject("unknown_token"); + if (context.controller !== controller) return reject("controller_changed"); + if (context.coordinator !== coordinator) return reject("coordinator_changed"); + if (context.revision === null) return reject("cleanup_not_committed"); + if (controller.getSnapshot().revision !== context.revision) + return reject("revision_changed"); + if (!coordinator) + return ( + ((scope.projectId ?? null) === + (context.transientScope.projectId ?? null) && + (scope.sessionId ?? null) === + (context.transientScope.sessionId ?? null)) || + reject("surface_scope_changed") + ); + return ( + (status?.identity === null && + status.generation === context.generation && + sameScope(status.scope, context.scope)) || + reject("binding_changed") + ); +} + +/** Capture once; acceptance starts persistence without delaying the runtime result. */ +export function createComposerSubmissionSettlement({ + controller, + coordinator, + target, + scope, + canApplyUi, + onAccepted, + onDraftAccepted, + onCommitted, +}: { + controller?: ComposerController; + coordinator?: ComposerDraftCoordinator; + target: ComposerSubmitTarget; + scope: SurfaceScope; + canApplyUi: () => boolean; + onAccepted?: ( + result: AcceptedRuntimeSubmissionResult, + draft?: ComposerDraftSnapshot, + ) => void; + onDraftAccepted?: ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => Promise | void; + onCommitted: (result: AcceptedRuntimeSubmissionResult) => void; +}) { + if (coordinator && !controller) + throw new Error("Persistent composer requires its Controller"); + const capture: ComposerDraftCapture | undefined = + coordinator?.captureSubmission(target); + const receipt = capture?.receipt ?? controller?.submit(target); + const token: ComposerSubmissionRestoreToken | undefined = + controller && receipt?.kind === "accepted" + ? Object.freeze({ kind: "composer-submission" }) + : undefined; + if (token && controller) + restores.set(token, { + controller, + coordinator, + transientScope: { ...scope }, + revision: null, + generation: null, + scope: null, + state: "pending", + listeners: new Set(), + }); + + const committed = (result: AcceptedRuntimeSubmissionResult) => { + const context = token && restores.get(token); + if (context && receipt?.kind === "accepted") { + // A listener may already have edited again before the cleanup promise resolves. + if (controller?.getSnapshot().revision !== receipt.revision + 1) { + publishRestore(context, "blocked", "revision_changed_during_commit"); + return; + } + if (coordinator) { + const status = coordinator.getSnapshot(); + if ( + !capture?.binding || + status.generation !== capture.binding.generation || + status.identity !== null + ) { + publishRestore(context, "blocked", "binding_changed_during_commit"); + return; + } + context.scope = status.scope; + context.generation = status.generation; + } + context.revision = controller.getSnapshot().revision; + publishRestore(context, "ready", "committed"); + } + if (!canApplyUi()) return; + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + onCommitted(result), + ); + }; + let cleanup: Promise | undefined; + const runtime = createRuntimeSubmissionCapture(undefined, (result) => { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + onAccepted?.(result, receipt?.draft), + ); + if (coordinator && capture) { + cleanup = Promise.resolve() + .then(() => onDraftAccepted?.(capture, result)) + .catch(() => { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => { + throw new Error("Composer promotion failed after acceptance"); + }); + }) + .then(() => coordinator.settleSubmission(capture, result)) + .then((applied) => { + if (applied) committed(result); + else { + const context = token && restores.get(token); + if (context) + publishRestore(context, "blocked", "consume_not_applied"); + } + return applied; + }) + .catch(() => { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => { + throw new Error("Composer cleanup failed after acceptance"); + }); + const context = token && restores.get(token); + if (context) publishRestore(context, "blocked", "consume_failed"); + return false; + }); + return; + } + if ( + !canApplyUi() || + (receipt?.kind === "accepted" && !controller?.commit(receipt)) + ) { + const context = token && restores.get(token); + if (context) publishRestore(context, "blocked", "commit_rejected"); + cleanup = Promise.resolve(false); + return; + } + cleanup = Promise.resolve(true); + committed(result); + }); + return { + receipt, + /** Local persistence/UI cleanup only; runtime send must not await it. */ + get cleanup() { + return cleanup; + }, + composerRestoreToken: token, + composerDraftCapture: capture, + onRuntimeAccepted: runtime.observer.onRuntimeAccepted, + finish: (result: unknown) => + runtime.finish(normalizeRuntimeSubmissionResult(result)), + reject: runtime.reject, + }; +} diff --git a/src/components/agent/chat/homePendingPreview.ts b/src/components/agent/chat/homePendingPreview.ts index 55a54db47..11c1e0f52 100644 --- a/src/components/agent/chat/homePendingPreview.ts +++ b/src/components/agent/chat/homePendingPreview.ts @@ -1,3 +1,6 @@ +import type { RuntimeSubmissionResult } from "./hooks/agentSendResult"; +import type { AcceptedRuntimeSubmissionResult } from "./hooks/agentSendResult"; +import type { ComposerDraftCapture } from "@/components/input-kit/ComposerDraftCoordinator"; import type { HandleSendOptions } from "./hooks/handleSendTypes"; import type { Message, MessageImage } from "./types"; import { @@ -7,21 +10,35 @@ import { import type { SoulInteractionCopy } from "@/lib/soul/interactionCopy"; import { resolveSoulInteractionCopy } from "@/lib/soul/interactionCopy"; +export interface TaskCenterSendSource { + sessionId: string | null; + workspaceId: string | null; + navigationEpoch: number | null; + newChatAt: number | null; +} + +export interface TaskCenterSessionReadyOptions { + composerDraftCapture?: ComposerDraftCapture; + sendSource?: TaskCenterSendSource; + acceptedResult?: AcceptedRuntimeSubmissionResult; +} + export interface TaskCenterDraftSendRequest { id: string; - draftTabId: string; + sessionId: string | null; text: string; images: MessageImage[]; sendExecutionStrategy?: "react"; sendOptions?: HandleSendOptions; + composerDraftCapture?: ComposerDraftCapture; + sendSource?: TaskCenterSendSource; submittedAt: number; - materializeDraft: boolean; dispatchState?: "queued" | "dispatched"; sessionReady?: boolean; source: "task-center-empty-state" | "empty-state"; triggerSource?: string | null; /** 将内存中的实际发送结果返回给原始 Composer receipt。 */ - onDispatchSettled?: (accepted: boolean) => void; + onDispatchSettled?: (result: RuntimeSubmissionResult) => void; } export function buildHomePendingPreviewMessages( diff --git a/src/components/agent/chat/hooks/agentChatSendMessage.acceptance.test.ts b/src/components/agent/chat/hooks/agentChatSendMessage.acceptance.test.ts new file mode 100644 index 000000000..3a11bdb71 --- /dev/null +++ b/src/components/agent/chat/hooks/agentChatSendMessage.acceptance.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAgentChatSendMessage } from "./agentChatSendMessage"; +import type { RawSendMessageFn } from "./agentChatShared"; +const receipt = { + kind: "steer" as const, + sessionId: "captured-session", + threadId: "thread-1", + turnId: "turn-steer", +}; +function setup(rawSendMessage: RawSendMessageFn) { + return createAgentChatSendMessage({ + baseStatusSnapshot: { + sessionId: "other", + currentTurnId: null, + providerType: "openai", + model: "fixture", + executionStrategy: "react", + queuedTurnsCount: 0, + isSending: false, + }, + rawSendMessage, + compactSession: vi.fn(), + clearMessages: vi.fn(), + createFreshSession: vi.fn(), + appendAssistantMessage: vi.fn(), + notifyInfo: vi.fn(), + notifySuccess: vi.fn(), + }); +} +describe("outer chat acceptance", () => { + it.each(["normal prompt", "/review changes", "/diff", "/init"])( + "returns real receipt through %s", + async (content) => { + const raw = vi.fn(async (...args) => { + args[8]?.observer?.onRuntimeAccepted?.(receipt); + return receipt; + }); + const accepted = vi.fn(); + const onComplete = vi.fn(); + const result = await setup(raw)( + content, + [], + false, + false, + false, + undefined, + undefined, + undefined, + { observer: { onRuntimeAccepted: accepted, onComplete } }, + ); + expect(result).toEqual({ kind: "runtime-accepted", receipt }); + expect(accepted).toHaveBeenCalledOnce(); + expect(raw.mock.calls[0][8]?.observer?.onComplete).toBe(onComplete); + }, + ); + it("local slash action never fabricates runtime acceptance", async () => { + const raw = vi.fn(); + const accepted = vi.fn(); + expect( + await setup(raw)( + "/help", + [], + false, + false, + false, + undefined, + undefined, + undefined, + { observer: { onRuntimeAccepted: accepted } }, + ), + ).toEqual({ kind: "local-handled" }); + expect(raw).not.toHaveBeenCalled(); + expect(accepted).not.toHaveBeenCalled(); + }); + it("late postresponse rejection preserves steer acceptance; request rejection stays original", async () => { + const error = new Error("refresh failed"); + const raw = vi.fn(async (...args) => { + args[8]?.observer?.onRuntimeAccepted?.(receipt); + throw error; + }); + expect(await setup(raw)("/review changes", [])).toEqual({ + kind: "runtime-accepted", + receipt, + }); + raw.mockImplementation(async () => { + throw error; + }); + await expect(setup(raw)("normal", [])).rejects.toBe(error); + }); + it("unknown raw response keeps result unknown", async () => { + const raw = vi.fn().mockResolvedValue(undefined as never); + expect(await setup(raw)("normal", [])).toEqual({ kind: "unknown" }); + }); +}); diff --git a/src/components/agent/chat/hooks/agentChatSendMessage.test.ts b/src/components/agent/chat/hooks/agentChatSendMessage.test.ts index 00ebe74d9..907f96289 100644 --- a/src/components/agent/chat/hooks/agentChatSendMessage.test.ts +++ b/src/components/agent/chat/hooks/agentChatSendMessage.test.ts @@ -1,5 +1,6 @@ +import { runtimeReceipt } from "./agentSendResult.testFixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { SendMessageFn } from "./agentChatShared"; +import type { RawSendMessageFn, SendMessageFn } from "./agentChatShared"; import { createAgentChatSendMessage } from "./agentChatSendMessage"; import { listSlashEntryUsage } from "../skill-selection/slashEntryUsage"; @@ -19,7 +20,9 @@ function createTestAgentChatSendMessage( describe("createAgentChatSendMessage", () => { it("普通消息应直接透传到 rawSendMessage", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { sessionId: "session-1", @@ -51,12 +54,18 @@ describe("createAgentChatSendMessage", () => { "react", "gpt-5.4", undefined, - undefined, + expect.objectContaining({ + observer: expect.objectContaining({ + onRuntimeAccepted: expect.any(Function), + }), + }), ); }); it("带图片普通消息不应在 rawSendMessage 前拉取模型能力摘要", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { sessionId: "session-1", @@ -86,11 +95,15 @@ describe("createAgentChatSendMessage", () => { "gpt-5.4", ); - expect(rawSendMessage.mock.calls[0]?.[8]).toBeUndefined(); + expect(rawSendMessage.mock.calls[0]?.[8]).not.toHaveProperty( + "modelCapabilitySummary", + ); }); it("显式传入模型能力摘要时应原样透传", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const sendOptions = { requestMetadata: { source: "test" }, modelCapabilitySummary: null, @@ -126,11 +139,13 @@ describe("createAgentChatSendMessage", () => { sendOptions, ); - expect(rawSendMessage.mock.calls[0]?.[8]).toBe(sendOptions); + expect(rawSendMessage.mock.calls[0]?.[8]).toMatchObject(sendOptions); }); it("发送选项中的 provider/model override 应直接透传", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { sessionId: "session-1", @@ -165,14 +180,16 @@ describe("createAgentChatSendMessage", () => { }, ); - expect(rawSendMessage.mock.calls[0]?.[8]).toEqual({ + expect(rawSendMessage.mock.calls[0]?.[8]).toMatchObject({ providerOverride: "translation-provider", modelOverride: "translation-model", }); }); it("命中本地 slash 命令时应跳过 rawSendMessage", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const appendAssistantMessage = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { @@ -214,7 +231,9 @@ describe("createAgentChatSendMessage", () => { }); it("命中 prompt slash 命令时应转换 prompt 后透传", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { sessionId: "session-review", @@ -251,7 +270,9 @@ describe("createAgentChatSendMessage", () => { }); it("命中 /subagents 时应打开 Subagents 面板并跳过 rawSendMessage", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const onOpenSubagents = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { @@ -286,7 +307,9 @@ describe("createAgentChatSendMessage", () => { }); it("命中 /agent alias 时也应打开 Subagents 面板", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const onOpenSubagents = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { @@ -321,7 +344,9 @@ describe("createAgentChatSendMessage", () => { }); it("skipUserMessage 为 true 时应绕过 slash 分流", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const appendAssistantMessage = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { @@ -354,13 +379,19 @@ describe("createAgentChatSendMessage", () => { undefined, undefined, undefined, - undefined, + expect.objectContaining({ + observer: expect.objectContaining({ + onRuntimeAccepted: expect.any(Function), + }), + }), ); expect(appendAssistantMessage).not.toHaveBeenCalled(); }); it("!cmd 应提交用户 shell 命令并跳过模型发送", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const runUserShellCommand = vi.fn(async () => true); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { @@ -389,7 +420,9 @@ describe("createAgentChatSendMessage", () => { }); it("空用户 shell 命令应显示提示且不发请求", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const runUserShellCommand = vi.fn(async () => true); const notifyInfo = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ @@ -421,7 +454,9 @@ describe("createAgentChatSendMessage", () => { }); it("用户 shell 请求错误应通过可见错误通道呈现", async () => { - const rawSendMessage = vi.fn(async () => undefined); + const rawSendMessage = vi.fn(async () => + runtimeReceipt(), + ); const notifyError = vi.fn(); const sendMessage = createTestAgentChatSendMessage({ baseStatusSnapshot: { diff --git a/src/components/agent/chat/hooks/agentChatSendMessage.ts b/src/components/agent/chat/hooks/agentChatSendMessage.ts index bccda62eb..659d2dc3e 100644 --- a/src/components/agent/chat/hooks/agentChatSendMessage.ts +++ b/src/components/agent/chat/hooks/agentChatSendMessage.ts @@ -1,12 +1,20 @@ import type { SlashCommandStatusSnapshot } from "../commands"; import { executeSlashCommand, parseSlashCommand } from "../commands"; import { recordSlashEntryUsage } from "../skill-selection/slashEntryUsage"; -import type { ClearMessagesOptions, SendMessageFn } from "./agentChatShared"; +import type { + ClearMessagesOptions, + RawSendMessageFn, + SendMessageFn, +} from "./agentChatShared"; +import { + createRuntimeSubmissionCapture, + fromRuntimeAcceptanceReceipt, +} from "./agentSendResult"; import { normalizeExecutionStrategy } from "./agentChatCoreUtils"; interface CreateAgentChatSendMessageOptions { baseStatusSnapshot: SlashCommandStatusSnapshot; - rawSendMessage: SendMessageFn; + rawSendMessage: RawSendMessageFn; compactSession: () => Promise; clearMessages: (options?: ClearMessagesOptions) => void; createFreshSession: (sessionName?: string) => Promise; @@ -50,90 +58,88 @@ export function createAgentChatSendMessage( autoContinue, sendOptions, ) => { - if (!skipUserMessage) { - if (content.startsWith("!")) { - const command = content.slice(1).trim(); - if (!command) { - if (shellCommandHelp) { - notifyInfo(shellCommandHelp); - } - return; - } - if (!runUserShellCommand) { - return; - } - try { - await runUserShellCommand(command); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - notifyError?.(shellCommandError?.(message) ?? message); - } - return; - } - - const parsedSlashCommand = parseSlashCommand(content); - if (parsedSlashCommand) { - const effectiveModel = - modelOverride?.trim() || baseStatusSnapshot.model; - const effectiveExecutionStrategy = normalizeExecutionStrategy( - executionStrategyOverride || baseStatusSnapshot.executionStrategy, - ); - const handled = await executeSlashCommand({ - command: parsedSlashCommand, - statusSnapshot: { - ...baseStatusSnapshot, - model: effectiveModel, - executionStrategy: effectiveExecutionStrategy, - }, - sendPrompt: async (prompt) => { - await rawSendMessage( - prompt, - images, - webSearch, - thinking, - skipUserMessage, - executionStrategyOverride, - modelOverride, - autoContinue, - sendOptions, - ); - }, - compactSession, - clearMessages, - createFreshSession, - appendAssistantMessage, - notifyInfo, - notifySuccess, - onOpenSubagents, - onExecutedCommand: (command) => { - if (command.definition.support !== "supported") { - return; + const capture = createRuntimeSubmissionCapture(sendOptions?.observer); + const sendRaw = async (prompt: string) => { + const receipt = await rawSendMessage( + prompt, + images, + webSearch, + thinking, + skipUserMessage, + executionStrategyOverride, + modelOverride, + autoContinue, + { ...sendOptions, observer: capture.observer }, + ); + return capture.finish(fromRuntimeAcceptanceReceipt(receipt)); + }; + try { + if (!skipUserMessage) { + if (content.startsWith("!")) { + const command = content.slice(1).trim(); + if (!command) { + if (shellCommandHelp) { + notifyInfo(shellCommandHelp); } + return { kind: "local-handled" }; + } + if (!runUserShellCommand) { + return { kind: "rejected" }; + } + try { + const handled = await runUserShellCommand(command); + return { kind: handled ? "local-handled" : "rejected" }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + notifyError?.(shellCommandError?.(message) ?? message); + return { kind: "rejected", error }; + } + } - recordSlashEntryUsage({ - kind: "command", - entryId: command.definition.key, - replayText: command.userInput, - }); - }, - }); - if (handled) { - return; + const parsedSlashCommand = parseSlashCommand(content); + if (parsedSlashCommand) { + const effectiveModel = + modelOverride?.trim() || baseStatusSnapshot.model; + const effectiveExecutionStrategy = normalizeExecutionStrategy( + executionStrategyOverride || baseStatusSnapshot.executionStrategy, + ); + const handled = await executeSlashCommand({ + command: parsedSlashCommand, + statusSnapshot: { + ...baseStatusSnapshot, + model: effectiveModel, + executionStrategy: effectiveExecutionStrategy, + }, + sendPrompt: sendRaw, + compactSession, + clearMessages, + createFreshSession, + appendAssistantMessage, + notifyInfo, + notifySuccess, + onOpenSubagents, + onExecutedCommand: (command) => { + if (command.definition.support !== "supported") { + return; + } + + recordSlashEntryUsage({ + kind: "command", + entryId: command.definition.key, + replayText: command.userInput, + }); + }, + }); + if (handled !== null) { + return capture.finish(handled); + } } } - } - await rawSendMessage( - content, - images, - webSearch, - thinking, - skipUserMessage, - executionStrategyOverride, - modelOverride, - autoContinue, - sendOptions, - ); + return await sendRaw(content); + } catch (error) { + return capture.rethrow(error); + } }; } diff --git a/src/components/agent/chat/hooks/agentChatShared.ts b/src/components/agent/chat/hooks/agentChatShared.ts index 4b010dbb3..f325b4d32 100644 --- a/src/components/agent/chat/hooks/agentChatShared.ts +++ b/src/components/agent/chat/hooks/agentChatShared.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { AgentExecutionStrategy } from "@/lib/api/agentExecutionRuntime"; import type { AgentSessionInfo, @@ -99,7 +100,14 @@ export interface UseAgentChatOptions { soulCopy?: SoulInteractionCopy; } +import type { RuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import type { + RuntimeSubmissionAcceptance, + RuntimeSubmissionResult, +} from "./agentSendResult"; + export interface SendMessageObserver { + onRuntimeAccepted?: (receipt: RuntimeSubmissionAcceptance) => void; onTextDelta?: (delta: string, accumulated: string) => void; onComplete?: (content: string) => void; onError?: (message: string) => void; @@ -153,6 +161,7 @@ export interface SendMessageOptions { skipSessionStartHooks?: boolean; composerIntent?: ComposerIntent; composerTarget?: ComposerSubmitTarget; + composerRestoreToken?: ComposerSubmissionRestoreToken; composerDraft?: ComposerDraftSnapshot; composerSubmission?: ComposerSubmission; } @@ -209,7 +218,11 @@ export type SendMessageFn = ( modelOverride?: string, autoContinue?: AutoContinueRequestPayload, options?: SendMessageOptions, -) => Promise; +) => Promise; + +export type RawSendMessageFn = ( + ...args: Parameters +) => Promise; export const getScopedStorageKey = ( workspaceId: string | null | undefined, diff --git a/src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts b/src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts index 6a1156bc8..188a498d3 100644 --- a/src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts +++ b/src/components/agent/chat/hooks/agentRuntimeAdapter.test.ts @@ -309,7 +309,9 @@ describe("defaultAgentRuntimeAdapter", () => { it("submitOp 应只委托 typed turn/start", async () => { const client = { ...mockRuntimeClient, - submitAgentRuntimeTurn: vi.fn().mockResolvedValue(undefined), + submitAgentRuntimeTurn: vi.fn().mockResolvedValue({ + result: { turn: { id: "turn-start", status: "inProgress" } }, + }), }; const adapter = createAgentRuntimeAdapter({ client }); @@ -325,19 +327,22 @@ describe("defaultAgentRuntimeAdapter", () => { }, }); - expect(client.submitAgentRuntimeTurn).toHaveBeenCalledWith({ - threadId: "thread-9", - clientUserMessageId: "user-9", - input: [{ type: "text", text: "开始" }], - approvalPolicy: "on-request", - sandboxPolicy: "workspace-write", - additionalContext: { - rendererEventName: { - kind: "application", - value: "event-start", + expect(client.submitAgentRuntimeTurn).toHaveBeenCalledWith( + { + threadId: "thread-9", + clientUserMessageId: "user-9", + input: [{ type: "text", text: "开始" }], + approvalPolicy: "on-request", + sandboxPolicy: "workspace-write", + additionalContext: { + rendererEventName: { + kind: "application", + value: "event-start", + }, }, }, - }); + undefined, + ); expect(client.steerAgentRuntimeTurn).not.toHaveBeenCalled(); }); diff --git a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts index 7a588abe8..0822c54a8 100644 --- a/src/components/agent/chat/hooks/agentRuntimeAdapter.ts +++ b/src/components/agent/chat/hooks/agentRuntimeAdapter.ts @@ -11,6 +11,7 @@ import { import type { AgentExecutionStrategy } from "@/lib/api/agentExecutionRuntime"; import type { AppServerThreadSettingsUpdateParams } from "@/lib/api/appServer"; import type { + TurnStartResponse, TurnSteerParams, TurnSteerResponse, } from "@limecloud/app-server-client"; @@ -120,7 +121,10 @@ export interface AgentRuntimeAdapter { sessionId: string, previewText?: string, ): Promise; - submitOp(op: AgentOp): Promise; + submitOp( + op: AgentOp, + onAccepted?: (response: TurnStartResponse) => void, + ): Promise; steerTurn(params: TurnSteerParams): Promise; compactSession(sessionId: string, eventName: string): Promise; interruptTurn( @@ -366,7 +370,7 @@ export function createAgentRuntimeAdapter({ } else { void getSessionDetail(threadId).then(publish, (error) => { if (active && revisions.get(threadId) === revision) { - revisions.delete(threadId); + revisions.delete(threadId); console.warn( "[AgentChat] 无法解析 thread settings 的会话归属:", error, @@ -499,13 +503,15 @@ export function createAgentRuntimeAdapter({ async generateSessionTitle(sessionId, previewText) { return client.generateAgentRuntimeSessionTitle(sessionId, previewText); }, - async submitOp(op) { + async submitOp(op, onAccepted) { switch (op.type) { - case "user_input": - await client.submitAgentRuntimeTurn( + case "user_input": { + const response = await client.submitAgentRuntimeTurn( createAgentSessionTurnStartParamsFromUserInputOp(op), + onAccepted ? (response) => onAccepted(response.result) : undefined, ); - return; + return response.result; + } default: throw new Error(`当前 runtime adapter 尚不支持 AgentOp: ${op.type}`); } diff --git a/src/components/agent/chat/hooks/agentSendResult.test.ts b/src/components/agent/chat/hooks/agentSendResult.test.ts new file mode 100644 index 000000000..d1f334b46 --- /dev/null +++ b/src/components/agent/chat/hooks/agentSendResult.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createRuntimeSubmissionCapture, + fromQueueAcceptanceResponse, + fromRuntimeAcceptanceReceipt, + normalizeRuntimeSubmissionResult, +} from "./agentSendResult"; +const receipt = { + kind: "start" as const, + sessionId: "request-session", + threadId: "canonical-thread", + turnId: "real-turn", +}; +describe("typed submission results", () => { + it.each([ + undefined, + true, + false, + {}, + { kind: "dispatched" }, + { kind: "accepted" }, + { kind: "runtime-accepted", receipt: {} }, + ])("does not accept ambiguous value %j", (value) => { + expect(normalizeRuntimeSubmissionResult(value)).toEqual({ + kind: "unknown", + }); + }); + it("keeps request identities and immutable start receipt", () => { + const result = fromRuntimeAcceptanceReceipt(receipt); + expect(result).toEqual({ kind: "runtime-accepted", receipt }); + if (result.kind !== "runtime-accepted") + throw new Error("unexpected result"); + expect(Object.isFrozen(result.receipt)).toBe(true); + }); + it("preserves all existing observer handlers and settles once before final processing", () => { + const onTextDelta = vi.fn(); + const onComplete = vi.fn(); + const onError = vi.fn(); + const onRuntimeAccepted = vi.fn(() => { + throw new Error("PRIVATE BODY"); + }); + const capture = createRuntimeSubmissionCapture({ + onTextDelta, + onComplete, + onError, + onRuntimeAccepted, + }); + expect(capture.observer).toMatchObject({ + onTextDelta, + onComplete, + onError, + }); + capture.observer.onRuntimeAccepted(receipt); + expect(capture.finish({ kind: "unknown" })).toEqual({ + kind: "runtime-accepted", + receipt, + }); + capture.observer.onRuntimeAccepted(receipt); + expect(onRuntimeAccepted).toHaveBeenCalledTimes(1); + expect(capture.reject(new Error("refresh failed"))).toEqual({ + kind: "runtime-accepted", + receipt, + }); + }); + it("rethrows the exact request rejection when no response accepted", () => { + const capture = createRuntimeSubmissionCapture(); + const error = new Error("request rejected"); + expect(() => capture.rethrow(error)).toThrow(error); + expect(capture.reject(error)).toEqual({ kind: "rejected", error }); + }); + it("keeps real queue response distinct from start/steer and verifies request message identity", () => { + const context = { + sessionId: "queue-session", + threadId: "queue-thread", + clientUserMessageId: "client-1", + }; + const submission = { + id: "queued-1", + clientUserMessageId: "client-1", + input: [{ type: "text" as const, text: "queued body" }], + }; + const result = fromQueueAcceptanceResponse(context, submission); + expect(result).toMatchObject({ + kind: "queue-accepted", + receipt: { kind: "queue", queuedSubmissionId: "queued-1", ...context }, + }); + if (result.kind !== "queue-accepted") throw new Error("unexpected result"); + expect(result.receipt.submission).toBe(submission); + expect(result.receipt).not.toHaveProperty("turnId"); + expect(result.receipt).not.toHaveProperty("itemId"); + expect( + fromQueueAcceptanceResponse( + { ...context, clientUserMessageId: "other" }, + submission, + ), + ).toEqual({ kind: "unknown" }); + expect( + fromQueueAcceptanceResponse({ ...context, sessionId: null }, submission), + ).toEqual({ kind: "unknown" }); + }); +}); + +describe("receipt identity consistency", () => { + it("keeps first genuine acceptance when a duplicate reports another turn", () => { + const accepted = vi.fn(); + const capture = createRuntimeSubmissionCapture({ + onRuntimeAccepted: accepted, + }); + capture.observer.onRuntimeAccepted(receipt); + capture.observer.onRuntimeAccepted({ + ...receipt, + sessionId: "other-session", + turnId: "other-turn", + }); + expect( + capture.finish({ + kind: "runtime-accepted", + receipt: { ...receipt, turnId: "other-turn" }, + }), + ).toEqual({ kind: "runtime-accepted", receipt }); + expect(accepted).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentSendResult.testFixtures.ts b/src/components/agent/chat/hooks/agentSendResult.testFixtures.ts new file mode 100644 index 000000000..84943392d --- /dev/null +++ b/src/components/agent/chat/hooks/agentSendResult.testFixtures.ts @@ -0,0 +1,18 @@ +import type { RuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import type { AcceptedRuntimeSubmissionResult } from "./agentSendResult"; +export function runtimeReceipt( + sessionId = "session-1", +): RuntimeAcceptanceReceipt { + return Object.freeze({ + kind: "start", + sessionId, + threadId: "thread-1", + turnId: "turn-1", + clientUserMessageId: "client-1", + }); +} +export function acceptedSubmission( + sessionId = "session-1", +): AcceptedRuntimeSubmissionResult { + return { kind: "runtime-accepted", receipt: runtimeReceipt(sessionId) }; +} diff --git a/src/components/agent/chat/hooks/agentSendResult.ts b/src/components/agent/chat/hooks/agentSendResult.ts new file mode 100644 index 000000000..2bbef1758 --- /dev/null +++ b/src/components/agent/chat/hooks/agentSendResult.ts @@ -0,0 +1,205 @@ +import type { QueuedSubmission } from "@limecloud/app-server-client"; +import { + requireRuntimeAcceptanceReceipt, + reportRuntimeAcceptancePostProcessingFailure, + type RuntimeAcceptanceReceipt, + type RuntimeAcceptancePostProcessingPhase, +} from "@/lib/api/agentRuntime/runtimeAcceptance"; +import { parseQueuedSubmission } from "@/lib/api/agentRuntime/threadQueueClient"; +import type { SendMessageObserver } from "./agentChatShared"; + +export interface RuntimeQueueAcceptanceReceipt { + readonly kind: "queue"; + readonly sessionId: string; + readonly threadId: string; + readonly queuedSubmissionId: string; + readonly clientUserMessageId: string; + readonly submission: QueuedSubmission; +} +export type RuntimeSubmissionAcceptance = + | RuntimeAcceptanceReceipt + | RuntimeQueueAcceptanceReceipt; +export type AcceptedRuntimeSubmissionResult = + | { + readonly kind: "runtime-accepted"; + readonly receipt: RuntimeAcceptanceReceipt; + } + | { + readonly kind: "queue-accepted"; + readonly receipt: RuntimeQueueAcceptanceReceipt; + }; +export type RuntimeSubmissionResult = + | AcceptedRuntimeSubmissionResult + | { readonly kind: "local-handled" } + | { readonly kind: "rejected"; readonly error?: unknown } + | { readonly kind: "unknown" }; + +function isId(value: unknown): value is string { + return ( + typeof value === "string" && Boolean(value.trim()) && value === value.trim() + ); +} +export function fromRuntimeAcceptanceReceipt( + receipt: RuntimeAcceptanceReceipt, +): RuntimeSubmissionResult { + try { + return Object.freeze({ + kind: "runtime-accepted", + receipt: requireRuntimeAcceptanceReceipt(receipt), + }); + } catch { + return { kind: "unknown" }; + } +} +export function fromQueueAcceptanceResponse( + context: { + sessionId?: string | null; + threadId: string; + clientUserMessageId: string; + }, + response: QueuedSubmission, +): RuntimeSubmissionResult { + const submission = parseQueuedSubmission(response); + if ( + !isId(context.sessionId) || + !isId(context.threadId) || + !isId(context.clientUserMessageId) || + !submission || + !isId(submission.id) || + submission.clientUserMessageId !== context.clientUserMessageId + ) { + return { kind: "unknown" }; + } + return Object.freeze({ + kind: "queue-accepted", + receipt: Object.freeze({ + kind: "queue", + sessionId: context.sessionId, + threadId: context.threadId, + queuedSubmissionId: submission.id, + clientUserMessageId: context.clientUserMessageId, + submission, + }), + }); +} +export function normalizeRuntimeSubmissionResult( + value: unknown, +): RuntimeSubmissionResult { + if (!value || typeof value !== "object" || !("kind" in value)) + return { kind: "unknown" }; + if (value.kind === "runtime-accepted" && "receipt" in value) { + return fromRuntimeAcceptanceReceipt( + value.receipt as RuntimeAcceptanceReceipt, + ); + } + if (value.kind === "queue-accepted" && "receipt" in value) { + const receipt = value.receipt as RuntimeQueueAcceptanceReceipt; + if (!receipt || receipt.kind !== "queue") return { kind: "unknown" }; + const result = fromQueueAcceptanceResponse(receipt, receipt.submission); + return result.kind === "queue-accepted" && + result.receipt.queuedSubmissionId === receipt.queuedSubmissionId + ? result + : { kind: "unknown" }; + } + if (value.kind === "local-handled") return { kind: "local-handled" }; + if (value.kind === "rejected") + return { + kind: "rejected", + ...("error" in value ? { error: value.error } : {}), + }; + return { kind: "unknown" }; +} +export function isRuntimeSubmissionAccepted( + result: RuntimeSubmissionResult, +): result is AcceptedRuntimeSubmissionResult { + return result.kind === "runtime-accepted" || result.kind === "queue-accepted"; +} +export function runRuntimeSubmissionEffect( + receipt: RuntimeSubmissionAcceptance, + phase: RuntimeAcceptancePostProcessingPhase, + effect: () => void, +): void { + try { + effect(); + } catch { + reportRuntimeAcceptancePostProcessingFailure(phase, receipt); + } +} +export function createRuntimeSubmissionCapture( + observer?: SendMessageObserver, + onAccepted?: (result: AcceptedRuntimeSubmissionResult) => void, +) { + let accepted: AcceptedRuntimeSubmissionResult | undefined; + const accept = (receipt: RuntimeSubmissionAcceptance) => { + const result = + receipt.kind === "queue" + ? normalizeRuntimeSubmissionResult({ kind: "queue-accepted", receipt }) + : fromRuntimeAcceptanceReceipt(receipt); + if (!isRuntimeSubmissionAccepted(result)) + throw new Error("Invalid submission acceptance receipt"); + if (accepted) { + if ( + accepted.kind !== result.kind || + accepted.receipt.kind !== result.receipt.kind || + accepted.receipt.sessionId !== result.receipt.sessionId || + accepted.receipt.threadId !== result.receipt.threadId || + accepted.receipt.clientUserMessageId !== + result.receipt.clientUserMessageId || + (accepted.kind === "runtime-accepted" && + result.kind === "runtime-accepted" && + accepted.receipt.turnId !== result.receipt.turnId) || + (accepted.kind === "queue-accepted" && + result.kind === "queue-accepted" && + accepted.receipt.queuedSubmissionId !== + result.receipt.queuedSubmissionId) + ) { + reportRuntimeAcceptancePostProcessingFailure( + "observer", + accepted.receipt, + ); + } + return; + } + accepted = result; + runRuntimeSubmissionEffect(result.receipt, "observer", () => + onAccepted?.(result), + ); + runRuntimeSubmissionEffect(result.receipt, "observer", () => + observer?.onRuntimeAccepted?.(result.receipt), + ); + }; + return { + observer: { + ...observer, + onRuntimeAccepted: accept, + } satisfies SendMessageObserver, + get accepted() { + return accepted; + }, + finish(value: RuntimeSubmissionResult): RuntimeSubmissionResult { + const result = normalizeRuntimeSubmissionResult(value); + if (isRuntimeSubmissionAccepted(result)) accept(result.receipt); + return accepted ?? result; + }, + reject(error: unknown): RuntimeSubmissionResult { + if (accepted) { + reportRuntimeAcceptancePostProcessingFailure( + "accepted_submit", + accepted.receipt, + ); + return accepted; + } + return { kind: "rejected", error }; + }, + rethrow(error: unknown): AcceptedRuntimeSubmissionResult { + if (accepted) { + reportRuntimeAcceptancePostProcessingFailure( + "accepted_submit", + accepted.receipt, + ); + return accepted; + } + throw error; + }, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamAcceptance.testFixtures.ts b/src/components/agent/chat/hooks/agentStreamAcceptance.testFixtures.ts new file mode 100644 index 000000000..d2421f93b --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamAcceptance.testFixtures.ts @@ -0,0 +1,124 @@ +import { vi } from "vitest"; +import type { ComposerSubmission } from "@/components/input-kit/types"; +import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; +import { executeAgentStreamSubmit } from "./agentStreamSubmitExecution"; + +const submission: ComposerSubmission = { + text: "A😀@x", + textElements: [{ byteRange: { start: 5, end: 7 }, placeholder: "@x" }], + inputMentions: [{ type: "skill", name: "x", path: "/skills/x" }], + attachments: [], + pathReferences: [ + { id: "path-1", path: "/tmp/report.md", name: "report.md", isDir: false }, + { id: "path-2", path: "/tmp/report.md", name: "report.md", isDir: false }, + ], +}; + +export function createAcceptanceOptions(activeTurnId: string | null) { + const submitOp = vi + .fn() + .mockResolvedValue({ turn: { id: "turn-start", status: "inProgress" } }); + const steerTurn = vi.fn().mockResolvedValue({ turnId: activeTurnId }); + const options: Parameters[0] = { + runtime: { + getThreadTurnControl: vi.fn().mockResolvedValue({ + activeTurnId, + queuedTurnIds: [], + threadId: "thread-1", + updatedAtMs: 0, + }), + listenToTurnEvents: vi.fn().mockResolvedValue(vi.fn()), + submitOp, + steerTurn, + } as unknown as AgentRuntimeAdapter, + ensureSession: vi.fn().mockResolvedValue("session-1"), + attemptSilentTurnRecovery: vi.fn().mockResolvedValue(false), + refreshSessionReadModel: vi.fn().mockResolvedValue(true), + sessionIdRef: { current: "session-1" }, + getWorkspaceIdForSubmit: () => "workspace-1", + getThreadIdForSubmit: () => "thread-1", + getSyncedSessionExecutionStrategy: () => "react", + effectiveAccessMode: "current", + content: submission.text, + images: [], + composerSubmission: submission, + skipUserMessage: false, + effectiveProviderType: "openai", + effectiveModel: "fixture", + effectiveExecutionStrategy: "react", + eventName: "event-1", + clientUserMessageId: "client-1", + assistantMsgId: "assistant-1", + pendingTurnKey: "pending-turn-1", + pendingItemKey: "pending-item-1", + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + requestState: { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + }, + callbacks: { + activateStream: vi.fn(), + isStreamActivated: () => false, + clearOptimisticItem: vi.fn(), + clearOptimisticTurn: vi.fn(), + disposeListener: vi.fn(), + clearActiveStreamIfMatch: () => false, + registerListener: vi.fn(), + }, + appendThinkingToParts: (parts) => parts, + setMessages: vi.fn(), + setIsSending: vi.fn(), + setPendingActions: vi.fn(), + setThreadItems: vi.fn(), + setThreadTurns: vi.fn(), + setCurrentTurnId: vi.fn(), + setExecutionRuntime: vi.fn(), + }; + return { options, submitOp, steerTurn }; +} + +import type { AgentStreamPreparedSendEnv } from "./agentStreamPreparedSendEnv"; +export function createAcceptanceEnv( + activeTurnId: string | null = null, +): AgentStreamPreparedSendEnv { + const { options } = createAcceptanceOptions(activeTurnId); + return { + runtime: options.runtime, + ensureSession: options.ensureSession, + attemptSilentTurnRecovery: options.attemptSilentTurnRecovery, + refreshSessionReadModel: options.refreshSessionReadModel, + executionStrategy: "react", + accessMode: "current", + clawTraceEnabled: false, + providerTypeRef: { current: "openai" }, + modelRef: { current: "fixture" }, + reasoningEffortRef: { current: "" }, + sessionIdRef: options.sessionIdRef, + runPreparedSubmit: (task) => task(), + getWorkspaceIdForSubmit: options.getWorkspaceIdForSubmit, + getThreadIdForSubmit: options.getThreadIdForSubmit, + getSyncedSessionModelPreference: () => null, + getSyncedSessionExecutionStrategy: + options.getSyncedSessionExecutionStrategy, + warnedKeysRef: options.warnedKeysRef, + listenerMapRef: { current: new Map() }, + activeStreamRef: { current: null }, + setActiveStream: vi.fn(), + clearActiveStreamIfMatch: vi.fn().mockReturnValue(false), + setMessages: options.setMessages, + setThreadItems: options.setThreadItems, + setThreadTurns: options.setThreadTurns, + setCurrentTurnId: options.setCurrentTurnId, + setExecutionRuntime: options.setExecutionRuntime, + setPendingActions: options.setPendingActions, + setWorkspacePathMissing: vi.fn(), + setIsSending: options.setIsSending, + appendThinkingToParts: options.appendThinkingToParts, + }; +} diff --git a/src/components/agent/chat/hooks/agentStreamComposerRestoreToken.test.ts b/src/components/agent/chat/hooks/agentStreamComposerRestoreToken.test.ts new file mode 100644 index 000000000..b07a7e611 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamComposerRestoreToken.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { + canRestoreComposerSubmission, + createComposerSubmissionSettlement, + type ComposerSubmissionRestoreToken, +} from "../components/composerSubmissionSettlement"; +import { prepareAgentStreamUserInputSend } from "./agentStreamUserInputSendPreparation"; +import { submitAgentStreamUserInput } from "./agentStreamUserInputSubmission"; +import { stopActiveAgentStream } from "./agentStreamFlowControl"; +import { createRestoreTokenFixture } from "./agentStreamRestoreToken.testFixtures"; +import { activityLogger } from "@/lib/workspace/workbenchRuntime"; +import { clearAllAgentStreamTextOverlays } from "./agentStreamTextOverlayStore"; + +vi.mock("@/lib/api/permissionProfiles", () => ({ + resolveAllowedPermissionProfile: vi.fn().mockResolvedValue({ allowed: true }), +})); +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: vi.fn().mockResolvedValue({ models: [] }), + }, +})); +afterEach(() => { + activityLogger.clear(); + clearAllAgentStreamTextOverlays(); + vi.restoreAllMocks(); +}); + +function token(): ComposerSubmissionRestoreToken { + return Object.freeze({ kind: "composer-submission" }); +} + +describe("in-process composer restore token", () => { + it("retains the actual capability through preparation, submission, stop and the restore guard", async () => { + const f = createRestoreTokenFixture(); + const controller = new ComposerController({ text: "A😀 原始输入" }); + const scope = { projectId: "workspace-1", sessionId: "session-1" }; + const settlement = createComposerSubmissionSettlement({ + controller, + target: "start", + scope, + canApplyUi: () => true, + onCommitted: vi.fn(), + }); + const receipt = settlement.receipt!; + const prepared = prepareAgentStreamUserInputSend({ + content: receipt.submission.text, + images: [], + skipUserMessage: false, + options: { + composerDraft: receipt.draft, + composerSubmission: receipt.submission, + composerRestoreToken: settlement.composerRestoreToken, + observer: { onRuntimeAccepted: settlement.onRuntimeAccepted }, + }, + env: f.env, + }); + expect(prepared.composerRestoreToken).toBe(settlement.composerRestoreToken); + expect(prepared.submittedDraft).not.toBe(receipt.draft); + await submitAgentStreamUserInput({ preparedSend: prepared, env: f.env }); + expect(f.state.active?.composerRestoreToken).toBe( + settlement.composerRestoreToken, + ); + expect(controller.getSnapshot().document.text).toBe(""); + await stopActiveAgentStream(f.stopOptions()); + const request = f.onRestoreInterruptedInput.mock.calls[0][0]; + expect(request.composerRestoreToken).toBe(settlement.composerRestoreToken); + expect(request.draft.text).toBe("A😀 原始输入"); + expect( + canRestoreComposerSubmission({ + controller, + token: request.composerRestoreToken, + scope, + }), + ).toBe(true); + expect( + canRestoreComposerSubmission({ + controller, + token: structuredClone(request.composerRestoreToken), + scope, + }), + ).toBe(false); + controller.setText("后续编辑"); + expect( + canRestoreComposerSubmission({ + controller, + token: request.composerRestoreToken, + scope, + }), + ).toBe(false); + for (const projection of [ + prepared.requestMetadata, + prepared.userMsg, + prepared.assistantMsg, + ...f.runtime.submitOp.mock.calls, + ...f.runtime.interruptTurn.mock.calls, + ]) { + expect(JSON.stringify(projection)).not.toContain("composerRestoreToken"); + expect(JSON.stringify(projection)).not.toContain("composer-submission"); + } + }); + + it.each(["composer", "legacy"] as const)( + "retains the token separately from cloned %s draft data", + async (mode) => { + const f = createRestoreTokenFixture(); + const original = token(); + const draft = new ComposerController({ text: "原输入" }).captureDraft(); + const prepared = prepareAgentStreamUserInputSend({ + content: draft.text, + images: [], + skipUserMessage: false, + options: { + composerRestoreToken: original, + ...(mode === "composer" + ? { composerDraft: draft } + : { inputRestoreDraft: { text: draft.text } }), + }, + env: f.env, + }); + expect(prepared.composerRestoreToken).toBe(original); + expect(prepared.submittedDraft).not.toHaveProperty( + "composerRestoreToken", + ); + await submitAgentStreamUserInput({ preparedSend: prepared, env: f.env }); + expect(f.state.active?.composerRestoreToken).toBe(original); + await stopActiveAgentStream(f.stopOptions()); + expect( + f.onRestoreInterruptedInput.mock.calls[0][0].composerRestoreToken, + ).toBe(original); + }, + ); + + it("uses the fallback token only when it uses the fallback draft", async () => { + const f = createRestoreTokenFixture(); + const original = token(); + await stopActiveAgentStream({ + ...f.stopOptions(), + submittedDraftFallback: { text: "备用输入" }, + composerRestoreTokenFallback: original, + }); + expect(f.onRestoreInterruptedInput.mock.calls[0][0]).toMatchObject({ + draft: { text: "备用输入" }, + }); + expect( + f.onRestoreInterruptedInput.mock.calls[0][0].composerRestoreToken, + ).toBe(original); + }); + + it.each([false, true])( + "does not borrow a fallback capability for an active draft (active token=%s)", + async (hasActiveToken) => { + const f = createRestoreTokenFixture(); + const active = token(); + const fallback = token(); + const options = f.stopOptions(); + await stopActiveAgentStream({ + ...options, + activeStream: { + assistantMsgId: "active-1", + eventName: "stream-1", + sessionId: "session-1", + submittedDraft: { text: "活动输入" }, + ...(hasActiveToken ? { composerRestoreToken: active } : {}), + }, + submittedDraftFallback: { text: "另一份输入" }, + composerRestoreTokenFallback: fallback, + }); + const request = f.onRestoreInterruptedInput.mock.calls[0][0]; + expect(request.draft.text).toBe("活动输入"); + expect(request.composerRestoreToken).toBe( + hasActiveToken ? active : undefined, + ); + expect(request.composerRestoreToken).not.toBe(fallback); + }, + ); + + it("keeps legacy requests token-free and output suppression unchanged", async () => { + const f = createRestoreTokenFixture(); + await stopActiveAgentStream({ + ...f.stopOptions(), + submittedDraftFallback: { text: "原输入" }, + }); + expect(f.onRestoreInterruptedInput.mock.calls[0][0]).not.toHaveProperty( + "composerRestoreToken", + ); + f.onRestoreInterruptedInput.mockClear(); + f.state.messages = [ + { + id: "assistant-1", + role: "assistant", + content: "已有回答", + timestamp: new Date(), + }, + ]; + await stopActiveAgentStream({ + ...f.stopOptions(), + activeStream: { + assistantMsgId: "assistant-1", + eventName: "stream-1", + sessionId: "session-1", + submittedDraft: { text: "原输入" }, + composerRestoreToken: token(), + }, + }); + expect(f.onRestoreInterruptedInput).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamFlowControl.test.ts b/src/components/agent/chat/hooks/agentStreamFlowControl.test.ts index 895f11708..1bac93218 100644 --- a/src/components/agent/chat/hooks/agentStreamFlowControl.test.ts +++ b/src/components/agent/chat/hooks/agentStreamFlowControl.test.ts @@ -1413,9 +1413,9 @@ describe("agentStreamFlowControl", () => { sourcePath: "/tmp/rich-restore-fixture.png", }; const pathReference = { - id: "file:/tmp/clawstream-rich-restore-fixture.md", - path: "/tmp/clawstream-rich-restore-fixture.md", - name: "clawstream-rich-restore-fixture.md", + id: "file:/tmp/composer-reference.md", + path: "/tmp/composer-reference.md", + name: "composer-reference.md", isDir: false, source: "file_manager" as const, }; diff --git a/src/components/agent/chat/hooks/agentStreamFlowControl.ts b/src/components/agent/chat/hooks/agentStreamFlowControl.ts index 3fbfb5202..d8b08380b 100644 --- a/src/components/agent/chat/hooks/agentStreamFlowControl.ts +++ b/src/components/agent/chat/hooks/agentStreamFlowControl.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol"; import { logAgentDebug } from "@/lib/agentDebug"; @@ -50,6 +51,7 @@ interface StopAgentStreamOptions { getThreadItems?: () => readonly AgentThreadItem[]; setActiveStream: (nextActive: ActiveStreamState | null) => void; submittedDraftFallback?: InterruptedInputDraftSnapshot | null; + composerRestoreTokenFallback?: ComposerSubmissionRestoreToken | null; onRestoreInterruptedInput?: (request: InterruptedInputRestoreRequest) => void; notify: AgentStreamFlowNotify; onInterruptError?: (error: unknown) => void; @@ -210,12 +212,8 @@ function resolveInterruptedCanonicalText(options: { runtimeTurnId?: string; threadItems: readonly AgentThreadItem[]; }): string { - const { - activeSessionId, - canonicalThreadId, - runtimeTurnId, - threadItems, - } = options; + const { activeSessionId, canonicalThreadId, runtimeTurnId, threadItems } = + options; if (!runtimeTurnId) { return ""; } @@ -263,7 +261,8 @@ function resolveInterruptedVisibleMessage(options: { if ( message.role !== "assistant" || message.runtimeTurnId !== runtimeTurnId || - messageVisibleTextLength(message) <= messageVisibleTextLength(visibleMessage) + messageVisibleTextLength(message) <= + messageVisibleTextLength(visibleMessage) ) { continue; } @@ -389,10 +388,8 @@ function ensureInterruptedAgentMessageItem(options: { return items; } const now = new Date().toISOString(); - const sequence = items.reduce( - (max, item) => Math.max(max, item.sequence), - 0, - ) + 1; + const sequence = + items.reduce((max, item) => Math.max(max, item.sequence), 0) + 1; return upsertThreadItemState(items, { id: activeMessageId, thread_id: threadId?.trim() || "", @@ -465,6 +462,7 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { getThreadItems, setActiveStream, submittedDraftFallback, + composerRestoreTokenFallback, onRestoreInterruptedInput, notify, onInterruptError, @@ -483,6 +481,10 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { ) ?? null) : null; const currentThreadItems = getThreadItems?.() ?? []; + const composerRestoreToken = + activeStream?.submittedDraft != null + ? activeStream.composerRestoreToken + : composerRestoreTokenFallback; const restorePlan = resolveInterruptedInputRestorePlan({ submittedDraft: activeStream?.submittedDraft ?? submittedDraftFallback, assistantMessage, @@ -536,6 +538,7 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { requestId: crypto.randomUUID(), reason: restorePlan.reason, draft: restorePlan.draft, + ...(composerRestoreToken ? { composerRestoreToken } : {}), }); }; const runInterruptAndRefresh = async () => { @@ -569,12 +572,9 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { } let refreshedReadModelItems: AgentThreadItem[] = []; try { - const refreshedReadModel = await runtime.getSessionReadModel( - activeSessionId, - ); - refreshedReadModelItems = Array.isArray( - refreshedReadModel?.thread_items, - ) + const refreshedReadModel = + await runtime.getSessionReadModel(activeSessionId); + refreshedReadModelItems = Array.isArray(refreshedReadModel?.thread_items) ? refreshedReadModel.thread_items : []; } catch (error) { @@ -582,10 +582,7 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { } let replayedThreadItems: AgentThreadItem[] = []; const canonicalThreadId = threadId?.trim(); - if ( - canonicalThreadId && - typeof runtime.resumeThread === "function" - ) { + if (canonicalThreadId && typeof runtime.resumeThread === "function") { try { await runtime.resumeThread(canonicalThreadId, (reducer) => { replayedThreadItems = [...reducer.getProjection().items]; @@ -608,16 +605,15 @@ export async function stopActiveAgentStream(options: StopAgentStreamOptions) { if (interruptedRuntimeTurnId && refreshedCanonicalText.trim()) { setThreadItems((prev) => markInterruptedAgentMessageThreadItems( - [...refreshedReadModelItems, ...replayedThreadItems].reduce( - (items, item) => upsertThreadItemState(items, item), - prev, - ).map((item) => - mergeInterruptedAgentMessageText( - item, - interruptedRuntimeTurnId, - refreshedCanonicalText, + [...refreshedReadModelItems, ...replayedThreadItems] + .reduce((items, item) => upsertThreadItemState(items, item), prev) + .map((item) => + mergeInterruptedAgentMessageText( + item, + interruptedRuntimeTurnId, + refreshedCanonicalText, + ), ), - ), new Set([interruptedRuntimeTurnId]), ), ); diff --git a/src/components/agent/chat/hooks/agentStreamInputRestoreTypes.ts b/src/components/agent/chat/hooks/agentStreamInputRestoreTypes.ts index 527dc029d..5274b2718 100644 --- a/src/components/agent/chat/hooks/agentStreamInputRestoreTypes.ts +++ b/src/components/agent/chat/hooks/agentStreamInputRestoreTypes.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { MessageImage, MessagePathReference } from "../types"; import type { InputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection"; import type { ComposerDraftSnapshot } from "@/components/input-kit/types"; @@ -25,6 +26,7 @@ export interface InterruptedInputRestorePlan { } export interface InterruptedInputRestoreRequest { + composerRestoreToken?: ComposerSubmissionRestoreToken; requestId: string; reason: InterruptedInputRestoreReason; draft: InterruptedInputDraftSnapshot; diff --git a/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.test.ts b/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.test.ts index 3a1511c1a..d12c390f2 100644 --- a/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.test.ts +++ b/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.test.ts @@ -95,11 +95,19 @@ describe("agentStreamPreparedSendDispatch", () => { it("应通过 prepared submit gate 提交 user_input", async () => { const env = createEnv(); - await dispatchPreparedAgentStreamSend({ + const receipt = { + kind: "start" as const, + sessionId: "session-1", + threadId: "thread-1", + turnId: "turn-1", + }; + vi.mocked(submitAgentStreamUserInput).mockResolvedValue(receipt); + const result = await dispatchPreparedAgentStreamSend({ preparedSend, env, }); + expect(result).toBe(receipt); expect(vi.mocked(env.runPreparedSubmit)).toHaveBeenCalledTimes(1); expect(submitAgentStreamUserInput).toHaveBeenCalledWith( expect.objectContaining({ preparedSend }), diff --git a/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.ts b/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.ts index eb6d87630..10ae69a1a 100644 --- a/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.ts +++ b/src/components/agent/chat/hooks/agentStreamPreparedSendDispatch.ts @@ -12,7 +12,7 @@ export async function dispatchPreparedAgentStreamSend( ) { const { preparedSend, env } = options; - await env.runPreparedSubmit(() => + return await env.runPreparedSubmit(() => submitAgentStreamUserInput({ preparedSend, env, diff --git a/src/components/agent/chat/hooks/agentStreamRestoreToken.testFixtures.ts b/src/components/agent/chat/hooks/agentStreamRestoreToken.testFixtures.ts new file mode 100644 index 000000000..4595279a3 --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamRestoreToken.testFixtures.ts @@ -0,0 +1,119 @@ +import { vi } from "vitest"; +import type { Dispatch, SetStateAction } from "react"; +import type { AgentStreamPreparedSendEnv } from "./agentStreamPreparedSendEnv"; +import type { AgentRuntimeAdapter } from "./agentRuntimeAdapter"; +import type { Message } from "../types"; +import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol"; +import type { ActiveStreamState } from "./agentStreamSubmissionLifecycle"; +import type { InterruptedInputRestoreRequest } from "./agentStreamInputRestoreTypes"; + +export function createRestoreTokenFixture() { + const state = { + messages: [] as Message[], + items: [] as AgentThreadItem[], + turns: [] as AgentThreadTurn[], + currentTurnId: null as string | null, + active: null as ActiveStreamState | null, + }; + function setter(read: () => T, write: (next: T) => void) { + return vi.fn((next: SetStateAction) => { + write( + typeof next === "function" ? (next as (value: T) => T)(read()) : next, + ); + }); + } + function ignore() { + return vi.fn() as Dispatch>; + } + const runtime = { + listenToTurnEvents: vi.fn().mockResolvedValue(vi.fn()), + getThreadTurnControl: vi.fn().mockResolvedValue({ + activeTurnId: null, + queuedTurnIds: [], + threadId: "thread-1", + updatedAtMs: 0, + }), + submitOp: vi.fn().mockResolvedValue({ + turn: { id: "turn-1", status: "inProgress" }, + }), + steerTurn: vi.fn().mockResolvedValue({ turnId: "turn-1" }), + interruptTurn: vi.fn().mockResolvedValue(undefined), + getSessionReadModel: vi.fn().mockResolvedValue({ thread_items: [] }), + }; + const env: AgentStreamPreparedSendEnv = { + runtime: runtime as unknown as AgentRuntimeAdapter, + executionStrategy: "react", + accessMode: "current", + executionRuntime: null, + clawTraceEnabled: false, + providerTypeRef: { current: "openai" }, + modelRef: { current: "fixture" }, + reasoningEffortRef: { current: "" }, + sessionIdRef: { current: "session-1" }, + runPreparedSubmit: (task) => task(), + getWorkspaceIdForSubmit: () => "workspace-1", + getThreadIdForSubmit: () => "thread-1", + waitForSessionProviderSelectionSync: vi.fn().mockResolvedValue(undefined), + getSyncedSessionExecutionStrategy: () => "react", + getSyncedSessionModelPreference: () => null, + ensureSession: vi.fn().mockResolvedValue("session-1"), + attemptSilentTurnRecovery: vi.fn().mockResolvedValue(false), + refreshSessionReadModel: vi.fn().mockResolvedValue(true), + warnedKeysRef: { current: new Set() }, + listenerMapRef: { current: new Map() }, + activeStreamRef: { current: null }, + setActiveStream: (active) => { + state.active = active; + env.activeStreamRef.current = active; + }, + clearActiveStreamIfMatch: (eventName) => { + if (state.active?.eventName !== eventName) return false; + env.setActiveStream(null); + return true; + }, + setMessages: setter( + () => state.messages, + (next) => (state.messages = next), + ), + getThreadItems: () => state.items, + setThreadItems: setter( + () => state.items, + (next) => (state.items = next), + ), + setThreadTurns: setter( + () => state.turns, + (next) => (state.turns = next), + ), + setCurrentTurnId: setter( + () => state.currentTurnId, + (next) => (state.currentTurnId = next), + ), + setTodoItems: ignore(), + setExecutionRuntime: ignore(), + setPendingActions: ignore(), + setWorkspacePathMissing: ignore(), + setIsSending: ignore(), + appendThinkingToParts: (parts) => parts, + }; + const onRestoreInterruptedInput = + vi.fn<(request: InterruptedInputRestoreRequest) => void>(); + const stopOptions = () => ({ + activeStream: state.active, + sessionIdRef: env.sessionIdRef, + threadId: "thread-1", + currentTurnId: state.currentTurnId, + runtime: env.runtime, + removeStreamListener: vi.fn().mockReturnValue(true), + refreshSessionReadModel: env.refreshSessionReadModel, + setThreadItems: env.setThreadItems, + setThreadTurns: env.setThreadTurns, + setCurrentTurnId: env.setCurrentTurnId, + setMessages: env.setMessages, + getMessages: () => state.messages, + getThreadItems: () => state.items, + setActiveStream: env.setActiveStream, + onRestoreInterruptedInput, + notify: { info: vi.fn(), error: vi.fn() }, + }); + return { state, runtime, env, stopOptions, onRestoreInterruptedInput }; +} diff --git a/src/components/agent/chat/hooks/agentStreamSend.test.ts b/src/components/agent/chat/hooks/agentStreamSend.test.ts index 358778857..146e3122f 100644 --- a/src/components/agent/chat/hooks/agentStreamSend.test.ts +++ b/src/components/agent/chat/hooks/agentStreamSend.test.ts @@ -32,7 +32,14 @@ describe("sendAgentStreamMessage", () => { preparedSend as never, ); - await sendAgentStreamMessage({ + const receipt = { + kind: "start" as const, + sessionId: "session-existing", + threadId: "thread-1", + turnId: "turn-1", + }; + vi.mocked(dispatchPreparedAgentStreamSend).mockResolvedValue(receipt); + const result = await sendAgentStreamMessage({ content: "继续执行", images: [], webSearch: true, @@ -43,6 +50,7 @@ describe("sendAgentStreamMessage", () => { env, }); + expect(result).toBe(receipt); expect(prepareAgentStreamUserInputSend).toHaveBeenCalledWith( expect.objectContaining({ content: "继续执行", diff --git a/src/components/agent/chat/hooks/agentStreamSend.ts b/src/components/agent/chat/hooks/agentStreamSend.ts index bf41f43ff..e344be757 100644 --- a/src/components/agent/chat/hooks/agentStreamSend.ts +++ b/src/components/agent/chat/hooks/agentStreamSend.ts @@ -69,7 +69,7 @@ export async function sendAgentStreamMessage( env, }); - await dispatchPreparedAgentStreamSend({ + return await dispatchPreparedAgentStreamSend({ preparedSend, env, }); diff --git a/src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.ts b/src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.ts index b9450387d..868560ce7 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmissionLifecycle.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { Dispatch, MutableRefObject, SetStateAction } from "react"; import type { AgentThreadItem, AgentThreadTurn } from "@/lib/api/agentProtocol"; import type { Message } from "../types"; @@ -21,6 +22,7 @@ export interface ActiveStreamState { turnId?: string; pendingTurnKey?: string; pendingItemKey?: string; + composerRestoreToken?: ComposerSubmissionRestoreToken; submittedDraft?: InterruptedInputDraftSnapshot | null; } @@ -74,6 +76,7 @@ interface CreateSubmissionLifecycleOptions { userMsgId: string | null; userMsg?: Message | null; content: string; + composerRestoreToken?: ComposerSubmissionRestoreToken; submittedDraft?: InterruptedInputDraftSnapshot | null; listenerMapRef: MutableRefObject void>>; setActiveStream: (nextActive: ActiveStreamState | null) => void; @@ -101,6 +104,7 @@ export function createAgentStreamSubmissionLifecycle( userMsg, content, submittedDraft, + composerRestoreToken, listenerMapRef, setActiveStream, setMessages, @@ -242,6 +246,7 @@ export function createAgentStreamSubmissionLifecycle( pendingTurnKey, pendingItemKey, submittedDraft: submittedDraft ?? null, + ...(composerRestoreToken ? { composerRestoreToken } : {}), }); setMessages((prev) => { const equivalentPendingUserMsg = findEquivalentPendingUserMessage( diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.acceptance.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.acceptance.test.ts new file mode 100644 index 000000000..ca743c09a --- /dev/null +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.acceptance.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { executeAgentStreamSubmit } from "./agentStreamSubmitExecution"; +import { + createAcceptanceOptions, + createAcceptanceEnv, +} from "./agentStreamAcceptance.testFixtures"; +import { sendAgentStreamMessage } from "./agentStreamSend"; +import type { TurnStartResponse } from "@limecloud/app-server-client"; +const { logMock } = vi.hoisted(() => ({ logMock: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: logMock })); +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: vi.fn().mockResolvedValue({ models: [] }), + }, +})); +vi.mock("@/lib/api/permissionProfiles", () => ({ + resolveAllowedPermissionProfile: vi.fn().mockResolvedValue({ allowed: true }), +})); +afterEach(() => vi.clearAllMocks()); +const response: TurnStartResponse = { + turn: { id: "turn-response", status: "inProgress" }, +}; +describe("request-scoped runtime acceptance", () => { + it("captures actual request session/thread before a later global ref changes", async () => { + const { options, submitOp } = createAcceptanceOptions(null); + const accepted = vi.fn(); + options.observer = { onRuntimeAccepted: accepted }; + submitOp.mockImplementation(async (_op, onAccepted) => { + options.sessionIdRef.current = "another-session"; + onAccepted?.(response); + return response; + }); + const receipt = await executeAgentStreamSubmit(options); + expect(receipt).toEqual({ + kind: "start", + sessionId: "session-1", + threadId: "thread-1", + turnId: "turn-response", + clientUserMessageId: "client-1", + }); + expect(accepted).toHaveBeenCalledTimes(1); + expect(accepted).toHaveBeenCalledWith(receipt); + expect(JSON.stringify(submitOp.mock.calls[0][0])).not.toMatch( + /draftId|scopeVersion|originScope|bindingGeneration/, + ); + }); + it("keeps early actual acceptance when later adapter processing throws", async () => { + const { options, submitOp } = createAcceptanceOptions(null); + const accepted = vi.fn(); + options.observer = { onRuntimeAccepted: accepted }; + submitOp.mockImplementation(async (_op, onAccepted) => { + onAccepted?.(response); + throw new Error("post-response publication failed"); + }); + await expect(executeAgentStreamSubmit(options)).resolves.toMatchObject({ + turnId: "turn-response", + sessionId: "session-1", + }); + expect(accepted).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + "AgentRuntime", + "acceptance.postProcessingFailed", + expect.objectContaining({ phase: "accepted_submit" }), + { level: "error" }, + ); + }); + it("settles steer before refresh and preserves acceptance if read model and projection fail", async () => { + const { options } = createAcceptanceOptions("turn-active"); + const order: string[] = []; + options.observer = { + onRuntimeAccepted: vi.fn(() => order.push("accepted")), + }; + options.refreshSessionReadModel = vi.fn(async () => { + order.push("refresh"); + throw new Error("PRIVATE READ ERROR"); + }); + options.setMessages = vi.fn(() => { + throw new Error("PRIVATE UI ERROR"); + }); + await expect(executeAgentStreamSubmit(options)).resolves.toEqual({ + kind: "steer", + sessionId: "session-1", + threadId: "thread-1", + turnId: "turn-active", + clientUserMessageId: "client-1", + }); + expect(order).toEqual(["accepted", "refresh"]); + expect( + logMock.mock.calls + .filter((call) => call[1] === "acceptance.postProcessingFailed") + .map((call) => call[2].phase), + ).toEqual(["steer_read_model", "steer_projection"]); + expect(JSON.stringify(logMock.mock.calls)).not.toContain("PRIVATE"); + }); + it("does not accept mismatched steer or a rejected request", async () => { + const { options, steerTurn } = createAcceptanceOptions("turn-active"); + const accepted = vi.fn(); + options.observer = { onRuntimeAccepted: accepted }; + steerTurn.mockResolvedValue({ turnId: "wrong" }); + await expect(executeAgentStreamSubmit(options)).rejects.toThrow( + "mismatched turnId", + ); + expect(accepted).not.toHaveBeenCalled(); + const error = new Error("request rejected"); + steerTurn.mockRejectedValue(error); + await expect(executeAgentStreamSubmit(options)).rejects.toBe(error); + expect(accepted).not.toHaveBeenCalled(); + }); + it.each([ + undefined, + true, + {}, + { turn: { turnId: "old", status: "accepted" } }, + ])("does not accept legacy/unknown submit return %j", async (unknown) => { + const { options, submitOp } = createAcceptanceOptions(null); + const accepted = vi.fn(); + options.observer = { onRuntimeAccepted: accepted }; + submitOp.mockResolvedValue(unknown); + await expect(executeAgentStreamSubmit(options)).rejects.toThrow(); + expect(accepted).not.toHaveBeenCalled(); + }); + it.each([null, "turn-active"])( + "passes acceptance through real preparation/dispatch/submission chain for %s", + async (activeTurn) => { + const env = createAcceptanceEnv(activeTurn); + const accepted = vi.fn(); + const receipt = await sendAgentStreamMessage({ + content: "A😀@x", + images: [], + options: { observer: { onRuntimeAccepted: accepted } }, + env, + }); + expect(receipt).toMatchObject({ + kind: activeTurn ? "steer" : "start", + sessionId: "session-1", + threadId: "thread-1", + turnId: activeTurn ?? "turn-start", + }); + expect(accepted).toHaveBeenCalledTimes(1); + expect(accepted).toHaveBeenCalledWith(receipt); + }, + ); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.structured.unit.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.structured.unit.test.ts index 835cb6a2c..e0d34f237 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.structured.unit.test.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.structured.unit.test.ts @@ -26,7 +26,9 @@ const submission: ComposerSubmission = { }; function createOptions(activeTurnId: string | null) { - const submitOp = vi.fn().mockResolvedValue(undefined); + const submitOp = vi + .fn() + .mockResolvedValue({ turn: { id: "turn-start", status: "inProgress" } }); const steerTurn = vi.fn().mockResolvedValue({ turnId: activeTurnId }); const options: Parameters[0] = { runtime: { diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts index 743386b3a..77e7e6118 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.test.ts @@ -118,7 +118,9 @@ describe("agentStreamSubmitExecution", () => { }); it("provider/model 不完整时应在创建会话和 turn/start 前直接失败", async () => { - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-should-not-create"); const runtime = { listenToTurnEvents: vi.fn(async () => vi.fn()), @@ -198,7 +200,9 @@ describe("agentStreamSubmitExecution", () => { reasoning_effort: "medium", }); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-1"); const registerListener = vi.fn(); const activateStream = vi.fn(); @@ -303,9 +307,9 @@ describe("agentStreamSubmitExecution", () => { permissions: ":read-only", }), }), + expect.any(Function), ); - const submittedOp = submitOp.mock - .calls[0]?.[0] as unknown as AgentUserInputOp; + const submittedOp = submitOp.mock.calls[0]?.[0] as AgentUserInputOp; for (const field of [ "sessionId", "workspaceId", @@ -341,7 +345,9 @@ describe("agentStreamSubmitExecution", () => { it("canonical thread 存在 active turn 时应提交 typed turn/steer", async () => { getModelRegistryMock.mockResolvedValueOnce([]); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const refreshSessionReadModel = vi.fn(async () => true); const disposeListener = vi.fn(); const activateStream = vi.fn(); @@ -439,7 +445,9 @@ describe("agentStreamSubmitExecution", () => { it("存在 targetSessionId 时应把 submit 绑定到指定会话", async () => { const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-materialized"); const threadIdBySessionId = new Map(); const getThreadIdForSubmit = vi.fn((targetSessionId?: string) => @@ -555,6 +563,7 @@ describe("agentStreamSubmitExecution", () => { permissions: ":read-only", }), }), + expect.any(Function), ); expect(activateStream).toHaveBeenCalledWith( "session-materialized", @@ -562,9 +571,18 @@ describe("agentStreamSubmitExecution", () => { "thread-materialized", ); const submittedOp = submitOp.mock.calls[0]?.[0] as AgentUserInputOp; - expect( - JSON.parse(submittedOp.turn.additionalContext?.metadata?.value ?? "null"), - ).toEqual({ harness: { content_id: "draft-content" } }); + const metadata = submittedOp.turn.additionalContext?.metadata; + if ( + !metadata || + typeof metadata !== "object" || + !("value" in metadata) || + typeof metadata.value !== "string" + ) { + throw new Error("Expected application metadata string"); + } + expect(JSON.parse(metadata.value)).toEqual({ + harness: { content_id: "draft-content" }, + }); }); it("图片输入不满足 selected model input_modalities 时不应调用 runtime submitOp", async () => { @@ -576,7 +594,9 @@ describe("agentStreamSubmitExecution", () => { ]); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-1"); const registerListener = vi.fn(); const activateStream = vi.fn(); @@ -692,7 +712,9 @@ describe("agentStreamSubmitExecution", () => { ]); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-1"); const registerListener = vi.fn(); const activateStream = vi.fn(); @@ -777,8 +799,7 @@ describe("agentStreamSubmitExecution", () => { expect(submitOp).toHaveBeenCalledTimes(1); expect(getModelRegistryMock).toHaveBeenCalledWith({ forceRefresh: true }); - const submittedOp = submitOp.mock - .calls[0]?.[0] as unknown as AgentUserInputOp; + const submittedOp = submitOp.mock.calls[0]?.[0] as AgentUserInputOp; expect(submittedOp).toMatchObject({ type: "user_input", eventName: "event-image-allowed", @@ -849,7 +870,9 @@ describe("agentStreamSubmitExecution", () => { ]); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const runtime = { listenToTurnEvents: vi.fn(async () => unlisten), getThreadTurnControl: vi.fn(async (threadId: string) => @@ -942,8 +965,7 @@ describe("agentStreamSubmitExecution", () => { }); expect(submitOp).toHaveBeenCalledTimes(1); - const submittedOp = submitOp.mock - .calls[0]?.[0] as unknown as AgentUserInputOp; + const submittedOp = submitOp.mock.calls[0]?.[0] as AgentUserInputOp; expect(submittedOp).toMatchObject({ type: "user_input", eventName: "event-target-model", @@ -967,7 +989,9 @@ describe("agentStreamSubmitExecution", () => { it("已接受用户 Turn 后 ThreadGoal 写入失败不应伪装成提交失败", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const unlisten = vi.fn(); - const submitOp = vi.fn(async () => {}); + const submitOp = vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })); const ensureSession = vi.fn(async () => "session-1"); const registerListener = vi.fn(); const activateStream = vi.fn(); @@ -1061,6 +1085,7 @@ describe("agentStreamSubmitExecution", () => { permissions: ":read-only", }), }), + expect.any(Function), ); expect(activateStream).toHaveBeenCalled(); }); diff --git a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts index 26cda144e..a50a41544 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitExecution.ts @@ -49,6 +49,14 @@ import { resolveAllowedPermissionProfile } from "@/lib/api/permissionProfiles"; import { permissionProfileIdFromAccessMode } from "../utils/accessModeRuntime"; import type { ComposerSubmission } from "@/components/input-kit/types"; +import { + createTurnStartAcceptance, + createTurnSteerAcceptance, + reportRuntimeAcceptancePostProcessingFailure, + runRuntimeAcceptanceEffect, + type RuntimeAcceptanceReceipt, +} from "@/lib/api/agentRuntime/runtimeAcceptance"; + type MessageParts = NonNullable; interface ExecuteAgentStreamSubmitOptions { @@ -207,7 +215,7 @@ async function setAcceptedThreadGoal( export async function executeAgentStreamSubmit( options: ExecuteAgentStreamSubmitOptions, -) { +): Promise { const { runtime, ensureSession, @@ -312,6 +320,13 @@ export async function executeAgentStreamSubmit( executionRuntime?.session_id === resolvedActiveSessionId ? executionRuntime : null; + const acceptanceContext = { + sessionId: resolvedActiveSessionId, + threadId: resolvedThreadId, + ...(clientUserMessageId?.trim() + ? { clientUserMessageId: clientUserMessageId.trim() } + : {}), + }; const turnControl = await runtime.getThreadTurnControl(resolvedThreadId); const expectedTurnId = turnControl.activeTurnId?.trim(); if (expectedTurnId) { @@ -345,25 +360,34 @@ export async function executeAgentStreamSubmit( callbacks.clearOptimisticItem(); callbacks.clearOptimisticTurn(); const response = await runtime.steerTurn(params); - if (response.turnId !== expectedTurnId) { - throw new Error( - `turn/steer returned mismatched turnId: expected ${expectedTurnId}, received ${response.turnId || ""}`, - ); - } - await refreshSessionReadModel(resolvedActiveSessionId); - setMessages((previous) => - previous.flatMap((message) => { - if (message.id === assistantMsgId) { - return []; - } - return [ - message.id === clientUserMessageId - ? { ...message, runtimeTurnId: expectedTurnId } - : message, - ]; - }), + const receipt = createTurnSteerAcceptance( + acceptanceContext, + expectedTurnId, + response, ); - return; + runRuntimeAcceptanceEffect(receipt, "observer", () => + observer?.onRuntimeAccepted?.(receipt), + ); + try { + await refreshSessionReadModel(resolvedActiveSessionId); + } catch { + reportRuntimeAcceptancePostProcessingFailure("steer_read_model", receipt); + } + runRuntimeAcceptanceEffect(receipt, "steer_projection", () => + setMessages((previous) => + previous.flatMap((message) => { + if (message.id === assistantMsgId) { + return []; + } + return [ + message.id === clientUserMessageId + ? { ...message, runtimeTurnId: expectedTurnId } + : message, + ]; + }), + ), + ); + return receipt; } callbacks.activateStream( resolvedActiveSessionId, @@ -446,16 +470,17 @@ export async function executeAgentStreamSubmit( callbacks.registerListener(unlisten); - await runAgentStreamSubmitLifecycle({ + const receipt = await runAgentStreamSubmitLifecycle({ activeSessionId: resolvedActiveSessionId, effectiveModel, effectiveProviderType, eventName, + onRuntimeAccepted: observer?.onRuntimeAccepted, onSubmitAccepted: () => { requestState.startTerminalRecoveryPoll?.(); }, requestState, - submit: async () => { + submit: async (accept) => { const { modelCapabilitySummary, modelRequestPolicyMetadata } = await resolveSubmitModelPolicy({ images, @@ -513,8 +538,14 @@ export async function executeAgentStreamSubmit( modelCapabilitySummary, }); - await runtime.submitOp(submitOp); - await setAcceptedThreadGoal(resolvedThreadId, threadGoal); + let accepted: RuntimeAcceptanceReceipt | undefined; + const response = await runtime.submitOp(submitOp, (response) => { + accepted = createTurnStartAcceptance(acceptanceContext, response); + accept(accepted); + }); + return accepted ?? createTurnStartAcceptance(acceptanceContext, response); }, }); + await setAcceptedThreadGoal(resolvedThreadId, threadGoal); + return receipt; } diff --git a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts index cafffdcab..8de9619cb 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.test.ts @@ -32,7 +32,12 @@ function createNow(values: number[]) { describe("agentStreamSubmitLifecycleController", () => { it("应记录 submit dispatched 与 accepted 生命周期", async () => { const requestState = createRequestState(); - const submit = vi.fn(async () => {}); + const submit = vi.fn(async () => ({ + kind: "start" as const, + sessionId: "session-a", + threadId: "thread-a", + turnId: "turn-a", + })); const recordMetric = vi.fn(); const logDebug = vi.fn(); @@ -143,3 +148,100 @@ describe("agentStreamSubmitLifecycleController", () => { ); }); }); + +describe("explicit acceptance survives post-processing", () => { + const receipt = { + kind: "start" as const, + sessionId: "session-a", + threadId: "thread-a", + turnId: "turn-a", + }; + function options() { + return { + activeSessionId: "session-a", + effectiveModel: "model", + effectiveProviderType: "provider", + eventName: "event-a", + requestState: createRequestState(), + deps: { recordMetric: vi.fn(), logDebug: vi.fn() }, + }; + } + it("notifies before pending submit completion and settles callback/return once", async () => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const onRuntimeAccepted = vi.fn(); + const promise = runAgentStreamSubmitLifecycle({ + ...options(), + onRuntimeAccepted, + submit: async (accept) => { + accept(receipt); + await pending; + return receipt; + }, + }); + expect(onRuntimeAccepted).toHaveBeenCalledTimes(1); + release(); + await expect(promise).resolves.toEqual(receipt); + expect(onRuntimeAccepted).toHaveBeenCalledTimes(1); + }); + it("keeps receipt and terminal recovery when accepted metric and observer throw", async () => { + const base = options(); + const onRuntimeAccepted = vi.fn(() => { + throw new Error("private callback body"); + }); + const onSubmitAccepted = vi.fn(); + base.deps.recordMetric.mockImplementation((phase) => { + if (phase === "agentStream.submitAccepted") + throw new Error("private metric body"); + }); + await expect( + runAgentStreamSubmitLifecycle({ + ...base, + onRuntimeAccepted, + onSubmitAccepted, + submit: async () => receipt, + }), + ).resolves.toEqual(receipt); + expect(onRuntimeAccepted).toHaveBeenCalledTimes(1); + expect(onSubmitAccepted).toHaveBeenCalledTimes(1); + expect( + base.deps.recordMetric.mock.calls.map((call) => call[0]), + ).not.toContain("agentStream.submitFailed"); + }); + it("preserves genuine rejection if its diagnostic recorder throws", async () => { + const base = options(); + const error = new Error("original runtime rejected"); + const onRuntimeAccepted = vi.fn(); + base.deps.recordMetric.mockImplementation((phase) => { + if (phase === "agentStream.submitFailed") + throw new Error("diagnostic error"); + }); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + await expect( + runAgentStreamSubmitLifecycle({ + ...base, + onRuntimeAccepted, + submit: async () => { + throw error; + }, + }), + ).rejects.toBe(error); + expect(onRuntimeAccepted).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + it("rejects a void result without inventing acceptance", async () => { + const onRuntimeAccepted = vi.fn(); + await expect( + runAgentStreamSubmitLifecycle({ + ...options(), + onRuntimeAccepted, + submit: (async () => undefined) as never, + }), + ).rejects.toThrow("Invalid runtime acceptance receipt"); + expect(onRuntimeAccepted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts index f57bf9f14..331494217 100644 --- a/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts +++ b/src/components/agent/chat/hooks/agentStreamSubmitLifecycleController.ts @@ -1,4 +1,10 @@ import { logAgentDebug } from "@/lib/agentDebug"; +import { + requireRuntimeAcceptanceReceipt, + reportRuntimeAcceptancePostProcessingFailure, + runRuntimeAcceptanceEffect, + type RuntimeAcceptanceReceipt, +} from "@/lib/api/agentRuntime/runtimeAcceptance"; import type { StreamRequestState } from "./agentStreamRuntimeHandlerTypes"; import { recordAgentStreamPerformanceMetric, @@ -16,7 +22,6 @@ type AgentStreamSubmitMetricRecorder = ( trace: AgentUiPerformanceTraceMetadata | null | undefined, context: Record, ) => unknown; - type AgentStreamSubmitDebugLogger = typeof logAgentDebug; export interface AgentStreamSubmitLifecycleDeps { @@ -31,20 +36,24 @@ export interface RunAgentStreamSubmitLifecycleOptions { effectiveProviderType: string; eventName: string; onSubmitAccepted?: () => void; + onRuntimeAccepted?: (receipt: RuntimeAcceptanceReceipt) => void; requestState: StreamRequestState; - submit: () => Promise; + submit: ( + accept: (receipt: RuntimeAcceptanceReceipt) => void, + ) => Promise; deps?: AgentStreamSubmitLifecycleDeps; } export async function runAgentStreamSubmitLifecycle( options: RunAgentStreamSubmitLifecycleOptions, -): Promise { +): Promise { const { activeSessionId, effectiveModel, effectiveProviderType, eventName, onSubmitAccepted, + onRuntimeAccepted, requestState, submit, deps, @@ -52,7 +61,6 @@ export async function runAgentStreamSubmitLifecycle( const now = deps?.now ?? Date.now; const recordMetric = deps?.recordMetric ?? recordAgentStreamPerformanceMetric; const logDebug = deps?.logDebug ?? logAgentDebug; - requestState.submissionDispatchedAt = now(); const submitDispatchedContext = buildAgentStreamSubmitDispatchedContext({ activeSessionId, @@ -72,14 +80,79 @@ export async function runAgentStreamSubmitLifecycle( ); logDebug("AgentStream", "submitDispatched", submitDispatchedContext); + let accepted: RuntimeAcceptanceReceipt | undefined; + const accept = (receipt: RuntimeAcceptanceReceipt) => { + const validated = requireRuntimeAcceptanceReceipt(receipt); + if (accepted) { + if ( + accepted.kind !== validated.kind || + accepted.sessionId !== validated.sessionId || + accepted.threadId !== validated.threadId || + accepted.turnId !== validated.turnId || + accepted.clientUserMessageId !== validated.clientUserMessageId + ) { + throw new Error( + "Runtime acceptance identity changed within one request", + ); + } + return; + } + accepted = validated; + runRuntimeAcceptanceEffect(validated, "observer", () => + onRuntimeAccepted?.(validated), + ); + runRuntimeAcceptanceEffect(validated, "accepted_time", () => { + requestState.submissionAcceptedAt = now(); + }); + }; try { - await submit(); - requestState.submissionAcceptedAt = now(); - const submitAcceptedContext = buildAgentStreamSubmitAcceptedContext({ + accept(await submit(accept)); + } catch (error) { + if (accepted) { + reportRuntimeAcceptancePostProcessingFailure("accepted_submit", accepted); + } else { + try { + const timing = { + now: now(), + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }; + recordMetric( + "agentStream.submitFailed", + requestState.performanceTrace, + buildAgentStreamSubmitFailedContext({ + activeSessionId, + error, + eventName, + timing, + }), + ); + logDebug( + "AgentStream", + "submitFailed", + buildAgentStreamSubmitFailedLogContext({ + activeSessionId, + error, + eventName, + timing, + }), + { level: "error" }, + ); + } catch { + console.error("[AgentStream] submission rejection diagnostic failed"); + } + throw error; + } + } + if (!accepted) + throw new Error("Runtime submission did not return acceptance"); + const receipt = accepted; + runRuntimeAcceptanceEffect(receipt, "accepted_metric", () => { + const context = buildAgentStreamSubmitAcceptedContext({ activeSessionId, eventName, timing: { - now: requestState.submissionAcceptedAt, + now: requestState.submissionAcceptedAt ?? Date.now(), requestStartedAt: requestState.requestStartedAt, submissionDispatchedAt: requestState.submissionDispatchedAt, }, @@ -87,37 +160,23 @@ export async function runAgentStreamSubmitLifecycle( recordMetric( "agentStream.submitAccepted", requestState.performanceTrace, - submitAcceptedContext, + context, ); - logDebug("AgentStream", "submitAccepted", submitAcceptedContext); - onSubmitAccepted?.(); - } catch (error) { - const failedTiming = { - now: now(), - requestStartedAt: requestState.requestStartedAt, - submissionDispatchedAt: requestState.submissionDispatchedAt, - }; - recordMetric( - "agentStream.submitFailed", - requestState.performanceTrace, - buildAgentStreamSubmitFailedContext({ - activeSessionId, - error, - eventName, - timing: failedTiming, - }), - ); - logDebug( - "AgentStream", - "submitFailed", - buildAgentStreamSubmitFailedLogContext({ - activeSessionId, - error, - eventName, - timing: failedTiming, - }), - { level: "error" }, - ); - throw error; - } + }); + runRuntimeAcceptanceEffect(receipt, "accepted_log", () => { + const context = buildAgentStreamSubmitAcceptedContext({ + activeSessionId, + eventName, + timing: { + now: requestState.submissionAcceptedAt ?? Date.now(), + requestStartedAt: requestState.requestStartedAt, + submissionDispatchedAt: requestState.submissionDispatchedAt, + }, + }); + logDebug("AgentStream", "submitAccepted", context); + }); + runRuntimeAcceptanceEffect(receipt, "terminal_recovery_poll", () => + onSubmitAccepted?.(), + ); + return receipt; } diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts index 5dee3416d..e51a5cb52 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSendPreparation.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { AgentExecutionStrategy } from "@/lib/api/agentExecutionRuntime"; import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; import type { ModeKind } from "@limecloud/app-server-client"; @@ -83,6 +84,7 @@ export interface PreparedAgentStreamUserInputSend { userMsgId: string | null; userMsg: Message | null; assistantMsg: Message; + composerRestoreToken?: ComposerSubmissionRestoreToken; submittedDraft?: InterruptedInputDraftSnapshot | null; inputMentions?: AgentInputMention[]; composerSubmission?: ComposerSubmission; @@ -440,6 +442,9 @@ export function prepareAgentStreamUserInputSend( userMsg, assistantMsg, submittedDraft, + ...(sendOptions?.composerRestoreToken + ? { composerRestoreToken: sendOptions.composerRestoreToken } + : {}), inputMentions, composerSubmission, }; diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.structured.unit.test.ts b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.structured.unit.test.ts index 632a41fe3..a04583a82 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.structured.unit.test.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.structured.unit.test.ts @@ -30,14 +30,12 @@ describe("structured submission acceptance", () => { const env: AgentStreamPreparedSendEnv = { runtime: { listenToTurnEvents: vi.fn().mockResolvedValue(vi.fn()), - getThreadTurnControl: vi - .fn() - .mockResolvedValue({ - activeTurnId, - queuedTurnIds: [], - threadId: "thread-1", - updatedAtMs: 0, - }), + getThreadTurnControl: vi.fn().mockResolvedValue({ + activeTurnId, + queuedTurnIds: [], + threadId: "thread-1", + updatedAtMs: 0, + }), submitOp, steerTurn, } as unknown as AgentRuntimeAdapter, diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts index 38504cdfa..1f2036e10 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.test.ts @@ -67,7 +67,9 @@ describe("agentStreamUserInputSubmission", () => { activeTurnId: null, queuedTurnIds: [], })), - submitOp: vi.fn(async () => {}), + submitOp: vi.fn(async () => ({ + turn: { id: "turn-start", status: "inProgress" as const }, + })), } as unknown as AgentRuntimeAdapter; const env: AgentStreamPreparedSendEnv = { runtime, @@ -162,6 +164,7 @@ describe("agentStreamUserInputSubmission", () => { permissions: ":workspace", }), }), + expect.any(Function), ); expect(activeStreamRefState.current?.sessionId).toBe("session-1"); expect(threadTurns).toHaveLength(1); diff --git a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts index 404b86a14..23771fe33 100644 --- a/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts +++ b/src/components/agent/chat/hooks/agentStreamUserInputSubmission.ts @@ -36,6 +36,7 @@ export async function submitAgentStreamUserInput( assistantDraft, targetSessionId, submittedDraft, + composerRestoreToken, inputMentions, composerSubmission, skipSessionRestore, @@ -51,6 +52,7 @@ export async function submitAgentStreamUserInput( userMsg, content, submittedDraft, + composerRestoreToken, listenerMapRef: env.listenerMapRef, setActiveStream: env.setActiveStream, setMessages: env.setMessages, @@ -78,7 +80,7 @@ export async function submitAgentStreamUserInput( } = lifecycle; try { - await executeAgentStreamSubmit({ + return await executeAgentStreamSubmit({ runtime: env.runtime, ensureSession: env.ensureSession, attemptSilentTurnRecovery: env.attemptSilentTurnRecovery, diff --git a/src/components/agent/chat/hooks/handleSendTypes.ts b/src/components/agent/chat/hooks/handleSendTypes.ts index f276a7234..0db8742f8 100644 --- a/src/components/agent/chat/hooks/handleSendTypes.ts +++ b/src/components/agent/chat/hooks/handleSendTypes.ts @@ -1,4 +1,6 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import type { + SendMessageObserver, AgentInputMention, AssistantDraftState, SlashSkillRequest, @@ -15,10 +17,7 @@ import type { ComposerSubmitTarget, } from "@/components/input-kit"; -export interface HandleSendObserver { - onComplete?: (content: string) => void; - onError?: (message: string) => void; -} +export type HandleSendObserver = SendMessageObserver; export interface HandleSendOptions { skipThemeSkillPrefix?: boolean; @@ -47,6 +46,7 @@ export interface HandleSendOptions { skipSessionStartHooks?: boolean; composerIntent?: ComposerIntent; composerTarget?: ComposerSubmitTarget; + composerRestoreToken?: ComposerSubmissionRestoreToken; composerDraft?: ComposerDraftSnapshot; composerSubmission?: ComposerSubmission; } diff --git a/src/components/agent/chat/hooks/useAgentChat.test/preferencesStorage.case.tsx b/src/components/agent/chat/hooks/useAgentChat.test/preferencesStorage.case.tsx index be897df01..c4cceb222 100644 --- a/src/components/agent/chat/hooks/useAgentChat.test/preferencesStorage.case.tsx +++ b/src/components/agent/chat/hooks/useAgentChat.test/preferencesStorage.case.tsx @@ -382,6 +382,7 @@ describe("useAgentChat 偏好持久化 - storage cleanup", () => { input: [{ type: "text", text: "继续处理这个任务" }], threadId: freshSessionId, }), + expect.any(Function), ); expect(harness.getValue().sessionId).toBe(freshSessionId); expect( diff --git a/src/components/agent/chat/hooks/useAgentChat.test/runtimeRouting.case.tsx b/src/components/agent/chat/hooks/useAgentChat.test/runtimeRouting.case.tsx index 741fa458b..aa1dfdd95 100644 --- a/src/components/agent/chat/hooks/useAgentChat.test/runtimeRouting.case.tsx +++ b/src/components/agent/chat/hooks/useAgentChat.test/runtimeRouting.case.tsx @@ -48,6 +48,7 @@ describe("useAgentChat runtime routing", () => { }, ], }), + expect.any(Function), ); expect(mockSubmitAgentRuntimeTurn.mock.calls[0]?.[0]).not.toHaveProperty( "searchMode", diff --git a/src/components/agent/chat/hooks/useAgentChat.test/slashSkillExecution.case.tsx b/src/components/agent/chat/hooks/useAgentChat.test/slashSkillExecution.case.tsx index aee7a69a3..8bfed194a 100644 --- a/src/components/agent/chat/hooks/useAgentChat.test/slashSkillExecution.case.tsx +++ b/src/components/agent/chat/hooks/useAgentChat.test/slashSkillExecution.case.tsx @@ -107,6 +107,7 @@ describe("useAgentChat slash skill 执行链路", () => { input: [{ type: "text", text: "你好" }], threadId: "session-detached-chat", }), + expect.any(Function), ); expect(mockToast.error).not.toHaveBeenCalledWith( expect.stringContaining("项目"), @@ -482,6 +483,7 @@ describe("useAgentChat slash skill 执行链路", () => { input: [{ type: "text", text: "继续发送,必须留在新会话" }], threadId: createdSessionId, }), + expect.any(Function), ); } finally { harness.unmount(); @@ -543,14 +545,13 @@ describe("useAgentChat slash skill 执行链路", () => { expect(harness.getValue().sessionId).toBeNull(); await act(async () => { - const newSessionId = await harness.getValue().createFreshSession( - "新对话", - { + const newSessionId = await harness + .getValue() + .createFreshSession("新对话", { activateSession: false, preserveCurrentSnapshot: false, skipSessionStartHooks: true, - }, - ); + }); expect(newSessionId).toBe(createdSessionId); }); @@ -562,7 +563,9 @@ describe("useAgentChat slash skill 执行链路", () => { createdSessionId, ); expect( - harness.getValue().topics.some((topic) => topic.id === createdSessionId), + harness + .getValue() + .topics.some((topic) => topic.id === createdSessionId), ).toBe(true); expect( sessionStorage.getItem(`agent_curr_sessionId_${workspaceId}`), diff --git a/src/components/agent/chat/hooks/useAgentChat.testUtils.tsx b/src/components/agent/chat/hooks/useAgentChat.testUtils.tsx index bd35f44d1..6c25adb44 100644 --- a/src/components/agent/chat/hooks/useAgentChat.testUtils.tsx +++ b/src/components/agent/chat/hooks/useAgentChat.testUtils.tsx @@ -1,7 +1,11 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, vi } from "vitest"; -import type { TurnStartParams } from "@limecloud/app-server-client"; +import type { + AppServerRequestResult, + TurnStartParams, + TurnStartResponse, +} from "@limecloud/app-server-client"; import { getDefaultPendingInteractionController } from "@/lib/api/agentRuntime/pendingInteractionController"; import type { WriteArtifactContext } from "../types"; import type { ChatToolPreferences } from "../utils/chatToolPreferences"; @@ -74,8 +78,9 @@ const { if (command !== "app_server_handle_json_lines") { return undefined; } - const request = (payload as { request?: { lines?: string[] } } | undefined) - ?.request; + const request = ( + payload as { request?: { lines?: string[] } } | undefined + )?.request; const line = request?.lines?.[0]; const message = line ? (JSON.parse(line) as { id?: number }) : {}; return { @@ -572,7 +577,27 @@ beforeEach(async () => { mockGetRuntimeProviderSelection.mockResolvedValue({ provider_configured: false, }); - mockSubmitAgentRuntimeTurn.mockResolvedValue(undefined); + mockSubmitAgentRuntimeTurn.mockImplementation( + async ( + _request: TurnStartParams, + onAccepted?: ( + response: AppServerRequestResult, + ) => void, + ) => { + const result: TurnStartResponse = { + turn: { id: "turn-start", status: "inProgress" }, + }; + const response: AppServerRequestResult = { + id: 1, + result, + response: { id: 1, result }, + notifications: [], + messages: [], + }; + onAccepted?.(response); + return response; + }, + ); mockCreateAgentRuntimeSession.mockResolvedValue("created-session"); mockListAgentRuntimeSessions.mockResolvedValue([]); mockGetAgentRuntimeSession.mockImplementation(async (sessionId: string) => ({ @@ -605,7 +630,10 @@ beforeEach(async () => { })); mockGenerateAgentRuntimeSessionTitle.mockResolvedValue(""); mockSetAgentRuntimeThreadName.mockResolvedValue(undefined); - mockUpdateAgentRuntimeThreadSettings.mockResolvedValue({ result: {}, notifications: [] }); + mockUpdateAgentRuntimeThreadSettings.mockResolvedValue({ + result: {}, + notifications: [], + }); mockDeleteAgentRuntimeSession.mockResolvedValue(undefined); mockCompactAgentRuntimeSession.mockResolvedValue(undefined); mockInterruptAgentRuntimeTurn.mockResolvedValue(undefined); diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 4ee76fe59..389241f7a 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -552,7 +552,7 @@ export function useAgentChat(options: UseAgentChatRuntimeOptions) { runUserShellCommand, onOpenSubagents, }); - await send(...args); + return await send(...args); }, [ appendLocalAssistantMessage, diff --git a/src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx b/src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx index eb6d2fe51..d6f65f473 100644 --- a/src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx +++ b/src/components/agent/chat/hooks/useAgentRuntimeSyncEffects.test.tsx @@ -716,20 +716,16 @@ describe("useAgentRuntimeSyncEffects", () => { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, response: { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, }, @@ -763,6 +759,8 @@ describe("useAgentRuntimeSyncEffects", () => { }; const harness = await mountHook({ runtime, + sessionId: "thread-1", + sessionIdRef: { current: "thread-1" }, currentTurnEventName: eventName, isSending: true, refreshSessionDetail, @@ -812,7 +810,7 @@ describe("useAgentRuntimeSyncEffects", () => { expect(refreshSessionDetail).toHaveBeenCalledTimes(1); expect(refreshSessionDetail).toHaveBeenCalledWith( - "session-1", + "thread-1", terminalRefreshRequest("runtimeSync.event"), ); expect(refreshSessionReadModel).not.toHaveBeenCalled(); diff --git a/src/components/agent/chat/hooks/useAgentStream.restoreToken.test.tsx b/src/components/agent/chat/hooks/useAgentStream.restoreToken.test.tsx new file mode 100644 index 000000000..d2a66c104 --- /dev/null +++ b/src/components/agent/chat/hooks/useAgentStream.restoreToken.test.tsx @@ -0,0 +1,149 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; +import { createRestoreTokenFixture } from "./agentStreamRestoreToken.testFixtures"; +import { useAgentStream } from "./useAgentStream"; +import { activityLogger } from "@/lib/workspace/workbenchRuntime"; +import { clearAllAgentStreamTextOverlays } from "./agentStreamTextOverlayStore"; + +vi.mock("@/lib/api/permissionProfiles", () => ({ + resolveAllowedPermissionProfile: vi.fn().mockResolvedValue({ allowed: true }), +})); +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: vi.fn().mockResolvedValue({ models: [] }), + }, +})); +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); +afterEach(() => { + activityLogger.clear(); + clearAllAgentStreamTextOverlays(); + vi.restoreAllMocks(); +}); + +function mountHook() { + const f = createRestoreTokenFixture(); + const options = { + ...f.env, + waitForSessionProviderSelectionSync: + f.env.waitForSessionProviderSelectionSync!, + setTodoItems: f.env.setTodoItems!, + currentAssistantMsgIdRef: { current: null }, + currentStreamingSessionIdRef: { current: null }, + currentStreamingEventNameRef: { current: null }, + getMessages: () => f.state.messages, + executionRuntime: null, + threadBusy: false, + threadTurns: [], + onRestoreInterruptedInput: f.onRestoreInterruptedInput, + }; + let current: ReturnType | undefined; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + function Harness() { + current = useAgentStream(options); + return null; + } + act(() => root.render()); + return { + ...f, + getHook: () => current!, + unmount: () => { + act(() => { + current?.detachStreamBindings(); + root.unmount(); + }); + container.remove(); + }, + }; +} +function token(): ComposerSubmissionRestoreToken { + return Object.freeze({ kind: "composer-submission" }); +} + +describe("useAgentStream restore capability lifetime", () => { + it.each([false, true])( + "preserves token identity with active stream detached=%s", + async (detached) => { + const h = mountHook(); + const original = token(); + try { + await act(async () => { + await h + .getHook() + .sendMessage( + "原输入", + [], + false, + false, + false, + undefined, + undefined, + undefined, + { composerRestoreToken: original }, + ); + }); + if (detached) act(() => h.getHook().detachStreamBindings()); + await act(async () => { + await h.getHook().stopSending(); + }); + const request = h.onRestoreInterruptedInput.mock.calls[0][0]; + expect(request.composerRestoreToken).toBe(original); + expect(request.draft.text).toBe("原输入"); + expect(request.draft).not.toHaveProperty("composerRestoreToken"); + const count = h.onRestoreInterruptedInput.mock.calls.length; + await act(async () => { + await h.getHook().stopSending(); + }); + expect(h.onRestoreInterruptedInput).toHaveBeenCalledTimes(count); + } finally { + h.unmount(); + } + }, + ); + + it("replaces the fallback token with the next send and clears it for a token-free send", async () => { + const h = mountHook(); + const first = token(); + const second = token(); + try { + for (const [text, restoreToken] of [ + ["first", first], + ["second", second], + ["legacy", undefined], + ] as const) { + await act(async () => { + await h + .getHook() + .sendMessage( + text, + [], + false, + false, + false, + undefined, + undefined, + undefined, + { composerRestoreToken: restoreToken }, + ); + }); + act(() => h.getHook().detachStreamBindings()); + await act(async () => { + await h.getHook().stopSending(); + }); + const request = h.onRestoreInterruptedInput.mock.calls.at(-1)![0]; + expect(request.draft.text).toBe(text); + expect(request.composerRestoreToken).toBe(restoreToken); + } + expect(h.onRestoreInterruptedInput).toHaveBeenCalledTimes(3); + } finally { + h.unmount(); + } + }); +}); diff --git a/src/components/agent/chat/hooks/useAgentStream.ts b/src/components/agent/chat/hooks/useAgentStream.ts index c197cba32..27fae5729 100644 --- a/src/components/agent/chat/hooks/useAgentStream.ts +++ b/src/components/agent/chat/hooks/useAgentStream.ts @@ -1,3 +1,4 @@ +import type { ComposerSubmissionRestoreToken } from "../components/composerSubmissionSettlement"; import { useCallback, useEffect, @@ -231,6 +232,8 @@ export function useAgentStream(options: UseAgentStreamOptions) { const preparedSubmitGateRef = useRef(new AgentStreamSubmitGate()); const submittedDraftFallbackRef = useRef(null); + const composerRestoreTokenFallbackRef = + useRef(null); const recoveredBindingAttemptKeyRef = useRef(null); const getMessagesRef = useRef(getMessages); const getThreadItemsRef = useRef(getThreadItems); @@ -472,7 +475,9 @@ export function useAgentStream(options: UseAgentStreamOptions) { inputCapabilityRoute: options?.capabilityRoute, }), ); - await sendAgentStreamMessage({ + composerRestoreTokenFallbackRef.current = + options?.composerRestoreToken ?? null; + return await sendAgentStreamMessage({ content, images, webSearch, @@ -506,6 +511,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { getThreadItems: () => getThreadItemsRef.current?.() ?? [], setActiveStream, submittedDraftFallback: submittedDraftFallbackRef.current, + composerRestoreTokenFallback: composerRestoreTokenFallbackRef.current, onRestoreInterruptedInput, notify: { info: (message) => toast.info(message), @@ -516,6 +522,7 @@ export function useAgentStream(options: UseAgentStreamOptions) { }, }); submittedDraftFallbackRef.current = null; + composerRestoreTokenFallbackRef.current = null; }, [ refreshSessionReadModel, runtime, diff --git a/src/components/agent/chat/index.serviceSkillA2ui.test.tsx b/src/components/agent/chat/index.serviceSkillA2ui.test.tsx index dc523b4f4..c3b5aa9d4 100644 --- a/src/components/agent/chat/index.serviceSkillA2ui.test.tsx +++ b/src/components/agent/chat/index.serviceSkillA2ui.test.tsx @@ -9,7 +9,7 @@ import { waitForElement, waitForPendingA2UIForm, } from "./index.testFixtures"; -import { requestTaskCenterDraftTask } from "./taskCenterDraftTaskEvents"; +import { commitNewConversationRoute } from "./index.testFixtures"; describe("AgentChatPage 服务技能 A2UI", () => { it("页面参数带着 pending service skill 时,应在当前对话挂起服务技能 A2UI", async () => { @@ -98,7 +98,7 @@ describe("AgentChatPage 服务技能 A2UI", () => { `pending A2UI 调用历史:${JSON.stringify(collectPendingA2UIFormIds())}`, ).toContain("service-skill-launch"); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); + expect(commitNewConversationRoute(mounted, {})).toBe(true); await flushEffects(); mounted.rerender(); await flushEffects(); diff --git a/src/components/agent/chat/index.taskTabs02.test.tsx b/src/components/agent/chat/index.taskTabs02.test.tsx index 82cc72b4b..a328e9d6b 100644 --- a/src/components/agent/chat/index.taskTabs02.test.tsx +++ b/src/components/agent/chat/index.taskTabs02.test.tsx @@ -7,14 +7,12 @@ import { installMockAgentChatUnifiedState, mountPage, } from "./index.testFixtures"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; +import { commitNewConversationRoute } from "./index.testFixtures"; import { TASK_CENTER_OPEN_TAB_IDS_STORAGE_KEY } from "./utils/taskCenterTabs"; -import { requestTaskCenterDraftTask } from "./taskCenterDraftTaskEvents"; describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () => { - it("切到另一条旧会话后仍应继续在当前页新建本地草稿", async () => { + it("切换旧会话后公开 New 请求的 committed route 应回到稳定 Home", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-current", topics: [ @@ -43,9 +41,10 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () }); await flushEffects(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); - await flushEffects(); - mounted.rerender(); + commitNewConversationRoute(mounted, { + projectId: "workspace-test", + freshDraft: true, + }); await flushEffects(); expect( @@ -64,14 +63,13 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () mounted.rerender({ initialSessionId: "topic-next" }); await flushEffects(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); - await flushEffects(); - mounted.rerender(); + commitNewConversationRoute(mounted, { + projectId: "workspace-test", + freshDraft: true, + }); await flushEffects(); expect(state.createFreshSession).not.toHaveBeenCalled(); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); expect( mounted.container.querySelector('[data-testid="empty-state"]'), ).not.toBeNull(); @@ -272,9 +270,8 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () expect(setMessages).not.toHaveBeenCalledWith([]); }); - it("new-task 首页连续新建任务事件应保留在当前页", async () => { + it("new-task 首页连续 committed New 请求不应预建 canonical 会话", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: null, topics: [], @@ -289,14 +286,16 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () }); await flushEffects(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); - await flushEffects(); - mounted.rerender(); + commitNewConversationRoute(mounted, { + projectId: "workspace-test", + freshDraft: true, + }); await flushEffects(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); - await flushEffects(); - mounted.rerender(); + commitNewConversationRoute(mounted, { + projectId: "workspace-test", + freshDraft: true, + }); await flushEffects(); expect( @@ -311,8 +310,6 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () mounted.container.querySelector('[data-testid="empty-state"]'), ).not.toBeNull(); expect(state.createFreshSession).not.toHaveBeenCalled(); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); }); it("new-task 首页收到 current route 历史会话时应立即切换会话", async () => { @@ -377,5 +374,4 @@ describe("AgentChatPage 任务中心初始会话标签", { timeout: 20_000 }, () ).toBeNull(); expect(onNavigate).not.toHaveBeenCalled(); }); - }); diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 978efbd87..264865c79 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -5,6 +5,7 @@ import * as traceHistoryModule from "@/lib/agentUiPerformanceTraceHistory"; import * as agentTaskRuntimeModule from "./utils/agentTaskRuntime"; import { clickButton, + commitNewConversationRoute, createMockAgentChatUnifiedState, FIXED_TOPIC_UPDATED_AT, flushEffects, @@ -16,8 +17,8 @@ import { renderPage, sharedSendMessageMock, } from "./index.testFixtures"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; -import { requestTaskCenterDraftTask } from "./taskCenterDraftTaskEvents"; +import { acceptedSubmission } from "./hooks/agentSendResult.testFixtures"; +import { createEmptyAgentSessionSnapshot } from "./hooks/agentSessionState"; const { mockEmptyState, @@ -172,7 +173,6 @@ describe("AgentChatPage 任务中心顶部工具区", () => { projectId: "workspace-test", }); await flushEffects(); - }); it("默认 Claw 对话不应首帧读取本地 Skills 候选", async () => { @@ -320,9 +320,8 @@ describe("AgentChatPage 任务中心顶部工具区", () => { ).not.toHaveBeenCalled(); }); - it("侧边栏新建任务事件应在任务中心内嵌首页起手页,不恢复顶部标签", async () => { + it("侧边栏新建会话请求应在任务中心内嵌首页起手页,不恢复顶部标签", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-current", topics: [ @@ -352,8 +351,7 @@ describe("AgentChatPage 任务中心顶部工具区", () => { await flushEffects(); expect( - requestTaskCenterDraftTask({ - source: "sidebar", + commitNewConversationRoute(mounted, { projectId: "workspace-test", }), ).toBe(true); @@ -363,8 +361,6 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(createFreshSession).not.toHaveBeenCalled(); expect(clearMessages).toHaveBeenCalledWith({ showToast: false }); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); expect( container.querySelector('[data-testid="task-center-chrome-shell"]'), ).toBeNull(); @@ -394,9 +390,8 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(container.querySelector('[data-testid="message-list"]')).toBeNull(); }); - it("侧边栏新建任务事件应保留在当前页面且不恢复顶部草稿标签", async () => { + it("侧边栏新建会话请求应保留在当前页面且不恢复顶部草稿标签", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-current", topics: [ @@ -439,8 +434,7 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(switchTopic).not.toHaveBeenCalled(); expect( - requestTaskCenterDraftTask({ - source: "sidebar", + commitNewConversationRoute(mounted, { projectId: "workspace-test", }), ).toBe(true); @@ -451,8 +445,6 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(createFreshSession).not.toHaveBeenCalled(); expect(clearMessages).toHaveBeenCalledWith({ showToast: false }); expect(switchTopic).not.toHaveBeenCalled(); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); expect( container.querySelector('[data-testid="task-center-tab-strip"]'), ).toBeNull(); @@ -470,9 +462,8 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(container.querySelector('[data-testid="message-list"]')).toBeNull(); }); - it("历史会话 route 收到无项目新建任务事件时应沿用当前项目打开任务首页", async () => { + it("历史会话 route 提交无项目新建会话后应展示独立空首页", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-project", topics: [ @@ -496,15 +487,11 @@ describe("AgentChatPage 任务中心顶部工具区", () => { const { container } = mounted; await flushEffects(); - expect( - requestTaskCenterDraftTask({ source: "sidebar", projectId: null }), - ).toBe(true); + expect(commitNewConversationRoute(mounted, { projectId: null })).toBe(true); await flushEffects(); mounted.rerender(); await flushEffects(); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); expect(state.clearMessages).toHaveBeenCalledWith({ showToast: false }); expect( container.querySelector( @@ -517,9 +504,8 @@ describe("AgentChatPage 任务中心顶部工具区", () => { expect(container.querySelector('[data-testid="message-list"]')).toBeNull(); }); - it("项目会话收到顶部新建任务事件时应保留当前项目上下文", async () => { + it("项目会话收到顶部新建会话请求时应保留当前项目上下文", async () => { const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-project", topics: [ @@ -534,7 +520,7 @@ describe("AgentChatPage 任务中心顶部工具区", () => { state.clearMessages = vi.fn(); installMockAgentChatUnifiedState(state); - mountPage({ + const mounted = mountPage({ agentEntry: "claw", initialSessionId: "topic-project", projectId: "workspace-test", @@ -543,19 +529,16 @@ describe("AgentChatPage 任务中心顶部工具区", () => { await flushEffects(); expect( - requestTaskCenterDraftTask({ - source: "sidebar", + commitNewConversationRoute(mounted, { projectId: "workspace-test", }), ).toBe(true); await flushEffects(); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); expect(state.clearMessages).toHaveBeenCalledWith({ showToast: false }); }); - it("打开旧会话后草稿预热创建新对话时不应继承旧消息快照", async () => { + it("打开旧会话后在新首页编辑不应继承旧消息或提前创建会话", async () => { mockEmptyState.mockImplementation((props?: MockEmptyStateProps) => (
{ await flushEffects(); mockMessageList.mockClear(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); + expect(commitNewConversationRoute(mounted, {})).toBe(true); await flushEffects(); mounted.rerender(); await flushEffects(); @@ -638,27 +621,19 @@ describe("AgentChatPage 任务中心顶部工具区", () => { clickButton(mounted.container, "mock-empty-type"); await flushEffects(8); - expect(createFreshSession).toHaveBeenCalledWith( - "新对话", - expect.objectContaining({ - preserveCurrentSnapshot: false, - }), + expect(createFreshSession).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + expect(mockEmptyState.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ input: "新的独立对话", sessionId: null }), ); + expect( + mounted.container.querySelector('[data-testid="message-list"]'), + ).toBeNull(); }); - it("隐藏草稿首页输入后应预热创建会话,发送时复用同一次创建", async () => { - const onNavigate = vi.fn(); - vi.mocked(buildHomeAgentParams).mockClear(); + it("首页编辑不创建会话,首次发送只派发一次并由接受回执激活 canonical 会话", async () => { mockEmptyState.mockImplementation((props?: MockEmptyStateProps) => ( -
- {props?.activeTheme === "general" ? ( -
- ) : null} +
)); - - const creationController: { resolve?: () => void } = {}; const state: Record = createMockAgentChatUnifiedState({ sessionId: "topic-current", isAutoRestoringSession: true, @@ -689,11 +662,24 @@ describe("AgentChatPage 任务中心顶部工具区", () => { }, ], }); - const createFreshSession = vi.fn( + const createFreshSession = vi.fn(async () => "unexpected-eager-session"); + state.createFreshSession = createFreshSession; + // Mirror the unified runtime's real clearMessages snapshot boundary. + state.clearMessages = vi.fn(() => { + Object.assign(state, createEmptyAgentSessionSnapshot(), { + isAutoRestoringSession: false, + isSessionHydrating: false, + }); + }); + let accept!: () => void; + // The unified runtime is the test boundary; only a typed acceptance can select its new canonical session. + sharedSendMessageMock.mockImplementation( () => - new Promise((resolve) => { - creationController.resolve = () => { + new Promise((resolve) => { + accept = () => { state.sessionId = "new-topic"; + state.currentTurnId = "pending-turn:test"; + state.isSending = true; state.topics = [ { id: "new-topic", @@ -701,101 +687,66 @@ describe("AgentChatPage 任务中心顶部工具区", () => { updatedAt: new Date(FIXED_TOPIC_UPDATED_AT + 1_000), workspaceId: "workspace-test", }, - ...((state.topics as Array>) || []), ]; - resolve("new-topic"); + resolve(acceptedSubmission("new-topic")); }; }), ); - state.createFreshSession = createFreshSession; - sharedSendMessageMock.mockImplementation(async () => { - state.currentTurnId = "pending-turn:test"; - state.isSending = true; - }); installMockAgentChatUnifiedState(state); - const mounted = mountPage({ agentEntry: "claw", initialSessionId: "topic-current", projectId: "workspace-test", - onNavigate, }); await flushEffects(); - - mockMessageList.mockClear(); - expect(requestTaskCenterDraftTask({ source: "sidebar" })).toBe(true); + commitNewConversationRoute(mounted, { projectId: "workspace-test" }); await flushEffects(); mounted.rerender(); await flushEffects(); - - expect( - mounted.container.querySelector('[data-testid="empty-state"]'), - ).not.toBeNull(); - expect( - mounted.container.querySelector('[data-testid="home-start-surface"]'), - ).not.toBeNull(); - expect( - mounted.container.querySelector('[data-testid="message-list"]'), - ).toBeNull(); - const latestEmptyStateProps = mockEmptyState.mock.calls.at(-1)?.[0] as - | MockEmptyStateProps - | undefined; - expect(latestEmptyStateProps?.activeTheme).toBe("general"); - expect(latestEmptyStateProps?.sessionId).toBeNull(); - const latestDraftMessageListProps = mockMessageList.mock.calls.at( - -1, - )?.[0] as - | { - emptyStateVariant?: string; - messages?: unknown[]; - sessionId?: string | null; - } - | undefined; - if (latestDraftMessageListProps) { - expect(latestDraftMessageListProps.emptyStateVariant).toBe("none"); - expect(latestDraftMessageListProps.messages).toEqual([]); - expect(latestDraftMessageListProps.sessionId).toBeNull(); - } - + expect(state.clearMessages).toHaveBeenCalledExactlyOnceWith({ + showToast: false, + }); + expect(state.sessionId).toBeNull(); + expect(mockEmptyState.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ sessionId: null }), + ); clickButton(mounted.container, "mock-empty-type"); await flushEffects(8); - expect(createFreshSession).toHaveBeenCalledTimes(1); - + expect(createFreshSession).not.toHaveBeenCalled(); + expect(sharedSendMessageMock).not.toHaveBeenCalled(); + expect(mockEmptyState.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ input: "你好", sessionId: null }), + ); clickButton(mounted.container, "mock-empty-send"); await flushEffects(2); - expect(createFreshSession).toHaveBeenCalledTimes(1); - expect(sharedSendMessageMock).not.toHaveBeenCalled(); - expect( - mounted.container.querySelector('[data-testid="empty-state"]'), - ).toBeNull(); - - expect(creationController.resolve).toBeTruthy(); - creationController.resolve?.(); - await flushEffects(10); - mounted.rerender(); - await flushEffects(); - + expect(createFreshSession).not.toHaveBeenCalled(); expect(sharedSendMessageMock).toHaveBeenCalledTimes(1); expect(getSendMessageCall().content).toBe("你好"); - mounted.rerender(); - await flushEffects(); - expect(mockUseSessionFiles).toHaveBeenLastCalledWith( - expect.objectContaining({ - sessionId: "new-topic", - autoInit: false, - }), + expect(getSendMessageCall().options).toEqual( + expect.objectContaining({ skipSessionRestore: true }), ); expect( ( - getSendMessageCall().options?.requestMetadata as - | Record> - | undefined + getSendMessageCall().options?.requestMetadata as Record< + string, + Record + > )?.agentUiPerformanceTrace?.sessionId, - ).toBe("new-topic"); - expect(createFreshSession).toHaveBeenCalledTimes(1); + ).toBeNull(); + expect( + mounted.container.querySelector('[data-testid="empty-state"]'), + ).toBeNull(); + expect(accept).toBeTypeOf("function"); + accept(); + await flushEffects(10); + mounted.rerender(); + await flushEffects(); + expect(sharedSendMessageMock).toHaveBeenCalledTimes(1); + expect(createFreshSession).not.toHaveBeenCalled(); + expect(mockUseSessionFiles).toHaveBeenLastCalledWith( + expect.objectContaining({ sessionId: "new-topic", autoInit: false }), + ); expect(mounted.container.textContent).not.toContain("正在恢复生成会话"); - expect(buildHomeAgentParams).not.toHaveBeenCalled(); - expect(onNavigate).not.toHaveBeenCalled(); }, 60_000); it("从导航栏直达会话时应延后加载 topics,优先恢复目标会话详情", async () => { diff --git a/src/components/agent/chat/index.testFixtures.tsx b/src/components/agent/chat/index.testFixtures.tsx index 40a382daf..1922cf524 100644 --- a/src/components/agent/chat/index.testFixtures.tsx +++ b/src/components/agent/chat/index.testFixtures.tsx @@ -2,6 +2,7 @@ import { act, type ComponentProps, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, beforeEach, vi } from "vitest"; +import { acceptedSubmission } from "./hooks/agentSendResult.testFixtures"; import { agentEnUSResource, agentZhCNResource } from "@/i18n/agentResources"; import type { WorkspaceRightSurfaceLauncherProjection } from "./workspace/right-surface/rightSurfaceToolbarProjection"; import { resetInitialSessionNavigationDeduplicationForTests } from "./workspace/useWorkspaceInitialSessionNavigation"; @@ -1067,6 +1068,28 @@ export function mountPage( return harness; } +let committedNewConversationId = 0; +/** Test-only router boundary: deliver an accepted typed route to the real page consumer. */ +export function commitNewConversationRoute( + mounted: MountedHarness, + options: { projectId?: string | null; freshDraft?: boolean } = {}, +): true { + const id = ++committedNewConversationId; + const workspaceId = options.projectId ?? null; + mounted.rerender({ + agentEntry: "new-task", + initialSessionId: undefined, + projectId: workspaceId ?? undefined, + newChatAt: id, + newConversationRequest: { + id, + workspaceId, + freshDraft: options.freshDraft ?? true, + }, + }); + return true; +} + export function renderPage( props: Partial> = {}, ): HTMLDivElement { @@ -1650,7 +1673,7 @@ beforeEach(async () => { }); sharedSwitchTopicMock = vi.fn(async () => undefined); - sharedSendMessageMock = vi.fn(async () => undefined); + sharedSendMessageMock = vi.fn(async () => acceptedSubmission()); sharedTriggerAIGuideMock = vi.fn(); installMockAgentChatUnifiedState(createMockAgentChatUnifiedState()); }, 60_000); diff --git a/src/components/agent/chat/index.tsx b/src/components/agent/chat/index.tsx index f8ffe349a..15818c347 100644 --- a/src/components/agent/chat/index.tsx +++ b/src/components/agent/chat/index.tsx @@ -12,7 +12,6 @@ export type { export function AgentChatPage(props: AgentChatWorkspaceProps) { const { onHasMessagesChange, - onSessionChange, onAgentStreamingChange, onWorkflowProgressChange, } = props; @@ -37,12 +36,10 @@ export function AgentChatPage(props: AgentChatWorkspaceProps) { } onHasMessagesChange?.(false); - onSessionChange?.(null); onAgentStreamingChange?.(false); onWorkflowProgressChange?.(null); }, [ onHasMessagesChange, - onSessionChange, onAgentStreamingChange, onWorkflowProgressChange, shouldForceClawWorkspace, diff --git a/src/components/agent/chat/index.workbench01.test.tsx b/src/components/agent/chat/index.workbench01.test.tsx index 210314c16..bb4e1f868 100644 --- a/src/components/agent/chat/index.workbench01.test.tsx +++ b/src/components/agent/chat/index.workbench01.test.tsx @@ -1,5 +1,6 @@ import { act } from "react"; import { describe, expect, it } from "vitest"; +import { acceptedSubmission } from "./hooks/agentSendResult.testFixtures"; import { clickButton, createMockAgentChatUnifiedState, @@ -161,7 +162,7 @@ describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => {
)); sharedSendMessageMock.mockImplementationOnce( - () => new Promise(() => undefined), + () => new Promise>(() => undefined), ); installMockAgentChatUnifiedState( createMockAgentChatUnifiedState({ @@ -229,7 +230,7 @@ describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => {
)); - sharedSendMessageMock.mockResolvedValueOnce(undefined); + sharedSendMessageMock.mockResolvedValueOnce(acceptedSubmission()); installMockAgentChatUnifiedState( createMockAgentChatUnifiedState({ sessionId: null, diff --git a/src/components/agent/chat/sessionTypesCurrentBoundary.test.ts b/src/components/agent/chat/sessionTypesCurrentBoundary.test.ts index 7b93a0ce5..f3278fea3 100644 --- a/src/components/agent/chat/sessionTypesCurrentBoundary.test.ts +++ b/src/components/agent/chat/sessionTypesCurrentBoundary.test.ts @@ -24,7 +24,6 @@ const CONSUMER_PATHS = [ "src/components/agent/chat/utils/sessionExecutionRuntime.ts", "src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx", "src/components/agent/chat/workspace/codingWorkbenchRecovery.ts", - "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts", "src/components/agent/chat/workspace/imageCommandIntent.ts", "src/components/agent/chat/workspace/imageWorkbenchTaskActions.ts", "src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts", @@ -58,6 +57,27 @@ describe("session types current owner boundary", () => { } }); + it("知识 Workspace 发送委托共享合同,不重复声明 session DTO", () => { + const source = readFileSync( + join( + cwd(), + "src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts", + ), + "utf8", + ); + + expect(source).toContain( + 'import type { WorkspaceHandleSend } from "../useWorkspaceSendActions"', + ); + expect(source).toContain( + "type WorkspaceKnowledgeHandleSend = WorkspaceHandleSend", + ); + expect(source).not.toContain('"@/lib/api/agentRuntime"'); + expect(source).not.toMatch( + /(?:interface|type)\s+Session(?:Info|Message)\b/, + ); + }); + it("turn binding 从独立类型 owner 读取选项,不恢复重复 DTO 或聚合入口", () => { const source = readFileSync( join( diff --git a/src/components/agent/chat/skill-selection/CharacterMention.testFixtures.tsx b/src/components/agent/chat/skill-selection/CharacterMention.testFixtures.tsx index 14ed031d0..4f21af99a 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.testFixtures.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.testFixtures.tsx @@ -72,7 +72,8 @@ vi.mock("@/lib/api/fuzzyFileSearch", async (importOriginal) => { }; }); -vi.mock("@/components/ui/popover", () => { +vi.mock("@/components/ui/popover", async () => { + const React = await import("react"); const Popover = ({ open, children, @@ -126,7 +127,8 @@ vi.mock("@/components/ui/popover", () => { return { Popover, PopoverTrigger, PopoverContent }; }); -vi.mock("@/components/ui/command", () => { +vi.mock("@/components/ui/command", async () => { + const React = await import("react"); const Command = React.forwardRef< HTMLDivElement, React.HTMLAttributes & { shouldFilter?: boolean } diff --git a/src/components/agent/chat/skill-selection/inputCapabilitySelection.ts b/src/components/agent/chat/skill-selection/inputCapabilitySelection.ts index e68d3ec94..50020098d 100644 --- a/src/components/agent/chat/skill-selection/inputCapabilitySelection.ts +++ b/src/components/agent/chat/skill-selection/inputCapabilitySelection.ts @@ -1,4 +1,5 @@ import type { Skill } from "@/lib/api/skills"; +import type { ComposerMentionBinding } from "@/components/input-kit"; import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types"; import { findCuratedTaskTemplateById, @@ -133,6 +134,20 @@ function matchesInstalledSkillRoute( ); } +export function matchesInstalledSkillMention( + skill: Skill, + binding: ComposerMentionBinding, +): boolean { + return ( + binding.kind === "skill" && + matchesInstalledSkillRoute(skill, { + kind: "installed_skill", + skillKey: binding.id, + skillName: binding.name, + }) + ); +} + export interface ResolvedInputCapabilityDispatch { displayContent?: string; capabilityRoute?: InputCapabilitySendRoute; diff --git a/src/components/agent/chat/skill-selection/skillSelectionBindings.ts b/src/components/agent/chat/skill-selection/skillSelectionBindings.ts index 63f0914d0..b807e96fe 100644 --- a/src/components/agent/chat/skill-selection/skillSelectionBindings.ts +++ b/src/components/agent/chat/skill-selection/skillSelectionBindings.ts @@ -14,6 +14,7 @@ export interface SkillSelectionSourceProps { onNavigateToSettings?: () => void; onImportSkill?: () => void | Promise; onRefreshSkills?: () => void | Promise; + onSkillSuggestionsNeeded?: () => void; } export interface SkillSelectionControllerProps { @@ -67,6 +68,7 @@ export function buildSkillSelectionBindings({ onNavigateToSettings, onImportSkill, onRefreshSkills, + onSkillSuggestionsNeeded, }: SkillSelectionProps) { return { mentionProps: { @@ -74,6 +76,7 @@ export function buildSkillSelectionBindings({ serviceSkills, serviceSkillGroups, onNavigateToSettings, + onSkillSuggestionsNeeded, }, selectorProps: { skills, diff --git a/src/components/agent/chat/taskCenterDraftTaskEvents.ts b/src/components/agent/chat/taskCenterDraftTaskEvents.ts deleted file mode 100644 index 8243b7125..000000000 --- a/src/components/agent/chat/taskCenterDraftTaskEvents.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const TASK_CENTER_CREATE_DRAFT_TASK_EVENT = - "lime:task-center:create-draft-task"; - -export type TaskCenterTaskEventSource = - | "sidebar" - | "sidebar_search" - | "conversation_shelf" - | "tab_strip"; - -export interface TaskCenterCreateDraftTaskDetail { - source?: TaskCenterTaskEventSource; - projectId?: string | null; -} - -export function requestTaskCenterDraftTask( - detail: TaskCenterCreateDraftTaskDetail = {}, -): boolean { - if (typeof window === "undefined") { - return false; - } - - const event = new CustomEvent( - TASK_CENTER_CREATE_DRAFT_TASK_EVENT, - { cancelable: true, detail }, - ); - return !window.dispatchEvent(event); -} - -export function subscribeTaskCenterDraftTaskRequests( - handler: (detail: TaskCenterCreateDraftTaskDetail) => void, -): () => void { - if (typeof window === "undefined") { - return () => undefined; - } - - const listener = (event: Event) => { - const detail = - event instanceof CustomEvent - ? (event.detail as TaskCenterCreateDraftTaskDetail | undefined) - : undefined; - handler(detail ?? {}); - event.preventDefault(); - }; - - window.addEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener); - return () => { - window.removeEventListener(TASK_CENTER_CREATE_DRAFT_TASK_EVENT, listener); - }; -} diff --git a/src/components/agent/chat/utils/agentChatCopy.test.ts b/src/components/agent/chat/utils/agentChatCopy.test.ts index 82227206f..e4d09447b 100644 --- a/src/components/agent/chat/utils/agentChatCopy.test.ts +++ b/src/components/agent/chat/utils/agentChatCopy.test.ts @@ -1,9 +1,14 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as namespaceResources from "@/i18n/loadNamespace"; -import { resolveRequiredAgentChatCopy } from "./agentChatCopy"; +import { + resolveAgentChatCopy, + resolveRequiredAgentChatCopy, +} from "./agentChatCopy"; afterEach(() => { document.documentElement.lang = ""; + vi.restoreAllMocks(); }); describe("agentChatCopy", () => { @@ -34,4 +39,30 @@ describe("agentChatCopy", () => { "agentChat.toolCall.group.__missing__", ); }); + + it("热路径切换语言应复用已加载资源,保留文案与插值", () => { + const key = "toolCall.group.command.completed"; + document.documentElement.lang = "ja-JP"; + const japanese = resolveRequiredAgentChatCopy(key, { count: 2 }); + document.documentElement.lang = "zh-TW"; + const traditional = resolveRequiredAgentChatCopy(key, { count: 2 }); + expect(japanese).not.toBe(traditional); + + const resourceLoad = vi.spyOn(namespaceResources, "loadNamespaceResource"); + for (let index = 0; index < 2; index += 1) { + for (const [locale, expected] of [ + ["ja-JP", japanese], + ["zh-TW", traditional], + ]) { + document.documentElement.lang = locale; + expect(resolveRequiredAgentChatCopy(key, { count: 2 })).toBe(expected); + expect( + resolveAgentChatCopy("__missing__", "fallback {{count}}", { + count: 2, + }), + ).toBe("fallback 2"); + } + } + expect(resourceLoad).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/utils/taskCenterTabs.test.ts b/src/components/agent/chat/utils/taskCenterTabs.test.ts index f907008ad..c67489ea9 100644 --- a/src/components/agent/chat/utils/taskCenterTabs.test.ts +++ b/src/components/agent/chat/utils/taskCenterTabs.test.ts @@ -57,7 +57,6 @@ function createFallbackRestoreParams( isAutoRestoringSession: false, isSessionHydrating: false, draftSurfaceActive: false, - draftTabActive: false, initialPendingServiceSkillLaunchSignature: null, initialDispatchKey: null, isBootstrapDispatchPending: false, @@ -150,7 +149,7 @@ describe("taskCenterTabs", () => { it("standalone 会话应使用 legacy bucket 保存任务标签", () => { const currentMap = { - "__legacy__": ["topic-standalone-a"], + __legacy__: ["topic-standalone-a"], "workspace-a": ["topic-project"], }; @@ -159,13 +158,12 @@ describe("taskCenterTabs", () => { ]); expect( - updateTaskCenterTabIdsForWorkspace( - currentMap, - null, - (currentIds) => ["topic-standalone-b", ...currentIds], - ), + updateTaskCenterTabIdsForWorkspace(currentMap, null, (currentIds) => [ + "topic-standalone-b", + ...currentIds, + ]), ).toEqual({ - "__legacy__": ["topic-standalone-b", "topic-standalone-a"], + __legacy__: ["topic-standalone-b", "topic-standalone-a"], "workspace-a": ["topic-project"], }); }); @@ -232,7 +230,7 @@ describe("taskCenterTabs", () => { normalizedInitialSessionId: "topic-imported", }), ).toEqual({ - "__legacy__": ["topic-imported"], + __legacy__: ["topic-imported"], "workspace-a": ["topic-a"], }); }); @@ -254,7 +252,6 @@ describe("taskCenterTabs", () => { ).toEqual({ shouldSync: true, routeChanged: true, - shouldClearActiveDraft: true, shouldClearTransitionAndDetached: false, nextRouteSyncSessionId: "topic-selected", }); @@ -282,7 +279,6 @@ describe("taskCenterTabs", () => { ).toMatchObject({ shouldSync: true, routeChanged: false, - shouldClearActiveDraft: true, shouldClearTransitionAndDetached: true, }); @@ -301,7 +297,7 @@ describe("taskCenterTabs", () => { it("standalone 路由同步应覆盖 legacy 标签桶", () => { const currentMap = { - "__legacy__": ["topic-old"], + __legacy__: ["topic-old"], "workspace-a": ["topic-project"], }; @@ -316,7 +312,6 @@ describe("taskCenterTabs", () => { ).toMatchObject({ shouldSync: true, routeChanged: true, - shouldClearActiveDraft: true, }); expect( @@ -327,7 +322,7 @@ describe("taskCenterTabs", () => { shouldRespectLocalSession: false, }), ).toEqual({ - "__legacy__": ["topic-imported"], + __legacy__: ["topic-imported"], "workspace-a": ["topic-project"], }); }); @@ -344,7 +339,6 @@ describe("taskCenterTabs", () => { ).toMatchObject({ shouldSync: true, routeChanged: true, - shouldClearActiveDraft: true, }); }); @@ -459,7 +453,6 @@ describe("taskCenterTabs", () => { topicId: "topic-current", activeSessionId: "topic-current", messagesLength: 1, - activeDraftTabId: null, draftSurfaceActive: false, detachedTopicId: null, shouldResume: false, @@ -471,7 +464,6 @@ describe("taskCenterTabs", () => { topicId: "topic-current", activeSessionId: "topic-current", messagesLength: 1, - activeDraftTabId: null, draftSurfaceActive: false, detachedTopicId: null, shouldResume: false, diff --git a/src/components/agent/chat/utils/taskCenterTabs.ts b/src/components/agent/chat/utils/taskCenterTabs.ts index 8281da90a..2892599fe 100644 --- a/src/components/agent/chat/utils/taskCenterTabs.ts +++ b/src/components/agent/chat/utils/taskCenterTabs.ts @@ -16,7 +16,6 @@ export interface TaskCenterLocalSessionOverride { export interface TaskCenterRouteTabSyncIntent { shouldSync: boolean; routeChanged: boolean; - shouldClearActiveDraft: boolean; shouldClearTransitionAndDetached: boolean; nextRouteSyncSessionId: string | null; } @@ -146,7 +145,6 @@ export function shouldSkipTaskCenterActiveTopicReopen(params: { topicId: string; activeSessionId?: string | null; messagesLength: number; - activeDraftTabId?: string | null; draftSurfaceActive: boolean; detachedTopicId?: string | null; shouldResume: boolean; @@ -157,7 +155,6 @@ export function shouldSkipTaskCenterActiveTopicReopen(params: { return ( params.topicId === (params.activeSessionId ?? null) && params.messagesLength > 0 && - (params.activeDraftTabId ?? null) === null && params.draftSurfaceActive === false && (params.detachedTopicId ?? null) === null && params.shouldResume === false && @@ -466,7 +463,6 @@ export function resolveTaskCenterRouteTabSyncIntent(params: { return { shouldSync: false, routeChanged: false, - shouldClearActiveDraft: false, shouldClearTransitionAndDetached: false, nextRouteSyncSessionId: params.lastSyncedInitialSessionId ?? null, }; @@ -478,7 +474,6 @@ export function resolveTaskCenterRouteTabSyncIntent(params: { return { shouldSync: true, routeChanged, - shouldClearActiveDraft: routeChanged || params.shouldRespectLocalSession, shouldClearTransitionAndDetached: params.shouldRespectLocalSession, nextRouteSyncSessionId: initialSessionId, }; @@ -626,7 +621,6 @@ export function resolveTaskCenterFallbackRestorePlan(params: { isAutoRestoringSession: boolean; isSessionHydrating: boolean; draftSurfaceActive: boolean; - draftTabActive: boolean; initialPendingServiceSkillLaunchSignature?: string | null; initialDispatchKey?: string | null; isBootstrapDispatchPending: boolean; @@ -665,10 +659,6 @@ export function resolveTaskCenterFallbackRestorePlan(params: { return { action: "skip", reason: "draft-surface-active" }; } - if (params.draftTabActive) { - return { action: "skip", reason: "draft-tab-active" }; - } - if (params.initialPendingServiceSkillLaunchSignature) { return { action: "skip", reason: "service-skill-launch-pending" }; } diff --git a/src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx b/src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx index a2fdbb3c6..bdea17412 100644 --- a/src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx +++ b/src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useState, type ReactNode, type ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import { Square, Terminal } from "lucide-react"; import type { CodingWorkbenchView } from "@limecloud/agent-runtime-projection"; @@ -7,10 +7,7 @@ import type { ActionRequired, ConfirmResponse } from "../types"; import { CodingWorkbenchActionPanel } from "./CodingWorkbenchActionPanel"; import { CodingWorkbenchDiagnosticPanel } from "./CodingWorkbenchDiagnosticPanel"; import { CodingWorkbenchRecoveryPanel } from "./CodingWorkbenchRecoveryPanel"; -import { - buildCodingWorkbenchRecoveryView, - type CodingWorkbenchRecoveryContext, -} from "./codingWorkbenchRecovery"; +import { buildCodingWorkbenchRecoveryView } from "./codingWorkbenchRecovery"; import { CodingStatusBadge } from "./codingWorkbenchStatus"; import { statusLabelKey } from "./codingWorkbenchStatusModel"; @@ -20,16 +17,13 @@ interface CodingWorkbenchOutputPanelProps { submittedActionsInFlight?: readonly ActionRequired[]; processControls?: CodingWorkbenchCommandProcessControls; onRespondToAction?: (response: ConfirmResponse) => void | Promise; - onSubmitRecoveryPrompt?: ( - prompt: string, - context?: CodingWorkbenchRecoveryContext, - ) => void | Promise | boolean; + onSubmitRecoveryPrompt?: ComponentProps< + typeof CodingWorkbenchRecoveryPanel + >["onSubmitRecoveryPrompt"]; } export interface CodingWorkbenchCommandProcessControls { - onTerminateBackgroundTerminal?: ( - itemId: string, - ) => void | Promise; + onTerminateBackgroundTerminal?: (itemId: string) => void | Promise; } export function CodingWorkbenchOutputPanel({ diff --git a/src/components/agent/chat/workspace/CodingWorkbenchRecoveryPanel.tsx b/src/components/agent/chat/workspace/CodingWorkbenchRecoveryPanel.tsx index 0845703be..f4db0344c 100644 --- a/src/components/agent/chat/workspace/CodingWorkbenchRecoveryPanel.tsx +++ b/src/components/agent/chat/workspace/CodingWorkbenchRecoveryPanel.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, ArrowRight, FileWarning, Loader2 } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import type { CodingWorkbenchRecoveryContext, CodingWorkbenchRecoverySignalKind, @@ -26,7 +27,7 @@ interface CodingWorkbenchRecoveryPanelProps { onSubmitRecoveryPrompt?: ( prompt: string, context?: CodingWorkbenchRecoveryContext, - ) => void | Promise | boolean; + ) => Promise; } export function CodingWorkbenchRecoveryPanel({ diff --git a/src/components/agent/chat/workspace/ComposerDraftStatus.test.tsx b/src/components/agent/chat/workspace/ComposerDraftStatus.test.tsx new file mode 100644 index 000000000..f717473a6 --- /dev/null +++ b/src/components/agent/chat/workspace/ComposerDraftStatus.test.tsx @@ -0,0 +1,90 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import type { ComposerDraftStatus as DraftStatus } from "@/components/input-kit/ComposerDraftCoordinator"; +import { ComposerDraftStatus } from "./ComposerDraftStatus"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; +const mounted: Array<{ root: Root; element: HTMLDivElement }> = []; +afterEach(async () => { + act(() => { + for (const { root, element } of mounted.splice(0)) { + root.unmount(); + element.remove(); + } + }); + await changeLimeLocale("en-US"); +}); +const status: DraftStatus = { + scope: { workspaceId: "w", kind: "draft", id: "d" }, + generation: 1, + ready: true, + identity: { draftId: "d", scopeVersion: "v" }, + remoteIdentity: null, + mutation: "applied", + storeRevision: 7, + persistence: { state: "saved", savedRevision: 7 }, + error: null, +}; + +function mount() { + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + mounted.push({ root, element }); + return { + element, + render: (value: DraftStatus | null, restoring = false) => + act(() => + root.render( + , + ), + ), + }; +} + +describe("draft save feedback", () => { + it.each([ + ["zh-CN", "草稿已保存", "正在恢复草稿…"], + ["zh-TW", "草稿已儲存", "正在還原草稿…"], + ["en-US", "Draft saved", "Restoring draft…"], + ["ja-JP", "下書きを保存しました", "下書きを復元中…"], + ["ko-KR", "초안 저장됨", "초안 복원 중…"], + ] as const)( + "%s uses actual locale resources for saved/restoring states", + async (locale, saved, restoring) => { + await changeLimeLocale(locale); + const view = mount(); + view.render(status); + expect(view.element.querySelector('[role="status"]')?.textContent).toBe( + saved, + ); + view.render(null, true); + expect(view.element.querySelector('[role="status"]')?.textContent).toBe( + restoring, + ); + }, + ); + it("memory application and an old saved revision cannot claim durable saving", () => { + const view = mount(); + view.render({ + ...status, + persistence: { state: "pending", savedRevision: 6 }, + }); + expect(view.element.querySelector('[data-state="saving"]')).not.toBeNull(); + view.render({ + ...status, + persistence: { state: "saved", savedRevision: 6 }, + }); + expect(view.element.querySelector('[data-state="saved"]')).toBeNull(); + view.render({ ...status, mutation: "conflict" }); + expect( + view.element.querySelector('[data-state="conflict"]'), + ).not.toBeNull(); + view.render({ ...status, mutation: "invalid", error: "mode_mismatch" }); + expect(view.element.querySelector('[data-state="error"]')).not.toBeNull(); + }); +}); diff --git a/src/components/agent/chat/workspace/ComposerDraftStatus.tsx b/src/components/agent/chat/workspace/ComposerDraftStatus.tsx new file mode 100644 index 000000000..e1f65a30c --- /dev/null +++ b/src/components/agent/chat/workspace/ComposerDraftStatus.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next"; +import type { ComposerDraftStatus as DraftStatus } from "@/components/input-kit/ComposerDraftCoordinator"; + +export function ComposerDraftStatus({ + status, + restoring, +}: { + status: DraftStatus | null; + restoring: boolean; +}) { + const { t } = useTranslation("agent"); + let state: + | "restoring" + | "conflict" + | "error" + | "unavailable" + | "saving" + | "saved" + | null = null; + if (restoring) state = "restoring"; + else if (status?.mutation === "conflict") state = "conflict"; + else if ( + status?.mutation === "invalid" || + status?.persistence?.state === "error" + ) + state = "error"; + else if ( + status?.mutation === "unavailable" || + status?.persistence?.state === "unavailable" + ) + state = "unavailable"; + else if ( + status?.mutation === "pending" || + status?.persistence?.state === "pending" + ) + state = "saving"; + else if ( + status?.identity && + status.storeRevision !== null && + status.persistence?.state === "saved" && + status.persistence.savedRevision !== null && + status.persistence.savedRevision >= status.storeRevision + ) + state = "saved"; + if (!state) return null; + return ( +

+ {t(`agentChat.inputbar.draftPersistence.${state}`)} +

+ ); +} diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index a8be3a240..78f4d85da 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -265,7 +265,10 @@ function renderWorkspaceChatContent({ ) : ( - + <> + + {landingSurface.composerDraftStatusNode} + )} diff --git a/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts b/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts index 81ca13c2f..af2de49b6 100644 --- a/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts +++ b/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.ts @@ -5,7 +5,6 @@ import { normalizeArtifactProtocolPath, resolveArtifactProtocolFilePath, } from "@/lib/artifact-protocol"; -import type { TaskCenterTabItem } from "../components/TaskCenterTabStrip"; import type { Message, MessagePreviewTarget } from "../types"; import { isHiddenInternalArtifactPath } from "../utils/internalArtifactVisibility"; import { shouldAutoSelectGeneralArtifact } from "./generalArtifactAutoSelection"; @@ -22,21 +21,10 @@ export const SESSION_RECENT_METADATA_BACKGROUND_SYNC_IDLE_TIMEOUT_MS = 20_000; export const FILE_MANAGER_NAV_COLLAPSE_BREAKPOINT_PX = 1180; export const APP_SIDEBAR_COLLAPSE_EVENT = "lime:app-sidebar-collapse"; -export const TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS = 1; export const NOOP_SET_CHAT_MESSAGES: Dispatch> = () => undefined; const FILE_MANAGER_SIDEBAR_OPEN_STORAGE_KEY = "lime.file-manager.sidebar-open"; -const TASK_CENTER_DRAFT_TAB_PREFIX = "task-draft"; - -export interface TaskCenterDraftTab { - id: string; - title: string; - createdAt: Date; - updatedAt: Date; - status: TaskCenterTabItem["status"]; -} - interface ResolveTaskCenterHomeSurfaceStateParams { agentEntry: string; draftSurfaceActive: boolean; @@ -141,14 +129,6 @@ export function saveFileManagerSidebarOpen(open: boolean): void { window.localStorage.setItem(FILE_MANAGER_SIDEBAR_OPEN_STORAGE_KEY, "false"); } -export function createTaskCenterDraftTabId(): string { - const random = - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID().slice(0, 8) - : Math.random().toString(36).slice(2, 10); - return `${TASK_CENTER_DRAFT_TAB_PREFIX}-${Date.now().toString(36)}-${random}`; -} - export function createLocalImageWorkbenchSessionKey(): string { const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" @@ -157,10 +137,6 @@ export function createLocalImageWorkbenchSessionKey(): string { return `__local_image_workbench__:${Date.now().toString(36)}:${random}`; } -export function isTaskCenterDraftTabId(value: string): boolean { - return value.startsWith(`${TASK_CENTER_DRAFT_TAB_PREFIX}-`); -} - export function createTaskCenterDraftSendRequestId(): string { const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" @@ -169,16 +145,6 @@ export function createTaskCenterDraftSendRequestId(): string { return `draft-send-${Date.now().toString(36)}-${random}`; } -export function resolveTaskCenterDraftSendTitle(text: string): string { - const normalized = text.trim().replace(/\s+/g, " "); - if (!normalized) { - return "新对话"; - } - - const preview = Array.from(normalized).slice(0, 18).join(""); - return normalized.length > preview.length ? `${preview}...` : preview; -} - export function isTaskCenterDraftSendPendingForLayout({ hasDraftSendRequest, hasDisplayMessages, @@ -278,26 +244,17 @@ export function shouldAutoInitWorkspaceSessionFiles({ export function shouldPauseTaskCenterInitialSessionNavigation({ agentEntry, draftSurfaceActive, - activeDraftTabId, - draftTabCount, hasHomeHotpathPending, }: { agentEntry?: string | null; draftSurfaceActive: boolean; - activeDraftTabId?: string | null; - draftTabCount: number; hasHomeHotpathPending: boolean; }): boolean { if (agentEntry !== "claw" && agentEntry !== "new-task") { return false; } - return ( - draftSurfaceActive || - Boolean(activeDraftTabId) || - draftTabCount > 0 || - hasHomeHotpathPending - ); + return draftSurfaceActive || hasHomeHotpathPending; } export function shouldSuppressTaskCenterDraftContentForLayout({ @@ -342,9 +299,7 @@ export function resolveTaskCenterHomeSurfaceState({ const shouldBlockForSessionSwitch = sessionSwitchPending && !hasCurrentSessionActivity; const isSessionRestorePending = - isAutoRestoringSession || - isSessionHydrating || - shouldBlockForSessionSwitch; + isAutoRestoringSession || isSessionHydrating || shouldBlockForSessionSwitch; const shouldHideBackgroundSessionContent = isHomeSessionBackgroundRecovery && !hasInitialSessionRoute; const shouldKeepDraftHomeShell = diff --git a/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.unit.test.ts b/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.unit.test.ts index 368f16ff8..856879baa 100644 --- a/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.unit.test.ts +++ b/src/components/agent/chat/workspace/agentChatWorkspaceHelpers.unit.test.ts @@ -229,8 +229,6 @@ describe("shouldPauseTaskCenterInitialSessionNavigation", () => { shouldPauseTaskCenterInitialSessionNavigation({ agentEntry: "claw", draftSurfaceActive: false, - activeDraftTabId: null, - draftTabCount: 0, hasHomeHotpathPending: true, }), ).toBe(true); @@ -241,8 +239,6 @@ describe("shouldPauseTaskCenterInitialSessionNavigation", () => { shouldPauseTaskCenterInitialSessionNavigation({ agentEntry: "general", draftSurfaceActive: false, - activeDraftTabId: null, - draftTabCount: 0, hasHomeHotpathPending: true, }), ).toBe(false); diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index 3cc26c9c2..e777dc1c4 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -1,3 +1,4 @@ +import type { ComposerDraftCoordinator } from "@/components/input-kit/ComposerDraftCoordinator"; import type { ComponentProps } from "react"; import type { ComposerController } from "@/components/input-kit/ComposerController"; import { StepProgress } from "@/components/workspace/layout/StepProgress"; @@ -44,6 +45,10 @@ export function buildStepProgressProps({ interface BuildWorkspaceEmptyStatePropsParams { input: ComponentProps["input"]; composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: ComponentProps< + typeof EmptyState + >["onComposerDraftAccepted"]; setInput: ComponentProps["setInput"]; onSendMessage: InputbarSendHandler; onStopSending?: ComponentProps["onStop"]; @@ -99,6 +104,9 @@ interface BuildWorkspaceEmptyStatePropsParams { typeof EmptyState >["onNavigateToSettings"]; onRefreshSkills?: ComponentProps["onRefreshSkills"]; + onSkillSuggestionsNeeded?: ComponentProps< + typeof EmptyState + >["onSkillSuggestionsNeeded"]; onLaunchBrowserAssist?: ComponentProps< typeof EmptyState >["onLaunchBrowserAssist"]; @@ -203,6 +211,8 @@ interface BuildWorkspaceEmptyStatePropsParams { export function buildWorkspaceEmptyStateProps({ input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, setInput, onSendMessage, onStopSending, @@ -239,6 +249,7 @@ export function buildWorkspaceEmptyStateProps({ onSelectServiceSkill, onNavigateToSettings, onRefreshSkills, + onSkillSuggestionsNeeded, onLaunchBrowserAssist, browserAssistLoading, recentSessionTitle, @@ -290,6 +301,8 @@ export function buildWorkspaceEmptyStateProps({ return { input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, setInput, onSend: handleEmptyStateSend, onStop: onStopSending, @@ -332,6 +345,7 @@ export function buildWorkspaceEmptyStateProps({ onSelectServiceSkill, onNavigateToSettings, onRefreshSkills, + onSkillSuggestionsNeeded, onLaunchBrowserAssist, browserAssistLoading, recentSessionTitle, diff --git a/src/components/agent/chat/workspace/composerDraftNavigation.integration.test.tsx b/src/components/agent/chat/workspace/composerDraftNavigation.integration.test.tsx new file mode 100644 index 000000000..f1a72804b --- /dev/null +++ b/src/components/agent/chat/workspace/composerDraftNavigation.integration.test.tsx @@ -0,0 +1,410 @@ +import { act, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { decodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { + a as coordinatorA, + b as coordinatorB, + deferred, + harness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { TaskCenterWorkspaceTabMap } from "../utils/taskCenterTabs"; +import { useWorkspaceTopicSwitch } from "./useWorkspaceTopicSwitch"; +import { useWorkspaceTaskCenterDraftStateRuntime } from "./useWorkspaceTaskCenterDraftStateRuntime"; +import { useTaskCenterTopicNavigationRuntime } from "./useTaskCenterTopicNavigationRuntime"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +const projects = vi.hoisted(() => ({ getProject: vi.fn() })); +vi.mock("@/lib/api/project", () => projects); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +const a = { ...coordinatorA, kind: "home" as const, id: "new-conversation" }; +const b = { ...coordinatorB, kind: "local" as const, id: "other-session" }; + +let active: Awaited> | null = null; +const mounted: Array<{ root: Root; container: HTMLDivElement }> = []; + +function mount

(useHook: (props: P) => V, props: P) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let value!: V; + function Probe(next: P) { + value = useHook(next); + return null; + } + mounted.push({ root, container }); + const render = (next: P) => act(() => root.render()); + render(props); + return { get: () => value, render }; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + vi.resetAllMocks(); +}); +afterEach(async () => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + await active?.close(); + active = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +async function setup() { + active = await harness({ safeInvoke, safeListen }); + await active.coordinator.bindCommitted(a); + active.controller.setText("保留旧草稿😀"); + return active; +} +function switchProps( + overrides: Partial[0]> = {}, +) { + return { + projectId: "w", + originalSwitchTopic: vi.fn(async () => undefined), + onBeforeTopicSwitch: vi.fn(), + onAfterTopicSwitch: vi.fn(), + composerDraftCoordinator: active!.coordinator, + startTopicProjectResolution: vi.fn(() => true), + finishTopicProjectResolution: vi.fn(), + deferTopicSwitch: vi.fn(), + consumePendingTopicSwitch: vi.fn(() => null), + rememberProjectId: vi.fn(), + getRememberedProjectId: vi.fn(() => null), + loadTopicBoundProjectId: vi.fn(() => "w"), + resetTopicLocalState: vi.fn(), + ...overrides, + }; +} + +describe("navigation commits through real Controller, gateway, parser and Store", () => { + it.each(["error", "blocked", "busy", "deferred", "missing"])( + "original %s cannot reset or commit a new composer scope", + async (outcome) => { + const h = await setup(); + const before = h.coordinator.captureBinding()!; + const props = switchProps({ + originalSwitchTopic: vi.fn(async () => outcome), + }); + const hook = mount(useWorkspaceTopicSwitch, props); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe(outcome); + }); + await h.coordinator.idle(); + expect(props.resetTopicLocalState).not.toHaveBeenCalled(); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + expect(h.coordinator.captureBinding()?.identity).toEqual(before.identity); + const entry = await h.read(a); + expect(entry?.state).toBe("draft"); + if (entry?.state !== "draft") throw new Error("missing source"); + expect(decodeComposerDraft(entry.draft).document.text).toBe( + "保留旧草稿😀", + ); + }, + ); + + it.each(["busy", "blocked", "missing"])( + "resolution %s preserves editing scope", + async (outcome) => { + const h = await setup(); + const props = switchProps( + outcome === "busy" + ? { startTopicProjectResolution: vi.fn(() => false) } + : outcome === "blocked" + ? { + projectId: "locked", + externalProjectId: "locked", + loadTopicBoundProjectId: () => "other", + } + : { projectId: undefined, loadTopicBoundProjectId: () => null }, + ); + const hook = mount(useWorkspaceTopicSwitch, props); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe(outcome); + }); + await h.coordinator.idle(); + expect(props.originalSwitchTopic).not.toHaveBeenCalled(); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.coordinator.getSnapshot().scope).toEqual(a); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + expect((await h.read(a))?.state).toBe("draft"); + }, + ); + + it("deferred project resume commits once with captured actual project", async () => { + const h = await setup(); + projects.getProject.mockResolvedValue({ id: "other", isArchived: false }); + const props = switchProps({ loadTopicBoundProjectId: () => "other" }); + const hook = mount(useWorkspaceTopicSwitch, props); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe("deferred"); + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + const consume = + vi.fn< + Parameters< + typeof useWorkspaceTopicSwitch + >[0]["consumePendingTopicSwitch"] + >(); + consume + .mockReturnValueOnce({ topicId: "next", targetProjectId: "other" }) + .mockReturnValue(null); + await act(async () => + hook.render({ + ...props, + projectId: "other", + consumePendingTopicSwitch: consume, + }), + ); + expect(props.onAfterTopicSwitch).toHaveBeenCalledExactlyOnceWith( + "next", + "other", + ); + expect(props.resetTopicLocalState).toHaveBeenCalledOnce(); + expect(h.coordinator.getSnapshot().scope).toEqual(a); + }); + + it("deferred topic cannot replace a later committed draft generation", async () => { + const h = await setup(); + projects.getProject.mockResolvedValue({ id: "other", isArchived: false }); + const props = switchProps({ loadTopicBoundProjectId: () => "other" }); + const hook = mount(useWorkspaceTopicSwitch, props); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe("deferred"); + }); + await h.coordinator.bindCommitted(b); + h.controller.setText("新草稿"); + const consume = + vi.fn< + Parameters< + typeof useWorkspaceTopicSwitch + >[0]["consumePendingTopicSwitch"] + >(); + consume + .mockReturnValueOnce({ topicId: "next", targetProjectId: "other" }) + .mockReturnValue(null); + await act(async () => + hook.render({ + ...props, + projectId: "other", + consumePendingTopicSwitch: consume, + }), + ); + expect(props.originalSwitchTopic).not.toHaveBeenCalled(); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.controller.getSnapshot().document.text).toBe("新草稿"); + expect(h.coordinator.getSnapshot().scope).toEqual(b); + }); + + it.each(["scope-promotion", "ui-epoch"])( + "deferred %s invalidation cancels resume without recapturing the source", + async (invalidation) => { + const h = await setup(); + let navigationEpoch = 1; + projects.getProject.mockResolvedValue({ id: "other", isArchived: false }); + const props = switchProps({ + loadTopicBoundProjectId: () => "other", + getComposerDraftNavigationEpoch: () => navigationEpoch, + }); + const hook = mount(useWorkspaceTopicSwitch, props); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe("deferred"); + }); + const generation = h.coordinator.getSnapshot().generation; + if (invalidation === "scope-promotion") { + const token = h.coordinator.captureBinding()!; + await h.coordinator.promote( + token, + { workspaceId: null, kind: "local", id: "actual" }, + { expectedTarget: null, sessionId: "actual" }, + ); + expect(h.coordinator.getSnapshot().generation).toBe(generation); + } else { + navigationEpoch += 1; + } + const consume = + vi.fn< + Parameters< + typeof useWorkspaceTopicSwitch + >[0]["consumePendingTopicSwitch"] + >(); + consume + .mockReturnValueOnce({ topicId: "next", targetProjectId: "other" }) + .mockReturnValue(null); + await act(async () => + hook.render({ + ...props, + projectId: "other", + consumePendingTopicSwitch: consume, + }), + ); + expect(props.originalSwitchTopic).not.toHaveBeenCalled(); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + }, + ); + + it("superseded completion cannot commit after a newer success", async () => { + await setup(); + const first = deferred(); + const second = deferred(); + const props = switchProps({ + originalSwitchTopic: vi + .fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + }); + const hook = mount(useWorkspaceTopicSwitch, props); + let firstResult!: Promise; + let secondResult!: Promise; + act(() => { + firstResult = hook.get().runTopicSwitch("old"); + secondResult = hook.get().runTopicSwitch("new"); + }); + await act(async () => { + second.resolve(undefined); + await secondResult; + }); + await act(async () => { + first.resolve(undefined); + expect(await firstResult).toBe("superseded"); + }); + expect(props.onAfterTopicSwitch).toHaveBeenCalledExactlyOnceWith( + "new", + "w", + ); + expect(props.resetTopicLocalState).toHaveBeenCalledOnce(); + }); + + it("draft activation and Controller survive preparation until actual topic commit", async () => { + const h = await setup(); + const hook = mount(useWorkspaceTaskCenterDraftStateRuntime, { + agentEntry: "claw", + composerDraftCoordinator: h.coordinator, + resetComposerDraft: vi.fn(), + deferSessionRecentMetadataSyncForNavigation: vi.fn(), + effectiveThreadItemCount: 0, + hasInitialSessionTopic: true, + messagesLength: 0, + normalizedInitialSessionId: "old", + sessionId: "old", + }); + act(() => { + hook.get().taskCenterHomeSurfaceActiveRef.current = true; + }); + act(() => hook.get().handleBeforeTopicSwitch("new")); + expect(hook.get().taskCenterHomeSurfaceActiveRef.current).toBe(true); + act(() => hook.get().handleTopicSwitchCommitted("new")); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + expect(h.coordinator.getSnapshot().scope).toEqual(a); + }); +}); + +function useNavigationHarness(props: { + switchTopic: Parameters< + typeof useTaskCenterTopicNavigationRuntime + >[0]["switchTopic"]; + openTopics?: string[]; + workspaceId?: string | null; + getNavigationEpoch?: () => number; +}) { + const [, setDetached] = useState(null); + const [, setOverride] = useState<{ + sessionId: string; + routeSessionId: string | null; + } | null>(null); + const [, setMap] = useState({}); + const [, setTransition] = useState(null); + const surface = useRef(true); + const runtime = useTaskCenterTopicNavigationRuntime({ + activeSessionIdRef: useRef("old"), + agentEntry: "claw", + clearEntryPendingA2UI: vi.fn(), + clearTaskCenterEmbeddedHomeSession: vi.fn(), + messagesLength: 0, + replaceTaskCenterOpenTabs: vi.fn(), + resetLocalImageWorkbenchSessionScope: vi.fn(), + composerDraftCoordinator: active!.coordinator, + getComposerDraftNavigationEpoch: props.getNavigationEpoch, + sessionId: "old", + setTaskCenterDetachedTopicId: setDetached, + setTaskCenterLocalSessionOverride: setOverride, + setTaskCenterOpenTabMap: setMap, + setTaskCenterTransitionTopicId: setTransition, + switchTopic: props.switchTopic, + taskCenterDetachedTopicId: null, + taskCenterHomeSurfaceActiveRef: surface, + taskCenterOpenTabIdsRef: useRef(props.openTopics ?? []), + taskCenterTransitionTopicId: null, + taskCenterWorkspaceId: + props.workspaceId === undefined ? "w" : props.workspaceId, + topicById: new Map(), + upsertTaskCenterOpenTab: vi.fn(), + markTaskCenterLocalSessionOverride: vi.fn(), + }); + return { ...runtime, surface }; +} + +describe("topic navigation only changes the editing target after commit", () => { + it.each(["blocked", "deferred", "error"])( + "%s retains active draft and editable document", + async (outcome) => { + const h = await setup(); + const hook = mount(useNavigationHarness, { + switchTopic: vi.fn(async () => outcome), + }); + await act(async () => { + await hook.get().handleOpenTaskTopic("next"); + }); + expect(hook.get().surface.current).toBe(true); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + }, + ); + + it("busy retry success finally clears activation without clearing Controller", async () => { + const h = await setup(); + const switching = vi + .fn() + .mockResolvedValueOnce("busy") + .mockResolvedValueOnce("success"); + const hook = mount(useNavigationHarness, { switchTopic: switching }); + await act(async () => { + await hook.get().handleOpenTaskTopic("next"); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + expect(hook.get().surface.current).toBe(false); + expect(h.controller.getSnapshot().document.text).toBe("保留旧草稿😀"); + }); + + it("busy retry cancels for a newer intended navigation before layout binding", async () => { + await setup(); + let epoch = 1; + const switching = vi.fn(async () => "busy"); + const hook = mount(useNavigationHarness, { + switchTopic: switching, + getNavigationEpoch: () => epoch, + }); + await act(async () => { + await hook.get().handleOpenTaskTopic("next"); + }); + epoch += 1; + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + expect(switching).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/agent/chat/workspace/composerDraftNavigation.ts b/src/components/agent/chat/workspace/composerDraftNavigation.ts new file mode 100644 index 000000000..3e6ce15fa --- /dev/null +++ b/src/components/agent/chat/workspace/composerDraftNavigation.ts @@ -0,0 +1,16 @@ +import type { + ComposerDraftBindingToken, + ComposerDraftCoordinator, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; + +export interface ComposerDraftNavigationCallbacks { + composerDraftCoordinator?: ComposerDraftCoordinator; + getComposerDraftNavigationEpoch?: () => number; + onComposerDraftScopeCommitted?: (scope: DraftScope) => void; + onComposerDraftPromotion?: ( + token: ComposerDraftBindingToken, + target: DraftScope, + metadata: { sessionId: string; threadId?: string }, + ) => void; +} diff --git a/src/components/agent/chat/workspace/inputCapabilityRouting.test.ts b/src/components/agent/chat/workspace/inputCapabilityRouting.test.ts index 170f01c69..df8e3f24a 100644 --- a/src/components/agent/chat/workspace/inputCapabilityRouting.test.ts +++ b/src/components/agent/chat/workspace/inputCapabilityRouting.test.ts @@ -1,7 +1,54 @@ import { describe, expect, it } from "vitest"; import { resolveInputCapabilityDispatchContext } from "./inputCapabilityRouting"; +import type { ComposerSubmission } from "@/components/input-kit/types"; +import { remapComposerSubmission } from "../utils/composerSubmission"; describe("inputCapabilityRouting", () => { + it("Skill 路由保留完整发送正文与 UTF8 原子范围,展示文案只用于回放", () => { + const text = "**中文😀** [链接](https://example.invalid)\n隐藏粘贴正文"; + const prefix = "**中文😀** [链接](https://example.invalid)\n"; + const submission: ComposerSubmission = { + text, + textElements: [ + { + byteRange: { + start: new TextEncoder().encode(prefix).length, + end: new TextEncoder().encode(text).length, + }, + placeholder: "[粘贴内容]", + }, + ], + inputMentions: [ + { type: "skill", name: "writer", path: "/skills/writer/SKILL.md" }, + ], + attachments: [{ data: "durable-image", mediaType: "image/png" }], + pathReferences: [ + { id: "file-1", path: "/tmp/ref.md", name: "ref.md", isDir: false }, + ], + }; + const displayContent = "中文😀 链接 [粘贴内容]"; + const routed = resolveInputCapabilityDispatchContext({ + sourceText: submission.text, + displayContent, + capabilityRoute: { + kind: "installed_skill", + skillKey: "writer", + skillName: "writer", + }, + }); + const sent = remapComposerSubmission(submission, routed.sourceText); + expect(sent.text).toBe(`/writer ${text}`); + expect(sent.textElements[0].byteRange).toEqual({ + start: submission.textElements[0].byteRange.start + 8, + end: submission.textElements[0].byteRange.end + 8, + }); + expect(sent.attachments).toEqual(submission.attachments); + expect(sent.pathReferences).toEqual(submission.pathReferences); + expect(sent.inputMentions).toEqual(submission.inputMentions); + expect(routed.completedSlashUsage?.replayText).toBe(displayContent); + expect(submission.text).toBe(text); + }); + it("已带 @配图 前缀的 builtin command route 不应重复拼接命令", () => { const context = resolveInputCapabilityDispatchContext({ sourceText: "@配图 生成 一张春日咖啡馆插画", diff --git a/src/components/agent/chat/workspace/inputCapabilityRouting.ts b/src/components/agent/chat/workspace/inputCapabilityRouting.ts index 5e043f505..26b47d2cc 100644 --- a/src/components/agent/chat/workspace/inputCapabilityRouting.ts +++ b/src/components/agent/chat/workspace/inputCapabilityRouting.ts @@ -128,10 +128,9 @@ function resolveRoutedSourceText(params: { } const trimmedSourceText = params.sourceText.trim(); - const userVisibleText = - normalizeOptionalText(params.displayContent) ?? - normalizeOptionalText(params.sourceText) ?? - ""; + const sourceBody = trimmedSourceText + ? params.sourceText + : (normalizeOptionalText(params.displayContent) ?? ""); if (route.kind === "builtin_command") { if ( @@ -141,7 +140,7 @@ function resolveRoutedSourceText(params: { return params.sourceText; } - return `${route.commandPrefix}${userVisibleText ? ` ${userVisibleText}` : ""}`; + return `${route.commandPrefix}${sourceBody ? ` ${sourceBody}` : ""}`; } if (route.kind === "installed_skill") { @@ -153,7 +152,7 @@ function resolveRoutedSourceText(params: { return params.sourceText; } - return `${skillPrefix}${userVisibleText ? ` ${userVisibleText}` : ""}`; + return `${skillPrefix}${sourceBody ? ` ${sourceBody}` : ""}`; } if (route.kind === "curated_task") { @@ -167,7 +166,7 @@ function resolveRoutedSourceText(params: { return params.sourceText; } - return `${route.commandPrefix}${userVisibleText ? ` ${userVisibleText}` : ""}`; + return `${route.commandPrefix}${sourceBody ? ` ${sourceBody}` : ""}`; } function resolveCompletedSlashUsage(params: { diff --git a/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts index 4417e1e02..156374853 100644 --- a/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts +++ b/src/components/agent/chat/workspace/knowledge/useWorkspaceKnowledgeRuntime.ts @@ -1,6 +1,6 @@ +import type { WorkspaceHandleSend } from "../useWorkspaceSendActions"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; import { listKnowledgePacks, type KnowledgePackSummary, @@ -24,18 +24,9 @@ import type { InputbarKnowledgePackOption, InputbarKnowledgePackSelection, } from "../../components/Inputbar/types"; -import type { HandleSendOptions } from "../../hooks/handleSendTypes"; -import type { MessageImage, MessagePathReference } from "../../types"; +import type { MessagePathReference } from "../../types"; -type WorkspaceKnowledgeHandleSend = ( - images?: MessageImage[], - webSearch?: boolean, - thinking?: boolean, - textOverride?: string, - executionStrategy?: "react", - autoContinuePayload?: AutoContinueRequestPayload, - sendOptions?: HandleSendOptions, -) => void | Promise | boolean; +type WorkspaceKnowledgeHandleSend = WorkspaceHandleSend; function isReadyKnowledgePack(pack: KnowledgePackSummary): boolean { return pack.metadata.status === "ready"; diff --git a/src/components/agent/chat/workspace/newConversationPublicNavigation.integration.test.tsx b/src/components/agent/chat/workspace/newConversationPublicNavigation.integration.test.tsx new file mode 100644 index 000000000..21e1e34aa --- /dev/null +++ b/src/components/agent/chat/workspace/newConversationPublicNavigation.integration.test.tsx @@ -0,0 +1,442 @@ +import { StrictMode, act, useLayoutEffect, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { encodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { + accepted, + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import { useAppNavigation } from "@/hooks/useAppNavigation"; +import type { AgentPageParams } from "@/types/page"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import type { NewConversationRequest } from "@/lib/workspace/navigation"; +import { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; +import { useWorkspaceComposerDraftNavigation } from "./useWorkspaceComposerDraftNavigation"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); +type Fixture = Awaited>; +const home: DraftScope = { + workspaceId: null, + kind: "home", + id: "new-conversation", +}; +const local: DraftScope = { workspaceId: null, kind: "local", id: "existing" }; +const roots: Array<{ root: Root; element: HTMLDivElement }> = []; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + sessionStorage.clear(); +}); +afterEach(() => { + act(() => { + for (const { root, element } of roots.splice(0)) { + root.unmount(); + element.remove(); + } + }); + sessionStorage.clear(); + vi.unstubAllGlobals(); +}); +async function mount( + h: Fixture, + initialScope: DraftScope = home, + strict = false, +) { + let current!: { + navigation: ReturnType; + draft: ReturnType; + setSession: (id: string | null) => void; + setCommit: (enabled: boolean) => void; + setWorkspace: (id: string | null | undefined) => void; + }; + const onSurfaceCommit = vi.fn(); + function Surface() { + const navigation = useAppNavigation(); + const route = navigation.pageParams as AgentPageParams; + const [committed, setCommit] = useState(true); + const [session, setSession] = useState(null); + const [workspaceOverride, setWorkspace] = useState< + string | null | undefined + >(undefined); + const workspaceId = + workspaceOverride === undefined + ? (route.projectId ?? null) + : workspaceOverride; + const draft = useWorkspaceComposerDraftRuntime({ + controller: h.controller, + initialScope, + }); + const request = + navigation.currentPage === "agent" && committed + ? route.newConversationRequest + : undefined; + useWorkspaceComposerDraftNavigation({ + composerDraftRuntime: draft, + newConversationRequest: request, + workspaceId, + projectSelectionSource: + workspaceOverride === undefined ? "manual" : "topic-switch", + normalizedInitialSessionId: route.initialSessionId ?? null, + sessionId: session, + onTopicSwitchCommitted: onSurfaceCommit, + }); + const { commitComposerDraftScope } = draft; + // Harness supplies a resolved canonical route, the same condition used by product navigation. + useLayoutEffect(() => { + if (route.initialSessionId && workspaceId === (route.projectId ?? null)) + commitComposerDraftScope({ + workspaceId, + kind: "local", + id: route.initialSessionId, + }); + }, [ + commitComposerDraftScope, + route.initialSessionId, + route.projectId, + workspaceId, + ]); + current = { navigation, draft, setSession, setCommit, setWorkspace }; + return ( + <> + + +

+
+
+ + ); + } + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + roots.push({ root, element }); + const render = async () => { + await act(async () => + root.render( + strict ? ( + + + + ) : ( + + ), + ), + ); + }; + await render(); + return { + current: () => current, + onSurfaceCommit, + render, + async request(request: { projectId: string | null; freshDraft: boolean }) { + await act(async () => current.navigation.startNewConversation(request)); + }, + async click(label: string) { + const button = [...element.querySelectorAll("button")].find( + (item) => item.textContent === label, + )!; + await act(async () => button.click()); + }, + }; +} +function rich() { + const source = new ComposerController({ text: "保留 rich 😀" }); + source.ingestPaste("折叠内容".repeat(300)); + source.setAttachments([{ data: "durable", mediaType: "image/png" }]); + source.setPathReferences([ + { id: "file", path: "/tmp/a.ts", name: "a.ts", isDir: false }, + ]); + return source.captureDraft(); +} +function seedRich(h: Fixture, scope: DraftScope) { + const value = rich(); + h.store.updatePersistedAtomState({ + op: "put", + scope, + draftId: "rich-home", + nextVersion: "v1", + expectedVersion: null, + draft: encodeComposerDraft(value, false), + }); + return value; +} + +describe("public action -> committed route -> sole draft owner -> real gateway/parser/Store", () => { + it("Home restores full existing home draft and repeated Home preserves identity/revision", async () => { + const h = await setup(), + saved = seedRich(h, home); + h.seed(local, "local unsent"); + const view = await mount(h, local); + act(() => { + h.controller.setText("new local source 😀 "); + h.controller.ingestPaste("local hidden content".repeat(120)); + }); + const source = h.controller.captureDraft(); + await view.click("Home"); + expect(h.controller.captureDraft()).toEqual(saved); + const identity = view.current().draft.composerDraftStatus?.identity, + revision = h.controller.getSnapshot().revision; + await view.click("Home"); + expect(h.controller.captureDraft()).toEqual(saved); + expect(view.current().draft.composerDraftStatus?.identity).toEqual( + identity, + ); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(h.mutations.some((item) => item.op === "clear")).toBe(false); + expect(await h.read(local)).toMatchObject({ + state: "draft", + draft: encodeComposerDraft(source, false), + }); + await act(async () => + view.current().navigation.handleNavigate("agent", { + agentEntry: "claw", + initialSessionId: local.id, + }), + ); + expect(h.controller.captureDraft()).toEqual(source); + }); + it("New chat retires the same-scope identity once, then the next edit gets a new identity", async () => { + const h = await setup(); + seedRich(h, home); + const view = await mount(h, home, true); + const identity = view.current().draft.composerDraftStatus?.identity, + generation = view.current().draft.composerDraftStatus?.generation, + epoch = view.current().draft.getComposerDraftNavigationEpoch(); + await view.click("New chat"); + expect(h.controller.getDocument().text).toBe(""); + expect(h.controller.captureDraft().attachments).toEqual([]); + expect(view.current().draft.composerDraftStatus?.identity).toBeNull(); + expect( + view.current().draft.composerDraftStatus?.generation, + ).toBeGreaterThan(generation!); + expect(view.current().draft.getComposerDraftNavigationEpoch()).toBe( + epoch + 1, + ); + await view.render(); + expect(h.mutations.filter((item) => item.op === "clear")).toHaveLength(1); + act(() => h.controller.setText("new valid edit")); + await act(async () => + view.current().draft.composerDraftCoordinator!.idle(), + ); + expect( + view.current().draft.composerDraftStatus?.identity?.draftId, + ).not.toBe(identity!.draftId); + expect(document.activeElement?.getAttribute("role")).toBe("textbox"); + }); + it("a withheld route intent keeps the local source until the actual commit arrives", async () => { + const h = await setup(); + h.seed(local, "source intact"); + const view = await mount(h, local); + await act(async () => view.current().setCommit(false)); + await view.click("New chat"); + expect(h.controller.getDocument().text).toBe("source intact"); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(local); + await act(async () => view.current().setCommit(true)); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(home); + expect(h.controller.getDocument().text).toBe(""); + expect((await h.read(local))?.state).toBe("draft"); + }); + it("explicit null never inherits the previous project; another project commits its own home", async () => { + const h = await setup(); + const projectHome = { ...home, workspaceId: "project-a" }; + h.seed(projectHome, "A unsent"); + seedRich(h, home); + const view = await mount(h, projectHome); + await view.request({ projectId: null, freshDraft: false }); + expect( + (view.current().navigation.pageParams as AgentPageParams).projectId, + ).toBeUndefined(); + expect( + (view.current().navigation.pageParams as AgentPageParams) + .newConversationRequest?.workspaceId, + ).toBeNull(); + expect(h.controller.captureDraft()).toEqual(rich()); + const target = { ...home, workspaceId: "project-b" }; + h.store.updatePersistedAtomState({ + op: "put", + scope: target, + draftId: "unique-project-b", + nextVersion: "b1", + expectedVersion: null, + draft: encodeComposerDraft( + new ComposerController({ text: "B unsent" }).captureDraft(), + false, + ), + }); + await view.request({ projectId: "project-b", freshDraft: false }); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(target); + expect(h.controller.getDocument().text).toBe("B unsent"); + expect((await h.read(projectHome))?.state).toBe("draft"); + }); + it("an unresolved workspace cannot clear its old document", async () => { + const h = await setup(); + h.seed(home, "original"); + const view = await mount(h); + await act(async () => view.current().setWorkspace("previous-project")); + await view.click("New chat"); + expect(h.controller.getDocument().text).toBe("original"); + await act(async () => view.current().setWorkspace(undefined)); + expect(h.controller.getDocument().text).toBe(""); + }); + it("same-millisecond New requests have distinct IDs and each is consumed only once", async () => { + const h = await setup(); + h.seed(home, "first"); + const view = await mount(h); + const now = vi.spyOn(Date, "now").mockReturnValue(1000); + await view.click("New chat"); + const first = (view.current().navigation.pageParams as AgentPageParams) + .newConversationRequest!; + act(() => h.controller.setText("second draft")); + await view.click("New chat"); + const second = (view.current().navigation.pageParams as AgentPageParams) + .newConversationRequest!; + expect(second.id).toBe(first.id + 1); + expect(h.controller.getDocument().text).toBe(""); + expect(h.mutations.filter((item) => item.op === "clear")).toHaveLength(2); + now.mockRestore(); + }); + it("a committed request never replays after accepted local activation or callback changes", async () => { + const h = await setup(); + const view = await mount(h); + await view.click("New chat"); + act(() => h.controller.setText("submitted")); + const capture = view + .current() + .draft.composerDraftCoordinator!.captureSubmission("start"); + const result = accepted(); + if (result.kind !== "runtime-accepted") throw new Error("fixture"); + const calls = view.onSurfaceCommit.mock.calls.length; + await act(async () => { + await view.current().draft.handleComposerDraftAccepted(capture, result); + view.current().setSession(result.receipt.sessionId); + }); + expect(view.onSurfaceCommit).toHaveBeenCalledTimes(calls); + expect(view.current().draft.composerDraftStatus?.scope?.kind).toBe("local"); + expect(h.mutations.filter((item) => item.op === "clear")).toHaveLength(0); + }); + it("failed fresh CAS keeps the full rich source and identity", async () => { + const h = await setup(), + saved = seedRich(h, home); + const view = await mount(h); + const identity = view.current().draft.composerDraftStatus?.identity; + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "clear" + ? Promise.reject(new Error("transport unavailable")) + : execute(), + ); + await view.click("New chat"); + expect(h.controller.captureDraft()).toEqual(saved); + expect(view.current().draft.composerDraftStatus?.identity).toEqual( + identity, + ); + expect((await h.read(home))?.state).toBe("draft"); + }); + it("late fresh CAS cannot erase edits made while transport is in flight", async () => { + const h = await setup(); + h.seed(home, "old"); + const view = await mount(h); + const gate = deferred(); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + h.intercept((command, args, execute) => { + if ( + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "clear" + ) { + entered(); + return gate.promise.then(execute); + } + return execute(); + }); + await view.click("New chat"); + await started; + act(() => h.controller.setText("newer wins")); + await act(async () => { + gate.resolve(undefined); + await gate.promise; + await view.current().draft.composerDraftCoordinator!.idle(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await view.current().draft.composerDraftCoordinator!.idle(); + }); + expect(h.controller.getDocument().text).toBe("newer wins"); + expect(await h.read(home)).toMatchObject({ + state: "draft", + draft: { document: { text: "newer wins" } }, + }); + }); + it("acceptance captured before fresh cannot reclaim the new home epoch", async () => { + const h = await setup(); + const view = await mount(h); + act(() => h.controller.setText("old send")); + const owner = view.current().draft.composerDraftCoordinator!; + const capture = owner.captureSubmission("start"); + await act(async () => owner.idle()); + await view.click("New chat"); + act(() => h.controller.setText("new draft")); + const result = accepted(); + if (result.kind !== "runtime-accepted") throw new Error("fixture"); + await act(async () => { + await view.current().draft.handleComposerDraftAccepted(capture, result); + await owner.settleSubmission(capture, result); + await owner.idle(); + }); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(home); + expect(h.controller.getDocument().text).toBe("new draft"); + expect(await h.read(home)).toMatchObject({ + state: "draft", + draft: { document: { text: "new draft" } }, + }); + }); + it("reload restores canonical navigation without replaying a fresh intent", async () => { + const h = await setup(); + const view = await mount(h); + const request: NewConversationRequest = { + id: 1, + workspaceId: null, + freshDraft: true, + }; + await act(async () => + view.current().navigation.handleNavigate("agent", { + agentEntry: "claw", + initialSessionId: "existing", + newConversationRequest: request, + }), + ); + const persisted = JSON.parse( + sessionStorage.getItem("lime.appNavigation.restore.v1")!, + ); + expect(persisted.params.initialSessionId).toBe("existing"); + expect(persisted.params.newConversationRequest).toBeUndefined(); + }); +}); diff --git a/src/components/agent/chat/workspace/stopSendChain.testFixtures.tsx b/src/components/agent/chat/workspace/stopSendChain.testFixtures.tsx new file mode 100644 index 000000000..2469045a6 --- /dev/null +++ b/src/components/agent/chat/workspace/stopSendChain.testFixtures.tsx @@ -0,0 +1,218 @@ +import { vi } from "vitest"; +import type { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; +import type { AgentRuntimeAdapter } from "../hooks/agentRuntimeAdapter"; +import type { AgentEvent } from "@/lib/api/agentProtocol"; + +export function captureCanonicalTurnEvents(runtime: { + listenToTurnEvents: ReturnType; +}) { + let listener: + | Parameters[1] + | null = null; + runtime.listenToTurnEvents.mockImplementation( + async ( + _eventName: string, + handler: Parameters[1], + ) => { + listener = handler; + return () => { + if (listener === handler) listener = null; + }; + }, + ); + return { + emitTurnStarted(promptText: string) { + if (!listener) throw new Error("Missing real turn-event consumer"); + const payload: Extract = { + type: "turn_started", + turn: { + id: "turn-1", + thread_id: "thread-1", + prompt_text: promptText, + status: "running", + started_at: "2026-10-15T00:00:00.000Z", + created_at: "2026-10-15T00:00:00.000Z", + updated_at: "2026-10-15T00:00:00.000Z", + }, + }; + listener({ payload }); + }, + }; +} + +type HookProps = Parameters[0]; +type HarnessPanelBaseProps = HookProps["generalWorkbenchHarnessPanelBaseProps"]; +type HookPropsOverrides = Partial< + Omit +> & + Partial & { + generalWorkbenchHarnessPanelBaseProps?: Partial; + }; + +// Fixture-only peripheral defaults. The regression supplies every send/Stop owner. +function createDefaultHarnessPanelBaseProps( + noop: ReturnType, + overrides: Partial = {}, +): HarnessPanelBaseProps { + return { + environment: { + skillsCount: 0, + skillNames: [], + memorySignals: [], + contextItemsCount: 0, + activeContextCount: 0, + contextItemNames: [], + contextEnabled: false, + }, + canonicalChildren: [], + threadRead: null, + turns: [], + threadItems: [], + currentTurnId: null, + pendingActions: [], + submittedActionsInFlight: [], + onRespondToAction: noop, + messages: [], + canInterrupt: false, + onInterruptCurrentTurn: noop, + onReplayPendingRequest: noop, + onManageProviders: noop, + onOpenExecutionPolicySettings: noop, + diagnosticRuntimeContext: { + sessionId: null, + workspaceId: "project-1", + workingDir: "/tmp/project-1", + providerType: "openai", + model: "gpt-5", + executionStrategy: "react", + activeTheme: "general", + }, + toolInventory: null, + toolInventoryLoading: false, + toolInventoryError: null, + onRefreshToolInventory: noop, + onOpenSubagentSession: noop, + onLoadFilePreview: noop, + onOpenFile: noop, + ...overrides, + }; +} + +export function createSceneProps( + overrides: HookPropsOverrides = {}, +): HookProps { + const noop = vi.fn(); + const { + generalWorkbenchHarnessPanelBaseProps, + pendingActions, + submittedActionsInFlight, + onRespondToAction, + ...hookOverrides + } = overrides; + + return { + setMentionedCharacters: noop, + isThemeWorkbench: true, + sessionId: "session-1", + threadId: null, + threadGoal: null, + threadGoalError: null, + threadGoalLoading: false, + generalWorkbenchHarnessPanelBaseProps: createDefaultHarnessPanelBaseProps( + noop, + { + ...generalWorkbenchHarnessPanelBaseProps, + ...(pendingActions ? { pendingActions } : {}), + ...(submittedActionsInFlight ? { submittedActionsInFlight } : {}), + ...(onRespondToAction ? { onRespondToAction } : {}), + }, + ), + currentSessionTitle: "当前会话", + handleStopSending: noop, + input: "", + setInput: noop, + currentGate: null, + generalWorkbenchWorkflowSteps: [], + steps: [], + workflowRunState: "idle", + handleSend: vi.fn().mockResolvedValue({ kind: "rejected" as const }), + isPreparingSend: false, + isSending: false, + providerType: "openai", + setProviderType: noop, + model: "gpt-5", + setModel: noop, + reasoningEffort: "", + setReasoningEffort: noop, + sessionExecutionRuntime: null, + projectId: "project-1", + projectRootPath: "/tmp/project-1", + accessMode: "current", + setAccessMode: noop, + activeTheme: "general", + navigationActions: { + handleManageProviders: noop, + handleOpenExecutionPolicySettings: noop, + handleOpenRuntimeMemoryWorkbench: noop, + handleOpenKnowledgeManagement: noop, + }, + characters: [], + skills: [], + serviceSkills: [], + serviceSkillGroups: [], + skillsLoading: false, + onSelectServiceSkill: noop, + initialInputCapability: undefined, + initialKnowledgePackSelection: undefined, + setChatToolPreferences: noop, + handleNavigateToSkillSettings: noop, + handleRefreshSkills: noop, + soulArtifactVoiceGenerationBrief: null, + soulArtifactVoiceEnabledForTurn: true, + onSoulArtifactVoiceEnabledForTurnChange: noop, + generalWorkbenchEntryPrompt: null, + handleRestartGeneralWorkbenchEntryPrompt: noop, + handleContinueGeneralWorkbenchEntryPrompt: noop, + generalWorkbenchEnabled: false, + harnessPanelVisible: false, + setHarnessPanelVisible: noop, + harnessState: { + runtimeStatus: null, + pendingApprovals: [], + latestContextTrace: [], + plan: { + phase: "idle", + items: [], + }, + activity: { + planning: 0, + filesystem: 0, + execution: 0, + web: 0, + skills: 0, + delegation: 0, + }, + delegatedTasks: [], + outputSignals: [], + activeFileWrites: [], + recentFileEvents: [], + hasSignals: false, + }, + mappedTheme: "general", + activeRuntimeStatusTitle: null, + chatToolPreferences: { + task: false, + subagent: false, + }, + defaultCuratedTaskReferenceMemoryIds: [], + defaultCuratedTaskReferenceEntries: [], + pathReferences: [], + onAddPathReferences: noop, + onRemovePathReference: noop, + onClearPathReferences: noop, + fileManagerOpen: false, + onToggleFileManager: noop, + inputCompletionEnabled: true, + ...hookOverrides, + }; +} diff --git a/src/components/agent/chat/workspace/taskCenterComposerDraftSend.integration.test.tsx b/src/components/agent/chat/workspace/taskCenterComposerDraftSend.integration.test.tsx new file mode 100644 index 000000000..6935c8d25 --- /dev/null +++ b/src/components/agent/chat/workspace/taskCenterComposerDraftSend.integration.test.tsx @@ -0,0 +1,352 @@ +import React, { act, useRef, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { decodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { + a, + b, + local, + accepted, + queueAccepted, + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import type { ComposerDraftCapture } from "@/components/input-kit/ComposerDraftCoordinator"; +import type { AcceptedRuntimeSubmissionResult } from "../hooks/agentSendResult"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; +import { createComposerSubmissionSettlement } from "../components/composerSubmissionSettlement"; +import type { InputbarSendHandler } from "../components/Inputbar/inputbarSendPayload"; +import type { + TaskCenterDraftSendRequest, + TaskCenterSessionReadyOptions, +} from "../homePendingPreview"; +import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; +import { + useTaskCenterEmptyStateSendRuntime, + useTaskCenterDraftSendDispatchRuntime, +} from "./useTaskCenterDraftSendRuntime"; +import { isTaskCenterSendSourceCurrent } from "./useWorkspaceTaskCenterSendRuntime"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("@/lib/agentUiPerformanceMetrics", () => ({ + recordAgentUiPerformanceMetric: vi.fn(), +})); +vi.mock("./agentChatWorkspaceHelpers", async (original) => ({ + ...(await original()), + scheduleAfterCommit: (callback: () => void) => + Promise.resolve().then(callback), + scheduleAfterNextPaint: (callback: () => void) => + Promise.resolve().then(callback), +})); + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); +const roots: Array<{ root: Root; container: HTMLDivElement }> = []; +afterEach(() => { + for (const { root, container } of roots.splice(0)) { + act(() => root.unmount()); + container.remove(); + } +}); + +async function mountSend(kind: "home" | "local", structured = true) { + const h = await setup(); + const source: DraftScope = + kind === "home" + ? { ...a, kind: "home", id: "new-conversation" } + : { ...a, kind: "local", id: "existing" }; + await h.coordinator.bindCommitted(source); + h.controller.setText("original rich draft"); + h.controller.ingestPaste("折叠粘贴".repeat(300)); + const ui = { + sessionId: (kind === "local" ? "existing" : null) as string | null, + workspaceId: a.workspaceId, + navigationEpoch: 7, + newChatAt: 100, + }; + const isSendSourceCurrent = ( + options: TaskCenterSessionReadyOptions, + sessionId?: string, + ) => + isTaskCenterSendSourceCurrent( + options, + { ...ui, composerDraftStatus: h.coordinator.getSnapshot() }, + sessionId, + ); + const onReady = vi.fn(); + const onCommitted = vi.fn(); + const onDraftAccepted = vi.fn( + async ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => { + if (!capture.binding) throw new Error("missing original binding"); + await h.coordinator.promote(capture.binding, local, { + expectedTarget: null, + sessionId: result.receipt.sessionId, + threadId: result.receipt.threadId, + }); + }, + ); + const settlement = createComposerSubmissionSettlement({ + controller: h.controller, + coordinator: h.coordinator, + target: "start", + scope: { projectId: "w", sessionId: null }, + canApplyUi: () => ui.navigationEpoch === 7, + onDraftAccepted, + onCommitted, + }); + const backend = deferred(); + const handleSend = vi.fn(() => backend.promise); + const restoreInput = vi.fn(); + const setInput = vi.fn(); + let send!: InputbarSendHandler; + let requestSnapshot: TaskCenterDraftSendRequest | null = null; + function Harness() { + const [request, setRequest] = useState( + null, + ); + const [, setPreview] = useState(null); + requestSnapshot = request; + const sendRef = useRef(handleSend); + send = useTaskCenterEmptyStateSendRuntime({ + agentEntry: "new-task", + input: "stale", + setInput, + activeSessionIdRef: { current: ui.sessionId }, + getComposerDraftNavigationEpoch: () => ui.navigationEpoch, + newChatAt: ui.newChatAt, + isSendSourceCurrent, + clearMessages: vi.fn(), + displayMessagesLength: 0, + turnsLength: 0, + threadItemsLength: 0, + hasDisplayMessages: false, + handleSend, + sessionId: ui.sessionId, + taskCenterWorkspaceId: ui.workspaceId, + setTaskCenterDraftSendRequest: setRequest, + taskCenterDraftSendRequest: request, + setHomePendingPreviewRequest: setPreview, + onSessionAccepted: onReady, + }); + useTaskCenterDraftSendDispatchRuntime({ + taskCenterDraftSendRequest: request, + setTaskCenterDraftSendRequest: setRequest, + setHomePendingPreviewRequest: setPreview, + messagesLength: 0, + currentSessionId: ui.sessionId, + restoreInput, + sendRef, + workspaceId: ui.workspaceId, + isSendSourceCurrent, + onSessionAccepted: onReady, + }); + return null; + } + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push({ root, container }); + act(() => root.render()); + let completion!: Promise; + await act(async () => { + const receipt = settlement.receipt!; + completion = Promise.resolve( + send({ + textOverride: receipt.submission.text, + composerDraft: structured ? receipt.draft : undefined, + composerSubmission: receipt.submission, + composerIntent: receipt.intent, + composerTarget: receipt.target, + composerDraftCapture: settlement.composerDraftCapture, + composerRestoreToken: settlement.composerRestoreToken, + onRuntimeAccepted: settlement.onRuntimeAccepted, + }), + ).then(settlement.finish, settlement.reject); + await Promise.resolve(); + }); + return { + h, + ui, + source, + settlement, + backend, + completion, + handleSend, + setInput, + restoreInput, + onReady, + onDraftAccepted, + onCommitted, + request: () => requestSnapshot, + unmount() { + act(() => root.unmount()); + container.remove(); + roots.splice( + roots.findIndex((entry) => entry.root === root), + 1, + ); + }, + async navigateAway() { + ui.navigationEpoch += 1; + await h.coordinator.bindCommitted(b); + h.controller.setText("new unsent draft"); + }, + async accept(result: RuntimeSubmissionResult = accepted()) { + await act(async () => { + backend.resolve(result); + await completion; + await settlement.cleanup; + await h.coordinator.idle(); + }); + }, + }; +} + +describe("TaskCenter captured rich draft through real persistence owners", () => { + it.each(["home", "local"] as const)( + "%s late acceptance consumes the old source without navigating or clearing a new draft", + async (kind) => { + const r = await mountSend(kind); + expect(r.request()?.composerDraftCapture).toBe( + r.settlement.composerDraftCapture, + ); + expect(r.handleSend.mock.calls[0][6]?.composerRestoreToken).toBe( + r.settlement.composerRestoreToken, + ); + expect(r.handleSend.mock.calls[0][6]).not.toHaveProperty( + "composerDraftCapture", + ); + await r.navigateAway(); + await r.accept(); + expect(await r.completion).toMatchObject({ kind: "runtime-accepted" }); + expect(r.onDraftAccepted).toHaveBeenCalledOnce(); + expect(r.onDraftAccepted.mock.calls[0][0]).toBe( + r.settlement.composerDraftCapture, + ); + expect(r.onReady).not.toHaveBeenCalled(); + expect(r.onCommitted).not.toHaveBeenCalled(); + expect(r.h.controller.getDocument().text).toBe("new unsent draft"); + expect(await r.h.read(local)).toBeNull(); + expect(await r.h.read(r.source)).toBeNull(); + expect( + r.h.mutations.filter((mutation) => mutation.op === "consume"), + ).toEqual([ + expect.objectContaining({ + identity: r.settlement.composerDraftCapture!.binding!.identity, + }), + ]); + const newEntry = await r.h.read(b); + if (newEntry?.state !== "draft") throw new Error("new draft was lost"); + expect(decodeComposerDraft(newEntry.draft).document.text).toBe( + "new unsent draft", + ); + expect(r.restoreInput).not.toHaveBeenCalled(); + expect(r.setInput).not.toHaveBeenCalled(); + }, + ); + + it.each(["home", "local"] as const)( + "%s hook unmount preserves accepted source cleanup", + async (kind) => { + const r = await mountSend(kind); + r.unmount(); + await r.accept(); + expect(await r.h.read(local)).toBeNull(); + expect(await r.h.read(r.source)).toBeNull(); + expect( + r.h.mutations.filter((mutation) => mutation.op === "consume"), + ).toEqual([ + expect.objectContaining({ + identity: r.settlement.composerDraftCapture!.binding!.identity, + }), + ]); + expect(r.onReady).not.toHaveBeenCalled(); + expect(r.onDraftAccepted).toHaveBeenCalledOnce(); + }, + ); + + it.each(["home", "local"] as const)( + "%s late rejection cannot restore old plain text into the new surface", + async (kind) => { + const r = await mountSend(kind, false); + expect(r.setInput).toHaveBeenCalledExactlyOnceWith(""); + await r.navigateAway(); + r.setInput.mockClear(); + await r.accept({ kind: "rejected" }); + expect(r.setInput).not.toHaveBeenCalled(); + expect(r.onReady).not.toHaveBeenCalled(); + expect(r.onDraftAccepted).not.toHaveBeenCalled(); + expect(r.h.controller.getDocument().text).toBe("new unsent draft"); + expect((await r.h.read(r.source))?.state).toBe("draft"); + expect(r.h.mutations.some((mutation) => mutation.op === "consume")).toBe( + false, + ); + }, + ); + + it("queue acceptance uses the same original capture and consumes only once", async () => { + const r = await mountSend("home"); + await r.navigateAway(); + await r.accept(queueAccepted()); + expect(await r.completion).toMatchObject({ kind: "queue-accepted" }); + expect(r.onDraftAccepted).toHaveBeenCalledOnce(); + expect(r.onDraftAccepted.mock.calls[0][0]).toBe( + r.settlement.composerDraftCapture, + ); + expect(await r.h.read(local)).toBeNull(); + const mutations = r.h.mutations; + const promoteIndex = mutations.findIndex( + (mutation) => mutation.op === "promote", + ); + const consumeIndex = mutations.findIndex( + (mutation) => mutation.op === "consume", + ); + expect(promoteIndex).toBeGreaterThan(-1); + expect(consumeIndex).toBeGreaterThan(promoteIndex); + expect(mutations.filter((mutation) => mutation.op === "consume")).toEqual([ + { + op: "consume", + identity: r.settlement.composerDraftCapture!.binding!.identity, + }, + ]); + expect(r.onReady).not.toHaveBeenCalled(); + expect(r.h.controller.getDocument().text).toBe("new unsent draft"); + }); + + it("same-scope newer draft version keeps navigation and the new document after old acceptance", async () => { + const r = await mountSend("home"); + const generation = r.h.coordinator.getSnapshot().generation; + r.h.controller.setText("new same-scope draft"); + expect(r.h.coordinator.getSnapshot().generation).toBe(generation); + expect(r.h.coordinator.getSnapshot().identity).not.toEqual( + r.settlement.composerDraftCapture!.binding!.identity, + ); + await r.accept(); + expect(await r.completion).toMatchObject({ kind: "runtime-accepted" }); + expect(r.onDraftAccepted).toHaveBeenCalledOnce(); + expect(r.onReady).not.toHaveBeenCalled(); + expect(r.h.controller.getDocument().text).toBe("new same-scope draft"); + // Pending edits on the same lane follow a successful captured promotion. + const entry = await r.h.read(local); + if (entry?.state !== "draft") + throw new Error("new same-scope draft was lost"); + expect(entry.scopeVersion).not.toBe( + r.settlement.composerDraftCapture!.binding!.identity.scopeVersion, + ); + expect(decodeComposerDraft(entry.draft).document.text).toBe( + "new same-scope draft", + ); + }); +}); diff --git a/src/components/agent/chat/workspace/taskCenterDraftTabs.ts b/src/components/agent/chat/workspace/taskCenterDraftTabs.ts deleted file mode 100644 index e15c5860e..000000000 --- a/src/components/agent/chat/workspace/taskCenterDraftTabs.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { MAX_TASK_CENTER_OPEN_TABS } from "../utils/taskCenterTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; - -export type TaskCenterDraftClosePlan = - | { - action: "remove"; - remainingDraftTabs: TaskCenterDraftTab[]; - } - | { - action: "selectDraft"; - fallbackDraftTabId: string; - remainingDraftTabs: TaskCenterDraftTab[]; - } - | { - action: "switchTopic"; - fallbackTopicId: string; - remainingDraftTabs: TaskCenterDraftTab[]; - } - | { - action: "clearActiveDraft"; - remainingDraftTabs: TaskCenterDraftTab[]; - }; - -export function buildTaskCenterDraftTab(params: { - id: string; - now?: Date; - title?: string; -}): TaskCenterDraftTab { - const now = params.now ?? new Date(); - return { - id: params.id, - title: params.title ?? "新对话", - createdAt: now, - updatedAt: now, - status: "draft", - }; -} - -export function upsertTaskCenterDraftTab( - current: TaskCenterDraftTab[], - draftTab: TaskCenterDraftTab, - maxCount = MAX_TASK_CENTER_OPEN_TABS, -): TaskCenterDraftTab[] { - const existingIndex = current.findIndex((item) => item.id === draftTab.id); - if (existingIndex >= 0) { - return current - .map((item, index) => (index === existingIndex ? draftTab : item)) - .slice(0, maxCount); - } - - return [...current, draftTab].slice(-maxCount); -} - -export function removeTaskCenterDraftTab( - current: TaskCenterDraftTab[], - draftTabId: string, -): TaskCenterDraftTab[] { - const next = current.filter((tab) => tab.id !== draftTabId); - return next.length === current.length ? current : next; -} - -export function clearActiveTaskCenterDraftTab( - currentDraftTabId: string | null, - draftTabId: string, -): string | null { - return currentDraftTabId === draftTabId ? null : currentDraftTabId; -} - -export function markTaskCenterDraftTabFailed( - current: TaskCenterDraftTab[], - draftTabId: string, - updatedAt = new Date(), -): TaskCenterDraftTab[] { - let changed = false; - const next = current.map((tab) => { - if (tab.id !== draftTabId) { - return tab; - } - - changed = true; - return { - ...tab, - status: "failed" as const, - updatedAt, - }; - }); - - return changed ? next : current; -} - -export function markTaskCenterDraftTabRunning(params: { - current: TaskCenterDraftTab[]; - draftTabId: string; - title: string; - updatedAt?: Date; -}): TaskCenterDraftTab[] { - const updatedAt = params.updatedAt ?? new Date(); - let changed = false; - const next = params.current.map((tab) => { - if (tab.id !== params.draftTabId) { - return tab; - } - - changed = true; - return { - ...tab, - title: params.title, - status: "running" as const, - updatedAt, - }; - }); - - return changed ? next : params.current; -} - -export function resolveActiveTaskCenterDraftTabId(params: { - draftTabs: TaskCenterDraftTab[]; - activeDraftTabId?: string | null; -}): string | null { - const activeDraftTabId = params.activeDraftTabId ?? null; - return params.draftTabs.some((tab) => tab.id === activeDraftTabId) - ? activeDraftTabId - : null; -} - -export function shouldWarmupTaskCenterDraftSession(params: { - agentEntry: string; - activeDraftTabId?: string | null; - draftTabs: TaskCenterDraftTab[]; - input?: string; - isPreparingSend: boolean; - isSending: boolean; -}): boolean { - return Boolean( - params.agentEntry === "claw" && - resolveActiveTaskCenterDraftTabId({ - draftTabs: params.draftTabs, - activeDraftTabId: params.activeDraftTabId, - }) && - Boolean(params.input?.trim()) && - !params.isPreparingSend && - !params.isSending, - ); -} - -export function resolveTaskCenterDraftClosePlan(params: { - closingDraftTabId: string; - currentDraftTabs: TaskCenterDraftTab[]; - activeDraftTabId?: string | null; - openTopicIds: string[]; -}): TaskCenterDraftClosePlan { - const remainingDraftTabs = removeTaskCenterDraftTab( - params.currentDraftTabs, - params.closingDraftTabId, - ); - if (params.activeDraftTabId !== params.closingDraftTabId) { - return { - action: "remove", - remainingDraftTabs, - }; - } - - const fallbackDraftTabId = remainingDraftTabs[0]?.id ?? null; - if (fallbackDraftTabId) { - return { - action: "selectDraft", - fallbackDraftTabId, - remainingDraftTabs, - }; - } - - const fallbackTopicId = params.openTopicIds[0] ?? null; - if (fallbackTopicId) { - return { - action: "switchTopic", - fallbackTopicId, - remainingDraftTabs, - }; - } - - return { - action: "clearActiveDraft", - remainingDraftTabs, - }; -} diff --git a/src/components/agent/chat/workspace/taskCenterDraftTabs.unit.test.ts b/src/components/agent/chat/workspace/taskCenterDraftTabs.unit.test.ts deleted file mode 100644 index c5cce416c..000000000 --- a/src/components/agent/chat/workspace/taskCenterDraftTabs.unit.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; -import { - buildTaskCenterDraftTab, - clearActiveTaskCenterDraftTab, - markTaskCenterDraftTabFailed, - markTaskCenterDraftTabRunning, - removeTaskCenterDraftTab, - resolveActiveTaskCenterDraftTabId, - resolveTaskCenterDraftClosePlan, - shouldWarmupTaskCenterDraftSession, - upsertTaskCenterDraftTab, -} from "./taskCenterDraftTabs"; - -function createDraft(id: string, overrides?: Partial) { - const createdAt = new Date("2026-04-20T00:00:00.000Z"); - return { - id, - title: "新对话", - createdAt, - updatedAt: createdAt, - status: "draft", - ...overrides, - } satisfies TaskCenterDraftTab; -} - -describe("taskCenterDraftTabs", () => { - it("应构建并稳定 upsert 草稿标签,同时控制最大数量", () => { - const now = new Date("2026-04-20T01:00:00.000Z"); - const draft = buildTaskCenterDraftTab({ - id: "task-draft-new", - now, - }); - - expect(draft).toEqual({ - id: "task-draft-new", - title: "新对话", - createdAt: now, - updatedAt: now, - status: "draft", - }); - - expect( - upsertTaskCenterDraftTab( - [createDraft("task-draft-a"), createDraft("task-draft-new")], - draft, - 2, - ).map((item) => item.id), - ).toEqual(["task-draft-a", "task-draft-new"]); - - expect( - upsertTaskCenterDraftTab( - [createDraft("task-draft-a"), createDraft("task-draft-b")], - draft, - 2, - ).map((item) => item.id), - ).toEqual(["task-draft-b", "task-draft-new"]); - }); - - it("应把提交中的草稿标记为 running,并在创建失败时标记 failed", () => { - const draft = createDraft("task-draft-a"); - const runningAt = new Date("2026-04-20T02:00:00.000Z"); - const failedAt = new Date("2026-04-20T03:00:00.000Z"); - - const running = markTaskCenterDraftTabRunning({ - current: [draft], - draftTabId: "task-draft-a", - title: "生成封面图", - updatedAt: runningAt, - }); - expect(running[0]).toMatchObject({ - id: "task-draft-a", - title: "生成封面图", - status: "running", - updatedAt: runningAt, - }); - - const failed = markTaskCenterDraftTabFailed( - running, - "task-draft-a", - failedAt, - ); - expect(failed[0]).toMatchObject({ - id: "task-draft-a", - status: "failed", - updatedAt: failedAt, - }); - expect(markTaskCenterDraftTabFailed(failed, "missing")).toBe(failed); - }); - - it("应解析当前有效草稿与预热创建条件", () => { - const draftTabs = [createDraft("task-draft-a")]; - - expect( - resolveActiveTaskCenterDraftTabId({ - draftTabs, - activeDraftTabId: "task-draft-a", - }), - ).toBe("task-draft-a"); - expect( - resolveActiveTaskCenterDraftTabId({ - draftTabs, - activeDraftTabId: "missing", - }), - ).toBeNull(); - - expect( - shouldWarmupTaskCenterDraftSession({ - agentEntry: "claw", - activeDraftTabId: "task-draft-a", - draftTabs, - input: " 你好 ", - isPreparingSend: false, - isSending: false, - }), - ).toBe(true); - expect( - shouldWarmupTaskCenterDraftSession({ - agentEntry: "claw", - activeDraftTabId: "task-draft-a", - draftTabs, - input: "你好", - isPreparingSend: true, - isSending: false, - }), - ).toBe(false); - expect( - shouldWarmupTaskCenterDraftSession({ - agentEntry: "new-task", - activeDraftTabId: "task-draft-a", - draftTabs, - input: "你好", - isPreparingSend: false, - isSending: false, - }), - ).toBe(false); - expect( - shouldWarmupTaskCenterDraftSession({ - agentEntry: "claw", - activeDraftTabId: "task-draft-a", - draftTabs, - input: " ", - isPreparingSend: false, - isSending: false, - }), - ).toBe(false); - expect( - shouldWarmupTaskCenterDraftSession({ - agentEntry: "claw", - activeDraftTabId: "task-draft-a", - draftTabs, - input: "你好", - isPreparingSend: false, - isSending: true, - }), - ).toBe(false); - }); - - it("关闭草稿时应优先切换剩余草稿,其次回退到打开的任务", () => { - const draftA = createDraft("task-draft-a"); - const draftB = createDraft("task-draft-b"); - - expect( - resolveTaskCenterDraftClosePlan({ - closingDraftTabId: "task-draft-a", - currentDraftTabs: [draftA, draftB], - activeDraftTabId: "task-draft-a", - openTopicIds: ["topic-a"], - }), - ).toEqual({ - action: "selectDraft", - fallbackDraftTabId: "task-draft-b", - remainingDraftTabs: [draftB], - }); - - expect( - resolveTaskCenterDraftClosePlan({ - closingDraftTabId: "task-draft-a", - currentDraftTabs: [draftA], - activeDraftTabId: "task-draft-a", - openTopicIds: ["topic-a"], - }), - ).toEqual({ - action: "switchTopic", - fallbackTopicId: "topic-a", - remainingDraftTabs: [], - }); - - expect( - resolveTaskCenterDraftClosePlan({ - closingDraftTabId: "task-draft-a", - currentDraftTabs: [draftA], - activeDraftTabId: "task-draft-a", - openTopicIds: [], - }), - ).toEqual({ - action: "clearActiveDraft", - remainingDraftTabs: [], - }); - }); - - it("删除非活跃草稿时不应改变当前活跃草稿", () => { - const draftA = createDraft("task-draft-a"); - const draftB = createDraft("task-draft-b"); - - expect(removeTaskCenterDraftTab([draftA, draftB], "task-draft-b")).toEqual([ - draftA, - ]); - expect( - clearActiveTaskCenterDraftTab("task-draft-a", "task-draft-b"), - ).toBe("task-draft-a"); - expect( - resolveTaskCenterDraftClosePlan({ - closingDraftTabId: "task-draft-b", - currentDraftTabs: [draftA, draftB], - activeDraftTabId: "task-draft-a", - openTopicIds: ["topic-a"], - }), - ).toEqual({ - action: "remove", - remainingDraftTabs: [draftA], - }); - }); -}); diff --git a/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.test.ts b/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.test.ts new file mode 100644 index 000000000..8b1ab66a2 --- /dev/null +++ b/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTaskCenterSubmissionSettlement } from "./taskCenterSubmissionSettlement"; +const receipt = { + kind: "start" as const, + sessionId: "request-session", + threadId: "request-thread", + turnId: "request-turn", +}; +describe("TaskCenter request-scoped settlement", () => { + it("in-flight observer survives preview disposal and settles before backend wrapper completes", async () => { + const accepted = vi.fn(); + const settlement = createTaskCenterSubmissionSettlement({ + onRuntimeAccepted: accepted, + }); + const sendOptions = { observer: settlement.observer }; + let activeSession = "request-session"; + const requestSession = activeSession; + activeSession = "unrelated-late-session"; + sendOptions.observer.onRuntimeAccepted(receipt); + expect(await settlement.completion).toMatchObject({ + kind: "runtime-accepted", + receipt: { sessionId: requestSession }, + }); + expect(activeSession).toBe("unrelated-late-session"); + expect(accepted).toHaveBeenCalledOnce(); + expect(settlement.reject(new Error("steer refresh failed"))).toEqual({ + kind: "runtime-accepted", + receipt, + }); + }); + it("settles only once despite duplicate final result and throwing existing observer", async () => { + const observer = vi.fn(() => { + throw new Error("observer failed"); + }); + const callback = vi.fn(); + const settlement = createTaskCenterSubmissionSettlement( + { onRuntimeAccepted: observer }, + callback, + ); + settlement.observer.onRuntimeAccepted(receipt); + settlement.settle({ kind: "runtime-accepted", receipt }); + expect(await settlement.completion).toEqual({ + kind: "runtime-accepted", + receipt, + }); + expect(observer).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledOnce(); + }); + it("undispatched cancellation and genuine rejection remain nonaccepted", async () => { + const canceled = createTaskCenterSubmissionSettlement(); + canceled.settle({ kind: "rejected" }); + expect(await canceled.completion).toEqual({ kind: "rejected" }); + const failed = createTaskCenterSubmissionSettlement(); + const error = new Error("request rejected"); + failed.reject(error); + expect(await failed.completion).toEqual({ kind: "rejected", error }); + }); +}); diff --git a/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.ts b/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.ts new file mode 100644 index 000000000..0bbd47548 --- /dev/null +++ b/src/components/agent/chat/workspace/taskCenterSubmissionSettlement.ts @@ -0,0 +1,65 @@ +import type { RuntimeAcceptancePostProcessingPhase } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import type { SendMessageObserver } from "../hooks/agentChatShared"; +import { + createRuntimeSubmissionCapture, + isRuntimeSubmissionAccepted, + runRuntimeSubmissionEffect, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; + +export function createTaskCenterSubmissionSettlement( + observer?: SendMessageObserver, + onSettled?: (result: RuntimeSubmissionResult) => void, +) { + let settled = false; + let resolve!: (result: RuntimeSubmissionResult) => void; + const completion = new Promise((done) => { + resolve = done; + }); + const settleOnce = (result: RuntimeSubmissionResult) => { + if (!settled) { + settled = true; + resolve(result); + if (isRuntimeSubmissionAccepted(result)) + runRuntimeSubmissionEffect(result.receipt, "observer", () => + onSettled?.(result), + ); + else { + try { + onSettled?.(result); + } catch { + console.error("[TaskCenter] submission settlement callback failed"); + } + } + } + }; + const capture = createRuntimeSubmissionCapture(observer, settleOnce); + return { + completion, + observer: capture.observer, + effect(phase: RuntimeAcceptancePostProcessingPhase, effect: () => void) { + if (capture.accepted) { + runRuntimeSubmissionEffect(capture.accepted.receipt, phase, effect); + } else { + try { + effect(); + } catch { + console.error("[TaskCenter] submission post-processing failed"); + } + } + }, + get accepted() { + return capture.accepted; + }, + settle(result: RuntimeSubmissionResult) { + const finalResult = capture.finish(result); + settleOnce(finalResult); + return finalResult; + }, + reject(error: unknown) { + const result = capture.reject(error); + settleOnce(result); + return result; + }, + }; +} diff --git a/src/components/agent/chat/workspace/taskCenterSurfaceState.ts b/src/components/agent/chat/workspace/taskCenterSurfaceState.ts index 5ae37e006..74b52146c 100644 --- a/src/components/agent/chat/workspace/taskCenterSurfaceState.ts +++ b/src/components/agent/chat/workspace/taskCenterSurfaceState.ts @@ -3,15 +3,12 @@ import { isTaskCenterDraftSendPendingForLayout, resolveTaskCenterHomeSurfaceState, shouldSuppressTaskCenterDraftContentForLayout, - type TaskCenterDraftTab, type TaskCenterHomeSurfaceState, } from "./agentChatWorkspaceHelpers"; interface ResolveTaskCenterDraftSurfaceStateParams { agentEntry: string; isTaskCenterEntry: boolean; - activeDraftTabId: string | null; - draftTabs: readonly TaskCenterDraftTab[]; draftSurfaceActive: boolean; initialSessionId?: string | null; sessionId?: string | null; @@ -27,8 +24,6 @@ interface ResolveTaskCenterDraftSurfaceStateParams { } export interface TaskCenterDraftSurfaceState { - activeTaskCenterDraftTab: TaskCenterDraftTab | null; - isTaskCenterDraftTabActive: boolean; isTaskCenterDraftSurfaceActive: boolean; isTaskCenterDraftSendInFlight: boolean; hasVisibleSessionActivityForDraftSurface: boolean; @@ -55,15 +50,14 @@ export function shouldPrioritizeTaskCenterInitialSessionRoute({ const normalizedSessionId = sessionId?.trim() || null; return Boolean( agentEntry === "claw" && - normalizedInitialSessionId && - normalizedInitialSessionId !== normalizedSessionId, + normalizedInitialSessionId && + normalizedInitialSessionId !== normalizedSessionId, ); } interface ResolveTaskCenterHomeChromeStateParams { agentEntry: string; draftSurfaceActive: boolean; - draftTabActive: boolean; shouldSuppressDraftContent: boolean; draftSendRequest: TaskCenterDraftSendRequest | null; sessionSwitchPending: boolean; @@ -97,8 +91,6 @@ export interface TaskCenterHomeChromeState { export function resolveTaskCenterDraftSurfaceState({ agentEntry, isTaskCenterEntry, - activeDraftTabId, - draftTabs, draftSurfaceActive, initialSessionId, sessionId, @@ -112,49 +104,27 @@ export function resolveTaskCenterDraftSurfaceState({ isSending, queuedTurnCount, }: ResolveTaskCenterDraftSurfaceStateParams): TaskCenterDraftSurfaceState { - const activeTaskCenterDraftTab = activeDraftTabId - ? (draftTabs.find((tab) => tab.id === activeDraftTabId) ?? null) - : null; - const hasExplicitLocalDraftTakeover = Boolean( - activeTaskCenterDraftTab && - draftSurfaceActive && - !(sessionId?.trim() || null), - ); const shouldPrioritizeInitialSessionRoute = - !hasExplicitLocalDraftTakeover && shouldPrioritizeTaskCenterInitialSessionRoute({ agentEntry, initialSessionId, sessionId, }); - const isTaskCenterDraftTabActive = Boolean( + const isTaskCenterDraftSurfaceActive = isTaskCenterEntry && - activeTaskCenterDraftTab && - !shouldPrioritizeInitialSessionRoute, - ); - const isTaskCenterDraftSurfaceActive = Boolean( - isTaskCenterEntry && - !shouldPrioritizeInitialSessionRoute && - (activeTaskCenterDraftTab || draftSurfaceActive), - ); - const isTaskCenterDraftSendInFlight = Boolean( - (agentEntry === "claw" || agentEntry === "new-task") && - draftSendRequest?.materializeDraft && - (activeTaskCenterDraftTab - ? draftSendRequest.draftTabId === activeTaskCenterDraftTab.id - : draftSurfaceActive), - ); + draftSurfaceActive && + !shouldPrioritizeInitialSessionRoute; + const isTaskCenterDraftSendInFlight = Boolean(draftSendRequest); const hasVisibleSessionActivityForDraftSurface = Boolean(draftSendRequest) || hasHomePendingPreview || isPreparingSend || isSending || - (!isTaskCenterDraftTabActive && - (displayMessageCount > 0 || - threadItemCount > 0 || - hasLocalSessionOverride || - hasPendingA2UIForm || - queuedTurnCount > 0)); + displayMessageCount > 0 || + threadItemCount > 0 || + hasLocalSessionOverride || + hasPendingA2UIForm || + queuedTurnCount > 0; const shouldSuppressTaskCenterDraftContent = shouldSuppressTaskCenterDraftContentForLayout({ draftSurfaceActive: isTaskCenterDraftSurfaceActive, @@ -163,8 +133,6 @@ export function resolveTaskCenterDraftSurfaceState({ }); return { - activeTaskCenterDraftTab, - isTaskCenterDraftTabActive, isTaskCenterDraftSurfaceActive, isTaskCenterDraftSendInFlight, hasVisibleSessionActivityForDraftSurface, @@ -175,7 +143,6 @@ export function resolveTaskCenterDraftSurfaceState({ export function resolveTaskCenterHomeChromeState({ agentEntry, draftSurfaceActive, - draftTabActive, shouldSuppressDraftContent, draftSendRequest, sessionSwitchPending, @@ -200,7 +167,6 @@ export function resolveTaskCenterHomeChromeState({ hasLocalSessionOverride && Boolean(sessionId) && !draftSurfaceActive && - !draftTabActive && !shouldSuppressDraftContent; const isTaskCenterDraftSendPending = isTaskCenterDraftSendPendingForLayout({ hasDraftSendRequest: Boolean(draftSendRequest), @@ -215,7 +181,6 @@ export function resolveTaskCenterHomeChromeState({ !isTaskCenterDraftSendPending; const hasCurrentSessionActivity = !shouldTreatCurrentSessionAsBackground && - !draftTabActive && (displayMessageCount > 0 || threadItemCount > 0 || Boolean(draftSendRequest) || diff --git a/src/components/agent/chat/workspace/taskCenterSurfaceState.unit.test.ts b/src/components/agent/chat/workspace/taskCenterSurfaceState.unit.test.ts index 6713804cc..a4fa895ed 100644 --- a/src/components/agent/chat/workspace/taskCenterSurfaceState.unit.test.ts +++ b/src/components/agent/chat/workspace/taskCenterSurfaceState.unit.test.ts @@ -1,36 +1,20 @@ import { describe, expect, it } from "vitest"; import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import { hasTaskCenterPendingPreviewActivity, resolveTaskCenterDraftSurfaceState, resolveTaskCenterHomeChromeState, } from "./taskCenterSurfaceState"; -function createDraftTab( - id: string, - overrides: Partial = {}, -): TaskCenterDraftTab { - const now = new Date("2026-06-22T00:00:00.000Z"); - return { - id, - title: overrides.title ?? "新任务", - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, - status: overrides.status ?? "draft", - }; -} - function createDraftSendRequest( overrides: Partial = {}, ): TaskCenterDraftSendRequest { return { id: overrides.id ?? "request-1", - draftTabId: overrides.draftTabId ?? "draft-1", + sessionId: overrides.sessionId ?? null, text: overrides.text ?? "整理今天的国际新闻", images: overrides.images ?? [], submittedAt: overrides.submittedAt ?? Date.parse("2026-06-22T00:00:00Z"), - materializeDraft: overrides.materializeDraft ?? true, dispatchState: overrides.dispatchState, source: overrides.source ?? "task-center-empty-state", sendExecutionStrategy: overrides.sendExecutionStrategy, @@ -47,13 +31,10 @@ describe("hasTaskCenterPendingPreviewActivity", () => { describe("resolveTaskCenterDraftSurfaceState", () => { it("激活草稿页且尚未发送时应压制旧会话内容", () => { - const draftTab = createDraftTab("draft-1"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, + draftSurfaceActive: true, draftSendRequest: null, displayMessageCount: 0, threadItemCount: 0, @@ -62,23 +43,19 @@ describe("resolveTaskCenterDraftSurfaceState", () => { isSending: false, queuedTurnCount: 0, }); - - expect(state.activeTaskCenterDraftTab?.id).toBe("draft-1"); - expect(state.isTaskCenterDraftTabActive).toBe(true); expect(state.isTaskCenterDraftSurfaceActive).toBe(true); expect(state.isTaskCenterDraftSendInFlight).toBe(false); expect(state.shouldSuppressTaskCenterDraftContent).toBe(true); }); it("草稿发送中不应被首页空态压制", () => { - const draftTab = createDraftTab("draft-1"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, - draftSendRequest: createDraftSendRequest({ draftTabId: draftTab.id }), + draftSurfaceActive: true, + draftSendRequest: createDraftSendRequest({ + sessionId: "accepted-session", + }), displayMessageCount: 0, threadItemCount: 0, hasPendingA2UIForm: false, @@ -92,16 +69,12 @@ describe("resolveTaskCenterDraftSurfaceState", () => { }); it("激活草稿的 direct-dispatch pending send 应立即解除首页压制", () => { - const draftTab = createDraftTab("draft-1"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, + draftSurfaceActive: true, draftSendRequest: createDraftSendRequest({ - draftTabId: "draft-send-request-1", - materializeDraft: false, + sessionId: "draft-send-request-1", dispatchState: "dispatched", }), displayMessageCount: 0, @@ -112,20 +85,17 @@ describe("resolveTaskCenterDraftSurfaceState", () => { queuedTurnCount: 0, }); - expect(state.isTaskCenterDraftTabActive).toBe(true); - expect(state.isTaskCenterDraftSendInFlight).toBe(false); + expect(state.isTaskCenterDraftSurfaceActive).toBe(true); + expect(state.isTaskCenterDraftSendInFlight).toBe(true); expect(state.hasVisibleSessionActivityForDraftSurface).toBe(true); expect(state.shouldSuppressTaskCenterDraftContent).toBe(false); }); it("direct-dispatch tracking 清除后仍有 pending preview 时不应闪回首页", () => { - const draftTab = createDraftTab("draft-1"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, + draftSurfaceActive: true, draftSendRequest: null, hasHomePendingPreview: true, displayMessageCount: 0, @@ -136,19 +106,16 @@ describe("resolveTaskCenterDraftSurfaceState", () => { queuedTurnCount: 0, }); - expect(state.isTaskCenterDraftTabActive).toBe(true); + expect(state.isTaskCenterDraftSurfaceActive).toBe(true); expect(state.hasVisibleSessionActivityForDraftSurface).toBe(true); expect(state.shouldSuppressTaskCenterDraftContent).toBe(false); }); it("新 session 接管时 request 和 preview 已清但仍在发送不应闪回首页", () => { - const draftTab = createDraftTab("draft-1"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, + draftSurfaceActive: true, draftSendRequest: null, hasHomePendingPreview: false, displayMessageCount: 0, @@ -159,20 +126,19 @@ describe("resolveTaskCenterDraftSurfaceState", () => { queuedTurnCount: 0, }); - expect(state.isTaskCenterDraftTabActive).toBe(true); + expect(state.isTaskCenterDraftSurfaceActive).toBe(true); expect(state.hasVisibleSessionActivityForDraftSurface).toBe(true); expect(state.shouldSuppressTaskCenterDraftContent).toBe(false); }); it("new-task 草稿发送中也不应被首页空态压制", () => { - const draftTab = createDraftTab("draft-new-task"); const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "new-task", isTaskCenterEntry: true, - activeDraftTabId: draftTab.id, - draftTabs: [draftTab], - draftSurfaceActive: false, - draftSendRequest: createDraftSendRequest({ draftTabId: draftTab.id }), + draftSurfaceActive: true, + draftSendRequest: createDraftSendRequest({ + sessionId: "accepted-session", + }), displayMessageCount: 0, threadItemCount: 0, hasPendingA2UIForm: false, @@ -189,8 +155,6 @@ describe("resolveTaskCenterDraftSurfaceState", () => { const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: null, - draftTabs: [], draftSurfaceActive: true, draftSendRequest: null, displayMessageCount: 1, @@ -206,12 +170,10 @@ describe("resolveTaskCenterDraftSurfaceState", () => { expect(state.shouldSuppressTaskCenterDraftContent).toBe(false); }); - it("草稿 surface 标志滞留但已有本地物化会话时不应压制内容", () => { + it("草稿 surface 标志滞留但已有本地 canonical 会话时不应压制内容", () => { const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: null, - draftTabs: [], draftSurfaceActive: true, draftSendRequest: null, displayMessageCount: 0, @@ -231,8 +193,6 @@ describe("resolveTaskCenterDraftSurfaceState", () => { const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: "draft-1", - draftTabs: [createDraftTab("draft-1")], draftSurfaceActive: true, initialSessionId: "target-session", sessionId: "previous-session", @@ -245,19 +205,16 @@ describe("resolveTaskCenterDraftSurfaceState", () => { queuedTurnCount: 0, }); - expect(state.isTaskCenterDraftTabActive).toBe(false); expect(state.isTaskCenterDraftSurfaceActive).toBe(false); expect(state.shouldSuppressTaskCenterDraftContent).toBe(false); }); - it("历史 route 下显式打开新草稿后应由本地 draft surface 接管", () => { + it("committed New route 以稳定 Home surface 接管旧历史", () => { const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "claw", isTaskCenterEntry: true, - activeDraftTabId: "draft-from-sidebar", - draftTabs: [createDraftTab("draft-from-sidebar")], draftSurfaceActive: true, - initialSessionId: "route-session", + initialSessionId: null, sessionId: null, draftSendRequest: null, displayMessageCount: 0, @@ -268,7 +225,6 @@ describe("resolveTaskCenterDraftSurfaceState", () => { queuedTurnCount: 0, }); - expect(state.isTaskCenterDraftTabActive).toBe(true); expect(state.isTaskCenterDraftSurfaceActive).toBe(true); expect(state.shouldSuppressTaskCenterDraftContent).toBe(true); }); @@ -277,11 +233,8 @@ describe("resolveTaskCenterDraftSurfaceState", () => { const state = resolveTaskCenterDraftSurfaceState({ agentEntry: "new-task", isTaskCenterEntry: true, - activeDraftTabId: null, - draftTabs: [], draftSurfaceActive: true, draftSendRequest: createDraftSendRequest({ - materializeDraft: false, source: "empty-state", }), displayMessageCount: 0, @@ -302,7 +255,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: true, - draftTabActive: false, shouldSuppressDraftContent: true, draftSendRequest: null, sessionSwitchPending: false, @@ -333,7 +285,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: true, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: createDraftSendRequest(), sessionSwitchPending: false, @@ -364,7 +315,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: true, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: createDraftSendRequest(), sessionSwitchPending: false, @@ -398,7 +348,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: true, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: null, sessionSwitchPending: false, @@ -433,7 +382,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "new-task", draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: null, sessionSwitchPending: false, @@ -465,9 +413,8 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "new-task", draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, - draftSendRequest: createDraftSendRequest({ materializeDraft: false }), + draftSendRequest: createDraftSendRequest({}), sessionSwitchPending: false, hasInitialSessionRoute: false, isHomeSessionBackgroundRecovery: true, @@ -497,7 +444,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "new-task", draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: null, sessionSwitchPending: false, @@ -524,11 +470,10 @@ describe("resolveTaskCenterHomeChromeState", () => { expect(state.suppressHomeNavbarUtilityActions).toBe(false); }); - it("本地物化会话应优先作为前台会话,不被后台恢复卡片覆盖", () => { + it("本地 canonical 会话应优先作为前台会话,不被后台恢复卡片覆盖", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: null, sessionSwitchPending: false, @@ -542,8 +487,8 @@ describe("resolveTaskCenterHomeChromeState", () => { isHomePendingPreviewActive: false, queuedTurnCount: 0, hasLocalSessionOverride: true, - sessionId: "materialized-session", - embeddedHomeSessionIds: new Set(["materialized-session"]), + sessionId: "canonical-session", + embeddedHomeSessionIds: new Set(["canonical-session"]), isAutoRestoringSession: false, isSessionHydrating: false, shouldUseBrowserWorkspaceHomeChrome: true, @@ -558,7 +503,7 @@ describe("resolveTaskCenterHomeChromeState", () => { state.taskCenterHomeSurfaceState.shouldHideCurrentSessionContent, ).toBe(false); expect(state.taskCenterHomeSurfaceState.sceneSessionId).toBe( - "materialized-session", + "canonical-session", ); }); @@ -566,7 +511,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: true, - draftTabActive: false, shouldSuppressDraftContent: true, draftSendRequest: null, sessionSwitchPending: false, @@ -600,7 +544,6 @@ describe("resolveTaskCenterHomeChromeState", () => { const state = resolveTaskCenterHomeChromeState({ agentEntry: "claw", draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest: null, sessionSwitchPending: false, diff --git a/src/components/agent/chat/workspace/taskCenterTabProjection.test.ts b/src/components/agent/chat/workspace/taskCenterTabProjection.test.ts index 234e66bcf..0302dcd50 100644 --- a/src/components/agent/chat/workspace/taskCenterTabProjection.test.ts +++ b/src/components/agent/chat/workspace/taskCenterTabProjection.test.ts @@ -7,7 +7,6 @@ import { shouldRenderTaskCenterTabStrip, TASK_CENTER_HOME_TAB_ID, } from "./taskCenterTabProjection"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; function createTopic(id: string, overrides?: Partial): Topic { return { @@ -27,105 +26,6 @@ function createTopic(id: string, overrides?: Partial): Topic { } describe("taskCenterTabProjection", () => { - it("应把已有任务排在新对话草稿前面", () => { - const draft: TaskCenterDraftTab = { - id: "task-draft-a", - title: "新对话", - createdAt: new Date("2026-04-20T02:00:00.000Z"), - updatedAt: new Date("2026-04-20T02:00:00.000Z"), - status: "draft", - }; - const topicById = new Map([ - [ - "topic-a", - createTopic("topic-a", { - title: "", - hasUnread: true, - }), - ], - [ - "topic-b", - createTopic("topic-b", { - title: "任务 B", - status: "running", - updatedAt: "2026-04-20T03:00:00.000Z" as unknown as Date, - }), - ], - ]); - - const items = buildTaskCenterTabItems({ - draftTabs: [draft], - activeDraftTabId: draft.id, - isDraftTabActive: true, - sessionId: "topic-b", - previewTopicId: null, - visibleTabIds: ["topic-a", "topic-b"], - topicById, - untitledTaskLabel: "未命名任务", - }); - - expect(items.map((item) => item.id)).toEqual([ - "topic-a", - "topic-b", - "task-draft-a", - ]); - expect(items[0]).toMatchObject({ - title: "未命名任务", - hasUnread: true, - isActive: false, - }); - expect(items[1]).toMatchObject({ - title: "任务 B", - renamable: true, - isActive: false, - status: "running", - }); - expect(items[1]?.updatedAt).toBeInstanceOf(Date); - expect(items[2]).toMatchObject({ - isActive: true, - renamable: false, - status: "draft", - }); - }); - - it("达到标签上限时仍应把活跃新对话保留在最后", () => { - const draft: TaskCenterDraftTab = { - id: "task-draft-a", - title: "新对话", - createdAt: new Date("2026-04-20T04:00:00.000Z"), - updatedAt: new Date("2026-04-20T04:00:00.000Z"), - status: "draft", - }; - const topicById = new Map( - ["topic-a", "topic-b", "topic-c"].map((topicId) => [ - topicId, - createTopic(topicId), - ]), - ); - - const items = buildTaskCenterTabItems({ - draftTabs: [draft], - activeDraftTabId: draft.id, - isDraftTabActive: true, - sessionId: "topic-a", - previewTopicId: null, - visibleTabIds: ["topic-a", "topic-b", "topic-c"], - topicById, - untitledTaskLabel: "未命名任务", - maxCount: 3, - }); - - expect(items.map((item) => item.id)).toEqual([ - "topic-a", - "topic-b", - "task-draft-a", - ]); - expect(items[2]).toMatchObject({ - isActive: true, - status: "draft", - }); - }); - it("应构建不可关闭的浏览器首页标签", () => { expect( buildBrowserWorkspaceHomeTabItem({ @@ -149,12 +49,12 @@ describe("taskCenterTabProjection", () => { "Please retry if you think this is a transient or recoverable error.", ].join("\n"); - expect(resolveTaskCenterTopicTitle(runtimeErrorEnvelope, "未命名任务")).toBe( - "未命名任务", - ); - expect(resolveTaskCenterTopicTitle("Ran into this erro...", "未命名任务")).toBe( - "未命名任务", - ); + expect( + resolveTaskCenterTopicTitle(runtimeErrorEnvelope, "未命名任务"), + ).toBe("未命名任务"); + expect( + resolveTaskCenterTopicTitle("Ran into this erro...", "未命名任务"), + ).toBe("未命名任务"); expect(resolveTaskCenterTopicTitle("[Image #4]", "未命名任务")).toBe( "图片任务 4", ); @@ -164,9 +64,6 @@ describe("taskCenterTabProjection", () => { const topic = createTopic("thread-active", { title: "" }); const items = buildTaskCenterTabItems({ - draftTabs: [], - activeDraftTabId: null, - isDraftTabActive: false, sessionId: "thread-active", initialSessionId: "thread-active", initialSessionName: "修复恢复链路", diff --git a/src/components/agent/chat/workspace/taskCenterTabProjection.ts b/src/components/agent/chat/workspace/taskCenterTabProjection.ts index 2f894951b..1afd2b50f 100644 --- a/src/components/agent/chat/workspace/taskCenterTabProjection.ts +++ b/src/components/agent/chat/workspace/taskCenterTabProjection.ts @@ -6,14 +6,10 @@ import { } from "../utils/runtimeAttachmentPlaceholder"; import { isAssistantRuntimeErrorDisplayText } from "../utils/messageDisplaySanitizer"; import { MAX_TASK_CENTER_OPEN_TABS } from "../utils/taskCenterTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; export const TASK_CENTER_HOME_TAB_ID = "new-task-home"; interface BuildTaskCenterTabItemsParams { - draftTabs: TaskCenterDraftTab[]; - activeDraftTabId: string | null; - isDraftTabActive: boolean; sessionId?: string | null; initialSessionId?: string | null; initialSessionName?: string | null; @@ -61,9 +57,6 @@ export function resolveTaskCenterTopicTitle( } export function buildTaskCenterTabItems({ - draftTabs, - activeDraftTabId, - isDraftTabActive, sessionId, initialSessionId, initialSessionName, @@ -73,15 +66,6 @@ export function buildTaskCenterTabItems({ untitledTaskLabel, maxCount = MAX_TASK_CENTER_OPEN_TABS, }: BuildTaskCenterTabItemsParams): TaskCenterTabItem[] { - const draftItems = draftTabs.map((draft) => ({ - id: draft.id, - title: draft.title, - status: draft.status, - updatedAt: draft.updatedAt, - isActive: draft.id === activeDraftTabId, - hasUnread: false, - renamable: false, - })); const topicItems = visibleTabIds .map((topicId) => topicById.get(topicId)) .filter((topic): topic is Topic => Boolean(topic)) @@ -96,31 +80,13 @@ export function buildTaskCenterTabItems({ ), status: topic.status ?? "done", updatedAt: resolveTopicUpdatedAt(topic), - isActive: - !isDraftTabActive && topic.id === (previewTopicId ?? sessionId), + isActive: topic.id === (previewTopicId ?? sessionId), hasUnread: Boolean(topic.hasUnread), renamable: true, }; }); - const orderedItems = [...topicItems, ...draftItems]; - if (orderedItems.length <= maxCount) { - return orderedItems; - } - - const visibleItems = orderedItems.slice(0, maxCount); - const activeDraftItem = draftItems.find((item) => item.isActive); - if ( - activeDraftItem && - !visibleItems.some((item) => item.id === activeDraftItem.id) - ) { - return [ - ...visibleItems.slice(0, Math.max(0, maxCount - 1)), - activeDraftItem, - ]; - } - - return visibleItems; + return topicItems.slice(0, maxCount); } export function buildBrowserWorkspaceHomeTabItem({ @@ -146,7 +112,6 @@ export function shouldRenderTaskCenterTabStrip({ }: ShouldRenderTaskCenterTabStripParams): boolean { return ( agentEntry === "claw" || - (agentEntry === "new-task" && - (hasLocalSessionOverride || tabItemCount > 0)) + (agentEntry === "new-task" && (hasLocalSessionOverride || tabItemCount > 0)) ); } diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandRuntime.ts b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandRuntime.ts index dabf23c1f..a5b8c84ce 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandRuntime.ts +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandRuntime.ts @@ -43,7 +43,7 @@ export function useAgentChatWorkspaceCommandRuntime({ const { onNavigate: _onNavigate, projectId: externalProjectId, contentId, initialSessionId, initialAutoSendRequestMetadata, autoRunInitialPromptOnMount, agentEntry, theme: initialTheme, initialCreationMode, lockTheme, showChatPanel, hideTopBar, topBarChrome, onWorkflowProgressChange, initialUserPrompt, - initialUserImages, initialSessionName, initialInputCapability, initialProjectFileOpenTarget, onInitialUserPromptConsumed, newChatAt, onHasMessagesChange: _onHasMessagesChange, + initialUserImages, initialSessionName, initialInputCapability, initialProjectFileOpenTarget, onInitialUserPromptConsumed, newChatAt, newConversationRequest, onStartNewConversation, onHasMessagesChange: _onHasMessagesChange, } = props; const resolvedAutoRunInitialPromptOnMount = autoRunInitialPromptOnMount ?? false; @@ -81,6 +81,7 @@ export function useAgentChatWorkspaceCommandRuntime({ openRuntimeSceneGate, currentGate, documentEditorFocusedRef, themeWorkbenchActiveQueueItem, themeWorkbenchBackendRunState, themeWorkbenchRunState, handleViewContextDetail, harnessInventoryRuntime, } = setupRuntime; const commandWiring = useAgentChatWorkspaceCommandWiring({ + composerDraftTitle: untitledTaskLabel, navigationProjectId: validatedRuntimeProjectId ?? undefined, trayActiveTheme: mappedTheme, consumePendingSkill, @@ -152,6 +153,8 @@ export function useAgentChatWorkspaceCommandRuntime({ messages, messagesLength: messages.length, newChatAt, + newConversationRequest, + onStartNewConversation, normalizedInitialSessionId, normalizedInitialTheme: normalizedEntryTheme, onInitialUserPromptConsumed, @@ -206,12 +209,10 @@ export function useAgentChatWorkspaceCommandRuntime({ }); const { homePendingPreviewRequest, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, taskCenterDraftSendRequest, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, } = commandWiring.taskCenterDraftState; const { readSessionFile, @@ -294,10 +295,8 @@ export function useAgentChatWorkspaceCommandRuntime({ untitledTaskLabel, }); const { - activeTaskCenterDraftTabIdRef, bootstrapPendingPreviewMessages, browserWorkspaceHomeTabsNode, - commitMaterializedTaskCenterDraftTab, effectiveShowChatPanel, handleCanvasSelectionTextChange, handleCloseCanvas, @@ -311,7 +310,6 @@ export function useAgentChatWorkspaceCommandRuntime({ hasMessages, homePendingPreviewMessages, isTaskCenterDraftSendPending, - materializeTaskCenterDraftTab, projectConversationGroups, recentSessionActionLabel, recentSessionTopic, @@ -321,8 +319,6 @@ export function useAgentChatWorkspaceCommandRuntime({ shouldRestoreImageTasksFromWorkspace, shouldSuppressTaskCenterDraftContent, suppressHomeNavbarUtilityActions, - taskCenterDraftMaterializedSessionIdsRef, - taskCenterDraftWarmupSessionIdsRef, taskCenterHomeSurfaceState, taskCenterTabsNode, } = shellInteractionRuntime; @@ -605,14 +601,14 @@ export function useAgentChatWorkspaceCommandRuntime({ // prettier-ignore return { - shellChromeRuntime, showGeneralWorkbenchLeftExpandButton, effectiveInitialInputCapability, generalWorkbenchHarnessPanelBaseProps, navigationActions, homePendingPreviewRequest, setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, - setTaskCenterDraftSendRequest, setTaskCenterDraftTabs, taskCenterDraftSendRequest, taskCenterDraftSurfaceActiveRef, generalWorkbenchEntryPrompt, handleBackHome, markTaskCenterLocalSessionOverride, setTaskCenterDetachedTopicId, + ...commandWiring.composerDraft, + shellChromeRuntime, showGeneralWorkbenchLeftExpandButton, effectiveInitialInputCapability, generalWorkbenchHarnessPanelBaseProps, navigationActions, homePendingPreviewRequest, setHomePendingPreviewRequest, + setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, taskCenterHomeSurfaceActiveRef, generalWorkbenchEntryPrompt, handleBackHome, markTaskCenterLocalSessionOverride, setTaskCenterDetachedTopicId, setTaskCenterTransitionTopicId, upsertTaskCenterOpenTab, handleSend, handleRecommendationClick, handleSendRef, isPreparingSend, displayMessages, handleImageWorkbenchCommand, imageWorkbenchActionRuntime, handleContinueGeneralWorkbenchEntryPrompt, handleRestartGeneralWorkbenchEntryPrompt, handleDocumentAutoContinueRun, handleArtifactBlockRewriteRun, handleDocumentContentReviewRun, handleDocumentTextStylizeRun, handleAddImage, - handleImportDocument, activeTaskCenterDraftTabIdRef, bootstrapPendingPreviewMessages, browserWorkspaceHomeTabsNode, commitMaterializedTaskCenterDraftTab, handleCanvasSelectionTextChange, handleCloseCanvas, handleOpenProjectConversation, - handleOpenTaskTopic, handleResumeRecentSession, handleToggleCanvas, hasDisplayMessages, hasMessages, homePendingPreviewMessages, isTaskCenterDraftSendPending, materializeTaskCenterDraftTab, - projectConversationGroups, recentSessionActionLabel, recentSessionTopic, resolvedCanvasState, shouldRenderTaskCenterEmbeddedHome, shouldRenderTaskCenterTabStrip, shouldRestoreImageTasksFromWorkspace, shouldSuppressTaskCenterDraftContent, - suppressHomeNavbarUtilityActions, taskCenterDraftMaterializedSessionIdsRef, taskCenterDraftWarmupSessionIdsRef, taskCenterHomeSurfaceState, taskCenterTabsNode, bindArticleEditorRightSurface, bindRightSurfacePendingActions, handleCodeBlockClick, + handleImportDocument, bootstrapPendingPreviewMessages, browserWorkspaceHomeTabsNode, handleCanvasSelectionTextChange, handleCloseCanvas, handleOpenProjectConversation, + handleOpenTaskTopic, handleResumeRecentSession, handleToggleCanvas, hasDisplayMessages, hasMessages, homePendingPreviewMessages, isTaskCenterDraftSendPending, projectConversationGroups, recentSessionActionLabel, recentSessionTopic, resolvedCanvasState, shouldRenderTaskCenterEmbeddedHome, shouldRenderTaskCenterTabStrip, shouldRestoreImageTasksFromWorkspace, shouldSuppressTaskCenterDraftContent, + suppressHomeNavbarUtilityActions, taskCenterHomeSurfaceState, taskCenterTabsNode, bindArticleEditorRightSurface, bindRightSurfacePendingActions, handleCodeBlockClick, handleHarnessLoadFilePreview, handleOpenArtifactFromTimeline, handleOpenMessagePreview, handleOpenSavedSiteContent, handleOpenServiceSkillResultFile, handleOpenUrlPreview, handleSaveArtifactDocument, handleWorkspaceArtifactClick, handleWorkspaceFileClick, openProjectFilePreviewInCanvas, openWorkspaceArtifactInWorkbench, preferredServiceSkillResultFileTarget, renderArtifactWorkbenchToolbarActions, shouldCollapseCodeBlockInChat, shouldCollapseCodeBlocks, handleWriteFile, handleJumpToTimelineItem, serviceSkillExecutionCard, generalWorkbenchActivityLogs, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.navigation.integration.test.tsx b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.navigation.integration.test.tsx new file mode 100644 index 000000000..48db61270 --- /dev/null +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.navigation.integration.test.tsx @@ -0,0 +1,314 @@ +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + accepted, + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; +import type { useWorkspaceSendSurfaceRuntime } from "./useWorkspaceSendSurfaceRuntime"; +import { + useAgentChatWorkspaceCommandWiring, + type AgentChatWorkspaceCommandScope, +} from "./useAgentChatWorkspaceCommandWiring"; +import type { useWorkspaceTaskCenterNavigationRuntime } from "./useWorkspaceTaskCenterNavigationRuntime"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("../hooks/useTrayModelShortcuts", () => ({ + useTrayModelShortcuts: vi.fn(), +})); +vi.mock("./useWorkspaceCanvasMessageSyncRuntime", () => ({ + useWorkspaceCanvasMessageSyncRuntime: vi.fn(), +})); +vi.mock("./useWorkspacePersistenceRuntime", () => ({ + useWorkspacePersistenceRuntime: () => ({}), +})); +vi.mock("./useWorkspaceResetRuntime", () => ({ + useWorkspaceResetRuntime: () => ({}), +})); +vi.mock("./useWorkspaceSessionRestore", () => ({ + useWorkspaceSessionRestore: () => ({}), +})); +vi.mock("./useGeneralWorkbenchInitialDispatchRuntime", () => ({ + useGeneralWorkbenchInitialDispatchRuntime: () => ({}), +})); +vi.mock("./useWorkspaceTaskCenterDraftStateRuntime", () => ({ + useWorkspaceTaskCenterDraftStateRuntime: () => ({ + handleTopicSwitchCommitted: vi.fn(), + }), +})); +vi.mock("./useWorkspaceSendSurfaceRuntime", () => ({ + useWorkspaceSendSurfaceRuntime: (options: unknown) => { + navigation.send.capture(options); + return {}; + }, +})); +vi.mock("./useWorkspaceWorkbenchActionSurfaceRuntime", () => ({ + useWorkspaceWorkbenchActionSurfaceRuntime: () => ({}), +})); +const navigation = vi.hoisted(() => ({ + capture: vi.fn(), + send: { capture: vi.fn() }, +})); +vi.mock("./useWorkspaceTaskCenterNavigationRuntime", () => ({ + useWorkspaceTaskCenterNavigationRuntime: (options: unknown) => { + navigation.capture(options); + return {}; + }, +})); +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); +const roots: Array<{ root: Root; host: HTMLElement }> = []; +afterEach(() => { + act(() => { + for (const { root, host } of roots.splice(0)) { + root.unmount(); + host.remove(); + } + }); + vi.unstubAllGlobals(); +}); + +describe("Command wiring keeps Composer scope in the visible workspace", () => { + async function mountSend(submit: () => Promise) { + const fixture = await setup(); + const home = { + workspaceId: "visible-workspace", + kind: "home" as const, + id: "new-conversation", + }; + fixture.seed(home, "unsent Home draft"); + let current!: ReturnType; + let setSession!: (id: string) => void; + function Surface() { + const [sessionId, updateSession] = useState(null); + setSession = updateSession; + current = useAgentChatWorkspaceCommandWiring({ + scope: { + composerController: fixture.controller, + taskCenterWorkspaceId: home.workspaceId, + sessionId, + sendMessage: submit, + normalizedInitialSessionId: null, + projectSelectionSource: "prop", + } as unknown as AgentChatWorkspaceCommandScope, + navigationProjectId: "business-workspace", + trayActiveTheme: "general", + consumePendingSkill: vi.fn(), + pendingSkillKey: undefined, + sceneGateResumeHandlerRef: { current: async () => false }, + composerDraftTitle: "Draft", + }); + return ( + {String(current.composerDraft.composerDraftRestoring)} + ); + } + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push({ root, host }); + await act(async () => root.render()); + const send = ( + navigation.send.capture.mock.lastCall![0] as Parameters< + typeof useWorkspaceSendSurfaceRuntime + >[0] + ).sendActions.sendMessage; + return { + fixture, + home, + current: () => current, + setSession: (id: string) => setSession(id), + send, + }; + } + + it("binds a visible session accepted by an imperative initial send and preserves its Home draft", async () => { + const view = await mountSend(async () => accepted()); + expect(view.fixture.controller.getDocument().text).toBe( + "unsent Home draft", + ); + await act(async () => { + view.setSession("s1"); + expect(await view.send("external initial prompt", [])).toEqual( + accepted(), + ); + }); + expect(view.current().composerDraft.composerDraftStatus?.scope).toEqual({ + workspaceId: view.home.workspaceId, + kind: "local", + id: "s1", + }); + expect(view.current().composerDraft.composerDraftRestoring).toBe(false); + expect(view.fixture.controller.getDocument().text).toBe(""); + expect((await view.fixture.read(view.home))?.state).toBe("draft"); + expect(view.fixture.mutations.map((item) => item.op)).not.toContain( + "consume", + ); + }); + + it("waits for the accepted session to become the actual visible target", async () => { + const view = await mountSend(async () => accepted()); + await act(async () => { + await view.send("initial prompt", []); + }); + expect(view.current().composerDraft.composerDraftStatus?.scope).toEqual( + view.home, + ); + expect(view.fixture.controller.getDocument().text).toBe( + "unsent Home draft", + ); + await act(async () => view.setSession("s1")); + expect(view.current().composerDraft.composerDraftStatus?.scope?.id).toBe( + "s1", + ); + expect(view.current().composerDraft.composerDraftRestoring).toBe(false); + }); + + it.each(["rejected", "unknown", "local-handled"] as const)( + "does not commit a session from a %s result", + async (kind) => { + const view = await mountSend(async () => ({ kind })); + await act(async () => { + view.setSession("s1"); + expect(await view.send("initial prompt", [])).toEqual({ kind }); + }); + expect(view.current().composerDraft.composerDraftStatus?.scope).toEqual( + view.home, + ); + expect(view.current().composerDraft.composerDraftRestoring).toBe(true); + expect(view.fixture.controller.getDocument().text).toBe( + "unsent Home draft", + ); + }, + ); + + it("leaves Composer submission promotion to its existing captured identity owner", async () => { + const view = await mountSend(async () => accepted()); + const capture = view + .current() + .composerDraft.composerDraftCoordinator!.captureSubmission("start"); + await act(async () => { + view.setSession("s1"); + await view.send( + "Composer prompt", + [], + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { + composerDraft: view.fixture.controller.captureDraft(), + }, + ); + }); + expect(view.current().composerDraft.composerDraftStatus?.scope).toEqual( + view.home, + ); + const result = accepted(); + if (result.kind !== "runtime-accepted") + throw new Error("missing fixture receipt"); + await act(async () => + view.current().composerDraft.handleComposerDraftAccepted(capture, result), + ); + expect(view.current().composerDraft.composerDraftStatus?.scope?.id).toBe( + "s1", + ); + expect(view.current().composerDraft.composerDraftRestoring).toBe(false); + }); + + it("ignores an imperative acceptance after another navigation epoch commits", async () => { + const pending = deferred(); + const view = await mountSend(() => pending.promise); + const submitted = view.send("initial prompt", []); + const newer = { ...view.home, kind: "local" as const, id: "newer-session" }; + await act(async () => { + view.setSession(newer.id); + view.current().composerDraft.commitComposerDraftScope(newer); + }); + act(() => view.fixture.controller.setText("newer unsent edit")); + await act(async () => { + pending.resolve(accepted()); + expect(await submitted).toEqual(accepted()); + }); + expect(view.current().composerDraft.composerDraftStatus?.scope).toEqual( + newer, + ); + expect(view.current().composerDraft.composerDraftRestoring).toBe(false); + expect(view.fixture.controller.getDocument().text).toBe( + "newer unsent edit", + ); + }); + + it.each([ + { visible: "workspace-a", business: undefined, hydrated: null }, + { + visible: null, + business: "default-workspace", + hydrated: "default-workspace", + }, + ])( + "commits the local editor when UI workspace=$visible and business workspace=$business", + async ({ visible, business, hydrated }) => { + const fixture = await setup(); + let current!: ReturnType; + let setSession!: (id: string) => void; + function Surface() { + const [sessionId, updateSession] = useState(null); + setSession = updateSession; + current = useAgentChatWorkspaceCommandWiring({ + scope: { + composerController: fixture.controller, + taskCenterWorkspaceId: visible, + sessionId, + normalizedInitialSessionId: null, + projectSelectionSource: "manual", + } as AgentChatWorkspaceCommandScope, + navigationProjectId: business, + trayActiveTheme: "general", + consumePendingSkill: vi.fn(), + pendingSkillKey: undefined, + sceneGateResumeHandlerRef: { current: async () => false }, + composerDraftTitle: "Draft", + }); + return ( + + {current.composerDraft.composerDraftRestoring + ? "restoring" + : "ready"} + + ); + } + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + roots.push({ root, host }); + await act(async () => root.render()); + expect(current.composerDraft.composerDraftRestoring).toBe(false); + const options = navigation.capture.mock.lastCall![0] as Parameters< + typeof useWorkspaceTaskCenterNavigationRuntime + >[0]; + const epoch = current.composerDraft.getComposerDraftNavigationEpoch(); + await act(async () => { + setSession("canonical-session"); + options.onAfterTopicSwitch?.("canonical-session", hydrated, epoch); + }); + expect(current.composerDraft.composerDraftStatus?.scope).toEqual({ + workspaceId: visible, + kind: "local", + id: "canonical-session", + }); + expect(current.composerDraft.composerDraftRestoring).toBe(false); + expect(host.textContent).toBe("ready"); + }, + ); +}); diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts index eae4edd1a..a297f982a 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceCommandWiring.ts @@ -1,4 +1,11 @@ import { useTrayModelShortcuts } from "../hooks/useTrayModelShortcuts"; +import { useCallback, useMemo } from "react"; +import { + isRuntimeSubmissionAccepted, + runRuntimeSubmissionEffect, +} from "../hooks/agentSendResult"; +import { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; +import { useWorkspaceComposerDraftNavigation } from "./useWorkspaceComposerDraftNavigation"; import type { useAgentChatUnified } from "../hooks"; import type { useWorkspaceProjectSelection } from "../hooks/useWorkspaceProjectSelection"; import { useGeneralWorkbenchInitialDispatchRuntime } from "./useGeneralWorkbenchInitialDispatchRuntime"; @@ -52,13 +59,14 @@ export type AgentChatWorkspaceCommandScope = ReturnType< | "resetTopicLocalState" | "projectId" | "onBeforeTopicSwitch" - | "setActiveTaskCenterDraftTabId" + | "onAfterTopicSwitch" + | "composerDraftCoordinator" + | "getComposerDraftNavigationEpoch" | "setHomePendingPreviewRequest" | "setTaskCenterDraftSendRequest" - | "setTaskCenterDraftTabs" | "shouldHydrateEmptyMatchedInitialSession" | "shouldPauseInitialSessionNavigationForTaskCenterDraft" - | "taskCenterDraftSurfaceActiveRef" + | "taskCenterHomeSurfaceActiveRef" > & Omit & CanvasMessageSyncParams & @@ -89,6 +97,7 @@ interface UseAgentChatWorkspaceCommandWiringParams< consumePendingSkill: SendSurfaceParams["pendingSkill"]["consumePendingSkill"]; pendingSkillKey: SendSurfaceParams["pendingSkill"]["key"]; sceneGateResumeHandlerRef: SendSurfaceParams["sceneGateResumeHandlerRef"]; + composerDraftTitle: string; } /** 只组合既有 command owners,不持有 runtime 或 read-model truth。 */ @@ -102,7 +111,47 @@ export function useAgentChatWorkspaceCommandWiring< pendingSkillKey, sceneGateResumeHandlerRef, }: UseAgentChatWorkspaceCommandWiringParams) { - const taskCenterDraftState = useWorkspaceTaskCenterDraftStateRuntime(scope); + const workspaceId = scope.taskCenterWorkspaceId?.trim() || null; + const initialScope = useMemo(() => { + const currentSessionId = scope.sessionId?.trim(); + if ( + scope.normalizedInitialSessionId && + scope.normalizedInitialSessionId !== currentSessionId + ) + return null; + if (currentSessionId) + return { workspaceId, kind: "local" as const, id: currentSessionId }; + if (scope.normalizedInitialSessionId) return null; + return { workspaceId, kind: "home" as const, id: "new-conversation" }; + }, [workspaceId, scope.sessionId, scope.normalizedInitialSessionId]); + const composerDraftRuntime = useWorkspaceComposerDraftRuntime({ + controller: scope.composerController, + initialScope, + }); + const taskCenterDraftState = useWorkspaceTaskCenterDraftStateRuntime({ + ...scope, + composerDraftCoordinator: composerDraftRuntime.composerDraftCoordinator, + }); + const { handleTopicSwitchCommitted } = useWorkspaceComposerDraftNavigation({ + newConversationRequest: scope.newConversationRequest, + composerDraftRuntime, + workspaceId, + projectSelectionSource: scope.projectSelectionSource, + normalizedInitialSessionId: scope.normalizedInitialSessionId, + sessionId: scope.sessionId, + onTopicSwitchCommitted: taskCenterDraftState.handleTopicSwitchCommitted, + }); + const commitVisibleTopic = useCallback( + ( + topicId: string, + _runtimeWorkspaceId: string | null, + capturedNavigationEpoch?: number, + ) => { + // 业务工作区可能仍在解析或使用默认值;草稿始终绑定当前可见工作区。 + handleTopicSwitchCommitted(topicId, workspaceId, capturedNavigationEpoch); + }, + [handleTopicSwitchCommitted, workspaceId], + ); const persistence = useWorkspacePersistenceRuntime({ ...scope, draftSendInFlight: Boolean( @@ -118,6 +167,11 @@ export function useAgentChatWorkspaceCommandWiring< }); const reset = useWorkspaceResetRuntime({ ...scope, + composerDraftEnabled: true, + composerDraftCoordinator: composerDraftRuntime.composerDraftCoordinator, + onComposerDraftScopeCommitted: + composerDraftRuntime.commitComposerDraftScope, + composerDraftHomeWorkspaceId: workspaceId, resetGuideState: initialDispatch.resetGuideState, resetRestoredSessionState: sessionRestore.resetRestoredSessionState, }); @@ -125,20 +179,21 @@ export function useAgentChatWorkspaceCommandWiring< ...scope, projectId: navigationProjectId, onBeforeTopicSwitch: taskCenterDraftState.handleBeforeTopicSwitch, + onAfterTopicSwitch: commitVisibleTopic, + composerDraftCoordinator: composerDraftRuntime.composerDraftCoordinator, + getComposerDraftNavigationEpoch: + composerDraftRuntime.getComposerDraftNavigationEpoch, resetTopicLocalState: reset.resetTopicLocalState, - setActiveTaskCenterDraftTabId: - taskCenterDraftState.setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest: taskCenterDraftState.setHomePendingPreviewRequest, setTaskCenterDraftSendRequest: taskCenterDraftState.setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs: taskCenterDraftState.setTaskCenterDraftTabs, shouldHydrateEmptyMatchedInitialSession: taskCenterDraftState.shouldHydrateEmptyMatchedInitialSession, shouldPauseInitialSessionNavigationForTaskCenterDraft: taskCenterDraftState.shouldPauseInitialSessionNavigationForTaskCenterDraft, - taskCenterDraftSurfaceActiveRef: - taskCenterDraftState.taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef: + taskCenterDraftState.taskCenterHomeSurfaceActiveRef, }); useTrayModelShortcuts({ @@ -147,6 +202,21 @@ export function useAgentChatWorkspaceCommandWiring< }); useWorkspaceCanvasMessageSyncRuntime(scope); + const sendRuntimeMessage = scope.sendMessage; + const { getComposerDraftNavigationEpoch } = composerDraftRuntime; + const sendMessage = useCallback( + async (...args) => { + const epoch = getComposerDraftNavigationEpoch(); + const result = await sendRuntimeMessage(...args); + // 初始意图等程序化发送不经过 Composer settlement;仍提交真实可见会话。 + if (!args[8]?.composerDraft && isRuntimeSubmissionAccepted(result)) + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + commitVisibleTopic(result.receipt.sessionId, null, epoch), + ); + return result; + }, + [sendRuntimeMessage, getComposerDraftNavigationEpoch, commitVisibleTopic], + ); const send = useWorkspaceSendSurfaceRuntime({ imageWorkbench: scope, pendingSkill: { @@ -157,6 +227,7 @@ export function useAgentChatWorkspaceCommandWiring< sceneGateResumeHandlerRef, sendActions: { ...scope, + sendMessage, bootstrapDispatchPreview: initialDispatch.bootstrapDispatchPreview, finalizeAfterSendSuccess: initialDispatch.finalizeAfterSendSuccess, resolveSendBoundary: initialDispatch.resolveSendBoundary, @@ -182,6 +253,7 @@ export function useAgentChatWorkspaceCommandWiring< const compositionScope = { ...scope, + ...composerDraftRuntime, ...taskCenterDraftState, ...persistence, ...initialDispatch, @@ -194,6 +266,7 @@ export function useAgentChatWorkspaceCommandWiring< return { compositionScope, + composerDraft: composerDraftRuntime, initialDispatch, persistence, reset, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx b/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx index 5dd8268df..ecfcaa69b 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceSceneRuntime.tsx @@ -1,7 +1,5 @@ /** Workspace scene/projection current owner。 */ -/** Workspace scene/projection current owner。 */ - import { useCallback } from "react"; import { useWorkspaceCanvasSceneRuntime } from "./useWorkspaceCanvasSceneRuntime"; import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; @@ -66,7 +64,7 @@ export function useAgentChatWorkspaceSceneRuntime({ generalHarnessEntryEnabled, providerType, setProviderType, model, setModel, reasoningEffort, setReasoningEffort, accessMode, setAccessMode, messages, setChatMessages, currentTurnId, turns, todoItems, threadRead, threadGoal, threadGoalError, isThreadGoalLoading, executionRuntime, sessionWorkingDir, isSending, stopSending, replayPendingAction, - clearMessages, deleteMessage, editMessage, handlePermissionResponse, submittedActionsInFlight, sessionHistoryWindow, + deleteMessage, editMessage, handlePermissionResponse, submittedActionsInFlight, sessionHistoryWindow, isAutoRestoringSession, isSessionHydrating, sessionId, ensureSession, getThreadIdForSubmit, originalSwitchTopic, loadFullSessionHistory, refreshSessionReadModel, renameTopic, archiveTopic, forkTopic, workspacePathMissing, workspaceHealthError, combinedSkillsLoading, expertPanelRequestMetadata, expertPanelRuntimeKey, expertSkillRefsOverride, expertWorkspaceSkillRuntimeEnableBindings, expertWorkspaceSkillRuntimeEnableRefs, handleEnableExpertWorkspaceSkillRuntime, handleExpertSkillRefsChange, handlePluginSuggestionsNeeded, handleThreadExpertProfileSwitch, workspacePluginInputSuggestions, workspacePluginSuggestionsError, workspacePluginSuggestionsLoading, workspaceSkillBindings, topicById, effectiveChatToolPreferences, canonicalChildren, @@ -78,14 +76,14 @@ export function useAgentChatWorkspaceSceneRuntime({ } = setupRuntime; // prettier-ignore const { - shellChromeRuntime, showGeneralWorkbenchLeftExpandButton, effectiveInitialInputCapability, generalWorkbenchHarnessPanelBaseProps, navigationActions, homePendingPreviewRequest, setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, - setTaskCenterDraftSendRequest, setTaskCenterDraftTabs, taskCenterDraftSendRequest, taskCenterDraftSurfaceActiveRef, generalWorkbenchEntryPrompt, handleBackHome, markTaskCenterLocalSessionOverride, setTaskCenterDetachedTopicId, + composerDraftCoordinator, composerDraftStatus, composerDraftRestoring, handleComposerDraftAccepted, getComposerDraftNavigationEpoch, + shellChromeRuntime, showGeneralWorkbenchLeftExpandButton, effectiveInitialInputCapability, generalWorkbenchHarnessPanelBaseProps, navigationActions, homePendingPreviewRequest, setHomePendingPreviewRequest, + setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, taskCenterHomeSurfaceActiveRef, generalWorkbenchEntryPrompt, handleBackHome, markTaskCenterLocalSessionOverride, setTaskCenterDetachedTopicId, setTaskCenterTransitionTopicId, upsertTaskCenterOpenTab, handleSend, handleRecommendationClick, handleSendRef, isPreparingSend, displayMessages, handleImageWorkbenchCommand, imageWorkbenchActionRuntime, handleContinueGeneralWorkbenchEntryPrompt, handleRestartGeneralWorkbenchEntryPrompt, handleDocumentAutoContinueRun, handleArtifactBlockRewriteRun, handleDocumentContentReviewRun, handleDocumentTextStylizeRun, handleAddImage, - handleImportDocument, activeTaskCenterDraftTabIdRef, bootstrapPendingPreviewMessages, browserWorkspaceHomeTabsNode, commitMaterializedTaskCenterDraftTab, handleCanvasSelectionTextChange, handleCloseCanvas, handleOpenProjectConversation, - handleOpenTaskTopic, handleResumeRecentSession, handleToggleCanvas, hasDisplayMessages, hasMessages, homePendingPreviewMessages, isTaskCenterDraftSendPending, materializeTaskCenterDraftTab, - projectConversationGroups, recentSessionActionLabel, recentSessionTopic, resolvedCanvasState, shouldRenderTaskCenterEmbeddedHome, shouldRenderTaskCenterTabStrip, shouldRestoreImageTasksFromWorkspace, shouldSuppressTaskCenterDraftContent, - suppressHomeNavbarUtilityActions, taskCenterDraftMaterializedSessionIdsRef, taskCenterDraftWarmupSessionIdsRef, taskCenterHomeSurfaceState, taskCenterTabsNode, bindArticleEditorRightSurface, bindRightSurfacePendingActions, handleCodeBlockClick, + handleImportDocument, bootstrapPendingPreviewMessages, browserWorkspaceHomeTabsNode, handleCanvasSelectionTextChange, handleCloseCanvas, handleOpenProjectConversation, + handleOpenTaskTopic, handleResumeRecentSession, handleToggleCanvas, hasDisplayMessages, hasMessages, homePendingPreviewMessages, isTaskCenterDraftSendPending, projectConversationGroups, recentSessionActionLabel, recentSessionTopic, resolvedCanvasState, shouldRenderTaskCenterEmbeddedHome, shouldRenderTaskCenterTabStrip, shouldRestoreImageTasksFromWorkspace, shouldSuppressTaskCenterDraftContent, + suppressHomeNavbarUtilityActions, taskCenterHomeSurfaceState, taskCenterTabsNode, bindArticleEditorRightSurface, bindRightSurfacePendingActions, handleCodeBlockClick, handleHarnessLoadFilePreview, handleOpenArtifactFromTimeline, handleOpenMessagePreview, handleOpenSavedSiteContent, handleOpenServiceSkillResultFile, handleOpenUrlPreview, handleSaveArtifactDocument, handleWorkspaceArtifactClick, handleWorkspaceFileClick, openProjectFilePreviewInCanvas, openWorkspaceArtifactInWorkbench, preferredServiceSkillResultFileTarget, renderArtifactWorkbenchToolbarActions, shouldCollapseCodeBlockInChat, shouldCollapseCodeBlocks, handleWriteFile, handleJumpToTimelineItem, serviceSkillExecutionCard, generalWorkbenchActivityLogs, @@ -108,6 +106,10 @@ export function useAgentChatWorkspaceSceneRuntime({ handleStopSending, input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted: handleComposerDraftAccepted, + composerDraftStatus, + composerDraftRestoring, setInput, currentGate, generalWorkbenchWorkflowSteps, @@ -250,11 +252,10 @@ export function useAgentChatWorkspaceSceneRuntime({ }); const taskCenterSendRuntime = useWorkspaceTaskCenterSendRuntime({ - activeDraftTabIdRef: activeTaskCenterDraftTabIdRef, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, activeSessionIdRef, agentEntry: resolvedAgentEntry, - clearMessages, - commitMaterializedDraftTab: commitMaterializedTaskCenterDraftTab, currentSessionId: sessionId, currentTurnId, displayMessages, @@ -271,26 +272,21 @@ export function useAgentChatWorkspaceSceneRuntime({ layoutMode, markNewChatRequestHandled, markTaskCenterLocalSessionOverride, - materializedSessionIdsRef: taskCenterDraftMaterializedSessionIdsRef, - materializeDraftTab: materializeTaskCenterDraftTab, messagesLength: messages.length, newChatAt, normalizedInitialSessionId, planComposerPendingActions, - prewarmedDraftSessionIdsRef: taskCenterDraftWarmupSessionIdsRef, sendRef: handleSendRef, - setActiveDraftTabId: setActiveTaskCenterDraftTabId, setDetachedTopicId: setTaskCenterDetachedTopicId, setHomePendingPreviewRequest, setInput, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTransitionTopicId: setTaskCenterTransitionTopicId, shouldRenderTaskCenterEmbeddedHome, shouldSuppressTaskCenterDraftContent, submittedActionsInFlight, taskCenterDraftSendRequest, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterHomeSurfaceState, taskCenterWorkspaceId, threadRead, @@ -457,7 +453,8 @@ export function useAgentChatWorkspaceSceneRuntime({ initialCreationReplaySurface?.defaultReferenceEntries, defaultCuratedTaskReferenceMemoryIds: initialCreationReplaySurface?.defaultReferenceMemoryIds, - emptyStateDisabled: sceneIsPreparingSend || sceneIsSending, + emptyStateDisabled: + sceneIsPreparingSend || sceneIsSending || composerDraftRestoring, emptyStateIsLoading: sceneIsPreparingSend || sceneIsSending, emptyStateSendOnPointerDown: true, entryBannerMessage: effectiveEntryBannerMessage, @@ -468,6 +465,10 @@ export function useAgentChatWorkspaceSceneRuntime({ initialInputCapability: effectiveInitialInputCapability, input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted: handleComposerDraftAccepted, + composerDraftStatus, + composerDraftRestoring, inputbarScene, inputRestoreRequest, lockTheme: resolvedLockTheme, @@ -486,6 +487,7 @@ export function useAgentChatWorkspaceSceneRuntime({ onProjectChange: navigationActions.handleProjectChange, onRecommendationClick: handleRecommendationClick, onRefreshSkills: handleRefreshSkills, + onSkillSuggestionsNeeded: handleSkillSuggestionsNeeded, onRemovePathReference: handleRemovePathReference, onSelectServiceSkill: workspaceServiceSkillEntryActions.handleServiceSkillSelect, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts b/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts index 8ea776ee4..92ddb9f72 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceSetupRuntime.ts @@ -54,7 +54,7 @@ export function useAgentChatWorkspaceSetupRuntime({ initialPendingServiceSkillLaunch, newChatAt, expertAgentLaunch, - onSessionChange, + onLocationChange, onAgentStreamingChange, } = props; @@ -422,7 +422,7 @@ export function useAgentChatWorkspaceSetupRuntime({ messages, model, onAgentStreamingChange, - onSessionChange, + onLocationChange, pendingActions, projectId, projectMemory, diff --git a/src/components/agent/chat/workspace/useAgentChatWorkspaceShellInteractionRuntime.ts b/src/components/agent/chat/workspace/useAgentChatWorkspaceShellInteractionRuntime.ts index 84818a571..7dc0d357e 100644 --- a/src/components/agent/chat/workspace/useAgentChatWorkspaceShellInteractionRuntime.ts +++ b/src/components/agent/chat/workspace/useAgentChatWorkspaceShellInteractionRuntime.ts @@ -76,7 +76,6 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ scope, agentChatRuntime, localDisplayRuntime, - projectSelectionRuntime, contextSurfaceRuntime, artifactCanvasRuntime, imageWorkbenchSessionRuntime, @@ -109,50 +108,35 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ autoCollapsedTopicSidebarRef, bootstrapDispatchPreview, canvasState, - clearMessages, clearTaskCenterEmbeddedHomeSession, contentId, defaultTopicSidebarVisible, displayMessages, executionStrategy, - externalProjectId, homePendingPreviewRequest, initialDispatchKey, initialSessionId, - input, isAutoRestoringSession, isBootstrapDispatchPending, isPreparingSend, isSending, isTaskCenterEntry, - lockTheme, mappedTheme, - markTaskCenterEmbeddedHomeSession, markTaskCenterLocalSessionOverride, messages, newChatAt, normalizedInitialSessionId, - onNavigate, - originalSwitchTopic, projectId, queuedTurnCount, replaceTaskCenterOpenTabs, - resetProjectSelection, - resetTopicLocalState, sessionId, - setActiveTaskCenterDraftTabId, setCanvasState, setGeneralCanvasState, - setHomePendingPreviewRequest, - setInput, setLayoutMode, - setMentionedCharacters, setSelectedFileId, setSelectedText, setShowSidebar, setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTaskCenterLocalSessionOverride, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, @@ -160,8 +144,7 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ switchTopic, taskCenterDetachedTopicId, taskCenterDraftSendRequest, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabs, + taskCenterHomeSurfaceActiveRef, taskCenterEmbeddedHomeSessionIds, taskCenterFallbackRestoreRef, taskCenterLocalSessionOverride, @@ -172,28 +155,24 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ topics, upsertTaskCenterOpenTab, } = scope; - const { createFreshSession, renameTopic } = agentChatRuntime; + const { renameTopic } = agentChatRuntime; const { canvasWorkbenchLayoutMode, layoutMode, setCanvasWorkbenchLayoutMode, showSidebar, } = localDisplayRuntime; - const { applyProjectSelection } = projectSelectionRuntime; const { contextHarnessRuntime, effectiveThreadItems } = contextSurfaceRuntime; const { harnessPanelVisible, setHarnessPanelVisible } = contextHarnessRuntime; const { currentCanvasArtifact } = artifactCanvasRuntime; const { resetLocalImageWorkbenchSessionScope } = imageWorkbenchSessionRuntime; const taskCenterSurfaceRuntime = useWorkspaceTaskCenterSurfaceRuntime({ - activeTheme, bootstrapDispatchPreview, draftSurface: { agentEntry, isTaskCenterEntry, - activeDraftTabId: scope.activeTaskCenterDraftTabId, - draftTabs: taskCenterDraftTabs, - draftSurfaceActive: taskCenterDraftSurfaceActiveRef.current, + draftSurfaceActive: taskCenterHomeSurfaceActiveRef.current, initialSessionId: normalizedInitialSessionId, sessionId, draftSendRequest: taskCenterDraftSendRequest, @@ -212,17 +191,12 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ workspaceId: taskCenterWorkspaceId, soulCopy: soulInteractionCopy, }, - lockTheme, - onNavigate, - taskCenterWorkspaceId, }); const { - isTaskCenterDraftTabActive, isTaskCenterDraftSurfaceActive, shouldSuppressTaskCenterDraftContent, isHomePendingPreviewActive, isHomeSendStarting, - persistTaskCenterMaterializedSessionNavigation, } = taskCenterSurfaceRuntime; const hasCanvasWorkbenchContent = layoutMode !== "chat"; @@ -292,15 +266,18 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ const taskCenterInteractionRuntime = useWorkspaceTaskCenterInteractionRuntime( { + composerDraftCoordinator: scope.composerDraftCoordinator, + getComposerDraftNavigationEpoch: scope.getComposerDraftNavigationEpoch, chromeNavigation: { agentEntry, - applyProjectSelection, + activeTheme, + onStartNewConversation: scope.onStartNewConversation, clearEmbeddedHomeSession: clearTaskCenterEmbeddedHomeSession, detachedTopicId: taskCenterDetachedTopicId, displayMessageCount: displayMessages.length, draftSendRequest: taskCenterDraftSendRequest, + draftSurfaceActive: taskCenterHomeSurfaceActiveRef.current, embeddedHomeSessionIds: taskCenterEmbeddedHomeSessionIds, - externalProjectId, fallbackRestoreRef: taskCenterFallbackRestoreRef, hasDisplayMessages, hasLocalSessionOverride: taskCenterLocalSessionOverride !== null, @@ -320,13 +297,11 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ isPreparingSend, isSending, isSessionHydrating: scope.isSessionHydrating, - isThemeWorkbench: contextHarnessRuntime.isThemeWorkbench, layoutMode, messagesLength: messages.length, newChatAt, newConversationLabel, normalizedInitialSessionId, - onNavigate, onToggleWorkbench: canvasSurfaceRuntime.handleToggleCanvas, openTabIds: taskCenterOpenTabIds, openedProjects, @@ -334,7 +309,6 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ queuedTurnsLength: queuedTurnCount, renamePromptLabel, renameTopic, - resetProjectSelection, sessionId, setHarnessPanelVisible, shouldSuppressDraftContent: shouldSuppressTaskCenterDraftContent, @@ -346,65 +320,26 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ transitionTopicId: taskCenterTransitionTopicId, untitledTaskLabel, }, - draftMaterialization: { - activeTaskCenterDraftTabId: scope.activeTaskCenterDraftTabId, - agentEntry, - clearMessages, - createFreshSession, - input, - isPreparingSend, - isSending, - markTaskCenterEmbeddedHomeSession, - markTaskCenterLocalSessionOverride, - resetLocalImageWorkbenchSessionScope, - resetTopicLocalState, - resetComposerDraft: scope.resetComposerDraft, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, - setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, - setTaskCenterTransitionTopicId, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabs, - taskCenterWorkspaceId, - upsertTaskCenterOpenTab, - }, - persistMaterializedSessionNavigation: - persistTaskCenterMaterializedSessionNavigation, taskCenterSurface: { isTaskCenterDraftSurfaceActive, - isTaskCenterDraftTabActive, }, topicNavigation: { + onStartNewConversation: scope.onStartNewConversation, activeSessionIdRef, agentEntry, clearEntryPendingA2UI, - clearMessages, clearTaskCenterEmbeddedHomeSession, messagesLength: messages.length, replaceTaskCenterOpenTabs, resetLocalImageWorkbenchSessionScope, - resetTopicLocalState, - resetComposerDraft: scope.resetComposerDraft, sessionId, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTaskCenterLocalSessionOverride, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, switchTopic, taskCenterDetachedTopicId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterOpenTabIdsRef, taskCenterTransitionTopicId, taskCenterWorkspaceId, @@ -412,7 +347,6 @@ export function useAgentChatWorkspaceShellInteractionRuntime({ upsertTaskCenterOpenTab, markTaskCenterLocalSessionOverride, }, - switchTopic: originalSwitchTopic, }, ); diff --git a/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.test.tsx b/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.test.tsx index 181b32353..ca9f717cb 100644 --- a/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.test.tsx @@ -1,3 +1,4 @@ +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; import { useRef, useState } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -60,7 +61,9 @@ function Harness({ const [soulArtifactVoiceEnabledForTurn, setSoulArtifactVoiceEnabledForTurn] = useState(false); const triggerAIGuideRef = useRef(() => undefined); - const fallbackHandleSend = useRef(vi.fn(async () => true)).current; + const fallbackHandleSend = useRef( + vi.fn(async () => acceptedSubmission()), + ).current; const handleSend = handleSendOverride ?? fallbackHandleSend; const runtime = useGeneralWorkbenchInitialDispatchRuntime({ activeTheme: "general", @@ -272,7 +275,7 @@ describe("useGeneralWorkbenchInitialDispatchRuntime", () => { }); it("detached 初始自动发送在多实例重挂时应只启动一次", async () => { - const handleSend = vi.fn(async () => true); + const handleSend = vi.fn(async () => acceptedSubmission()); const onInitialUserPromptConsumed = vi.fn(); renderDualHarness({ diff --git a/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.ts b/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.ts index fe91f1450..2cdfaf3e0 100644 --- a/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.ts +++ b/src/components/agent/chat/workspace/useGeneralWorkbenchInitialDispatchRuntime.ts @@ -1,3 +1,4 @@ +import { isRuntimeSubmissionAccepted } from "../hooks/agentSendResult"; import { useCallback, useEffect, @@ -604,7 +605,7 @@ export function useGeneralWorkbenchInitialAutoGuideRuntime({ } : undefined, ); - if (!started) { + if (!isRuntimeSubmissionAccepted(started)) { releaseInitialAutoSendClaim(claimKey); if (disposed) { return; @@ -738,7 +739,7 @@ export function useGeneralWorkbenchInitialAutoGuideRuntime({ skipSessionRestore: true, }, ); - if (!started) { + if (!isRuntimeSubmissionAccepted(started)) { releaseInitialAutoSendClaim(claimKey); if (disposed) { return; diff --git a/src/components/agent/chat/workspace/useTaskCenterChromeNavigationRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterChromeNavigationRuntime.ts index b70c05cfe..5148c5862 100644 --- a/src/components/agent/chat/workspace/useTaskCenterChromeNavigationRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterChromeNavigationRuntime.ts @@ -5,18 +5,13 @@ import type { SetStateAction, } from "react"; import type { Topic } from "../hooks/agentChatShared"; -import type { AgentChatWorkspaceProps } from "../agentChatWorkspaceContract"; import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; import type { OpenedProjectSummary } from "../hooks/useOpenedProjectSummaries"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; -import type { - TaskCenterDraftTab, - TaskCenterHomeSurfaceState, -} from "./agentChatWorkspaceHelpers"; +import type { TaskCenterHomeSurfaceState } from "./agentChatWorkspaceHelpers"; import { useTaskCenterConversationNavigationRuntime } from "./useTaskCenterConversationNavigationRuntime"; import { useTaskCenterFallbackRestoreRuntime } from "./useTaskCenterFallbackRestoreRuntime"; import { useTaskCenterHomeChromeRuntime } from "./useTaskCenterHomeChromeRuntime"; -import { useTaskCenterNewTaskPageRuntime } from "./useTaskCenterNewTaskPageRuntime"; import { useTaskCenterTabChrome } from "./useTaskCenterTabChrome"; interface TaskCenterFallbackRestoreState { @@ -31,17 +26,14 @@ interface OpenTaskTopicOptions { } interface UseTaskCenterChromeNavigationRuntimeParams { - activeDraftTabId: string | null; agentEntry: "new-task" | "claw"; - applyProjectSelection: (projectId: string) => void; + activeTheme: string; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; clearEmbeddedHomeSession: (sessionId: string) => void; detachedTopicId: string | null; draftSendRequest: TaskCenterDraftSendRequest | null; draftSurfaceActive: boolean; - draftTabActive: boolean; - draftTabs: TaskCenterDraftTab[]; embeddedHomeSessionIds: ReadonlySet; - externalProjectId?: string | null; fallbackRestoreRef: MutableRefObject; hasDisplayMessages: boolean; hasLocalSessionOverride: boolean; @@ -59,29 +51,24 @@ interface UseTaskCenterChromeNavigationRuntimeParams { isSending: boolean; isSessionHydrating: boolean; isTaskCenterDraftSurfaceActive: boolean; - isTaskCenterDraftTabActive: boolean; - isThemeWorkbench: boolean; layoutMode: LayoutMode; messagesLength: number; newChatAt?: number; newConversationLabel: string; normalizedInitialSessionId?: string | null; onCloseTaskCenterTab: (topicId: string) => void; - onNavigate?: AgentChatWorkspaceProps["onNavigate"]; onOpenTaskTopic: ( topicId: string, options?: OpenTaskTopicOptions, ) => void | Promise; onSwitchTaskTopic: (topicId: string) => void | Promise; onToggleWorkbench: () => void; - openDraftTab: () => string; openTabIds: string[]; openedProjects: OpenedProjectSummary[]; projectId?: string | null; queuedTurnsLength: number; renamePromptLabel: string; renameTopic: (topicId: string, title: string) => void | Promise; - resetProjectSelection: () => void; sessionId?: string | null; setHarnessPanelVisible: Dispatch>; shouldSuppressDraftContent: boolean; @@ -121,18 +108,15 @@ interface TaskCenterChromeNavigationRuntime { } export function useTaskCenterChromeNavigationRuntime({ - activeDraftTabId, agentEntry, - applyProjectSelection, + activeTheme, + onStartNewConversation, clearEmbeddedHomeSession, detachedTopicId, displayMessageCount, draftSendRequest, draftSurfaceActive, - draftTabActive, - draftTabs, embeddedHomeSessionIds, - externalProjectId, fallbackRestoreRef, hasDisplayMessages, hasLocalSessionOverride, @@ -151,26 +135,21 @@ export function useTaskCenterChromeNavigationRuntime({ isSending, isSessionHydrating, isTaskCenterDraftSurfaceActive, - isTaskCenterDraftTabActive, - isThemeWorkbench, layoutMode, messagesLength, newChatAt, newConversationLabel, normalizedInitialSessionId, onCloseTaskCenterTab, - onNavigate, onOpenTaskTopic, onSwitchTaskTopic, onToggleWorkbench, - openDraftTab, openTabIds, openedProjects, projectId, queuedTurnsLength, renamePromptLabel, renameTopic, - resetProjectSelection, sessionId, setHarnessPanelVisible, shouldSuppressDraftContent, @@ -200,7 +179,6 @@ export function useTaskCenterChromeNavigationRuntime({ renamePromptLabel, renameTopic, draftSurfaceActive: isTaskCenterDraftSurfaceActive, - draftTabActive: isTaskCenterDraftTabActive, shouldSuppressDraftContent, draftSendRequest, normalizedInitialSessionId, @@ -237,15 +215,12 @@ export function useTaskCenterChromeNavigationRuntime({ onOpenTaskTopic, }); - const handleOpenTaskCenterNewTaskPage = useTaskCenterNewTaskPageRuntime({ - agentEntry, - externalProjectId, - normalizedInitialSessionId, - onNavigate, - applyProjectSelection, - resetProjectSelection, - openTaskCenterDraftTab: openDraftTab, - }); + const handleOpenTaskCenterNewTaskPage = () => { + onStartNewConversation?.({ + projectId: taskCenterWorkspaceId ?? null, + freshDraft: true, + }); + }; const { shouldHideDetachedTaskCenterTabs, @@ -255,6 +230,7 @@ export function useTaskCenterChromeNavigationRuntime({ browserWorkspaceHomeTabsNode, } = useTaskCenterTabChrome({ agentEntry, + activeTheme, sessionId, normalizedInitialSessionId, initialSessionName, @@ -262,9 +238,6 @@ export function useTaskCenterChromeNavigationRuntime({ openTabIds, topics, previewTopicId: taskCenterPreviewTopicId, - draftTabs, - activeDraftTabId, - isDraftTabActive: draftTabActive, hasLocalSessionOverride, topicById, untitledTaskLabel, @@ -272,7 +245,6 @@ export function useTaskCenterChromeNavigationRuntime({ newConversationLabel, newChatAt, homeMountedAt, - isThemeWorkbench, layoutMode, onSwitchTaskTopic, onRenameTaskTopic: handleRenameTaskTopic, @@ -287,7 +259,6 @@ export function useTaskCenterChromeNavigationRuntime({ isAutoRestoringSession, isSessionHydrating, draftSurfaceActive, - draftTabActive, initialPendingServiceSkillLaunchSignature, initialDispatchKey, isBootstrapDispatchPending, diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.test.tsx b/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.test.tsx deleted file mode 100644 index a00ac1ce1..000000000 --- a/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.test.tsx +++ /dev/null @@ -1,552 +0,0 @@ -import { act, useEffect, useRef, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Character } from "@/lib/api/projectMemory"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; -import { - TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS, - type TaskCenterDraftTab, -} from "./agentChatWorkspaceHelpers"; -import { useTaskCenterDraftMaterializationRuntime } from "./useTaskCenterDraftMaterializationRuntime"; - -const initialDraft: TaskCenterDraftTab = { - id: "task-draft-existing", - title: "新对话", - createdAt: new Date("2026-06-11T00:00:00.000Z"), - updatedAt: new Date("2026-06-11T00:00:00.000Z"), - status: "draft", -}; - -interface ProbeSnapshot { - activeDraftTabId: string | null; - draftTabs: TaskCenterDraftTab[]; - homePendingPreviewRequest: TaskCenterDraftSendRequest | null; - input: string; - taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - transitionTopicId: string | null; -} - -interface ProbeProps { - createFreshSession: ReturnType; - initialPendingRequest?: TaskCenterDraftSendRequest | null; - initialTransitionTopicId?: string | null; - onRuntime?: (runtime: { - commitMaterializedTaskCenterDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; - materializeTaskCenterDraftTab: ( - draftTabId: string, - options?: { reason?: "send" | "input_warmup"; commit?: boolean }, - ) => Promise; - openTaskCenterDraftTab: (options?: { - preservePendingSendRequest?: boolean; - }) => string; - }) => void; - onSnapshot: (snapshot: ProbeSnapshot) => void; - markTaskCenterEmbeddedHomeSession?: ReturnType; - markTaskCenterLocalSessionOverride?: ReturnType; - persistMaterializedSessionNavigation?: ReturnType; - switchMaterializedSession?: ReturnType; - upsertTaskCenterOpenTab: ReturnType; - resetComposerDraft?: () => void; -} - -function Probe({ - createFreshSession, - initialPendingRequest = null, - initialTransitionTopicId = null, - markTaskCenterEmbeddedHomeSession = vi.fn(), - markTaskCenterLocalSessionOverride = vi.fn(), - onRuntime, - onSnapshot, - persistMaterializedSessionNavigation = vi.fn(), - switchMaterializedSession = vi.fn(), - upsertTaskCenterOpenTab, - resetComposerDraft, -}: ProbeProps) { - const [draftTabs, setDraftTabs] = useState([ - initialDraft, - ]); - const [activeDraftTabId, setActiveTaskCenterDraftTabId] = useState< - string | null - >(initialDraft.id); - const [input, setInput] = useState("残留输入"); - const [homePendingPreviewRequest, setHomePendingPreviewRequest] = - useState(initialPendingRequest); - const [taskCenterDraftSendRequest, setTaskCenterDraftSendRequest] = - useState(initialPendingRequest); - const [, setMentionedCharacters] = useState([]); - const [, setSelectedText] = useState(""); - const [, setTaskCenterDetachedTopicId] = useState(null); - const [transitionTopicId, setTaskCenterTransitionTopicId] = useState< - string | null - >(initialTransitionTopicId); - const draftSurfaceActiveRef = useRef(true); - - const runtime = useTaskCenterDraftMaterializationRuntime({ - activeTaskCenterDraftTabId: activeDraftTabId, - agentEntry: "claw", - clearMessages: vi.fn(), - createFreshSession, - input, - isPreparingSend: false, - isSending: false, - markTaskCenterEmbeddedHomeSession, - markTaskCenterLocalSessionOverride, - resetLocalImageWorkbenchSessionScope: vi.fn(), - resetTopicLocalState: vi.fn(), - resetComposerDraft, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, - setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs: setDraftTabs, - setTaskCenterTransitionTopicId, - persistMaterializedSessionNavigation, - switchMaterializedSession, - taskCenterDraftSurfaceActiveRef: draftSurfaceActiveRef, - taskCenterDraftTabs: draftTabs, - taskCenterWorkspaceId: "workspace-test", - upsertTaskCenterOpenTab, - }); - - useEffect(() => { - onSnapshot({ - activeDraftTabId, - draftTabs, - homePendingPreviewRequest, - input, - taskCenterDraftSendRequest, - transitionTopicId, - }); - }, [ - activeDraftTabId, - draftTabs, - homePendingPreviewRequest, - input, - onSnapshot, - taskCenterDraftSendRequest, - transitionTopicId, - ]); - - useEffect(() => { - onRuntime?.({ - commitMaterializedTaskCenterDraftTab: - runtime.commitMaterializedTaskCenterDraftTab, - materializeTaskCenterDraftTab: runtime.materializeTaskCenterDraftTab, - openTaskCenterDraftTab: runtime.openTaskCenterDraftTab, - }); - }, [ - onRuntime, - runtime.commitMaterializedTaskCenterDraftTab, - runtime.materializeTaskCenterDraftTab, - runtime.openTaskCenterDraftTab, - ]); - - return null; -} - -describe("useTaskCenterDraftMaterializationRuntime", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - ( - globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; - } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.useFakeTimers(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => { - root.unmount(); - }); - container.remove(); - vi.useRealTimers(); - }); - - it("输入预热延迟应保持在首发热路径预算内", () => { - expect(TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS).toBeGreaterThan(0); - expect(TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS).toBeLessThanOrEqual(50); - }); - - it("首页 materialization 延续共享 scope,新草稿入口重置 scope", async () => { - const resetComposerDraft = vi.fn(); - let current!: Parameters>[0]; - await act(async () => { - root.render( undefined} onRuntime={(value) => { current = value; }} />); - }); - await act(async () => { - expect(await current.materializeTaskCenterDraftTab(initialDraft.id, { commit: false })).toBe("session-materialized"); - current.commitMaterializedTaskCenterDraftTab(initialDraft.id, "session-materialized", { - embedHomeSession: false, hydrateSession: false, preserveInput: true, syncRoute: false }); - }); - expect(resetComposerDraft).not.toHaveBeenCalled(); - act(() => { current.openTaskCenterDraftTab(); }); - expect(resetComposerDraft).toHaveBeenCalledOnce(); - }); - - it("输入预热只应创建底层会话,不应提交并移除草稿标签", async () => { - const createFreshSession = vi.fn(async () => "session-warmup"); - const markTaskCenterLocalSessionOverride = vi.fn(); - const upsertTaskCenterOpenTab = vi.fn(); - const snapshots: ProbeSnapshot[] = []; - - await act(async () => { - root.render( - { - snapshots.push(snapshot); - }} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - await act(async () => { - vi.advanceTimersByTime(TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS + 1); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(createFreshSession).toHaveBeenCalledWith("新对话", { - activateSession: false, - preserveCurrentSnapshot: false, - skipSessionStartHooks: true, - }); - expect(upsertTaskCenterOpenTab).not.toHaveBeenCalled(); - expect(markTaskCenterLocalSessionOverride).not.toHaveBeenCalled(); - const latestSnapshot = snapshots.at(-1); - expect(latestSnapshot?.activeDraftTabId).toBe(initialDraft.id); - expect(latestSnapshot?.draftTabs.map((tab) => tab.id)).toEqual([ - initialDraft.id, - ]); - }); - - it("首页首发打开草稿标签时不应清掉已排队的发送请求", async () => { - const createFreshSession = vi.fn(async () => "session-send"); - const upsertTaskCenterOpenTab = vi.fn(); - const snapshots: ProbeSnapshot[] = []; - let openDraftTab: - | ((options?: { preservePendingSendRequest?: boolean }) => string) - | null = null; - const pendingRequest: TaskCenterDraftSendRequest = { - id: "draft-send-home", - draftTabId: "task-draft-home", - text: "真实 E2E 目标", - images: [], - submittedAt: Date.now(), - materializeDraft: true, - source: "task-center-empty-state", - }; - - await act(async () => { - root.render( - { - openDraftTab = runtime.openTaskCenterDraftTab; - }} - onSnapshot={(snapshot) => { - snapshots.push(snapshot); - }} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - await act(async () => { - openDraftTab?.({ preservePendingSendRequest: true }); - await Promise.resolve(); - }); - - const latestSnapshot = snapshots.at(-1); - expect(latestSnapshot?.taskCenterDraftSendRequest?.id).toBe( - pendingRequest.id, - ); - expect(latestSnapshot?.homePendingPreviewRequest?.id).toBe( - pendingRequest.id, - ); - expect(latestSnapshot?.draftTabs).toHaveLength(2); - }); - - it("首页首发打开草稿后应能立即 materialize 新草稿", async () => { - const createFreshSession = vi.fn(async () => "session-immediate-send"); - const upsertTaskCenterOpenTab = vi.fn(); - const snapshots: ProbeSnapshot[] = []; - let runtime: { - commitMaterializedTaskCenterDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; - materializeTaskCenterDraftTab: ( - draftTabId: string, - options?: { reason?: "send" | "input_warmup"; commit?: boolean }, - ) => Promise; - openTaskCenterDraftTab: (options?: { - preservePendingSendRequest?: boolean; - }) => string; - } | null = null; - - await act(async () => { - root.render( - { - runtime = nextRuntime; - }} - onSnapshot={(snapshot) => { - snapshots.push(snapshot); - }} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - let draftTabId = ""; - let materializedSessionId: string | null = null; - await act(async () => { - draftTabId = - runtime?.openTaskCenterDraftTab({ - preservePendingSendRequest: true, - }) ?? ""; - materializedSessionId = - (await runtime?.materializeTaskCenterDraftTab(draftTabId, { - reason: "send", - commit: false, - })) ?? null; - }); - - expect(draftTabId).toMatch(/^task-draft-/); - expect(materializedSessionId).toBe("session-immediate-send"); - expect(createFreshSession).toHaveBeenCalledWith("新对话", { - activateSession: false, - preserveCurrentSnapshot: false, - skipSessionStartHooks: true, - }); - expect( - snapshots.at(-1)?.draftTabs.some((tab) => tab.id === draftTabId), - ).toBe(true); - }); - - it("commit materialized 草稿后应切入正式 session 以便刷新恢复", async () => { - const createFreshSession = vi.fn(async () => "session-committed-send"); - const persistMaterializedSessionNavigation = vi.fn(); - const switchMaterializedSession = vi.fn(async () => "success"); - const upsertTaskCenterOpenTab = vi.fn(); - const snapshots: ProbeSnapshot[] = []; - let runtime: { - materializeTaskCenterDraftTab: ( - draftTabId: string, - options?: { reason?: "send" | "input_warmup"; commit?: boolean }, - ) => Promise; - openTaskCenterDraftTab: (options?: { - preservePendingSendRequest?: boolean; - }) => string; - } | null = null; - - await act(async () => { - root.render( - { - runtime = nextRuntime; - }} - onSnapshot={(snapshot) => { - snapshots.push(snapshot); - }} - persistMaterializedSessionNavigation={ - persistMaterializedSessionNavigation - } - switchMaterializedSession={switchMaterializedSession} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - await act(async () => { - const draftTabId = - runtime?.openTaskCenterDraftTab({ - preservePendingSendRequest: true, - }) ?? ""; - await runtime?.materializeTaskCenterDraftTab(draftTabId, { - reason: "send", - }); - }); - - expect(persistMaterializedSessionNavigation).toHaveBeenCalledWith( - "session-committed-send", - ); - expect(switchMaterializedSession).toHaveBeenCalledWith( - "session-committed-send", - { - allowDetachedSession: true, - forceRefresh: true, - }, - ); - expect(snapshots.at(-1)?.transitionTopicId).toBeNull(); - }); - - it("commit materialized 草稿可只同步路由不拉详情", async () => { - const createFreshSession = vi.fn(async () => "session-navigation-only"); - const markTaskCenterEmbeddedHomeSession = vi.fn(); - const markTaskCenterLocalSessionOverride = vi.fn(); - const persistMaterializedSessionNavigation = vi.fn(); - const switchMaterializedSession = vi.fn(async () => "success"); - const upsertTaskCenterOpenTab = vi.fn(); - let runtime: { - commitMaterializedTaskCenterDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; - openTaskCenterDraftTab: (options?: { - preservePendingSendRequest?: boolean; - }) => string; - } | null = null; - - await act(async () => { - root.render( - { - runtime = nextRuntime; - }} - onSnapshot={() => undefined} - persistMaterializedSessionNavigation={ - persistMaterializedSessionNavigation - } - switchMaterializedSession={switchMaterializedSession} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - await act(async () => { - const draftTabId = - runtime?.openTaskCenterDraftTab({ - preservePendingSendRequest: true, - }) ?? ""; - runtime?.commitMaterializedTaskCenterDraftTab( - draftTabId, - "session-navigation-only", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - }, - ); - await Promise.resolve(); - }); - - expect(upsertTaskCenterOpenTab).toHaveBeenCalledWith( - "session-navigation-only", - "workspace-test", - ); - expect(persistMaterializedSessionNavigation).toHaveBeenCalledWith( - "session-navigation-only", - ); - expect(switchMaterializedSession).not.toHaveBeenCalled(); - expect(markTaskCenterEmbeddedHomeSession).not.toHaveBeenCalled(); - expect(markTaskCenterLocalSessionOverride).toHaveBeenCalledWith( - "session-navigation-only", - ); - }); - - it("内部发送 commit materialized 草稿时不应写路由或触发历史切换", async () => { - const createFreshSession = vi.fn(async () => "session-internal-send"); - const persistMaterializedSessionNavigation = vi.fn(); - const switchMaterializedSession = vi.fn(async () => "success"); - const upsertTaskCenterOpenTab = vi.fn(); - let runtime: { - commitMaterializedTaskCenterDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; - openTaskCenterDraftTab: (options?: { - preservePendingSendRequest?: boolean; - }) => string; - } | null = null; - - await act(async () => { - root.render( - { - runtime = nextRuntime; - }} - onSnapshot={() => undefined} - persistMaterializedSessionNavigation={ - persistMaterializedSessionNavigation - } - switchMaterializedSession={switchMaterializedSession} - upsertTaskCenterOpenTab={upsertTaskCenterOpenTab} - />, - ); - }); - - await act(async () => { - const draftTabId = - runtime?.openTaskCenterDraftTab({ - preservePendingSendRequest: true, - }) ?? ""; - runtime?.commitMaterializedTaskCenterDraftTab( - draftTabId, - "session-internal-send", - { syncRoute: false }, - ); - await Promise.resolve(); - }); - - expect(upsertTaskCenterOpenTab).toHaveBeenCalledWith( - "session-internal-send", - "workspace-test", - ); - expect(persistMaterializedSessionNavigation).not.toHaveBeenCalled(); - expect(switchMaterializedSession).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.ts deleted file mode 100644 index e70b7eff4..000000000 --- a/src/components/agent/chat/workspace/useTaskCenterDraftMaterializationRuntime.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { - startTransition, - useCallback, - useEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; -import type { Character } from "@/lib/api/projectMemory"; -import { logAgentDebug } from "@/lib/agentDebug"; -import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; -import { rememberInitialSessionNavigationStart } from "./useWorkspaceInitialSessionNavigation"; -import { - createTaskCenterDraftTabId, - TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS, - type TaskCenterDraftTab, -} from "./agentChatWorkspaceHelpers"; -import { - buildTaskCenterDraftTab, - clearActiveTaskCenterDraftTab, - markTaskCenterDraftTabFailed, - removeTaskCenterDraftTab, - resolveActiveTaskCenterDraftTabId, - shouldWarmupTaskCenterDraftSession, - upsertTaskCenterDraftTab, -} from "./taskCenterDraftTabs"; - -type AgentEntry = "new-task" | "claw"; - -type CreateFreshSession = ( - sessionName?: string, - options?: { - activateSession?: boolean; - preserveCurrentSnapshot?: boolean; - skipSessionStartHooks?: boolean; - }, -) => Promise; - -type SwitchMaterializedSession = ( - sessionId: string, - options?: { forceRefresh?: boolean; allowDetachedSession?: boolean }, -) => Promise; - -type PersistMaterializedSessionNavigation = (sessionId: string) => void; - -interface CommitMaterializedDraftOptions { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; -} - -interface UseTaskCenterDraftMaterializationRuntimeParams { - activeTaskCenterDraftTabId: string | null; - agentEntry: AgentEntry; - clearMessages: (options?: { showToast?: boolean }) => void; - createFreshSession: CreateFreshSession; - input: string; - isPreparingSend: boolean; - isSending: boolean; - markTaskCenterEmbeddedHomeSession: (topicId: string) => void; - markTaskCenterLocalSessionOverride: (topicId: string) => void; - resetLocalImageWorkbenchSessionScope: () => void; - resetTopicLocalState: () => void; - resetComposerDraft?: () => void; - setActiveTaskCenterDraftTabId: Dispatch>; - setHomePendingPreviewRequest: Dispatch< - SetStateAction - >; - setInput: Dispatch>; - setMentionedCharacters: Dispatch>; - setSelectedText: Dispatch>; - setTaskCenterDetachedTopicId: Dispatch>; - setTaskCenterDraftSendRequest: Dispatch< - SetStateAction - >; - setTaskCenterDraftTabs: Dispatch>; - setTaskCenterTransitionTopicId: Dispatch>; - persistMaterializedSessionNavigation?: PersistMaterializedSessionNavigation; - switchMaterializedSession?: SwitchMaterializedSession; - taskCenterDraftSurfaceActiveRef: MutableRefObject; - taskCenterDraftTabs: TaskCenterDraftTab[]; - taskCenterWorkspaceId?: string | null; - upsertTaskCenterOpenTab: ( - topicId: string, - workspaceIdOverride?: string | null, - ) => void; -} - -export function useTaskCenterDraftMaterializationRuntime({ - activeTaskCenterDraftTabId, - agentEntry, - clearMessages, - createFreshSession, - input, - isPreparingSend, - isSending, - markTaskCenterEmbeddedHomeSession, - markTaskCenterLocalSessionOverride, - resetLocalImageWorkbenchSessionScope, - resetTopicLocalState, - resetComposerDraft, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, - setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, - setTaskCenterTransitionTopicId, - persistMaterializedSessionNavigation, - switchMaterializedSession, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabs, - taskCenterWorkspaceId, - upsertTaskCenterOpenTab, -}: UseTaskCenterDraftMaterializationRuntimeParams) { - const taskCenterDraftTabsRef = useRef([]); - const activeTaskCenterDraftTabIdRef = useRef(null); - const taskCenterDraftMaterializePromisesRef = useRef< - Map> - >(new Map()); - const taskCenterDraftMaterializedSessionIdsRef = useRef>( - new Map(), - ); - const taskCenterDraftWarmupSessionIdsRef = useRef>(new Set()); - - useEffect(() => { - taskCenterDraftTabsRef.current = taskCenterDraftTabs; - }, [taskCenterDraftTabs]); - - useEffect(() => { - activeTaskCenterDraftTabIdRef.current = activeTaskCenterDraftTabId; - }, [activeTaskCenterDraftTabId]); - - const finalizeFreshTaskCenterConversation = useCallback( - ( - newSessionId: string, - workspaceIdOverride?: string | null, - options?: { embedHomeSession?: boolean; preserveInput?: boolean }, - ) => { - resetTopicLocalState(); - if (options?.preserveInput !== true) { - resetComposerDraft?.(); - setInput(""); - } - setSelectedText(""); - setMentionedCharacters([]); - upsertTaskCenterOpenTab(newSessionId, workspaceIdOverride); - if (options?.embedHomeSession !== false) { - markTaskCenterEmbeddedHomeSession(newSessionId); - } - markTaskCenterLocalSessionOverride(newSessionId); - }, - [ - markTaskCenterEmbeddedHomeSession, - markTaskCenterLocalSessionOverride, - resetTopicLocalState, - resetComposerDraft, - setInput, - setMentionedCharacters, - setSelectedText, - upsertTaskCenterOpenTab, - ], - ); - - const openTaskCenterDraftTab = useCallback( - (options?: { preservePendingSendRequest?: boolean }) => { - resetComposerDraft?.(); - const draftTab = buildTaskCenterDraftTab({ - id: createTaskCenterDraftTabId(), - }); - - taskCenterDraftSurfaceActiveRef.current = true; - activeTaskCenterDraftTabIdRef.current = draftTab.id; - taskCenterDraftTabsRef.current = upsertTaskCenterDraftTab( - taskCenterDraftTabsRef.current, - draftTab, - ); - resetLocalImageWorkbenchSessionScope(); - clearMessages({ showToast: false }); - startTransition(() => { - setTaskCenterTransitionTopicId(null); - setTaskCenterDetachedTopicId(null); - setActiveTaskCenterDraftTabId(draftTab.id); - if (options?.preservePendingSendRequest !== true) { - setTaskCenterDraftSendRequest(null); - setHomePendingPreviewRequest(null); - } - setTaskCenterDraftTabs((current) => - upsertTaskCenterDraftTab(current, draftTab), - ); - resetTopicLocalState(); - setInput(""); - setSelectedText(""); - setMentionedCharacters([]); - }); - - logAgentDebug("AgentChatPage", "taskCenter.draftTab.open", { - draftTabId: draftTab.id, - workspaceId: taskCenterWorkspaceId, - }); - - return draftTab.id; - }, - [ - clearMessages, - resetLocalImageWorkbenchSessionScope, - resetComposerDraft, - resetTopicLocalState, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, - setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, - setTaskCenterTransitionTopicId, - taskCenterDraftSurfaceActiveRef, - taskCenterWorkspaceId, - ], - ); - - const commitMaterializedTaskCenterDraftTab = useCallback( - ( - draftTabId: string, - newSessionId: string, - options?: CommitMaterializedDraftOptions, - ) => { - taskCenterDraftSurfaceActiveRef.current = false; - taskCenterDraftWarmupSessionIdsRef.current.delete(draftTabId); - startTransition(() => { - setTaskCenterTransitionTopicId(null); - setTaskCenterDraftTabs((current) => - removeTaskCenterDraftTab(current, draftTabId), - ); - setActiveTaskCenterDraftTabId((current) => - clearActiveTaskCenterDraftTab(current, draftTabId), - ); - finalizeFreshTaskCenterConversation( - newSessionId, - taskCenterWorkspaceId, - options, - ); - }); - if (options?.syncRoute !== false) { - rememberInitialSessionNavigationStart(newSessionId); - persistMaterializedSessionNavigation?.(newSessionId); - if (switchMaterializedSession && options?.hydrateSession !== false) { - void switchMaterializedSession(newSessionId, { - allowDetachedSession: true, - forceRefresh: true, - }).catch((error) => { - logAgentDebug( - "AgentChatPage", - "taskCenter.draftTab.commit.switchSessionError", - { - draftTabId, - error, - newSessionId, - workspaceId: taskCenterWorkspaceId, - }, - { level: "error" }, - ); - }); - } - } - }, - [ - finalizeFreshTaskCenterConversation, - persistMaterializedSessionNavigation, - setActiveTaskCenterDraftTabId, - setTaskCenterDraftTabs, - setTaskCenterTransitionTopicId, - switchMaterializedSession, - taskCenterDraftSurfaceActiveRef, - taskCenterWorkspaceId, - ], - ); - - const materializeTaskCenterDraftTab = useCallback( - async ( - draftTabId: string, - options?: { - reason?: "send" | "input_warmup"; - commit?: boolean; - }, - ): Promise => { - const reason = options?.reason ?? "send"; - const materializedSessionId = - taskCenterDraftMaterializedSessionIdsRef.current.get(draftTabId); - if (materializedSessionId) { - return materializedSessionId; - } - - const existingPromise = - taskCenterDraftMaterializePromisesRef.current.get(draftTabId); - if (existingPromise) { - logAgentDebug( - "AgentChatPage", - "taskCenter.draftTab.materialize.reuse", - { - draftTabId, - reason, - workspaceId: taskCenterWorkspaceId, - }, - ); - return existingPromise; - } - - const draftExists = taskCenterDraftTabsRef.current.some( - (tab) => tab.id === draftTabId, - ); - if (!draftExists) { - return null; - } - if (reason === "input_warmup") { - taskCenterDraftWarmupSessionIdsRef.current.add(draftTabId); - } - - const startedAt = Date.now(); - logAgentDebug("AgentChatPage", "taskCenter.draftTab.materialize.start", { - draftTabId, - reason, - workspaceId: taskCenterWorkspaceId, - }); - recordAgentUiPerformanceMetric("taskCenter.draftMaterialize.start", { - sessionId: draftTabId, - reason, - workspaceId: taskCenterWorkspaceId, - }); - - const materializePromise = (async () => { - const newSessionId = await createFreshSession("新对话", { - activateSession: options?.commit !== false, - preserveCurrentSnapshot: false, - skipSessionStartHooks: true, - }); - if (!newSessionId) { - setTaskCenterDraftTabs((current) => - markTaskCenterDraftTabFailed(current, draftTabId), - ); - recordAgentUiPerformanceMetric("taskCenter.draftMaterialize.error", { - durationMs: Date.now() - startedAt, - reason, - sessionId: draftTabId, - workspaceId: taskCenterWorkspaceId, - }); - taskCenterDraftWarmupSessionIdsRef.current.delete(draftTabId); - return null; - } - taskCenterDraftMaterializedSessionIdsRef.current.set( - draftTabId, - newSessionId, - ); - - if (options?.commit !== false) { - commitMaterializedTaskCenterDraftTab(draftTabId, newSessionId, { - preserveInput: true, - }); - } - - logAgentDebug("AgentChatPage", "taskCenter.draftTab.materialize.done", { - draftTabId, - durationMs: Date.now() - startedAt, - newSessionId, - reason, - workspaceId: taskCenterWorkspaceId, - }); - recordAgentUiPerformanceMetric("taskCenter.draftMaterialize.success", { - durationMs: Date.now() - startedAt, - materializedSessionId: newSessionId, - reason, - sessionId: draftTabId, - workspaceId: taskCenterWorkspaceId, - }); - return newSessionId; - })(); - - const trackedPromise = materializePromise.finally(() => { - if ( - taskCenterDraftMaterializePromisesRef.current.get(draftTabId) === - trackedPromise - ) { - taskCenterDraftMaterializePromisesRef.current.delete(draftTabId); - } - }); - taskCenterDraftMaterializePromisesRef.current.set( - draftTabId, - trackedPromise, - ); - return trackedPromise; - }, - [ - commitMaterializedTaskCenterDraftTab, - createFreshSession, - setTaskCenterDraftTabs, - taskCenterWorkspaceId, - ], - ); - - useEffect(() => { - const draftTabId = resolveActiveTaskCenterDraftTabId({ - draftTabs: taskCenterDraftTabs, - activeDraftTabId: activeTaskCenterDraftTabId, - }); - if ( - !draftTabId || - !shouldWarmupTaskCenterDraftSession({ - agentEntry, - activeDraftTabId: draftTabId, - draftTabs: taskCenterDraftTabs, - input, - isPreparingSend, - isSending, - }) - ) { - return; - } - - const timer = window.setTimeout(() => { - logAgentDebug("AgentChatPage", "taskCenter.draftTab.warmup.request", { - draftTabId, - inputLength: input.trim().length, - workspaceId: taskCenterWorkspaceId, - }); - void materializeTaskCenterDraftTab(draftTabId, { - reason: "input_warmup", - commit: false, - }).catch((error) => { - logAgentDebug( - "AgentChatPage", - "taskCenter.draftTab.warmup.error", - { - draftTabId, - error, - workspaceId: taskCenterWorkspaceId, - }, - { level: "error" }, - ); - }); - }, TASK_CENTER_DRAFT_SESSION_WARMUP_DELAY_MS); - - return () => { - window.clearTimeout(timer); - }; - }, [ - activeTaskCenterDraftTabId, - agentEntry, - input, - isPreparingSend, - isSending, - materializeTaskCenterDraftTab, - taskCenterDraftTabs, - taskCenterWorkspaceId, - ]); - - return { - activeTaskCenterDraftTabIdRef, - commitMaterializedTaskCenterDraftTab, - materializeTaskCenterDraftTab, - openTaskCenterDraftTab, - taskCenterDraftMaterializedSessionIdsRef, - taskCenterDraftWarmupSessionIdsRef, - taskCenterDraftTabsRef, - }; -} diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts index 77080256c..5f8f78ccb 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendDispatchRuntime.ts @@ -1,3 +1,5 @@ +import { isRuntimeSubmissionAccepted } from "../hooks/agentSendResult"; +import { createTaskCenterSubmissionSettlement } from "./taskCenterSubmissionSettlement"; import { useEffect, useRef, @@ -8,7 +10,10 @@ import { import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; import { mergeAgentUiPerformanceTraceMetadata } from "../hooks/agentStreamPerformanceMetrics"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; +import type { + TaskCenterDraftSendRequest, + TaskCenterSessionReadyOptions, +} from "../homePendingPreview"; import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; import { scheduleAfterNextPaint } from "./agentChatWorkspaceHelpers"; @@ -23,50 +28,19 @@ interface UseTaskCenterDraftSendDispatchRuntimeParams { messagesLength: number; displayMessagesLength?: number; currentSessionId?: string | null; - materializedSessionIdsRef: MutableRefObject>; - prewarmedDraftSessionIdsRef?: MutableRefObject>; - materializeDraftTab: ( - draftTabId: string, - options: { reason: "send"; commit: false }, - ) => Promise; - commitMaterializedDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; - onNonMaterializedSessionReady?: ( + onSessionAccepted?: ( sessionId: string, - options?: { sourceDraftTabId?: string | null }, + options?: TaskCenterSessionReadyOptions, ) => void; + isSendSourceCurrent?: ( + options: TaskCenterSessionReadyOptions, + acceptedSessionId?: string, + ) => boolean; restoreInput?: (value: string) => void; sendRef: MutableRefObject; workspaceId?: string | null; } -function isTemporaryDraftSendRequestId(value: string): boolean { - return value.startsWith("draft-send-"); -} - -function resolveNonMaterializedReadySessionId( - request: TaskCenterDraftSendRequest, - currentSessionId?: string | null, -): string | null { - const activeSessionId = currentSessionId?.trim(); - if (activeSessionId) { - return activeSessionId; - } - - const requestSessionId = request.draftTabId.trim(); - return requestSessionId && !isTemporaryDraftSendRequestId(requestSessionId) - ? requestSessionId - : null; -} - function isDraftSendDispatchRuntimeOwned( request: TaskCenterDraftSendRequest, ): boolean { @@ -80,11 +54,8 @@ export function useTaskCenterDraftSendDispatchRuntime({ messagesLength, displayMessagesLength = messagesLength, currentSessionId, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - materializeDraftTab, - commitMaterializedDraftTab, - onNonMaterializedSessionReady, + onSessionAccepted, + isSendSourceCurrent, restoreInput, sendRef, workspaceId, @@ -101,11 +72,18 @@ export function useTaskCenterDraftSendDispatchRuntime({ useEffect(() => { mountedRef.current = true; + const dispatchedRequestIds = dispatchedRequestIdsRef.current; return () => { mountedRef.current = false; - latestDispatchContextRef.current.taskCenterDraftSendRequest?.onDispatchSettled?.( - false, - ); + const request = + latestDispatchContextRef.current.taskCenterDraftSendRequest; + if ( + request && + isDraftSendDispatchRuntimeOwned(request) && + !dispatchedRequestIds.has(request.id) + ) { + request.onDispatchSettled?.({ kind: "rejected" }); + } }; }, []); @@ -133,32 +111,29 @@ export function useTaskCenterDraftSendDispatchRuntime({ useEffect(() => { if ( !taskCenterDraftSendRequest || - !isDraftSendDispatchRuntimeOwned(taskCenterDraftSendRequest) || - taskCenterDraftSendRequest.materializeDraft + !isDraftSendDispatchRuntimeOwned(taskCenterDraftSendRequest) ) { return; } - const readySessionId = resolveNonMaterializedReadySessionId( - taskCenterDraftSendRequest, - currentSessionId, - ); + const readySessionId = taskCenterDraftSendRequest.sessionId; if (!readySessionId) { return; } if ( taskCenterDraftSendRequest.sessionReady && - taskCenterDraftSendRequest.draftTabId === readySessionId + taskCenterDraftSendRequest.sessionId === readySessionId ) { return; } - onNonMaterializedSessionReady?.(readySessionId); + if (isSendSourceCurrent?.(taskCenterDraftSendRequest) === false) return; + onSessionAccepted?.(readySessionId, taskCenterDraftSendRequest); setTaskCenterDraftSendRequest((current) => current?.id === taskCenterDraftSendRequest.id ? { ...current, - draftTabId: readySessionId, + sessionId: readySessionId, sessionReady: true, } : current, @@ -167,14 +142,15 @@ export function useTaskCenterDraftSendDispatchRuntime({ current?.id === taskCenterDraftSendRequest.id ? { ...current, - draftTabId: readySessionId, + sessionId: readySessionId, sessionReady: true, } : current, ); }, [ currentSessionId, - onNonMaterializedSessionReady, + onSessionAccepted, + isSendSourceCurrent, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, @@ -184,8 +160,7 @@ export function useTaskCenterDraftSendDispatchRuntime({ if ( messagesLength === 0 || !taskCenterDraftSendRequest || - !isDraftSendDispatchRuntimeOwned(taskCenterDraftSendRequest) || - taskCenterDraftSendRequest.materializeDraft + !isDraftSendDispatchRuntimeOwned(taskCenterDraftSendRequest) ) { return; } @@ -194,31 +169,33 @@ export function useTaskCenterDraftSendDispatchRuntime({ if ( !current || !isDraftSendDispatchRuntimeOwned(current) || - current.materializeDraft || current.id !== taskCenterDraftSendRequest.id ) { return current; } - const readySessionId = resolveNonMaterializedReadySessionId( - current, - currentSessionId, - ); + const readySessionId = current.sessionId; if (!readySessionId) { return current; } - if (!current.sessionReady || current.draftTabId !== readySessionId) { - onNonMaterializedSessionReady?.(readySessionId); + if (isSendSourceCurrent?.(current) === false) return current; + if (!current.sessionReady || current.sessionId !== readySessionId) { + onSessionAccepted?.(readySessionId, current); } return null; }); setHomePendingPreviewRequest((current) => - current && isDraftSendDispatchRuntimeOwned(current) ? null : current, + current?.id === taskCenterDraftSendRequest.id && + isDraftSendDispatchRuntimeOwned(current) && + isSendSourceCurrent?.(current) !== false + ? null + : current, ); }, [ displayMessagesLength, currentSessionId, messagesLength, - onNonMaterializedSessionReady, + onSessionAccepted, + isSendSourceCurrent, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, @@ -233,10 +210,7 @@ export function useTaskCenterDraftSendDispatchRuntime({ } const request = taskCenterDraftSendRequest; - if ( - !request.materializeDraft && - !resolveNonMaterializedReadySessionId(request, currentSessionId) - ) { + if (!request.sessionId) { return; } if ( @@ -253,7 +227,8 @@ export function useTaskCenterDraftSendDispatchRuntime({ !mountedRef.current || dispatchedRequestIdsRef.current.has(request.id) ) { - if (!mountedRef.current) request.onDispatchSettled?.(false); + if (!mountedRef.current) + request.onDispatchSettled?.({ kind: "rejected" }); return; } @@ -264,16 +239,10 @@ export function useTaskCenterDraftSendDispatchRuntime({ latestRequest.id !== request.id || !isDraftSendDispatchRuntimeOwned(latestRequest) ) { - request.onDispatchSettled?.(false); + request.onDispatchSettled?.({ kind: "rejected" }); return; } - if ( - !latestRequest.materializeDraft && - !resolveNonMaterializedReadySessionId( - latestRequest, - latestContext.currentSessionId, - ) - ) { + if (!latestRequest.sessionId) { return; } @@ -288,86 +257,39 @@ export function useTaskCenterDraftSendDispatchRuntime({ current?.id === latestRequest.id ? null : current, ); } - if (latestRequest.materializeDraft) { - materializedSessionIdsRef.current.delete(latestRequest.draftTabId); - } }; + const settlement = createTaskCenterSubmissionSettlement( + latestRequest.sendOptions?.observer, + latestRequest.onDispatchSettled, + ); void (async () => { - const readyMaterializedSessionId = latestRequest.materializeDraft - ? (materializedSessionIdsRef.current.get(latestRequest.draftTabId) ?? - null) - : null; - const materializedSessionId = latestRequest.materializeDraft - ? (readyMaterializedSessionId ?? - (await materializeDraftTab(latestRequest.draftTabId, { - reason: "send", - commit: false, - }))) - : null; - if (latestRequest.materializeDraft && !materializedSessionId) { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - latestRequest.submittedAt, - error: "draft_materialize_failed", - requestId: latestRequest.id, - sessionId: latestRequest.draftTabId, - source: latestRequest.source, - workspaceId: dispatchWorkspaceId ?? null, - }); - clearRequest(); - latestRequest.onDispatchSettled?.(false); - return; - } - if ( !mountedRef.current || latestDispatchContextRef.current.taskCenterDraftSendRequest?.id !== latestRequest.id ) { - latestRequest.onDispatchSettled?.(false); + settlement.settle({ kind: "rejected" }); return; } - const dispatchSessionId = - materializedSessionId ?? latestRequest.draftTabId; - if (latestRequest.materializeDraft && materializedSessionId) { - const markMaterializedSessionReady = ( - current: TaskCenterDraftSendRequest | null, - ) => - current?.id === latestRequest.id - ? { - ...current, - draftTabId: materializedSessionId, - sessionReady: true, - } - : current; - setTaskCenterDraftSendRequest(markMaterializedSessionReady); - setHomePendingPreviewRequest(markMaterializedSessionReady); + const dispatchSessionId = latestRequest.sessionId; + if (!dispatchSessionId) { + settlement.settle({ kind: "rejected" }); + return; } - if (latestRequest.materializeDraft && materializedSessionId) { - const commitOptions: Parameters< - typeof commitMaterializedDraftTab - >[2] = { - embedHomeSession: false, - hydrateSession: false, - preserveInput: Boolean(latestRequest.onDispatchSettled), - syncRoute: false, - }; - commitMaterializedDraftTab( - latestRequest.draftTabId, - materializedSessionId, - commitOptions, - ); - } - recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { - elapsedMs: Date.now() - latestRequest.submittedAt, - requestId: latestRequest.id, - sessionId: dispatchSessionId, - source: latestRequest.source, - workspaceId: dispatchWorkspaceId ?? null, - }); + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { + elapsedMs: Date.now() - latestRequest.submittedAt, + requestId: latestRequest.id, + sessionId: dispatchSessionId, + source: latestRequest.source, + workspaceId: dispatchWorkspaceId ?? null, + }), + ); const tracedSendOptions: HandleSendOptions = { ...(latestRequest.sendOptions || {}), + observer: settlement.observer, targetSessionId: dispatchSessionId, skipSessionRestore: true, skipSessionStartHooks: true, @@ -391,18 +313,25 @@ export function useTaskCenterDraftSendDispatchRuntime({ undefined, tracedSendOptions, ); - void sendPromise.then( - (result) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.done", { - durationMs: Date.now() - latestRequest.submittedAt, - requestId: latestRequest.id, - result, - sessionId: dispatchSessionId, - source: latestRequest.source, - workspaceId: dispatchWorkspaceId ?? null, - }); - if (result !== true) { - if (!latestRequest.onDispatchSettled) + await sendPromise.then( + (actualResult) => { + const result = settlement.settle(actualResult); + if (!mountedRef.current) return; + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.done", { + durationMs: Date.now() - latestRequest.submittedAt, + requestId: latestRequest.id, + result: result.kind, + sessionId: dispatchSessionId, + source: latestRequest.source, + workspaceId: dispatchWorkspaceId ?? null, + }), + ); + if (!isRuntimeSubmissionAccepted(result)) { + if ( + !latestRequest.onDispatchSettled && + isSendSourceCurrent?.(latestRequest) !== false + ) restoreInput?.(latestRequest.text); } const latestCounts = messageCountsRef.current; @@ -410,53 +339,65 @@ export function useTaskCenterDraftSendDispatchRuntime({ latestDispatchContextRef.current.currentSessionId?.trim() || null; clearRequest({ keepHomePreview: - result === true && + isRuntimeSubmissionAccepted(result) && + isSendSourceCurrent?.( + latestRequest, + result.receipt.sessionId, + ) !== false && (latestCounts.messagesLength === 0 || - (latestRequest.materializeDraft && - latestActiveSessionId !== dispatchSessionId)), + latestActiveSessionId !== dispatchSessionId), }); - latestRequest.onDispatchSettled?.(result === true); + settlement.settle(result); }, (error) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - latestRequest.submittedAt, - error: error instanceof Error ? error.message : String(error), - requestId: latestRequest.id, - sessionId: dispatchSessionId, - source: latestRequest.source, - workspaceId: dispatchWorkspaceId ?? null, - }); - if (!latestRequest.onDispatchSettled) + const result = settlement.reject(error); + if (!mountedRef.current || isRuntimeSubmissionAccepted(result)) + return; + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { + durationMs: Date.now() - latestRequest.submittedAt, + error: "request_rejected", + requestId: latestRequest.id, + sessionId: dispatchSessionId, + source: latestRequest.source, + workspaceId: dispatchWorkspaceId ?? null, + }), + ); + if ( + !latestRequest.onDispatchSettled && + isSendSourceCurrent?.(latestRequest) !== false + ) restoreInput?.(latestRequest.text); clearRequest(); - latestRequest.onDispatchSettled?.(false); + settlement.settle({ kind: "rejected" }); }, ); })().catch((error) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - latestRequest.submittedAt, - error: error instanceof Error ? error.message : String(error), - requestId: latestRequest.id, - sessionId: latestRequest.draftTabId, - source: latestRequest.source, - workspaceId: dispatchWorkspaceId ?? null, - }); + const result = settlement.reject(error); + if (!mountedRef.current || isRuntimeSubmissionAccepted(result)) return; + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { + durationMs: Date.now() - latestRequest.submittedAt, + error: "request_rejected", + requestId: latestRequest.id, + sessionId: latestRequest.sessionId, + source: latestRequest.source, + workspaceId: dispatchWorkspaceId ?? null, + }), + ); if ( !latestRequest.onDispatchSettled && - !latestRequest.materializeDraft + isSendSourceCurrent?.(latestRequest) !== false ) { restoreInput?.(latestRequest.text); } clearRequest(); - latestRequest.onDispatchSettled?.(false); + settlement.settle({ kind: "rejected" }); }); }); }, [ - commitMaterializedDraftTab, currentSessionId, - materializeDraftTab, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, + isSendSourceCurrent, messageCountsRef, restoreInput, sendRef, diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts index ec2c5e01f..ea7a0cd98 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.ts @@ -1,3 +1,8 @@ +import { + isRuntimeSubmissionAccepted, + type AcceptedRuntimeSubmissionResult, +} from "../hooks/agentSendResult"; +import { createTaskCenterSubmissionSettlement } from "./taskCenterSubmissionSettlement"; import { useCallback, useEffect, @@ -14,6 +19,7 @@ import { mergeAgentUiPerformanceTraceMetadata } from "../hooks/agentStreamPerfor import { buildHomePendingPreviewMessages, type TaskCenterDraftSendRequest, + type TaskCenterSessionReadyOptions, } from "../homePendingPreview"; import type { Message } from "../types"; import { @@ -23,13 +29,10 @@ import { import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; import { createTaskCenterDraftSendRequestId, - resolveTaskCenterDraftSendTitle, scheduleAfterCommit, scheduleAfterNextPaint, - type TaskCenterDraftTab, } from "./agentChatWorkspaceHelpers"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; -import { markTaskCenterDraftTabRunning } from "./taskCenterDraftTabs"; import type { SoulInteractionCopy } from "@/lib/soul/interactionCopy"; import { logAgentDebug } from "@/lib/agentDebug"; @@ -54,16 +57,16 @@ interface UseTaskCenterEmptyStateSendRuntimeParams { input: string; setInput: (value: string) => void; activeSessionIdRef?: MutableRefObject; - activeDraftTabIdRef: MutableRefObject; - clearMessages: (options?: { showToast?: boolean }) => void; - displayMessagesLength: number; - turnsLength: number; - threadItemsLength: number; + getComposerDraftNavigationEpoch?: () => number; + newChatAt?: number; + isSendSourceCurrent?: ( + options: TaskCenterSessionReadyOptions, + acceptedSessionId?: string, + ) => boolean; hasDisplayMessages: boolean; handleSend: WorkspaceHandleSend; sessionId?: string | null; taskCenterWorkspaceId?: string | null; - setTaskCenterDraftTabs: Dispatch>; setTaskCenterDraftSendRequest: Dispatch< SetStateAction >; @@ -71,11 +74,9 @@ interface UseTaskCenterEmptyStateSendRuntimeParams { setHomePendingPreviewRequest: Dispatch< SetStateAction >; - materializedSessionIdsRef?: MutableRefObject>; - prewarmedDraftSessionIdsRef?: MutableRefObject>; - onNonMaterializedSessionReady?: ( + onSessionAccepted?: ( sessionId: string, - options?: { sourceDraftTabId?: string | null }, + options?: TaskCenterSessionReadyOptions, ) => void; } @@ -125,7 +126,7 @@ export function useTaskCenterHomePendingPreviewRuntime({ recordAgentUiPerformanceMetric("homeInput.pendingPreviewCommitted", { durationMs: Date.now() - request.submittedAt, requestId: request.id, - sessionId: request.draftTabId, + sessionId: request.sessionId, source: request.source, workspaceId: workspaceId ?? null, }); @@ -150,7 +151,7 @@ export function useTaskCenterHomePendingPreviewRuntime({ recordAgentUiPerformanceMetric("homeInput.pendingPreviewPaint", { durationMs: Date.now() - request.submittedAt, requestId: request.id, - sessionId: request.draftTabId, + sessionId: request.sessionId, source: request.source, workspaceId: workspaceId ?? null, }); @@ -172,24 +173,26 @@ export function useTaskCenterEmptyStateSendRuntime({ input, setInput, activeSessionIdRef, - activeDraftTabIdRef, - clearMessages, - displayMessagesLength, - turnsLength, - threadItemsLength, + getComposerDraftNavigationEpoch, + isSendSourceCurrent, + newChatAt, hasDisplayMessages, handleSend, sessionId, taskCenterWorkspaceId, - setTaskCenterDraftTabs, setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, setHomePendingPreviewRequest, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - onNonMaterializedSessionReady, + onSessionAccepted, }: UseTaskCenterEmptyStateSendRuntimeParams): InputbarSendHandler { const homeSendInFlightRef = useRef(false); + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); useEffect(() => { if (!taskCenterDraftSendRequest) { @@ -214,179 +217,64 @@ export function useTaskCenterEmptyStateSendRuntime({ triggerSource, workspaceId: taskCenterWorkspaceId, }); - return false; + return Promise.resolve({ kind: "rejected" }); } const text = payload.textOverride ?? input; const images = payload.images ?? []; - const sendOptions = getInputbarSendOptions(payload); - let settleSend!: (accepted: boolean) => void; - const completion = new Promise((resolve) => { - settleSend = resolve; - }); + const originalSendOptions = getInputbarSendOptions(payload); + const settlement = createTaskCenterSubmissionSettlement( + originalSendOptions?.observer, + ); + const sendOptions = { + ...originalSendOptions, + observer: settlement.observer, + }; + const { completion } = settlement; + const settleSend = settlement.settle; const normalizedText = text.trim(); - const activeDraftTabId = activeDraftTabIdRef.current; - if ( - (agentEntry === "claw" || agentEntry === "new-task") && - activeDraftTabId - ) { - homeSendInFlightRef.current = true; - const submittedAt = triggeredAt; - const requestId = createTaskCenterDraftSendRequestId(); - const tracedSendOptions: HandleSendOptions = { - ...(sendOptions || {}), - skipWorkspaceCommandRouting: true, - skipSessionRestore: true, - skipSessionStartHooks: true, - requestMetadata: mergeAgentUiPerformanceTraceMetadata( - sendOptions?.requestMetadata, - { - requestId, - sessionId: null, - source: "task-center-empty-state", - submittedAt, - workspaceId: taskCenterWorkspaceId ?? null, - }, - ), - }; - const shouldUseMaterializedDraftDispatch = - materializedSessionIdsRef?.current.has(activeDraftTabId) || - prewarmedDraftSessionIdsRef?.current.has(activeDraftTabId) || - false; - recordAgentUiPerformanceMetric("homeInput.submit", { - hasDraftTab: true, - inputbarHandlerToHomeSubmitMs: Math.max( - 0, - Date.now() - handlerEnteredAt, - ), - inputLength: normalizedText.length, - requestId, - sessionId: activeDraftTabId, - source: "task-center-empty-state", - triggerToHomeSubmitMs: Math.max(0, Date.now() - triggeredAt), - triggerSource, - workspaceId: taskCenterWorkspaceId, - }); - const request: TaskCenterDraftSendRequest = { - id: requestId, - draftTabId: shouldUseMaterializedDraftDispatch - ? activeDraftTabId - : requestId, - text, - images, - sendOptions: tracedSendOptions, - submittedAt, - materializeDraft: shouldUseMaterializedDraftDispatch, - dispatchState: shouldUseMaterializedDraftDispatch - ? undefined - : "dispatched", - source: "task-center-empty-state", - triggerSource, - onDispatchSettled: settleSend, - }; - flushHomePendingPreview(() => { - if (!sendOptions?.composerDraft) setInput(""); - if ( - displayMessagesLength > 0 || - turnsLength > 0 || - threadItemsLength > 0 - ) { - clearMessages({ showToast: false }); - } - setTaskCenterDraftSendRequest(request); - setHomePendingPreviewRequest(request); - }); - if (request.materializeDraft) { - return completion; + const sendSource = { + sessionId: activeSessionIdRef?.current ?? sessionId ?? null, + workspaceId: taskCenterWorkspaceId ?? null, + navigationEpoch: getComposerDraftNavigationEpoch?.() ?? null, + newChatAt: newChatAt ?? null, + }; + const sourceOptions: TaskCenterSessionReadyOptions = { + composerDraftCapture: payload.composerDraftCapture, + sendSource, + }; + const restoreRejectedInput = () => { + if ( + mountedRef.current && + !sendOptions.composerDraft && + isSendSourceCurrent?.(sourceOptions) !== false + ) + setInput(text); + }; + const handleAcceptedSessionReady = ( + id: string, + result: AcceptedRuntimeSubmissionResult, + ) => { + const readySessionId = result.receipt.sessionId; + if (!mountedRef.current) return; + if (isSendSourceCurrent?.(sourceOptions, readySessionId) === false) { + setHomePendingPreviewRequest((current) => + current?.id === id ? null : current, + ); + return; } - - scheduleAfterCommit(() => { - setTaskCenterDraftTabs((current) => - markTaskCenterDraftTabRunning({ - current, - draftTabId: activeDraftTabId, - title: resolveTaskCenterDraftSendTitle(text), - }), - ); - recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { - elapsedMs: Date.now() - submittedAt, - requestId, - sessionId: null, - source: "task-center-empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - void (async () => - handleSend( - images, - undefined, - undefined, - text, - undefined, - undefined, - tracedSendOptions, - ))().then( - (result) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.done", { - durationMs: Date.now() - submittedAt, - requestId, - result, - sessionId: null, - source: "task-center-empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - if (result !== true) { - if (!sendOptions?.composerDraft) setInput(text); - setHomePendingPreviewRequest((current) => - current?.id === requestId ? null : current, - ); - } else { - const readySessionId = - activeSessionIdRef?.current?.trim() || null; - if (readySessionId) { - onNonMaterializedSessionReady?.(readySessionId, { - sourceDraftTabId: activeDraftTabId, - }); - setHomePendingPreviewRequest((current) => - current?.id === requestId - ? { - ...current, - draftTabId: readySessionId, - sessionReady: true, - } - : current, - ); - } - } - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); - homeSendInFlightRef.current = false; - settleSend(result === true); - }, - (error) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - submittedAt, - error: error instanceof Error ? error.message : String(error), - requestId, - sessionId: null, - source: "task-center-empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - if (!sendOptions?.composerDraft) setInput(text); - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); - setHomePendingPreviewRequest((current) => - current?.id === requestId ? null : current, - ); - homeSendInFlightRef.current = false; - settleSend(false); - }, - ); - }); - return completion; - } - + settlement.effect("accepted_submit", () => + onSessionAccepted?.(readySessionId, { + ...sourceOptions, + acceptedResult: result, + }), + ); + setHomePendingPreviewRequest((current) => + current?.id === id + ? { ...current, sessionId: readySessionId, sessionReady: true } + : current, + ); + }; const shouldQueueHomeSend = !hasDisplayMessages && (agentEntry === "claw" || agentEntry === "new-task"); @@ -395,20 +283,21 @@ export function useTaskCenterEmptyStateSendRuntime({ const submittedAt = triggeredAt; const requestId = createTaskCenterDraftSendRequestId(); const existingSessionId = sessionId?.trim() || null; - recordAgentUiPerformanceMetric("homeInput.submit", { - hasDraftTab: false, - inputbarHandlerToHomeSubmitMs: Math.max( - 0, - Date.now() - handlerEnteredAt, - ), - inputLength: normalizedText.length, - requestId, - sessionId: existingSessionId, - source: "empty-state", - triggerToHomeSubmitMs: Math.max(0, Date.now() - triggeredAt), - triggerSource, - workspaceId: taskCenterWorkspaceId, - }); + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.submit", { + inputbarHandlerToHomeSubmitMs: Math.max( + 0, + Date.now() - handlerEnteredAt, + ), + inputLength: normalizedText.length, + requestId, + sessionId: existingSessionId, + source: "empty-state", + triggerToHomeSubmitMs: Math.max(0, Date.now() - triggeredAt), + triggerSource, + workspaceId: taskCenterWorkspaceId, + }), + ); if (!existingSessionId) { const tracedSendOptions: HandleSendOptions = { ...(sendOptions || {}), @@ -428,12 +317,13 @@ export function useTaskCenterEmptyStateSendRuntime({ }; const previewRequest: TaskCenterDraftSendRequest = { id: requestId, - draftTabId: requestId, + sessionId: null, text, images, sendOptions: tracedSendOptions, + composerDraftCapture: payload.composerDraftCapture, + sendSource, submittedAt, - materializeDraft: false, dispatchState: "dispatched", source: "empty-state", triggerSource, @@ -443,19 +333,23 @@ export function useTaskCenterEmptyStateSendRuntime({ setTaskCenterDraftSendRequest(previewRequest); setHomePendingPreviewRequest(previewRequest); }); - logAgentDebug("AgentChatPage", "homeInput.directDispatch.start", { - requestId, - source: "empty-state", - workspaceId: taskCenterWorkspaceId, - }); - scheduleAfterCommit(() => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { - elapsedMs: Date.now() - submittedAt, + settlement.effect("accepted_log", () => + logAgentDebug("AgentChatPage", "homeInput.directDispatch.start", { requestId, - sessionId: null, source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); + workspaceId: taskCenterWorkspaceId, + }), + ); + scheduleAfterCommit(() => { + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { + elapsedMs: Date.now() - submittedAt, + requestId, + sessionId: null, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }), + ); void (async () => handleSend( images, @@ -465,84 +359,96 @@ export function useTaskCenterEmptyStateSendRuntime({ undefined, undefined, tracedSendOptions, - ))().then( - (result) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.done", { - durationMs: Date.now() - submittedAt, - requestId, - result, - sessionId: null, - source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - logAgentDebug( - "AgentChatPage", - "homeInput.directDispatch.done", - { - requestId, - result, - source: "empty-state", - workspaceId: taskCenterWorkspaceId, - }, - ); - if (result !== true) { - if (!sendOptions?.composerDraft) setInput(text); + ))() + .then( + (actualResult) => { + const result = settleSend(actualResult); + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric( + "homeInput.sendDispatch.done", + { + durationMs: Date.now() - submittedAt, + requestId, + result: result.kind, + sessionId: null, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }, + ), + ); + settlement.effect("accepted_log", () => + logAgentDebug( + "AgentChatPage", + "homeInput.directDispatch.done", + { + requestId, + result: result.kind, + source: "empty-state", + workspaceId: taskCenterWorkspaceId, + }, + ), + ); + if (!isRuntimeSubmissionAccepted(result)) { + restoreRejectedInput(); + setHomePendingPreviewRequest((current) => + current?.id === requestId ? null : current, + ); + } else { + handleAcceptedSessionReady(requestId, result); + } + setTaskCenterDraftSendRequest((current) => + current?.id === requestId ? null : current, + ); + homeSendInFlightRef.current = false; + settleSend(result); + }, + (error) => { + const result = settlement.reject(error); + if (isRuntimeSubmissionAccepted(result)) { + homeSendInFlightRef.current = false; + return; + } + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric( + "homeInput.sendDispatch.error", + { + durationMs: Date.now() - submittedAt, + error: "request_rejected", + requestId, + sessionId: null, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }, + ), + ); + settlement.effect("accepted_log", () => + logAgentDebug( + "AgentChatPage", + "homeInput.directDispatch.error", + { + error: "request_rejected", + requestId, + source: "empty-state", + workspaceId: taskCenterWorkspaceId, + }, + { level: "error" }, + ), + ); + restoreRejectedInput(); + setTaskCenterDraftSendRequest((current) => + current?.id === requestId ? null : current, + ); setHomePendingPreviewRequest((current) => current?.id === requestId ? null : current, ); - } else { - const readySessionId = - activeSessionIdRef?.current?.trim() || null; - if (readySessionId) { - onNonMaterializedSessionReady?.(readySessionId); - setHomePendingPreviewRequest((current) => - current?.id === requestId - ? { - ...current, - draftTabId: readySessionId, - sessionReady: true, - } - : current, - ); - } - } - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); + homeSendInFlightRef.current = false; + settleSend(result); + }, + ) + .catch((error) => { homeSendInFlightRef.current = false; - settleSend(result === true); - }, - (error) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - submittedAt, - error: error instanceof Error ? error.message : String(error), - requestId, - sessionId: null, - source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - logAgentDebug( - "AgentChatPage", - "homeInput.directDispatch.error", - { - error, - requestId, - source: "empty-state", - workspaceId: taskCenterWorkspaceId, - }, - { level: "error" }, - ); - if (!sendOptions?.composerDraft) setInput(text); - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); - setHomePendingPreviewRequest((current) => - current?.id === requestId ? null : current, - ); - homeSendInFlightRef.current = false; - settleSend(false); - }, - ); + settlement.reject(error); + }); }); return completion; } @@ -564,12 +470,13 @@ export function useTaskCenterEmptyStateSendRuntime({ }; const previewRequest: TaskCenterDraftSendRequest = { id: requestId, - draftTabId: existingSessionId, + sessionId: existingSessionId, text, images, sendOptions: tracedSendOptions, + composerDraftCapture: payload.composerDraftCapture, + sendSource, submittedAt, - materializeDraft: false, dispatchState: "dispatched", source: "empty-state", triggerSource, @@ -580,13 +487,15 @@ export function useTaskCenterEmptyStateSendRuntime({ setHomePendingPreviewRequest(previewRequest); }); scheduleAfterCommit(() => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { - elapsedMs: Date.now() - submittedAt, - requestId, - sessionId: existingSessionId, - source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.sendDispatch.start", { + elapsedMs: Date.now() - submittedAt, + requestId, + sessionId: existingSessionId, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }), + ); void (async () => handleSend( images, @@ -596,96 +505,115 @@ export function useTaskCenterEmptyStateSendRuntime({ undefined, undefined, tracedSendOptions, - ))().then( - (result) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.done", { - durationMs: Date.now() - submittedAt, - requestId, - result, - sessionId: existingSessionId, - source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - if (result !== true) { - if (!sendOptions?.composerDraft) setInput(text); + ))() + .then( + (actualResult) => { + const result = settleSend(actualResult); + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric( + "homeInput.sendDispatch.done", + { + durationMs: Date.now() - submittedAt, + requestId, + result: result.kind, + sessionId: existingSessionId, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }, + ), + ); + if (!isRuntimeSubmissionAccepted(result)) { + restoreRejectedInput(); + setHomePendingPreviewRequest((current) => + current?.id === requestId ? null : current, + ); + } + setTaskCenterDraftSendRequest((current) => + current?.id === requestId ? null : current, + ); + homeSendInFlightRef.current = false; + settleSend(result); + }, + (error) => { + const result = settlement.reject(error); + if (isRuntimeSubmissionAccepted(result)) { + homeSendInFlightRef.current = false; + return; + } + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric( + "homeInput.sendDispatch.error", + { + durationMs: Date.now() - submittedAt, + error: "request_rejected", + requestId, + sessionId: existingSessionId, + source: "empty-state", + workspaceId: taskCenterWorkspaceId ?? null, + }, + ), + ); + restoreRejectedInput(); + setTaskCenterDraftSendRequest((current) => + current?.id === requestId ? null : current, + ); setHomePendingPreviewRequest((current) => current?.id === requestId ? null : current, ); - } - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); + homeSendInFlightRef.current = false; + settleSend(result); + }, + ) + .catch((error) => { homeSendInFlightRef.current = false; - settleSend(result === true); - }, - (error) => { - recordAgentUiPerformanceMetric("homeInput.sendDispatch.error", { - durationMs: Date.now() - submittedAt, - error: error instanceof Error ? error.message : String(error), - requestId, - sessionId: existingSessionId, - source: "empty-state", - workspaceId: taskCenterWorkspaceId ?? null, - }); - if (!sendOptions?.composerDraft) setInput(text); - setTaskCenterDraftSendRequest((current) => - current?.id === requestId ? null : current, - ); - setHomePendingPreviewRequest((current) => - current?.id === requestId ? null : current, - ); - homeSendInFlightRef.current = false; - settleSend(false); - }, - ); + settlement.reject(error); + }); }); return completion; } - recordAgentUiPerformanceMetric("homeInput.submit", { - hasDraftTab: false, - inputbarHandlerToHomeSubmitMs: Math.max( - 0, - Date.now() - handlerEnteredAt, - ), - inputLength: normalizedText.length, - sessionId: sessionId ?? null, - source: "empty-state", - triggerToHomeSubmitMs: Math.max(0, Date.now() - triggeredAt), - triggerSource, - workspaceId: taskCenterWorkspaceId, - }); - return handleSend( - images, - undefined, - undefined, - text, - undefined, - undefined, - sendOptions, + settlement.effect("accepted_metric", () => + recordAgentUiPerformanceMetric("homeInput.submit", { + inputbarHandlerToHomeSubmitMs: Math.max( + 0, + Date.now() - handlerEnteredAt, + ), + inputLength: normalizedText.length, + sessionId: sessionId ?? null, + source: "empty-state", + triggerToHomeSubmitMs: Math.max(0, Date.now() - triggeredAt), + triggerSource, + workspaceId: taskCenterWorkspaceId, + }), ); + void (async () => + handleSend( + images, + undefined, + undefined, + text, + undefined, + undefined, + sendOptions, + ))().then(settlement.settle, settlement.reject); + return completion; }, [ - activeDraftTabIdRef, activeSessionIdRef, + getComposerDraftNavigationEpoch, + isSendSourceCurrent, + newChatAt, agentEntry, - clearMessages, - displayMessagesLength, handleSend, hasDisplayMessages, input, - materializedSessionIdsRef, sessionId, - onNonMaterializedSessionReady, - prewarmedDraftSessionIdsRef, + onSessionAccepted, setHomePendingPreviewRequest, setInput, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, taskCenterDraftSendRequest, taskCenterWorkspaceId, - threadItemsLength, - turnsLength, ], ); } diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts index 8ce54f351..15b705359 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendRuntime.unit.test.ts @@ -1,3 +1,5 @@ +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import { createElement, useEffect, useRef, useState } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -5,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import { useTaskCenterHomePendingPreviewRuntime, useTaskCenterDraftSendDispatchRuntime, @@ -22,8 +23,6 @@ vi.mock("@/lib/agentUiPerformanceMetrics", () => ({ const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; type DraftSendRuntimeHandle = { - commitMaterializedDraftTab: ReturnType; - materializeDraftTab: ReturnType; restoreInput: ReturnType; send: ReturnType; }; @@ -36,11 +35,10 @@ function createDraftSendRequest( ): TaskCenterDraftSendRequest { return { id: "draft-send-test", - draftTabId: "task-draft-test", + sessionId: null, text: "真实 E2E 目标", images: [], submittedAt: Date.now(), - materializeDraft: false, source: "empty-state", ...overrides, }; @@ -51,24 +49,18 @@ function mountDraftSendRuntime({ messagesLength = 0, currentSessionId, request = createDraftSendRequest(), - sendResult = true, + sendResult = acceptedSubmission(), onSnapshot, - onNonMaterializedSessionReady, - materializeDraftResult = null, - prewarmedMaterializedSessionId = null, - prewarmedDraftSession = false, + onSessionAccepted, rerenderAfterMount = false, }: { displayMessagesLength: number; messagesLength?: number; currentSessionId?: string | null; request?: TaskCenterDraftSendRequest | null; - sendResult?: boolean; - materializeDraftResult?: string | null; - prewarmedMaterializedSessionId?: string | null; - prewarmedDraftSession?: boolean; + sendResult?: RuntimeSubmissionResult; rerenderAfterMount?: boolean; - onNonMaterializedSessionReady?: (sessionId: string) => void; + onSessionAccepted?: (sessionId: string) => void; onSnapshot: (snapshot: { taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; @@ -78,8 +70,6 @@ function mountDraftSendRuntime({ document.body.appendChild(container); const root = createRoot(container); const restoreInput = vi.fn(); - const commitMaterializedDraftTab = vi.fn(); - const materializeDraftTab = vi.fn(async () => materializeDraftResult); const send = vi.fn(async () => sendResult); function Harness() { @@ -88,18 +78,6 @@ function mountDraftSendRuntime({ useState(request); const [homePendingPreviewRequest, setHomePendingPreviewRequest] = useState(request); - const materializedSessionIdsRef = useRef( - new Map( - request && prewarmedMaterializedSessionId - ? [[request.draftTabId, prewarmedMaterializedSessionId]] - : [], - ), - ); - const prewarmedDraftSessionIdsRef = useRef( - new Set( - request && prewarmedDraftSession ? [request.draftTabId] : [], - ), - ); const sendRef = useRef(send); useEffect(() => { @@ -115,11 +93,7 @@ function mountDraftSendRuntime({ messagesLength, displayMessagesLength, currentSessionId, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - materializeDraftTab, - commitMaterializedDraftTab, - onNonMaterializedSessionReady, + onSessionAccepted, restoreInput, sendRef, workspaceId: rerenderAfterMount @@ -143,8 +117,6 @@ function mountDraftSendRuntime({ mountedRoots.push({ root, container }); return { - commitMaterializedDraftTab, - materializeDraftTab, restoreInput, send, }; @@ -187,35 +159,29 @@ function mountHomePendingPreviewRuntime({ function mountEmptyStateSendRuntime({ activeSessionId = null, activeSessionIdAfterSend, - activeDraftTabId = null, agentEntry = "claw", displayMessagesLength = 0, turnsLength = 0, threadItemsLength = 0, hasDisplayMessages = false, input = "默认输入", - prewarmedDraftSession = false, - prewarmedMaterializedSessionId = null, sessionId = null, - sendResult = true, + sendResult = acceptedSubmission(), taskCenterDraftSendRequest = null, - onNonMaterializedSessionReady = vi.fn(), + onSessionAccepted = vi.fn(), }: { activeSessionId?: string | null; activeSessionIdAfterSend?: string | null; - activeDraftTabId?: string | null; agentEntry?: string; displayMessagesLength?: number; turnsLength?: number; threadItemsLength?: number; hasDisplayMessages?: boolean; input?: string; - prewarmedDraftSession?: boolean; - prewarmedMaterializedSessionId?: string | null; sessionId?: string | null; - sendResult?: boolean; + sendResult?: RuntimeSubmissionResult; taskCenterDraftSendRequest?: TaskCenterDraftSendRequest | null; - onNonMaterializedSessionReady?: ReturnType; + onSessionAccepted?: ReturnType; } = {}) { const container = document.createElement("div"); document.body.appendChild(container); @@ -227,9 +193,10 @@ function mountEmptyStateSendRuntime({ if (activeSessionIdAfterSend !== undefined) { activeSessionIdRef.current = activeSessionIdAfterSend; } - return sendResult; + return sendResult.kind === "runtime-accepted" && activeSessionIdAfterSend + ? acceptedSubmission(activeSessionIdAfterSend) + : sendResult; }); - const setTaskCenterDraftTabs = vi.fn(); const setTaskCenterDraftSendRequest = vi.fn(); const setHomePendingPreviewRequest = vi.fn(); let sendHandler: ReturnType< @@ -237,25 +204,11 @@ function mountEmptyStateSendRuntime({ > | null = null; function Harness() { - const activeDraftTabIdRef = useRef(activeDraftTabId); - const materializedSessionIdsRef = useRef( - new Map( - activeDraftTabId && prewarmedMaterializedSessionId - ? [[activeDraftTabId, prewarmedMaterializedSessionId]] - : [], - ), - ); - const prewarmedDraftSessionIdsRef = useRef( - new Set( - activeDraftTabId && prewarmedDraftSession ? [activeDraftTabId] : [], - ), - ); sendHandler = useTaskCenterEmptyStateSendRuntime({ agentEntry, input, setInput, activeSessionIdRef, - activeDraftTabIdRef, clearMessages, displayMessagesLength, turnsLength, @@ -264,13 +217,10 @@ function mountEmptyStateSendRuntime({ handleSend, sessionId, taskCenterWorkspaceId: "workspace-test", - setTaskCenterDraftTabs, setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, setHomePendingPreviewRequest, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - onNonMaterializedSessionReady, + onSessionAccepted, }); return null; } @@ -283,11 +233,10 @@ function mountEmptyStateSendRuntime({ return { clearMessages, handleSend, - onNonMaterializedSessionReady, + onSessionAccepted, setHomePendingPreviewRequest, setInput, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, send: (payload?: Parameters>[0]) => { if (!sendHandler) { throw new Error("send handler 尚未初始化"); @@ -329,7 +278,7 @@ describe("useTaskCenterHomePendingPreviewRuntime", () => { it("真实会话未 ready 前即使存在临时消息投影也应保留 pending preview", () => { const snapshots: HomePendingPreviewSnapshot[] = []; const request = createDraftSendRequest({ - draftTabId: "draft-send-pending-preview", + sessionId: null, sessionReady: false, text: "你好", }); @@ -352,7 +301,7 @@ describe("useTaskCenterHomePendingPreviewRuntime", () => { it("真实会话消息接管后应退出 pending preview", () => { const snapshots: HomePendingPreviewSnapshot[] = []; const request = createDraftSendRequest({ - draftTabId: "sess-ready-with-message", + sessionId: "sess-ready-with-message", sessionReady: true, text: "你好", }); @@ -370,7 +319,7 @@ describe("useTaskCenterHomePendingPreviewRuntime", () => { it("真实会话 ready 但消息尚未投影时仍应保留 pending preview", () => { const snapshots: HomePendingPreviewSnapshot[] = []; const request = createDraftSendRequest({ - draftTabId: "sess-ready-before-message", + sessionId: "sess-ready-before-message", sessionReady: true, text: "整理今天的国际新闻", }); @@ -391,13 +340,13 @@ describe("useTaskCenterHomePendingPreviewRuntime", () => { describe("useTaskCenterDraftSendDispatchRuntime", () => { it("仅临时展示投影出现时不应清理首页首发 pending preview", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const snapshots: Array<{ taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; }> = []; const request = createDraftSendRequest({ - draftTabId: "sess_ready", + sessionId: "sess_ready", }); await act(async () => { @@ -405,7 +354,7 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { displayMessagesLength: 1, messagesLength: 0, request, - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: (snapshot) => { snapshots.push(snapshot); }, @@ -418,14 +367,14 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { expect(latest?.taskCenterDraftSendRequest).toBeNull(); expect(latest?.homePendingPreviewRequest).toMatchObject({ id: request.id, - draftTabId: "sess_ready", + sessionId: "sess_ready", sessionReady: true, }); - expect(onNonMaterializedSessionReady).toHaveBeenCalledWith("sess_ready"); + expect(onSessionAccepted).toHaveBeenCalledWith("sess_ready", request); }); it("真实会话已有消息时应清理 draft surface 请求", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const snapshots: Array<{ taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; @@ -436,9 +385,9 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { displayMessagesLength: 0, messagesLength: 1, request: createDraftSendRequest({ - draftTabId: "sess_real_message", + sessionId: "sess_real_message", }), - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: (snapshot) => { snapshots.push(snapshot); }, @@ -449,13 +398,14 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { const latest = snapshots.at(-1); expect(latest?.taskCenterDraftSendRequest).toBeNull(); expect(latest?.homePendingPreviewRequest).toBeNull(); - expect(onNonMaterializedSessionReady).toHaveBeenCalledWith( + expect(onSessionAccepted).toHaveBeenCalledWith( "sess_real_message", + expect.objectContaining({ sessionId: "sess_real_message" }), ); }); it("临时 draft-send id 没有真实 session 时应等待后续真实 session", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const snapshots: Array<{ taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; @@ -466,9 +416,9 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { displayMessagesLength: 1, messagesLength: 0, request: createDraftSendRequest({ - draftTabId: "draft-send-test", + sessionId: null, }), - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: (snapshot) => { snapshots.push(snapshot); }, @@ -476,14 +426,12 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { await Promise.resolve(); }); - expect(onNonMaterializedSessionReady).not.toHaveBeenCalled(); - expect(snapshots.at(-1)?.taskCenterDraftSendRequest?.draftTabId).toBe( - "draft-send-test", - ); + expect(onSessionAccepted).not.toHaveBeenCalled(); + expect(snapshots.at(-1)?.taskCenterDraftSendRequest?.sessionId).toBeNull(); }); - it("临时 draft-send id 有真实 session 时应标记真实会话 ready", async () => { - const onNonMaterializedSessionReady = vi.fn(); + it("临时 draft-send id 不能借用当前活跃 session 推导接受身份", async () => { + const onSessionAccepted = vi.fn(); await act(async () => { mountDraftSendRuntime({ @@ -491,29 +439,26 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { messagesLength: 0, currentSessionId: "sess_ready_from_state", request: createDraftSendRequest({ - draftTabId: "draft-send-test", + sessionId: null, }), - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: () => {}, }); await Promise.resolve(); }); - expect(onNonMaterializedSessionReady).toHaveBeenCalledWith( - "sess_ready_from_state", - ); + expect(onSessionAccepted).not.toHaveBeenCalled(); }); it("direct-dispatch 首发应由发起者完成 session 交接", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const snapshots: Array<{ taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; }> = []; const request = createDraftSendRequest({ - draftTabId: "draft-send-live", + sessionId: null, dispatchState: "dispatched", - materializeDraft: false, source: "empty-state", text: "@配图 画一张深圳夏天的图", }); @@ -523,7 +468,7 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { messagesLength: 0, currentSessionId: "sess_live_ready", request, - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: (snapshot) => { snapshots.push(snapshot); }, @@ -533,24 +478,23 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { const latest = snapshots.at(-1); expect(latest?.taskCenterDraftSendRequest).toMatchObject({ id: request.id, - draftTabId: "draft-send-live", + sessionId: null, dispatchState: "dispatched", }); expect(latest?.homePendingPreviewRequest).toMatchObject({ id: request.id, - draftTabId: "draft-send-live", + sessionId: null, dispatchState: "dispatched", }); }); - expect(onNonMaterializedSessionReady).not.toHaveBeenCalled(); + expect(onSessionAccepted).not.toHaveBeenCalled(); }); it("direct-dispatch 首条消息出现时也不得提前清理 pending owner", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const request = createDraftSendRequest({ - draftTabId: "draft-send-live-message", + sessionId: null, dispatchState: "dispatched", - materializeDraft: false, source: "task-center-empty-state", }); const snapshots: Array<{ @@ -563,7 +507,7 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { displayMessagesLength: 2, messagesLength: 2, request, - onNonMaterializedSessionReady, + onSessionAccepted, onSnapshot: (snapshot) => snapshots.push(snapshot), }); @@ -571,7 +515,7 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { expect(snapshots.at(-1)?.taskCenterDraftSendRequest).toEqual(request); expect(snapshots.at(-1)?.homePendingPreviewRequest).toEqual(request); }); - expect(onNonMaterializedSessionReady).not.toHaveBeenCalled(); + expect(onSessionAccepted).not.toHaveBeenCalled(); }); it("首页首发发送返回 false 时应清理 pending preview", async () => { @@ -583,9 +527,9 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { const runtime = mountDraftSendRuntime({ displayMessagesLength: 0, messagesLength: 0, - sendResult: false, + sendResult: { kind: "rejected" }, request: createDraftSendRequest({ - draftTabId: "sess-image-no-project", + sessionId: "sess-image-no-project", text: "参考图生成一张小红书封面", }), onSnapshot: (snapshot) => { @@ -604,357 +548,13 @@ describe("useTaskCenterDraftSendDispatchRuntime", () => { "参考图生成一张小红书封面", ); }); - - it("materialized 草稿发送成功但真实消息未接管前应保留 pending preview", async () => { - const snapshots: Array<{ - taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - homePendingPreviewRequest: TaskCenterDraftSendRequest | null; - }> = []; - const request = createDraftSendRequest({ - draftTabId: "task-draft-materialized", - materializeDraft: true, - source: "task-center-empty-state", - text: "画一张深圳夏天的图", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 0, - materializeDraftResult: "session-materialized", - messagesLength: 0, - request, - sendResult: true, - onSnapshot: (snapshot) => { - snapshots.push(snapshot); - }, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - const latest = snapshots.at(-1); - expect(latest?.taskCenterDraftSendRequest).toBeNull(); - expect(latest?.homePendingPreviewRequest?.id).toBe(request.id); - }); - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-materialized", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - expect(runtime.send).toHaveBeenCalledWith( - request.images, - undefined, - undefined, - request.text, - undefined, - undefined, - expect.objectContaining({ - targetSessionId: "session-materialized", - requestMetadata: expect.objectContaining({ - agentUiPerformanceTrace: expect.objectContaining({ - sessionId: "session-materialized", - }), - }), - }), - ); - expect( - runtime.commitMaterializedDraftTab.mock.invocationCallOrder[0], - ).toBeLessThan(runtime.send.mock.invocationCallOrder[0]); - }); - - it("materialized 草稿发送应等待 pending preview paint 后再启动 materialize", async () => { - const request = createDraftSendRequest({ - draftTabId: "task-draft-defer-materialize", - materializeDraft: true, - source: "task-center-empty-state", - text: "先展示用户消息再创建正式会话", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 0, - materializeDraftResult: "session-defer-materialize", - messagesLength: 0, - request, - sendResult: true, - onSnapshot: () => {}, - }); - - expect(runtime.materializeDraftTab).not.toHaveBeenCalled(); - expect(runtime.send).not.toHaveBeenCalled(); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - expect(runtime.materializeDraftTab).toHaveBeenCalledTimes(1); - expect(runtime.send).toHaveBeenCalledTimes(1); - }); - }); - - it("materialized 草稿发送期间仅有临时展示投影时应继续保留 pending preview", async () => { - const snapshots: Array<{ - taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - homePendingPreviewRequest: TaskCenterDraftSendRequest | null; - }> = []; - const request = createDraftSendRequest({ - draftTabId: "task-draft-materialized-preview-race", - materializeDraft: true, - source: "task-center-empty-state", - text: "你好-cdp-race", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 1, - materializeDraftResult: "session-materialized-preview-race", - messagesLength: 0, - request, - sendResult: true, - onSnapshot: (snapshot) => { - snapshots.push(snapshot); - }, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - const latest = snapshots.at(-1); - expect(latest?.taskCenterDraftSendRequest).toBeNull(); - expect(latest?.homePendingPreviewRequest?.id).toBe(request.id); - }); - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-materialized-preview-race", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - expect(runtime.send).toHaveBeenCalledWith( - request.images, - undefined, - undefined, - request.text, - undefined, - undefined, - expect.objectContaining({ - targetSessionId: "session-materialized-preview-race", - }), - ); - }); - - it("materialized 草稿目标 session 接管前不应被旧消息计数清掉 pending preview", async () => { - const snapshots: Array<{ - taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - homePendingPreviewRequest: TaskCenterDraftSendRequest | null; - }> = []; - const request = createDraftSendRequest({ - draftTabId: "task-draft-materialized-owner", - materializeDraft: true, - source: "task-center-empty-state", - text: "你好-owner-stability", - }); - - const runtime = mountDraftSendRuntime({ - currentSessionId: "old-session", - displayMessagesLength: 0, - materializeDraftResult: "session-materialized-owner", - messagesLength: 1, - request, - sendResult: true, - onSnapshot: (snapshot) => { - snapshots.push(snapshot); - }, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - const latest = snapshots.at(-1); - expect(latest?.taskCenterDraftSendRequest).toBeNull(); - expect(latest?.homePendingPreviewRequest).toMatchObject({ - id: request.id, - draftTabId: "session-materialized-owner", - sessionReady: true, - }); - }); - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-materialized-owner", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - }); - - it("materialized 草稿发送成功后不应同步路由或拉取空详情", async () => { - const request = createDraftSendRequest({ - draftTabId: "task-draft-route-sync", - materializeDraft: true, - source: "task-center-empty-state", - text: "从草稿进入正式会话", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 1, - materializeDraftResult: "session-route-sync", - messagesLength: 1, - request, - sendResult: true, - onSnapshot: () => {}, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-route-sync", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - }); - const commitOptions = runtime.commitMaterializedDraftTab.mock.calls[0]?.[2]; - expect(commitOptions).toEqual( - expect.objectContaining({ hydrateSession: false }), - ); - expect(commitOptions).toEqual( - expect.objectContaining({ syncRoute: false }), - ); - }); - - it("materialized 草稿发送复用输入预热 session 时不应同步路由", async () => { - const request = createDraftSendRequest({ - draftTabId: "task-draft-prewarmed-route", - materializeDraft: true, - source: "task-center-empty-state", - text: "复用预热会话发送", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 0, - materializeDraftResult: "session-prewarmed-route", - messagesLength: 0, - prewarmedDraftSession: true, - request, - sendResult: true, - onSnapshot: () => {}, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-prewarmed-route", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - }); - }); - - it("materialized 草稿发送命中已预热 session 时不应重新 materialize", async () => { - const request = createDraftSendRequest({ - draftTabId: "task-draft-prewarmed-ready", - materializeDraft: true, - source: "task-center-empty-state", - text: "命中预热会话后立即发送", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 0, - materializeDraftResult: "session-should-not-be-used", - messagesLength: 0, - prewarmedMaterializedSessionId: "session-prewarmed-ready", - request, - sendResult: true, - onSnapshot: () => {}, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - expect(runtime.send).toHaveBeenCalledTimes(1); - }); - expect(runtime.materializeDraftTab).not.toHaveBeenCalled(); - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - request.draftTabId, - "session-prewarmed-ready", - { - embedHomeSession: false, - hydrateSession: false, - preserveInput: false, - syncRoute: false, - }, - ); - expect(runtime.send).toHaveBeenCalledWith( - request.images, - undefined, - undefined, - request.text, - undefined, - undefined, - expect.objectContaining({ - targetSessionId: "session-prewarmed-ready", - requestMetadata: expect.objectContaining({ - agentUiPerformanceTrace: expect.objectContaining({ - sessionId: "session-prewarmed-ready", - }), - }), - }), - ); - }); - - it("materialized 草稿请求在 re-render 下也只派发一次", async () => { - const request = createDraftSendRequest({ - draftTabId: "task-draft-rerender", - materializeDraft: true, - source: "empty-state", - text: "@配图 画一张深圳夏天的图", - }); - - const runtime = mountDraftSendRuntime({ - displayMessagesLength: 0, - materializeDraftResult: "session-rerender", - messagesLength: 0, - request, - rerenderAfterMount: true, - sendResult: true, - onSnapshot: () => {}, - }); - - await flushAfterNextPaint(); - - await vi.waitFor(() => { - expect(runtime.send).toHaveBeenCalledTimes(1); - }); - expect(runtime.materializeDraftTab).toHaveBeenCalledTimes(1); - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledTimes(1); - }); }); describe("useTaskCenterEmptyStateSendRuntime", () => { - it("已有首页首发 pending 请求时应拒绝重复发送", () => { + it("已有首页首发 pending 请求时应拒绝重复发送", async () => { const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: "draft-a", taskCenterDraftSendRequest: createDraftSendRequest({ - draftTabId: "draft-a", - materializeDraft: true, + sessionId: "draft-a", }), }); @@ -965,18 +565,15 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { }); }); - expect(result).toBe(false); + expect(await result).toMatchObject({ kind: "rejected" }); expect(runtime.setInput).not.toHaveBeenCalled(); expect(runtime.setTaskCenterDraftSendRequest).not.toHaveBeenCalled(); expect(runtime.setHomePendingPreviewRequest).not.toHaveBeenCalled(); - expect(runtime.setTaskCenterDraftTabs).not.toHaveBeenCalled(); expect(runtime.handleSend).not.toHaveBeenCalled(); }); it("React 状态提交前的连续触发也应只创建一次首页首发请求", async () => { - const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: "draft-a", - }); + const runtime = mountEmptyStateSendRuntime({}); let secondResult: unknown; act(() => { @@ -988,187 +585,16 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { }); }); - expect(secondResult).toBe(false); + const secondCompletion = secondResult; expect(runtime.setTaskCenterDraftSendRequest).toHaveBeenCalledTimes(1); expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledTimes(1); - expect(runtime.setTaskCenterDraftTabs).not.toHaveBeenCalled(); + expect(await secondCompletion).toMatchObject({ kind: "rejected" }); await flushAfterNextPaint(); - expect(runtime.setTaskCenterDraftTabs).toHaveBeenCalledTimes(1); expect(runtime.handleSend).toHaveBeenCalledTimes(1); }); - it("已有 draft tab 时应创建 direct draft send request 并清理旧会话内容", async () => { - const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: "draft-a", - displayMessagesLength: 1, - hasDisplayMessages: true, - }); - - act(() => { - runtime.send({ - textOverride: "生成一版项目复盘", - sendOptions: { displayContent: "展示文本" }, - triggeredAt: 1_780_000_010_000, - triggerSource: "button", - }); - }); - - expect(runtime.setInput).toHaveBeenCalledWith(""); - expect(runtime.setInput.mock.invocationCallOrder[0]).toBeLessThan( - runtime.setTaskCenterDraftSendRequest.mock.invocationCallOrder[0], - ); - expect(runtime.clearMessages).toHaveBeenCalledWith({ showToast: false }); - expect(runtime.setTaskCenterDraftSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ - draftTabId: expect.stringMatching(/^draft-send-/), - dispatchState: "dispatched", - materializeDraft: false, - source: "task-center-empty-state", - submittedAt: 1_780_000_010_000, - text: "生成一版项目复盘", - triggerSource: "button", - }), - ); - expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledWith( - expect.objectContaining({ - draftTabId: expect.stringMatching(/^draft-send-/), - dispatchState: "dispatched", - materializeDraft: false, - }), - ); - - expect(runtime.setTaskCenterDraftTabs).not.toHaveBeenCalled(); - await flushAfterNextPaint(); - const updateTabs = runtime.setTaskCenterDraftTabs.mock.calls[0]?.[0] as - | ((tabs: TaskCenterDraftTab[]) => TaskCenterDraftTab[]) - | undefined; - const now = new Date("2026-06-22T00:00:00.000Z"); - expect( - updateTabs?.([ - { - id: "draft-a", - title: "新对话", - createdAt: now, - updatedAt: now, - status: "draft", - }, - ]), - ).toEqual([ - expect.objectContaining({ - id: "draft-a", - title: "生成一版项目复盘", - status: "running", - }), - ]); - expect(runtime.handleSend).toHaveBeenCalledWith( - [], - undefined, - undefined, - "生成一版项目复盘", - undefined, - undefined, - expect.objectContaining({ - displayContent: "展示文本", - skipSessionRestore: true, - skipSessionStartHooks: true, - skipWorkspaceCommandRouting: true, - requestMetadata: expect.objectContaining({ - agentUiPerformanceTrace: expect.objectContaining({ - sessionId: null, - source: "task-center-empty-state", - workspaceId: "workspace-test", - }), - }), - }), - ); - expect(recordAgentUiPerformanceMetric).toHaveBeenCalledWith( - "homeInput.submit", - expect.objectContaining({ - hasDraftTab: true, - source: "task-center-empty-state", - triggerSource: "button", - workspaceId: "workspace-test", - }), - ); - }); - - it("已有 draft tab 且输入预热会话未完成时应交给 materialized dispatch", async () => { - const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: "draft-a", - hasDisplayMessages: false, - prewarmedDraftSession: true, - sessionId: null, - }); - - act(() => { - runtime.send({ textOverride: "你好" }); - }); - - expect(runtime.setTaskCenterDraftSendRequest).toHaveBeenCalledWith( - expect.objectContaining({ - draftTabId: "draft-a", - materializeDraft: true, - source: "task-center-empty-state", - text: "你好", - }), - ); - expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledWith( - expect.objectContaining({ - draftTabId: "draft-a", - materializeDraft: true, - source: "task-center-empty-state", - text: "你好", - }), - ); - expect(runtime.handleSend).not.toHaveBeenCalled(); - - await flushAfterNextPaint(); - - expect(runtime.handleSend).not.toHaveBeenCalled(); - expect(runtime.setTaskCenterDraftTabs).not.toHaveBeenCalled(); - }); - - it("已有 draft tab 首发成功后应把源 draft tab 绑定到真实 session", async () => { - const onNonMaterializedSessionReady = vi.fn(); - const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: "draft-a", - activeSessionIdAfterSend: "sess_ready_from_active_draft", - hasDisplayMessages: false, - onNonMaterializedSessionReady, - sessionId: null, - }); - - act(() => { - runtime.send({ textOverride: "你好" }); - }); - await flushAfterNextPaint(); - - expect(onNonMaterializedSessionReady).toHaveBeenCalledWith( - "sess_ready_from_active_draft", - { sourceDraftTabId: "draft-a" }, - ); - const bindHomePreview = - runtime.setHomePendingPreviewRequest.mock.calls.find( - ([arg]) => typeof arg === "function", - )?.[0] as - | (( - current: TaskCenterDraftSendRequest | null, - ) => TaskCenterDraftSendRequest | null) - | undefined; - const initialPreview = - runtime.setHomePendingPreviewRequest.mock.calls[0]?.[0]; - expect( - bindHomePreview?.(initialPreview as TaskCenterDraftSendRequest), - ).toMatchObject({ - draftTabId: "sess_ready_from_active_draft", - sessionReady: true, - text: "你好", - }); - }); - it("Task Center 首页无会话内容且没有 session 时应直接派发普通发送并保留 pending preview", async () => { const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: null, hasDisplayMessages: false, sessionId: null, }); @@ -1179,18 +605,16 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect(runtime.setTaskCenterDraftSendRequest).toHaveBeenCalledWith( expect.objectContaining({ - draftTabId: expect.stringMatching(/^draft-send-/), + id: expect.stringMatching(/^draft-send-/), dispatchState: "dispatched", - materializeDraft: false, source: "empty-state", text: "继续拆分 workspace", }), ); expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledWith( expect.objectContaining({ - draftTabId: expect.stringMatching(/^draft-send-/), + id: expect.stringMatching(/^draft-send-/), dispatchState: "dispatched", - materializeDraft: false, source: "empty-state", text: "继续拆分 workspace", }), @@ -1199,7 +623,6 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect(runtime.setInput.mock.invocationCallOrder[0]).toBeLessThan( runtime.setTaskCenterDraftSendRequest.mock.invocationCallOrder[0], ); - expect(runtime.setTaskCenterDraftTabs).not.toHaveBeenCalled(); expect(runtime.handleSend).not.toHaveBeenCalled(); await flushAfterNextPaint(); @@ -1241,12 +664,11 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { initialTrackingRequest as TaskCenterDraftSendRequest, ), ).toBeNull(); - expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledTimes(1); + expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledTimes(2); }); it("首页 direct dispatch 在同步 preview 提交后的 microtask 中启动,不等待下一帧", async () => { const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: null, hasDisplayMessages: false, sessionId: null, }); @@ -1264,12 +686,11 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { }); it("Task Center 首页首发成功后拿到真实 session 时应立即接管前台会话", async () => { - const onNonMaterializedSessionReady = vi.fn(); + const onSessionAccepted = vi.fn(); const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: null, activeSessionIdAfterSend: "sess_ready_after_direct_send", hasDisplayMessages: false, - onNonMaterializedSessionReady, + onSessionAccepted, sessionId: null, }); @@ -1278,8 +699,9 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { }); await flushAfterNextPaint(); - expect(onNonMaterializedSessionReady).toHaveBeenCalledWith( + expect(onSessionAccepted).toHaveBeenCalledWith( "sess_ready_after_direct_send", + expect.objectContaining({}), ); const bindHomePreview = runtime.setHomePendingPreviewRequest.mock.calls.find( @@ -1294,7 +716,7 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect( bindHomePreview?.(initialPreview as TaskCenterDraftSendRequest), ).toMatchObject({ - draftTabId: "sess_ready_after_direct_send", + sessionId: "sess_ready_after_direct_send", sessionReady: true, text: "你好", }); @@ -1302,7 +724,6 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { it("Task Center 首页已有 session 时应直接进入普通发送流并保留 pending preview", async () => { const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: null, hasDisplayMessages: false, sessionId: "sess_existing", }); @@ -1313,18 +734,16 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect(runtime.setTaskCenterDraftSendRequest).toHaveBeenCalledWith( expect.objectContaining({ - draftTabId: "sess_existing", + sessionId: "sess_existing", dispatchState: "dispatched", - materializeDraft: false, source: "empty-state", text: "继续拆分 workspace", }), ); expect(runtime.setHomePendingPreviewRequest).toHaveBeenCalledWith( expect.objectContaining({ - draftTabId: "sess_existing", + sessionId: "sess_existing", dispatchState: "dispatched", - materializeDraft: false, source: "empty-state", text: "继续拆分 workspace", }), @@ -1360,7 +779,7 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { expect(recordAgentUiPerformanceMetric).toHaveBeenCalledWith( "homeInput.sendDispatch.done", expect.objectContaining({ - result: true, + result: "runtime-accepted", sessionId: "sess_existing", source: "empty-state", workspaceId: "workspace-test", @@ -1384,10 +803,9 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { it("Task Center 首页已有 session 普通发送派发失败时应恢复输入并清理 pending preview", async () => { const runtime = mountEmptyStateSendRuntime({ - activeDraftTabId: null, hasDisplayMessages: false, input: "失败后保留文本", - sendResult: false, + sendResult: { kind: "rejected" }, sessionId: "sess_existing", }); @@ -1453,14 +871,15 @@ describe("useTaskCenterEmptyStateSendRuntime", () => { "普通发送", undefined, undefined, - sendOptions, + expect.objectContaining(sendOptions), ); expect(recordAgentUiPerformanceMetric).toHaveBeenCalledWith( "homeInput.submit", expect.objectContaining({ - hasDraftTab: false, + inputLength: 4, sessionId: "session-a", source: "empty-state", + workspaceId: "workspace-test", }), ); }); diff --git a/src/components/agent/chat/workspace/useTaskCenterDraftSendStructured.unit.test.tsx b/src/components/agent/chat/workspace/useTaskCenterDraftSendStructured.unit.test.tsx index 2f54fd074..39ebb5fec 100644 --- a/src/components/agent/chat/workspace/useTaskCenterDraftSendStructured.unit.test.tsx +++ b/src/components/agent/chat/workspace/useTaskCenterDraftSendStructured.unit.test.tsx @@ -1,6 +1,15 @@ +import { + acceptedSubmission, + runtimeReceipt, +} from "../hooks/agentSendResult.testFixtures"; +import { + isRuntimeSubmissionAccepted, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; import React, { act, useRef, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; import { ComposerController } from "@/components/input-kit/ComposerController"; import type { InputbarSendHandler } from "../components/Inputbar/inputbarSendPayload"; import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; @@ -20,6 +29,10 @@ vi.mock("./agentChatWorkspaceHelpers", async (importOriginal) => ({ scheduleAfterNextPaint: (callback: () => void) => queueMicrotask(callback), })); +vi.mock("@/lib/agentUiPerformanceMetrics", () => ({ + recordAgentUiPerformanceMetric: vi.fn(), +})); + const mounted: Array<{ root: Root; container: HTMLDivElement }> = []; afterEach(() => { for (const { root, container } of mounted.splice(0)) { @@ -30,22 +43,25 @@ afterEach(() => { function mountHome( options: { - materialized?: boolean; - materializeFails?: boolean; reject?: boolean; + existingContent?: boolean; } = {}, ) { - let finish!: (accepted: boolean) => void; - const backendAcceptance = new Promise((resolve, reject) => { - finish = options.reject ? () => reject(new Error("send failed")) : resolve; - }); + let finish!: (result: RuntimeSubmissionResult) => void; + let fail!: (error: Error) => void; + const activeSessionIdRef = { current: null as string | null }; + const onSessionAccepted = vi.fn(); + const backendAcceptance = new Promise( + (resolve, reject) => { + fail = reject; + finish = options.reject + ? () => reject(new Error("send failed")) + : resolve; + }, + ); const handleSend = vi.fn(() => backendAcceptance); const setInput = vi.fn(); const restoreInput = vi.fn(); - const commitMaterializedDraftTab = vi.fn(); - const materializeDraftTab = vi - .fn() - .mockResolvedValue(options.materializeFails ? null : "session-ready"); let send!: InputbarSendHandler; function Harness() { @@ -53,32 +69,24 @@ function mountHome( null, ); const [, setPreview] = useState(null); - const activeDraftTabIdRef = useRef(options.materialized ? "draft-1" : null); - const materializedSessionIdsRef = useRef(new Map()); - const prewarmedDraftSessionIdsRef = useRef( - new Set(options.materialized ? ["draft-1"] : []), - ); const sendRef = useRef(handleSend); send = useTaskCenterEmptyStateSendRuntime({ agentEntry: "new-task", input: "stale", setInput, - activeDraftTabIdRef, - activeSessionIdRef: { current: null }, + activeSessionIdRef, clearMessages: vi.fn(), displayMessagesLength: 0, turnsLength: 0, threadItemsLength: 0, - hasDisplayMessages: false, + hasDisplayMessages: options.existingContent ?? false, handleSend, sessionId: null, taskCenterWorkspaceId: "workspace-1", - setTaskCenterDraftTabs: vi.fn(), setTaskCenterDraftSendRequest: setRequest, taskCenterDraftSendRequest: request, setHomePendingPreviewRequest: setPreview, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, + onSessionAccepted, }); useTaskCenterDraftSendDispatchRuntime({ taskCenterDraftSendRequest: request, @@ -86,10 +94,6 @@ function mountHome( setHomePendingPreviewRequest: setPreview, messagesLength: 0, currentSessionId: null, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - materializeDraftTab, - commitMaterializedDraftTab, restoreInput, sendRef, workspaceId: "workspace-1", @@ -106,7 +110,7 @@ function mountHome( controller.ingestPaste("完整粘贴".repeat(300)); controller.setSelection(1, 3); const receipt = controller.submit("start"); - let completion!: Promise; + let completion!: Promise; async function submit() { await act(async () => { completion = Promise.resolve( @@ -118,7 +122,11 @@ function mountHome( composerTarget: receipt.target, }), ).then((accepted) => { - if (accepted && receipt.kind === "accepted") controller.commit(receipt); + if ( + isRuntimeSubmissionAccepted(accepted) && + receipt.kind === "accepted" + ) + controller.commit(receipt); return accepted; }); await Promise.resolve(); @@ -129,96 +137,205 @@ function mountHome( receipt, submit, finish, + fail, + activeSessionIdRef, + onSessionAccepted, + accept(kind: "start" | "steer" = "start", sessionId = "session-ready") { + handleSend.mock.calls[0][6]?.observer?.onRuntimeAccepted?.({ + ...runtimeReceipt(sessionId), + kind, + }); + }, + unmount() { + act(() => root.unmount()); + container.remove(); + mounted.splice( + mounted.findIndex((entry) => entry.root === root), + 1, + ); + }, handleSend, setInput, restoreInput, - commitMaterializedDraftTab, - materializeDraftTab, completion: () => completion, }; } describe("structured home send acceptance", () => { - it.each([false, true])( - "keeps the original folded draft until a %s materialized send is accepted", - async (materialized) => { - const runtime = mountHome({ materialized }); - await runtime.submit(); - expect(runtime.handleSend).toHaveBeenCalledOnce(); - expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); - expect(runtime.setInput).not.toHaveBeenCalled(); - expect(runtime.handleSend.mock.calls[0][6]).toMatchObject({ - composerDraft: runtime.receipt.draft, - composerSubmission: runtime.receipt.submission, - }); - if (materialized) - expect(runtime.commitMaterializedDraftTab).toHaveBeenCalledWith( - "draft-1", - "session-ready", - expect.objectContaining({ preserveInput: true }), - ); - let settled = false; - void runtime.completion().then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - await act(async () => { - runtime.finish(false); - await runtime.completion(); - }); - expect(await runtime.completion()).toBe(false); - expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); - expect(runtime.restoreInput).not.toHaveBeenCalled(); - expect(runtime.setInput).not.toHaveBeenCalled(); - }, - ); + it("keeps the original folded draft until a send is accepted", async () => { + const runtime = mountHome(); + await runtime.submit(); + expect(runtime.handleSend).toHaveBeenCalledOnce(); + expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); + expect(runtime.setInput).not.toHaveBeenCalled(); + expect(runtime.handleSend.mock.calls[0][6]).toMatchObject({ + composerDraft: runtime.receipt.draft, + composerSubmission: runtime.receipt.submission, + }); + let settled = false; + void runtime.completion().then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + await act(async () => { + runtime.finish({ kind: "rejected" }); + await runtime.completion(); + }); + expect(await runtime.completion()).toMatchObject({ kind: "rejected" }); + expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); + expect(runtime.restoreInput).not.toHaveBeenCalled(); + expect(runtime.setInput).not.toHaveBeenCalled(); + }); - it.each([false, true])( - "a %s materialized send commits only after true acceptance", - async (materialized) => { - const runtime = mountHome({ materialized }); - await runtime.submit(); - await act(async () => { - runtime.finish(true); - await runtime.completion(); - }); - expect(await runtime.completion()).toBe(true); - expect(runtime.controller.getDocument().text).toBe(""); - }, - ); + it("a send commits only after true acceptance", async () => { + const runtime = mountHome(); + await runtime.submit(); + await act(async () => { + runtime.finish(acceptedSubmission("session-ready")); + await runtime.completion(); + }); + expect(await runtime.completion()).toMatchObject({ + kind: "runtime-accepted", + }); + expect(runtime.controller.getDocument().text).toBe(""); + }); it("late success does not clear a newer draft", async () => { const runtime = mountHome(); await runtime.submit(); runtime.controller.setText("新的草稿😀"); await act(async () => { - runtime.finish(true); + runtime.finish(acceptedSubmission("session-ready")); await runtime.completion(); }); expect(runtime.controller.getDocument().text).toBe("新的草稿😀"); expect(runtime.setInput).not.toHaveBeenCalled(); }); - it("materialization failure settles false and preserves the original document", async () => { - const runtime = mountHome({ materialized: true, materializeFails: true }); - await runtime.submit(); - expect(await runtime.completion()).toBe(false); - expect(runtime.handleSend).not.toHaveBeenCalled(); - expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); - expect(runtime.restoreInput).not.toHaveBeenCalled(); - }); - it("send rejection does not overwrite a newer structured draft", async () => { - const runtime = mountHome({ materialized: true, reject: true }); + const runtime = mountHome({ reject: true }); await runtime.submit(); runtime.controller.setText("新编辑"); await act(async () => { - runtime.finish(false); + runtime.finish({ kind: "rejected" }); await runtime.completion(); }); - expect(await runtime.completion()).toBe(false); + expect(await runtime.completion()).toMatchObject({ kind: "rejected" }); expect(runtime.controller.getDocument().text).toBe("新编辑"); expect(runtime.restoreInput).not.toHaveBeenCalled(); }); + + it("actual hook unmount keeps an in-flight acceptance", async () => { + const runtime = mountHome(); + await runtime.submit(); + runtime.unmount(); + runtime.accept(); + expect(await runtime.completion()).toMatchObject({ + kind: "runtime-accepted", + receipt: { sessionId: "session-ready" }, + }); + runtime.fail(new Error("late refresh failure")); + await Promise.resolve(); + expect(runtime.controller.getDocument().text).toBe(""); + }); + + it("direct fallback preserves early acceptance after a late rejection", async () => { + const runtime = mountHome({ existingContent: true }); + await runtime.submit(); + await act(async () => { + runtime.accept("steer", "fallback-session"); + runtime.fail(new Error("refresh failed")); + await runtime.completion(); + }); + expect(await runtime.completion()).toMatchObject({ + kind: "runtime-accepted", + receipt: { sessionId: "fallback-session", kind: "steer" }, + }); + }); + + it.each(["start", "steer"] as const)( + "actual home %s acceptance survives a later refresh rejection", + async (kind) => { + const runtime = mountHome(); + await runtime.submit(); + await act(async () => { + runtime.accept(kind); + await runtime.completion(); + runtime.fail(new Error("refresh")); + await Promise.resolve(); + }); + expect(await runtime.completion()).toMatchObject({ + kind: "runtime-accepted", + receipt: { kind }, + }); + expect(runtime.controller.getDocument().text).toBe(""); + }, + ); + + it("late first send promotes receipt session from stable Home", async () => { + const runtime = mountHome(); + await runtime.submit(); + runtime.activeSessionIdRef.current = "unrelated-new-active"; + await act(async () => { + runtime.finish(acceptedSubmission("captured-request-session")); + await runtime.completion(); + await Promise.resolve(); + }); + expect(runtime.onSessionAccepted).toHaveBeenCalledWith( + "captured-request-session", + expect.objectContaining({ + acceptedResult: expect.objectContaining({ + receipt: expect.objectContaining({ + sessionId: "captured-request-session", + }), + }), + }), + ); + }); + + it.each([{ kind: "unknown" as const }, { kind: "local-handled" as const }])( + "nonaccepted %j keeps complete home document", + async (result) => { + const runtime = mountHome(); + await runtime.submit(); + await act(async () => { + runtime.finish(result); + await runtime.completion(); + }); + expect(await runtime.completion()).toEqual(result); + expect(runtime.controller.captureDraft()).toEqual(runtime.receipt.draft); + expect(runtime.onSessionAccepted).not.toHaveBeenCalled(); + }, + ); + + it("postaccepted metric failure cannot suppress receipt promotion or change completion", async () => { + const runtime = mountHome(); + await runtime.submit(); + const actualMetrics = await vi.importActual< + typeof import("@/lib/agentUiPerformanceMetrics") + >("@/lib/agentUiPerformanceMetrics"); + vi.mocked(recordAgentUiPerformanceMetric).mockImplementation( + (name, context) => { + if (name === "homeInput.sendDispatch.done") + throw new Error("PRIVATE METRIC BODY"); + return actualMetrics.recordAgentUiPerformanceMetric(name, context); + }, + ); + await act(async () => { + runtime.finish(acceptedSubmission("metric-session")); + await runtime.completion(); + await Promise.resolve(); + }); + expect(runtime.onSessionAccepted).toHaveBeenCalledWith( + "metric-session", + expect.objectContaining({ + acceptedResult: expect.objectContaining({ kind: "runtime-accepted" }), + }), + ); + expect(await runtime.completion()).toMatchObject({ + kind: "runtime-accepted", + }); + vi.mocked(recordAgentUiPerformanceMetric).mockReset(); + }); }); diff --git a/src/components/agent/chat/workspace/useTaskCenterFallbackRestoreRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterFallbackRestoreRuntime.ts index b828d959b..601e31e47 100644 --- a/src/components/agent/chat/workspace/useTaskCenterFallbackRestoreRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterFallbackRestoreRuntime.ts @@ -14,7 +14,6 @@ interface UseTaskCenterFallbackRestoreRuntimeParams { isAutoRestoringSession: boolean; isSessionHydrating: boolean; draftSurfaceActive: boolean; - draftTabActive: boolean; initialPendingServiceSkillLaunchSignature?: string | null; initialDispatchKey?: string | null; isBootstrapDispatchPending: boolean; @@ -42,7 +41,6 @@ export function useTaskCenterFallbackRestoreRuntime({ isAutoRestoringSession, isSessionHydrating, draftSurfaceActive, - draftTabActive, initialPendingServiceSkillLaunchSignature, initialDispatchKey, isBootstrapDispatchPending, @@ -70,7 +68,6 @@ export function useTaskCenterFallbackRestoreRuntime({ isAutoRestoringSession, isSessionHydrating, draftSurfaceActive, - draftTabActive, initialPendingServiceSkillLaunchSignature, initialDispatchKey, isBootstrapDispatchPending, @@ -127,7 +124,6 @@ export function useTaskCenterFallbackRestoreRuntime({ currentSessionIsKnownTopic, detachedTopicId, draftSurfaceActive, - draftTabActive, fallbackRestoreRef, hasDisplayMessages, initialDispatchKey, diff --git a/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.ts index 0c1c54efa..1cf27da0b 100644 --- a/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.ts @@ -11,7 +11,6 @@ import { isTaskCenterTopicSwitchPending, resolveTaskCenterPreviewTopicId, } from "../utils/taskCenterTabs"; -import { isTaskCenterDraftTabId } from "./agentChatWorkspaceHelpers"; import { resolveTaskCenterHomeChromeState, type TaskCenterHomeChromeState, @@ -28,7 +27,6 @@ interface UseTaskCenterHomeChromeRuntimeParams { renamePromptLabel: string; renameTopic: (topicId: string, title: string) => void | Promise; draftSurfaceActive: boolean; - draftTabActive: boolean; shouldSuppressDraftContent: boolean; draftSendRequest: TaskCenterDraftSendRequest | null; normalizedInitialSessionId?: string | null; @@ -67,7 +65,6 @@ export function useTaskCenterHomeChromeRuntime({ renamePromptLabel, renameTopic, draftSurfaceActive, - draftTabActive, shouldSuppressDraftContent, draftSendRequest, normalizedInitialSessionId, @@ -91,7 +88,7 @@ export function useTaskCenterHomeChromeRuntime({ }: UseTaskCenterHomeChromeRuntimeParams): TaskCenterHomeChromeRuntime { const handleRenameTaskTopic = useCallback( async (topicId: string) => { - if (isTaskCenterDraftTabId(topicId) || typeof window === "undefined") { + if (typeof window === "undefined") { return; } @@ -134,7 +131,6 @@ export function useTaskCenterHomeChromeRuntime({ const homeChromeState = resolveTaskCenterHomeChromeState({ agentEntry, draftSurfaceActive, - draftTabActive, shouldSuppressDraftContent, draftSendRequest, sessionSwitchPending: taskCenterSessionSwitchPending, diff --git a/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.unit.test.tsx b/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.unit.test.tsx index 5bebdc50a..0f51d63aa 100644 --- a/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.unit.test.tsx +++ b/src/components/agent/chat/workspace/useTaskCenterHomeChromeRuntime.unit.test.tsx @@ -81,7 +81,6 @@ function Harness({ renamePromptLabel: "重命名任务", renameTopic, draftSurfaceActive: false, - draftTabActive: false, shouldSuppressDraftContent: false, draftSendRequest, normalizedInitialSessionId: null, diff --git a/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.ts deleted file mode 100644 index e7cc8978c..000000000 --- a/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { useCallback, useEffect } from "react"; -import { logAgentDebug } from "@/lib/agentDebug"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; -import type { AgentChatWorkspaceProps } from "../agentChatWorkspaceContract"; -import { - subscribeTaskCenterDraftTaskRequests, - type TaskCenterCreateDraftTaskDetail, -} from "../taskCenterDraftTaskEvents"; -import { normalizeProjectId } from "../utils/topicProjectResolution"; - -interface ResolveTaskCenterNewTaskPageRequestPlanParams { - agentEntry: string; - requestedProjectId?: string | null; - externalProjectId?: string | null; - normalizedInitialSessionId?: string | null; -} - -type TaskCenterNewTaskPageRequestPlan = - | { - action: "skip"; - reason: "unsupported-entry"; - } - | { - action: "ignore"; - reason: "route-session-project-mismatch"; - agentEntry: string; - initialSessionId: string; - requestedProjectId: string | null; - externalProjectId: string | null; - } - | { - action: "navigate"; - projectId: string | null; - } - | { - action: "open-draft"; - projectId: string | null; - }; - -interface UseTaskCenterNewTaskPageRuntimeParams { - agentEntry: string; - externalProjectId?: string | null; - normalizedInitialSessionId?: string | null; - onNavigate?: AgentChatWorkspaceProps["onNavigate"]; - applyProjectSelection: (projectId: string) => void; - resetProjectSelection: () => void; - openTaskCenterDraftTab: () => string; -} - -export function resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry, - requestedProjectId, - externalProjectId, - normalizedInitialSessionId, -}: ResolveTaskCenterNewTaskPageRequestPlanParams): TaskCenterNewTaskPageRequestPlan { - if (agentEntry !== "claw" && agentEntry !== "new-task") { - return { - action: "skip", - reason: "unsupported-entry", - }; - } - - const normalizedRequestedProjectId = - requestedProjectId === undefined || - (requestedProjectId === null && normalizedInitialSessionId) - ? normalizeProjectId(externalProjectId) - : normalizeProjectId(requestedProjectId); - const normalizedExternalProjectId = normalizeProjectId(externalProjectId); - - if ( - normalizedInitialSessionId && - requestedProjectId !== undefined && - normalizedRequestedProjectId !== normalizedExternalProjectId - ) { - return { - action: "ignore", - reason: "route-session-project-mismatch", - agentEntry, - initialSessionId: normalizedInitialSessionId, - requestedProjectId: requestedProjectId ?? null, - externalProjectId: normalizedExternalProjectId, - }; - } - - if (normalizedRequestedProjectId !== normalizedExternalProjectId) { - return { - action: "navigate", - projectId: normalizedRequestedProjectId, - }; - } - - return { - action: "open-draft", - projectId: normalizedRequestedProjectId, - }; -} - -export function useTaskCenterNewTaskPageRuntime({ - agentEntry, - externalProjectId, - normalizedInitialSessionId, - onNavigate, - applyProjectSelection, - resetProjectSelection, - openTaskCenterDraftTab, -}: UseTaskCenterNewTaskPageRuntimeParams): ( - requestedProjectId?: string | null, -) => void { - const handleOpenTaskCenterNewTaskPage = useCallback( - (requestedProjectId?: string | null) => { - const plan = resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry, - requestedProjectId, - externalProjectId, - normalizedInitialSessionId, - }); - - if (plan.action === "skip") { - return; - } - - if (plan.action === "ignore") { - logAgentDebug( - "AgentChatPage", - "taskCenter.draftRequestIgnoredForRouteSession", - { - agentEntry: plan.agentEntry, - initialSessionId: plan.initialSessionId, - requestedProjectId: plan.requestedProjectId, - externalProjectId: plan.externalProjectId, - }, - ); - return; - } - - if (plan.action === "navigate") { - onNavigate?.( - "agent", - buildHomeAgentParams({ - projectId: plan.projectId ?? undefined, - }), - ); - return; - } - - if (plan.projectId) { - applyProjectSelection(plan.projectId); - } else { - resetProjectSelection(); - } - - openTaskCenterDraftTab(); - }, - [ - agentEntry, - applyProjectSelection, - externalProjectId, - normalizedInitialSessionId, - onNavigate, - openTaskCenterDraftTab, - resetProjectSelection, - ], - ); - - useEffect(() => { - if (agentEntry !== "claw" && agentEntry !== "new-task") { - return; - } - - return subscribeTaskCenterDraftTaskRequests( - (detail: TaskCenterCreateDraftTaskDetail) => { - handleOpenTaskCenterNewTaskPage(detail.projectId); - }, - ); - }, [agentEntry, handleOpenTaskCenterNewTaskPage]); - - return handleOpenTaskCenterNewTaskPage; -} diff --git a/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.unit.test.ts b/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.unit.test.ts deleted file mode 100644 index c53a6a4a3..000000000 --- a/src/components/agent/chat/workspace/useTaskCenterNewTaskPageRuntime.unit.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveTaskCenterNewTaskPageRequestPlan } from "./useTaskCenterNewTaskPageRuntime"; - -describe("resolveTaskCenterNewTaskPageRequestPlan", () => { - it("非任务中心入口应跳过", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "settings", - externalProjectId: "project-a", - }), - ).toEqual({ - action: "skip", - reason: "unsupported-entry", - }); - }); - - it("未显式请求项目时应沿用当前路由项目打开草稿", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "claw", - externalProjectId: " project-a ", - }), - ).toEqual({ - action: "open-draft", - projectId: "project-a", - }); - }); - - it("route session 存在且请求项目不同,应忽略外部 draft request", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "claw", - requestedProjectId: "project-b", - externalProjectId: "project-a", - normalizedInitialSessionId: "session-1", - }), - ).toEqual({ - action: "ignore", - reason: "route-session-project-mismatch", - agentEntry: "claw", - initialSessionId: "session-1", - requestedProjectId: "project-b", - externalProjectId: "project-a", - }); - }); - - it("没有 route session 时请求项目不同应导航到对应首页", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "claw", - requestedProjectId: "project-b", - externalProjectId: "project-a", - }), - ).toEqual({ - action: "navigate", - projectId: "project-b", - }); - }); - - it("route session 存在且请求清空项目时应沿用当前路由项目打开草稿", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "claw", - requestedProjectId: null, - externalProjectId: "project-a", - normalizedInitialSessionId: "session-1", - }), - ).toEqual({ - action: "open-draft", - projectId: "project-a", - }); - }); - - it("请求清空项目时应导航到无项目任务首页", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "new-task", - requestedProjectId: null, - externalProjectId: "project-a", - }), - ).toEqual({ - action: "navigate", - projectId: null, - }); - }); - - it("请求项目与当前项目一致时应打开草稿", () => { - expect( - resolveTaskCenterNewTaskPageRequestPlan({ - agentEntry: "new-task", - requestedProjectId: "project-a", - externalProjectId: "project-a", - }), - ).toEqual({ - action: "open-draft", - projectId: "project-a", - }); - }); -}); diff --git a/src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx b/src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx new file mode 100644 index 000000000..df1ee16a1 --- /dev/null +++ b/src/components/agent/chat/workspace/useTaskCenterTabChrome.test.tsx @@ -0,0 +1,104 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useTaskCenterTabChrome } from "./useTaskCenterTabChrome"; +import type { Topic } from "../hooks/agentChatShared"; + +// Strip presentation is outside this hook test; all projection and navigation decisions are real. +vi.mock("../components/TaskCenterTabStrip", () => ({ + TaskCenterTabStrip: (props: { + items: Array<{ id: string; title: string }>; + onCloseTask: (id: string) => void; + }) => ( +
+ {props.items.map((item) => ( + + ))} +
+ ), +})); +const cleanups: Array<() => void> = []; +const canonicalTopic: Topic = { + id: "existing", + title: "", + createdAt: new Date(1), + updatedAt: new Date(1), + workspaceId: null, + workingDir: null, + messagesCount: 1, + executionStrategy: "react", + status: "done", + lastPreview: "", + hasUnread: false, + tag: null, + sourceSessionId: "existing", +}; +afterEach(() => { + act(() => cleanups.splice(0).forEach((close) => close())); + vi.unstubAllGlobals(); +}); +function mount(theme: string, home = false) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + cleanups.push(() => { + root.unmount(); + element.remove(); + }); + const close = vi.fn(); + let value!: ReturnType; + function Surface() { + value = useTaskCenterTabChrome({ + agentEntry: "claw", + activeTheme: theme, + sessionId: "existing", + normalizedInitialSessionId: "existing", + initialSessionName: "Canonical title", + openTabIds: ["existing"], + topics: [canonicalTopic], + previewTopicId: "existing", + hasLocalSessionOverride: true, + topicById: new Map([[canonicalTopic.id, canonicalTopic]]), + untitledTaskLabel: "Untitled", + shouldUseBrowserWorkspaceHomeChrome: home, + newConversationLabel: "New", + homeMountedAt: 1, + layoutMode: "chat", + onSwitchTaskTopic: vi.fn(), + onRenameTaskTopic: vi.fn(), + onCloseTaskCenterTab: close, + onOpenTaskCenterNewTaskPage: vi.fn(), + onToggleWorkbench: vi.fn(), + }); + return ( + <> + {value.taskCenterTabsNode} + {value.browserWorkspaceHomeTabsNode} + + ); + } + act(() => root.render()); + return { element, value, close }; +} +describe("general Chrome retirement and specialized canonical strip", () => { + it.each([false, true])( + "general home=%s creates no second strip node", + (home) => { + const view = mount("general", home); + expect(view.value.shouldRenderTaskCenterTabStrip).toBe(false); + expect(view.value.taskCenterTabsNode).toBeNull(); + expect(view.value.browserWorkspaceHomeTabsNode).toBeNull(); + expect(view.element.childElementCount).toBe(0); + }, + ); + it("specialized workbench keeps canonical titles and close navigation", () => { + const view = mount("document"); + const button = view.element.querySelector("button"); + expect(button?.textContent).toBe("Canonical title"); + act(() => button?.click()); + expect(view.close).toHaveBeenCalledExactlyOnceWith("existing"); + }); +}); diff --git a/src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx b/src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx index aac7bb382..35c4782bb 100644 --- a/src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx +++ b/src/components/agent/chat/workspace/useTaskCenterTabChrome.tsx @@ -7,7 +7,6 @@ import { resolveTaskCenterVisibleTabIds, shouldHideTaskCenterTabsForDetachedSession, } from "../utils/taskCenterTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import { buildBrowserWorkspaceHomeTabItem, buildTaskCenterTabItems, @@ -18,6 +17,7 @@ type AgentEntry = NonNullable; interface UseTaskCenterTabChromeParams { agentEntry: AgentEntry; + activeTheme: string; sessionId?: string | null; normalizedInitialSessionId?: string | null; initialSessionName?: string | null; @@ -25,9 +25,6 @@ interface UseTaskCenterTabChromeParams { openTabIds: string[]; topics: Topic[]; previewTopicId: string | null; - draftTabs: TaskCenterDraftTab[]; - activeDraftTabId: string | null; - isDraftTabActive: boolean; hasLocalSessionOverride: boolean; topicById: ReadonlyMap; untitledTaskLabel: string; @@ -35,7 +32,6 @@ interface UseTaskCenterTabChromeParams { newConversationLabel: string; newChatAt?: number; homeMountedAt: number; - isThemeWorkbench: boolean; layoutMode: LayoutMode; onSwitchTaskTopic: (topicId: string) => void | Promise; onRenameTaskTopic: (topicId: string) => void | Promise; @@ -46,6 +42,7 @@ interface UseTaskCenterTabChromeParams { export function useTaskCenterTabChrome({ agentEntry, + activeTheme, sessionId, normalizedInitialSessionId, initialSessionName, @@ -53,9 +50,6 @@ export function useTaskCenterTabChrome({ openTabIds, topics, previewTopicId, - draftTabs, - activeDraftTabId, - isDraftTabActive, hasLocalSessionOverride, topicById, untitledTaskLabel, @@ -63,7 +57,6 @@ export function useTaskCenterTabChrome({ newConversationLabel, newChatAt, homeMountedAt, - isThemeWorkbench: _isThemeWorkbench, layoutMode, onSwitchTaskTopic, onRenameTaskTopic, @@ -94,22 +87,19 @@ export function useTaskCenterTabChrome({ ); const tabItems = useMemo( () => - buildTaskCenterTabItems({ - draftTabs, - activeDraftTabId, - isDraftTabActive, - sessionId, - initialSessionId: normalizedInitialSessionId, - initialSessionName, - previewTopicId, - visibleTabIds, - topicById, - untitledTaskLabel, - }), + activeTheme === "general" + ? [] + : buildTaskCenterTabItems({ + sessionId, + initialSessionId: normalizedInitialSessionId, + initialSessionName, + previewTopicId, + visibleTabIds, + topicById, + untitledTaskLabel, + }), [ - activeDraftTabId, - draftTabs, - isDraftTabActive, + activeTheme, initialSessionName, normalizedInitialSessionId, previewTopicId, @@ -119,11 +109,13 @@ export function useTaskCenterTabChrome({ visibleTabIds, ], ); - const shouldRenderTabs = shouldRenderTaskCenterTabStrip({ - agentEntry, - hasLocalSessionOverride, - tabItemCount: tabItems.length, - }); + const shouldRenderTabs = + activeTheme !== "general" && + shouldRenderTaskCenterTabStrip({ + agentEntry, + hasLocalSessionOverride, + tabItemCount: tabItems.length, + }); const taskCenterTabsNode = useMemo(() => { if (!shouldRenderTabs) { return null; @@ -159,7 +151,7 @@ export function useTaskCenterTabChrome({ tabItems, ]); const browserWorkspaceHomeTabsNode = useMemo(() => { - if (!shouldUseBrowserWorkspaceHomeChrome) { + if (activeTheme === "general" || !shouldUseBrowserWorkspaceHomeChrome) { return null; } @@ -177,6 +169,7 @@ export function useTaskCenterTabChrome({ /> ); }, [ + activeTheme, homeMountedAt, newChatAt, newConversationLabel, diff --git a/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.test.tsx b/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.test.tsx index 2afc34e09..3b4f7c8c0 100644 --- a/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.test.tsx @@ -8,7 +8,6 @@ import { type TaskCenterLocalSessionOverride, type TaskCenterWorkspaceTabMap, } from "../utils/taskCenterTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import { useTaskCenterTabSessionRuntime, type TaskCenterTabSessionRuntimeState, @@ -24,10 +23,8 @@ interface HarnessProps { } interface LatestState { - activeDraftTabId: string | null; draftSendRequest: TaskCenterDraftSendRequest | null; draftSurfaceActive: boolean; - draftTabs: TaskCenterDraftTab[]; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; runtime: TaskCenterTabSessionRuntimeState; } @@ -56,25 +53,13 @@ function createTopic(id: string, overrides?: Partial): Topic { }; } -function createDraft(id: string): TaskCenterDraftTab { - const now = new Date("2026-06-18T00:00:00.000Z"); - return { - id, - title: id, - createdAt: now, - updatedAt: now, - status: "draft", - }; -} - function createDraftSendRequest(id: string): TaskCenterDraftSendRequest { return { id, - draftTabId: id, + sessionId: id, text: "生成任务", images: [], submittedAt: Date.now(), - materializeDraft: false, source: "empty-state", }; } @@ -87,10 +72,7 @@ function Harness({ taskCenterWorkspaceId = "workspace-a", topics = [createTopic("topic-a")], }: HarnessProps) { - const taskCenterDraftSurfaceActiveRef = useRef(false); - const [activeDraftTabId, setActiveDraftTabId] = useState( - "draft-a", - ); + const taskCenterHomeSurfaceActiveRef = useRef(false); const [homePendingPreviewRequest, setHomePendingPreviewRequest] = useState( createDraftSendRequest("pending-a"), @@ -99,9 +81,6 @@ function Harness({ useState( createDraftSendRequest("send-a"), ); - const [draftTabs, setDraftTabs] = useState([ - createDraft("draft-a"), - ]); const topicById = useMemo( () => new Map(topics.map((topic) => [topic.id, topic])), [topics], @@ -111,21 +90,17 @@ function Harness({ normalizedInitialSessionId, newChatAt, sessionId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, topicById, topics, - setActiveTaskCenterDraftTabId: setActiveDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest: setDraftSendRequest, - setTaskCenterDraftTabs: setDraftTabs, }); latest = { - activeDraftTabId, draftSendRequest, - draftSurfaceActive: taskCenterDraftSurfaceActiveRef.current, - draftTabs, + draftSurfaceActive: taskCenterHomeSurfaceActiveRef.current, homePendingPreviewRequest, runtime, }; @@ -255,18 +230,20 @@ describe("useTaskCenterTabSessionRuntime", () => { }); expect(latest?.runtime.taskCenterOpenTabIds).toEqual([]); - expect(latest?.activeDraftTabId).toBeNull(); - expect(latest?.draftTabs).toEqual([]); expect(latest?.draftSendRequest).toBeNull(); expect(latest?.homePendingPreviewRequest).toBeNull(); - expect(latest?.draftSurfaceActive).toBe(false); + expect(latest?.draftSurfaceActive).toBe(true); expect(latest?.runtime.taskCenterDetachedTopicId).toBeNull(); expect(latest?.runtime.taskCenterTransitionTopicId).toBeNull(); }); it("可维护打开标签、嵌入 home 标记和本地 session override", () => { renderHarness({ - topics: [createTopic("topic-a"), createTopic("topic-b"), createTopic("topic-c")], + topics: [ + createTopic("topic-a"), + createTopic("topic-b"), + createTopic("topic-c"), + ], }); act(() => { @@ -279,9 +256,9 @@ describe("useTaskCenterTabSessionRuntime", () => { "topic-a", "topic-b", ]); - expect(latest?.runtime.taskCenterEmbeddedHomeSessionIds.has("topic-b")).toBe( - true, - ); + expect( + latest?.runtime.taskCenterEmbeddedHomeSessionIds.has("topic-b"), + ).toBe(true); expect(latest?.runtime.taskCenterLocalSessionOverride).toEqual({ sessionId: "topic-b", routeSessionId: "topic-a", @@ -292,9 +269,9 @@ describe("useTaskCenterTabSessionRuntime", () => { latest?.runtime.replaceTaskCenterOpenTabs("topic-c", "workspace-a"); }); - expect(latest?.runtime.taskCenterEmbeddedHomeSessionIds.has("topic-b")).toBe( - false, - ); + expect( + latest?.runtime.taskCenterEmbeddedHomeSessionIds.has("topic-b"), + ).toBe(false); expect(latest?.runtime.taskCenterOpenTabIds).toEqual(["topic-c"]); }); @@ -324,8 +301,6 @@ describe("useTaskCenterTabSessionRuntime", () => { }); expect(state.runtime.isTaskCenterEntry).toBe(false); - expect(state.activeDraftTabId).toBeNull(); - expect(state.draftTabs).toEqual([]); expect(state.draftSendRequest).toBeNull(); expect(state.homePendingPreviewRequest).toBeNull(); expect(state.draftSurfaceActive).toBe(false); diff --git a/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.ts index 456ccde64..093663762 100644 --- a/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterTabSessionRuntime.ts @@ -31,7 +31,6 @@ import { } from "../utils/taskCenterTabs"; import type { AgentChatWorkspaceProps } from "../agentChatWorkspaceContract"; import { normalizeProjectId } from "../utils/topicProjectResolution"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; type AgentEntry = AgentChatWorkspaceProps["agentEntry"] | null; @@ -47,18 +46,16 @@ interface UseTaskCenterTabSessionRuntimeParams { normalizedInitialSessionId?: string | null; newChatAt?: number; sessionId?: string | null; - taskCenterDraftSurfaceActiveRef: MutableRefObject; + taskCenterHomeSurfaceActiveRef: MutableRefObject; taskCenterWorkspaceId?: string | null; topicById: ReadonlyMap; topics: Topic[]; - setActiveTaskCenterDraftTabId: Dispatch>; setHomePendingPreviewRequest: Dispatch< SetStateAction >; setTaskCenterDraftSendRequest: Dispatch< SetStateAction >; - setTaskCenterDraftTabs: Dispatch>; } export interface TaskCenterTabSessionRuntimeState { @@ -97,14 +94,12 @@ export function useTaskCenterTabSessionRuntime({ normalizedInitialSessionId, newChatAt, sessionId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, topicById, topics, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, }: UseTaskCenterTabSessionRuntimeParams): TaskCenterTabSessionRuntimeState { const shouldStartNewTaskHome = agentEntry === "new-task" && @@ -186,39 +181,31 @@ export function useTaskCenterTabSessionRuntime({ taskCenterNewChatResetRef.current = requestKey; taskCenterFallbackRestoreRef.current = null; - taskCenterDraftSurfaceActiveRef.current = false; + taskCenterHomeSurfaceActiveRef.current = true; setTaskCenterDetachedTopicId(null); setTaskCenterTransitionTopicId(null); setTaskCenterEmbeddedHomeSessionIds((current) => current.size > 0 ? new Set() : current, ); setTaskCenterLocalSessionOverride(null); - setActiveTaskCenterDraftTabId(null); - setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current)); setTaskCenterDraftSendRequest(null); setHomePendingPreviewRequest(null); setTaskCenterOpenTabMap((currentMap) => - updateTaskCenterTabIdsForWorkspace( - currentMap, - taskCenterWorkspaceId, - [], - ), + updateTaskCenterTabIdsForWorkspace(currentMap, taskCenterWorkspaceId, []), ); }, [ agentEntry, newChatAt, normalizedInitialSessionId, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, ]); useEffect(() => { if (!isTaskCenterEntry) { - taskCenterDraftSurfaceActiveRef.current = false; + taskCenterHomeSurfaceActiveRef.current = false; setTaskCenterTransitionTopicId(null); return; } @@ -232,7 +219,7 @@ export function useTaskCenterTabSessionRuntime({ }, [ isTaskCenterEntry, sessionId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterTransitionTopicId, ]); @@ -242,9 +229,7 @@ export function useTaskCenterTabSessionRuntime({ setTaskCenterEmbeddedHomeSessionIds((current) => current.size > 0 ? new Set() : current, ); - setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current)); - setActiveTaskCenterDraftTabId(null); - taskCenterDraftSurfaceActiveRef.current = false; + taskCenterHomeSurfaceActiveRef.current = false; if (agentEntry !== "new-task") { setTaskCenterDraftSendRequest(null); setHomePendingPreviewRequest(null); @@ -273,11 +258,9 @@ export function useTaskCenterTabSessionRuntime({ isTaskCenterEntry, normalizedInitialSessionId, sessionId, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, topicById, ]); @@ -294,9 +277,6 @@ export function useTaskCenterTabSessionRuntime({ } taskCenterRouteTabSyncRef.current = syncIntent.nextRouteSyncSessionId; - if (syncIntent.shouldClearActiveDraft) { - setActiveTaskCenterDraftTabId(null); - } if (syncIntent.shouldClearTransitionAndDetached) { setTaskCenterTransitionTopicId(null); setTaskCenterDetachedTopicId(null); @@ -312,7 +292,6 @@ export function useTaskCenterTabSessionRuntime({ }, [ agentEntry, normalizedInitialSessionId, - setActiveTaskCenterDraftTabId, shouldRespectTaskCenterLocalSession, taskCenterWorkspaceId, ]); diff --git a/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.test.tsx b/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.test.tsx index 78b5d5aca..188ac1567 100644 --- a/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.test.tsx @@ -2,26 +2,29 @@ import { useRef, useState, type Dispatch, type SetStateAction } from "react"; import { createRoot, type Root } from "react-dom/client"; import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Character } from "@/lib/api/projectMemory"; import type { Topic } from "../hooks/agentChatShared"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; import type { TaskCenterWorkspaceTabMap } from "../utils/taskCenterTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import { useTaskCenterTopicNavigationRuntime } from "./useTaskCenterTopicNavigationRuntime"; type Runtime = ReturnType; const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; -function mountRuntime(overrides: { - clearTaskCenterEmbeddedHomeSession?: (topicId: string) => void; - setTaskCenterOpenTabMap?: Dispatch>; - switchTopic?: (topicId: string, options?: unknown) => Promise; - upsertTaskCenterOpenTab?: ( - topicId: string, - workspaceIdOverride?: string | null, - ) => void; -} = {}) { +function mountRuntime( + overrides: { + getComposerDraftNavigationEpoch?: () => number; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; + clearTaskCenterEmbeddedHomeSession?: (topicId: string) => void; + setTaskCenterOpenTabMap?: Dispatch< + SetStateAction + >; + switchTopic?: (topicId: string, options?: unknown) => Promise; + upsertTaskCenterOpenTab?: ( + topicId: string, + workspaceIdOverride?: string | null, + ) => void; + } = {}, +) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -49,20 +52,9 @@ function mountRuntime(overrides: { function Harness() { const activeSessionIdRef = useRef("topic-current"); - const activeTaskCenterDraftTabIdRef = useRef(null); - const taskCenterDraftSurfaceActiveRef = useRef(true); - const taskCenterDraftTabsRef = useRef([]); + const taskCenterHomeSurfaceActiveRef = useRef(true); const taskCenterOpenTabIdsRef = useRef(["topic-current"]); - const [, setActiveTaskCenterDraftTabId] = useState(null); - const [, setHomePendingPreviewRequest] = - useState(null); - const [, setInput] = useState(""); - const [, setMentionedCharacters] = useState([]); - const [, setSelectedText] = useState(""); const [, setTaskCenterDetachedTopicId] = useState(null); - const [, setTaskCenterDraftSendRequest] = - useState(null); - const [, setTaskCenterDraftTabs] = useState([]); const [, setTaskCenterLocalSessionOverride] = useState<{ sessionId: string; routeSessionId: string | null; @@ -75,34 +67,25 @@ function mountRuntime(overrides: { runtimeRef.current = useTaskCenterTopicNavigationRuntime({ activeSessionIdRef, - activeTaskCenterDraftTabIdRef, + onStartNewConversation: overrides.onStartNewConversation, + getComposerDraftNavigationEpoch: + overrides.getComposerDraftNavigationEpoch, agentEntry: "claw", clearEntryPendingA2UI: vi.fn(), - clearMessages: vi.fn(), clearTaskCenterEmbeddedHomeSession: overrides.clearTaskCenterEmbeddedHomeSession ?? vi.fn(), messagesLength: 0, - openTaskCenterDraftTab: vi.fn(() => "draft-1"), replaceTaskCenterOpenTabs: vi.fn(), resetLocalImageWorkbenchSessionScope: vi.fn(), - resetTopicLocalState: vi.fn(), sessionId: "topic-current", - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTaskCenterLocalSessionOverride, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, switchTopic: overrides.switchTopic ?? vi.fn(async () => "success" as const), taskCenterDetachedTopicId: null, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabsRef, + taskCenterHomeSurfaceActiveRef, taskCenterOpenTabIdsRef, taskCenterTransitionTopicId: null, taskCenterWorkspaceId: "workspace-test", @@ -125,12 +108,14 @@ function mountRuntime(overrides: { } afterEach(() => { + vi.useRealTimers(); mountedRoots.splice(0).forEach(({ root, container }) => { act(() => { root.unmount(); }); container.remove(); }); + vi.unstubAllGlobals(); }); describe("useTaskCenterTopicNavigationRuntime", () => { @@ -150,7 +135,9 @@ describe("useTaskCenterTopicNavigationRuntime", () => { expect(clearTaskCenterEmbeddedHomeSession).toHaveBeenCalledWith( "topic-next", ); - expect(switchTopic).toHaveBeenCalledWith("topic-next", undefined); + expect(switchTopic).toHaveBeenCalledWith("topic-next", undefined, { + onHydrated: expect.any(Function), + }); }); it("switchTopic 成功返回 undefined 时不应回滚已打开会话", async () => { @@ -168,11 +155,176 @@ describe("useTaskCenterTopicNavigationRuntime", () => { await runtime.handleOpenTaskTopic("topic-next"); }); - expect(switchTopic).toHaveBeenCalledWith("topic-next", undefined); + expect(switchTopic).toHaveBeenCalledWith("topic-next", undefined, { + onHydrated: expect.any(Function), + }); expect(upsertTaskCenterOpenTab).toHaveBeenCalledWith( "topic-next", "workspace-test", ); expect(setTaskCenterOpenTabMap).not.toHaveBeenCalled(); }); + + it("迟到普通 switch 成功不能清理新 Home 的 presentation", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let epoch = 0; + let resolve!: (value: string) => void; + const clearTaskCenterEmbeddedHomeSession = vi.fn(); + const upsertTaskCenterOpenTab = vi.fn(); + const runtime = mountRuntime({ + getComposerDraftNavigationEpoch: () => epoch, + clearTaskCenterEmbeddedHomeSession, + upsertTaskCenterOpenTab, + switchTopic: () => + new Promise((done) => { + resolve = done; + }), + }); + let pending!: Promise; + act(() => { + pending = runtime.handleOpenTaskTopic("topic-next"); + }); + epoch += 1; + await act(async () => { + resolve("success"); + await pending; + }); + expect(await pending).toBe("superseded"); + expect(clearTaskCenterEmbeddedHomeSession).not.toHaveBeenCalled(); + expect(upsertTaskCenterOpenTab).toHaveBeenCalledTimes(1); + }); + + it("迟到失败不能回滚后来提交的 Home", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let epoch = 0; + let reject!: (error: Error) => void; + const setTaskCenterOpenTabMap = vi.fn(); + const runtime = mountRuntime({ + getComposerDraftNavigationEpoch: () => epoch, + setTaskCenterOpenTabMap, + switchTopic: () => + new Promise((_done, fail) => { + reject = fail; + }), + }); + let pending!: Promise; + act(() => { + pending = runtime.handleOpenTaskTopic("topic-next"); + }); + const observed = pending.catch((error: Error) => error.message); + epoch += 1; + await act(async () => { + reject(new Error("late failure")); + await observed; + }); + expect(await observed).toBe("late failure"); + expect(setTaskCenterOpenTabMap).not.toHaveBeenCalled(); + }); + + it("busy 会话切换只在重试成功后提交 canonical presentation", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + const switchTopic = vi + .fn() + .mockResolvedValueOnce("busy") + .mockResolvedValueOnce("success"); + const clearTaskCenterEmbeddedHomeSession = vi.fn(); + const setTaskCenterOpenTabMap = vi.fn(); + const runtime = mountRuntime({ + switchTopic, + clearTaskCenterEmbeddedHomeSession, + setTaskCenterOpenTabMap, + }); + await act(async () => { + expect(await runtime.handleOpenTaskTopic("topic-next")).toBe("busy"); + }); + expect(clearTaskCenterEmbeddedHomeSession).not.toHaveBeenCalled(); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + expect(switchTopic).toHaveBeenCalledTimes(2); + expect(clearTaskCenterEmbeddedHomeSession).toHaveBeenCalledExactlyOnceWith( + "topic-next", + ); + expect(setTaskCenterOpenTabMap).not.toHaveBeenCalled(); + }); + + it("新 Home 在 busy 定时器触发前提交时不会重开旧会话", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + let epoch = 0; + const switchTopic = vi.fn(async () => "busy"); + const clearTaskCenterEmbeddedHomeSession = vi.fn(); + const runtime = mountRuntime({ + switchTopic, + clearTaskCenterEmbeddedHomeSession, + getComposerDraftNavigationEpoch: () => epoch, + }); + await act(async () => { + await runtime.handleOpenTaskTopic("topic-next"); + }); + epoch += 1; + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + expect(switchTopic).toHaveBeenCalledTimes(1); + expect(clearTaskCenterEmbeddedHomeSession).not.toHaveBeenCalled(); + }); + + it.each(["success", "error", "rejection"])( + "busy 重试在途后新 Home 不能被迟到 %s 改写或回滚", + async (outcome) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + let epoch = 0; + let finish!: () => void; + const switchTopic = vi + .fn() + .mockResolvedValueOnce("busy") + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finish = () => + outcome === "rejection" + ? reject(new Error("late retry")) + : resolve(outcome); + }), + ); + const clearTaskCenterEmbeddedHomeSession = vi.fn(); + const setTaskCenterOpenTabMap = vi.fn(); + const runtime = mountRuntime({ + switchTopic, + clearTaskCenterEmbeddedHomeSession, + setTaskCenterOpenTabMap, + getComposerDraftNavigationEpoch: () => epoch, + }); + await act(async () => { + await runtime.handleOpenTaskTopic("topic-next"); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(120); + }); + expect(switchTopic).toHaveBeenCalledTimes(2); + epoch += 1; + await act(async () => { + finish(); + await Promise.resolve(); + }); + expect(clearTaskCenterEmbeddedHomeSession).not.toHaveBeenCalled(); + expect(setTaskCenterOpenTabMap).not.toHaveBeenCalled(); + }, + ); + + it("关闭最后的 canonical 会话只发公开 Home(false) intent", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const onStartNewConversation = vi.fn(); + const runtime = mountRuntime({ onStartNewConversation }); + await act(async () => { + await runtime.handleCloseTaskCenterTab("topic-current"); + }); + expect(onStartNewConversation).toHaveBeenCalledExactlyOnceWith({ + projectId: "workspace-test", + freshDraft: false, + }); + }); }); diff --git a/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts b/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts index a8449a05a..ab1b35421 100644 --- a/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts +++ b/src/components/agent/chat/workspace/useTaskCenterTopicNavigationRuntime.ts @@ -1,18 +1,13 @@ import { - startTransition, useCallback, + useRef, type Dispatch, type MutableRefObject, type SetStateAction, } from "react"; -import type { Character } from "@/lib/api/projectMemory"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; import type { Topic } from "../hooks/agentChatShared"; import { loadPersistedSessionWorkspaceId } from "../hooks/agentProjectStorage"; -import { - isTaskCenterDraftTabId, - type TaskCenterDraftTab, -} from "./agentChatWorkspaceHelpers"; import { clearTaskCenterLocalSessionOverrideForTopic, clearTaskCenterTransitionTopicForTopic, @@ -25,9 +20,10 @@ import { updateTaskCenterTabIdsForWorkspace, type TaskCenterWorkspaceTabMap, } from "../utils/taskCenterTabs"; -import { resolveTaskCenterDraftClosePlan } from "./taskCenterDraftTabs"; import { normalizeProjectId } from "../utils/topicProjectResolution"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; + +import type { ComposerDraftNavigationCallbacks } from "./composerDraftNavigation"; +import type { WorkspaceTopicSwitchLifecycle } from "./useWorkspaceTopicSwitch"; type AgentEntry = "new-task" | "claw"; @@ -37,35 +33,20 @@ interface TaskCenterTopicSwitchOptions { allowDetachedSession?: boolean; } -interface UseTaskCenterTopicNavigationRuntimeParams { +interface UseTaskCenterTopicNavigationRuntimeParams extends ComposerDraftNavigationCallbacks { activeSessionIdRef: MutableRefObject; - activeTaskCenterDraftTabIdRef: MutableRefObject; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; agentEntry: AgentEntry; clearEntryPendingA2UI: () => void; - clearMessages: (options?: { showToast?: boolean }) => void; clearTaskCenterEmbeddedHomeSession: (topicId: string) => void; messagesLength: number; - openTaskCenterDraftTab: () => string; replaceTaskCenterOpenTabs: ( topicId: string, workspaceIdOverride?: string | null, ) => void; resetLocalImageWorkbenchSessionScope: () => void; - resetTopicLocalState: () => void; - resetComposerDraft?: () => void; sessionId?: string | null; - setActiveTaskCenterDraftTabId: Dispatch>; - setHomePendingPreviewRequest: Dispatch< - SetStateAction - >; - setInput: Dispatch>; - setMentionedCharacters: Dispatch>; - setSelectedText: Dispatch>; setTaskCenterDetachedTopicId: Dispatch>; - setTaskCenterDraftSendRequest: Dispatch< - SetStateAction - >; - setTaskCenterDraftTabs: Dispatch>; setTaskCenterLocalSessionOverride: Dispatch< SetStateAction<{ sessionId: string; routeSessionId: string | null } | null> >; @@ -74,10 +55,10 @@ interface UseTaskCenterTopicNavigationRuntimeParams { switchTopic: ( topicId: string, options?: TaskCenterTopicSwitchOptions, + lifecycle?: WorkspaceTopicSwitchLifecycle, ) => Promise; taskCenterDetachedTopicId: string | null; - taskCenterDraftSurfaceActiveRef: MutableRefObject; - taskCenterDraftTabsRef: MutableRefObject; + taskCenterHomeSurfaceActiveRef: MutableRefObject; taskCenterOpenTabIdsRef: MutableRefObject; taskCenterTransitionTopicId: string | null; taskCenterWorkspaceId?: string | null; @@ -91,33 +72,23 @@ interface UseTaskCenterTopicNavigationRuntimeParams { export function useTaskCenterTopicNavigationRuntime({ activeSessionIdRef, - activeTaskCenterDraftTabIdRef, + onStartNewConversation, agentEntry, clearEntryPendingA2UI, - clearMessages, clearTaskCenterEmbeddedHomeSession, messagesLength, - openTaskCenterDraftTab, replaceTaskCenterOpenTabs, resetLocalImageWorkbenchSessionScope, - resetTopicLocalState, - resetComposerDraft, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, sessionId, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTaskCenterLocalSessionOverride, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, switchTopic, taskCenterDetachedTopicId, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabsRef, + taskCenterHomeSurfaceActiveRef, taskCenterOpenTabIdsRef, taskCenterTransitionTopicId, taskCenterWorkspaceId, @@ -125,6 +96,7 @@ export function useTaskCenterTopicNavigationRuntime({ upsertTaskCenterOpenTab, markTaskCenterLocalSessionOverride, }: UseTaskCenterTopicNavigationRuntimeParams) { + const navigationVersionRef = useRef(0); const handleOpenTaskTopic = useCallback( async ( topicId: string, @@ -155,8 +127,7 @@ export function useTaskCenterTopicNavigationRuntime({ topicId, activeSessionId: activeSessionIdRef.current, messagesLength, - activeDraftTabId: activeTaskCenterDraftTabIdRef.current, - draftSurfaceActive: taskCenterDraftSurfaceActiveRef.current, + draftSurfaceActive: taskCenterHomeSurfaceActiveRef.current, detachedTopicId: taskCenterDetachedTopicId, shouldResume, forceRefresh: options?.forceRefresh, @@ -186,31 +157,52 @@ export function useTaskCenterTopicNavigationRuntime({ return; } - taskCenterDraftSurfaceActiveRef.current = false; - clearTaskCenterEmbeddedHomeSession(topicId); - resetLocalImageWorkbenchSessionScope(); + const navigationVersion = ++navigationVersionRef.current; + const sourceNavigationEpoch = getComposerDraftNavigationEpoch?.(); + const isNavigationCurrent = () => + navigationVersion === navigationVersionRef.current && + sourceNavigationEpoch === getComposerDraftNavigationEpoch?.(); + composerDraftCoordinator?.prepareNavigation(); + let activationCommitted = false; + const commitTopicActivation = () => { + if (activationCommitted || !isNavigationCurrent()) return; + activationCommitted = true; + taskCenterHomeSurfaceActiveRef.current = false; + clearTaskCenterEmbeddedHomeSession(topicId); + resetLocalImageWorkbenchSessionScope(); + clearEntryPendingA2UI(); + }; + const lifecycle = { onHydrated: commitTopicActivation }; setTaskCenterTransitionTopicId(topicId); setTaskCenterDetachedTopicId(isDetachedTopic ? topicId : null); - setActiveTaskCenterDraftTabId(null); - clearEntryPendingA2UI(); if (options?.replaceOpenTabs === true) { replaceTaskCenterOpenTabs(topicId, topicWorkspaceId); } else if (shouldMaintainTaskCenterTab) { upsertTaskCenterOpenTab(topicId, topicWorkspaceId); } markTaskCenterLocalSessionOverride(topicId); - const switchResult = await switchTopic(topicId, switchOptions); + let switchResult: unknown; + try { + switchResult = await switchTopic(topicId, switchOptions, lifecycle); + } catch (error) { + if (isNavigationCurrent()) rollbackPendingOpen(); + throw error; + } if (switchResult === "busy") { scheduleMinimumDelayIdleTask( () => { - void switchTopic(topicId, switchOptions) + if (!isNavigationCurrent()) return; + void switchTopic(topicId, switchOptions, lifecycle) .then((retryResult) => { + if (!isNavigationCurrent()) return; if (!isTaskCenterTopicSwitchSuccess(retryResult)) { rollbackPendingOpen(); + } else if (retryResult !== "deferred") { + commitTopicActivation(); } }) .catch(() => { - rollbackPendingOpen(); + if (isNavigationCurrent()) rollbackPendingOpen(); }); }, { @@ -218,127 +210,55 @@ export function useTaskCenterTopicNavigationRuntime({ idleTimeoutMs: 600, }, ); - return; + return switchResult; } + if (!isNavigationCurrent()) + return activationCommitted && + isTaskCenterTopicSwitchSuccess(switchResult) + ? switchResult + : "superseded"; if (!isTaskCenterTopicSwitchSuccess(switchResult)) { rollbackPendingOpen(); - return; + return switchResult; + } + if (switchResult !== "deferred") { + commitTopicActivation(); } if (options?.replaceOpenTabs === true) { replaceTaskCenterOpenTabs(topicId, topicWorkspaceId); } else if (shouldMaintainTaskCenterTab) { upsertTaskCenterOpenTab(topicId, topicWorkspaceId); } + return switchResult; }, [ activeSessionIdRef, - activeTaskCenterDraftTabIdRef, agentEntry, clearEntryPendingA2UI, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, clearTaskCenterEmbeddedHomeSession, markTaskCenterLocalSessionOverride, messagesLength, replaceTaskCenterOpenTabs, resetLocalImageWorkbenchSessionScope, - setActiveTaskCenterDraftTabId, setTaskCenterDetachedTopicId, setTaskCenterLocalSessionOverride, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, switchTopic, taskCenterDetachedTopicId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterOpenTabIdsRef, topicById, upsertTaskCenterOpenTab, ], ); - const handleSelectTaskCenterDraftTab = useCallback( - (draftTabId: string) => { - const draft = taskCenterDraftTabsRef.current.find( - (tab) => tab.id === draftTabId, - ); - if (!draft) { - return; - } - - resetComposerDraft?.(); - taskCenterDraftSurfaceActiveRef.current = true; - resetLocalImageWorkbenchSessionScope(); - clearMessages({ showToast: false }); - startTransition(() => { - setTaskCenterTransitionTopicId(null); - setTaskCenterDetachedTopicId(null); - setActiveTaskCenterDraftTabId(draftTabId); - setTaskCenterDraftSendRequest(null); - setHomePendingPreviewRequest(null); - resetTopicLocalState(); - setInput(""); - setSelectedText(""); - setMentionedCharacters([]); - }); - }, - [ - clearMessages, - resetLocalImageWorkbenchSessionScope, - resetComposerDraft, - resetTopicLocalState, - setActiveTaskCenterDraftTabId, - setHomePendingPreviewRequest, - setInput, - setMentionedCharacters, - setSelectedText, - setTaskCenterDetachedTopicId, - setTaskCenterDraftSendRequest, - setTaskCenterTransitionTopicId, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabsRef, - ], - ); - - const handleSwitchTaskTopic = useCallback( - async (topicId: string) => { - if (isTaskCenterDraftTabId(topicId)) { - handleSelectTaskCenterDraftTab(topicId); - return; - } - - await handleOpenTaskTopic(topicId); - }, - [handleOpenTaskTopic, handleSelectTaskCenterDraftTab], - ); + const handleSwitchTaskTopic = handleOpenTaskTopic; const handleCloseTaskCenterTab = useCallback( async (topicId: string) => { - if (isTaskCenterDraftTabId(topicId)) { - const closePlan = resolveTaskCenterDraftClosePlan({ - closingDraftTabId: topicId, - currentDraftTabs: taskCenterDraftTabsRef.current, - activeDraftTabId: activeTaskCenterDraftTabIdRef.current, - openTopicIds: taskCenterOpenTabIdsRef.current, - }); - setTaskCenterDraftTabs(closePlan.remainingDraftTabs); - if (closePlan.action === "selectDraft") { - handleSelectTaskCenterDraftTab(closePlan.fallbackDraftTabId); - return; - } - if (closePlan.action === "switchTopic") { - taskCenterDraftSurfaceActiveRef.current = false; - setActiveTaskCenterDraftTabId(null); - setInput(""); - await handleSwitchTaskTopic(closePlan.fallbackTopicId); - return; - } - if (closePlan.action === "clearActiveDraft") { - resetComposerDraft?.(); - taskCenterDraftSurfaceActiveRef.current = false; - setActiveTaskCenterDraftTabId(null); - setInput(""); - } - return; - } - const closePlan = resolveTaskCenterTopicClosePlan({ closingTopicId: topicId, currentOpenTabIds: taskCenterOpenTabIdsRef.current, @@ -367,27 +287,22 @@ export function useTaskCenterTopicNavigationRuntime({ if (closePlan.fallbackTopicId) { await handleSwitchTaskTopic(closePlan.fallbackTopicId); } else { - openTaskCenterDraftTab(); + onStartNewConversation?.({ + projectId: normalizeProjectId(taskCenterWorkspaceId), + freshDraft: false, + }); } } }, [ - activeTaskCenterDraftTabIdRef, clearTaskCenterEmbeddedHomeSession, - handleSelectTaskCenterDraftTab, handleSwitchTaskTopic, - openTaskCenterDraftTab, sessionId, - setActiveTaskCenterDraftTabId, - setInput, - resetComposerDraft, + onStartNewConversation, setTaskCenterDetachedTopicId, - setTaskCenterDraftTabs, setTaskCenterOpenTabMap, setTaskCenterTransitionTopicId, taskCenterDetachedTopicId, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabsRef, taskCenterOpenTabIdsRef, taskCenterTransitionTopicId, taskCenterWorkspaceId, @@ -397,7 +312,6 @@ export function useTaskCenterTopicNavigationRuntime({ return { handleCloseTaskCenterTab, handleOpenTaskTopic, - handleSelectTaskCenterDraftTab, handleSwitchTaskTopic, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts index 45e2e93d6..463c36c5a 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasWorkflowActions.ts @@ -15,6 +15,7 @@ import type { import type { AutoContinueRequestPayload } from "@/lib/api/agentRuntime/sessionTypes"; import { importDocument } from "@/lib/api/session-files"; import type { HandleSendOptions } from "../hooks/handleSendTypes"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import type { TopicBranchStatus } from "../hooks/useTopicBranchBoard"; import type { MessageImage } from "../types"; import { @@ -42,7 +43,7 @@ type WorkspaceSendHandler = ( sendExecutionStrategy?: "react", autoContinuePayload?: AutoContinueRequestPayload, sendOptions?: HandleSendOptions, -) => Promise; +) => Promise; interface RunImageWorkbenchCommandParams { rawText: string; diff --git a/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.integration.test.tsx b/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.integration.test.tsx new file mode 100644 index 000000000..33dd932b4 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.integration.test.tsx @@ -0,0 +1,412 @@ +import { act, useRef, useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import { LAST_PROJECT_ID_KEY } from "../hooks/agentProjectStorage"; +import { useWorkspaceProjectSelection } from "../hooks/useWorkspaceProjectSelection"; +import { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; +import { useWorkspaceComposerDraftNavigation } from "./useWorkspaceComposerDraftNavigation"; +import { useWorkspaceTaskCenterDraftStateRuntime } from "./useWorkspaceTaskCenterDraftStateRuntime"; +import { useWorkspaceTopicSwitch } from "./useWorkspaceTopicSwitch"; +import { useTaskCenterTopicNavigationRuntime } from "./useTaskCenterTopicNavigationRuntime"; +import type { TaskCenterWorkspaceTabMap } from "../utils/taskCenterTabs"; +import type { Topic } from "../hooks/agentChatShared"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +const projectApi = vi.hoisted(() => ({ getProject: vi.fn() })); +vi.mock("@/lib/api/project", () => projectApi); + +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); +type StoreFixture = Awaited>; +const mounted: Array<{ root: Root; element: HTMLDivElement }> = []; +const homeA: DraftScope = { + workspaceId: "workspace-a", + kind: "home", + id: "new-conversation", +}; +const homeB: DraftScope = { ...homeA, workspaceId: "workspace-b" }; +const draftA: DraftScope = homeA; +const draftB: DraftScope = homeB; +const topicB: DraftScope = { ...homeB, kind: "local", id: "topic-b" }; +const noop = () => undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.clear(); + localStorage.setItem(LAST_PROJECT_ID_KEY, JSON.stringify(homeA.workspaceId)); + projectApi.getProject.mockImplementation(async (id: string) => ({ + id, + isArchived: false, + })); +}); +afterEach(() => { + act(() => { + for (const { root, element } of mounted.splice(0)) { + root.unmount(); + element.remove(); + } + }); + localStorage.clear(); + vi.unstubAllGlobals(); +}); + +async function mount( + fixture: StoreFixture, + initialScope: DraftScope, + originalSwitchTopic: Parameters< + typeof useWorkspaceTopicSwitch + >[0]["originalSwitchTopic"] = vi.fn(async (_topicId: string) => undefined), + commitSessionSynchronously = false, +) { + const clearTaskCenterEmbeddedHomeSession = vi.fn(); + let current!: { + draft: ReturnType; + selection: ReturnType; + state: ReturnType; + navigation: ReturnType; + topics: ReturnType; + topicNavigation: ReturnType; + setSessionId: (sessionId: string | null) => void; + }; + function Surface() { + const selection = useWorkspaceProjectSelection(); + const [sessionId, setSessionId] = useState(null); + const draft = useWorkspaceComposerDraftRuntime({ + controller: fixture.controller, + initialScope, + }); + const state = useWorkspaceTaskCenterDraftStateRuntime({ + agentEntry: "claw", + deferSessionRecentMetadataSyncForNavigation: noop, + effectiveThreadItemCount: 0, + hasInitialSessionTopic: false, + messagesLength: 0, + normalizedInitialSessionId: null, + sessionId, + composerDraftCoordinator: draft.composerDraftCoordinator, + }); + const navigation = useWorkspaceComposerDraftNavigation({ + composerDraftRuntime: draft, + workspaceId: selection.projectId ?? null, + projectSelectionSource: selection.projectSelectionSource, + normalizedInitialSessionId: null, + sessionId, + onTopicSwitchCommitted: state.handleTopicSwitchCommitted, + }); + const topics = useWorkspaceTopicSwitch({ + ...selection, + originalSwitchTopic: async (id, options) => { + const result = options + ? await originalSwitchTopic(id, options) + : await originalSwitchTopic(id); + if (commitSessionSynchronously) flushSync(() => setSessionId(id)); + return result; + }, + onBeforeTopicSwitch: state.handleBeforeTopicSwitch, + onAfterTopicSwitch: (...args) => { + if (commitSessionSynchronously) + flushSync(() => navigation.handleTopicSwitchCommitted(...args)); + else navigation.handleTopicSwitchCommitted(...args); + }, + composerDraftCoordinator: draft.composerDraftCoordinator, + getComposerDraftNavigationEpoch: draft.getComposerDraftNavigationEpoch, + loadTopicBoundProjectId: () => homeB.workspaceId, + resetTopicLocalState: noop, + }); + const activeSessionIdRef = useRef(sessionId); + activeSessionIdRef.current = sessionId; + const openIds = useRef([]); + const [detachedTopicId, setDetachedTopicId] = useState(null); + const [transitionTopicId, setTransitionTopicId] = useState( + null, + ); + const [, setLocalOverride] = useState<{ + sessionId: string; + routeSessionId: string | null; + } | null>(null); + const [, setOpenMap] = useState({}); + const topic: Topic = { + id: topicB.id, + title: "B history", + createdAt: new Date(), + updatedAt: new Date(), + workspaceId: homeB.workspaceId, + workingDir: null, + messagesCount: 1, + executionStrategy: "react", + status: "done", + lastPreview: "B history", + hasUnread: false, + tag: null, + sourceSessionId: topicB.id, + }; + const topicNavigation = useTaskCenterTopicNavigationRuntime({ + activeSessionIdRef, + agentEntry: "claw", + clearEntryPendingA2UI: noop, + clearTaskCenterEmbeddedHomeSession, + messagesLength: 0, + replaceTaskCenterOpenTabs: noop, + resetLocalImageWorkbenchSessionScope: noop, + composerDraftCoordinator: draft.composerDraftCoordinator, + getComposerDraftNavigationEpoch: draft.getComposerDraftNavigationEpoch, + sessionId, + setTaskCenterDetachedTopicId: setDetachedTopicId, + setTaskCenterLocalSessionOverride: setLocalOverride, + setTaskCenterOpenTabMap: setOpenMap, + setTaskCenterTransitionTopicId: setTransitionTopicId, + switchTopic: topics.switchTopic, + taskCenterDetachedTopicId: detachedTopicId, + taskCenterHomeSurfaceActiveRef: state.taskCenterHomeSurfaceActiveRef, + taskCenterOpenTabIdsRef: openIds, + taskCenterTransitionTopicId: transitionTopicId, + taskCenterWorkspaceId: selection.projectId, + topicById: new Map([[topic.id, topic]]), + upsertTaskCenterOpenTab: noop, + markTaskCenterLocalSessionOverride: noop, + }); + current = { + draft, + selection, + state, + navigation, + topics, + topicNavigation, + setSessionId, + }; + return null; + } + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + mounted.push({ root, element }); + await act(async () => root.render()); + const activate = (_id: string) => { + act(() => { + current.state.taskCenterHomeSurfaceActiveRef.current = true; + }); + }; + return { + current: () => current, + activate, + originalSwitchTopic, + clearTaskCenterEmbeddedHomeSession, + }; +} + +describe("Workspace draft navigation with real scope and document owners", () => { + it("resumes a cross-workspace topic switch without turning it into picker home navigation", async () => { + const h = await setup(); + h.seed(topicB, "B topic draft"); + const view = await mount(h, draftA); + view.activate(draftA.id); + act(() => h.controller.setText("A unsent")); + const generation = view.current().draft.composerDraftStatus?.generation; + const epoch = view.current().draft.getComposerDraftNavigationEpoch(); + + await act(async () => { + expect(await view.current().topics.switchTopic(topicB.id)).toBe( + "deferred", + ); + }); + expect(view.originalSwitchTopic).toHaveBeenCalledExactlyOnceWith(topicB.id); + expect(view.current().selection.projectSelectionSource).toBe( + "topic-switch", + ); + expect(view.current().selection.projectId).toBe(homeB.workspaceId); + expect(view.current().draft.getComposerDraftNavigationEpoch()).toBe(epoch); + expect(view.current().draft.composerDraftStatus?.generation).toBe( + generation, + ); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(draftA); + expect(h.controller.getDocument().text).toBe("A unsent"); + + await act(async () => view.current().setSessionId(topicB.id)); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(topicB); + expect(view.current().state.taskCenterHomeSurfaceActiveRef.current).toBe( + false, + ); + expect(h.controller.getDocument().text).toBe("B topic draft"); + await act(async () => + view.current().draft.composerDraftCoordinator!.idle(), + ); + expect((await h.read(draftA))?.state).toBe("draft"); + }); + + it("same-workspace hydration can commit its canonical scope before the outer topic await resumes", async () => { + localStorage.setItem( + LAST_PROJECT_ID_KEY, + JSON.stringify(homeB.workspaceId), + ); + const h = await setup(); + h.seed(topicB, "canonical B draft"); + const view = await mount( + h, + homeB, + vi.fn(async (_id: string) => undefined), + true, + ); + act(() => h.controller.setText("home B retained")); + let result: unknown; + await act(async () => { + result = await view + .current() + .topicNavigation.handleOpenTaskTopic(topicB.id); + }); + expect(result).toBe("success"); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(topicB); + expect(h.controller.getDocument().text).toBe("canonical B draft"); + expect(view.current().state.taskCenterHomeSurfaceActiveRef.current).toBe( + false, + ); + expect( + view.clearTaskCenterEmbeddedHomeSession, + ).toHaveBeenCalledExactlyOnceWith(topicB.id); + }); + + it("waits for both the real session and workspace before clearing draft activation", async () => { + const h = await setup(); + const view = await mount(h, draftA); + view.activate(draftA.id); + act(() => h.controller.setText("editable source")); + const generation = view.current().draft.composerDraftStatus?.generation; + await act(async () => { + view + .current() + .navigation.handleTopicSwitchCommitted( + topicB.id, + homeB.workspaceId, + view.current().draft.getComposerDraftNavigationEpoch(), + ); + }); + await act(async () => view.current().setSessionId(topicB.id)); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(draftA); + expect(view.current().draft.composerDraftStatus?.generation).toBe( + generation, + ); + expect(h.controller.getDocument().text).toBe("editable source"); + }); + + it("a pending old topic cannot clear a newer draft when its session arrives late", async () => { + const h = await setup(); + const newer: DraftScope = homeB; + h.seed(newer, "new draft unsent"); + const view = await mount(h, draftA); + view.activate(draftA.id); + const capturedEpoch = view + .current() + .draft.getComposerDraftNavigationEpoch(); + await act(async () => { + view + .current() + .navigation.handleTopicSwitchCommitted( + "old-topic", + homeA.workspaceId, + capturedEpoch, + ); + }); + await act(async () => { + view.current().draft.commitComposerDraftScope(newer); + view.current().state.taskCenterHomeSurfaceActiveRef.current = true; + }); + await act(async () => view.current().setSessionId("old-topic")); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(newer); + expect(view.current().state.taskCenterHomeSurfaceActiveRef.current).toBe( + true, + ); + expect(h.controller.getDocument().text).toBe("new draft unsent"); + await act(async () => { + view + .current() + .navigation.handleTopicSwitchCommitted( + "old-topic", + homeA.workspaceId, + capturedEpoch, + ); + }); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(newer); + }); + + it("the actual project picker saves A and restores the selected workspace home", async () => { + const h = await setup(); + h.seed(homeB, "B home unsent"); + const view = await mount(h, homeA); + act(() => h.controller.setText("A home unsent")); + await act(async () => + view.current().selection.applyProjectSelection(homeB.workspaceId), + ); + expect(view.current().selection.projectSelectionSource).toBe("manual"); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(homeB); + expect(h.controller.getDocument().text).toBe("B home unsent"); + await act(async () => + view.current().draft.composerDraftCoordinator!.idle(), + ); + expect((await h.read(homeA))?.state).toBe("draft"); + await act(async () => + view.current().selection.applyProjectSelection(homeA.workspaceId), + ); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(homeA); + expect(h.controller.getDocument().text).toBe("A home unsent"); + }); + + it("a project selection and explicit draft target in the same event preserve activation", async () => { + const h = await setup(); + h.seed(draftB, "explicit B draft"); + const view = await mount(h, draftA); + act(() => h.controller.setText("A retained")); + await act(async () => { + view.current().selection.applyProjectSelection(homeB.workspaceId); + view.current().draft.commitComposerDraftScope(draftB); + view.current().state.taskCenterHomeSurfaceActiveRef.current = true; + }); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(draftB); + expect(view.current().draft.getComposerDraftNavigationEpoch()).toBe(1); + expect(view.current().state.taskCenterHomeSurfaceActiveRef.current).toBe( + true, + ); + expect(h.controller.getDocument().text).toBe("explicit B draft"); + await act(async () => + view.current().draft.composerDraftCoordinator!.idle(), + ); + expect((await h.read(draftA))?.state).toBe("draft"); + }); + + it("navigation during deferred hydration preserves the new target and activation", async () => { + const h = await setup(); + const newer: DraftScope = homeB; + h.seed(newer, "B remains editable"); + const gate = deferred(); + const original = vi.fn(() => gate.promise); + const view = await mount(h, draftA, original); + view.activate(draftA.id); + await act(async () => { + expect(await view.current().topics.switchTopic(topicB.id)).toBe( + "deferred", + ); + }); + expect(original).toHaveBeenCalledExactlyOnceWith(topicB.id); + await act(async () => { + view.current().draft.commitComposerDraftScope(newer); + view.current().state.taskCenterHomeSurfaceActiveRef.current = true; + }); + await act(async () => { + view.current().setSessionId(topicB.id); + gate.resolve(undefined); + await gate.promise; + }); + expect(view.current().draft.composerDraftStatus?.scope).toEqual(newer); + expect(h.controller.getDocument().text).toBe("B remains editable"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts b/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts new file mode 100644 index 000000000..2cdf5ae28 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceComposerDraftNavigation.ts @@ -0,0 +1,142 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import type { WorkspaceProjectSelectionSource } from "../hooks/useWorkspaceProjectSelection"; +import type { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; + +type ComposerDraftRuntime = Pick< + ReturnType, + | "composerDraftCoordinator" + | "prepareComposerDraftNavigation" + | "commitComposerDraftScope" + | "getComposerDraftNavigationEpoch" + | "getComposerDraftTarget" + | "commitNewConversationRequest" +>; + +interface WorkspaceComposerDraftNavigationParams { + newConversationRequest?: import("@/lib/workspace/navigation").NewConversationRequest; + composerDraftRuntime: ComposerDraftRuntime; + workspaceId: string | null; + projectSelectionSource: WorkspaceProjectSelectionSource; + normalizedInitialSessionId: string | null; + sessionId?: string | null; + onTopicSwitchCommitted: (topicId: string) => void; +} + +/** Workspace navigation commits scopes; the root runtime alone binds documents. */ +export function useWorkspaceComposerDraftNavigation({ + newConversationRequest, + composerDraftRuntime: { + composerDraftCoordinator, + prepareComposerDraftNavigation, + commitComposerDraftScope, + getComposerDraftNavigationEpoch, + getComposerDraftTarget, + commitNewConversationRequest, + }, + workspaceId, + projectSelectionSource, + normalizedInitialSessionId, + sessionId, + onTopicSwitchCommitted, +}: WorkspaceComposerDraftNavigationParams) { + const processedRequest = useRef(null); + useLayoutEffect(() => { + if ( + !newConversationRequest || + normalizedInitialSessionId || + workspaceId !== newConversationRequest.workspaceId || + processedRequest.current === newConversationRequest.id + ) + return; + processedRequest.current = newConversationRequest.id; + onTopicSwitchCommitted(""); + commitNewConversationRequest(newConversationRequest); + }, [ + newConversationRequest, + normalizedInitialSessionId, + workspaceId, + onTopicSwitchCommitted, + commitNewConversationRequest, + ]); + const previousWorkspace = useRef(workspaceId); + useLayoutEffect(() => { + if (previousWorkspace.current === workspaceId || !composerDraftCoordinator) + return; + previousWorkspace.current = workspaceId; + // A same-event draft activation or acceptance has already chosen its target. + if ( + getComposerDraftTarget()?.workspaceId === workspaceId || + projectSelectionSource === "topic-switch" || + normalizedInitialSessionId + ) + return; + prepareComposerDraftNavigation(); + onTopicSwitchCommitted(""); + commitComposerDraftScope({ + workspaceId, + kind: "home", + id: "new-conversation", + }); + }, [ + workspaceId, + projectSelectionSource, + normalizedInitialSessionId, + composerDraftCoordinator, + getComposerDraftTarget, + prepareComposerDraftNavigation, + commitComposerDraftScope, + onTopicSwitchCommitted, + ]); + + const [pendingTopic, setPendingTopic] = useState<{ + id: string; + workspaceId: string | null; + epoch: number; + } | null>(null); + const handleTopicSwitchCommitted = useCallback( + ( + topicId: string, + targetWorkspaceId: string | null, + capturedEpoch?: number, + ) => { + const epoch = capturedEpoch ?? getComposerDraftNavigationEpoch(); + if (getComposerDraftNavigationEpoch() !== epoch) return; + setPendingTopic({ id: topicId, workspaceId: targetWorkspaceId, epoch }); + }, + [getComposerDraftNavigationEpoch], + ); + useEffect(() => { + if (!pendingTopic) return; + if (getComposerDraftNavigationEpoch() !== pendingTopic.epoch) { + setPendingTopic(null); + return; + } + if ( + sessionId !== pendingTopic.id || + workspaceId !== pendingTopic.workspaceId + ) + return; + onTopicSwitchCommitted(pendingTopic.id); + commitComposerDraftScope({ + workspaceId: pendingTopic.workspaceId, + kind: "local", + id: pendingTopic.id, + }); + setPendingTopic(null); + }, [ + pendingTopic, + sessionId, + workspaceId, + onTopicSwitchCommitted, + commitComposerDraftScope, + getComposerDraftNavigationEpoch, + ]); + + return { handleTopicSwitchCommitted }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.test.tsx new file mode 100644 index 000000000..3a5136529 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.test.tsx @@ -0,0 +1,319 @@ +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { encodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { + a, + b, + home, + accepted, + deferred, + installCoordinatorHarness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; +const mounted: Array<{ root: Root; element: HTMLDivElement }> = []; +afterEach(() => { + act(() => { + for (const { root, element } of mounted.splice(0)) { + root.unmount(); + element.remove(); + } + }); +}); + +async function mount( + controller: ComposerController, + scope: DraftScope | null, + strict = false, +) { + let current!: ReturnType; + function Surface({ initialScope }: { initialScope: DraftScope | null }) { + current = useWorkspaceComposerDraftRuntime({ controller, initialScope }); + return ( + {current.composerDraftRestoring ? "restoring" : "ready"} + ); + } + const element = document.createElement("div"); + document.body.appendChild(element); + const root = createRoot(element); + mounted.push({ root, element }); + async function render(initialScope: DraftScope | null) { + await act(async () => { + root.render( + strict ? ( + + + + ) : ( + + ), + ); + }); + } + await render(scope); + return { current: () => current, render, element }; +} + +describe("Workspace committed draft lifecycle through the real Coordinator and Store", () => { + it("restores full rich JSON/resources and waits for a committed target", async () => { + const h = await setup(); + const original = new ComposerController(); + original.setText("A😀 link "); + original.ingestPaste("collapsed content".repeat(120)); + original.setAttachments([ + { + data: "durable-image", + mediaType: "image/png", + previewUrl: "blob:ephemeral", + }, + ]); + original.setPathReferences([ + { id: "p", path: "/tmp/code.ts", name: "code.ts", isDir: false }, + ]); + h.store.updatePersistedAtomState({ + op: "put", + scope: a, + draftId: "rich", + expectedVersion: null, + nextVersion: "v1", + draft: encodeComposerDraft(original.captureDraft(), false), + }); + const view = await mount(h.controller, null); + expect(view.current().composerDraftRestoring).toBe(true); + expect(h.controller.getDocument().text).toBe(""); + await view.render(a); + expect(view.current().composerDraftRestoring).toBe(false); + expect(h.controller.captureDraft()).toEqual({ + ...original.captureDraft(), + attachments: [{ data: "durable-image", mediaType: "image/png" }], + }); + const identity = view.current().composerDraftStatus?.identity; + act(() => view.current().prepareComposerDraftNavigation()); + expect(view.current().composerDraftStatus?.identity).toEqual(identity); + expect(h.controller.getDocument().text).toBe(original.getDocument().text); + }); + + it("a late read cannot restore a superseded scope or overwrite an edit", async () => { + const h = await setup(); + h.seed(a, "old remote"); + h.seed(b, "target remote"); + const gate = deferred(); + h.intercept((command, args, execute) => { + const query = args.query as + | { kind?: string; scope?: DraftScope } + | undefined; + return command === "persisted_atom_read" && query?.scope?.id === a.id + ? gate.promise.then(execute) + : execute(); + }); + const view = await mount(h.controller, a); + await act(async () => view.current().commitComposerDraftScope(b)); + expect(h.controller.getDocument().text).toBe("target remote"); + act(() => h.controller.setText("new edit")); + await act(async () => { + gate.resolve(null); + await gate.promise; + }); + expect(h.controller.getDocument().text).toBe("new edit"); + expect(view.current().composerDraftStatus?.scope).toEqual(b); + }); + + it("promotes the same captured home identity before accepted consumption", async () => { + const h = await setup(); + const view = await mount(h.controller, home); + act(() => h.controller.setText("home submission")); + const owner = view.current().composerDraftCoordinator!; + const capture = owner.captureSubmission("start"); + const revision = h.controller.getSnapshot().revision; + const result = accepted(); + if (result.kind !== "runtime-accepted") + throw new Error("test acceptance missing"); + await act(async () => + view.current().handleComposerDraftAccepted(capture, result), + ); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(view.current().composerDraftStatus?.generation).toBe( + capture.binding!.generation, + ); + expect(view.current().composerDraftStatus?.scope).toEqual({ + ...home, + kind: "local", + id: "s1", + }); + await act(async () => { + await owner.settleSubmission(capture, result); + }); + expect(h.controller.getDocument().text).toBe(""); + expect(h.mutations.map((item) => item.op)).toEqual([ + "put", + "promote", + "consume", + ]); + expect(await h.read(home)).toBeNull(); + }); + + it("cross-navigation acceptance consumes the old identity and leaves the new document", async () => { + const h = await setup(); + h.seed(b, "B unsent"); + const view = await mount(h.controller, a); + act(() => h.controller.setText("A submitted")); + const owner = view.current().composerDraftCoordinator!; + const capture = owner.captureSubmission("start"); + await act(async () => view.current().commitComposerDraftScope(b)); + const result = accepted(); + if (result.kind !== "runtime-accepted") + throw new Error("test acceptance missing"); + await act(async () => { + await view.current().handleComposerDraftAccepted(capture, result); + await owner.settleSubmission(capture, result); + }); + expect(view.current().composerDraftStatus?.scope).toEqual(b); + expect(h.controller.getDocument().text).toBe("B unsent"); + expect(await h.read(a)).toBeNull(); + expect((await h.read(b))?.state).toBe("draft"); + }); + + it("keeps a pending edit and its committed target when the captured home lane is promoted", async () => { + const h = await setup(); + const view = await mount(h.controller, home); + act(() => h.controller.setText("home submitted")); + const owner = view.current().composerDraftCoordinator!; + const capture = owner.captureSubmission("start"); + await owner.idle(); + const epoch = view.current().getComposerDraftNavigationEpoch(); + act(() => h.controller.setText("new edit while submission is pending")); + const revision = h.controller.getSnapshot().revision; + const newerIdentity = owner.getSnapshot().identity; + expect(newerIdentity?.draftId).toBe(capture.binding!.identity.draftId); + expect(newerIdentity?.scopeVersion).not.toBe( + capture.binding!.identity.scopeVersion, + ); + expect((await h.read(home))?.scopeVersion).toBe( + capture.binding!.identity.scopeVersion, + ); + const result = accepted(); + if (result.kind !== "runtime-accepted") + throw new Error("test acceptance missing"); + const target: DraftScope = { ...home, kind: "local", id: "s1" }; + await act(async () => { + await view.current().handleComposerDraftAccepted(capture, result); + expect(await owner.settleSubmission(capture, result)).toBe(false); + await owner.idle(); + }); + expect(h.controller.getDocument().text).toBe( + "new edit while submission is pending", + ); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(view.current().getComposerDraftTarget()).toEqual(target); + expect(owner.getSnapshot().scope).toEqual(target); + expect(owner.getSnapshot().identity).toEqual(newerIdentity); + expect(owner.getSnapshot().generation).toBe(capture.binding!.generation); + expect(view.current().getComposerDraftNavigationEpoch()).toBe(epoch + 1); + expect(await h.read(home)).toBeNull(); + expect(await h.read(target)).toMatchObject({ + state: "draft", + draftId: newerIdentity!.draftId, + scopeVersion: newerIdentity!.scopeVersion, + draft: { + document: { text: "new edit while submission is pending" }, + }, + }); + expect(h.mutations.map((item) => item.op)).toEqual([ + "put", + "promote", + "put", + "consume", + ]); + await act(async () => view.current().commitComposerDraftScope(target)); + expect(view.current().getComposerDraftNavigationEpoch()).toBe(epoch + 1); + }); + + it("saved plain mode remains intact and reports an explicit mismatch in rich Workspace", async () => { + const h = await setup(); + h.seed(a, "plain saved", true); + h.controller.setText("current rich"); + const view = await mount(h.controller, a); + // An initial local edit conflicts before a remote mode can replace it. + expect(h.controller.getDocument().text).toBe("current rich"); + expect(view.current().composerDraftStatus?.mutation).toBe("conflict"); + await act(async () => view.current().commitComposerDraftScope(b)); + await act(async () => view.current().commitComposerDraftScope(a)); + expect(view.current().composerDraftStatus?.error).toBe("mode_mismatch"); + expect((await h.read(a))?.state).toBe("draft"); + }); + + it("StrictMode cleans up abandoned owners and does not duplicate an edit put", async () => { + const h = await setup(); + const view = await mount(h.controller, a, true); + act(() => h.controller.setText("strict edit")); + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + await view.current().composerDraftCoordinator!.idle(); + }); + expect(h.mutations.filter((item) => item.op === "put")).toHaveLength(1); + expect(view.current().composerDraftRestoring).toBe(false); + }); + + it("a repeated committed target does not cancel navigation from the same scope", async () => { + const h = await setup(); + const view = await mount(h.controller, a); + const epoch = view.current().getComposerDraftNavigationEpoch(); + await act(async () => view.current().commitComposerDraftScope(a)); + expect(view.current().getComposerDraftNavigationEpoch()).toBe(epoch); + await act(async () => view.current().commitComposerDraftScope(b)); + expect(view.current().getComposerDraftNavigationEpoch()).toBe(epoch + 1); + }); + + it("promotion cannot reclaim a newer intended target before its layout binding", async () => { + const h = await setup(); + h.seed(b, "B unsent"); + const view = await mount(h.controller, home); + act(() => h.controller.setText("home submitted")); + const capture = view + .current() + .composerDraftCoordinator!.captureSubmission("start"); + const gate = deferred(); + let entered!: () => void; + const promotionEntered = new Promise((resolve) => { + entered = resolve; + }); + h.intercept((command, args, execute) => { + if ( + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "promote" + ) { + entered(); + return gate.promise.then(execute); + } + return execute(); + }); + const result = accepted(); + if (result.kind !== "runtime-accepted") + throw new Error("missing acceptance"); + const promotion = view + .current() + .handleComposerDraftAccepted(capture, result); + await promotionEntered; + await act(async () => { + view.current().commitComposerDraftScope(b); + gate.resolve(null); + await promotion; + }); + expect(view.current().composerDraftStatus?.scope).toEqual(b); + expect(h.controller.getDocument().text).toBe("B unsent"); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts new file mode 100644 index 000000000..141db4e77 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.ts @@ -0,0 +1,236 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ComposerController } from "@/components/input-kit/ComposerController"; +import { + ComposerDraftCoordinator, + type ComposerDraftBindingToken, + type ComposerDraftCapture, + type ComposerDraftStatus, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import type { NewConversationRequest } from "@/lib/workspace/navigation"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import type { AcceptedRuntimeSubmissionResult } from "../hooks/agentSendResult"; + +function sameScope(left: DraftScope | null, right: DraftScope | null) { + return ( + left === right || + Boolean( + left && + right && + left.workspaceId === right.workspaceId && + left.kind === right.kind && + left.id === right.id, + ) + ); +} + +const emptySnapshot = () => null; +const emptySubscribe = () => () => undefined; + +/** React commits the editing target; Controller remains the sole document owner. */ +export function useWorkspaceComposerDraftRuntime({ + controller, + initialScope, +}: { + controller: ComposerController; + initialScope: DraftScope | null; +}) { + const [coordinator, setCoordinator] = + useState(null); + const [committedScope, setCommittedScope] = useState(initialScope); + const [committedRequest, setCommittedRequest] = + useState(null); + const latestRequest = useRef(null); + const handledFreshRequest = useRef(null); + const intendedScope = useRef(initialScope); + const [restoring, setRestoring] = useState(true); + const explicitScope = useRef(false); + const mounted = useRef(false); + const activeOwner = useRef(null); + const readEpoch = useRef(0); + const navigationEpoch = useRef(0); + + // Effects own subscriptions so StrictMode's abandoned render cannot leak an owner. + useLayoutEffect(() => { + const owner = new ComposerDraftCoordinator(controller, { + plainTextMode: false, + }); + mounted.current = true; + activeOwner.current = owner; + setCoordinator(owner); + void owner.connect(); + return () => { + mounted.current = false; + if (activeOwner.current === owner) activeOwner.current = null; + readEpoch.current += 1; + owner.detach(); + }; + }, [controller]); + + useLayoutEffect(() => { + if (!explicitScope.current) { + intendedScope.current = initialScope; + setCommittedScope((current) => + sameScope(current, initialScope) ? current : initialScope, + ); + } + }, [initialScope]); + + useLayoutEffect(() => { + if (!coordinator || !committedScope) return; + explicitScope.current = true; + const epoch = ++readEpoch.current; + const navigation = navigationEpoch.current; + setRestoring(true); + const current = () => + mounted.current && + activeOwner.current === coordinator && + readEpoch.current === epoch && + navigationEpoch.current === navigation && + sameScope(intendedScope.current, committedScope) && + (!committedRequest || latestRequest.current?.id === committedRequest.id); + void (async () => { + await coordinator.bindCommitted(committedScope); + if ( + current() && + committedRequest?.freshDraft && + handledFreshRequest.current !== committedRequest.id && + latestRequest.current?.id === committedRequest.id && + committedScope.kind === "home" && + committedScope.workspaceId === committedRequest.workspaceId + ) { + handledFreshRequest.current = committedRequest.id; + await coordinator.replaceCommittedDraft(committedScope, current); + } + if (current()) { + setRestoring(false); + if (committedRequest) + document + .querySelector( + '[data-composer-editor-host="true"] [role="textbox"]', + ) + ?.focus(); + } + })(); + }, [coordinator, committedScope, committedRequest]); + + const status = useSyncExternalStore( + coordinator?.subscribe ?? emptySubscribe, + coordinator?.getSnapshot ?? emptySnapshot, + coordinator?.getSnapshot ?? emptySnapshot, + ); + const prepareComposerDraftNavigation = useCallback(() => { + coordinator?.prepareNavigation(); + }, [coordinator]); + const commitComposerDraftScope = useCallback((scope: DraftScope) => { + if (sameScope(intendedScope.current, scope)) return; + navigationEpoch.current += 1; + intendedScope.current = scope; + explicitScope.current = true; + setCommittedScope((current) => + sameScope(current, scope) ? current : scope, + ); + }, []); + const commitNewConversationRequest = useCallback( + (request: NewConversationRequest) => { + if (latestRequest.current?.id === request.id) return; + coordinator?.prepareNavigation(); + const scope: DraftScope = { + workspaceId: request.workspaceId, + kind: "home", + id: "new-conversation", + }; + if (request.freshDraft && sameScope(intendedScope.current, scope)) + navigationEpoch.current += 1; + commitComposerDraftScope(scope); + latestRequest.current = request; + setCommittedRequest(request); + }, + [coordinator, commitComposerDraftScope], + ); + const getComposerDraftNavigationEpoch = useCallback( + () => navigationEpoch.current, + [], + ); + const getComposerDraftTarget = useCallback(() => intendedScope.current, []); + + const promoteComposerDraft = useCallback( + async ( + token: ComposerDraftBindingToken, + target: DraftScope, + metadata: { sessionId: string; threadId?: string }, + ) => { + if (!coordinator) return; + const before = coordinator.getSnapshot(); + const capturedEpoch = navigationEpoch.current; + const ownsVisibleDraft = + before.generation === token.generation && + sameScope(before.scope, token.scope) && + sameScope(intendedScope.current, token.scope); + if (!sameScope(token.scope, target)) { + const applied = await coordinator.promote(token, target, { + expectedTarget: null, + ...metadata, + }); + if (!applied) return; + } + // An old acceptance may move/consume its source without changing the new surface. + if ( + mounted.current && + activeOwner.current === coordinator && + ownsVisibleDraft && + navigationEpoch.current === capturedEpoch && + sameScope(intendedScope.current, token.scope) && + coordinator.getSnapshot().generation === token.generation + ) + commitComposerDraftScope(target); + }, + [coordinator, commitComposerDraftScope], + ); + + const handleComposerDraftAccepted = useCallback( + async ( + capture: ComposerDraftCapture, + result: AcceptedRuntimeSubmissionResult, + ) => { + const token = capture.binding; + if (!token) return; + await promoteComposerDraft( + token, + { + workspaceId: token.scope.workspaceId, + kind: "local", + id: result.receipt.sessionId, + }, + { + sessionId: result.receipt.sessionId, + threadId: result.receipt.threadId, + }, + ); + }, + [promoteComposerDraft], + ); + + return { + composerDraftCoordinator: coordinator ?? undefined, + composerDraftStatus: status, + composerDraftRestoring: + restoring || + !coordinator || + !committedScope || + !status?.ready || + !sameScope(initialScope, status.scope), + prepareComposerDraftNavigation, + commitComposerDraftScope, + commitNewConversationRequest, + getComposerDraftNavigationEpoch, + getComposerDraftTarget, + promoteComposerDraft, + handleComposerDraftAccepted, + }; +} diff --git a/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.visibleReady.integration.test.tsx b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.visibleReady.integration.test.tsx new file mode 100644 index 000000000..ba12ef0a5 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceComposerDraftRuntime.visibleReady.integration.test.tsx @@ -0,0 +1,368 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { changeLimeLocale } from "@/i18n/createI18n"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { ComposerEditor } from "@/components/input-kit/ComposerEditor"; +import { encodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { + deferred, + harness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import { useWorkspaceComposerDraftRuntime } from "./useWorkspaceComposerDraftRuntime"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); + +type Fixture = Awaited>; +type Runtime = ReturnType; +const scopeA: DraftScope = { + workspaceId: "workspace-a", + kind: "local", + id: "session-a", +}; +const scopeB: DraftScope = { + workspaceId: "workspace-b", + kind: "local", + id: "session-b", +}; +const signalScope: DraftScope = { + workspaceId: "metadata-workspace", + kind: "home", + id: "new-conversation", +}; +const fixtures: Fixture[] = []; +const mounted: Array<{ root: Root; host: HTMLDivElement }> = []; +const rangeDescriptors = new Map( + ["getClientRects", "getBoundingClientRect"].map( + (name) => + [name, Object.getOwnPropertyDescriptor(Range.prototype, name)] as const, + ), +); + +beforeEach(async () => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + await changeLimeLocale("en-US"); + // JSDOM supplies no native geometry, trusted input or desktop evidence. + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 1, 1), + }); +}); + +afterEach(async () => { + act(() => { + for (const { root, host } of mounted.splice(0)) { + root.unmount(); + host.remove(); + } + }); + for (const fixture of fixtures.splice(0)) await fixture.close(); + for (const [name, descriptor] of rangeDescriptors) { + if (descriptor) Object.defineProperty(Range.prototype, name, descriptor); + else Reflect.deleteProperty(Range.prototype, name); + } + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +async function setup() { + const fixture = await harness({ safeInvoke, safeListen }); + fixtures.push(fixture); + return fixture; +} + +function richDocument(label: string) { + const source = new ComposerController(); + source.ingestHTML( + "

" + + label + + ' 中😀 link

' + + '
const value = "中😀";
' + + "

tail

", + ); + source.setSelection(source.getDocument().text.length); + source.ingestPaste("hidden rich draft payload 中😀 ".repeat(100)); + source.insertMention({ + id: "user:capability-report", + kind: "skill", + name: "capability-report", + path: "/skills/capability-report", + label: "/user:capability-report", + }); + source.setAttachments([{ data: "YQ==", mediaType: "image/png" }]); + source.setPathReferences([ + { + id: "readiness-file-" + label, + path: "/workspace/" + label + ".ts", + name: label + ".ts", + isDir: false, + }, + ]); + source.setInputCapabilityRoute({ skill: "capability-report" }); + return source.captureDraft(); +} + +function seedRich(fixture: Fixture, scope: DraftScope, label: string) { + const document = richDocument(label); + const result = fixture.store.updatePersistedAtomState({ + op: "put", + scope, + draftId: "readiness-draft-" + scope.id, + expectedVersion: null, + nextVersion: "readiness-version-" + scope.id, + draft: encodeComposerDraft(document, false), + }); + expect(result.outcome).toBe("applied"); + expect(document.pendingPastes).toHaveLength(1); + expect(document.mentionBindings).toHaveLength(1); + return document; +} + +function storedEntry(fixture: Fixture, scope: DraftScope) { + const read = fixture.store.sendPersistedAtomState({ kind: "scope", scope }); + if (read.outcome !== "ready" || read.data.queryKind !== "scope") + throw new Error("Real Store did not return a scope"); + return read.data.view.entry; +} + +async function mount(fixture: Fixture, initialScope: DraftScope | null) { + let current!: Runtime; + function Surface({ visibleTarget }: { visibleTarget: DraftScope | null }) { + current = useWorkspaceComposerDraftRuntime({ + controller: fixture.controller, + initialScope: visibleTarget, + }); + return ( + + ); + } + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + mounted.push({ root, host }); + async function render(visibleTarget: DraftScope | null) { + await act(async () => + root.render(), + ); + } + await render(initialScope); + const editor = () => { + const node = host.querySelector( + '[data-testid="composer-editor"]', + ); + if (!node) throw new Error("Real EditorView was not mounted"); + return node; + }; + return { current: () => current, editor, render }; +} + +type Surface = Awaited>; + +function expectEditable(surface: Surface, editable: boolean) { + expect(surface.editor().getAttribute("contenteditable")).toBe( + String(editable), + ); + expect(surface.editor().getAttribute("aria-disabled")).toBe( + String(!editable), + ); + expect(surface.current().composerDraftRestoring).toBe(!editable); +} + +function pasteHtml(node: HTMLElement, html: string) { + const plainText = new DOMParser().parseFromString(html, "text/html").body + .textContent; + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + getData: (format: string) => + format === "text/html" ? html : (plainText ?? ""), + }, + }); + node.dispatchEvent(event); + return event.defaultPrevented; +} + +function expectBlockedPaste(surface: Surface, fixture: Fixture) { + const before = fixture.controller.captureDraft(); + act(() => { + expect(pasteHtml(surface.editor(), "

must not enter old scope

")).toBe( + true, + ); + }); + expect(fixture.controller.captureDraft()).toEqual(before); +} + +describe("visible Composer identity and real binding readiness", () => { + it("blocks the old scope and superseded initial read until B actually hydrates", async () => { + const fixture = await setup(); + const documentA = seedRich(fixture, scopeA, "A"); + const documentB = seedRich(fixture, scopeB, "B"); + await fixture.store.flush(); + expect(fixture.store.metadata().persistence.state).toBe("saved"); + const originalA = storedEntry(fixture, scopeA); + const surface = await mount(fixture, scopeA); + expectEditable(surface, true); + expect(fixture.controller.captureDraft()).toEqual(documentA); + + const firstRead = deferred(); + const refreshRead = deferred(); + let targetReadCount = 0; + fixture.intercept((command, args, execute) => { + const query = args.query as { scope?: DraftScope } | undefined; + if (command === "persisted_atom_read" && query?.scope?.id === scopeB.id) { + targetReadCount += 1; + if (targetReadCount === 1) return firstRead.promise.then(execute); + if (targetReadCount === 2) return refreshRead.promise.then(execute); + } + return execute(); + }); + + // The public visible target changes before the navigation owner commits it. + await surface.render(scopeB); + expect(surface.editor().dataset.sessionId).toBe(scopeB.id); + expect(surface.current().composerDraftStatus?.scope).toEqual(scopeA); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + + await act(async () => surface.current().commitComposerDraftScope(scopeB)); + expect(targetReadCount).toBe(1); + expect(surface.current().composerDraftStatus?.scope).toEqual(scopeB); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + + // Real Store metadata invalidation starts a newer read of the same binding. + await act(async () => { + expect(fixture.seed(signalScope, "metadata signal").outcome).toBe( + "applied", + ); + }); + expect(targetReadCount).toBe(2); + await act(async () => { + firstRead.resolve(null); + await firstRead.promise; + }); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + expect(fixture.controller.captureDraft()).toEqual(documentA); + + await act(async () => { + refreshRead.resolve(null); + await refreshRead.promise; + }); + expect(surface.current().composerDraftStatus?.scope).toEqual(scopeB); + expectEditable(surface, true); + expect(fixture.controller.captureDraft()).toEqual(documentB); + expect( + surface.editor().querySelectorAll("[data-composer-code-block]"), + ).toHaveLength(1); + expect( + surface + .editor() + .querySelectorAll( + '[role="link"][text-link-href="https://example.invalid/readiness"]', + ), + ).toHaveLength(1); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + + act(() => { + expect(pasteHtml(surface.editor(), "

B accepted edit

")).toBe(true); + }); + expect(fixture.controller.getDocument().text).toContain("B accepted edit"); + await act(async () => surface.current().composerDraftCoordinator!.idle()); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + const editedB = storedEntry(fixture, scopeB); + if (editedB?.state !== "draft") throw new Error("B edit was not persisted"); + expect(JSON.stringify(editedB.draft)).toContain("B accepted edit"); + }); + + it.each(["mode-mismatch", "transport-unavailable"] as const)( + "%s cannot become editable through the bind promise finally", + async (failure) => { + const fixture = await setup(); + seedRich(fixture, scopeA, "A"); + if (failure === "mode-mismatch") fixture.seed(scopeB, "plain B", true); + else seedRich(fixture, scopeB, "B"); + await fixture.store.flush(); + const originalA = storedEntry(fixture, scopeA); + const surface = await mount(fixture, scopeA); + const before = fixture.controller.captureDraft(); + if (failure === "transport-unavailable") { + fixture.intercept((command, args, execute) => { + const query = args.query as { scope?: DraftScope } | undefined; + if ( + command === "persisted_atom_read" && + query?.scope?.id === scopeB.id + ) + throw new Error("test-only read transport unavailable"); + return execute(); + }); + } + await surface.render(scopeB); + await act(async () => surface.current().commitComposerDraftScope(scopeB)); + expect(surface.current().composerDraftStatus?.error).toBe( + failure === "mode-mismatch" ? "mode_mismatch" : "transport", + ); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + expect(fixture.controller.captureDraft()).toEqual(before); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + expect(fixture.mutations).toHaveLength(0); + }, + ); + + it("opens an empty B after real hydration without requiring a saved identity", async () => { + const fixture = await setup(); + seedRich(fixture, scopeA, "A"); + await fixture.store.flush(); + const originalA = storedEntry(fixture, scopeA); + const surface = await mount(fixture, scopeA); + await surface.render(scopeB); + expectEditable(surface, false); + await act(async () => surface.current().commitComposerDraftScope(scopeB)); + expect(surface.current().composerDraftStatus?.scope).toEqual(scopeB); + expect(surface.current().composerDraftStatus?.identity).toBeNull(); + expectEditable(surface, true); + expect(fixture.controller.getDocument().text).toBe(""); + expect(storedEntry(fixture, scopeB)).toBeNull(); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + expect(fixture.mutations).toHaveLength(0); + }); + + it("requires the exact workspace and a non-null visible target even after readiness", async () => { + const fixture = await setup(); + seedRich(fixture, scopeA, "A"); + await fixture.store.flush(); + const originalA = storedEntry(fixture, scopeA); + const surface = await mount(fixture, scopeA); + expectEditable(surface, true); + await surface.render({ ...scopeA, workspaceId: "other-workspace" }); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + await surface.render(null); + expectEditable(surface, false); + expectBlockedPaste(surface, fixture); + await surface.render(scopeA); + expectEditable(surface, true); + expect(storedEntry(fixture, scopeA)).toEqual(originalA); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceContextSurfaceRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceContextSurfaceRuntime.ts index 7336029b7..7b11db792 100644 --- a/src/components/agent/chat/workspace/useWorkspaceContextSurfaceRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceContextSurfaceRuntime.ts @@ -1,3 +1,4 @@ +import type { AgentLocation } from "@/types/page"; import { useEffect, useMemo } from "react"; import type { AgentRuntimeThreadReadModel, @@ -29,7 +30,7 @@ interface UseWorkspaceContextSurfaceRuntimeParams { messages: Message[]; model: string; onAgentStreamingChange?: (isStreaming: boolean) => void; - onSessionChange?: (sessionId: string | null) => void; + onLocationChange?: (location: AgentLocation) => void; pendingActions: ActionRequired[]; projectId?: string | null; projectMemory: ProjectMemory | null; @@ -56,7 +57,7 @@ export function useWorkspaceContextSurfaceRuntime({ messages, model, onAgentStreamingChange, - onSessionChange, + onLocationChange, pendingActions, projectId, projectMemory, @@ -74,8 +75,11 @@ export function useWorkspaceContextSurfaceRuntime({ ); useEffect(() => { - onSessionChange?.(sessionId ?? null); - }, [onSessionChange, sessionId]); + onLocationChange?.({ + sessionId: sessionId ?? null, + workspaceId: runtimeWorkspaceId ?? projectId ?? null, + }); + }, [onLocationChange, sessionId, runtimeWorkspaceId, projectId]); useEffect(() => { return () => { @@ -119,8 +123,7 @@ export function useWorkspaceContextSurfaceRuntime({ todoItems, }); const inputbarIsSending = - isSending || - hasActiveThreadReadActivity(threadRead); + isSending || hasActiveThreadReadActivity(threadRead); useEffect(() => { onAgentStreamingChange?.(inputbarIsSending); diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.test.tsx index a977a01f6..f314e4784 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.test.tsx @@ -150,6 +150,14 @@ afterEach(() => { }); describe("useWorkspaceConversationLandingSurfaceRuntime", () => { + it("routes Home skill suggestions to the existing directory owner", () => { + const onSkillSuggestionsNeeded = vi.fn(); + const { getValue } = renderHook( + createDefaultProps({ onSkillSuggestionsNeeded }), + ); + getValue().emptyStateProps.onSkillSuggestionsNeeded?.(); + expect(onSkillSuggestionsNeeded).toHaveBeenCalledOnce(); + }); it("应集中产出入口 banner、leading card 与空态技能 props", () => { const onSelectServiceSkill = vi.fn(); const serviceSkills = [ diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.tsx index 81d1f550a..3607d2da4 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationLandingSurfaceRuntime.tsx @@ -1,3 +1,6 @@ +import type { ComposerDraftCoordinator } from "@/components/input-kit/ComposerDraftCoordinator"; +import type { ComposerDraftStatus as DraftStatus } from "@/components/input-kit/ComposerDraftCoordinator"; +import { ComposerDraftStatus } from "./ComposerDraftStatus"; import { useCallback, useMemo, @@ -24,6 +27,7 @@ import { buildWorkspaceEmptyStateProps } from "./chatSurfaceProps"; import type { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; export interface WorkspaceConversationLandingSurfaceRuntime { + composerDraftStatusNode?: ReactNode; entryBannerVisible: boolean; entryBannerMessage?: string; onDismissEntryBanner: () => void; @@ -94,6 +98,12 @@ interface UseWorkspaceConversationLandingSurfaceRuntimeParams { >["initialInputCapability"]; input: ComponentProps["input"]; composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + composerDraftStatus?: DraftStatus | null; + composerDraftRestoring?: boolean; + onComposerDraftAccepted?: ComponentProps< + typeof EmptyState + >["onComposerDraftAccepted"]; inputbarScene: WorkspaceConversationLandingInputbarSceneRuntime; inputRestoreRequest?: InterruptedInputRestoreRequest | null; lockTheme: boolean; @@ -130,6 +140,9 @@ interface UseWorkspaceConversationLandingSurfaceRuntimeParams { typeof EmptyState >["onRecommendationClick"]; onRefreshSkills?: ComponentProps["onRefreshSkills"]; + onSkillSuggestionsNeeded?: ComponentProps< + typeof EmptyState + >["onSkillSuggestionsNeeded"]; onRemovePathReference?: ComponentProps< typeof EmptyState >["onRemovePathReference"]; @@ -287,6 +300,10 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ initialInputCapability, input, composerController, + composerDraftCoordinator, + composerDraftStatus = null, + composerDraftRestoring = false, + onComposerDraftAccepted, inputbarScene, inputRestoreRequest, lockTheme, @@ -305,6 +322,7 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ onProjectChange, onRecommendationClick, onRefreshSkills, + onSkillSuggestionsNeeded, onRemovePathReference, onResumeRecentSession, onSelectServiceSkill, @@ -366,6 +384,8 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ buildWorkspaceEmptyStateProps({ input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, setInput, onSendMessage: handleSendFromEmptyState, onStopSending, @@ -405,6 +425,7 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ onSelectServiceSkill, onNavigateToSettings, onRefreshSkills, + onSkillSuggestionsNeeded, onLaunchBrowserAssist, browserAssistLoading, recentSessionTitle: suppressRecentSessionRecovery @@ -483,6 +504,8 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ initialInputCapability, input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, inputRestoreRequest, lockTheme, model, @@ -501,6 +524,7 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ onProjectChange, onRecommendationClick, onRefreshSkills, + onSkillSuggestionsNeeded, onRemovePathReference, onResumeRecentSession, onSelectKnowledgePack, @@ -546,6 +570,12 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ return useMemo( () => ({ + composerDraftStatusNode: composerDraftCoordinator ? ( + + ) : undefined, entryBannerVisible, entryBannerMessage, onDismissEntryBanner, @@ -554,6 +584,9 @@ export function useWorkspaceConversationLandingSurfaceRuntime({ emptyStateProps, }), [ + composerDraftCoordinator, + composerDraftStatus, + composerDraftRestoring, creationReplaySurface, emptyStateProps, entryBannerMessage, diff --git a/src/components/agent/chat/workspace/useWorkspaceHomeRecoveryRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceHomeRecoveryRuntime.ts index 0da5b7f40..553328632 100644 --- a/src/components/agent/chat/workspace/useWorkspaceHomeRecoveryRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceHomeRecoveryRuntime.ts @@ -22,7 +22,7 @@ interface UseWorkspaceHomeRecoveryRuntimeParams { onOpenTaskTopic: ( topicId: string, options?: OpenTaskTopicOptions, - ) => void | Promise; + ) => void | Promise; onResumeRecentSession: () => void; projectId?: string | null; recentSessionTopic: Topic | null; diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx index a550e7ce0..cbf5340fc 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx @@ -1,5 +1,18 @@ import { act } from "react"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import { + fromRuntimeAcceptanceReceipt, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; +const accepted = fromRuntimeAcceptanceReceipt( + createRuntimeAcceptanceReceipt( + { sessionId: "s", threadId: "t" }, + "start", + "turn", + ), +); + import { createInitialSessionImageWorkbenchState } from "./imageWorkbenchHelpers"; import { createParsedCommand, @@ -10,7 +23,9 @@ import { describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { it("图片 Provider 刷新后同一轮命令应使用最新 selection", async () => { - const submitImageWorkbenchAgentCommand = vi.fn().mockResolvedValue(true); + const submitImageWorkbenchAgentCommand = vi + .fn() + .mockResolvedValue(accepted); let resolveProvidersLoaded: (() => void) | null = null; const ensureImageWorkbenchProvidersLoaded = vi.fn( () => @@ -28,7 +43,7 @@ describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { await render(); - let handledPromise: Promise | null = null; + let handledPromise: Promise | null = null; await act(async () => { handledPromise = getValue().handleImageWorkbenchCommand({ rawText: "@配图 生成 城市夜景主视觉", @@ -49,7 +64,7 @@ describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { await handledPromise; }); - expect(await handledPromise).toBe(true); + expect(await handledPromise).toBe(accepted); expect(ensureImageWorkbenchProvidersLoaded).toHaveBeenCalledTimes(1); expect(submitImageWorkbenchAgentCommand).toHaveBeenCalledWith( expect.objectContaining({ @@ -65,14 +80,16 @@ describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { }); it("应通过 Agent 主链提交图片 skill launch,而不是前端直建 task", async () => { - const submitImageWorkbenchAgentCommand = vi.fn().mockResolvedValue(true); + const submitImageWorkbenchAgentCommand = vi + .fn() + .mockResolvedValue(accepted); const { render, getValue } = renderCommandActionHook({ submitImageWorkbenchAgentCommand, }); await render(); - let handled = false; + let handled: RuntimeSubmissionResult = { kind: "unknown" }; await act(async () => { handled = await getValue().handleImageWorkbenchCommand({ rawText: "@配图 生成 城市夜景主视觉", @@ -81,7 +98,7 @@ describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { }); }); - expect(handled).toBe(true); + expect(handled).toBe(accepted); expect(submitImageWorkbenchAgentCommand).toHaveBeenCalledTimes(1); expect(submitImageWorkbenchAgentCommand).toHaveBeenCalledWith({ rawText: "@配图 生成 城市夜景主视觉", @@ -115,7 +132,9 @@ describe("useWorkspaceImageWorkbenchCommandActionRuntime", () => { }); it("本地图片工作台 key 首次提交时应保留统一发送主线,延后由发送边界绑定真实会话", async () => { - const submitImageWorkbenchAgentCommand = vi.fn().mockResolvedValue(true); + const submitImageWorkbenchAgentCommand = vi + .fn() + .mockResolvedValue(accepted); const localImageWorkbenchSessionKey = "__local_image_workbench__:draft:image"; const { render, getValue } = renderCommandActionHook({ diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.testFixtures.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.testFixtures.tsx index 976ad4b85..7a9b98fcd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.testFixtures.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.testFixtures.tsx @@ -1,3 +1,5 @@ +import { createRuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import { fromRuntimeAcceptanceReceipt } from "../hooks/agentSendResult"; import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -192,7 +194,17 @@ export function renderCommandActionHook( imageWorkbenchSessionKey: "session-1", projectId: "project-1", projectRootPath: "/workspace/project-1", - submitImageWorkbenchAgentCommand: vi.fn().mockResolvedValue(true), + submitImageWorkbenchAgentCommand: vi + .fn() + .mockResolvedValue( + fromRuntimeAcceptanceReceipt( + createRuntimeAcceptanceReceipt( + { sessionId: "s", threadId: "t" }, + "start", + "turn", + ), + ), + ), }; function Probe(currentProps: CommandActionHookProps) { diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchCommandActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchCommandActionRuntime.ts index 40d76aa1d..90becdaa2 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchCommandActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchCommandActionRuntime.ts @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { generateAgentRuntimeTitleResult } from "@/lib/api/agentRuntime/agentClient"; import type { MessageImage } from "../types"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import type { ParsedImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import type { ImageWorkbenchCommandActionParams, @@ -36,7 +37,7 @@ interface UseWorkspaceImageWorkbenchCommandActionRuntimeParams { projectRootPath?: string | null; submitImageWorkbenchAgentCommand: ( params: SubmitImageWorkbenchAgentCommandParams, - ) => Promise; + ) => Promise; } interface ResolveImageWorkbenchCommandActionRequestParams { @@ -207,18 +208,20 @@ export function useWorkspaceImageWorkbenchCommandActionRuntime({ ); const handleImageWorkbenchCommand = useCallback( - async (params: ImageWorkbenchCommandActionParams): Promise => { + async ( + params: ImageWorkbenchCommandActionParams, + ): Promise => { if (!projectId) { toast.error( t("agentChat.imageWorkbenchAction.toast.command.missingProject"), ); - return false; + return { kind: "rejected" }; } if (!projectRootPath?.trim()) { toast.error( t("agentChat.imageWorkbenchAction.toast.command.projectNotReady"), ); - return false; + return { kind: "rejected" }; } const effectivePrompt = @@ -230,7 +233,7 @@ export function useWorkspaceImageWorkbenchCommandActionRuntime({ toast.error( t("agentChat.imageWorkbenchAction.toast.command.missingPrompt"), ); - return false; + return { kind: "rejected" }; } await ensureImageWorkbenchProviderSelectionCommitted( @@ -246,7 +249,7 @@ export function useWorkspaceImageWorkbenchCommandActionRuntime({ resolveImageGenerationSelectionError(); if (imageGenerationSelectionError) { toast.error(imageGenerationSelectionError); - return false; + return { kind: "rejected" }; } const resolvedSessionKey = await resolveImageWorkbenchSessionKey({}); @@ -300,7 +303,7 @@ export function useWorkspaceImageWorkbenchCommandActionRuntime({ : "at_image_command", }); if (!skillRequest) { - return false; + return { kind: "rejected" }; } return submitImageWorkbenchAgentCommand({ diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts index a71d44ee5..1ff0d50dd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchEventRuntime.ts @@ -15,6 +15,7 @@ import { } from "@/lib/imageWorkbenchEvents"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import type { MessageImage } from "../types"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import { buildImageWorkbenchCommandText, resolveCoverAspectRatio, @@ -40,7 +41,7 @@ interface UseWorkspaceImageWorkbenchEventRuntimeParams { parsedCommand: NonNullable>; images: MessageImage[]; applyTarget?: ImageWorkbenchApplyTarget | null; - }) => Promise; + }) => Promise; } export function useWorkspaceImageWorkbenchEventRuntime({ diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.test.tsx index a588a8927..6dc4591f9 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.test.tsx @@ -1,3 +1,5 @@ +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -163,7 +165,7 @@ describe("useWorkspaceImageWorkbenchSendCommandRuntime", () => { it("图片工作台动作应通过绑定的 handleSendRef 进入统一发送主线", async () => { const { render, getValue } = renderHook(); - const handleSend = vi.fn().mockResolvedValue(true); + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); const images = [{ data: "base64-image", mediaType: "image/png" }]; const requestContext = { kind: "image_task", @@ -175,7 +177,7 @@ describe("useWorkspaceImageWorkbenchSendCommandRuntime", () => { await render(); getValue().bindWorkspaceHandleSendRef({ current: handleSend }); - let submitted = false; + let submitted: RuntimeSubmissionResult | undefined; await act(async () => { submitted = await getValue().submitImageWorkbenchAgentCommand({ rawText: "@配图 生成 城市夜景主视觉", @@ -185,7 +187,7 @@ describe("useWorkspaceImageWorkbenchSendCommandRuntime", () => { }); }); - expect(submitted).toBe(true); + expect(submitted).toMatchObject({ kind: "runtime-accepted" }); expect(handleSend).toHaveBeenCalledWith( images, undefined, diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.ts index 045ec0b7a..39a72df92 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchSendCommandRuntime.ts @@ -254,7 +254,7 @@ export function useWorkspaceImageWorkbenchSendCommandRuntime({ async (params: SubmitImageWorkbenchAgentCommandParams) => { const handleSend = workspaceHandleSendRef.current?.current; if (!handleSend) { - return false; + return { kind: "rejected" as const }; } return await handleSend( diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.test.tsx index 13521e84e..961a8c7dd 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.test.tsx @@ -1,3 +1,8 @@ +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import type { ComponentProps } from "react"; +import { Inputbar } from "../components/Inputbar"; import React from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -18,11 +23,11 @@ const mockStrictReviewRuntime = vi.hoisted(() => ({ }, })); const mockThreadQueueActions = vi.hoisted(() => ({ - addThreadQueue: vi.fn().mockResolvedValue({ + addThreadQueue: vi.fn(async (params) => ({ id: "queued-1", - clientUserMessageId: "client-1", - input: [{ type: "text", text: "排队任务" }], - }), + clientUserMessageId: params.clientUserMessageId, + input: params.input, + })), })); const toastMock = vi.hoisted(() => ({ error: vi.fn(), @@ -105,7 +110,6 @@ function createDefaultHarnessPanelBaseProps( canInterrupt: false, onInterruptCurrentTurn: noop, onReplayPendingRequest: noop, - onObjectiveChanged: noop, onManageProviders: noop, onOpenExecutionPolicySettings: noop, diagnosticRuntimeContext: { @@ -142,6 +146,10 @@ function createDefaultProps(overrides: HookPropsOverrides = {}): HookProps { setMentionedCharacters: noop, isThemeWorkbench: true, sessionId: "session-1", + threadId: null, + threadGoal: null, + threadGoalError: null, + threadGoalLoading: false, generalWorkbenchHarnessPanelBaseProps: createDefaultHarnessPanelBaseProps( noop, { @@ -159,7 +167,7 @@ function createDefaultProps(overrides: HookPropsOverrides = {}): HookProps { generalWorkbenchWorkflowSteps: [], steps: [], workflowRunState: "idle", - handleSend: vi.fn().mockResolvedValue(true), + handleSend: vi.fn().mockResolvedValue(acceptedSubmission()), isPreparingSend: false, isSending: false, providerType: "openai", @@ -259,68 +267,10 @@ function renderHookNode(props: HookProps): HTMLDivElement { return container; } -function getLatestInputbarProps(): { - disabled?: boolean; - disabledPlaceholder?: string; - isLoading?: boolean; - inputRestoreRequest?: unknown; - onInputRestoreRequestHandled?: (requestId: string) => void; - onSend?: (payload?: { - images?: unknown[]; - textOverride?: string; - composerTarget?: "start" | "queue" | "steer" | "interrupt" | "command"; - composerIntent?: string; - composerDraft?: { - text: string; - selectionStart: number; - selectionEnd: number; - textElements: readonly unknown[]; - mentionBindings: readonly unknown[]; - attachments: readonly unknown[]; - pathReferences: readonly unknown[]; - pendingPastes: readonly unknown[]; - }; - sendOptions?: { - requestMetadata?: Record; - targetSessionId?: string; - skipSessionRestore?: boolean; - }; - }) => void | Promise | boolean; - toolStates?: Record; - onToolStatesChange?: (states: Record) => void; -} { +function getLatestInputbarProps(): ComponentProps { const latestCall = mockInputbarRender.mock.calls.at(-1); expect(latestCall).toBeTruthy(); - return latestCall?.[0] as { - disabled?: boolean; - disabledPlaceholder?: string; - isLoading?: boolean; - inputRestoreRequest?: unknown; - onInputRestoreRequestHandled?: (requestId: string) => void; - onSend?: (payload?: { - images?: unknown[]; - textOverride?: string; - composerTarget?: "start" | "queue" | "steer" | "interrupt" | "command"; - composerIntent?: string; - composerDraft?: { - text: string; - selectionStart: number; - selectionEnd: number; - textElements: readonly unknown[]; - mentionBindings: readonly unknown[]; - attachments: readonly unknown[]; - pathReferences: readonly unknown[]; - pendingPastes: readonly unknown[]; - }; - sendOptions?: { - requestMetadata?: Record; - targetSessionId?: string; - skipSessionRestore?: boolean; - }; - }) => void | Promise | boolean; - toolStates?: Record; - onToolStatesChange?: (states: Record) => void; - }; + return latestCall?.[0]; } beforeEach(async () => { @@ -349,6 +299,19 @@ afterEach(() => { }); describe("useWorkspaceInputbarSceneRuntime", () => { + it("草稿恢复期间禁用 Composer 且直接发送回调不能越过恢复门禁", async () => { + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); + renderHookNode( + createDefaultProps({ composerDraftRestoring: true, handleSend }), + ); + const props = getLatestInputbarProps(); + expect(props.disabled).toBe(true); + expect(await props.onSend?.({ textOverride: "pending restore" })).toEqual({ + kind: "rejected", + }); + expect(handleSend).not.toHaveBeenCalled(); + expect(mockThreadQueueActions.addThreadQueue).not.toHaveBeenCalled(); + }); it("strict review 状态应位于 Composer 上方且不替换 Inputbar", () => { mockStrictReviewRuntime.status = { startedAtMs: 1_783_814_400_100, @@ -448,7 +411,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { }); it("Inputbar 发送应绑定当前会话,避免落到旧 active session", async () => { - const handleSend = vi.fn().mockResolvedValue(true); + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHookNode( createDefaultProps({ sessionId: "session-current-inputbar", @@ -488,7 +451,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { }); it("Inputbar 显式 targetSessionId 应优先于当前会话", async () => { - const handleSend = vi.fn().mockResolvedValue(true); + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHookNode( createDefaultProps({ sessionId: "session-current-inputbar", @@ -532,16 +495,9 @@ describe("useWorkspaceInputbarSceneRuntime", () => { await getLatestInputbarProps().onSend?.({ composerTarget: "queue", composerIntent: "queue", - composerDraft: { + composerDraft: new ComposerController({ text: "排队任务", - selectionStart: 4, - selectionEnd: 4, - textElements: [], - mentionBindings: [], - attachments: [], - pathReferences: [], - pendingPastes: [], - }, + }).captureDraft(), textOverride: "排队任务", }); }); @@ -554,23 +510,62 @@ describe("useWorkspaceInputbarSceneRuntime", () => { ); }); + it("queue cannot pair another target session with the current canonical thread", async () => { + renderHookNode( + createDefaultProps({ + sessionId: "current-session", + threadId: "current-thread", + }), + ); + expect( + await getLatestInputbarProps().onSend({ + composerTarget: "queue", + textOverride: "body", + sendOptions: { targetSessionId: "other-session" }, + }), + ).toEqual({ kind: "unknown" }); + expect(mockThreadQueueActions.addThreadQueue).not.toHaveBeenCalled(); + }); + it("queue 等待 canonical 接受期间拒绝重复派发,拒绝后允许重试", async () => { let reject!: (error: Error) => void; - mockThreadQueueActions.addThreadQueue.mockImplementationOnce(() => - new Promise((_resolve, rejectQueue) => { reject = rejectQueue; })); + mockThreadQueueActions.addThreadQueue.mockImplementationOnce( + () => + new Promise((_resolve, rejectQueue) => { + reject = rejectQueue; + }), + ); renderHookNode(createDefaultProps({ threadId: "thread-queue-pending" })); - let pending!: ReturnType["onSend"]>>; + let pending!: ReturnType< + NonNullable["onSend"]> + >; await act(async () => { - pending = getLatestInputbarProps().onSend?.({ composerTarget: "queue", textOverride: "排队任务" }); + pending = getLatestInputbarProps().onSend?.({ + composerTarget: "queue", + textOverride: "排队任务", + }); await Promise.resolve(); }); expect(mockThreadQueueActions.addThreadQueue).toHaveBeenCalledOnce(); - expect(await getLatestInputbarProps().onSend?.({ composerTarget: "queue", textOverride: "排队任务" })).toBe(false); + expect( + await getLatestInputbarProps().onSend?.({ + composerTarget: "queue", + textOverride: "排队任务", + }), + ).toMatchObject({ kind: "rejected" }); expect(mockThreadQueueActions.addThreadQueue).toHaveBeenCalledOnce(); - await act(async () => { reject(new Error("queue rejected")); await pending; }); - expect(await pending).toBe(false); await act(async () => { - expect(await getLatestInputbarProps().onSend?.({ composerTarget: "queue", textOverride: "排队任务" })).toBe(true); + reject(new Error("queue rejected")); + await pending; + }); + expect(await pending).toMatchObject({ kind: "rejected" }); + await act(async () => { + expect( + await getLatestInputbarProps().onSend?.({ + composerTarget: "queue", + textOverride: "排队任务", + }), + ).toMatchObject({ kind: "queue-accepted" }); }); expect(mockThreadQueueActions.addThreadQueue).toHaveBeenCalledTimes(2); }); @@ -578,7 +573,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { it.each(["start", "steer"] as const)( "%s 应保留顶层 typed submission 和完整 draft", async (target) => { - const handleSend = vi.fn().mockResolvedValue(true); + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); renderHookNode( createDefaultProps({ sessionId: "session-structured", handleSend }), ); @@ -673,7 +668,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { }, }); }); - expect(result).toBe(false); + expect(result).toMatchObject({ kind: "rejected" }); expect(mockThreadQueueActions.addThreadQueue).not.toHaveBeenCalled(); expect(toastMock.error).toHaveBeenCalled(); }); @@ -689,26 +684,19 @@ describe("useWorkspaceInputbarSceneRuntime", () => { }), ); - let result: boolean | void = true; + let result: RuntimeSubmissionResult | undefined; await act(async () => { result = await getLatestInputbarProps().onSend?.({ composerTarget: "queue", composerIntent: "queue", - composerDraft: { + composerDraft: new ComposerController({ text: "保留这份草稿", - selectionStart: 7, - selectionEnd: 7, - textElements: [], - mentionBindings: [], - attachments: [], - pathReferences: [], - pendingPastes: [], - }, + }).captureDraft(), textOverride: "保留这份草稿", }); }); - expect(result).toBe(false); + expect(result).toMatchObject({ kind: "rejected" }); expect(toastMock.error).toHaveBeenCalledWith("队列操作失败,请重试"); }); @@ -1085,7 +1073,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { }); it("parent-owned thread 应保留草稿并拒绝发送与设置变更", async () => { - const handleSend = vi.fn().mockResolvedValue(true); + const handleSend = vi.fn().mockResolvedValue(acceptedSubmission()); const setChatToolPreferences = vi.fn(); renderHookNode( createDefaultProps({ @@ -1101,9 +1089,9 @@ describe("useWorkspaceInputbarSceneRuntime", () => { "此子线程由父线程管理,无法直接输入", ); - expect(await inputbarProps.onSend?.({ textOverride: "不得直接提交" })).toBe( - false, - ); + expect( + await inputbarProps.onSend?.({ textOverride: "不得直接提交" }), + ).toMatchObject({ kind: "rejected" }); act(() => { inputbarProps.onToolStatesChange?.({ plan: true }); }); @@ -1147,7 +1135,7 @@ describe("useWorkspaceInputbarSceneRuntime", () => { text: "恢复这段输入", images: [], pathReferences: [], - inputCapabilityRoute: null, + inputCapabilityRoute: undefined, }, }; diff --git a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx index 4e8bae0cf..69f0564b3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceInputbarSceneRuntime.tsx @@ -1,3 +1,9 @@ +import type { + ComposerDraftCoordinator, + ComposerDraftStatus as DraftStatus, +} from "@/components/input-kit/ComposerDraftCoordinator"; +import { ComposerDraftStatus } from "./ComposerDraftStatus"; +import { submitWorkspaceQueue } from "./workspaceQueueSubmission"; import { useCallback, useMemo, @@ -67,6 +73,10 @@ interface UseWorkspaceInputbarSceneRuntimeParams { handleStopSending: InputbarParams["onStop"]; input: InputbarParams["input"]; composerController?: ComposerController; + composerDraftCoordinator?: ComposerDraftCoordinator; + onComposerDraftAccepted?: InputbarParams["onComposerDraftAccepted"]; + composerDraftStatus?: DraftStatus | null; + composerDraftRestoring?: boolean; setInput: InputbarParams["setInput"]; currentGate: InputbarParams["workflowGate"]; generalWorkbenchWorkflowSteps: InputbarParams["workflowSteps"]; @@ -156,6 +166,10 @@ export function useWorkspaceInputbarSceneRuntime({ handleStopSending, input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, + composerDraftStatus = null, + composerDraftRestoring = false, setInput, currentGate, generalWorkbenchWorkflowSteps, @@ -273,16 +287,28 @@ export function useWorkspaceInputbarSceneRuntime({ const sendPendingRef = useRef(false); const handleInputbarSend = useCallback( async (payload = {}) => { - if (directInputBlocked || sendPendingRef.current) { - return false; + if ( + directInputBlocked || + composerDraftRestoring || + sendPendingRef.current + ) { + return { kind: "rejected" }; } sendPendingRef.current = true; try { const composerSendOptions = getInputbarSendOptions(payload); if (composerSendOptions?.composerTarget === "queue") { const targetThreadId = threadId?.trim(); + const requestedSessionId = + composerSendOptions.targetSessionId?.trim(); + if ( + requestedSessionId && + requestedSessionId !== inputbarTargetSessionId + ) { + return { kind: "unknown" }; + } if (!targetThreadId) { - return false; + return { kind: "rejected" }; } const submission = composerSendOptions.composerSubmission; const pathText = (submission?.pathReferences ?? []) @@ -297,10 +323,13 @@ export function useWorkspaceInputbarSceneRuntime({ !content.trim() && !(submission?.attachments.length ?? payload.images?.length ?? 0) ) { - return false; + return { kind: "rejected" }; } try { - await addThreadQueue({ + return await submitWorkspaceQueue({ + sessionId: + composerSendOptions.targetSessionId?.trim() || + inputbarTargetSessionId, threadId: targetThreadId, clientUserMessageId: crypto.randomUUID(), input: buildTurnInput({ @@ -309,11 +338,14 @@ export function useWorkspaceInputbarSceneRuntime({ inputMentions: composerSendOptions.inputMentions, composerSubmission: submission, }), + observer: composerSendOptions.observer, + addQueue: addThreadQueue, + onRejected: () => + toast.error(String(t("agentChat.threadQueue.actionFailed"))), }); - return true; - } catch { + } catch (error) { toast.error(String(t("agentChat.threadQueue.actionFailed"))); - return false; + return { kind: "rejected", error }; } } const payloadTargetSessionId = @@ -339,7 +371,14 @@ export function useWorkspaceInputbarSceneRuntime({ sendPendingRef.current = false; } }, - [directInputBlocked, handleSend, inputbarTargetSessionId, t, threadId], + [ + directInputBlocked, + composerDraftRestoring, + handleSend, + inputbarTargetSessionId, + t, + threadId, + ], ); const handleInputbarToolStatesChange = useCallback( (nextToolStates: WorkspaceInputbarToolStates) => { @@ -399,6 +438,8 @@ export function useWorkspaceInputbarSceneRuntime({ inputbar: { input, composerController, + composerDraftCoordinator, + onComposerDraftAccepted, setInput, contextVariant, variant: isThemeWorkbench ? "workspace" : "default", @@ -438,6 +479,7 @@ export function useWorkspaceInputbarSceneRuntime({ disabled: directInputBlocked || isSessionRestoring || + composerDraftRestoring || isPreparingSend || (contextVariant !== "task-center" && !projectId && !sessionId), disabledPlaceholder: directInputBlocked @@ -539,17 +581,25 @@ export function useWorkspaceInputbarSceneRuntime({ ) : null; const composerStatusNode = pendingInteractionLayer ?? strictReviewStatusNode; + const draftStatusNode = composerDraftCoordinator ? ( + + ) : null; return { ...presentationRuntime, - inputbarNode: composerStatusNode ? ( -
- {composerStatusNode} - {presentationRuntime.inputbarNode} -
- ) : ( - presentationRuntime.inputbarNode - ), + inputbarNode: + composerStatusNode || draftStatusNode ? ( +
+ {composerStatusNode} + {presentationRuntime.inputbarNode} + {draftStatusNode} +
+ ) : ( + presentationRuntime.inputbarNode + ), knowledgePackSelection: knowledgeRuntime.knowledgePackSelection, knowledgePackOptions: knowledgeRuntime.knowledgePackOptions, onToggleKnowledgePack: knowledgeRuntime.onToggleKnowledgePack, diff --git a/src/components/agent/chat/workspace/useWorkspacePlanDecisionRuntime.tsx b/src/components/agent/chat/workspace/useWorkspacePlanDecisionRuntime.tsx index cc4e2a743..c16f7142c 100644 --- a/src/components/agent/chat/workspace/useWorkspacePlanDecisionRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspacePlanDecisionRuntime.tsx @@ -1,3 +1,4 @@ +import { isRuntimeSubmissionAccepted } from "../hooks/agentSendResult"; import { useCallback, useEffect, @@ -179,7 +180,7 @@ export function useWorkspacePlanDecisionRuntime({ submitPlan.sendOptions, ); - if (!sendResult) { + if (!isRuntimeSubmissionAccepted(sendResult)) { return; } diff --git a/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts index 17afed0bf..4e0b129d4 100644 --- a/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.ts @@ -13,9 +13,10 @@ import type { } from "@/lib/workspace/workbenchContract"; import type { CanvasStateUnion } from "@/components/workspace/canvas/canvasUtils"; import type { TaskFile } from "../components/TaskFiles"; -import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import type { Character } from "@/lib/api/projectMemory"; import type { Page, PageParams } from "@/types/page"; +import type { ComposerDraftCoordinator } from "@/components/input-kit/ComposerDraftCoordinator"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; type SetStringState = Dispatch>; @@ -31,6 +32,10 @@ interface UseWorkspaceResetRuntimeParams { resetRestoredSessionState: () => void; resetGuideState: () => void; resetComposerDraft?: () => void; + composerDraftCoordinator?: ComposerDraftCoordinator; + composerDraftEnabled?: boolean; + onComposerDraftScopeCommitted?: (scope: DraftScope) => void; + composerDraftHomeWorkspaceId?: string | null; hasHandledNewChatRequest: (requestKey: string) => boolean; markNewChatRequestHandled: (requestKey: string) => void; defaultTopicSidebarVisible: boolean; @@ -40,6 +45,8 @@ interface UseWorkspaceResetRuntimeParams { externalProjectId?: string | null; preserveSessionRestoreOnNewChat?: boolean; onNavigate?: (page: Page, params?: PageParams) => void; + onStartNewConversation?: import("@/lib/workspace/navigation").StartNewConversation; + newConversationRequest?: import("@/lib/workspace/navigation").NewConversationRequest; autoCollapsedTopicSidebarRef: MutableRefObject; processedMessageIdsRef: MutableRefObject>; setInput: SetStringState; @@ -63,6 +70,10 @@ export function useWorkspaceResetRuntime({ resetRestoredSessionState, resetGuideState, resetComposerDraft, + composerDraftCoordinator, + composerDraftEnabled = false, + onComposerDraftScopeCommitted, + composerDraftHomeWorkspaceId, hasHandledNewChatRequest, markNewChatRequestHandled, defaultTopicSidebarVisible, @@ -71,7 +82,8 @@ export function useWorkspaceResetRuntime({ newChatAt, externalProjectId, preserveSessionRestoreOnNewChat = false, - onNavigate, + onStartNewConversation, + newConversationRequest, autoCollapsedTopicSidebarRef, processedMessageIdsRef, setInput, @@ -115,35 +127,17 @@ export function useWorkspaceResetRuntime({ ]); const handleBackHome = useCallback(() => { - resetComposerDraft?.(); - clearMessages({ - showToast: false, + onStartNewConversation?.({ + projectId: composerDraftHomeWorkspaceId ?? null, + freshDraft: false, }); - setInput(""); - setSelectedText(""); - setShowSidebar(true); - resetWorkbenchSurface(); - clearPendingEntryA2UI?.(); - setActiveTheme("general"); - setCreationMode("guided"); - onNavigate?.("agent", buildHomeAgentParams()); - }, [ - clearMessages, - clearPendingEntryA2UI, - onNavigate, - resetComposerDraft, - resetWorkbenchSurface, - setActiveTheme, - setCreationMode, - setInput, - setSelectedText, - setShowSidebar, - ]); + }, [composerDraftHomeWorkspaceId, onStartNewConversation]); useEffect(() => { if (!newChatAt) { return; } + if (composerDraftEnabled && !composerDraftCoordinator) return; const requestKey = String(newChatAt); if (hasHandledNewChatRequest(requestKey)) { @@ -155,11 +149,18 @@ export function useWorkspaceResetRuntime({ return; } - resetComposerDraft?.(); + if (composerDraftCoordinator && !newConversationRequest) { + composerDraftCoordinator.prepareNavigation(); + onComposerDraftScopeCommitted?.({ + workspaceId: composerDraftHomeWorkspaceId ?? null, + kind: "home", + id: "new-conversation", + }); + } else if (!composerDraftCoordinator) resetComposerDraft?.(); clearMessages({ showToast: false, }); - setInput(""); + if (!composerDraftCoordinator) setInput(""); setSelectedText(""); autoCollapsedTopicSidebarRef.current = false; setShowSidebar(defaultTopicSidebarVisible); @@ -188,9 +189,14 @@ export function useWorkspaceResetRuntime({ initialCreationMode, markNewChatRequestHandled, newChatAt, + newConversationRequest, normalizedInitialTheme, preserveSessionRestoreOnNewChat, resetComposerDraft, + composerDraftCoordinator, + composerDraftEnabled, + composerDraftHomeWorkspaceId, + onComposerDraftScopeCommitted, resetGuideState, resetProjectSelection, resetRestoredSessionState, diff --git a/src/components/agent/chat/workspace/useWorkspaceResetRuntime.unit.test.tsx b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.unit.test.tsx index ed1e340c2..aedd1526c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceResetRuntime.unit.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceResetRuntime.unit.test.tsx @@ -44,9 +44,10 @@ function renderHook(props?: Partial) { setActiveTheme: vi.fn(), setCreationMode: vi.fn(), }; + let runtime!: ReturnType; function Probe(currentProps: HookProps) { - useWorkspaceResetRuntime(currentProps); + runtime = useWorkspaceResetRuntime(currentProps); return null; } @@ -62,6 +63,7 @@ function renderHook(props?: Partial) { return { render, props: defaultProps, + current: () => runtime, }; } @@ -88,6 +90,28 @@ afterEach(() => { }); describe("useWorkspaceResetRuntime", () => { + it.each([null, "project-a"])( + "返回首页仅委托根导航并恢复 workspace %s 的草稿", + async (workspaceId) => { + const onStartNewConversation = vi.fn(); + const harness = renderHook({ + newChatAt: undefined, + composerDraftHomeWorkspaceId: workspaceId, + onStartNewConversation, + }); + await harness.render(); + act(() => harness.current().handleBackHome()); + expect(onStartNewConversation).toHaveBeenCalledExactlyOnceWith({ + projectId: workspaceId, + freshDraft: false, + }); + expect(harness.props.clearMessages).not.toHaveBeenCalled(); + expect(harness.props.resetComposerDraft).not.toHaveBeenCalled(); + expect(harness.props.setInput).not.toHaveBeenCalled(); + expect(harness.props.onNavigate).not.toHaveBeenCalled(); + }, + ); + it("刷新恢复运行中会话时不应把 newChatAt 当作新任务清空候选", async () => { const harness = renderHook({ preserveSessionRestoreOnNewChat: true, @@ -115,4 +139,15 @@ describe("useWorkspaceResetRuntime", () => { expect(harness.props.resetRestoredSessionState).toHaveBeenCalled(); expect(harness.props.resetComposerDraft).toHaveBeenCalledOnce(); }); + + it("持久化 owner 尚未建立时不把新任务标为已处理或清空正文", async () => { + const harness = renderHook({ composerDraftEnabled: true }); + await harness.render(); + act(() => harness.current().handleBackHome()); + expect(harness.props.markNewChatRequestHandled).not.toHaveBeenCalled(); + expect(harness.props.resetComposerDraft).not.toHaveBeenCalled(); + expect(harness.props.setInput).not.toHaveBeenCalled(); + expect(harness.props.clearMessages).not.toHaveBeenCalled(); + expect(harness.props.onNavigate).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index 2fc792363..ea816413b 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -1,3 +1,6 @@ +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -91,7 +94,9 @@ interface HookHarness { unmount: () => void; } -const mockSendMessage = vi.fn(async () => undefined); +const mockSendMessage = vi.fn(async () => + acceptedSubmission(), +); const mockFinalizeAfterSendSuccess = vi.fn(); const mockRollbackAfterSendFailure = vi.fn(); const mockSetInput = vi.fn(); @@ -491,7 +496,7 @@ describe("useWorkspaceSendActions", () => { "react", autoContinue, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -521,7 +526,7 @@ describe("useWorkspaceSendActions", () => { try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockUseGlobalMediaGenerationDefaults).not.toHaveBeenCalled(); @@ -544,7 +549,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "你好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -562,7 +567,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, text, "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -593,7 +598,7 @@ describe("useWorkspaceSendActions", () => { const harness = mountHook(); try { - let result: boolean | "timeout" = "timeout"; + let result: RuntimeSubmissionResult | "timeout" = "timeout"; await act(async () => { result = await Promise.race([ harness @@ -605,7 +610,7 @@ describe("useWorkspaceSendActions", () => { ]); }); - expect(result).toBe(true); + expect(result).toMatchObject({ kind: "runtime-accepted" }); expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); expect(mockSendMessage).toHaveBeenCalledTimes(1); } finally { @@ -630,7 +635,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "请只回复一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -658,7 +663,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "只回答一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -692,7 +697,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "只回答一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -737,7 +742,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "只回答一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(resolveServiceModelsBeforeSend).not.toHaveBeenCalled(); @@ -776,7 +781,7 @@ describe("useWorkspaceSendActions", () => { skipWorkspaceCommandRouting: true, }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -813,7 +818,7 @@ describe("useWorkspaceSendActions", () => { .handleSend([], false, false, "请改写这段文案", "react", undefined, { purpose: "style_rewrite", }); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(resolveServiceModelsBeforeSend).toHaveBeenCalledTimes(1); @@ -841,7 +846,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, "只回答一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -880,7 +885,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, brief, "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSetChatMessages).not.toHaveBeenCalled(); @@ -945,7 +950,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, brief, "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -981,7 +986,7 @@ describe("useWorkspaceSendActions", () => { const brief = "参考图生成一张小红书封面,保留醒目的标题层级、清晰的主体构图和适合收藏转发的视觉冲击力"; const deferredSession = createDeferred(); - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); mockEnsureSessionForCommandMetadata.mockImplementationOnce( async () => deferredSession.promise, ); @@ -1001,7 +1006,7 @@ describe("useWorkspaceSendActions", () => { mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); const harness = mountHook(); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -1038,7 +1043,7 @@ describe("useWorkspaceSendActions", () => { ).toBeUndefined(); await act(async () => { - deferredSend.resolve(); + deferredSend.resolve(acceptedSubmission()); await sendPromise; }); @@ -1065,7 +1070,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, brief, "react"); - expect(started).toBe(false); + expect(started).toMatchObject({ kind: "rejected" }); await Promise.resolve(); }); @@ -1088,7 +1093,7 @@ describe("useWorkspaceSendActions", () => { const started = await harness .getValue() .handleSend([], false, false, promptText, "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -1118,7 +1123,7 @@ DOWNLOAD_URL="https://limeai.run/skill-packages/viral-content-breakdown/latest/v const started = await harness .getValue() .handleSend([], false, false, prompt, "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "local-handled" }); }); expect(mockInstallSkillFromPromptInstruction).toHaveBeenCalledTimes(1); @@ -1172,7 +1177,7 @@ DOWNLOAD_URL="https://limeai.run/skill-packages/viral-content-breakdown/latest/v Extract it into the Agent Skills directory.`, "react", ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "local-handled" }); }); const applyLocalMessages = mockSetChatMessages.mock.calls[0]?.[0] as @@ -1195,7 +1200,7 @@ Extract it into the Agent Skills directory.`, const started = await harness .getValue() .handleSend([], false, false, "只回答一个字:好", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1233,7 +1238,7 @@ Extract it into the Agent Skills directory.`, skipSessionStartHooks: true, }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1298,7 +1303,7 @@ Extract it into the Agent Skills directory.`, const started = await harness .getValue() .handleSend([], false, false, "继续处理当前话题", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1371,7 +1376,7 @@ Extract it into the Agent Skills directory.`, const started = await harness .getValue() .handleSend([], false, false, "继续处理当前话题", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1421,7 +1426,7 @@ Extract it into the Agent Skills directory.`, const started = await harness .getValue() .handleSend([], false, false, "继续处理当前话题", "react"); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1470,7 +1475,7 @@ Extract it into the Agent Skills directory.`, .handleSend([], false, false, "请改写这段文案", "react", undefined, { purpose: "style_rewrite", }); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ @@ -1496,13 +1501,13 @@ Extract it into the Agent Skills directory.`, }); it("发送准备阶段应立即暴露 submission preview,并在真实提交接管后清理", async () => { - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); const harness = mountHook({ input: "帮我找一下今天的新闻", }); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -1525,7 +1530,7 @@ Extract it into the Agent Skills directory.`, ]); await act(async () => { - deferredSend.resolve(); + deferredSend.resolve(acceptedSubmission()); await sendPromise; }); @@ -1537,7 +1542,7 @@ Extract it into the Agent Skills directory.`, it("普通 Claw 空上下文准备较慢时不应阻塞发送", async () => { const deferredContext = createDeferred(); - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); const prepareActiveContextPrompt = vi.fn( async () => deferredContext.promise, ); @@ -1551,7 +1556,7 @@ Extract it into the Agent Skills directory.`, }, }); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -1584,7 +1589,7 @@ Extract it into the Agent Skills directory.`, }); await act(async () => { - deferredSend.resolve(); + deferredSend.resolve(acceptedSubmission()); await sendPromise; }); @@ -1597,7 +1602,7 @@ Extract it into the Agent Skills directory.`, it("主题工作台首轮发送等待上下文时应并行预热会话", async () => { const deferredContext = createDeferred(); const deferredSession = createDeferred(); - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); mockEnsureSessionForCommandMetadata.mockImplementationOnce( async () => deferredSession.promise, ); @@ -1612,7 +1617,7 @@ Extract it into the Agent Skills directory.`, }, }); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -1635,7 +1640,7 @@ Extract it into the Agent Skills directory.`, await act(async () => { deferredSession.resolve("session-prewarmed"); - deferredSend.resolve(); + deferredSend.resolve(acceptedSubmission()); await sendPromise; }); } finally { @@ -1644,14 +1649,14 @@ Extract it into the Agent Skills directory.`, }); it("发送准备阶段不应重复递交同一条首页消息", async () => { - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); mockSendMessage.mockImplementationOnce(async () => deferredSend.promise); const harness = mountHook({ input: "帮我生成一版首页插图方案", }); - let firstSendPromise: Promise | null = null; - let secondSendPromise: Promise | null = null; + let firstSendPromise: Promise | null = null; + let secondSendPromise: Promise | null = null; try { await act(async () => { @@ -1660,7 +1665,7 @@ Extract it into the Agent Skills directory.`, await Promise.resolve(); }); - expect(await secondSendPromise).toBe(false); + expect(await secondSendPromise).toMatchObject({ kind: "rejected" }); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(harness.getValue().displayMessages).toEqual([ expect.objectContaining({ @@ -1677,8 +1682,10 @@ Extract it into the Agent Skills directory.`, ]); await act(async () => { - deferredSend.resolve(); - expect(await firstSendPromise).toBe(true); + deferredSend.resolve(acceptedSubmission()); + expect(await firstSendPromise).toMatchObject({ + kind: "runtime-accepted", + }); }); expect(harness.getValue().isPreparingSend).toBe(false); @@ -1696,7 +1703,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -1728,7 +1735,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -1794,7 +1801,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -1839,7 +1846,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(false); + expect(started).toMatchObject({ kind: "rejected" }); }); expect(mockPrepareImageWorkbenchSkillSend).toHaveBeenCalledTimes(1); @@ -1870,7 +1877,7 @@ Extract it into the Agent Skills directory.`, const harness = mountHook({ input: "@配图 生成 一张春日咖啡馆插画", }); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -1887,7 +1894,7 @@ Extract it into the Agent Skills directory.`, await sendPromise; }); - expect(await sendPromise).toBe(true); + expect(await sendPromise).toMatchObject({ kind: "runtime-accepted" }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ @@ -1943,7 +1950,7 @@ Extract it into the Agent Skills directory.`, displayContent: "@配图 生成 一张春日咖啡馆插画", }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2022,7 +2029,7 @@ Extract it into the Agent Skills directory.`, displayContent: "画一张广州夏天的图", }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2078,7 +2085,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); const sendOptions = mockSendMessage.mock.calls[0]?.[8]; @@ -2127,7 +2134,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); const sendOptions = mockSendMessage.mock.calls[0]?.[8]; @@ -2181,7 +2188,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockCreateImageGenerationTask).not.toHaveBeenCalled(); @@ -2265,7 +2272,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -2317,7 +2324,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2390,7 +2397,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -2440,7 +2447,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -2462,7 +2469,7 @@ Extract it into the Agent Skills directory.`, it("@配图 首次发送建会话较慢时,也应先展示 submission preview", async () => { const deferredSession = createDeferred(); - const deferredSend = createDeferred(); + const deferredSend = createDeferred(); mockEnsureSessionForCommandMetadata.mockImplementationOnce( async () => deferredSession.promise, ); @@ -2482,7 +2489,7 @@ Extract it into the Agent Skills directory.`, input: "@配图 生成 一张春日咖啡馆插画", }); - let sendPromise: Promise | null = null; + let sendPromise: Promise | null = null; try { await act(async () => { @@ -2529,7 +2536,7 @@ Extract it into the Agent Skills directory.`, }); await act(async () => { - deferredSend.resolve(); + deferredSend.resolve(acceptedSubmission()); await sendPromise; }); @@ -2548,7 +2555,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -2598,7 +2605,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -2637,7 +2644,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2677,7 +2684,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2717,7 +2724,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2756,7 +2763,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).toHaveBeenCalledTimes(1); @@ -2811,7 +2818,7 @@ Extract it into the Agent Skills directory.`, }, }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -2841,7 +2848,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -2888,7 +2895,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -2917,7 +2924,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -2967,7 +2974,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -3011,7 +3018,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -3039,7 +3046,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -3087,7 +3094,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3133,7 +3140,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -3175,7 +3182,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -3210,7 +3217,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ @@ -3241,7 +3248,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -3320,7 +3327,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3389,7 +3396,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3436,7 +3443,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3481,7 +3488,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -3524,7 +3531,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -3573,7 +3580,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listServiceSkillUsage()).toEqual([ @@ -3635,7 +3642,7 @@ Extract it into the Agent Skills directory.`, undefined, "@搜索 GitHub 最近一周 openai agents sdk issue 讨论", ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listMentionEntryUsage()).toEqual([ @@ -3676,7 +3683,7 @@ Extract it into the Agent Skills directory.`, displayContent: "整理最近发布计划", }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3734,7 +3741,7 @@ Extract it into the Agent Skills directory.`, displayContent: "GitHub 最近一周 openai agents sdk issue 讨论", }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3807,7 +3814,7 @@ Extract it into the Agent Skills directory.`, displayContent: "帮我做一版新品活动启动方案", }, ); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -3858,7 +3865,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -3914,7 +3921,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -3957,7 +3964,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listMentionEntryUsage()).toEqual([ @@ -3982,7 +3989,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4042,7 +4049,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -4098,7 +4105,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -4154,7 +4161,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -4208,7 +4215,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4269,7 +4276,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -4326,7 +4333,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -4384,7 +4391,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -4423,7 +4430,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4480,7 +4487,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listMentionEntryUsage()).toEqual([ @@ -4517,7 +4524,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -4560,7 +4567,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4616,7 +4623,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listMentionEntryUsage()).toEqual([ @@ -4647,7 +4654,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ @@ -4680,7 +4687,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe("@翻译 内容:hello world"); @@ -4721,7 +4728,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4795,7 +4802,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -4836,7 +4843,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -4895,7 +4902,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -4969,7 +4976,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5011,7 +5018,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -5077,7 +5084,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -5123,7 +5130,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listServiceSkillUsage()).toEqual([ @@ -5168,7 +5175,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(false); + expect(started).toMatchObject({ kind: "rejected" }); }); expect(toastErrorSpy).toHaveBeenCalledWith("发送失败: network down"); @@ -5189,7 +5196,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -5239,7 +5246,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -5267,7 +5274,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5312,7 +5319,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5356,7 +5363,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5402,7 +5409,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5446,7 +5453,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5489,7 +5496,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -5537,7 +5544,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockEnsureSessionForCommandMetadata).toHaveBeenCalledTimes(1); @@ -5576,7 +5583,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5615,7 +5622,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockResolveImageWorkbenchCommandRequest).not.toHaveBeenCalled(); @@ -5665,7 +5672,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5719,7 +5726,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5761,7 +5768,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5811,7 +5818,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5869,7 +5876,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -5912,7 +5919,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -5975,7 +5982,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[0]).toBe( @@ -6017,7 +6024,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6096,7 +6103,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6146,7 +6153,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6185,7 +6192,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6238,7 +6245,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6303,7 +6310,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6360,7 +6367,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6427,7 +6434,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6470,7 +6477,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6527,7 +6534,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6570,7 +6577,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6622,7 +6629,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6704,7 +6711,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6764,7 +6771,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6817,7 +6824,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -6905,7 +6912,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage.mock.calls[0]?.[8]).toMatchObject({ @@ -6938,7 +6945,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7065,7 +7072,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(listServiceSkillUsage()).toEqual([ @@ -7094,7 +7101,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7134,7 +7141,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7168,7 +7175,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7210,7 +7217,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7268,7 +7275,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7344,7 +7351,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7423,7 +7430,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7493,7 +7500,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(false); + expect(started).toMatchObject({ kind: "rejected" }); }); expect(mockGetOrCreateDefaultProject).not.toHaveBeenCalled(); @@ -7546,7 +7553,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(false); + expect(started).toMatchObject({ kind: "rejected" }); }); expect(mockSendMessage).not.toHaveBeenCalled(); @@ -7603,7 +7610,7 @@ Extract it into the Agent Skills directory.`, }, }, }); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7648,7 +7655,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7678,7 +7685,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7711,7 +7718,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7738,7 +7745,7 @@ Extract it into the Agent Skills directory.`, try { await act(async () => { const started = await harness.getValue().handleSend(); - expect(started).toBe(true); + expect(started).toMatchObject({ kind: "runtime-accepted" }); }); expect(mockSendMessage).toHaveBeenCalledTimes(1); @@ -7754,4 +7761,38 @@ Extract it into the Agent Skills directory.`, harness.unmount(); } }); + it("local skill install confirmation keeps the owned composer draft", async () => { + const harness = mountHook(); + const controller = new ComposerController({ + text: 'Download and install a skill.\nSKILL_NAME="viral-content-breakdown"\nDOWNLOAD_URL="https://limeai.run/skill-packages/viral-content-breakdown/latest/viral-content-breakdown.zip"\nExtract it into the Agent Skills directory.', + }); + try { + const before = controller.captureDraft(); + mockSetInput.mockClear(); + mockSetMentionedCharacters.mockClear(); + mockFinalizeAfterSendSuccess.mockClear(); + let result!: RuntimeSubmissionResult; + await act(async () => { + result = await harness + .getValue() + .handleSend( + [], + false, + false, + controller.getDocument().text, + "react", + undefined, + { composerDraft: before }, + ); + }); + expect(result).toEqual({ kind: "local-handled" }); + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(mockFinalizeAfterSendSuccess).not.toHaveBeenCalled(); + expect(mockSetInput).not.toHaveBeenCalledWith(""); + expect(mockSetMentionedCharacters).not.toHaveBeenCalled(); + expect(controller.captureDraft()).toEqual(before); + } finally { + harness.unmount(); + } + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts index 077f64018..7e8c93473 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.ts @@ -1,3 +1,9 @@ +import { + submitWorkspaceMessage, + submitWorkspaceLocalConfirmation, + withWorkspaceSubmissionCleanup, +} from "./workspaceSendSubmission"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -195,7 +201,7 @@ export type WorkspaceHandleSend = ( sendExecutionStrategy?: CurrentExecutionStrategy, autoContinuePayload?: AutoContinueRequestPayload, sendOptions?: HandleSendOptions, -) => Promise; +) => Promise; type SetStringState = (value: string) => void; type ParsedImageWorkbenchCommand = NonNullable< ReturnType @@ -326,6 +332,7 @@ async function resolveImageCommandProjectContext(params: { } interface WorkspaceLocalConfirmationPlan { + preserveDraft: boolean; sourceText: string; images: MessageImage[]; sendBoundary: GeneralWorkbenchSendBoundaryState; @@ -336,7 +343,7 @@ interface WorkspaceLocalConfirmationPlan { type WorkspaceSendResolution = | { kind: "done"; - result: boolean; + result: RuntimeSubmissionResult; } | { kind: "local_confirmation"; @@ -461,7 +468,7 @@ export function useWorkspaceSendActions({ logAgentDebug("WorkspaceSend", "plan.empty", { durationMs: Date.now() - planStartedAt, }); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } let effectiveImages = images || []; const sendBoundary = resolveSendBoundary({ @@ -792,6 +799,7 @@ export function useWorkspaceSendActions({ return { kind: "local_confirmation", plan: { + preserveDraft: Boolean(sendOptions?.composerDraft), sourceText, images: effectiveImages, sendBoundary, @@ -829,13 +837,13 @@ export function useWorkspaceSendActions({ if (!imageCommandProjectContext.projectRootPath) { clearSubmissionPreview(); toast.error("默认项目目录未就绪,暂时无法创建图片任务"); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } if (prepareImageWorkbenchSkillSend) { const prepared = await prepareImageWorkbenchSkillSend(); if (!prepared) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } } const imageDispatchText = @@ -851,7 +859,7 @@ export function useWorkspaceSendActions({ }); if (!skillRequest) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } effectiveImages = skillRequest.images.length > 0 @@ -909,13 +917,13 @@ export function useWorkspaceSendActions({ if (!imageCommandProjectContext.projectRootPath) { clearSubmissionPreview(); toast.error("默认项目目录未就绪,暂时无法创建图片任务"); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } if (prepareImageWorkbenchSkillSend) { const prepared = await prepareImageWorkbenchSkillSend(); if (!prepared) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } } const skillRequest = resolveImageWorkbenchCommandRequest({ @@ -940,7 +948,7 @@ export function useWorkspaceSendActions({ }); if (!skillRequest) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } effectiveImages = skillRequest.images.length > 0 @@ -990,7 +998,7 @@ export function useWorkspaceSendActions({ }); if (!requestContext) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } pendingCommandSessionBinding = { kind: "request_context", @@ -1036,7 +1044,7 @@ export function useWorkspaceSendActions({ }); if (!requestContext) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } pendingCommandSessionBinding = { kind: "request_context", @@ -1233,7 +1241,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -1284,7 +1292,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -1334,7 +1342,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -1385,7 +1393,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -1436,7 +1444,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } effectiveWebSearch = false; sendOptions = { @@ -1488,7 +1496,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -1542,7 +1550,7 @@ export function useWorkspaceSendActions({ contentId, }); if (!requestContext) { - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } sendOptions = { ...(sendOptions || {}), @@ -2464,7 +2472,7 @@ export function useWorkspaceSendActions({ }); if (!voiceSkillLaunch) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } ensureSubmissionPreview(); @@ -2500,7 +2508,7 @@ export function useWorkspaceSendActions({ }); if (!growthSkillLaunch) { clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } ensureSubmissionPreview(); @@ -2570,11 +2578,11 @@ export function useWorkspaceSendActions({ if (error.gateRequest && openRuntimeSceneGate) { await openRuntimeSceneGate(error.gateRequest); clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } toast.error(error.message); clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } throw error; } @@ -2622,7 +2630,7 @@ export function useWorkspaceSendActions({ sendOptions?.observer?.onError?.("请先选择项目后再开始对话"); toast.error("请先选择项目后再开始对话"); clearSubmissionPreview(); - return { kind: "done", result: false }; + return { kind: "done", result: { kind: "rejected" } }; } const shouldPrimeSessionForInitialConversationSend = @@ -2768,35 +2776,42 @@ export function useWorkspaceSendActions({ ); const executeLocalConfirmationPlan = useCallback( - async (plan: WorkspaceLocalConfirmationPlan): Promise => { + async ( + plan: WorkspaceLocalConfirmationPlan, + ): Promise => { const { sourceText, images, sendBoundary, submissionPreviewKey } = plan; - setInput(""); - setMentionedCharacters([]); - setChatMessages((previous) => { - const timestamp = new Date(); - return [ - ...previous, - { - id: crypto.randomUUID(), - role: "user", - content: sourceText, - images: images.length > 0 ? images : undefined, - timestamp, - }, - { - id: crypto.randomUUID(), - role: "assistant", - content: plan.confirmation, - timestamp: new Date(), - }, - ]; + return submitWorkspaceLocalConfirmation({ + preserveDraft: plan.preserveDraft, + clearDraft: () => { + setInput(""); + setMentionedCharacters([]); + finalizeAfterSendSuccess(sendBoundary); + }, + publish: () => { + setChatMessages((previous) => { + const timestamp = new Date(); + return [ + ...previous, + { + id: crypto.randomUUID(), + role: "user", + content: sourceText, + images: images.length > 0 ? images : undefined, + timestamp, + }, + { + id: crypto.randomUUID(), + role: "assistant", + content: plan.confirmation, + timestamp: new Date(), + }, + ]; + }); + setSubmissionPreview((current) => + current?.key === submissionPreviewKey ? null : current, + ); + }, }); - - finalizeAfterSendSuccess(sendBoundary); - setSubmissionPreview((current) => - current?.key === submissionPreviewKey ? null : current, - ); - return true; }, [ finalizeAfterSendSuccess, @@ -2807,7 +2822,7 @@ export function useWorkspaceSendActions({ ); const executeSendPlan = useCallback( - async (plan: WorkspaceSendPlan): Promise => { + async (plan: WorkspaceSendPlan): Promise => { const { sourceText, dispatchText, @@ -2837,149 +2852,174 @@ export function useWorkspaceSendActions({ setMentionedCharacters([]); } - try { - let nextRequestMetadata = buildWorkspaceRequestMetadata({ - workspaceRequestMetadataBase, - savedSoulArtifactVoiceGenerationBrief, - soulArtifactVoiceEnabledForTurn, - sendOptions: { - ...(sendOptions || {}), - toolPreferencesOverride: effectiveToolPreferences, - }, - currentProviderType: providerType, - effectiveToolPreferences, - mappedTheme, - isThemeWorkbench, - currentGateKey, - themeWorkbenchActiveQueueTitle, - contentId, - browserRequirementMatch: browserRequirementForSend, - workspaceSkillBindings, - workspaceSkillRuntimeEnable, - agentResponseLanguage, - }); - const shouldRefreshServiceModels = - Boolean(resolveServiceModelsBeforeSend) && - shouldRefreshServiceModelsBeforeSend({ - requestMetadata: nextRequestMetadata, - purpose: sendOptions?.purpose, - }); - const serviceModelsForSend = shouldRefreshServiceModels - ? await resolveServiceModelsBeforeSend?.() - : null; - const effectiveServiceModels = - serviceModelsForSend?.serviceModels ?? serviceModels; - const effectiveAgentResponseLanguage = - serviceModelsForSend?.agentResponseLanguage ?? agentResponseLanguage; - if (shouldRefreshServiceModels) { - nextRequestMetadata = buildWorkspaceRequestMetadata({ - workspaceRequestMetadataBase, - savedSoulArtifactVoiceGenerationBrief, - soulArtifactVoiceEnabledForTurn, - sendOptions: { + return withWorkspaceSubmissionCleanup( + async () => { + try { + let nextRequestMetadata = buildWorkspaceRequestMetadata({ + workspaceRequestMetadataBase, + savedSoulArtifactVoiceGenerationBrief, + soulArtifactVoiceEnabledForTurn, + sendOptions: { + ...(sendOptions || {}), + toolPreferencesOverride: effectiveToolPreferences, + }, + currentProviderType: providerType, + effectiveToolPreferences, + mappedTheme, + isThemeWorkbench, + currentGateKey, + themeWorkbenchActiveQueueTitle, + contentId, + browserRequirementMatch: browserRequirementForSend, + workspaceSkillBindings, + workspaceSkillRuntimeEnable, + agentResponseLanguage, + }); + const shouldRefreshServiceModels = + Boolean(resolveServiceModelsBeforeSend) && + shouldRefreshServiceModelsBeforeSend({ + requestMetadata: nextRequestMetadata, + purpose: sendOptions?.purpose, + }); + const serviceModelsForSend = shouldRefreshServiceModels + ? await resolveServiceModelsBeforeSend?.() + : null; + const effectiveServiceModels = + serviceModelsForSend?.serviceModels ?? serviceModels; + const effectiveAgentResponseLanguage = + serviceModelsForSend?.agentResponseLanguage ?? + agentResponseLanguage; + if (shouldRefreshServiceModels) { + nextRequestMetadata = buildWorkspaceRequestMetadata({ + workspaceRequestMetadataBase, + savedSoulArtifactVoiceGenerationBrief, + soulArtifactVoiceEnabledForTurn, + sendOptions: { + ...(sendOptions || {}), + toolPreferencesOverride: effectiveToolPreferences, + }, + currentProviderType: providerType, + effectiveToolPreferences, + mappedTheme, + isThemeWorkbench, + currentGateKey, + themeWorkbenchActiveQueueTitle, + contentId, + browserRequirementMatch: browserRequirementForSend, + workspaceSkillBindings, + workspaceSkillRuntimeEnable, + agentResponseLanguage: effectiveAgentResponseLanguage, + }); + } + const serviceModelSendOverrides = resolveServiceModelSendOverrides({ + requestMetadata: nextRequestMetadata, + purpose: sendOptions?.purpose, + serviceModels: effectiveServiceModels, + }); + const nextAssistantDraft = + sendOptions?.assistantDraft ?? + buildImageWorkbenchAssistantDraft(nextRequestMetadata); + const nextSendOptions: HandleSendOptions = { ...(sendOptions || {}), - toolPreferencesOverride: effectiveToolPreferences, - }, - currentProviderType: providerType, - effectiveToolPreferences, - mappedTheme, - isThemeWorkbench, - currentGateKey, - themeWorkbenchActiveQueueTitle, - contentId, - browserRequirementMatch: browserRequirementForSend, - workspaceSkillBindings, - workspaceSkillRuntimeEnable, - agentResponseLanguage: effectiveAgentResponseLanguage, - }); - } - const serviceModelSendOverrides = resolveServiceModelSendOverrides({ - requestMetadata: nextRequestMetadata, - purpose: sendOptions?.purpose, - serviceModels: effectiveServiceModels, - }); - const nextAssistantDraft = - sendOptions?.assistantDraft ?? - buildImageWorkbenchAssistantDraft(nextRequestMetadata); - const nextSendOptions: HandleSendOptions = { - ...(sendOptions || {}), - displayContent: - dispatchText !== sourceText - ? (sendOptions?.displayContent ?? sourceText) - : sendOptions?.displayContent, - requestMetadata: withConfiguredModelSlots( - nextRequestMetadata, - effectiveServiceModels, + displayContent: + dispatchText !== sourceText + ? (sendOptions?.displayContent ?? sourceText) + : sendOptions?.displayContent, + requestMetadata: withConfiguredModelSlots( + nextRequestMetadata, + effectiveServiceModels, + ), + ...(effectiveSearchMode + ? { searchMode: effectiveSearchMode } + : {}), + providerOverride: + sendOptions?.providerOverride ?? + serviceModelSendOverrides.providerOverride, + modelOverride: + sendOptions?.modelOverride ?? + serviceModelSendOverrides.modelOverride, + systemPromptOverride: sendOptions?.systemPromptOverride, + assistantDraft: nextAssistantDraft, + }; + + logAgentDebug("WorkspaceSend", "sendMessage.start", { + durationMs: Date.now() - executeStartedAt, + responsiveModelSlotConfigured: Boolean( + effectiveServiceModels?.responsive_chat?.enabled !== false && + effectiveServiceModels?.responsive_chat?.preferredProviderId && + effectiveServiceModels?.responsive_chat?.preferredModelId, + ), + modelOverride: nextSendOptions.modelOverride ?? null, + providerOverride: nextSendOptions.providerOverride ?? null, + }); + return await submitWorkspaceMessage({ + observer: sendOptions?.observer, + submit: (observer) => + sendMessage( + text, + images, + effectiveWebSearch, + undefined, + false, + sendExecutionStrategy, + undefined, + autoContinuePayload, + { ...nextSendOptions, observer }, + ), + onAccepted: () => { + logAgentDebug("WorkspaceSend", "sendMessage.done", { + durationMs: Date.now() - executeStartedAt, + }); + + if (completedMentionCommandUsage) { + recordMentionEntryUsage({ + kind: "builtin_command", + entryId: completedMentionCommandUsage.entryId, + replayText: completedMentionCommandUsage.replayText, + slotValues: completedMentionCommandUsage.slotValues, + }); + } + + if (completedMentionUsage) { + recordServiceSkillUsage(completedMentionUsage); + } + + if (plan.completedSlashUsage) { + recordSlashEntryUsage(plan.completedSlashUsage); + } + + finalizeAfterSendSuccess(sendBoundary); + }, + onRejected: (error) => { + if (!sendOptions?.composerDraft) + rollbackAfterSendFailure(sendBoundary); + const errorMessage = + error instanceof Error ? error.message : String(error); + sendOptions?.observer?.onError?.(errorMessage); + console.error("[AgentChat] 发送消息失败:", error); + toast.error( + resolveAgentRuntimeSubmitErrorMessage(errorMessage), + ); + if (!sendOptions?.composerDraft) setInput(sourceText); + }, + }); + } catch (error) { + if (!sendOptions?.composerDraft) + rollbackAfterSendFailure(sendBoundary); + const errorMessage = + error instanceof Error ? error.message : String(error); + sendOptions?.observer?.onError?.(errorMessage); + console.error("[AgentChat] 发送消息失败:", error); + toast.error(resolveAgentRuntimeSubmitErrorMessage(errorMessage)); + if (!sendOptions?.composerDraft) setInput(sourceText); + return { kind: "rejected", error }; + } + }, + () => + setSubmissionPreview((current) => + current?.key === submissionPreviewKey ? null : current, ), - ...(effectiveSearchMode ? { searchMode: effectiveSearchMode } : {}), - providerOverride: - sendOptions?.providerOverride ?? - serviceModelSendOverrides.providerOverride, - modelOverride: - sendOptions?.modelOverride ?? - serviceModelSendOverrides.modelOverride, - systemPromptOverride: sendOptions?.systemPromptOverride, - assistantDraft: nextAssistantDraft, - }; - - logAgentDebug("WorkspaceSend", "sendMessage.start", { - durationMs: Date.now() - executeStartedAt, - responsiveModelSlotConfigured: Boolean( - effectiveServiceModels?.responsive_chat?.enabled !== false && - effectiveServiceModels?.responsive_chat?.preferredProviderId && - effectiveServiceModels?.responsive_chat?.preferredModelId, - ), - modelOverride: nextSendOptions.modelOverride ?? null, - providerOverride: nextSendOptions.providerOverride ?? null, - }); - await sendMessage( - text, - images, - effectiveWebSearch, - undefined, - false, - sendExecutionStrategy, - undefined, - autoContinuePayload, - nextSendOptions, - ); - logAgentDebug("WorkspaceSend", "sendMessage.done", { - durationMs: Date.now() - executeStartedAt, - }); - - if (completedMentionCommandUsage) { - recordMentionEntryUsage({ - kind: "builtin_command", - entryId: completedMentionCommandUsage.entryId, - replayText: completedMentionCommandUsage.replayText, - slotValues: completedMentionCommandUsage.slotValues, - }); - } - - if (completedMentionUsage) { - recordServiceSkillUsage(completedMentionUsage); - } - - if (plan.completedSlashUsage) { - recordSlashEntryUsage(plan.completedSlashUsage); - } - - finalizeAfterSendSuccess(sendBoundary); - return true; - } catch (error) { - if (!sendOptions?.composerDraft) rollbackAfterSendFailure(sendBoundary); - const errorMessage = - error instanceof Error ? error.message : String(error); - sendOptions?.observer?.onError?.(errorMessage); - console.error("[AgentChat] 发送消息失败:", error); - toast.error(resolveAgentRuntimeSubmitErrorMessage(errorMessage)); - if (!sendOptions?.composerDraft) setInput(sourceText); - return false; - } finally { - setSubmissionPreview((current) => - current?.key === submissionPreviewKey ? null : current, - ); - } + ); }, [ agentResponseLanguage, @@ -3016,33 +3056,36 @@ export function useWorkspaceSendActions({ sendOptions, ) => { if (isPreparingSendRef.current) { - return false; + return { kind: "rejected" }; } isPreparingSendRef.current = true; setIsPreparingSend(true); - try { - const resolution = await resolveSendExecutionPlan( - images, - webSearch, - thinking, - textOverride, - sendExecutionStrategy, - autoContinuePayload, - sendOptions, - ); - if (resolution.kind === "done") { - return resolution.result; - } - if (resolution.kind === "local_confirmation") { - return await executeLocalConfirmationPlan(resolution.plan); - } - return await executeSendPlan(resolution.plan); - } finally { - isPreparingSendRef.current = false; - setIsPreparingSend(false); - } + return withWorkspaceSubmissionCleanup( + async () => { + const resolution = await resolveSendExecutionPlan( + images, + webSearch, + thinking, + textOverride, + sendExecutionStrategy, + autoContinuePayload, + sendOptions, + ); + if (resolution.kind === "done") { + return resolution.result; + } + if (resolution.kind === "local_confirmation") { + return await executeLocalConfirmationPlan(resolution.plan); + } + return await executeSendPlan(resolution.plan); + }, + () => { + isPreparingSendRef.current = false; + setIsPreparingSend(false); + }, + ); }, [executeLocalConfirmationPlan, executeSendPlan, resolveSendExecutionPlan], ); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendMetadata.stopRestore.integration.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendMetadata.stopRestore.integration.test.tsx new file mode 100644 index 000000000..817e432f9 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceSendMetadata.stopRestore.integration.test.tsx @@ -0,0 +1,492 @@ +import React, { act, createRef, useCallback, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + harness, + a, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import { useComposerController } from "@/components/input-kit/useComposerController"; +import { + ComposerEditor, + type ComposerEditorHandle, +} from "@/components/input-kit/ComposerEditor"; +import { useInputbarController } from "../components/Inputbar/hooks/useInputbarController"; +import type { InputbarSendPayload } from "../components/Inputbar/inputbarSendPayload"; +import type { Inputbar } from "../components/Inputbar"; +import { insertComposerSkill } from "../skill-selection/composerCapabilityMention"; +import { resolveInputCapabilitySendRoute } from "../skill-selection/inputCapabilitySelection"; +import { createAgentChatSendMessage } from "../hooks/agentChatSendMessage"; +import { createRestoreTokenFixture } from "../hooks/agentStreamRestoreToken.testFixtures"; +import { useAgentStream } from "../hooks/useAgentStream"; +import type { InterruptedInputRestoreRequest } from "../hooks/agentStreamInputRestoreTypes"; +import { clearAllAgentStreamTextOverlays } from "../hooks/agentStreamTextOverlayStore"; +import type { + RawSendMessageFn, + SendMessageOptions, +} from "../hooks/agentChatShared"; +import type { Skill } from "@/lib/api/skills"; +import { activityLogger } from "@/lib/workspace/workbenchRuntime"; +import { useWorkspaceSendActions } from "./useWorkspaceSendActions"; +import { useWorkspaceInputbarSceneRuntime } from "./useWorkspaceInputbarSceneRuntime"; +import { + captureCanonicalTurnEvents, + createSceneProps, +} from "./stopSendChain.testFixtures"; + +const observed = vi.hoisted(() => ({ + controller: undefined as + | ReturnType< + typeof import("../hooks/useAgentStreamController").useAgentStreamController + > + | undefined, + inputbar: undefined as ReturnType | undefined, + payload: undefined as InputbarSendPayload | undefined, + acceptance: undefined as ReturnType | undefined, +})); + +vi.mock("../hooks/useAgentStreamController", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + // Observation only: the real hook, state, ref and methods are returned unchanged. + useAgentStreamController: ( + ...args: Parameters + ) => { + const value = actual.useAgentStreamController(...args); + observed.controller = value; + return value; + }, + }; +}); +vi.mock("../components/Inputbar", () => ({ + // Replace presentation only; Controller send, settlement and restore effects stay real. + Inputbar: (props: React.ComponentProps) => { + const runtime = useInputbarController({ + ...props, + onSend: async (payload = {}) => { + observed.payload = payload; + if (!payload.onRuntimeAccepted) + throw new Error("Missing real settlement observer"); + const acceptance = vi.fn(payload.onRuntimeAccepted); + observed.acceptance = acceptance; + return props.onSend({ ...payload, onRuntimeAccepted: acceptance }); + }, + }); + observed.inputbar = runtime; + return ( + + ); + }, +})); +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/api/promptHistory", () => ({ + readPromptHistory: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/api/hintRoutes", () => ({ + listHintRoutes: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("@/lib/api/permissionProfiles", () => ({ + resolveAllowedPermissionProfile: vi.fn().mockResolvedValue({ allowed: true }), +})); +vi.mock("@/lib/api/modelRegistry", () => ({ + modelRegistryApi: { + getModelRegistry: vi.fn(async () => [ + { + id: "fixture", + provider_id: "openai", + capabilities: { vision: true, reasoning: false }, + task_families: ["chat", "vision_understanding"], + input_modalities: ["text", "image"], + output_modalities: ["text"], + runtime_features: ["streaming"], + capability_provenance: "provider_explicit", + }, + ]), + }, +})); +vi.mock("@/lib/api/appConfig", () => ({ getConfig: vi.fn(async () => ({})) })); +vi.mock("../hooks/usePendingInteractions", () => ({ + usePendingInteractions: () => ({ interactions: [], respond: vi.fn() }), +})); +vi.mock("../hooks/useStrictReviewStatus", () => ({ + useStrictReviewStatus: () => null, +})); +vi.mock("./WorkspaceHarnessDialogs", () => ({ + GeneralWorkbenchDialogSection: () => null, +})); +vi.mock("./knowledge/useWorkspaceKnowledgeRuntime", () => ({ + useWorkspaceKnowledgeRuntime: () => ({ + knowledgePackSelection: null, + knowledgePackOptions: [], + }), +})); + +const skill: Skill = { + key: "user:capability-report", + name: "Capability Report", + description: "Report", + directory: "/tmp/capability-report", + installed: true, + sourceKind: "other", +}; +type PersistenceHarness = Awaited>; +const mounted: { + root: ReturnType; + element: HTMLElement; + h: PersistenceHarness; + detach: () => void; +}[] = []; +const noop = () => undefined; +const rangeDescriptors = new Map( + ["getClientRects", "getBoundingClientRect"].map( + (name) => + [name, Object.getOwnPropertyDescriptor(Range.prototype, name)] as const, + ), +); + +beforeEach(() => { + vi.useFakeTimers(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + observed.controller = undefined; + observed.inputbar = undefined; + observed.payload = undefined; + observed.acceptance = undefined; + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 1, 1), + }); +}); +afterEach(async () => { + for (const m of mounted.splice(0)) { + act(() => { + m.detach(); + m.root.unmount(); + }); + m.element.remove(); + await m.h.close(); + } + activityLogger.clear(); + clearAllAgentStreamTextOverlays(); + vi.useRealTimers(); + for (const [name, descriptor] of rangeDescriptors) { + if (descriptor) Object.defineProperty(Range.prototype, name, descriptor); + else Reflect.deleteProperty(Range.prototype, name); + } +}); + +async function setup(withEditor = false) { + const h = await harness({ safeInvoke, safeListen }); + await h.coordinator.bindCommitted(a); + h.controller.setText("原始中😀 "); + h.controller.ingestHTML( + '

链接中😀

const value = "中😀";
', + ); + h.controller.ingestPaste("历史粘贴验证😀 ".repeat(180)); + insertComposerSkill(h.controller, skill); + h.controller.setInputCapabilityRoute( + resolveInputCapabilitySendRoute({ kind: "installed_skill", skill }), + ); + h.controller.setAttachments([ + { data: "cGljdHVyZQ==", mediaType: "image/png" }, + ]); + h.controller.setPathReferences([ + { + id: "file:/tmp/composer-reference.md", + path: "/tmp/composer-reference.md", + name: "composer-reference.md", + isDir: false, + source: "file_manager", + }, + ]); + h.controller.setSelection(1, 4); + const original = h.controller.captureDraft(); + await h.coordinator.idle(); + const f = createRestoreTokenFixture(); + const turnEvents = captureCanonicalTurnEvents(f.runtime); + const editorRef = createRef(); + let stream!: ReturnType; + let rawOptions: SendMessageOptions | undefined; + let stopping: Promise | undefined; + const restoreRequests: InterruptedInputRestoreRequest[] = []; + const rootElement = document.createElement("div"); + document.body.appendChild(rootElement); + const root = createRoot(rootElement); + + function Harness() { + const { snapshot } = useComposerController({ controller: h.controller }); + const [request, setRequest] = + useState(null); + const [currentTurnId, setCurrentTurnId] = useState(null); + const updateCurrentTurnId = useCallback( + (value: React.SetStateAction) => { + f.env.setCurrentTurnId(value); + setCurrentTurnId(f.state.currentTurnId); + }, + [], + ); + const setInput = useCallback( + (value: string) => h.controller.setText(value), + [], + ); + const onRestore = useCallback((value: InterruptedInputRestoreRequest) => { + restoreRequests.push(value); + setRequest(value); + }, []); + const onHandled = useCallback( + (requestId: string) => + setRequest((value) => (value?.requestId === requestId ? null : value)), + [], + ); + stream = useAgentStream({ + ...f.env, + waitForSessionProviderSelectionSync: + f.env.waitForSessionProviderSelectionSync!, + setTodoItems: f.env.setTodoItems!, + currentAssistantMsgIdRef: React.useRef(null), + currentStreamingSessionIdRef: React.useRef(null), + currentStreamingEventNameRef: React.useRef(null), + getMessages: () => f.state.messages, + executionRuntime: null, + threadBusy: false, + currentTurnId, + setCurrentTurnId: updateCurrentTurnId, + threadTurns: f.state.turns, + onRestoreInterruptedInput: onRestore, + }); + const rawSendMessage: RawSendMessageFn = (...args) => { + rawOptions = args[8]; + return stream.sendMessage(...args); + }; + const sendMessage = createAgentChatSendMessage({ + rawSendMessage, + baseStatusSnapshot: { + sessionId: "session-1", + currentTurnId, + providerType: "openai", + model: "fixture", + executionStrategy: "react", + queuedTurnsCount: 0, + isSending: stream.isSending, + }, + compactSession: stream.compactSession, + clearMessages: noop, + createFreshSession: async () => null, + appendAssistantMessage: noop, + notifyInfo: noop, + notifySuccess: noop, + }); + const send = useWorkspaceSendActions({ + input: snapshot.document.text, + setInput, + mentionedCharacters: [], + setMentionedCharacters: noop, + chatToolPreferences: { task: false, subagent: false }, + setChatToolPreferences: noop, + serviceSkills: [], + activeTheme: "general", + mappedTheme: "general", + isThemeWorkbench: false, + contextWorkspace: { + enabled: false, + activeContextPrompt: "", + prepareActiveContextPrompt: async () => "", + }, + projectId: "workspace-1", + projectRootPath: "/workspace", + sessionId: "session-1", + executionStrategy: "react", + accessMode: "current", + currentGateKey: "default_gate", + contentId: null, + messages: f.state.messages, + setChatMessages: f.env.setMessages, + sendMessage, + ensureSessionForCommandMetadata: f.env.ensureSession, + resolveSendBoundary: ({ sourceText }) => ({ + sourceText, + browserRequirementMatch: null, + shouldConsumePendingGeneralWorkbenchInitialPrompt: false, + shouldDismissGeneralWorkbenchEntryPrompt: false, + }), + finalizeAfterSendSuccess: noop, + rollbackAfterSendFailure: noop, + resolveImageWorkbenchCommandRequest: () => null, + }); + const scene = useWorkspaceInputbarSceneRuntime( + createSceneProps({ + composerController: h.controller, + composerDraftCoordinator: h.coordinator, + sessionId: "session-1", + threadId: "thread-1", + projectId: "workspace-1", + projectRootPath: "/workspace", + isThemeWorkbench: false, + input: snapshot.document.text, + setInput, + skills: [skill], + handleSend: send.handleSend, + isPreparingSend: send.isPreparingSend, + isSending: stream.isSending, + inputRestoreRequest: request, + onInputRestoreRequestHandled: onHandled, + pathReferences: [...snapshot.document.pathReferences], + onClearPathReferences: () => h.controller.setPathReferences([]), + handleStopSending: () => { + stopping = stream.stopSending(); + }, + providerType: "openai", + model: "fixture", + }), + ); + return ( + <> + {scene.inputbarNode} + {withEditor && ( + + )} + + ); + } + act(() => root.render()); + mounted.push({ + root, + element: rootElement, + h, + detach: () => stream.detachStreamBindings(), + }); + return { + h, + f, + original, + restoreRequests, + editorRef, + get rawOptions() { + return rawOptions; + }, + get active() { + return observed.controller!.activeStreamRef.current; + }, + detach: () => act(() => stream.detachStreamBindings()), + async send() { + await act(async () => { + const result = await observed.inputbar!.handleSend(); + expect(result).toMatchObject({ kind: "runtime-accepted" }); + turnEvents.emitTurnStarted(original.text); + await h.coordinator.idle(); + }); + expect(f.state.currentTurnId).toBe("turn-1"); + }, + async stop() { + await act(async () => { + rootElement + .querySelector("button[data-testid=stop]")! + .click(); + await stopping; + }); + // Restore is applied by the real Inputbar effect after the Stop update commits. + await act(async () => { + await h.coordinator.idle(); + }); + }, + }; +} + +describe("Workspace rich send metadata full chain", () => { + it.each([false, true])( + "retains the real token through scene/workspace/chat/stream and public Stop (detached=%s)", + async (detached) => { + const s = await setup(); + await s.send(); + const payload = observed.payload!; + const token = payload.composerRestoreToken; + expect(token).toBeDefined(); + expect(payload.composerDraft).toEqual(s.original); + expect(s.rawOptions?.composerRestoreToken).toBe(token); + expect(s.rawOptions?.composerDraft).toEqual(s.original); + expect(s.rawOptions?.capabilityRoute).toEqual( + s.original.inputCapabilityRoute, + ); + expect(s.active?.composerRestoreToken).toBe(token); + expect(s.active?.submittedDraft).toMatchObject(s.original); + expect(observed.acceptance).toHaveBeenCalledOnce(); + expect( + JSON.stringify(s.f.runtime.submitOp.mock.calls[0][0]).includes( + "composerRestoreToken", + ), + ).toBe(false); + expect(s.h.controller.getDocument().text).toBe(""); + expect(await s.h.read(a)).toBeNull(); + if (detached) s.detach(); + await s.stop(); + expect(s.f.runtime.interruptTurn).toHaveBeenCalledOnce(); + expect(s.restoreRequests).toHaveLength(1); + expect(s.restoreRequests[0].composerRestoreToken).toBe(token); + expect(s.restoreRequests[0].reason).toBe( + detached + ? "output_free_interrupted_turn" + : "thinking_only_cancelled_turn", + ); + expect(s.restoreRequests[0].draft).toMatchObject(s.original); + expect(s.h.controller.captureDraft()).toEqual(s.original); + expect(observed.acceptance).toHaveBeenCalledOnce(); + expect((await s.h.read(a)) === null).toBe(false); + }, + ); + + it("mounts real EditorView and verifies accepted clear, disabled DOM focus, editable Stop restore", async () => { + const s = await setup(true); + const editor = s.editorRef.current!.getElement()!; + expect(editor.getAttribute("contenteditable")).toBe("true"); + expect(editor.textContent).toContain("原始中😀"); + expect(editor.querySelector("pre") === null).toBe(false); + expect(editor.querySelector('[data-composer-token="skill"]') === null).toBe( + false, + ); + act(() => s.editorRef.current!.focus()); + expect(document.activeElement).toBe(editor); + await s.send(); + const token = observed.payload!.composerRestoreToken; + expect(token).toBeDefined(); + expect(s.rawOptions?.composerRestoreToken).toBe(token); + expect(s.active?.composerRestoreToken).toBe(token); + expect(observed.acceptance).toHaveBeenCalledOnce(); + expect(editor.textContent).toBe(""); + expect(editor.getAttribute("contenteditable")).toBe("false"); + expect(editor.getAttribute("aria-disabled")).toBe("true"); + act(() => { + editor.blur(); + editor.focus(); + }); + expect(document.activeElement).toBe(editor); + await s.stop(); + expect(s.f.runtime.interruptTurn).toHaveBeenCalledOnce(); + expect(s.restoreRequests).toHaveLength(1); + expect(s.restoreRequests[0].composerRestoreToken).toBe(token); + expect(s.h.controller.captureDraft()).toEqual(s.original); + expect(editor.getAttribute("contenteditable")).toBe("true"); + expect(editor.getAttribute("aria-disabled")).toBe("false"); + expect(editor.textContent).toContain("原始中😀"); + act(() => s.editorRef.current!.focus()); + expect(document.activeElement).toBe(editor); + expect(observed.acceptance).toHaveBeenCalledOnce(); + expect((await s.h.read(a)) === null).toBe(false); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendStructured.unit.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendStructured.unit.test.tsx index 830a7adbb..063c0e7bc 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendStructured.unit.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendStructured.unit.test.tsx @@ -7,6 +7,8 @@ import { type WorkspaceHandleSend, } from "./useWorkspaceSendActions"; import { changeLimeLocale } from "@/i18n/createI18n"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; +import { acceptedSubmission } from "../hooks/agentSendResult.testFixtures"; vi.mock("../skill-selection/runtimeInputCapabilityCatalog", () => ({ useRuntimeMentionCommandCatalog: () => ({ @@ -17,119 +19,142 @@ vi.mock("../skill-selection/runtimeInputCapabilityCatalog", () => ({ })); describe("structured workspace acceptance", () => { - it.each([true, false])("runtime acceptance %s holds the dispatch lock and preserves newer rich input", async (accepted) => { - await changeLimeLocale("zh-CN"); - ( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true; - vi.spyOn(console, "error").mockImplementation(() => {}); - const controller = new ComposerController({ text: "A😀 " }); - controller.setSelection(4); - controller.ingestPaste("完整粘贴".repeat(300)); - const receipt = controller.submit("start"); - let finishSend!: (accepted: boolean) => void; - const pending = new Promise((resolve, reject) => { - finishSend = (value) => value ? resolve() : reject(new Error("runtime rejected")); - }); - const sendMessage = vi.fn< - Parameters[0]["sendMessage"] - >(() => pending); - const setInput = vi.fn((text: string) => controller.setText(text)); - const rollbackAfterSendFailure = vi.fn(); - const finalizeAfterSendSuccess = vi.fn(); - let handleSend!: WorkspaceHandleSend; - let preparing = false; - const props: Parameters[0] = { - input: receipt.draft.text, - setInput, - mentionedCharacters: [], - setMentionedCharacters: vi.fn(), - chatToolPreferences: { task: false, subagent: false }, - setChatToolPreferences: vi.fn(), - serviceSkills: [], - activeTheme: "general", - mappedTheme: "general", - isThemeWorkbench: false, - contextWorkspace: { - enabled: false, - activeContextPrompt: "", - prepareActiveContextPrompt: async () => "", - }, - projectId: "project-1", - projectRootPath: "/workspace", - sessionId: "session-1", - executionStrategy: "react", - accessMode: "current", - currentGateKey: "default_gate", - contentId: null, - messages: [], - setChatMessages: vi.fn(), - bootstrapDispatchPreview: null, - sendMessage, - resolveSendBoundary: (({ sourceText }) => ({ - sourceText, - browserRequirementMatch: null, - shouldConsumePendingGeneralWorkbenchInitialPrompt: false, - shouldDismissGeneralWorkbenchEntryPrompt: false, - })) as Parameters< - typeof useWorkspaceSendActions - >[0]["resolveSendBoundary"], - finalizeAfterSendSuccess, - rollbackAfterSendFailure, - resolveImageWorkbenchCommandRequest: () => null, - }; - function Harness() { - const runtime = useWorkspaceSendActions(props); - handleSend = runtime.handleSend; - preparing = runtime.isPreparingSend; - return null; - } - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - try { - act(() => root.render()); - let completion!: Promise; - await act(async () => { - completion = handleSend( - [], - undefined, - undefined, - receipt.submission.text, - "react", - undefined, - { - composerDraft: receipt.draft, - composerSubmission: receipt.submission, - skipWorkspaceCommandRouting: true, - }, + it.each([true, false])( + "runtime acceptance %s holds the dispatch lock and preserves newer rich input", + async (accepted) => { + await changeLimeLocale("zh-CN"); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = new ComposerController({ text: "A😀 " }); + controller.setSelection(4); + controller.ingestPaste("完整粘贴".repeat(300)); + const receipt = controller.submit("start"); + const acceptedResult = acceptedSubmission(); + const rejection = new Error("runtime rejected"); + let finishSend!: (accepted: boolean) => void; + const pending = new Promise( + (resolve, reject) => { + finishSend = (value) => + value ? resolve(acceptedResult) : reject(rejection); + }, + ); + const sendMessage = vi.fn< + Parameters[0]["sendMessage"] + >(() => pending); + const setInput = vi.fn((text: string) => controller.setText(text)); + const rollbackAfterSendFailure = vi.fn(); + const finalizeAfterSendSuccess = vi.fn(); + let handleSend!: WorkspaceHandleSend; + let preparing = false; + const props: Parameters[0] = { + input: receipt.draft.text, + setInput, + mentionedCharacters: [], + setMentionedCharacters: vi.fn(), + chatToolPreferences: { task: false, subagent: false }, + setChatToolPreferences: vi.fn(), + serviceSkills: [], + activeTheme: "general", + mappedTheme: "general", + isThemeWorkbench: false, + contextWorkspace: { + enabled: false, + activeContextPrompt: "", + prepareActiveContextPrompt: async () => "", + }, + projectId: "project-1", + projectRootPath: "/workspace", + sessionId: "session-1", + executionStrategy: "react", + accessMode: "current", + currentGateKey: "default_gate", + contentId: null, + messages: [], + setChatMessages: vi.fn(), + bootstrapDispatchPreview: null, + sendMessage, + resolveSendBoundary: (({ sourceText }) => ({ + sourceText, + browserRequirementMatch: null, + shouldConsumePendingGeneralWorkbenchInitialPrompt: false, + shouldDismissGeneralWorkbenchEntryPrompt: false, + })) as Parameters< + typeof useWorkspaceSendActions + >[0]["resolveSendBoundary"], + finalizeAfterSendSuccess, + rollbackAfterSendFailure, + resolveImageWorkbenchCommandRequest: () => null, + }; + function Harness() { + const runtime = useWorkspaceSendActions(props); + handleSend = runtime.handleSend; + preparing = runtime.isPreparingSend; + return null; + } + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + try { + act(() => root.render()); + let completion!: ReturnType; + await act(async () => { + completion = handleSend( + [], + undefined, + undefined, + receipt.submission.text, + "react", + undefined, + { + composerDraft: receipt.draft, + composerSubmission: receipt.submission, + skipWorkspaceCommandRouting: true, + }, + ); + await Promise.resolve(); + }); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(preparing).toBe(true); + expect( + await handleSend( + [], + undefined, + undefined, + receipt.submission.text, + "react", + undefined, + { + composerDraft: receipt.draft, + composerSubmission: receipt.submission, + skipWorkspaceCommandRouting: true, + }, + ), + ).toEqual({ kind: "rejected" }); + expect(sendMessage).toHaveBeenCalledOnce(); + expect(controller.captureDraft()).toEqual(receipt.draft); + expect(setInput).not.toHaveBeenCalled(); + controller.setText("新的文档😀"); + await act(async () => { + finishSend(accepted); + await completion; + }); + expect(await completion).toEqual( + accepted ? acceptedResult : { kind: "rejected", error: rejection }, ); - await Promise.resolve(); - }); - expect(sendMessage).toHaveBeenCalledOnce(); - expect(preparing).toBe(true); - expect(await handleSend([], undefined, undefined, receipt.submission.text, - "react", undefined, { composerDraft: receipt.draft, - composerSubmission: receipt.submission, - skipWorkspaceCommandRouting: true })).toBe(false); - expect(sendMessage).toHaveBeenCalledOnce(); - expect(controller.captureDraft()).toEqual(receipt.draft); - expect(setInput).not.toHaveBeenCalled(); - controller.setText("新的文档😀"); - await act(async () => { - finishSend(accepted); - await completion; - }); - expect(await completion).toBe(accepted); - expect(preparing).toBe(false); - expect(controller.getDocument().text).toBe("新的文档😀"); - expect(setInput).not.toHaveBeenCalled(); - expect(rollbackAfterSendFailure).not.toHaveBeenCalled(); - expect(finalizeAfterSendSuccess).toHaveBeenCalledTimes(accepted ? 1 : 0); - } finally { - act(() => root.unmount()); - container.remove(); - vi.restoreAllMocks(); - } - }); + expect(preparing).toBe(false); + expect(controller.getDocument().text).toBe("新的文档😀"); + expect(setInput).not.toHaveBeenCalled(); + expect(rollbackAfterSendFailure).not.toHaveBeenCalled(); + expect(finalizeAfterSendSuccess).toHaveBeenCalledTimes( + accepted ? 1 : 0, + ); + } finally { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + } + }, + ); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceSendSurfaceRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceSendSurfaceRuntime.ts index bac66a117..9addfd78e 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendSurfaceRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSendSurfaceRuntime.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, type MutableRefObject } from "react"; import { useWorkspaceImageWorkbenchRuntime } from "./useWorkspaceImageWorkbenchRuntime"; import { useWorkspaceSendActions } from "./useWorkspaceSendActions"; +import { isRuntimeSubmissionAccepted } from "../hooks/agentSendResult"; type ImageWorkbenchParams = Parameters< typeof useWorkspaceImageWorkbenchRuntime @@ -53,17 +54,19 @@ export function useWorkspaceSendSurfaceRuntime({ useEffect(() => { sceneGateResumeHandlerRef.current = async ({ rawText, requestMetadata }) => - await handleSendRef.current( - [], - undefined, - undefined, - rawText, - undefined, - undefined, - { - requestMetadata, - skipSceneCommandRouting: true, - }, + isRuntimeSubmissionAccepted( + await handleSendRef.current( + [], + undefined, + undefined, + rawText, + undefined, + undefined, + { + requestMetadata, + skipSceneCommandRouting: true, + }, + ), ); }, [handleSendRef, sceneGateResumeHandlerRef]); diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.test.tsx index 905b9a1b6..d6802752f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.test.tsx @@ -69,16 +69,20 @@ describe("useWorkspaceTaskCenterDraftStateRuntime", () => { expect(renderHarness().shouldHydrateEmptyMatchedInitialSession).toBe(true); }); - it("换会话重置共享草稿,匹配当前会话的 hydration 保留 revision", () => { + it("导航前保留草稿,实际提交后才清理 transient scope", () => { let resets = 0; - const resetComposerDraft = () => { resets += 1; }; + const resetComposerDraft = () => { + resets += 1; + }; const current = renderHarness({ resetComposerDraft }); - act(() => current.handleBeforeTopicSwitch("session-history")); - expect(resets).toBe(0); act(() => current.handleBeforeTopicSwitch("session-other")); + expect(resets).toBe(0); + act(() => current.handleTopicSwitchCommitted("session-history")); + expect(resets).toBe(0); + act(() => current.handleTopicSwitchCommitted("session-other")); expect(resets).toBe(1); - current.taskCenterDraftSurfaceActiveRef.current = true; - act(() => current.handleBeforeTopicSwitch("session-history")); + current.taskCenterHomeSurfaceActiveRef.current = true; + act(() => current.handleTopicSwitchCommitted("session-history")); expect(resets).toBe(2); }); @@ -95,24 +99,10 @@ describe("useWorkspaceTaskCenterDraftStateRuntime", () => { it("从 new-task 进入 Claw 时应在绘制前清理旧草稿激活状态", () => { const newTaskRuntime = renderHarness({ agentEntry: "new-task" }); - newTaskRuntime.taskCenterDraftSurfaceActiveRef.current = true; - act(() => { - newTaskRuntime.setActiveTaskCenterDraftTabId("draft-1"); - newTaskRuntime.setTaskCenterDraftTabs([ - { - id: "draft-1", - title: "新任务", - createdAt: new Date("2026-06-22T00:00:00.000Z"), - updatedAt: new Date("2026-06-22T00:00:00.000Z"), - status: "draft", - }, - ]); - }); + newTaskRuntime.taskCenterHomeSurfaceActiveRef.current = true; const clawRuntime = renderHarness({ agentEntry: "claw" }); - expect(clawRuntime.taskCenterDraftSurfaceActiveRef.current).toBe(false); - expect(clawRuntime.activeTaskCenterDraftTabId).toBeNull(); - expect(clawRuntime.taskCenterDraftTabs).toEqual([]); + expect(clawRuntime.taskCenterHomeSurfaceActiveRef.current).toBe(false); }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.ts index 0b15a9849..75f2d309f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterDraftStateRuntime.ts @@ -7,11 +7,9 @@ import { type MutableRefObject, type SetStateAction, } from "react"; +import type { ComposerDraftCoordinator } from "@/components/input-kit/ComposerDraftCoordinator"; import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; -import { - shouldPauseTaskCenterInitialSessionNavigation, - type TaskCenterDraftTab, -} from "./agentChatWorkspaceHelpers"; +import { shouldPauseTaskCenterInitialSessionNavigation } from "./agentChatWorkspaceHelpers"; interface UseWorkspaceTaskCenterDraftStateRuntimeParams { agentEntry?: string; @@ -23,25 +21,23 @@ interface UseWorkspaceTaskCenterDraftStateRuntimeParams { normalizedInitialSessionId: string | null; sessionId?: string | null; resetComposerDraft?: () => void; + composerDraftCoordinator?: ComposerDraftCoordinator; } interface WorkspaceTaskCenterDraftStateRuntime { - activeTaskCenterDraftTabId: string | null; homePendingPreviewRequest: TaskCenterDraftSendRequest | null; - setActiveTaskCenterDraftTabId: Dispatch>; setHomePendingPreviewRequest: Dispatch< SetStateAction >; setTaskCenterDraftSendRequest: Dispatch< SetStateAction >; - setTaskCenterDraftTabs: Dispatch>; shouldHydrateEmptyMatchedInitialSession: boolean; shouldPauseInitialSessionNavigationForTaskCenterDraft: boolean; taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - taskCenterDraftSurfaceActiveRef: MutableRefObject; - taskCenterDraftTabs: TaskCenterDraftTab[]; + taskCenterHomeSurfaceActiveRef: MutableRefObject; handleBeforeTopicSwitch: (topicId: string) => void; + handleTopicSwitchCommitted: (topicId: string) => void; } export function useWorkspaceTaskCenterDraftStateRuntime({ @@ -54,18 +50,13 @@ export function useWorkspaceTaskCenterDraftStateRuntime({ normalizedInitialSessionId, sessionId, resetComposerDraft, + composerDraftCoordinator, }: UseWorkspaceTaskCenterDraftStateRuntimeParams): WorkspaceTaskCenterDraftStateRuntime { const [taskCenterDraftSendRequest, setTaskCenterDraftSendRequest] = useState(null); const [homePendingPreviewRequest, setHomePendingPreviewRequest] = useState(null); - const taskCenterDraftSurfaceActiveRef = useRef(false); - const [taskCenterDraftTabs, setTaskCenterDraftTabs] = useState< - TaskCenterDraftTab[] - >([]); - const [activeTaskCenterDraftTabId, setActiveTaskCenterDraftTabId] = useState< - string | null - >(null); + const taskCenterHomeSurfaceActiveRef = useRef(!normalizedInitialSessionId); const previousAgentEntryRef = useRef(agentEntry); useLayoutEffect(() => { @@ -76,24 +67,33 @@ export function useWorkspaceTaskCenterDraftStateRuntime({ return; } - resetComposerDraft?.(); - taskCenterDraftSurfaceActiveRef.current = false; - setActiveTaskCenterDraftTabId(null); - setTaskCenterDraftTabs((current) => (current.length > 0 ? [] : current)); - }, [agentEntry, resetComposerDraft]); + composerDraftCoordinator?.prepareNavigation(); + if (!composerDraftCoordinator) resetComposerDraft?.(); + taskCenterHomeSurfaceActiveRef.current = false; + }, [agentEntry, composerDraftCoordinator, resetComposerDraft]); const handleBeforeTopicSwitch = useCallback( (topicId: string) => { - if (topicId.trim() !== sessionId?.trim() || taskCenterDraftSurfaceActiveRef.current) { - resetComposerDraft?.(); - } - taskCenterDraftSurfaceActiveRef.current = false; - setActiveTaskCenterDraftTabId(null); - setTaskCenterDraftSendRequest(null); - setHomePendingPreviewRequest(null); + composerDraftCoordinator?.prepareNavigation(); deferSessionRecentMetadataSyncForNavigation(topicId); }, - [deferSessionRecentMetadataSyncForNavigation, resetComposerDraft, sessionId], + [composerDraftCoordinator, deferSessionRecentMetadataSyncForNavigation], + ); + + const handleTopicSwitchCommitted = useCallback( + (topicId: string) => { + if ( + !composerDraftCoordinator && + (topicId.trim() !== sessionId?.trim() || + taskCenterHomeSurfaceActiveRef.current) + ) { + resetComposerDraft?.(); + } + taskCenterHomeSurfaceActiveRef.current = topicId.trim() === ""; + setTaskCenterDraftSendRequest(null); + setHomePendingPreviewRequest(null); + }, + [composerDraftCoordinator, resetComposerDraft, sessionId], ); const hasTaskCenterHomeHotpathPending = Boolean( @@ -102,9 +102,7 @@ export function useWorkspaceTaskCenterDraftStateRuntime({ const shouldPauseInitialSessionNavigationForTaskCenterDraft = shouldPauseTaskCenterInitialSessionNavigation({ agentEntry, - draftSurfaceActive: taskCenterDraftSurfaceActiveRef.current, - activeDraftTabId: activeTaskCenterDraftTabId, - draftTabCount: taskCenterDraftTabs.length, + draftSurfaceActive: taskCenterHomeSurfaceActiveRef.current, hasHomeHotpathPending: hasTaskCenterHomeHotpathPending, }); const shouldHydrateEmptyMatchedInitialSession = @@ -116,17 +114,14 @@ export function useWorkspaceTaskCenterDraftStateRuntime({ (!hasInitialSessionTopic || (initialSessionMessagesCount ?? 0) > 0); return { - activeTaskCenterDraftTabId, handleBeforeTopicSwitch, + handleTopicSwitchCommitted, homePendingPreviewRequest, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, shouldHydrateEmptyMatchedInitialSession, shouldPauseInitialSessionNavigationForTaskCenterDraft, taskCenterDraftSendRequest, - taskCenterDraftSurfaceActiveRef, - taskCenterDraftTabs, + taskCenterHomeSurfaceActiveRef, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterInteractionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterInteractionRuntime.ts index 14720266c..ee0379b98 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterInteractionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterInteractionRuntime.ts @@ -1,93 +1,64 @@ +import { useCallback } from "react"; +import type { ComposerDraftNavigationCallbacks } from "./composerDraftNavigation"; import { useTaskCenterChromeNavigationRuntime } from "./useTaskCenterChromeNavigationRuntime"; -import { useTaskCenterDraftMaterializationRuntime } from "./useTaskCenterDraftMaterializationRuntime"; import { useTaskCenterTopicNavigationRuntime } from "./useTaskCenterTopicNavigationRuntime"; -type DraftMaterializationParams = Parameters< - typeof useTaskCenterDraftMaterializationRuntime ->[0]; type TopicNavigationParams = Parameters< typeof useTaskCenterTopicNavigationRuntime >[0]; type ChromeNavigationParams = Parameters< typeof useTaskCenterChromeNavigationRuntime >[0]; - -interface UseWorkspaceTaskCenterInteractionRuntimeParams { +interface UseWorkspaceTaskCenterInteractionRuntimeParams extends Pick< + ComposerDraftNavigationCallbacks, + "composerDraftCoordinator" | "getComposerDraftNavigationEpoch" +> { chromeNavigation: Omit< ChromeNavigationParams, - | "activeDraftTabId" - | "draftSurfaceActive" - | "draftTabActive" - | "draftTabs" - | "isTaskCenterDraftSurfaceActive" - | "isTaskCenterDraftTabActive" | "onCloseTaskCenterTab" | "onOpenTaskTopic" | "onSwitchTaskTopic" - | "openDraftTab" + | "isTaskCenterDraftSurfaceActive" >; - draftMaterialization: Omit< - DraftMaterializationParams, - "persistMaterializedSessionNavigation" | "switchMaterializedSession" - >; - persistMaterializedSessionNavigation: NonNullable< - DraftMaterializationParams["persistMaterializedSessionNavigation"] - >; - taskCenterSurface: { - isTaskCenterDraftSurfaceActive: boolean; - isTaskCenterDraftTabActive: boolean; - }; + taskCenterSurface: { isTaskCenterDraftSurfaceActive: boolean }; topicNavigation: Omit< TopicNavigationParams, - | "activeTaskCenterDraftTabIdRef" - | "openTaskCenterDraftTab" - | "taskCenterDraftTabsRef" - >; - switchTopic: NonNullable< - DraftMaterializationParams["switchMaterializedSession"] + "composerDraftCoordinator" | "getComposerDraftNavigationEpoch" >; } - -/** Task Center 草稿物化、切题和 Chrome 共用同一组交互状态。 */ +/** Canonical 切题与 Chrome 共用同一导航 owner,不持有草稿文档。 */ export function useWorkspaceTaskCenterInteractionRuntime({ chromeNavigation, - draftMaterialization, - persistMaterializedSessionNavigation, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, taskCenterSurface, topicNavigation, - switchTopic, }: UseWorkspaceTaskCenterInteractionRuntimeParams) { - const materializationRuntime = useTaskCenterDraftMaterializationRuntime({ - ...draftMaterialization, - persistMaterializedSessionNavigation, - switchMaterializedSession: switchTopic, - }); const topicNavigationRuntime = useTaskCenterTopicNavigationRuntime({ ...topicNavigation, - activeTaskCenterDraftTabIdRef: - materializationRuntime.activeTaskCenterDraftTabIdRef, - openTaskCenterDraftTab: materializationRuntime.openTaskCenterDraftTab, - taskCenterDraftTabsRef: materializationRuntime.taskCenterDraftTabsRef, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, }); + const { handleOpenTaskTopic, handleSwitchTaskTopic } = topicNavigationRuntime; + const onOpenTaskTopic = useCallback( + async (...args: Parameters) => { + await handleOpenTaskTopic(...args); + }, + [handleOpenTaskTopic], + ); + const onSwitchTaskTopic = useCallback( + async (id: string) => { + await handleSwitchTaskTopic(id); + }, + [handleSwitchTaskTopic], + ); const chromeNavigationRuntime = useTaskCenterChromeNavigationRuntime({ ...chromeNavigation, - activeDraftTabId: draftMaterialization.activeTaskCenterDraftTabId, - draftSurfaceActive: - draftMaterialization.taskCenterDraftSurfaceActiveRef.current, - draftTabActive: taskCenterSurface.isTaskCenterDraftTabActive, - draftTabs: draftMaterialization.taskCenterDraftTabs, isTaskCenterDraftSurfaceActive: taskCenterSurface.isTaskCenterDraftSurfaceActive, - isTaskCenterDraftTabActive: taskCenterSurface.isTaskCenterDraftTabActive, onCloseTaskCenterTab: topicNavigationRuntime.handleCloseTaskCenterTab, - onOpenTaskTopic: topicNavigationRuntime.handleOpenTaskTopic, - onSwitchTaskTopic: topicNavigationRuntime.handleSwitchTaskTopic, - openDraftTab: materializationRuntime.openTaskCenterDraftTab, + onOpenTaskTopic, + onSwitchTaskTopic, }); - - return { - ...materializationRuntime, - ...topicNavigationRuntime, - ...chromeNavigationRuntime, - }; + return { ...topicNavigationRuntime, ...chromeNavigationRuntime }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterNavigationRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterNavigationRuntime.ts index 1e3b659ba..35dcdda13 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterNavigationRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterNavigationRuntime.ts @@ -29,18 +29,19 @@ interface UseWorkspaceTaskCenterNavigationRuntimeParams { newChatAt: TabSessionParams["newChatAt"]; normalizedInitialSessionId: TabSessionParams["normalizedInitialSessionId"]; onBeforeTopicSwitch: TopicSwitchParams["onBeforeTopicSwitch"]; + onAfterTopicSwitch?: TopicSwitchParams["onAfterTopicSwitch"]; + composerDraftCoordinator?: TopicSwitchParams["composerDraftCoordinator"]; + getComposerDraftNavigationEpoch?: TopicSwitchParams["getComposerDraftNavigationEpoch"]; originalSwitchTopic: TopicSwitchParams["originalSwitchTopic"]; projectId: TopicSwitchParams["projectId"]; rememberProjectId: TopicSwitchParams["rememberProjectId"]; resetTopicLocalState: TopicSwitchParams["resetTopicLocalState"]; - setActiveTaskCenterDraftTabId: TabSessionParams["setActiveTaskCenterDraftTabId"]; setHomePendingPreviewRequest: TabSessionParams["setHomePendingPreviewRequest"]; setTaskCenterDraftSendRequest: TabSessionParams["setTaskCenterDraftSendRequest"]; - setTaskCenterDraftTabs: TabSessionParams["setTaskCenterDraftTabs"]; shouldHydrateEmptyMatchedInitialSession: boolean; shouldPauseInitialSessionNavigationForTaskCenterDraft: boolean; startTopicProjectResolution: TopicSwitchParams["startTopicProjectResolution"]; - taskCenterDraftSurfaceActiveRef: TabSessionParams["taskCenterDraftSurfaceActiveRef"]; + taskCenterHomeSurfaceActiveRef: TabSessionParams["taskCenterHomeSurfaceActiveRef"]; taskCenterWorkspaceId: TabSessionParams["taskCenterWorkspaceId"]; threadItemsLength: number; topicById: TabSessionParams["topicById"]; @@ -64,18 +65,19 @@ export function useWorkspaceTaskCenterNavigationRuntime({ newChatAt, normalizedInitialSessionId, onBeforeTopicSwitch, + onAfterTopicSwitch, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, originalSwitchTopic, projectId, rememberProjectId, resetTopicLocalState, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, shouldHydrateEmptyMatchedInitialSession, shouldPauseInitialSessionNavigationForTaskCenterDraft, startTopicProjectResolution, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, threadItemsLength, topicById, @@ -87,6 +89,9 @@ export function useWorkspaceTaskCenterNavigationRuntime({ externalProjectId, originalSwitchTopic, onBeforeTopicSwitch, + onAfterTopicSwitch, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, startTopicProjectResolution, finishTopicProjectResolution, deferTopicSwitch, @@ -128,14 +133,12 @@ export function useWorkspaceTaskCenterNavigationRuntime({ normalizedInitialSessionId, newChatAt, sessionId: currentSessionId, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, topicById, topics, - setActiveTaskCenterDraftTabId, setHomePendingPreviewRequest, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, }); return { diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts index e1c06ac88..233aeb3a8 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.ts @@ -1,10 +1,16 @@ import { useCallback, + useRef, type Dispatch, type MutableRefObject, type SetStateAction, } from "react"; -import type { TaskCenterDraftSendRequest } from "../homePendingPreview"; +import type { + TaskCenterDraftSendRequest, + TaskCenterSessionReadyOptions, +} from "../homePendingPreview"; +import type { ComposerDraftNavigationCallbacks } from "./composerDraftNavigation"; +import type { ComposerDraftStatus } from "@/components/input-kit/ComposerDraftCoordinator"; import type { InputbarSendHandler } from "../components/Inputbar/inputbarSendPayload"; import type { WorkspaceHandleSend } from "./useWorkspaceSendActions"; import { @@ -15,11 +21,6 @@ import { resolveWorkspaceSceneSessionProjection, type WorkspaceSceneSessionProjection, } from "./workspaceSceneSessionProjection"; -import { - clearActiveTaskCenterDraftTab, - removeTaskCenterDraftTab, -} from "./taskCenterDraftTabs"; -import type { TaskCenterDraftTab } from "./agentChatWorkspaceHelpers"; import type { TaskCenterHomeSurfaceState } from "./agentChatWorkspaceHelpers"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; @@ -31,21 +32,12 @@ interface UseWorkspaceTaskCenterSendRuntimeParams< TExecutionRuntime, TPendingAction, TSubmittedAction, +> extends Pick< + ComposerDraftNavigationCallbacks, + "composerDraftCoordinator" | "getComposerDraftNavigationEpoch" > { - activeDraftTabIdRef: MutableRefObject; activeSessionIdRef?: MutableRefObject; agentEntry: string; - clearMessages: (options?: { showToast?: boolean }) => void; - commitMaterializedDraftTab: ( - draftTabId: string, - newSessionId: string, - options?: { - embedHomeSession?: boolean; - hydrateSession?: boolean; - preserveInput?: boolean; - syncRoute?: boolean; - }, - ) => void; currentSessionId?: string | null; currentTurnId: string | null; displayMessages: TMessage[]; @@ -62,18 +54,11 @@ interface UseWorkspaceTaskCenterSendRuntimeParams< layoutMode: LayoutMode; markNewChatRequestHandled: (requestKey: string) => void; markTaskCenterLocalSessionOverride: (topicId: string) => void; - materializedSessionIdsRef: MutableRefObject>; - materializeDraftTab: ( - draftTabId: string, - options: { reason: "send"; commit: false }, - ) => Promise; messagesLength: number; newChatAt?: number; normalizedInitialSessionId?: string | null; planComposerPendingActions: TPendingAction[]; - prewarmedDraftSessionIdsRef: MutableRefObject>; sendRef: MutableRefObject; - setActiveDraftTabId: Dispatch>; setDetachedTopicId: Dispatch>; setHomePendingPreviewRequest: Dispatch< SetStateAction @@ -82,13 +67,12 @@ interface UseWorkspaceTaskCenterSendRuntimeParams< setTaskCenterDraftSendRequest: Dispatch< SetStateAction >; - setTaskCenterDraftTabs: Dispatch>; setTransitionTopicId: Dispatch>; shouldRenderTaskCenterEmbeddedHome: boolean; shouldSuppressTaskCenterDraftContent: boolean; submittedActionsInFlight: TSubmittedAction[]; taskCenterDraftSendRequest: TaskCenterDraftSendRequest | null; - taskCenterDraftSurfaceActiveRef: MutableRefObject; + taskCenterHomeSurfaceActiveRef: MutableRefObject; taskCenterHomeSurfaceState: TaskCenterHomeSurfaceState; taskCenterWorkspaceId?: string | null; threadRead: TThreadRead | null; @@ -143,6 +127,72 @@ export function shouldSwitchToReadyTaskCenterSession(params: { .includes(readySessionId); } +/** 只允许发送时的编辑目标;同一绑定的已接受 promotion 可继续接管。 */ +export function isTaskCenterSendSourceCurrent( + options: TaskCenterSessionReadyOptions, + current: { + sessionId: string | null; + workspaceId: string | null; + navigationEpoch: number | null; + newChatAt: number | null; + composerDraftStatus?: Pick< + ComposerDraftStatus, + "generation" | "scope" | "identity" + >; + }, + acceptedSessionId?: string, +): boolean { + const source = options.sendSource; + const binding = options.composerDraftCapture?.binding; + const status = current.composerDraftStatus; + if (binding && !status) return false; + const promoted = Boolean( + binding && + status && + acceptedSessionId && + status.generation === binding.generation && + status.scope?.workspaceId === binding.scope.workspaceId && + status.scope.kind === "local" && + status.scope.id === acceptedSessionId, + ); + if (binding && status) { + const matchesIdentity = + status.identity?.draftId === binding.identity.draftId && + status.identity?.scopeVersion === binding.identity.scopeVersion; + if (!matchesIdentity && !(promoted && status.identity === null)) + return false; + } + if ( + binding && + status && + !promoted && + (status.generation !== binding.generation || + status.scope?.workspaceId !== binding.scope.workspaceId || + status.scope?.kind !== binding.scope.kind || + status.scope?.id !== binding.scope.id) + ) + return false; + if (!source) return true; + if ( + source.workspaceId !== current.workspaceId || + source.newChatAt !== current.newChatAt + ) + return false; + if ( + source.navigationEpoch !== current.navigationEpoch && + !( + promoted && + source.navigationEpoch !== null && + current.navigationEpoch === source.navigationEpoch + 1 + ) + ) + return false; + return ( + source.sessionId === current.sessionId || + current.sessionId === acceptedSessionId + ); +} + export function useWorkspaceTaskCenterSendRuntime< TMessage, TTurn, @@ -152,11 +202,10 @@ export function useWorkspaceTaskCenterSendRuntime< TPendingAction, TSubmittedAction, >({ - activeDraftTabIdRef, activeSessionIdRef, agentEntry, - clearMessages, - commitMaterializedDraftTab, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, currentSessionId, currentTurnId, displayMessages, @@ -173,26 +222,21 @@ export function useWorkspaceTaskCenterSendRuntime< layoutMode, markNewChatRequestHandled, markTaskCenterLocalSessionOverride, - materializedSessionIdsRef, - materializeDraftTab, messagesLength, newChatAt, normalizedInitialSessionId, planComposerPendingActions, - prewarmedDraftSessionIdsRef, sendRef, - setActiveDraftTabId, setDetachedTopicId, setHomePendingPreviewRequest, setInput, setTaskCenterDraftSendRequest, - setTaskCenterDraftTabs, setTransitionTopicId, shouldRenderTaskCenterEmbeddedHome, shouldSuppressTaskCenterDraftContent, submittedActionsInFlight, taskCenterDraftSendRequest, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterHomeSurfaceState, taskCenterWorkspaceId, threadRead, @@ -216,27 +260,54 @@ export function useWorkspaceTaskCenterSendRuntime< TPendingAction, TSubmittedAction > { - const handleNonMaterializedSessionReady = useCallback( - ( - readySessionId: string, - options?: { sourceDraftTabId?: string | null }, - ) => { - if (typeof newChatAt === "number") { - markNewChatRequestHandled(String(newChatAt)); - } - taskCenterDraftSurfaceActiveRef.current = false; - const sourceDraftTabId = options?.sourceDraftTabId?.trim() || null; - if (sourceDraftTabId) { - setTaskCenterDraftTabs((current) => - removeTaskCenterDraftTab(current, sourceDraftTabId), - ); - setActiveDraftTabId((current) => - clearActiveTaskCenterDraftTab(current, sourceDraftTabId), - ); + const currentSendSourceRef = useRef({ + currentSessionId, + taskCenterWorkspaceId, + newChatAt, + }); + currentSendSourceRef.current = { + currentSessionId, + taskCenterWorkspaceId, + newChatAt, + }; + const isSendSourceCurrent = useCallback( + (options: TaskCenterSessionReadyOptions, acceptedSessionId?: string) => { + const latest = currentSendSourceRef.current; + return isTaskCenterSendSourceCurrent( + options, + { + sessionId: + activeSessionIdRef?.current ?? latest.currentSessionId ?? null, + workspaceId: latest.taskCenterWorkspaceId ?? null, + navigationEpoch: getComposerDraftNavigationEpoch?.() ?? null, + newChatAt: latest.newChatAt ?? null, + composerDraftStatus: composerDraftCoordinator?.getSnapshot(), + }, + acceptedSessionId, + ); + }, + [ + activeSessionIdRef, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, + ], + ); + const handleSessionAccepted = useCallback( + (readySessionId: string, options?: TaskCenterSessionReadyOptions) => { + const acceptedSessionId = options?.acceptedResult?.receipt.sessionId; + if (acceptedSessionId && acceptedSessionId !== readySessionId) return; + if (options && !isSendSourceCurrent(options, acceptedSessionId)) return; + const sourceNewChatAt = options?.sendSource?.newChatAt ?? newChatAt; + if (typeof sourceNewChatAt === "number") { + markNewChatRequestHandled(String(sourceNewChatAt)); } + taskCenterHomeSurfaceActiveRef.current = false; setTransitionTopicId(null); setDetachedTopicId(null); - upsertTaskCenterOpenTab(readySessionId, taskCenterWorkspaceId); + upsertTaskCenterOpenTab( + readySessionId, + options?.sendSource?.workspaceId ?? taskCenterWorkspaceId, + ); markTaskCenterLocalSessionOverride(readySessionId); if ( shouldSwitchToReadyTaskCenterSession({ @@ -253,16 +324,15 @@ export function useWorkspaceTaskCenterSendRuntime< }, [ currentSessionId, + isSendSourceCurrent, activeSessionIdRef, markNewChatRequestHandled, markTaskCenterLocalSessionOverride, newChatAt, - setActiveDraftTabId, setDetachedTopicId, - setTaskCenterDraftTabs, setTransitionTopicId, switchToReadySession, - taskCenterDraftSurfaceActiveRef, + taskCenterHomeSurfaceActiveRef, taskCenterWorkspaceId, upsertTaskCenterOpenTab, ], @@ -273,22 +343,17 @@ export function useWorkspaceTaskCenterSendRuntime< input, setInput, activeSessionIdRef, - activeDraftTabIdRef: activeDraftTabIdRef, - clearMessages, - displayMessagesLength: displayMessages.length, - turnsLength: turns.length, - threadItemsLength: effectiveThreadItems.length, + getComposerDraftNavigationEpoch, + isSendSourceCurrent, + newChatAt, hasDisplayMessages, handleSend, sessionId: currentSessionId, taskCenterWorkspaceId, - setTaskCenterDraftTabs, setTaskCenterDraftSendRequest, taskCenterDraftSendRequest, setHomePendingPreviewRequest, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - onNonMaterializedSessionReady: handleNonMaterializedSessionReady, + onSessionAccepted: handleSessionAccepted, }); useTaskCenterDraftSendDispatchRuntime({ @@ -296,13 +361,9 @@ export function useWorkspaceTaskCenterSendRuntime< setTaskCenterDraftSendRequest, setHomePendingPreviewRequest, messagesLength, - displayMessagesLength: displayMessages.length, currentSessionId, - materializedSessionIdsRef, - prewarmedDraftSessionIdsRef, - materializeDraftTab, - commitMaterializedDraftTab, - onNonMaterializedSessionReady: handleNonMaterializedSessionReady, + onSessionAccepted: handleSessionAccepted, + isSendSourceCurrent, restoreInput: setInput, sendRef, workspaceId: taskCenterWorkspaceId, diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.unit.test.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.unit.test.ts index bb18f3f4e..904a84600 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.unit.test.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSendRuntime.unit.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { shouldSwitchToReadyTaskCenterSession } from "./useWorkspaceTaskCenterSendRuntime"; +import { + isTaskCenterSendSourceCurrent, + shouldSwitchToReadyTaskCenterSession, +} from "./useWorkspaceTaskCenterSendRuntime"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import type { TaskCenterSessionReadyOptions } from "../homePendingPreview"; describe("shouldSwitchToReadyTaskCenterSession", () => { it("实时 active session 已接管首页首发时不应再次切换话题", () => { @@ -42,3 +47,114 @@ describe("shouldSwitchToReadyTaskCenterSession", () => { ).toBe(false); }); }); + +describe("captured TaskCenter send source", () => { + const scope = { workspaceId: "w", kind: "draft" as const, id: "draft-a" }; + const options: TaskCenterSessionReadyOptions = { + composerDraftCapture: { + receipt: new ComposerController({ text: "sent" }).submit("start"), + binding: { + scope, + identity: { draftId: "d", scopeVersion: "v" }, + generation: 3, + payload: {}, + }, + }, + sendSource: { + sessionId: null, + workspaceId: "w", + navigationEpoch: 7, + newChatAt: 100, + }, + }; + const current = { + ...options.sendSource!, + composerDraftStatus: { + generation: 3, + scope, + identity: options.composerDraftCapture!.binding!.identity, + }, + }; + + it("allows the original surface without inventing an acceptance", () => { + expect(isTaskCenterSendSourceCurrent(options, current)).toBe(true); + }); + + it.each([ + { sessionId: "new-draft" }, + { sessionId: "unrelated-session" }, + { workspaceId: "other-workspace" }, + { newChatAt: 101 }, + { navigationEpoch: 9 }, + { + composerDraftStatus: { + generation: 5, + scope, + identity: current.composerDraftStatus.identity, + }, + }, + { + composerDraftStatus: { + generation: 3, + scope, + identity: { draftId: "d", scopeVersion: "new-version" }, + }, + }, + { + composerDraftStatus: { + generation: 3, + scope, + identity: { draftId: "new-draft", scopeVersion: "v" }, + }, + }, + { composerDraftStatus: { generation: 3, scope, identity: null } }, + ])("rejects a changed editing target %j, including away/back", (delta) => { + expect( + isTaskCenterSendSourceCurrent(options, { ...current, ...delta }, "ready"), + ).toBe(false); + }); + + it("only permits epoch+1 for same-generation promotion to the accepted receipt session", () => { + const promoted = { + ...current, + sessionId: "ready", + navigationEpoch: 8, + composerDraftStatus: { + generation: 3, + identity: null, + scope: { workspaceId: "w", kind: "local" as const, id: "ready" }, + }, + }; + expect(isTaskCenterSendSourceCurrent(options, promoted)).toBe(false); + expect( + isTaskCenterSendSourceCurrent(options, promoted, "wrong-receipt"), + ).toBe(false); + expect(isTaskCenterSendSourceCurrent(options, promoted, "ready")).toBe( + true, + ); + expect( + isTaskCenterSendSourceCurrent( + options, + { ...promoted, navigationEpoch: 10 }, + "ready", + ), + ).toBe(false); + const newerPromotedDraft = { + ...promoted, + composerDraftStatus: { + ...promoted.composerDraftStatus, + identity: { draftId: "d", scopeVersion: "new-version" }, + }, + }; + expect( + isTaskCenterSendSourceCurrent(options, newerPromotedDraft, "ready"), + ).toBe(false); + expect( + isTaskCenterSendSourceCurrent( + options, + { ...promoted, sessionId: "new-draft" }, + "ready", + ), + ).toBe(false); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSurfaceRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSurfaceRuntime.ts index 11e4d700a..96695708b 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTaskCenterSurfaceRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTaskCenterSurfaceRuntime.ts @@ -1,6 +1,4 @@ -import { useCallback, useMemo } from "react"; -import { buildClawAgentParams } from "@/lib/workspace/navigation"; -import type { AgentChatWorkspaceProps } from "../agentChatWorkspaceContract"; +import { useMemo } from "react"; import { buildInitialDispatchPreviewMessages } from "./workspaceSendHelpers"; import { useTaskCenterHomePendingPreviewRuntime } from "./useTaskCenterDraftSendRuntime"; import { @@ -16,26 +14,18 @@ type HomePendingPreviewParams = Parameters< >[0]; interface UseWorkspaceTaskCenterSurfaceRuntimeParams { - activeTheme: string; bootstrapDispatchPreview?: | Parameters[0] | null; draftSurface: DraftSurfaceParams; homePendingPreview: HomePendingPreviewParams; - lockTheme: boolean; - onNavigate?: AgentChatWorkspaceProps["onNavigate"]; - taskCenterWorkspaceId?: string | null; } /** Task Center 草稿 surface、首页 pending preview 与物化路由统一由此处派生。 */ export function useWorkspaceTaskCenterSurfaceRuntime({ - activeTheme, bootstrapDispatchPreview, draftSurface, homePendingPreview, - lockTheme, - onNavigate, - taskCenterWorkspaceId, }: UseWorkspaceTaskCenterSurfaceRuntimeParams) { const { homePendingPreviewMessages, isHomePendingPreviewActive } = useTaskCenterHomePendingPreviewRuntime(homePendingPreview); @@ -54,27 +44,6 @@ export function useWorkspaceTaskCenterSurfaceRuntime({ ...draftSurface, hasHomePendingPreview: hasPendingPreviewActivity, }); - const persistTaskCenterMaterializedSessionNavigation = useCallback( - (sessionId: string) => { - const normalizedSessionId = sessionId.trim(); - if (!onNavigate || !normalizedSessionId) { - return; - } - - onNavigate( - "agent", - buildClawAgentParams({ - ...(taskCenterWorkspaceId - ? { projectId: taskCenterWorkspaceId } - : {}), - initialSessionId: normalizedSessionId, - theme: activeTheme, - lockTheme, - }), - ); - }, - [activeTheme, lockTheme, onNavigate, taskCenterWorkspaceId], - ); const isHomeSendStarting = Boolean( draftSurface.draftSendRequest || homePendingPreview.homePendingPreviewRequest || @@ -90,6 +59,5 @@ export function useWorkspaceTaskCenterSurfaceRuntime({ homePendingPreviewMessages, isHomePendingPreviewActive, isHomeSendStarting, - persistTaskCenterMaterializedSessionNavigation, }; } diff --git a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.navigationSource.test.tsx b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.navigationSource.test.tsx new file mode 100644 index 000000000..41bc287b6 --- /dev/null +++ b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.navigationSource.test.tsx @@ -0,0 +1,255 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + a, + b, + deferred, + harness, +} from "@/components/input-kit/ComposerDraftCoordinator.testFixtures"; +import { useWorkspaceTopicSwitch } from "./useWorkspaceTopicSwitch"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); +const projectApi = vi.hoisted(() => ({ getProject: vi.fn() })); +vi.mock("@/lib/api/project", () => projectApi); + +type HookProps = Parameters[0]; +type HookResult = ReturnType; +let fixture: Awaited> | null = null; +const mounted: Array<{ root: Root; container: HTMLDivElement }> = []; + +function mount(props: HookProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let current!: HookResult; + function Probe(next: HookProps) { + current = useWorkspaceTopicSwitch(next); + return null; + } + mounted.push({ root, container }); + const render = (next: HookProps) => { + act(() => root.render()); + }; + render(props); + return { get: () => current, render }; +} + +async function setup(overrides: Partial = {}) { + fixture = await harness({ safeInvoke, safeListen }); + await fixture.coordinator.bindCommitted(a); + fixture.controller.setText("source draft"); + let epoch = 7; + const props: HookProps = { + projectId: "w", + originalSwitchTopic: vi.fn(async () => undefined), + onBeforeTopicSwitch: vi.fn(), + onAfterTopicSwitch: vi.fn(), + composerDraftCoordinator: fixture.coordinator, + getComposerDraftNavigationEpoch: () => epoch, + startTopicProjectResolution: () => true, + finishTopicProjectResolution: vi.fn(), + deferTopicSwitch: vi.fn(), + consumePendingTopicSwitch: () => null, + rememberProjectId: vi.fn(), + getRememberedProjectId: () => null, + loadTopicBoundProjectId: () => "w", + resetTopicLocalState: vi.fn(), + ...overrides, + }; + return { + fixture, + props, + hook: mount(props), + advanceEpoch: () => { + epoch += 1; + }, + }; +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.resetAllMocks(); + vi.useFakeTimers(); +}); +afterEach(async () => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + await fixture?.close(); + fixture = null; + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("topic navigation preserves the original editing source", () => { + it("passes the start epoch after a successful original switch", async () => { + const { hook, props } = await setup(); + await act(async () => { + expect(await hook.get().switchTopic("next")).toBe("success"); + }); + expect(props.onAfterTopicSwitch).toHaveBeenCalledExactlyOnceWith( + "next", + "w", + 7, + ); + }); + + it.each(["switchTopic", "runTopicSwitch"] as const)( + "%s cannot commit a late void result after an intended target changes", + async (entry) => { + const pending = deferred(); + const { + hook, + props, + advanceEpoch, + fixture: h, + } = await setup({ + originalSwitchTopic: () => pending.promise, + }); + let completion!: Promise; + act(() => { + completion = hook.get()[entry]("old"); + }); + advanceEpoch(); + h.controller.setText("new target remains editable"); + await act(async () => { + pending.resolve(undefined); + expect(await completion).toBe("superseded"); + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(props.resetTopicLocalState).not.toHaveBeenCalled(); + expect(h.controller.getDocument().text).toBe( + "new target remains editable", + ); + }, + ); + + it("a committed new binding generation cancels an in-flight switch", async () => { + const pending = deferred(); + const { + hook, + props, + fixture: h, + } = await setup({ + originalSwitchTopic: () => pending.promise, + }); + let completion!: Promise; + act(() => { + completion = hook.get().switchTopic("old"); + }); + await h.coordinator.bindCommitted(b); + h.controller.setText("B remains editable"); + await act(async () => { + pending.resolve(undefined); + expect(await completion).toBe("superseded"); + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(h.coordinator.getSnapshot().scope).toEqual(b); + expect(h.controller.getDocument().text).toBe("B remains editable"); + }); + + it("promotion changing scope within the same generation cancels completion", async () => { + const pending = deferred(); + const { + hook, + props, + fixture: h, + } = await setup({ + originalSwitchTopic: () => pending.promise, + }); + const token = h.coordinator.captureBinding()!; + let completion!: Promise; + act(() => { + completion = hook.get().runTopicSwitch("old"); + }); + await h.coordinator.promote( + token, + { workspaceId: null, kind: "local", id: "actual" }, + { expectedTarget: null, sessionId: "actual" }, + ); + expect(h.coordinator.getSnapshot().generation).toBe(token.generation); + await act(async () => { + pending.resolve(undefined); + expect(await completion).toBe("superseded"); + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(props.resetTopicLocalState).not.toHaveBeenCalled(); + }); + + it("project resolution cannot capture a new source after the old one changes", async () => { + const project = deferred<{ id: string; isArchived: boolean }>(); + projectApi.getProject.mockReturnValue(project.promise); + const { hook, props, advanceEpoch } = await setup({ + loadTopicBoundProjectId: () => "other", + }); + let completion!: Promise; + act(() => { + completion = hook.get().switchTopic("old"); + }); + advanceEpoch(); + await act(async () => { + project.resolve({ id: "other", isArchived: false }); + expect(await completion).toBe("superseded"); + }); + expect(props.deferTopicSwitch).not.toHaveBeenCalled(); + expect(props.originalSwitchTopic).not.toHaveBeenCalled(); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + }); + + it("deferred resume retains the original start epoch through completion", async () => { + projectApi.getProject.mockResolvedValue({ id: "other", isArchived: false }); + const pending = deferred(); + const { hook, props, advanceEpoch } = await setup({ + originalSwitchTopic: () => pending.promise, + loadTopicBoundProjectId: () => "other", + }); + await act(async () => { + expect(await hook.get().switchTopic("old")).toBe("deferred"); + }); + const consume = vi.fn(); + consume + .mockReturnValueOnce({ topicId: "old", targetProjectId: "other" }) + .mockReturnValue(null); + act(() => { + hook.render({ + ...props, + projectId: "other", + consumePendingTopicSwitch: consume, + }); + }); + advanceEpoch(); + await act(async () => { + pending.resolve(undefined); + await pending.promise; + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + expect(props.resetTopicLocalState).not.toHaveBeenCalled(); + }); + + it("a detached null binding becoming committed invalidates an older source", async () => { + const pending = deferred(); + const { hook, props } = await setup({ + composerDraftCoordinator: undefined, + originalSwitchTopic: () => pending.promise, + }); + let completion!: Promise; + act(() => { + completion = hook.get().runTopicSwitch("old"); + }); + hook.render({ ...props, composerDraftCoordinator: fixture!.coordinator }); + await act(async () => { + pending.resolve(undefined); + expect(await completion).toBe("superseded"); + }); + expect(props.onAfterTopicSwitch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts index 95bbdc257..59bf6de83 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { DraftScope } from "@/lib/api/persistedAtomTypes"; +import type { ComposerDraftCoordinator } from "@/components/input-kit/ComposerDraftCoordinator"; import { toast } from "sonner"; import { logAgentDebug } from "@/lib/agentDebug"; import { getProject } from "@/lib/api/project"; @@ -19,13 +21,26 @@ interface TopicSwitchOptions { allowDetachedSession?: boolean; } +/** Local hydration lifecycle; never forwarded to runtime session options. */ +export interface WorkspaceTopicSwitchLifecycle { + onHydrated: () => void; +} + +interface ComposerDraftNavigationSource { + coordinator: ComposerDraftCoordinator | undefined; + sourceGeneration: number | undefined; + sourceScope: DraftScope | null; + navigationEpoch: number | undefined; +} + export type WorkspaceTopicSwitchResult = | "success" | "deferred" | "blocked" | "missing" | "busy" - | "error"; + | "error" + | "superseded"; interface UseWorkspaceTopicSwitchParams { projectId?: string; @@ -35,6 +50,13 @@ interface UseWorkspaceTopicSwitchParams { options?: TopicSwitchOptions, ) => Promise; onBeforeTopicSwitch?: (topicId: string) => void; + onAfterTopicSwitch?: ( + topicId: string, + workspaceId: string | null, + capturedNavigationEpoch?: number, + ) => void; + composerDraftCoordinator?: ComposerDraftCoordinator; + getComposerDraftNavigationEpoch?: () => number; startTopicProjectResolution: () => boolean; finishTopicProjectResolution: () => void; deferTopicSwitch: ( @@ -56,6 +78,9 @@ export function useWorkspaceTopicSwitch({ externalProjectId, originalSwitchTopic, onBeforeTopicSwitch, + onAfterTopicSwitch, + composerDraftCoordinator, + getComposerDraftNavigationEpoch, startTopicProjectResolution, finishTopicProjectResolution, deferTopicSwitch, @@ -65,12 +90,64 @@ export function useWorkspaceTopicSwitch({ loadTopicBoundProjectId, resetTopicLocalState, }: UseWorkspaceTopicSwitchParams) { + const topicSwitchRequestVersionRef = useRef(0); + const currentDraftRuntimeRef = useRef({ + composerDraftCoordinator, + getComposerDraftNavigationEpoch, + }); + currentDraftRuntimeRef.current = { + composerDraftCoordinator, + getComposerDraftNavigationEpoch, + }; + const deferredNavigationRef = useRef< + | (ComposerDraftNavigationSource & { + topicId: string; + requestVersion: number; + lifecycle?: WorkspaceTopicSwitchLifecycle; + }) + | null + >(null); + const captureNavigationSource = + useCallback((): ComposerDraftNavigationSource => { + const runtime = currentDraftRuntimeRef.current; + const snapshot = runtime.composerDraftCoordinator?.getSnapshot(); + return { + coordinator: runtime.composerDraftCoordinator, + sourceGeneration: snapshot?.generation, + sourceScope: snapshot?.scope ?? null, + navigationEpoch: runtime.getComposerDraftNavigationEpoch?.(), + }; + }, []); + const isNavigationSourceCurrent = useCallback( + (source: ComposerDraftNavigationSource) => { + const current = captureNavigationSource(); + return ( + source.coordinator === current.coordinator && + source.sourceGeneration === current.sourceGeneration && + source.navigationEpoch === current.navigationEpoch && + source.sourceScope?.workspaceId === current.sourceScope?.workspaceId && + source.sourceScope?.kind === current.sourceScope?.kind && + source.sourceScope?.id === current.sourceScope?.id + ); + }, + [captureNavigationSource], + ); const runTopicSwitch = useCallback( async ( topicId: string, options?: TopicSwitchOptions, - runOptions?: { skipBeforeSwitch?: boolean }, + runOptions?: { + skipBeforeSwitch?: boolean; + requestVersion?: number; + source?: ComposerDraftNavigationSource; + lifecycle?: WorkspaceTopicSwitchLifecycle; + }, ) => { + const requestVersion = + runOptions?.requestVersion ?? ++topicSwitchRequestVersionRef.current; + const source = runOptions?.source ?? captureNavigationSource(); + if (!isNavigationSourceCurrent(source)) return "superseded" as const; + const committedWorkspaceId = normalizeProjectId(projectId); const forwardedOptions = options?.forceRefresh === true || options?.resumeSessionStartHooks === true || @@ -93,14 +170,14 @@ export function useWorkspaceTopicSwitch({ topicId, }); if (runOptions?.skipBeforeSwitch !== true) { + composerDraftCoordinator?.prepareNavigation(); onBeforeTopicSwitch?.(topicId); } - resetTopicLocalState(); try { const hydrationResult = forwardedOptions ? await originalSwitchTopic(topicId, forwardedOptions) : await originalSwitchTopic(topicId); - if (hydrationResult === "error") { + if (hydrationResult !== undefined && hydrationResult !== "success") { logAgentDebug( "AgentChatPage", "runTopicSwitch.error", @@ -113,7 +190,30 @@ export function useWorkspaceTopicSwitch({ }, { level: "error" }, ); - return "error" as const; + return hydrationResult === "blocked" || + hydrationResult === "busy" || + hydrationResult === "deferred" || + hydrationResult === "missing" || + hydrationResult === "superseded" + ? hydrationResult + : ("error" as const); + } + if ( + requestVersion !== topicSwitchRequestVersionRef.current || + !isNavigationSourceCurrent(source) + ) { + return "superseded" as const; + } + resetTopicLocalState(); + runOptions?.lifecycle?.onHydrated(); + if (source.navigationEpoch === undefined) { + onAfterTopicSwitch?.(topicId, committedWorkspaceId); + } else { + onAfterTopicSwitch?.( + topicId, + committedWorkspaceId, + source.navigationEpoch, + ); } logAgentDebug("AgentChatPage", "runTopicSwitch.success", { allowDetachedSession: options?.allowDetachedSession === true, @@ -138,11 +238,25 @@ export function useWorkspaceTopicSwitch({ throw error; } }, - [onBeforeTopicSwitch, originalSwitchTopic, projectId, resetTopicLocalState], + [ + composerDraftCoordinator, + captureNavigationSource, + isNavigationSourceCurrent, + onAfterTopicSwitch, + onBeforeTopicSwitch, + originalSwitchTopic, + projectId, + resetTopicLocalState, + ], ); const switchTopic = useCallback( - async (topicId: string, options?: TopicSwitchOptions) => { + async ( + topicId: string, + options?: TopicSwitchOptions, + lifecycle?: WorkspaceTopicSwitchLifecycle, + ) => { + composerDraftCoordinator?.prepareNavigation(); if (!startTopicProjectResolution()) { logAgentDebug( "AgentChatPage", @@ -157,6 +271,8 @@ export function useWorkspaceTopicSwitch({ return "busy" as const; } + const requestVersion = ++topicSwitchRequestVersionRef.current; + const source = captureNavigationSource(); let resolutionActive = true; const finishResolutionIfNeeded = () => { if (!resolutionActive) { @@ -195,6 +311,9 @@ export function useWorkspaceTopicSwitch({ finishResolutionIfNeeded(); return await runTopicSwitch(topicId, options, { skipBeforeSwitch: true, + requestVersion, + source, + lifecycle, }); } @@ -214,6 +333,9 @@ export function useWorkspaceTopicSwitch({ finishResolutionIfNeeded(); return await runTopicSwitch(topicId, options, { skipBeforeSwitch: true, + requestVersion, + source, + lifecycle, }); } @@ -228,6 +350,12 @@ export function useWorkspaceTopicSwitch({ : null; }, }); + if ( + requestVersion !== topicSwitchRequestVersionRef.current || + !isNavigationSourceCurrent(source) + ) { + return "superseded" as const; + } logAgentDebug("AgentChatPage", "switchTopic.decision", { decisionStatus: decision.status, projectId: decision.status === "ok" ? decision.projectId : null, @@ -245,6 +373,12 @@ export function useWorkspaceTopicSwitch({ const targetProjectId = decision.projectId; if (currentProjectId !== targetProjectId) { + deferredNavigationRef.current = { + topicId, + requestVersion, + lifecycle, + ...source, + }; deferTopicSwitch(topicId, targetProjectId, options); logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { allowDetachedSession: options?.allowDetachedSession === true, @@ -260,6 +394,9 @@ export function useWorkspaceTopicSwitch({ finishResolutionIfNeeded(); return await runTopicSwitch(topicId, options, { skipBeforeSwitch: true, + requestVersion, + source, + lifecycle, }); } catch (error) { console.error("[AgentChatPage] 解析任务项目失败:", error); @@ -282,6 +419,9 @@ export function useWorkspaceTopicSwitch({ } }, [ + composerDraftCoordinator, + captureNavigationSource, + isNavigationSourceCurrent, deferTopicSwitch, externalProjectId, finishTopicProjectResolution, @@ -301,6 +441,16 @@ export function useWorkspaceTopicSwitch({ return; } + const source = deferredNavigationRef.current; + deferredNavigationRef.current = null; + if ( + source && + (source.topicId !== pending.topicId || + source.requestVersion !== topicSwitchRequestVersionRef.current || + !isNavigationSourceCurrent(source)) + ) { + return; + } const currentProjectId = normalizeProjectId(projectId); logAgentDebug("AgentChatPage", "switchTopic.resumePending", { allowDetachedSession: pending.allowDetachedSession === true, @@ -309,11 +459,21 @@ export function useWorkspaceTopicSwitch({ projectId: currentProjectId, topicId: pending.topicId, }); - runTopicSwitch(pending.topicId, { - allowDetachedSession: pending.allowDetachedSession === true, - forceRefresh: pending.forceRefresh === true, - resumeSessionStartHooks: pending.resumeSessionStartHooks === true, - }).catch((error) => { + runTopicSwitch( + pending.topicId, + { + allowDetachedSession: pending.allowDetachedSession === true, + forceRefresh: pending.forceRefresh === true, + resumeSessionStartHooks: pending.resumeSessionStartHooks === true, + }, + source + ? { + requestVersion: source.requestVersion, + source, + lifecycle: source.lifecycle, + } + : undefined, + ).catch((error) => { console.error("[AgentChatPage] 执行待切换任务失败:", error); logAgentDebug( "AgentChatPage", @@ -330,7 +490,14 @@ export function useWorkspaceTopicSwitch({ ); toast.error("加载会话失败,请重试"); }); - }, [consumePendingTopicSwitch, projectId, runTopicSwitch]); + }, [ + composerDraftCoordinator, + getComposerDraftNavigationEpoch, + isNavigationSourceCurrent, + consumePendingTopicSwitch, + projectId, + runTopicSwitch, + ]); return { runTopicSwitch, diff --git a/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.ts b/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.ts index 3ae95daef..bf583ea5c 100644 --- a/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.ts +++ b/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.ts @@ -1,4 +1,8 @@ import type { HandleSendOptions } from "../hooks/handleSendTypes"; +import { + isRuntimeSubmissionAccepted, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; import type { WorkspaceArticleWorkspaceActionIntent } from "./workspaceArticleWorkspaceModel"; import { buildWorkspaceArticleWorkspaceActionRequestMetadata } from "./workspaceArticleWorkspaceActionRequestMetadata"; import { resolveWorkspaceArticleWorkspaceActionOutputArtifactKind } from "./workspaceArticleWorkspaceActionOutputKind"; @@ -6,7 +10,7 @@ import { resolveWorkspaceArticleWorkspaceActionOutputArtifactKind } from "./work export type SubmitWorkspaceArticleEditorAction = ( prompt: string, options: HandleSendOptions, -) => Promise; +) => Promise; export interface SubmitWorkspaceArticleEditorActionIntentParams { intent: WorkspaceArticleWorkspaceActionIntent; @@ -67,10 +71,10 @@ export async function submitWorkspaceArticleEditorActionIntent({ intent, restoreInput, submit, -}: SubmitWorkspaceArticleEditorActionIntentParams): Promise { +}: SubmitWorkspaceArticleEditorActionIntentParams): Promise { const normalizedPrompt = intent.prompt.trim(); if (!normalizedPrompt) { - return false; + return { kind: "rejected" }; } const sent = await submit(normalizedPrompt, { @@ -82,7 +86,7 @@ export async function submitWorkspaceArticleEditorActionIntent({ searchMode: "disabled", explicitToolPreferences: true, }); - if (!sent) { + if (!isRuntimeSubmissionAccepted(sent)) { restoreInput?.(normalizedPrompt); } return sent; diff --git a/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.unit.test.ts b/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.unit.test.ts index cb32205b0..6c12057cc 100644 --- a/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.unit.test.ts +++ b/src/components/agent/chat/workspace/workspaceArticleEditorActionDispatch.unit.test.ts @@ -1,4 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +import { createRuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import { fromRuntimeAcceptanceReceipt } from "../hooks/agentSendResult"; +const accepted = fromRuntimeAcceptanceReceipt( + createRuntimeAcceptanceReceipt( + { sessionId: "s", threadId: "t" }, + "start", + "turn", + ), +); + import { buildWorkspaceArticleEditorActionSystemPrompt, submitWorkspaceArticleEditorActionIntent, @@ -45,19 +55,22 @@ const intent: WorkspaceArticleWorkspaceActionIntent = { describe("workspaceArticleEditorActionDispatch", () => { it("应通过 Claw turn submit 发送 Article Editor action metadata", async () => { - const submit = vi.fn(async () => true); + const submit = vi.fn( + async () => accepted, + ); const restoreInput = vi.fn(); await expect( submitWorkspaceArticleEditorActionIntent({ intent: { ...intent, - editedMarkdown: "# 本地编辑正文\n\n这是用户在 Article Editor 里改过的内容。", + editedMarkdown: + "# 本地编辑正文\n\n这是用户在 Article Editor 里改过的内容。", }, restoreInput, submit, }), - ).resolves.toBe(true); + ).resolves.toBe(accepted); expect(submit).toHaveBeenCalledWith( "请重新生成「配图组」", @@ -87,9 +100,7 @@ describe("workspaceArticleEditorActionDispatch", () => { expect(sendOptions?.systemPromptOverride).toContain( "content.image.generate", ); - expect(sendOptions?.systemPromptOverride).toContain( - "workspace_patch", - ); + expect(sendOptions?.systemPromptOverride).toContain("workspace_patch"); expect(sendOptions?.systemPromptOverride).toContain( "imageGenerationSet/image-set-1", ); @@ -101,7 +112,8 @@ describe("workspaceArticleEditorActionDispatch", () => { it("应从 Article Editor action intent 生成 runtime 指令", () => { const systemPrompt = buildWorkspaceArticleEditorActionSystemPrompt({ ...intent, - editedMarkdown: "# 本地编辑正文\n\n这是用户在 Article Editor 里改过的内容。", + editedMarkdown: + "# 本地编辑正文\n\n这是用户在 Article Editor 里改过的内容。", }); expect(systemPrompt).toContain("Article Editor action"); @@ -119,9 +131,9 @@ describe("workspaceArticleEditorActionDispatch", () => { }); it("发送失败时应把 prompt 恢复到 Claw 输入框", async () => { - const submit = vi.fn( - async () => false, - ); + const submit = vi.fn(async () => ({ + kind: "rejected", + })); const restoreInput = vi.fn(); await expect( @@ -130,13 +142,15 @@ describe("workspaceArticleEditorActionDispatch", () => { restoreInput, submit, }), - ).resolves.toBe(false); + ).resolves.toEqual({ kind: "rejected" }); expect(restoreInput).toHaveBeenCalledWith("请重新生成「配图组」"); }); it("空 prompt 不应发送 action", async () => { - const submit = vi.fn(async () => true); + const submit = vi.fn( + async () => accepted, + ); const restoreInput = vi.fn(); await expect( @@ -148,9 +162,28 @@ describe("workspaceArticleEditorActionDispatch", () => { restoreInput, submit, }), - ).resolves.toBe(false); + ).resolves.toEqual({ kind: "rejected" }); expect(submit).not.toHaveBeenCalled(); expect(restoreInput).not.toHaveBeenCalled(); }); + + it.each(["rejected", "unknown", "local-handled"] as const)( + "%s object must preserve input rather than pass a truthiness check", + async (kind) => { + const result = { kind }; + const restoreInput = vi.fn(); + const submit = vi.fn( + async () => result, + ); + expect( + await submitWorkspaceArticleEditorActionIntent({ + intent, + submit, + restoreInput, + }), + ).toBe(result); + expect(restoreInput).toHaveBeenCalledWith(intent.prompt); + }, + ); }); diff --git a/src/components/agent/chat/workspace/workspaceConversationCodingViews.tsx b/src/components/agent/chat/workspace/workspaceConversationCodingViews.tsx index c652f83b8..f8125467f 100644 --- a/src/components/agent/chat/workspace/workspaceConversationCodingViews.tsx +++ b/src/components/agent/chat/workspace/workspaceConversationCodingViews.tsx @@ -21,6 +21,7 @@ import type { import { CodingWorkbenchLogPanel } from "./CodingWorkbenchLogPanel"; import { CodingWorkbenchOutputPanel } from "./CodingWorkbenchOutputPanel"; import type { CodingWorkbenchRecoveryContext } from "./codingWorkbenchRecovery"; +import type { RuntimeSubmissionResult } from "../hooks/agentSendResult"; import { buildCodingSessionOverviewActivities } from "./codingSessionOverviewProjection"; import { buildCanvasWorkbenchChangeViewFromCodingProjection, @@ -51,7 +52,7 @@ interface WorkspaceConversationCodingViewsParams { onSubmitRecoveryPrompt?: ( prompt: string, context?: CodingWorkbenchRecoveryContext, - ) => void | Promise | boolean; + ) => Promise; } interface WorkspaceConversationCodingViews { diff --git a/src/components/agent/chat/workspace/workspaceQueueSubmission.test.ts b/src/components/agent/chat/workspace/workspaceQueueSubmission.test.ts new file mode 100644 index 000000000..d465a14c8 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceQueueSubmission.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; +import type { QueuedSubmission } from "@limecloud/app-server-client"; +import { submitWorkspaceQueue } from "./workspaceQueueSubmission"; +const context = { + sessionId: "session-queue", + threadId: "thread-queue", + clientUserMessageId: "client-queue", +}; +const input = [{ type: "text" as const, text: "queued body" }]; +const response: QueuedSubmission = { + id: "queued-real", + clientUserMessageId: "client-queue", + input, +}; +describe("confirmed queue response", () => { + it("keeps raw queued submission and captured identity separate from start/steer", async () => { + const observer = vi.fn(); + const addQueue = vi.fn(async () => response); + const result = await submitWorkspaceQueue({ + ...context, + input, + addQueue, + observer: { onRuntimeAccepted: observer }, + }); + expect(result).toMatchObject({ + kind: "queue-accepted", + receipt: { + ...context, + queuedSubmissionId: "queued-real", + submission: response, + }, + }); + expect(addQueue).toHaveBeenCalledWith({ + threadId: "thread-queue", + clientUserMessageId: "client-queue", + input, + }); + expect(observer).toHaveBeenCalledOnce(); + if (result.kind !== "queue-accepted") throw new Error("unexpected result"); + expect(result.receipt.submission).toBe(response); + }); + it.each([ + undefined, + true, + {}, + { id: "queued-real", clientUserMessageId: "other", input }, + ])("does not accept incomplete/mismatched response %j", async (value) => { + const observer = vi.fn(); + const addQueue = vi.fn().mockResolvedValue(value); + expect( + await submitWorkspaceQueue({ + ...context, + input, + addQueue, + observer: { onRuntimeAccepted: observer }, + }), + ).toEqual({ kind: "unknown" }); + expect(observer).not.toHaveBeenCalled(); + }); + it("keeps original rejection and unknown captured session without inventing identity", async () => { + const error = new Error("queue persistence failed"); + const addQueue = vi.fn(async () => { + throw error; + }); + expect(await submitWorkspaceQueue({ ...context, input, addQueue })).toEqual( + { kind: "rejected", error }, + ); + addQueue.mockImplementation(async () => response as never); + expect( + await submitWorkspaceQueue({ + ...context, + sessionId: null, + input, + addQueue, + }), + ).toEqual({ kind: "unknown" }); + }); +}); + +describe("queue request context capture", () => { + it("late response retains request session and thread after caller navigates", async () => { + let resolve!: (response: QueuedSubmission) => void; + const request = { + ...context, + input, + addQueue: vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ), + }; + const pending = submitWorkspaceQueue(request); + request.sessionId = "new-session"; + request.threadId = "new-thread"; + request.clientUserMessageId = "new-message"; + resolve(response); + expect(await pending).toMatchObject({ + kind: "queue-accepted", + receipt: { ...context, queuedSubmissionId: response.id }, + }); + }); +}); diff --git a/src/components/agent/chat/workspace/workspaceQueueSubmission.ts b/src/components/agent/chat/workspace/workspaceQueueSubmission.ts new file mode 100644 index 000000000..58201d944 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceQueueSubmission.ts @@ -0,0 +1,44 @@ +import type { QueuedSubmission, UserInput } from "@limecloud/app-server-client"; +import type { SendMessageObserver } from "../hooks/agentChatShared"; +import { + createRuntimeSubmissionCapture, + fromQueueAcceptanceResponse, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; + +export async function submitWorkspaceQueue(options: { + sessionId?: string | null; + threadId: string; + clientUserMessageId: string; + input: UserInput[]; + observer?: SendMessageObserver; + addQueue: (params: { + threadId: string; + clientUserMessageId: string; + input: readonly UserInput[]; + }) => Promise; + onRejected?: () => void; +}): Promise { + const context = { + sessionId: options.sessionId, + threadId: options.threadId, + clientUserMessageId: options.clientUserMessageId, + }; + const capture = createRuntimeSubmissionCapture(options.observer); + try { + const response = await options.addQueue({ + threadId: context.threadId, + clientUserMessageId: context.clientUserMessageId, + input: options.input, + }); + return capture.finish(fromQueueAcceptanceResponse(context, response)); + } catch (error) { + if (capture.accepted) return capture.reject(error); + try { + options.onRejected?.(); + } catch { + console.error("[WorkspaceSend] queue rejection notification failed"); + } + return { kind: "rejected", error }; + } +} diff --git a/src/components/agent/chat/workspace/workspaceSendSubmission.test.ts b/src/components/agent/chat/workspace/workspaceSendSubmission.test.ts new file mode 100644 index 000000000..80a355519 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceSendSubmission.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import { + submitWorkspaceMessage, + submitWorkspaceLocalConfirmation, + withWorkspaceSubmissionCleanup, +} from "./workspaceSendSubmission"; +const receipt = { + kind: "steer" as const, + sessionId: "actual-session", + threadId: "thread-1", + turnId: "turn-1", +}; +describe("Workspace typed submission boundary", () => { + it("settles early observer then retains acceptance when refresh wrapper throws", async () => { + const onRuntimeAccepted = vi.fn(); + const onRejected = vi.fn(); + const result = await submitWorkspaceMessage({ + observer: { onRuntimeAccepted }, + onRejected, + submit: async (observer) => { + observer.onRuntimeAccepted?.(receipt); + throw new Error("read model failed"); + }, + }); + expect(result).toEqual({ kind: "runtime-accepted", receipt }); + expect(onRuntimeAccepted).toHaveBeenCalledOnce(); + expect(onRejected).not.toHaveBeenCalled(); + }); + it("postaccepted finalization error does not withdraw accepted result", async () => { + expect( + await submitWorkspaceMessage({ + submit: async () => ({ kind: "runtime-accepted", receipt }), + onAccepted: () => { + throw new Error("PRIVATE POST BODY"); + }, + }), + ).toEqual({ kind: "runtime-accepted", receipt }); + }); + it.each([ + { kind: "local-handled" as const }, + { kind: "unknown" as const }, + { kind: "rejected" as const }, + ])("does not finalize nonaccepted %j", async (result) => { + const onAccepted = vi.fn(); + expect( + await submitWorkspaceMessage({ submit: async () => result, onAccepted }), + ).toEqual(result); + expect(onAccepted).not.toHaveBeenCalled(); + }); + it("keeps original rejected error despite failing UI diagnostic", async () => { + const error = new Error("runtime rejected"); + const onRejected = vi.fn(() => { + throw new Error("UI error"); + }); + expect( + await submitWorkspaceMessage({ + submit: async () => { + throw error; + }, + onRejected, + }), + ).toEqual({ kind: "rejected", error }); + expect(onRejected).toHaveBeenCalledWith(error); + }); +}); + +describe("local submission draft policy", () => { + it.each([false, true])( + "preserves structured draft when requested %s", + async (preserveDraft) => { + const publish = vi.fn(), + clearDraft = vi.fn(); + expect( + await submitWorkspaceLocalConfirmation({ + preserveDraft, + publish, + clearDraft, + }), + ).toEqual({ kind: "local-handled" }); + expect(publish).toHaveBeenCalledOnce(); + expect(clearDraft).toHaveBeenCalledTimes(preserveDraft ? 0 : 1); + }, + ); +}); + +describe("accepted cleanup identity", () => { + it("cleanup throw retains accepted result", async () => { + const result = { kind: "runtime-accepted" as const, receipt }; + expect( + await withWorkspaceSubmissionCleanup( + async () => result, + () => { + throw new Error("PRIVATE CLEANUP BODY"); + }, + ), + ).toEqual(result); + }); + it("cleanup throw cannot mask original request rejection", async () => { + const original = new Error("runtime rejected"); + await expect( + withWorkspaceSubmissionCleanup( + async () => { + throw original; + }, + () => { + throw new Error("cleanup"); + }, + ), + ).rejects.toBe(original); + }); +}); diff --git a/src/components/agent/chat/workspace/workspaceSendSubmission.ts b/src/components/agent/chat/workspace/workspaceSendSubmission.ts new file mode 100644 index 000000000..ea0686f49 --- /dev/null +++ b/src/components/agent/chat/workspace/workspaceSendSubmission.ts @@ -0,0 +1,65 @@ +import type { SendMessageObserver } from "../hooks/agentChatShared"; +import { + createRuntimeSubmissionCapture, + isRuntimeSubmissionAccepted, + runRuntimeSubmissionEffect, + type AcceptedRuntimeSubmissionResult, + type RuntimeSubmissionResult, +} from "../hooks/agentSendResult"; + +export async function submitWorkspaceMessage(options: { + observer?: SendMessageObserver; + submit: (observer: SendMessageObserver) => Promise; + onAccepted?: (result: AcceptedRuntimeSubmissionResult) => void; + onRejected?: (error: unknown) => void; +}): Promise { + const capture = createRuntimeSubmissionCapture(options.observer); + try { + const result = capture.finish(await options.submit(capture.observer)); + if (isRuntimeSubmissionAccepted(result)) { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", () => + options.onAccepted?.(result), + ); + } + return result; + } catch (error) { + if (capture.accepted) return capture.reject(error); + try { + options.onRejected?.(error); + } catch { + console.error("[WorkspaceSend] rejection post-processing failed"); + } + return { kind: "rejected", error }; + } +} + +export async function submitWorkspaceLocalConfirmation(options: { + preserveDraft: boolean; + publish: () => void; + clearDraft: () => void; +}): Promise { + if (!options.preserveDraft) options.clearDraft(); + options.publish(); + return { kind: "local-handled" }; +} + +export async function withWorkspaceSubmissionCleanup( + submit: () => Promise, + cleanup: () => void, +): Promise { + let result: RuntimeSubmissionResult = { kind: "unknown" }; + try { + result = await submit(); + return result; + } finally { + if (isRuntimeSubmissionAccepted(result)) { + runRuntimeSubmissionEffect(result.receipt, "accepted_submit", cleanup); + } else { + try { + cleanup(); + } catch { + console.error("[WorkspaceSend] submission cleanup failed"); + } + } + } +} diff --git a/src/components/app-sidebar/useAppSidebarConversationActions.ts b/src/components/app-sidebar/useAppSidebarConversationActions.ts index 5255d3bfe..a1ead40b2 100644 --- a/src/components/app-sidebar/useAppSidebarConversationActions.ts +++ b/src/components/app-sidebar/useAppSidebarConversationActions.ts @@ -18,9 +18,8 @@ import type { import { recordAgentUiPerformanceMetric } from "@/lib/agentUiPerformanceMetrics"; import { buildClawAgentParams, - buildHomeAgentParams, + type StartNewConversation, } from "@/lib/workspace/navigation"; -import { requestTaskCenterDraftTask } from "@/components/agent/chat/taskCenterDraftTaskEvents"; import { requestExplicitInitialSessionNavigation } from "@/components/agent/chat/workspace/useWorkspaceInitialSessionNavigation"; import type { Page, PageParams } from "@/types/page"; import type { SidebarOpenedProjectSummary } from "@/components/app-sidebar/sidebarConversationGroups"; @@ -38,6 +37,7 @@ interface UseAppSidebarConversationActionsParams { projectScopedNavigationProjectId: string | null; requestedNavigationTargetRef: MutableRefObject; onNavigate: (page: Page, params?: PageParams) => void; + onStartNewConversation?: StartNewConversation; closeSidebarSearchDialog: () => void; deferConversationNavigation: () => void; beginSidebarSessionAction: (sessionId: string) => void; @@ -112,10 +112,10 @@ export function useAppSidebarConversationActions({ currentProjectId, currentSessionId, conversationDisplayProjects, - isAgentWorkspace, projectScopedNavigationProjectId, requestedNavigationTargetRef, onNavigate, + onStartNewConversation, closeSidebarSearchDialog, deferConversationNavigation, beginSidebarSessionAction, @@ -133,39 +133,12 @@ export function useAppSidebarConversationActions({ deleteConversationSuccessLabel, deleteConversationErrorLabel, }: UseAppSidebarConversationActionsParams) { - const tryOpenTaskCenterDraftFromSidebar = useCallback( - (projectId?: string | null) => { - return ( - isAgentWorkspace && - requestTaskCenterDraftTask({ source: "sidebar", projectId }) - ); - }, - [isAgentWorkspace], - ); - const navigateToHome = useCallback(() => { - if (tryOpenTaskCenterDraftFromSidebar(currentProjectId)) { - return; - } - - const targetParams = buildHomeAgentParams({ - projectId: currentProjectId ?? undefined, + onStartNewConversation?.({ + projectId: currentProjectId, + freshDraft: false, }); - navigateIfNeeded({ - target: { - page: "agent", - rawParams: targetParams, - paramsKey: serializeNavigationParams(targetParams), - }, - requestedNavigationTargetRef, - onNavigate, - }); - }, [ - currentProjectId, - onNavigate, - requestedNavigationTargetRef, - tryOpenTaskCenterDraftFromSidebar, - ]); + }, [currentProjectId, onStartNewConversation]); const navigateToWorkbench = useCallback( (fallbackSessionId?: string | null) => { @@ -227,32 +200,10 @@ export function useAppSidebarConversationActions({ ); const navigateToNewTask = useCallback( - (projectId?: string | null) => { - const normalizedProjectId = projectId?.trim() || null; - if ( - isAgentWorkspace && - requestTaskCenterDraftTask({ - source: "sidebar", - projectId: normalizedProjectId, - }) - ) { - return; - } - - const targetParams = buildHomeAgentParams({ - projectId: normalizedProjectId ?? undefined, - }); - navigateIfNeeded({ - target: { - page: "agent", - rawParams: targetParams, - paramsKey: serializeNavigationParams(targetParams), - }, - requestedNavigationTargetRef, - onNavigate, - }); + (projectId: string | null) => { + onStartNewConversation?.({ projectId, freshDraft: true }); }, - [isAgentWorkspace, onNavigate, requestedNavigationTargetRef], + [onStartNewConversation], ); const navigateToProjectNewTask = useCallback( @@ -482,7 +433,6 @@ export function useAppSidebarConversationActions({ moveSessionToSection, toggleSessionArchive, toggleSessionPinned, - tryOpenTaskCenterDraftFromSidebar, renameConversation, }; } diff --git a/src/components/input-kit/CodeBlockNodeView.test.ts b/src/components/input-kit/CodeBlockNodeView.test.ts new file mode 100644 index 000000000..0da46753c --- /dev/null +++ b/src/components/input-kit/CodeBlockNodeView.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Schema } from "@tiptap/pm/model"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import { history, redo, undo } from "@tiptap/pm/history"; +import { composerSchema } from "@/components/input-kit/composerSchema"; +import { CodeBlockNodeViewRegistry } from "./CodeBlockNodeViewRegistry"; + +const mounted: { + view: EditorView; + registry: CodeBlockNodeViewRegistry; + host: HTMLElement; +}[] = []; +afterEach(() => { + for (const { view, registry, host } of mounted.splice(0)) { + view.destroy(); + registry.destroy(); + host.remove(); + } +}); + +function mount( + options: { + schema?: Schema; + language?: string | null; + auto?: boolean; + getPos?: () => number | undefined; + } = {}, +) { + const schema = options.schema ?? composerSchema; + const node = schema.node( + "code_block", + { + language: options.language ?? null, + autoDetectLanguage: options.auto ?? true, + marker: "keep", + }, + schema.text("\n A😀\n"), + ); + const doc = schema.node("doc", null, [ + node, + schema.node("paragraph", null, schema.text("tail")), + ]); + const registry = new CodeBlockNodeViewRegistry(); + const host = document.createElement("div"); + document.body.append(host); + const dispatched = vi.fn(); + const view = new EditorView(host, { + state: EditorState.create({ doc, plugins: [history()] }), + nodeViews: { + code_block: (node, view, getPos) => + registry.createNodeView(node, view, options.getPos ?? getPos), + }, + dispatchTransaction(tr) { + dispatched(tr); + view.updateState(view.state.apply(tr)); + }, + }); + mounted.push({ view, registry, host }); + const entry = () => registry.getSnapshot()[0]; + return { view, registry, entry, host, dispatched }; +} + +describe("CodeBlockNodeView", () => { + it("uses toolbar + pre[dir=ltr] > code as PM contentDOM and no old data-language", () => { + const { host, entry } = mount({ language: "js", auto: false }); + const block = host.querySelector('[data-composer-code-block="true"]')!; + expect(block.tagName).toBe("DIV"); + expect(block.getAttribute("data-composer-code-block-wrap")).toBe("false"); + const toolbar = block.children[0]; + expect(toolbar).toBe(entry().toolbarPortalContainer); + expect(toolbar.getAttribute("contenteditable")).toBe("false"); + expect(block.children[1].getAttribute("dir")).toBe("ltr"); + const code = block.querySelector("pre > code")!; + expect(code.getAttribute("spellcheck")).toBe("false"); + expect(code.className).toBe("hljs language-javascript"); + expect(code.hasAttribute("data-language")).toBe(false); + expect(code.textContent).toBe("\n A😀\n"); + expect(entry().getContent()).toBe("\n A😀\n"); + }); + + it("language changes preserve body, other attrs and code selection; undo/redo use the same PM state", () => { + const codeSpec = composerSchema.spec.nodes.get("code_block")!; + const schema = new Schema({ + nodes: composerSchema.spec.nodes.update("code_block", { + ...codeSpec, + attrs: { ...codeSpec.attrs, marker: { default: "keep" } }, + }), + marks: composerSchema.spec.marks, + }); + const { view, entry, dispatched } = mount({ schema }); + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, 3, 6)), + ); + const before = view.state.doc; + dispatched.mockClear(); + expect(entry().selectLanguage(" TS ")).toBe(true); + expect(dispatched).toHaveBeenCalledTimes(1); + expect(view.state.doc.firstChild?.attrs).toMatchObject({ + language: "typescript", + autoDetectLanguage: false, + marker: "keep", + }); + expect(view.state.doc.firstChild?.textContent).toBe("\n A😀\n"); + expect(view.state.doc.lastChild).toBe(before.lastChild); + expect([view.state.selection.from, view.state.selection.to]).toEqual([ + 3, 6, + ]); + expect(undo(view.state, view.dispatch)).toBe(true); + expect(view.state.doc.eq(before)).toBe(true); + expect(redo(view.state, view.dispatch)).toBe(true); + expect(view.state.doc.firstChild?.attrs.language).toBe("typescript"); + }); + + it("Auto/null, explicit plaintext and unchanged language have distinct transaction contracts", () => { + const { view, entry, dispatched } = mount(); + expect(entry().selectLanguage("auto")).toBe(false); + expect(dispatched).not.toHaveBeenCalled(); + expect(entry().selectLanguage(null)).toBe(true); + expect(view.state.doc.firstChild?.attrs).toMatchObject({ + language: "plaintext", + autoDetectLanguage: false, + }); + dispatched.mockClear(); + expect(entry().selectLanguage("plaintext")).toBe(false); + expect(dispatched).not.toHaveBeenCalled(); + expect(entry().selectLanguage("auto")).toBe(true); + expect(view.state.doc.firstChild?.attrs).toMatchObject({ + language: null, + autoDetectLanguage: true, + }); + }); + + it("schema without auto attr supports Plain and explicit language but has no Auto action", () => { + const codeSpec = composerSchema.spec.nodes.get("code_block")!; + const schema = new Schema({ + nodes: composerSchema.spec.nodes.update("code_block", { + ...codeSpec, + attrs: { language: { default: null } }, + }), + marks: composerSchema.spec.marks, + }); + const { view, entry, dispatched } = mount({ schema, language: "python" }); + expect(entry().autoDetectLanguage).toBe(null); + expect(entry().selectLanguage("auto")).toBe(false); + expect(dispatched).not.toHaveBeenCalled(); + expect(entry().selectLanguage(null)).toBe(true); + expect(view.state.doc.firstChild?.attrs.language).toBe(null); + expect( + Object.hasOwn(view.state.doc.firstChild!.attrs, "autoDetectLanguage"), + ).toBe(false); + }); + + it.each([undefined, -1, 0.5, 999, Number.NaN])( + "rejects invalid/stale getPos %s", + (position) => { + const { entry, dispatched } = mount({ getPos: () => position }); + expect(entry().selectLanguage("python")).toBe(false); + expect(entry().getContent()).toBe(""); + expect(dispatched).not.toHaveBeenCalled(); + }, + ); + + it("rejects throwing getPos and same-type wrong node identity without editing another block", () => { + const first = mount({ + getPos: () => { + throw new Error("deleted"); + }, + }); + expect(first.entry().selectLanguage("python")).toBe(false); + expect(first.dispatched).not.toHaveBeenCalled(); + const second = mount(); + const original = second.view.state.doc.firstChild!; + const other = composerSchema.node( + "code_block", + null, + composerSchema.text("other"), + ); + let pos = 0; + const stale = second.registry.createNodeView( + original, + second.view, + () => pos, + ); + second.view.dispatch(second.view.state.tr.insert(original.nodeSize, other)); + pos = original.nodeSize; + second.dispatched.mockClear(); + expect(stale.selectLanguage("python")).toBe(false); + expect(second.view.state.doc.nodeAt(pos)?.textContent).toBe("other"); + expect(second.dispatched).not.toHaveBeenCalled(); + }); + + it("disabled/composing/destroyed or unregistered entry never dispatches or focuses", () => { + const { view, entry, registry, dispatched } = mount(); + const oldEntry = entry(); + const focus = vi.spyOn(view, "focus"); + view.setProps({ editable: () => false }); + expect(oldEntry.selectLanguage("python")).toBe(false); + oldEntry.focusEditor(); + expect(focus).not.toHaveBeenCalled(); + view.setProps({ editable: () => true }); + const composing = vi.spyOn(view, "composing", "get").mockReturnValue(true); + expect(oldEntry.selectLanguage("python")).toBe(false); + composing.mockRestore(); + registry.unregisterNodeView(oldEntry.id); + expect(oldEntry.selectLanguage("python")).toBe(false); + expect(oldEntry.getContent()).toBe(""); + view.destroy(); + expect(oldEntry.selectLanguage("python")).toBe(false); + expect(dispatched).not.toHaveBeenCalled(); + }); + + it("unknown saved language is displayed safely, unknown manual selection cannot write", () => { + const { entry, host, dispatched } = mount({ + language: "custom-lang", + auto: false, + }); + expect(entry().language).toBe("custom-lang"); + expect(host.querySelector("code")?.className).toBe( + "hljs language-custom-lang", + ); + expect(entry().selectLanguage("custom-lang")).toBe(false); + expect(dispatched).not.toHaveBeenCalled(); + }); + + it("metadata update preserves PM code text nodes and isolates only presentation mutations/events", () => { + const { view, registry } = mount(); + const nodeView = registry.createNodeView( + view.state.doc.firstChild!, + view, + () => 0, + ); + const text = document.createTextNode("sentinel"); + nodeView.contentDOM.append(text); + const node = view.state.doc.firstChild!.type.create( + { language: "python", autoDetectLanguage: false }, + view.state.doc.firstChild!.content, + ); + expect(nodeView.update(node)).toBe(true); + expect(nodeView.contentDOM.firstChild).toBe(text); + expect(nodeView.contentDOM.textContent).toBe("sentinel"); + const mutation = (type: string, target: Node) => + ({ type, target }) as unknown as MutationRecord; + expect(nodeView.ignoreMutation(mutation("characterData", text))).toBe( + false, + ); + expect( + nodeView.ignoreMutation(mutation("childList", nodeView.contentDOM)), + ).toBe(false); + expect( + nodeView.ignoreMutation(mutation("attributes", nodeView.contentDOM)), + ).toBe(true); + expect(nodeView.ignoreMutation({ type: "selection", target: text })).toBe( + false, + ); + const button = document.createElement("button"); + nodeView.toolbarPortalContainer.append(button); + expect(nodeView.ignoreMutation(mutation("childList", button))).toBe(true); + const toolbarEvent = new Event("keydown"); + Object.defineProperty(toolbarEvent, "target", { value: button }); + expect(nodeView.stopEvent(toolbarEvent)).toBe(true); + const codeEvent = new Event("keydown"); + Object.defineProperty(codeEvent, "target", { value: text }); + expect(nodeView.stopEvent(codeEvent)).toBe(false); + expect(nodeView.update(composerSchema.node("paragraph"))).toBe(false); + nodeView.destroy(); + nodeView.destroy(); + expect(nodeView.update(node)).toBe(false); + }); +}); diff --git a/src/components/input-kit/CodeBlockNodeView.ts b/src/components/input-kit/CodeBlockNodeView.ts new file mode 100644 index 000000000..c06faff8e --- /dev/null +++ b/src/components/input-kit/CodeBlockNodeView.ts @@ -0,0 +1,188 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorView, NodeView, ViewMutationRecord } from "@tiptap/pm/view"; +import type { + CodeBlockNodeViewEntry, + CodeBlockNodeViewRegistry, +} from "./CodeBlockNodeViewRegistry"; +import { normalizeCodeBlockLanguage } from "./composerCodeBlockLanguage"; + +/** Presentation around PM's contentDOM; the editor owns all code children. */ +export class CodeBlockNodeView implements NodeView { + readonly dom: HTMLDivElement; + readonly contentDOM: HTMLElement; + readonly toolbarPortalContainer: HTMLDivElement; + private readonly pre: HTMLPreElement; + private destroyed = false; + + constructor( + private node: ProseMirrorNode, + private readonly view: EditorView, + private readonly getPos: () => number | undefined, + private readonly registry: CodeBlockNodeViewRegistry, + readonly id: string, + ) { + if (node.type.name !== "code_block") throw new Error("Expected code_block"); + const document = view.dom.ownerDocument; + this.dom = document.createElement("div"); + this.dom.dataset.composerCodeBlock = "true"; + this.dom.dataset.composerCodeBlockWrap = "false"; + this.toolbarPortalContainer = document.createElement("div"); + this.toolbarPortalContainer.dataset.composerCodeBlockToolbar = "true"; + this.toolbarPortalContainer.contentEditable = "false"; + this.toolbarPortalContainer.setAttribute("contenteditable", "false"); + this.pre = document.createElement("pre"); + this.pre.dir = "ltr"; + this.contentDOM = document.createElement("code"); + this.contentDOM.setAttribute("spellcheck", "false"); + this.pre.append(this.contentDOM); + this.dom.append(this.toolbarPortalContainer, this.pre); + this.updateCodeElement(); + } + + update(node: ProseMirrorNode): boolean { + if (this.destroyed || node.type !== this.node.type) return false; + const changed = !node.eq(this.node); + this.node = node; + this.updateCodeElement(); + if (changed) this.registry.publishNodeViewUpdate(this.id, this); + return true; + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + this.registry.unregisterNodeView(this.id, this); + } + + ignoreMutation(mutation: ViewMutationRecord): boolean { + if (this.toolbarPortalContainer.contains(mutation.target)) return true; + return ( + mutation.type === "attributes" && + (mutation.target === this.dom || + mutation.target === this.pre || + mutation.target === this.contentDOM) + ); + } + + stopEvent(event: Event): boolean { + return ( + !!event.target && + this.toolbarPortalContainer.contains(event.target as globalThis.Node) + ); + } + + createEntry(): CodeBlockNodeViewEntry { + return Object.freeze({ + id: this.id, + language: this.getLanguage(), + autoDetectLanguage: this.getAutoDetectLanguage(), + selectLanguage: this.selectLanguage, + focusEditor: this.focusEditor, + getContent: this.getContent, + toolbarPortalContainer: this.toolbarPortalContainer, + }); + } + + readonly selectLanguage = (selection: string | null): boolean => { + if (!this.canEdit()) return false; + const current = this.currentNode(); + if (!current) return false; + const supportsAuto = Object.prototype.hasOwnProperty.call( + current.node.type.spec.attrs ?? {}, + "autoDetectLanguage", + ); + if (selection === "auto" && !supportsAuto) return false; + const explicit = + selection === null || selection === "auto" + ? null + : normalizeCodeBlockLanguage(selection); + if (selection !== null && selection !== "auto" && explicit === null) + return false; + const language = + selection === "auto" + ? null + : selection === null + ? supportsAuto + ? "plaintext" + : null + : explicit; + const attrs = { + ...current.node.attrs, + language, + ...(supportsAuto ? { autoDetectLanguage: selection === "auto" } : {}), + }; + if ( + attrs.language === current.node.attrs.language && + (!supportsAuto || + attrs.autoDetectLanguage === current.node.attrs.autoDetectLanguage) + ) + return false; + // Recheck guards after getPos/current-node reads; no stale toolbar writes. + if (!this.canEdit()) return false; + this.view.dispatch( + this.view.state.tr.setNodeMarkup(current.pos, undefined, attrs), + ); + return true; + }; + + readonly focusEditor = (): void => { + if (this.canEdit() && this.currentNode()) this.view.focus(); + }; + + readonly getContent = (): string => + this.currentNode()?.node.textContent ?? ""; + + getAutoDetectLanguage(): boolean | null { + return Object.prototype.hasOwnProperty.call( + this.node.type.spec.attrs ?? {}, + "autoDetectLanguage", + ) + ? this.node.attrs.autoDetectLanguage !== false + : null; + } + + updateCodeElement(): void { + const language = this.getLanguage(); + this.dom.dataset.composerCodeBlockWrap = "false"; + this.contentDOM.className = + "hljs" + (language ? ` language-${language}` : ""); + } + + private getLanguage(): string | null { + const raw = this.node.attrs.language; + return ( + normalizeCodeBlockLanguage(raw) ?? + (typeof raw === "string" && /^[A-Za-z0-9_+#.-]+$/.test(raw) ? raw : null) + ); + } + + private canEdit(): boolean { + return !this.destroyed && this.registry.canEdit(this.id, this, this.view); + } + + private currentNode(): { pos: number; node: ProseMirrorNode } | null { + if ( + this.destroyed || + this.view.isDestroyed || + !this.registry.isOwnerCurrent(this.id, this, this.view) + ) + return null; + let pos: number | undefined; + try { + pos = this.getPos(); + } catch { + return null; + } + if ( + pos === undefined || + !Number.isInteger(pos) || + pos < 0 || + pos >= this.view.state.doc.content.size + ) + return null; + const node = this.view.state.doc.nodeAt(pos); + return node?.type === this.node.type && node === this.node + ? { pos, node } + : null; + } +} diff --git a/src/components/input-kit/CodeBlockNodeViewRegistry.test.ts b/src/components/input-kit/CodeBlockNodeViewRegistry.test.ts new file mode 100644 index 000000000..dd19d3f15 --- /dev/null +++ b/src/components/input-kit/CodeBlockNodeViewRegistry.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import { composerSchema } from "@/components/input-kit/composerSchema"; +import { CodeBlockNodeViewRegistry } from "./CodeBlockNodeViewRegistry"; + +const cleanups: (() => void)[] = []; +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())); +function setup() { + const doc = composerSchema.node( + "doc", + null, + ["one", "two"].map((text) => + composerSchema.node("code_block", null, composerSchema.text(text)), + ), + ); + const host = document.createElement("div"); + document.body.append(host); + const view = new EditorView(host, { state: EditorState.create({ doc }) }); + const registry = new CodeBlockNodeViewRegistry(); + cleanups.push(() => { + view.destroy(); + registry.destroy(); + host.remove(); + }); + return { view, registry }; +} + +describe("CodeBlockNodeViewRegistry", () => { + it("snapshot identity stays stable between publications; entries/id/container remain distinct", () => { + const { view, registry } = setup(); + const initial = registry.getSnapshot(); + expect(registry.getSnapshot()).toBe(initial); + const listener = vi.fn(); + const unsubscribe = registry.subscribe(listener); + const first = registry.createNodeView( + view.state.doc.firstChild!, + view, + () => 0, + ); + const one = registry.getSnapshot(); + const second = registry.createNodeView( + view.state.doc.lastChild!, + view, + () => view.state.doc.firstChild!.nodeSize, + ); + const two = registry.getSnapshot(); + expect(two).not.toBe(one); + expect(registry.getSnapshot()).toBe(two); + expect(new Set(two.map((entry) => entry.id)).size).toBe(2); + expect(first.toolbarPortalContainer).not.toBe( + second.toolbarPortalContainer, + ); + expect(two.map((entry) => entry.getContent())).toEqual(["one", "two"]); + expect(listener).toHaveBeenCalledTimes(2); + expect(first.update(view.state.doc.firstChild!)).toBe(true); + expect(registry.getSnapshot()).toBe(two); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + first.destroy(); + expect(registry.getSnapshot()).toHaveLength(1); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("wrong-instance unregister/update cannot publish or remove another entry", () => { + const { view, registry } = setup(); + const first = registry.createNodeView( + view.state.doc.firstChild!, + view, + () => 0, + ); + const second = registry.createNodeView( + view.state.doc.lastChild!, + view, + () => 5, + ); + const snapshot = registry.getSnapshot(); + registry.unregisterNodeView(first.id, second); + registry.publishNodeViewUpdate(first.id, second); + expect(registry.getSnapshot()).toBe(snapshot); + expect(registry.isOwnerCurrent(first.id, first, view)).toBe(true); + expect(registry.isOwnerCurrent(first.id, second, view)).toBe(false); + }); + + it("destroy clears snapshot once, invalidates old entry, and supports repeated cleanup/unsubscribe", () => { + const { view, registry } = setup(); + registry.createNodeView(view.state.doc.firstChild!, view, () => 0); + const entry = registry.getSnapshot()[0]; + const listener = vi.fn(); + const unsubscribe = registry.subscribe(listener); + registry.destroy(); + const empty = registry.getSnapshot(); + registry.destroy(); + expect(registry.getSnapshot()).toBe(empty); + expect(empty).toEqual([]); + expect(listener).toHaveBeenCalledTimes(1); + expect(entry.selectLanguage("python")).toBe(false); + expect(entry.getContent()).toBe(""); + unsubscribe(); + unsubscribe(); + expect(() => + registry.createNodeView(view.state.doc.firstChild!, view, () => 0), + ).toThrow("destroyed"); + }); +}); diff --git a/src/components/input-kit/CodeBlockNodeViewRegistry.ts b/src/components/input-kit/CodeBlockNodeViewRegistry.ts new file mode 100644 index 000000000..42c045e9f --- /dev/null +++ b/src/components/input-kit/CodeBlockNodeViewRegistry.ts @@ -0,0 +1,89 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { EditorView } from "@tiptap/pm/view"; +import { CodeBlockNodeView } from "./CodeBlockNodeView"; + +export interface CodeBlockNodeViewEntry { + readonly id: string; + readonly language: string | null; + readonly autoDetectLanguage: boolean | null; + readonly selectLanguage: (language: string | null) => boolean; + readonly focusEditor: () => void; + readonly getContent: () => string; + readonly toolbarPortalContainer: HTMLElement; +} + +/** Controller-owned registry; entries contain metadata, never editable code. */ +export class CodeBlockNodeViewRegistry { + private readonly nodeViews = new Map(); + private readonly listeners = new Set<() => void>(); + private snapshot: readonly CodeBlockNodeViewEntry[] = Object.freeze([]); + private sequence = 0; + private destroyed = false; + + readonly createNodeView = ( + node: ProseMirrorNode, + view: EditorView, + getPos: () => number | undefined, + ): CodeBlockNodeView => { + if (this.destroyed) throw new Error("Code block registry is destroyed"); + const id = `code-block-${++this.sequence}`; + const nodeView = new CodeBlockNodeView(node, view, getPos, this, id); + this.nodeViews.set(id, nodeView); + this.publish(); + return nodeView; + }; + + readonly getSnapshot = (): readonly CodeBlockNodeViewEntry[] => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + if (this.destroyed) return () => undefined; + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + unregisterNodeView(id: string, nodeView?: CodeBlockNodeView): void { + if (nodeView && this.nodeViews.get(id) !== nodeView) return; + if (this.nodeViews.delete(id)) this.publish(); + } + + publishNodeViewUpdate(id: string, nodeView: CodeBlockNodeView): void { + if (this.nodeViews.get(id) === nodeView) this.publish(); + } + + isOwnerCurrent( + id: string, + nodeView: CodeBlockNodeView, + view: EditorView, + ): boolean { + return ( + !this.destroyed && + this.nodeViews.get(id) === nodeView && + !view.isDestroyed + ); + } + + canEdit(id: string, nodeView: CodeBlockNodeView, view: EditorView): boolean { + return ( + this.isOwnerCurrent(id, nodeView, view) && + view.editable && + !view.composing + ); + } + + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + const nodeViews = [...this.nodeViews.values()]; + this.nodeViews.clear(); + for (const nodeView of nodeViews) nodeView.destroy(); + this.publish(); + this.listeners.clear(); + } + + private publish(): void { + this.snapshot = Object.freeze( + [...this.nodeViews.values()].map((nodeView) => nodeView.createEntry()), + ); + for (const listener of this.listeners) listener(); + } +} diff --git a/src/components/input-kit/CodeBlockToolbar.css b/src/components/input-kit/CodeBlockToolbar.css new file mode 100644 index 000000000..9cd46d7ad --- /dev/null +++ b/src/components/input-kit/CodeBlockToolbar.css @@ -0,0 +1,107 @@ +[data-composer-code-block="true"] { + min-width: 0; + max-width: 100%; + overflow: clip; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius, 0.5rem); + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +[data-composer-code-block-toolbar="true"] { + position: sticky; + top: 0; + z-index: 1; + min-height: 2rem; + background: hsl(var(--muted)); + font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); + user-select: none; +} + +.composer-code-toolbar { + display: flex; + padding: 0.125rem 0.375rem; +} +.composer-code-language-trigger { + height: 1.75rem; + gap: 0.375rem; + padding: 0 0.375rem; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; +} +.composer-code-language-trigger:hover { + background: hsl(var(--accent)); +} +.composer-code-language-trigger:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} +.composer-code-language-menu { + width: 15rem; + max-width: calc(100vw - 1rem); + padding: 0; +} +.composer-code-language-menu [cmdk-input] { + height: 2.25rem; +} +.composer-code-language-menu [cmdk-item] { + gap: 0.5rem; +} + +[data-composer-code-block="true"] > pre { + margin: 0; + padding: 0.5rem 0.75rem 0.75rem; + max-width: 100%; + overflow-x: auto; + tab-size: 4; + font-family: var( + --font-mono, + ui-monospace, + SFMono-Regular, + Consolas, + monospace + ); +} +[data-composer-code-block="true"] > pre > code { + display: block; + white-space: pre; + overflow-wrap: normal; + word-break: normal; + font: inherit; + background: transparent; + color: inherit; +} + +/* PM token Decorations reuse the active Lime theme. */ +[data-composer-code-block="true"] .hljs-comment, +[data-composer-code-block="true"] .hljs-quote { + color: var(--lime-text-muted, hsl(var(--muted-foreground))); + font-style: italic; +} +[data-composer-code-block="true"] .hljs-keyword, +[data-composer-code-block="true"] .hljs-selector-tag, +[data-composer-code-block="true"] .hljs-built_in { + color: var(--lime-brand-strong, hsl(var(--primary))); + font-weight: 600; +} +[data-composer-code-block="true"] .hljs-string, +[data-composer-code-block="true"] .hljs-regexp, +[data-composer-code-block="true"] .hljs-symbol, +[data-composer-code-block="true"] .hljs-addition { + color: var(--lime-brand, hsl(var(--foreground))); +} +[data-composer-code-block="true"] .hljs-number, +[data-composer-code-block="true"] .hljs-literal, +[data-composer-code-block="true"] .hljs-type, +[data-composer-code-block="true"] .hljs-title, +[data-composer-code-block="true"] .hljs-attribute, +[data-composer-code-block="true"] .hljs-attr { + color: var(--lime-info, hsl(var(--foreground))); +} +[data-composer-code-block="true"] .hljs-meta, +[data-composer-code-block="true"] .hljs-deletion { + color: var(--lime-danger, hsl(var(--destructive))); +} +[data-composer-code-block="true"] .hljs-subst { + color: var(--lime-text, hsl(var(--foreground))); +} diff --git a/src/components/input-kit/CodeBlockToolbar.locales.component.test.tsx b/src/components/input-kit/CodeBlockToolbar.locales.component.test.tsx new file mode 100644 index 000000000..dcf318b0e --- /dev/null +++ b/src/components/input-kit/CodeBlockToolbar.locales.component.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { ComposerEditor } from "@/components/input-kit/ComposerEditor"; +import { changeLimeLocale } from "@/i18n/createI18n"; + +const mounted: { root: Root; container: HTMLElement }[] = []; + +afterEach(async () => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + await changeLimeLocale("en-US"); +}); + +describe("CodeBlockToolbar actual locale resources", () => { + it.each([ + ["zh-CN", "纯文本", "代码语言:纯文本", "自动检测", "自动检测代码语言"], + ["zh-TW", "純文字", "程式碼語言:純文字", "自動偵測", "自動偵測程式碼語言"], + [ + "en-US", + "Plain text", + "Code language: Plain text", + "Auto detect", + "Auto detect code language", + ], + [ + "ja-JP", + "プレーンテキスト", + "コードの言語: プレーンテキスト", + "自動検出", + "コードの言語を自動検出", + ], + [ + "ko-KR", + "일반 텍스트", + "코드 언어: 일반 텍스트", + "자동 감지", + "코드 언어 자동 감지", + ], + ] as const)( + "%s 的实际主编辑器消费翻译并区分 Plain 与 Auto", + async (locale, plain, plainLabel, auto, autoLabel) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + await changeLimeLocale(locale); + const controller = new ComposerController({ + editorDocument: { + type: "doc", + content: [ + { + type: "code_block", + attrs: { language: "plaintext", autoDetectLanguage: false }, + content: [{ type: "text", text: "body 😀" }], + }, + ], + }, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => root.render()); + const button = () => + container.querySelector( + '[data-composer-code-block-toolbar="true"] button', + ); + expect(button()?.getAttribute("aria-label")).toBe(plainLabel); + expect(button()?.textContent).toBe(plain); + act(() => + controller.codeBlockNodeViewRegistry + .getSnapshot()[0] + .selectLanguage("auto"), + ); + expect(button()?.getAttribute("aria-label")).toBe(autoLabel); + expect(button()?.textContent).toBe(auto); + expect(container.querySelector("code")?.textContent).toBe("body 😀"); + }, + ); +}); diff --git a/src/components/input-kit/CodeBlockToolbar.test.tsx b/src/components/input-kit/CodeBlockToolbar.test.tsx new file mode 100644 index 000000000..d1c9cc6a4 --- /dev/null +++ b/src/components/input-kit/CodeBlockToolbar.test.tsx @@ -0,0 +1,330 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import { composerSchema } from "@/components/input-kit/composerSchema"; +import { CodeBlockNodeViewRegistry } from "./CodeBlockNodeViewRegistry"; +import { CodeBlockToolbar, CodeBlockToolbars } from "./CodeBlockToolbar"; +import type { CodeBlockNodeViewEntry } from "./CodeBlockNodeViewRegistry"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; +beforeAll(() => { + // JSDOM only: these no-layout shims do not establish geometry/native input. + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: () => undefined, + }); + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: class { + observe() {} + unobserve() {} + disconnect() {} + }, + }); +}); +const mounted: { root: Root; host: HTMLElement; cleanup?: () => void }[] = []; +afterEach(async () => { + for (const { root, host, cleanup } of mounted.splice(0)) { + await act(async () => { + root.unmount(); + cleanup?.(); + }); + host.remove(); + } + expect(document.querySelector("[data-radix-popper-content-wrapper]")).toBe( + null, + ); +}); + +const prefix = "composer.codeBlock."; +const messages = { + "language.none": "Plain text", + "language.auto": "Auto detect", + languagePickerLabel: "Choose {{language}}", + autoLanguagePickerLabel: "Choose Auto", + autoPlainTextLanguage: "Auto (Plain text)", + autoPlainTextLanguagePickerLabel: "Choose Auto Plain", + autoDetectedLanguage: "Auto ({{language}})", + autoDetectedLanguagePickerLabel: "Choose Auto {{language}}", + searchLanguages: "Search languages", + noLanguagesFound: "No languages found", + "language.math": "Math", + "language.plaintext": "Plain text", +}; +async function i18nFor(locale = "en-US") { + const i18n = createInstance(); + const translation = Object.fromEntries( + Object.entries(messages).map(([key, value]) => [ + prefix + key, + locale === "en-US" ? value : locale + " " + value, + ]), + ); + await i18n.init({ + lng: locale, + fallbackLng: false, + keySeparator: false, + resources: { [locale]: { agent: translation } }, + interpolation: { escapeValue: false }, + }); + return i18n; +} +function entry( + overrides: Partial = {}, +): CodeBlockNodeViewEntry { + const toolbarPortalContainer = document.createElement("div"); + return { + id: "entry", + language: null, + autoDetectLanguage: true, + selectLanguage: vi.fn(() => true), + focusEditor: vi.fn(), + getContent: () => "code", + toolbarPortalContainer, + ...overrides, + }; +} +async function renderToolbar( + current: CodeBlockNodeViewEntry, + locale = "en-US", + disabled = false, +) { + const i18n = await i18nFor(locale); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + mounted.push({ root, host }); + const render = async (item = current, nextDisabled = disabled) => { + await act(async () => + root.render( + + + , + ), + ); + }; + await render(); + return { host, render }; +} +async function click(element: Element | null) { + if (!element) throw new Error("Missing click target"); + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + // Radix schedules close autofocus after the committed unmount. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} +async function search(value: string) { + const input = document.querySelector("[cmdk-input]"); + if (!input) throw new Error("Missing language search"); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +describe("CodeBlockToolbar", () => { + it.each([ + [null, true, "Auto detect", "Choose Auto"], + ["plaintext", true, "Auto (Plain text)", "Choose Auto Plain"], + ["python", true, "Auto (Python)", "Choose Auto Python"], + ["python", false, "Python", "Choose Python"], + ["plaintext", false, "Plain text", "Choose Plain text"], + [null, null, "Plain text", "Choose Plain text"], + ["custom-lang", false, "custom-lang", "Choose custom-lang"], + ] as const)( + "metadata %s/%s determines label/aria without inspecting code", + async (language, auto, label, aria) => { + const current = entry({ + language, + autoDetectLanguage: auto, + getContent: () => { + throw new Error("UI must not read body"); + }, + }); + const { host } = await renderToolbar(current); + const button = host.querySelector("button")!; + expect(button.textContent).toBe(label); + expect(button.getAttribute("aria-label")).toBe(aria); + }, + ); + + it.each(["zh-CN", "zh-TW", "en-US", "ja-JP", "ko-KR"])( + "consumes translated picker/search/empty keys in %s fixtures", + async (locale) => { + const { host } = await renderToolbar( + entry({ language: "math", autoDetectLanguage: false }), + locale, + ); + const translated = (value: string) => + locale === "en-US" ? value : locale + " " + value; + expect(host.querySelector("button")?.textContent).toBe( + translated("Math"), + ); + expect(host.querySelector("button")?.getAttribute("aria-label")).toBe( + translated("Choose " + translated("Math")), + ); + await click(host.querySelector("button")); + expect( + document.querySelector("[cmdk-input]")?.getAttribute("aria-label"), + ).toBe(translated("Search languages")); + await search("no-language-matches-123"); + expect(document.querySelector("[cmdk-empty]")?.textContent).toBe( + translated("No languages found"), + ); + }, + ); + + it("search trims/lowercases label/value, selecting canonical language closes and restores editor focus", async () => { + const current = entry(); + const { host } = await renderToolbar(current); + await click(host.querySelector("button")); + await search(" TyPeScRiPt "); + expect( + [...document.querySelectorAll("[data-code-language]")].map((item) => + item.getAttribute("data-code-language"), + ), + ).toEqual(["typescript"]); + await click(document.querySelector('[data-code-language="typescript"]')); + expect(current.selectLanguage).toHaveBeenCalledWith("typescript"); + expect(current.focusEditor).toHaveBeenCalledTimes(1); + expect(document.querySelector("[cmdk-input]")).toBe(null); + await click(host.querySelector("button")); + expect( + (document.querySelector("[cmdk-input]") as HTMLInputElement).value, + ).toBe(""); + }); + + it("Auto/Plain actions are distinct; schema without auto has no Auto option", async () => { + const current = entry(); + const { host, render } = await renderToolbar(current); + await click(host.querySelector("button")); + await click(document.querySelector('[data-code-language="plaintext"]')); + expect(current.selectLanguage).toHaveBeenLastCalledWith(null); + await click(host.querySelector("button")); + await click(document.querySelector('[data-code-language="auto"]')); + expect(current.selectLanguage).toHaveBeenLastCalledWith("auto"); + await render(entry({ autoDetectLanguage: null })); + await click(host.querySelector("button")); + expect(document.querySelector('[data-code-language="auto"]')).toBe(null); + }); + + it("search/menu keyboard and paste do not bubble into editor submit handlers; Escape restores focus", async () => { + const current = entry(); + const { host } = await renderToolbar(current); + const key = vi.fn(); + const paste = vi.fn(); + host.addEventListener("keydown", key); + host.addEventListener("paste", paste); + await click(host.querySelector("button")); + const input = document.querySelector("[cmdk-input]")!; + await act(async () => { + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "a", bubbles: true }), + ); + input.dispatchEvent(new Event("paste", { bubbles: true })); + }); + expect(key).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + await act(async () => { + input.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(document.querySelector("[cmdk-input]")).toBe(null); + expect(current.focusEditor).toHaveBeenCalled(); + expect(current.selectLanguage).not.toHaveBeenCalled(); + }); + + it("disabled picker cannot open or select, and disabling an open menu discards search state", async () => { + const current = entry(); + const { host, render } = await renderToolbar(current, "en-US", true); + await click(host.querySelector("button")); + expect(document.querySelector("[cmdk-input]")).toBe(null); + expect(current.selectLanguage).not.toHaveBeenCalled(); + await render(current, false); + await click(host.querySelector("button")); + await search("python"); + await render(current, true); + expect(document.querySelector("[cmdk-input]")).toBe(null); + await render(current, false); + expect(document.querySelector("[cmdk-input]")).toBe(null); + await click(host.querySelector("button")); + expect( + (document.querySelector("[cmdk-input]") as HTMLInputElement).value, + ).toBe(""); + }); + + it("two real NodeViews get one Portal each; mutation/delete/destroy unmount menus and subscriptions", async () => { + const registry = new CodeBlockNodeViewRegistry(); + const host = document.createElement("div"); + document.body.append(host); + const editorHost = document.createElement("div"); + host.append(editorHost); + const node = (text: string) => + composerSchema.node("code_block", null, composerSchema.text(text)); + const view = new EditorView(editorHost, { + state: EditorState.create({ + doc: composerSchema.node("doc", null, [node("one"), node("two")]), + }), + nodeViews: { code_block: registry.createNodeView }, + dispatchTransaction(tr) { + view.updateState(view.state.apply(tr)); + }, + }); + const reactHost = document.createElement("div"); + host.append(reactHost); + const root = createRoot(reactHost); + const i18n = await i18nFor(); + mounted.push({ + root, + host, + cleanup: () => { + view.destroy(); + registry.destroy(); + }, + }); + await act(async () => + root.render( + + + + + , + ), + ); + expect(host.querySelectorAll(".composer-code-toolbar")).toHaveLength(2); + const old = registry.getSnapshot()[0]; + await click(old.toolbarPortalContainer.querySelector("button")); + await click(document.querySelector('[data-code-language="python"]')); + expect(view.state.doc.firstChild?.attrs.language).toBe("python"); + expect(view.state.doc.firstChild?.textContent).toBe("one"); + expect( + old.toolbarPortalContainer.querySelector("button")?.textContent, + ).toBe("Python"); + await act(async () => + view.dispatch( + view.state.tr.delete(0, view.state.doc.firstChild!.nodeSize), + ), + ); + expect(host.querySelectorAll(".composer-code-toolbar")).toHaveLength(1); + expect(old.selectLanguage("typescript")).toBe(false); + await act(async () => view.destroy()); + expect(registry.getSnapshot()).toHaveLength(0); + expect(host.querySelectorAll(".composer-code-toolbar")).toHaveLength(0); + await act(async () => registry.destroy()); + }); +}); diff --git a/src/components/input-kit/CodeBlockToolbar.tsx b/src/components/input-kit/CodeBlockToolbar.tsx new file mode 100644 index 000000000..bbc152987 --- /dev/null +++ b/src/components/input-kit/CodeBlockToolbar.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import type { + CodeBlockNodeViewEntry, + CodeBlockNodeViewRegistry, +} from "./CodeBlockNodeViewRegistry"; +import { + codeBlockLanguages, + codeBlockLanguageLabelKey, + normalizeCodeBlockLanguage, +} from "./composerCodeBlockLanguage"; +import "./CodeBlockToolbar.css"; + +export interface CodeBlockToolbarProps { + entry: CodeBlockNodeViewEntry; + disabled?: boolean; +} + +/** The Portal owns only menu/search state; code remains in PM's contentDOM. */ +export function CodeBlockToolbar({ + entry, + disabled = false, +}: CodeBlockToolbarProps) { + const { t } = useTranslation("agent"); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + useEffect(() => { + if (disabled) { + setOpen(false); + setQuery(""); + } + }, [disabled]); + const languageLabel = (value: string): string => { + const language = normalizeCodeBlockLanguage(value) ?? value; + const known = codeBlockLanguages.find((item) => item.value === language); + const key = codeBlockLanguageLabelKey(language); + if (key === "composer.codeBlock.language.math") + return t("composer.codeBlock.language.math"); + if (key === "composer.codeBlock.language.plaintext") + return t("composer.codeBlock.language.plaintext"); + return known?.label ?? language; + }; + const none = t("composer.codeBlock.language.none"); + const auto = t("composer.codeBlock.language.auto"); + const language = entry.language; + const autoEnabled = entry.autoDetectLanguage === true; + const plain = !language || language === "plaintext"; + const label = autoEnabled + ? !language + ? auto + : plain + ? t("composer.codeBlock.autoPlainTextLanguage") + : t("composer.codeBlock.autoDetectedLanguage", { + language: languageLabel(language), + }) + : plain + ? none + : languageLabel(language); + const pickerLabel = autoEnabled + ? !language + ? t("composer.codeBlock.autoLanguagePickerLabel") + : plain + ? t("composer.codeBlock.autoPlainTextLanguagePickerLabel") + : t("composer.codeBlock.autoDetectedLanguagePickerLabel", { + language: languageLabel(language), + }) + : t("composer.codeBlock.languagePickerLabel", { + language: plain ? none : languageLabel(language), + }); + const search = query.trim().toLowerCase(); + const matches = (label: string, value: string): boolean => + !search || + label.toLowerCase().includes(search) || + value.toLowerCase().includes(search); + const options = [ + ...(entry.autoDetectLanguage === null + ? [] + : [{ value: "auto", label: auto, selection: "auto" as string | null }]), + { value: "plaintext", label: none, selection: null as string | null }, + ...codeBlockLanguages + .filter((item) => item.value !== "plaintext") + .map((item) => ({ + value: item.value, + label: languageLabel(item.value), + selection: item.value, + })), + ].filter((item) => matches(item.label, item.value)); + const selectedValue = autoEnabled ? "auto" : plain ? "plaintext" : language; + const changeOpen = (value: boolean) => { + setOpen(value); + if (!value) setQuery(""); + }; + const select = (value: string | null) => { + if (disabled) return; + entry.selectLanguage(value); + changeOpen(false); + }; + const stop = (event: React.SyntheticEvent) => event.stopPropagation(); + + return ( +
+ + + + + { + event.preventDefault(); + if (!disabled) entry.focusEditor(); + }} + onKeyDown={stop} + onPaste={stop} + onBeforeInput={stop} + onCompositionStart={stop} + onCompositionEnd={stop} + > + + + + + {t("composer.codeBlock.noLanguagesFound")} + + + {options.map((item) => ( + select(item.selection)} + disabled={disabled} + data-code-language={item.value} + > + + ))} + + + + + +
+ ); +} + +export function CodeBlockToolbars({ + registry, + disabled, +}: { + registry: CodeBlockNodeViewRegistry; + disabled?: boolean; +}) { + const entries = useSyncExternalStore( + registry.subscribe, + registry.getSnapshot, + registry.getSnapshot, + ); + return ( + <> + {entries.map((entry) => + createPortal( + , + entry.toolbarPortalContainer, + entry.id, + ), + )} + + ); +} diff --git a/src/components/input-kit/ComposerController.ts b/src/components/input-kit/ComposerController.ts index 4e6b1234f..e316bfabf 100644 --- a/src/components/input-kit/ComposerController.ts +++ b/src/components/input-kit/ComposerController.ts @@ -4,8 +4,6 @@ import { type Node as ProseMirrorNode, } from "@tiptap/pm/model"; import { EditorState, type Transaction } from "@tiptap/pm/state"; -import { history } from "@tiptap/pm/history"; -import { createComposerEditingKeymap } from "./composerKeymap"; import { composerDocumentFromJSON, composerDocumentFromText, @@ -20,12 +18,11 @@ import { } from "./composerSerialization"; import { composerLinkDefaults, normalizeComposerHref } from "./composerLinks"; import { replaceComposerAnnotations } from "./composerAnnotations"; -import { - composerPositionToOffset, - composerTextSelection, -} from "./composerPositions"; +import { composerTextSelection } from "./composerPositions"; import type { ComposerAttachment, + ComposerDocumentInput, + ComposerControllerOptions, ComposerControllerSnapshot, ComposerDocument, ComposerDraftSnapshot, @@ -35,7 +32,6 @@ import type { ComposerMentionBinding, ComposerPasteOptions, ComposerPathReference, - ComposerPendingPaste, ComposerSelection, ComposerSubmission, ComposerSubmitResult, @@ -44,79 +40,20 @@ import type { ComposerLinkAttributes, } from "./types"; +import type { NodeViewConstructor } from "@tiptap/pm/view"; +import { CodeBlockNodeViewRegistry } from "./CodeBlockNodeViewRegistry"; +import { + clone, + stateFrom, + documentFrom, + resourcesFrom, + buildDocument, + type ComposerResources, +} from "./composerState"; + export const LARGE_PASTE_CHAR_THRESHOLD = 1_000; const MAX_LOCAL_HISTORY_ENTRIES = 50; -export interface ComposerDocumentInput { - editorDocument?: ComposerEditorDocument; - text?: string; - selectionStart?: number; - selectionEnd?: number; - textElements?: readonly ComposerTextElement[]; - mentionBindings?: readonly ComposerMentionBinding[]; - attachments?: readonly ComposerAttachment[]; - pathReferences?: readonly ComposerPathReference[]; - pendingPastes?: readonly ComposerPendingPaste[]; - inputCapabilityRoute?: unknown; -} - -interface ComposerResources { - attachments: readonly ComposerAttachment[]; - pathReferences: readonly ComposerPathReference[]; - inputCapabilityRoute?: unknown; -} - -const clone = (value: T): T => structuredClone(value); - -function resourcesFrom(input: ComposerDocumentInput): ComposerResources { - return clone({ - attachments: [...(input.attachments ?? [])], - pathReferences: [...(input.pathReferences ?? [])], - ...(input.inputCapabilityRoute === undefined - ? {} - : { inputCapabilityRoute: input.inputCapabilityRoute }), - }); -} - -function stateFrom(input: ComposerDocumentInput): EditorState { - const doc = input.editorDocument - ? composerDocumentFromJSON(input.editorDocument) - : composerDocumentFromAnnotations(input); - // Validate programmatically supplied annotations as strictly as JSON restores. - composerDocumentFromJSON(doc.toJSON() as ComposerEditorDocument); - const text = projectComposerDocument(doc).text; - const start = input.selectionStart ?? text.length; - return EditorState.create({ - doc, - selection: composerTextSelection(doc, { - start, - end: input.selectionEnd ?? start, - }), - plugins: [history(), ...createComposerEditingKeymap()], - }); -} - -function documentFrom( - state: EditorState, - resources: ComposerResources, -): ComposerDocument { - const projection = projectComposerDocument(state.doc); - return { - editorDocument: state.doc.toJSON() as ComposerEditorDocument, - text: projection.text, - selectionStart: composerPositionToOffset(state.doc, state.selection.from), - selectionEnd: composerPositionToOffset(state.doc, state.selection.to), - textElements: projection.textElements, - mentionBindings: projection.mentionBindings, - pendingPastes: projection.pendingPastes, - ...clone(resources), - }; -} - -function buildDocument(input: ComposerDocumentInput = {}): ComposerDocument { - return documentFrom(stateFrom(input), resourcesFrom(input)); -} - function documentsEqual( left: ComposerDocument, right: ComposerDocument, @@ -150,6 +87,8 @@ function hasContent(draft: ComposerDocument): boolean { } export class ComposerController { + readonly codeBlockNodeViewRegistry = new CodeBlockNodeViewRegistry(); + private readonly codeBlocksEnabled: boolean; private state: EditorState; private resources: ComposerResources; private revision = 0; @@ -161,8 +100,15 @@ export class ComposerController { private snapshot: ComposerControllerSnapshot; private readonly listeners = new Set<() => void>(); - constructor(initialDocument: ComposerDocumentInput = {}) { - this.state = stateFrom(initialDocument); + constructor( + initialDocument: ComposerDocumentInput = {}, + options: ComposerControllerOptions = {}, + ) { + this.codeBlocksEnabled = + options.enableRichText !== false && + options.enableCodeBlocks !== false && + options.plainTextMode !== true; + this.state = stateFrom(initialDocument, this.codeBlocksEnabled); this.resources = resourcesFrom(initialDocument); this.snapshot = this.createSnapshot(); } @@ -174,21 +120,37 @@ export class ComposerController { return () => this.listeners.delete(listener); }; + createNodeViews(): Record { + return this.codeBlocksEnabled + ? { code_block: this.codeBlockNodeViewRegistry.createNodeView } + : {}; + } + getEditorState(): EditorState { return this.state; } applyTransaction(transaction: Transaction): boolean { - const applied = this.state.applyTransaction(transaction); + const previous = this.state; + const applied = previous.applyTransaction(transaction); if (applied.transactions.length === 0) return false; - if (applied.transactions.some((item) => item.docChanged)) + const documentChanged = !previous.doc.eq(applied.state.doc); + const selectionChanged = !previous.selection.eq(applied.state.selection); + if (documentChanged) { composerDocumentFromJSON( applied.state.doc.toJSON() as ComposerEditorDocument, ); - if (applied.transactions.some((item) => item.docChanged)) - this.resetHistoryNavigation(); + // Automatic metadata is persisted, but is not a manual history edit. + if ( + applied.transactions.some( + (item) => item.docChanged && item.getMeta("addToHistory") !== false, + ) + ) + this.resetHistoryNavigation(); + } this.state = applied.state; - this.publish(); + if (documentChanged || selectionChanged) this.publish(); + else this.notify(); return true; } @@ -713,7 +675,7 @@ export class ComposerController { resetHistory: boolean, force = false, ): void { - const state = stateFrom(input); + const state = stateFrom(input, this.codeBlocksEnabled); const resources = resourcesFrom(input); if ( !force && @@ -749,6 +711,10 @@ export class ComposerController { private publish(): void { this.revision += 1; this.snapshot = this.createSnapshot(); + this.notify(); + } + + private notify(): void { for (const listener of this.listeners) listener(); } diff --git a/src/components/input-kit/ComposerDraftCoordinator.binding.test.ts b/src/components/input-kit/ComposerDraftCoordinator.binding.test.ts new file mode 100644 index 000000000..5063c09e7 --- /dev/null +++ b/src/components/input-kit/ComposerDraftCoordinator.binding.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { decodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { ComposerDraftCoordinator } from "./ComposerDraftCoordinator"; +import { + a, + b, + home, + deferred, + installCoordinatorHarness, +} from "./ComposerDraftCoordinator.testFixtures"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); + +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); + +describe("Composer binding through explicit test-only bridge and real owners", () => { + it("keeps a queued discard retired while its preceding put notification reads the old entry", async () => { + const h = await setup(); + h.seed(home, "old home draft"); + await h.coordinator.bindCommitted(home); + await h.coordinator.connect(); + h.controller.setText("scope probe before rich replacement"); + const old = h.coordinator.captureBinding()!; + const gate = deferred(); + const entered = deferred(); + h.intercept((command, args, execute) => { + if ( + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "clear" + ) { + entered.resolve(undefined); + return gate.promise.then(execute); + } + return execute(); + }); + const clearing = h.coordinator.clear(); + await entered.promise; + await vi.advanceTimersByTimeAsync(0); + const afterDiscard = h.controller.captureDraft(); + h.controller.ingestHTML( + '

new rich 中😀

const next = 1;
', + ); + const replacement = h.controller.captureDraft(); + gate.resolve(null); + expect(await clearing).toBe(true); + await h.coordinator.idle(); + expect(afterDiscard.text).toBe(""); + const saved = await h.read(home); + expect(saved?.state).toBe("draft"); + expect(saved?.draftId).not.toBe(old.identity.draftId); + if (saved?.state !== "draft") + throw new Error("new rich draft was not saved"); + expect(decodeComposerDraft(saved.draft).document).toEqual(replacement); + expect(h.coordinator.getSnapshot().mutation).toBe("applied"); + }); + + it("coalesces user edits for 250ms and preserves selection/resources in the codec", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("first"); + h.controller.setText("中文😀 final", { start: 2, end: 4 }); + h.controller.setAttachments([ + { data: "YQ==", mediaType: "image/png", previewUrl: "blob:old" }, + ]); + await vi.advanceTimersByTimeAsync(249); + expect(h.mutations).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + await h.coordinator.idle(); + expect(h.mutations).toHaveLength(1); + const entry = await h.read(a); + expect(entry?.state).toBe("draft"); + if (entry?.state !== "draft") throw new Error("missing test draft"); + const decoded = decodeComposerDraft(entry.draft); + expect(decoded.document.text).toBe("中文😀 final"); + expect(decoded.document.selectionStart).toBe(2); + expect(decoded.document.selectionEnd).toBe(4); + expect(decoded.document.attachments[0]).toEqual({ + data: "YQ==", + mediaType: "image/png", + }); + }); + + it("changes opaque version only when durable payload changes, not submit intent or preview", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("same"); + const first = h.coordinator.captureBinding()!; + h.controller.submit("queue"); + h.controller.setAttachments([ + { data: "YQ==", mediaType: "image/png", previewUrl: "blob:one" }, + ]); + const second = h.coordinator.captureBinding()!; + h.controller.setAttachments([ + { data: "YQ==", mediaType: "image/png", previewUrl: "blob:two" }, + ]); + expect(h.coordinator.captureBinding()?.identity).toEqual(second.identity); + expect(second.identity.scopeVersion).not.toBe(first.identity.scopeVersion); + h.controller.setSelection(1, 1); + expect(h.coordinator.captureBinding()?.identity.scopeVersion).not.toBe( + second.identity.scopeVersion, + ); + }); + + it("prepareNavigation preserves the source until actual navigation commits", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("source"); + h.coordinator.prepareNavigation(); + await h.coordinator.idle(); + expect(h.coordinator.getSnapshot().scope).toEqual(a); + expect(h.controller.captureDraft().text).toBe("source"); + await h.coordinator.bindCommitted(b); + expect(h.controller.captureDraft().text).toBe(""); + h.controller.setText("target"); + await h.coordinator.bindCommitted(home); + h.controller.setText("home"); + await h.coordinator.bindCommitted(a); + expect(h.controller.captureDraft().text).toBe("source"); + await h.coordinator.bindCommitted(b); + expect(h.controller.captureDraft().text).toBe("target"); + await h.coordinator.bindCommitted(home); + expect(h.controller.captureDraft().text).toBe("home"); + }); + + it("rejects a late hydrate after another committed scope", async () => { + const h = await setup(); + h.seed(a, "remote a"); + h.seed(b, "remote b"); + const gate = deferred(); + let once = true; + h.intercept((command, _args, execute) => { + if (command === "persisted_atom_read" && once) { + once = false; + const result = execute(); + return gate.promise.then(() => result); + } + return execute(); + }); + const pending = h.coordinator.bindCommitted(a); + await h.coordinator.bindCommitted(b); + gate.resolve(null); + expect(await pending).toBe(false); + expect(h.controller.captureDraft().text).toBe("remote b"); + }); + + it("preserves user edits made while hydrate is delayed and does not rebase onto remote", async () => { + const h = await setup(); + h.seed(a, "remote"); + const gate = deferred(); + h.intercept((command, _args, execute) => + command === "persisted_atom_read" + ? gate.promise.then(execute) + : execute(), + ); + const pending = h.coordinator.bindCommitted(a); + h.controller.setText("new local"); + gate.resolve(null); + expect(await pending).toBe(false); + expect(h.controller.captureDraft().text).toBe("new local"); + expect(h.coordinator.getSnapshot().mutation).toBe("conflict"); + expect(h.mutations).toHaveLength(0); + }); + + it("allows local edits during a delayed absent scope read", async () => { + const h = await setup(); + const gate = deferred(); + h.intercept((command, _args, execute) => + command === "persisted_atom_read" + ? gate.promise.then(execute) + : execute(), + ); + const pending = h.coordinator.bindCommitted(a); + h.controller.setText("local"); + gate.resolve(null); + expect(await pending).toBe(true); + h.intercept(null); + await h.coordinator.idle(); + expect(h.controller.captureDraft().text).toBe("local"); + expect((await h.read(a))?.state).toBe("draft"); + }); + + it("rejects wrong codec mode without altering the immutable Controller mode or storing empty", async () => { + const h = await setup(); + h.seed(a, "plain", true); + expect(await h.coordinator.bindCommitted(a)).toBe(false); + expect(h.coordinator.getSnapshot().error).toBe("mode_mismatch"); + expect(h.mutations).toHaveLength(0); + expect(h.controller.getSnapshot().document.text).toBe(""); + }); + + it("requires explicit mode and restores with an explicitly matching plain Controller", async () => { + expect( + () => new ComposerDraftCoordinator(new ComposerController(), {} as never), + ).toThrow("explicitly"); + const h = await setup({ plainTextMode: true }); + h.seed(a, "plain"); + expect(await h.coordinator.bindCommitted(a)).toBe(true); + expect(h.controller.captureDraft().text).toBe("plain"); + expect(h.mutations).toHaveLength(0); + }); + + it("metadata notifications only reread and fail closed when listen is unavailable", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + await h.coordinator.connect(); + const count = h.reads.length; + h.seed(b, "other"); + await vi.advanceTimersByTimeAsync(0); + expect(h.reads.length).toBeGreaterThan(count); + expect(h.reads.every((scope) => scope.id === a.id)).toBe(true); + expect(h.controller.captureDraft().text).toBe(""); + expect(h.mutations).toHaveLength(0); + h.coordinator.detach(); + const failed = new ComposerDraftCoordinator(h.controller, { + plainTextMode: false, + }); + vi.mocked(safeListen).mockRejectedValue(new Error("unavailable")); + await failed.connect(); + expect(failed.getSnapshot().mutation).toBe("unavailable"); + failed.detach(); + }); + + it("handles unavailable reads without clearing or treating the scope as absent", async () => { + const h = await setup(); + h.controller.setText("unsaved local"); + vi.mocked(safeInvoke).mockRejectedValue(new Error("transport")); + expect(await h.coordinator.bindCommitted(a)).toBe(false); + expect(h.controller.captureDraft().text).toBe("unsaved local"); + expect(h.mutations).toHaveLength(0); + expect(h.coordinator.getSnapshot().mutation).toBe("unavailable"); + }); + + it("preserves initial whitespace/route as a draft without using send's empty predicate", async () => { + const h = await setup(); + h.controller.setText(" "); + h.controller.setInputCapabilityRoute({ capability: "image" }); + await h.coordinator.bindCommitted(a); + await h.coordinator.idle(); + const entry = await h.read(a); + if (entry?.state !== "draft") throw new Error("initial draft missing"); + expect(decodeComposerDraft(entry.draft).document.text).toBe(" "); + expect( + decodeComposerDraft(entry.draft).document.inputCapabilityRoute, + ).toEqual({ capability: "image" }); + }); + + it("does not hydrate over user clear while the old read is delayed", async () => { + const h = await setup(); + h.seed(a, "remote"); + const gate = deferred(); + h.intercept((command, _args, execute) => + command === "persisted_atom_read" + ? gate.promise.then(execute) + : execute(), + ); + const pending = h.coordinator.bindCommitted(a); + await h.coordinator.clear(); + gate.resolve(null); + expect(await pending).toBe(false); + expect(h.controller.captureDraft().text).toBe(""); + h.intercept(null); + expect((await h.read(a))?.state).toBe("draft"); + }); + + it("ignores old metadata revisions and never treats a notification's saved field as a receipt", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + await h.coordinator.connect(); + h.controller.setText("pending"); + await h.coordinator.idle(); + const handler = vi.mocked(safeListen).mock.calls[0][1]; + const count = h.reads.length; + handler({ + event: "persisted-atom-update", + id: 1, + payload: { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + event: "persisted-atom-update", + }, + } as never); + await vi.advanceTimersByTimeAsync(0); + expect(h.reads).toHaveLength(count); + handler({ + event: "persisted-atom-update", + id: 2, + payload: { + key: "composer-drafts-v1", + storeRevision: h.store.metadata().storeRevision, + persistence: { + state: "saved", + savedRevision: h.store.metadata().storeRevision, + }, + event: "persisted-atom-update", + }, + } as never); + await vi.advanceTimersByTimeAsync(0); + expect(h.coordinator.getSnapshot().persistence?.state).toBe("pending"); + }); + + it("does not restore an older notification read after navigation commits another scope", async () => { + const h = await setup(); + h.seed(a, "a"); + h.seed(b, "b"); + await h.coordinator.bindCommitted(a); + await h.coordinator.connect(); + const remoteDraft = h.coordinator.captureBinding()!.payload; + const gate = deferred(); + let once = true; + h.intercept((command, _args, execute) => { + if (command === "persisted_atom_read" && once) { + once = false; + const result = execute(); + return gate.promise.then(() => result); + } + return execute(); + }); + h.store.updatePersistedAtomState({ + op: "put", + scope: a, + draftId: "seed-draft-a", + expectedVersion: "seed-version-a", + nextVersion: "new-remote", + draft: remoteDraft, + }); + await h.coordinator.bindCommitted(b); + gate.resolve(null); + await vi.advanceTimersByTimeAsync(0); + expect(h.controller.captureDraft().text).toBe("b"); + expect(h.coordinator.getSnapshot().scope).toEqual(b); + }); +}); diff --git a/src/components/input-kit/ComposerDraftCoordinator.fresh.integration.test.ts b/src/components/input-kit/ComposerDraftCoordinator.fresh.integration.test.ts new file mode 100644 index 000000000..6d89a20fc --- /dev/null +++ b/src/components/input-kit/ComposerDraftCoordinator.fresh.integration.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { decodeComposerDraft } from "./composerDraftCodec"; +import { + home, + local, + deferred, + installCoordinatorHarness, +} from "./ComposerDraftCoordinator.testFixtures"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); + +describe("committed fresh draft through real Controller, Coordinator, parser and Store", () => { + it("clears only after the existing identity CAS applies, then allocates a new edited identity", async () => { + const h = await setup(); + h.seed(home, "retained rich draft"); + await h.coordinator.bindCommitted(home); + const identity = h.coordinator.getSnapshot().identity; + const gate = deferred(); + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op?: string })?.op === "clear" + ? gate.promise.then(execute) + : execute(), + ); + const replace = h.coordinator.replaceCommittedDraft(home, () => true); + await Promise.resolve(); + expect(h.controller.getDocument().text).toBe("retained rich draft"); + gate.resolve(null); + expect(await replace).toBe("applied"); + expect(h.controller.getDocument().text).toBe(""); + expect(await h.read(home)).toBeNull(); + h.controller.setText("new logical draft"); + await h.coordinator.idle(); + const entry = await h.read(home); + expect(entry?.draftId).not.toBe(identity?.draftId); + expect(entry?.state).toBe("draft"); + }); + + it("does not clear on transport failure", async () => { + const h = await setup(); + h.seed(home, "kept"); + await h.coordinator.bindCommitted(home); + const before = h.controller.captureDraft(); + h.intercept((command, args, execute) => { + if ( + command === "persisted_atom_update" && + (args.mutation as { op?: string })?.op === "clear" + ) + throw new Error("unavailable"); + return execute(); + }); + expect(await h.coordinator.replaceCommittedDraft(home, () => true)).toBe( + "unavailable", + ); + expect(h.controller.captureDraft()).toEqual(before); + expect((await h.read(home))?.state).toBe("draft"); + }); + + it("a real Store CAS conflict preserves rich local content and the newer remote version", async () => { + const h = await setup(); + h.seed(home, "local rich kept"); + await h.coordinator.bindCommitted(home); + h.controller.ingestPaste("折叠保留".repeat(300)); + await h.coordinator.idle(); + const before = h.controller.captureDraft(); + const entry = await h.read(home); + if (entry?.state !== "draft") throw new Error("missing seeded draft"); + h.intercept((command, args, execute) => { + if ( + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "clear" + ) + h.store.updatePersistedAtomState({ + op: "put", + scope: home, + draftId: entry.draftId, + expectedVersion: entry.scopeVersion, + nextVersion: "remote-concurrent-edit", + draft: entry.draft, + }); + return execute(); + }); + expect(await h.coordinator.replaceCommittedDraft(home, () => true)).toBe( + "conflict", + ); + expect(h.controller.captureDraft()).toEqual(before); + expect((await h.read(home))?.scopeVersion).toBe("remote-concurrent-edit"); + }); + + it("a queued fresh clear rechecks route ownership before it reaches the gateway", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(home); + const gate = deferred(); + let current = true; + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op?: string }).op === "put" + ? gate.promise.then(execute) + : execute(), + ); + h.controller.setText("source survives withheld clear"); + const replace = h.coordinator.replaceCommittedDraft(home, () => current); + await Promise.resolve(); + current = false; + gate.resolve(undefined); + expect(await replace).toBe("stale"); + expect(h.controller.getDocument().text).toBe( + "source survives withheld clear", + ); + expect(h.mutations.some((mutation) => mutation.op === "clear")).toBe(false); + expect(await h.read(home)).toMatchObject({ + state: "draft", + draft: { document: { text: "source survives withheld clear" } }, + }); + }); + + it("preserves edits made while the old identity clear is in flight, including durable re-persistence", async () => { + const h = await setup(); + h.seed(home, "old draft"); + await h.coordinator.bindCommitted(home); + const gate = deferred(); + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op?: string })?.op === "clear" + ? gate.promise.then(execute) + : execute(), + ); + const replace = h.coordinator.replaceCommittedDraft(home, () => true); + await Promise.resolve(); + h.controller.setText("edit wins"); + gate.resolve(null); + expect(await replace).toBe("stale"); + expect(h.controller.getDocument().text).toBe("edit wins"); + await h.coordinator.idle(); + const entry = await h.read(home); + if (entry?.state !== "draft") + throw new Error("latest edited draft must remain durable"); + expect(decodeComposerDraft(entry.draft).document.text).toBe("edit wins"); + }); + + it("rejects noncommitted or mismatched scope without mutating the current document", async () => { + const h = await setup(); + h.seed(local, "local retained"); + await h.coordinator.bindCommitted(local); + expect(await h.coordinator.replaceCommittedDraft(home, () => true)).toBe( + "stale", + ); + expect(await h.coordinator.replaceCommittedDraft(local, () => false)).toBe( + "stale", + ); + expect(h.controller.getDocument().text).toBe("local retained"); + expect(h.mutations).toEqual([]); + }); + + it("late fresh completion never clears a newly navigated local target", async () => { + const h = await setup(); + h.seed(home, "old home"); + h.seed(local, "local target"); + await h.coordinator.bindCommitted(home); + const gate = deferred(); + let current = true; + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op?: string })?.op === "clear" + ? gate.promise.then(execute) + : execute(), + ); + const replace = h.coordinator.replaceCommittedDraft(home, () => current); + await Promise.resolve(); + current = false; + await h.coordinator.bindCommitted(local); + gate.resolve(null); + expect(await replace).toBe("stale"); + expect(h.controller.getDocument().text).toBe("local target"); + }); +}); diff --git a/src/components/input-kit/ComposerDraftCoordinator.test.ts b/src/components/input-kit/ComposerDraftCoordinator.test.ts new file mode 100644 index 000000000..591e4e086 --- /dev/null +++ b/src/components/input-kit/ComposerDraftCoordinator.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { decodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { ComposerDraftCoordinator } from "./ComposerDraftCoordinator"; +import { + a, + b, + home, + local, + accepted, + queueAccepted, + deferred, + installCoordinatorHarness, +} from "./ComposerDraftCoordinator.testFixtures"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); +vi.mock("@/lib/api/appServer", () => ({ AppServerClient: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: vi.fn() })); + +const setup = installCoordinatorHarness({ safeInvoke, safeListen }); + +describe("Composer draft coordination through explicit test-only bridge and real owners", () => { + it("persists a synchronous subscriber edit produced during accepted commit", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("submitted"); + const capture = h.coordinator.captureSubmission("start"); + let edited = false; + const unsubscribe = h.controller.subscribe(() => { + if (!edited && h.controller.getDocument().text === "") { + edited = true; + h.controller.setText("synchronous new edit"); + } + }); + await h.coordinator.settleSubmission(capture, accepted()); + await h.coordinator.idle(); + unsubscribe(); + expect(h.controller.getDocument().text).toBe("synchronous new edit"); + const entry = await h.read(a); + expect(entry?.state).toBe("draft"); + if (entry?.state !== "draft") throw new Error("new edit was not persisted"); + expect(decodeComposerDraft(entry.draft).document.text).toBe( + "synchronous new edit", + ); + expect(entry.draftId).not.toBe(capture.binding!.identity.draftId); + }); + it("fixes capture payload before send continues and serializes put before consume", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured"); + const gate = deferred(); + let once = true; + h.intercept((command, _args, execute) => { + if (command === "persisted_atom_update" && once) { + once = false; + return gate.promise.then(execute); + } + return execute(); + }); + const capture = h.coordinator.captureSubmission("start"); + const businessSend = vi.fn(() => accepted()); + const actual = businessSend(); + const cleanup = h.coordinator.settleSubmission(capture, actual); + expect(businessSend).toHaveBeenCalledOnce(); + expect(h.controller.captureDraft().text).toBe("captured"); + h.controller.setText("later edit"); + gate.resolve(null); + expect(await cleanup).toBe(false); + await h.coordinator.idle(); + expect(h.mutations.slice(0, 2).map((mutation) => mutation.op)).toEqual([ + "put", + "consume", + ]); + const put = h.mutations[0]; + if (put.op !== "put") throw new Error("missing put"); + expect(decodeComposerDraft(put.draft).document.text).toBe("captured"); + expect(capture.receipt.submission.text).toBe("captured"); + expect(h.controller.captureDraft().text).toBe("later edit"); + const later = await h.read(a); + expect(later?.state).toBe("draft"); + expect(later?.draftId).not.toBe(capture.binding!.identity.draftId); + }); + + it("commits matching UI only after consume applied and creates no empty put", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("submitted"); + const capture = h.coordinator.captureSubmission("start"); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + true, + ); + await vi.advanceTimersByTimeAsync(1_000); + await h.coordinator.idle(); + expect(h.controller.captureDraft().text).toBe(""); + expect(await h.read(a)).toBeNull(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "consume", + ]); + expect(h.controller.getHistory()[0].text).toBe("submitted"); + }); + + it("consumes accepted source after navigation while preserving the new scope document", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("sent a"); + const capture = h.coordinator.captureSubmission("start"); + await h.coordinator.bindCommitted(b); + h.controller.setText("editing b"); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + await h.coordinator.idle(); + expect(await h.read(a)).toBeNull(); + expect(h.controller.captureDraft().text).toBe("editing b"); + }); + + it("does not commit a receipt after navigating away and back to the same identity", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("sent"); + const capture = h.coordinator.captureSubmission("start"); + await h.coordinator.idle(); + await h.coordinator.bindCommitted(b); + await h.coordinator.bindCommitted(a); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + expect(h.controller.captureDraft().text).toBe("sent"); + expect(await h.read(a)).toBeNull(); + }); + + it("keeps capture immutable and deduplicates observer/return acceptance", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("sent"); + const capture = h.coordinator.captureSubmission("start"); + expect(() => { + (capture.binding!.identity as { scopeVersion: string }).scopeVersion = + "mutated"; + }).toThrow(); + const first = h.coordinator.settleSubmission(capture, accepted()); + expect(h.coordinator.settleSubmission(capture, accepted())).toBe(first); + await first; + expect( + h.mutations.filter((mutation) => mutation.op === "consume"), + ).toHaveLength(1); + }); + + it.each([ + undefined, + true, + false, + { kind: "unknown" }, + { kind: "local-handled" }, + { kind: "rejected" }, + { kind: "runtime-accepted", receipt: {} }, + ])("does not consume from nonacceptance %j", async (result) => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("keep"); + const capture = h.coordinator.captureSubmission("start"); + expect(await h.coordinator.settleSubmission(capture, result)).toBe(false); + await h.coordinator.idle(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual(["put"]); + expect(h.controller.captureDraft().text).toBe("keep"); + }); + + it("can still consume a later real observer acceptance after an unknown return", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("keep"); + const capture = h.coordinator.captureSubmission("start"); + await h.coordinator.settleSubmission(capture, { kind: "unknown" }); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + true, + ); + }); + + it("supports the existing real queue acceptance owner", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("queue"); + const capture = h.coordinator.captureSubmission("queue"); + expect(await h.coordinator.settleSubmission(capture, queueAccepted())).toBe( + true, + ); + }); + + it("does not consume empty or interrupt captures", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + expect( + await h.coordinator.settleSubmission( + h.coordinator.captureSubmission("start"), + accepted(), + ), + ).toBe(false); + h.controller.setText("keep"); + expect( + await h.coordinator.settleSubmission( + h.coordinator.captureSubmission("interrupt"), + accepted(), + ), + ).toBe(false); + await h.coordinator.idle(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual(["put"]); + }); + + it.each([a, home])( + "promotes captured scope %j without changing logical identity or Controller receipt", + async (source) => { + const h = await setup(); + await h.coordinator.bindCommitted(source); + h.controller.setText("promote"); + const capture = h.coordinator.captureSubmission("start"); + const revision = h.controller.getSnapshot().revision; + expect( + await h.coordinator.promote(capture.binding!, local, { + expectedTarget: null, + sessionId: "s1", + threadId: "t1", + }), + ).toBe(true); + expect(h.controller.getSnapshot().revision).toBe(revision); + expect(h.coordinator.getSnapshot().identity).toEqual( + capture.binding!.identity, + ); + expect(h.coordinator.getSnapshot().scope).toEqual(local); + expect(await h.read(source)).toBeNull(); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + true, + ); + expect(await h.read(local)).toBeNull(); + }, + ); + + it("routes edits enqueued during promotion to the successfully promoted binding", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("first"); + const token = h.coordinator.captureBinding()!; + const gate = deferred(); + h.intercept((command, args, execute) => + command === "persisted_atom_update" && + (args.mutation as { op: string }).op === "promote" + ? gate.promise.then(execute) + : execute(), + ); + const promotion = h.coordinator.promote(token, local, { + expectedTarget: null, + sessionId: "s1", + }); + h.controller.setText("edited during promotion"); + h.coordinator.captureBinding(); + gate.resolve(null); + expect(await promotion).toBe(true); + await h.coordinator.idle(); + expect(h.coordinator.getSnapshot().mutation).toBe("applied"); + const final = await h.read(local); + if (final?.state !== "draft") throw new Error("missing promoted draft"); + expect(decodeComposerDraft(final.draft).document.text).toBe( + "edited during promotion", + ); + }); + + it("preserves source and target on a promotion conflict", async () => { + const h = await setup(); + h.seed(local, "occupied"); + await h.coordinator.bindCommitted(a); + h.controller.setText("source"); + const token = h.coordinator.captureBinding()!; + expect( + await h.coordinator.promote(token, local, { + expectedTarget: null, + sessionId: "s1", + }), + ).toBe(false); + expect(h.coordinator.getSnapshot().scope).toEqual(a); + expect(h.controller.captureDraft().text).toBe("source"); + expect((await h.read(local))?.draftId).toBe("seed-local-s1"); + }); + + it("clears with a tombstone and never revives the retired draft ID", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("old"); + const old = h.coordinator.captureBinding()!; + expect(await h.coordinator.clear()).toBe(true); + await h.coordinator.clear(); + expect( + h.store.updatePersistedAtomState({ + op: "put", + scope: a, + draftId: old.identity.draftId, + expectedVersion: null, + nextVersion: "late", + draft: old.payload, + }).reason, + ).toBe("retired_draft"); + h.controller.setText("new"); + const fresh = h.coordinator.captureBinding()!; + expect(fresh.identity.draftId).not.toBe(old.identity.draftId); + await h.coordinator.idle(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "clear", + "put", + ]); + }); + + it("observes explicit Controller clear as discard rather than an ordinary empty put", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("old"); + h.controller.clear(); + await h.coordinator.idle(); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "clear", + ]); + expect(await h.read(a)).toBeNull(); + }); + + it("retains current input when a later consume has a version conflict", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("local"); + const capture = h.coordinator.captureSubmission("start"); + await h.coordinator.idle(); + const token = capture.binding!; + h.store.updatePersistedAtomState({ + op: "put", + scope: a, + draftId: token.identity.draftId, + expectedVersion: token.identity.scopeVersion, + nextVersion: "other-window", + draft: token.payload, + }); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + expect(h.controller.captureDraft().text).toBe("local"); + expect(h.coordinator.getSnapshot().mutation).toBe("conflict"); + expect((await h.read(a))?.scopeVersion).toBe("other-window"); + }); + + it("flushes newer local edits before an older acceptance so its CAS cannot consume them", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("captured"); + const capture = h.coordinator.captureSubmission("start"); + h.controller.setText("already newer"); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "put", + "consume", + ]); + const entry = await h.read(a); + if (entry?.state !== "draft") throw new Error("new draft missing"); + expect(decodeComposerDraft(entry.draft).document.text).toBe( + "already newer", + ); + expect(h.controller.captureDraft().text).toBe("already newer"); + }); + + it("captures even a cyclic unsupported route without blocking the business payload", async () => { + await setup(); + const route: Record = {}; + route.self = route; + const controller = new ComposerController({ + text: "send", + inputCapabilityRoute: route, + }); + const coordinator = new ComposerDraftCoordinator(controller, { + plainTextMode: false, + }); + await coordinator.bindCommitted(a); + const capture = coordinator.captureSubmission("start"); + expect(capture.binding).toBeNull(); + expect(capture.receipt.submission.text).toBe("send"); + expect(await coordinator.settleSubmission(capture, accepted())).toBe(false); + coordinator.detach(); + }); + + it("reports a conflicting explicit clear while retaining another window's stored version", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("old"); + const token = h.coordinator.captureBinding()!; + await h.coordinator.idle(); + h.store.updatePersistedAtomState({ + op: "put", + scope: a, + draftId: token.identity.draftId, + expectedVersion: token.identity.scopeVersion, + nextVersion: "remote-version", + draft: token.payload, + }); + expect(await h.coordinator.clear()).toBe(false); + expect(h.coordinator.getSnapshot().mutation).toBe("conflict"); + expect((await h.read(a))?.scopeVersion).toBe("remote-version"); + }); + + it("does not turn transport rejection into a saved receipt or UI clear", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("keep"); + h.intercept((command, _args, execute) => { + if (command === "persisted_atom_update") + throw new Error("private draft echo"); + return execute(); + }); + const capture = h.coordinator.captureSubmission("start"); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + expect(h.controller.captureDraft().text).toBe("keep"); + expect(h.coordinator.getSnapshot().mutation).toBe("unavailable"); + expect(JSON.stringify(h.coordinator.getSnapshot())).not.toContain( + "private", + ); + }); + + it("distinguishes applied in memory from saved on disk and preserves old savedRevision after IO error", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("keep"); + await h.coordinator.idle(); + expect(h.coordinator.getSnapshot().mutation).toBe("applied"); + expect(h.coordinator.getSnapshot().persistence?.state).toBe("pending"); + h.failWrite(true); + await h.store.flush(); + await h.coordinator.refresh(); + expect(h.coordinator.getSnapshot().persistence?.state).toBe("error"); + expect(h.coordinator.getSnapshot().persistence?.savedRevision).toBe(0); + expect(h.controller.captureDraft().text).toBe("keep"); + h.failWrite(false); + await h.store.flush(); + await h.coordinator.refresh(); + expect(h.coordinator.getSnapshot().persistence?.state).toBe("saved"); + expect(h.coordinator.getSnapshot().persistence?.savedRevision).toBe( + h.coordinator.getSnapshot().storeRevision, + ); + }); + + it("keeps local and stored documents intact on codec failure and still captures a business receipt", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("keep"); + await h.coordinator.idle(); + h.controller.setInputCapabilityRoute({ + unsupported: new Map([["secret", "value"]]), + }); + const capture = h.coordinator.captureSubmission("start"); + expect(capture.receipt.kind).toBe("accepted"); + expect(capture.binding).toBeNull(); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + await h.coordinator.idle(); + expect(h.mutations).toHaveLength(1); + expect(h.controller.captureDraft().text).toBe("keep"); + expect((await h.read(a))?.state).toBe("draft"); + }); + + it("keeps fixed mutations alive after UI detaches and later runtime acceptance arrives", async () => { + const h = await setup(); + await h.coordinator.bindCommitted(a); + h.controller.setText("sent"); + const capture = h.coordinator.captureSubmission("start"); + h.coordinator.detach(); + expect(await h.coordinator.settleSubmission(capture, accepted())).toBe( + false, + ); + await h.coordinator.idle(); + expect(await h.read(a)).toBeNull(); + expect(h.controller.captureDraft().text).toBe("sent"); + expect(h.mutations.map((mutation) => mutation.op)).toEqual([ + "put", + "consume", + ]); + }); + + it("does not evict tombstones to make room for a new logical draft", async () => { + const h = await setup({ + limits: { + maxEntries: 1, + maxBindings: 1, + maxDraftBytes: 8_192, + maxFileBytes: 16_384, + }, + }); + await h.coordinator.bindCommitted(a); + h.controller.setText("old"); + await h.coordinator.clear(); + h.controller.setText("new"); + await h.coordinator.idle(); + expect(h.coordinator.getSnapshot().mutation).toBe("invalid"); + expect(h.controller.captureDraft().text).toBe("new"); + expect(await h.read(a)).toBeNull(); + }); +}); diff --git a/src/components/input-kit/ComposerDraftCoordinator.testFixtures.ts b/src/components/input-kit/ComposerDraftCoordinator.testFixtures.ts new file mode 100644 index 000000000..77446d16e --- /dev/null +++ b/src/components/input-kit/ComposerDraftCoordinator.testFixtures.ts @@ -0,0 +1,206 @@ +import { Buffer } from "node:buffer"; +import { afterEach, beforeEach, vi } from "vitest"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { encodeComposerDraft } from "@/components/input-kit/composerDraftCodec"; +import { createRuntimeAcceptanceReceipt } from "@/lib/api/agentRuntime/runtimeAcceptance"; +import { + fromRuntimeAcceptanceReceipt, + fromQueueAcceptanceResponse, +} from "@/components/agent/chat/hooks/agentSendResult"; +import type { + DraftScope, + PersistedAtomMutation, +} from "@/lib/api/persistedAtomTypes"; +import { sendPersistedAtomState } from "@/lib/api/persistedAtoms"; +import { PersistedAtomStore } from "../../../electron/persistedAtomStore"; +import type { PersistedAtomFileIO } from "../../../electron/persistedAtomFile"; +import { ComposerDraftCoordinator } from "./ComposerDraftCoordinator"; + +export const a: DraftScope = { workspaceId: "w", kind: "draft", id: "a" }; +export const b: DraftScope = { workspaceId: "w", kind: "draft", id: "b" }; +export const home: DraftScope = { + workspaceId: null, + kind: "home", + id: "new-conversation", +}; +export const local: DraftScope = { workspaceId: "w", kind: "local", id: "s1" }; +export const accepted = () => + fromRuntimeAcceptanceReceipt( + createRuntimeAcceptanceReceipt( + { sessionId: "s1", threadId: "t1" }, + "start", + "turn1", + ), + ); +export const queueAccepted = () => + fromQueueAcceptanceResponse( + { sessionId: "s1", threadId: "t1", clientUserMessageId: "u1" }, + { + id: "q1", + clientUserMessageId: "u1", + input: [{ type: "text", text: "queued", text_elements: [] }], + }, + ); +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +/** Test-only bridge, real gateway/parser/Store; no duplicated persistence oracle. */ +export async function harness( + bridge: { + safeInvoke: typeof import("@/lib/dev-bridge").safeInvoke; + safeListen: typeof import("@/lib/dev-bridge").safeListen; + }, + options: { + plainTextMode?: boolean; + limits?: { + maxEntries: number; + maxBindings: number; + maxDraftBytes: number; + maxFileBytes: number; + }; + } = {}, +) { + const files = new Map(); + let failWrite = false; + const io: PersistedAtomFileIO = { + readFile: async (file) => { + const value = files.get(file); + if (!value) + throw Object.assign(new Error("test missing file"), { code: "ENOENT" }); + return value; + }, + mkdir: async () => undefined, + writeFile: async (file, data) => { + if (failWrite) + throw Object.assign(new Error("test storage failure"), { code: "EIO" }); + files.set(file, Buffer.from(data, "utf8")); + }, + rename: async (source, target) => { + files.set(target, files.get(source)!); + files.delete(source); + }, + removeTemporary: async (file) => { + files.delete(file); + }, + }; + const store = new PersistedAtomStore("/test-only/coordinator", { + io, + platform: "darwin", + limits: options.limits, + }); + await store.ready; + const controller = new ComposerController( + {}, + { plainTextMode: options.plainTextMode ?? false }, + ); + const coordinator = new ComposerDraftCoordinator(controller, { + plainTextMode: options.plainTextMode ?? false, + }); + const mutations: PersistedAtomMutation[] = []; + const reads: DraftScope[] = []; + let intercept: + | (( + command: string, + args: Record, + execute: () => unknown, + ) => unknown) + | null = null; + vi.mocked(bridge.safeInvoke).mockImplementation(async (command, args) => { + const data = args ?? {}; + const execute = () => { + if (command === "persisted_atom_read") + return store.sendPersistedAtomState(data.query); + if (command === "persisted_atom_update") + return store.updatePersistedAtomState(data.mutation); + throw new Error("Unexpected test-only command"); + }; + if (command === "persisted_atom_read") + reads.push((data.query as { scope: DraftScope }).scope); + if (command === "persisted_atom_update") + mutations.push(structuredClone(data.mutation as PersistedAtomMutation)); + return ( + intercept ? await intercept(command, data, execute) : execute() + ) as never; + }); + vi.mocked(bridge.safeListen).mockImplementation(async (_event, handler) => + store.subscribe((metadata) => { + handler({ + event: "persisted-atom-update", + id: 1, + payload: { ...metadata, event: "persisted-atom-update" }, + } as never); + }), + ); + return { + store, + controller, + coordinator, + mutations, + reads, + files, + intercept(value: typeof intercept) { + intercept = value; + }, + failWrite(value: boolean) { + failWrite = value; + }, + async read(scope: DraftScope) { + const result = await sendPersistedAtomState({ + key: "composer-drafts-v1", + query: { kind: "scope", scope }, + }); + if (result.outcome !== "ready" || result.data.queryKind !== "scope") + throw new Error("Test scope is not ready"); + return result.data.view.entry; + }, + seed( + scope: DraftScope, + text: string, + plainTextMode = options.plainTextMode ?? false, + ) { + const source = new ComposerController({ text }, { plainTextMode }); + const draftId = `seed-${scope.kind}-${scope.id}`, + nextVersion = `seed-version-${text}`; + return store.updatePersistedAtomState({ + op: "put", + scope, + draftId, + nextVersion, + expectedVersion: null, + draft: encodeComposerDraft(source.captureDraft(), plainTextMode), + }); + }, + async close() { + coordinator.detach(); + await coordinator.idle(); + store.dispose(); + await store.flush(); + }, + }; +} + +export function installCoordinatorHarness( + bridge: Parameters[0], +) { + let active: Awaited> | undefined; + beforeEach(() => { + vi.useFakeTimers(); + vi.resetAllMocks(); + }); + afterEach(async () => { + await active?.close(); + active = undefined; + vi.useRealTimers(); + }); + return async (options: Parameters[1] = {}) => { + active = await harness(bridge, options); + return active; + }; +} diff --git a/src/components/input-kit/ComposerDraftCoordinator.ts b/src/components/input-kit/ComposerDraftCoordinator.ts new file mode 100644 index 000000000..44c14a291 --- /dev/null +++ b/src/components/input-kit/ComposerDraftCoordinator.ts @@ -0,0 +1,766 @@ +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { clone } from "@/components/input-kit/composerState"; +import { + decodeComposerDraft, + encodeComposerDraft, +} from "@/components/input-kit/composerDraftCodec"; +import { + listenPersistedAtomUpdates, + sendPersistedAtomState, + updatePersistedAtomState, +} from "@/lib/api/persistedAtoms"; +import { parsePersistedDraftScope } from "@/lib/api/persistedAtomProtocol"; +import { + isRuntimeSubmissionAccepted, + normalizeRuntimeSubmissionResult, +} from "@/components/agent/chat/hooks/agentSendResult"; +import type { + DraftIdentity, + DraftScope, + JsonObject, + PersistedAtomMetadata, + PersistedAtomMutation, + PersistedAtomPersistence, + PersistedAtomUpdateResult, + PersistedDraftEntry, +} from "@/lib/api/persistedAtomTypes"; +import type { + ComposerDocument, + ComposerSubmitResult, + ComposerSubmitTarget, +} from "@/components/input-kit/types"; + +const key = "composer-drafts-v1"; +const defaultGateway = { + read: sendPersistedAtomState, + update: updatePersistedAtomState, + listen: listenPersistedAtomUpdates, +}; + +export interface ComposerDraftBindingToken { + readonly scope: DraftScope; + readonly identity: DraftIdentity; + readonly generation: number; + readonly payload: JsonObject; +} + +export interface ComposerDraftCapture { + readonly receipt: ComposerSubmitResult; + readonly binding: ComposerDraftBindingToken | null; +} + +export interface ComposerDraftStatus { + readonly scope: DraftScope | null; + readonly generation: number; + readonly ready: boolean; + readonly identity: DraftIdentity | null; + readonly remoteIdentity: DraftIdentity | null; + readonly mutation: + | "idle" + | "pending" + | "applied" + | "conflict" + | "invalid" + | "unavailable"; + readonly storeRevision: number | null; + readonly persistence: PersistedAtomPersistence | null; + readonly error: "codec" | "mode_mismatch" | "transport" | null; +} + +type DraftLane = { + draftId: string; + version: string; + queuedVersion: string | null; + queuedSignature: string | null; + payload: JsonObject; + signature: string; + scope: DraftScope; + retiring: boolean; +}; +type Binding = { + scope: DraftScope; + generation: number; + read: number; + ready: boolean; + edited: boolean; + blocked: boolean; + codecValid: boolean; + observedSignature: string | null; + lane: DraftLane | null; + clearing: DraftLane | null; + remote: PersistedDraftEntry | null; +}; +type TokenContext = { binding: Binding; lane: DraftLane }; +type CaptureContext = { + binding: Binding | null; + settled: Promise | null; +}; + +function freeze(value: T, seen = new WeakSet()): T { + if (value && typeof value === "object" && !seen.has(value)) { + seen.add(value); + for (const nested of Object.values(value)) freeze(nested, seen); + Object.freeze(value); + } + return value; +} +function detached(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} +function sameScope(a: DraftScope, b: DraftScope): boolean { + return a.workspaceId === b.workspaceId && a.kind === b.kind && a.id === b.id; +} +function identity(lane: DraftLane): DraftIdentity { + return { draftId: lane.draftId, scopeVersion: lane.version }; +} +function hasContent(document: ComposerDocument): boolean { + return ( + document.text !== "" || + document.attachments.length > 0 || + document.pathReferences.length > 0 || + document.inputCapabilityRoute !== undefined || + document.editorDocument.content.some((block) => block.type !== "paragraph") + ); +} + +/** Controller owns the editable document; this owner only coordinates projections. */ +export class ComposerDraftCoordinator { + readonly #controller: ComposerController; + readonly #plainTextMode: boolean; + readonly #gateway: typeof defaultGateway; + readonly #unsubscribe: () => void; + readonly #listeners = new Set<() => void>(); + readonly #tokens = new WeakMap(); + readonly #captures = new WeakMap(); + #binding: Binding | null = null; + #generation = 0; + #suppressed = 0; + #attached = true; + #timer: ReturnType | undefined; + #queue: Promise = Promise.resolve(); + #unlisten: (() => void) | null = null; + #connection: Promise | null = null; + #status: ComposerDraftStatus = freeze({ + scope: null, + generation: 0, + ready: false, + identity: null, + remoteIdentity: null, + mutation: "idle", + storeRevision: null, + persistence: null, + error: null, + }); + + constructor( + controller: ComposerController, + options: { + plainTextMode: boolean; + gateway?: typeof defaultGateway; + }, + ) { + if (typeof options.plainTextMode !== "boolean") + throw new Error("Composer mode must be explicitly provided"); + this.#controller = controller; + this.#plainTextMode = options.plainTextMode; + this.#gateway = options.gateway ?? defaultGateway; + this.#unsubscribe = controller.subscribe(() => this.#observe()); + } + + getSnapshot = (): ComposerDraftStatus => this.#status; + subscribe = (listener: () => void): (() => void) => { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + }; + + connect(): Promise { + if (this.#connection) return this.#connection; + this.#connection = this.#gateway + .listen((metadata) => { + if ( + !this.#attached || + metadata.storeRevision < (this.#status.storeRevision ?? 0) + ) + return; + // Metadata is only an invalidation signal, never a saved/body receipt. + void this.refresh(); + }) + .then((unlisten) => { + if (this.#attached) this.#unlisten = unlisten; + else unlisten(); + }) + .catch(() => + this.#publish({ mutation: "unavailable", error: "transport" }), + ); + return this.#connection; + } + + /** Call before navigation; blocked/deferred navigation must not call bindCommitted. */ + prepareNavigation(): void { + this.#observe(); + this.#flush(); + } + + /** The caller must have committed the actual editing target, not a preview target. */ + bindCommitted(scope: DraftScope): Promise { + if (!this.#attached) return Promise.resolve(false); + const parsed = freeze(parsePersistedDraftScope(scope)); + if (this.#binding && sameScope(this.#binding.scope, parsed)) + return this.refresh(); + const initialContent = + !this.#binding && hasContent(this.#controller.captureDraft()); + let signature: string | null = null; + try { + signature = JSON.stringify( + encodeComposerDraft( + this.#controller.captureDraft(), + this.#plainTextMode, + ), + ); + } catch { + /* A bad local document cannot be projected as an empty draft. */ + } + this.prepareNavigation(); + this.#binding = { + scope: parsed, + generation: ++this.#generation, + read: 0, + ready: false, + edited: initialContent, + blocked: false, + codecValid: signature !== null, + observedSignature: signature, + lane: null, + clearing: null, + remote: null, + }; + this.#publish({ mutation: "idle", error: null }); + return this.refresh(); + } + + async refresh(): Promise { + const binding = this.#binding; + if (!binding || !this.#attached) return false; + const read = ++binding.read; + const scope = binding.scope; + const revision = this.#controller.getSnapshot().revision; + try { + const result = await this.#gateway + .read({ key, query: { kind: "scope", scope } }) + .catch(() => null); + if ( + this.#binding !== binding || + binding.read !== read || + !sameScope(binding.scope, scope) || + !this.#attached + ) + return false; + if (result && result.storeRevision < (this.#status.storeRevision ?? 0)) + return this.refresh(); + if (!result) { + this.#publish({ mutation: "unavailable", error: "transport" }); + return false; + } + this.#metadata(result); + if (result.outcome !== "ready" || result.data.queryKind !== "scope") { + this.#publish({ + mutation: result.outcome === "invalid" ? "invalid" : "unavailable", + }); + return false; + } + const entry = result.data.view.entry; + // A preceding put can notify while its queued discard still owns the scope. + if (binding.clearing) return false; + binding.remote = entry; + const edited = + binding.edited || revision !== this.#controller.getSnapshot().revision; + const local = binding.lane; + if (local) { + // An in-flight or locally changed version is never rebased from a read. + if ( + edited || + local.version !== local.queuedVersion || + entry?.draftId !== local.draftId || + entry.scopeVersion !== local.version + ) { + if (entry && entry.scopeVersion !== local.queuedVersion) + this.#publish({ mutation: "conflict" }); + else this.#publish({}); + return false; + } + this.#publish({}); + return true; + } + if (entry && edited) { + binding.blocked = true; + this.#publish({ mutation: "conflict" }); + return false; + } + if (entry?.state === "draft") { + const decoded = decodeComposerDraft(entry.draft); + if (decoded.plainTextMode !== this.#plainTextMode) { + this.#publish({ mutation: "invalid", error: "mode_mismatch" }); + return false; + } + const payload = encodeComposerDraft( + decoded.document, + this.#plainTextMode, + ); + const signature = JSON.stringify(payload); + this.#project(() => this.#controller.restoreDraft(decoded.document)); + binding.lane = { + draftId: entry.draftId, + version: entry.scopeVersion, + queuedVersion: entry.scopeVersion, + queuedSignature: signature, + payload, + signature, + scope: binding.scope, + retiring: false, + }; + binding.observedSignature = signature; + binding.codecValid = true; + } else if (!edited) { + this.#project(() => this.#controller.replaceDocument({})); + binding.observedSignature = JSON.stringify( + encodeComposerDraft( + this.#controller.captureDraft(), + this.#plainTextMode, + ), + ); + } + binding.ready = true; + binding.edited = false; + this.#publish({ mutation: "idle", error: null }); + if (edited) { + binding.observedSignature = null; + this.#observe(); + } + return true; + } catch { + if (this.#binding === binding && binding.read === read && this.#attached) + this.#publish({ mutation: "invalid", error: "codec" }); + return false; + } + } + + captureBinding(): ComposerDraftBindingToken | null { + this.#observe(); + this.#flush(); + const binding = this.#binding; + if ( + !binding?.ready || + !binding.codecValid || + binding.blocked || + !binding.lane + ) + return null; + const lane = binding.lane; + const token = freeze({ + scope: detached(lane.scope), + identity: identity(lane), + generation: binding.generation, + payload: detached(lane.payload), + }); + this.#tokens.set(token, { binding, lane }); + return token; + } + + captureSubmission( + target: ComposerSubmitTarget, + options: { allowEmpty?: boolean } = {}, + ): ComposerDraftCapture { + const receipt = this.#controller.submit(target, options); + const capture = freeze({ + receipt: clone(receipt), + binding: + receipt.kind === "accepted" && target !== "interrupt" + ? this.captureBinding() + : null, + }); + this.#captures.set(capture, { binding: this.#binding, settled: null }); + return capture; + } + + /** Runtime acceptance is already settled; this promise only represents local cleanup. */ + settleSubmission( + capture: ComposerDraftCapture, + result: unknown, + ): Promise { + const context = this.#captures.get(capture); + if (!context || capture.receipt.kind !== "accepted") + return Promise.resolve(false); + if (context.settled) return context.settled; + try { + if ( + !isRuntimeSubmissionAccepted(normalizeRuntimeSubmissionResult(result)) + ) + return Promise.resolve(false); + } catch { + return Promise.resolve(false); + } + const token = capture.binding; + const tracked = token && this.#tokens.get(token); + if (!token || !tracked || capture.receipt.target === "interrupt") + return Promise.resolve(false); + // Newer edits queued before acceptance keep their exact CAS predecessor. + this.#observe(); + this.#flush(); + if (tracked.lane.version === token.identity.scopeVersion) + tracked.lane.retiring = true; + context.settled = this.#enqueue(tracked.lane, { + op: "consume", + identity: token.identity, + }).then((response) => { + if ( + response?.outcome !== "applied" || + !this.#attached || + this.#binding !== context.binding || + context.binding?.lane !== tracked.lane || + tracked.lane.version !== token.identity.scopeVersion + ) + return false; + const committed = this.#project(() => + this.#controller.commit( + capture.receipt as Extract< + ComposerSubmitResult, + { kind: "accepted" } + >, + ), + ); + if (committed) { + this.#retire(context.binding); + // commit notifies synchronously. A subscriber may edit the freshly cleared + // document while projection observation is suppressed; publish that edit + // as a new logical draft after retiring the accepted identity. + if ( + this.#controller.getSnapshot().revision !== + capture.receipt.revision + 1 + ) { + context.binding.observedSignature = null; + this.#observe(); + } + } + return committed; + }); + return context.settled; + } + + /** Promotion preserves the logical draft, Controller revision and pending receipt. */ + promote( + token: ComposerDraftBindingToken, + target: DraftScope, + options: { + expectedTarget: DraftIdentity | null; + sessionId?: string; + threadId?: string; + }, + ): Promise { + const tracked = this.#tokens.get(token); + if (!tracked) return Promise.resolve(false); + const parsed = freeze(parsePersistedDraftScope(target)); + const mutation: PersistedAtomMutation = { + op: "promote", + source: token.scope, + target: parsed, + identity: token.identity, + ...options, + }; + return this.#enqueue(tracked.lane, mutation).then((result) => { + if (result?.outcome !== "applied") return false; + return true; + }); + } + + /** Fresh navigation preserves the document until the committed identity CAS applies. */ + async replaceCommittedDraft( + scope: DraftScope, + isCurrent: () => boolean, + ): Promise<"applied" | "stale" | "conflict" | "unavailable"> { + const binding = this.#binding; + if ( + !binding || + !binding.ready || + binding.blocked || + !this.#attached || + !sameScope(binding.scope, scope) || + !isCurrent() + ) + return "stale"; + this.#observe(); + this.#flush(); + const lane = binding.lane; + const revision = this.#controller.getSnapshot().revision; + const captured = lane && identity(lane); + const ownsDocument = () => + this.#attached && + this.#binding === binding && + sameScope(binding.scope, scope) && + isCurrent(); + const unchanged = () => + ownsDocument() && + this.#controller.getSnapshot().revision === revision && + binding.lane === lane && + (!lane || lane.version === captured?.scopeVersion); + if (lane && captured) { + let skipped = false; + const result = await this.#enqueue( + lane, + { op: "clear", identity: captured }, + () => { + if (unchanged()) return true; + skipped = true; + return false; + }, + ); + if (skipped) return "stale"; + if (!result || result.outcome === "unavailable") return "unavailable"; + if (result.outcome !== "applied") return "conflict"; + lane.retiring = true; + if (!unchanged()) { + // A late applied clear retires only its old identity; newer edits stay durable. + if (this.#binding === binding && binding.lane === lane) { + this.#retire(binding); + binding.generation = ++this.#generation; + binding.observedSignature = null; + this.#observe(); + } + return "stale"; + } + } + if (!unchanged()) return "stale"; + binding.generation = ++this.#generation; + this.#project(() => this.#controller.clear()); + this.#retire(binding); + if (this.#controller.getSnapshot().revision > revision + 1) { + binding.observedSignature = null; + this.#observe(); + } + return "applied"; + } + + clear(): Promise { + const binding = this.#binding; + this.#flush(); + const lane = binding?.lane; + const captured = lane && identity(lane); + if (binding && lane) binding.clearing = lane; + this.#project(() => this.#controller.clear()); + if (binding) { + binding.read += 1; + this.#retire(binding); + if (!binding.ready) { + binding.edited = true; + this.#publish({ mutation: "unavailable" }); + } + } + if (!lane || !captured) return Promise.resolve(false); + return this.#enqueue(lane, { op: "clear", identity: captured }).then( + (result) => { + if (binding?.clearing === lane) { + binding.clearing = null; + binding.read += 1; + } + if (this.#binding === binding && !binding?.lane && result) + this.#publish({ mutation: result.outcome }); + return result?.outcome === "applied"; + }, + ); + } + + /** Awaiting is for evidence/tests; business submission never waits for this queue. */ + async idle(): Promise { + this.#flush(); + await this.#queue; + } + + detach(): void { + this.prepareNavigation(); + this.#attached = false; + this.#unsubscribe(); + this.#unlisten?.(); + this.#unlisten = null; + this.#listeners.clear(); + } + + #observe(): void { + const binding = this.#binding; + if (this.#suppressed || !binding || !this.#attached) return; + try { + const document = this.#controller.captureDraft(); + const payload = encodeComposerDraft(document, this.#plainTextMode); + const signature = JSON.stringify(payload); + const wasValid = binding.codecValid; + binding.codecValid = true; + if (signature === binding.observedSignature) { + if (!wasValid) this.#publish({ error: null }); + return; + } + binding.observedSignature = signature; + if ( + this.#controller.getSnapshot().lastIntent === "clear" && + document.text === "" && + document.attachments.length === 0 && + document.pathReferences.length === 0 && + document.inputCapabilityRoute === undefined + ) { + void this.clear(); + return; + } + binding.edited = true; + if (!binding.ready || binding.blocked) return; + const lane = binding.lane; + if (lane && !lane.retiring) { + lane.version = crypto.randomUUID(); + lane.payload = payload; + lane.signature = signature; + } else { + binding.lane = { + draftId: crypto.randomUUID(), + version: crypto.randomUUID(), + queuedVersion: null, + queuedSignature: null, + payload, + signature, + scope: binding.scope, + retiring: false, + }; + } + this.#publish({ mutation: "pending", error: null }); + if (this.#timer) clearTimeout(this.#timer); + this.#timer = setTimeout(() => this.#flush(), 250); + } catch { + binding.edited = true; + binding.codecValid = false; + this.#publish({ mutation: "invalid", error: "codec" }); + } + } + + #flush(): void { + if (this.#timer) clearTimeout(this.#timer); + this.#timer = undefined; + const binding = this.#binding; + const lane = binding?.lane; + if (!lane || binding?.blocked || lane.signature === lane.queuedSignature) + return; + const mutation: PersistedAtomMutation = { + op: "put", + scope: detached(lane.scope), + draftId: lane.draftId, + expectedVersion: lane.queuedVersion, + nextVersion: lane.version, + draft: detached(lane.payload), + }; + lane.queuedVersion = lane.version; + lane.queuedSignature = lane.signature; + void this.#enqueue(lane, mutation); + } + + #enqueue( + lane: DraftLane, + mutation: PersistedAtomMutation, + mayApply?: () => boolean, + ): Promise { + mutation = freeze(detached(mutation)); + const operation = this.#queue.then(async () => { + if (mayApply && !mayApply()) return null; + try { + const result = await this.#gateway.update({ + key, + mutation: + mutation.op === "put" + ? { ...mutation, scope: detached(lane.scope) } + : mutation, + }); + if (result.outcome === "applied" && mutation.op === "promote") { + lane.scope = freeze(detached(mutation.target)); + if (this.#binding?.lane === lane) { + this.#binding.scope = lane.scope; + this.#binding.read += 1; + } + } + this.#metadata(result); + if (this.#binding?.lane === lane) { + this.#binding.remote = result.entry; + const current = identity(lane); + const matches = + result.entry?.draftId === current.draftId && + result.entry.scopeVersion === current.scopeVersion; + if (result.outcome === "applied" && matches) + this.#binding.edited = false; + this.#publish({ + mutation: + result.outcome === "applied" && !matches + ? "pending" + : result.outcome, + }); + } + return result; + } catch { + if (this.#binding?.lane === lane) + this.#publish({ mutation: "unavailable", error: "transport" }); + return null; + } + }); + this.#queue = operation.then(() => undefined); + return operation; + } + + #retire(binding: Binding): void { + // A read started before consumption must not recreate the retired draft. + binding.read += 1; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = undefined; + binding.lane = null; + binding.edited = false; + binding.codecValid = true; + binding.observedSignature = JSON.stringify( + encodeComposerDraft(this.#controller.captureDraft(), this.#plainTextMode), + ); + this.#publish({ mutation: "idle", error: null }); + } + + #project(operation: () => T): T { + this.#suppressed += 1; + try { + return operation(); + } finally { + this.#suppressed -= 1; + } + } + + #metadata(metadata: PersistedAtomMetadata): void { + if (metadata.storeRevision < (this.#status.storeRevision ?? 0)) return; + this.#publish({ + storeRevision: metadata.storeRevision, + persistence: detached(metadata.persistence), + }); + } + + #publish(update: Partial): void { + const binding = this.#binding; + this.#status = freeze({ + ...this.#status, + ...update, + scope: binding?.scope ?? null, + generation: binding?.generation ?? this.#generation, + ready: binding?.ready ?? false, + identity: binding?.lane ? identity(binding.lane) : null, + remoteIdentity: + binding?.remote?.state === "draft" + ? { + draftId: binding.remote.draftId, + scopeVersion: binding.remote.scopeVersion, + } + : null, + }); + if (!this.#attached) return; + for (const listener of this.#listeners) { + try { + listener(); + } catch { + /* Subscribers do not own persistence progress. */ + } + } + } +} diff --git a/src/components/input-kit/ComposerEditor.codeBlock.component.test.tsx b/src/components/input-kit/ComposerEditor.codeBlock.component.test.tsx new file mode 100644 index 000000000..5c2e836f7 --- /dev/null +++ b/src/components/input-kit/ComposerEditor.codeBlock.component.test.tsx @@ -0,0 +1,229 @@ +import React, { act, createRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { loadCodeBlockParser } from "@/components/input-kit/composerCodeBlockHighlight"; +import { + ComposerEditor, + type ComposerEditorHandle, +} from "@/components/input-kit/ComposerEditor"; +import { changeLimeLocale } from "@/i18n/createI18n"; + +const mounted: { root: Root; container: HTMLElement }[] = []; +const rangeDescriptors = new Map( + ["getClientRects", "getBoundingClientRect"].map( + (name) => + [name, Object.getOwnPropertyDescriptor(Range.prototype, name)] as const, + ), +); + +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + await changeLimeLocale("en-US"); + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 1, 1), + }); +}); + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.restoreAllMocks(); + for (const [name, descriptor] of rangeDescriptors) { + if (descriptor) Object.defineProperty(Range.prototype, name, descriptor); + else Reflect.deleteProperty(Range.prototype, name); + } +}); + +function richController() { + return new ComposerController({ + editorDocument: { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "before😀" }] }, + { + type: "code_block", + attrs: { language: "javascript", autoDetectLanguage: false }, + content: [ + { + type: "text", + text: "const value = '😀';\n console.log(value);\n", + }, + ], + }, + { + type: "code_block", + attrs: { language: "python", autoDetectLanguage: false }, + content: [{ type: "text", text: " print('second')\n" }], + }, + ], + }, + attachments: [{ data: "image", mediaType: "image/png" }], + pathReferences: [ + { id: "brief", path: "/tmp/brief.md", name: "brief.md", isDir: false }, + ], + }); +} + +function mount(controller = richController(), disabled = false) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const editorRef = createRef(); + const render = (next = controller, isDisabled = disabled) => + act(() => { + root.render( + , + ); + }); + render(); + return { root, container, controller, render, editorRef }; +} + +describe("ComposerEditor code block production integration", () => { + it("主编辑器为两个块建立独立 toolbar 和 PM contentDOM,不写旧data-language", () => { + const { container } = mount(); + const blocks = container.querySelectorAll( + '[data-composer-code-block="true"]', + ); + expect(blocks).toHaveLength(2); + for (const block of blocks) { + expect(block.tagName).toBe("DIV"); + expect(block.getAttribute("data-composer-code-block-wrap")).toBe("false"); + expect( + block + .querySelector('[data-composer-code-block-toolbar="true"]') + ?.getAttribute("contenteditable"), + ).toBe("false"); + expect(block.querySelector("pre")?.getAttribute("dir")).toBe("ltr"); + expect(block.querySelector("code")?.getAttribute("spellcheck")).toBe( + "false", + ); + expect(block.querySelector("code")?.hasAttribute("data-language")).toBe( + false, + ); + expect(block.querySelector("code")?.classList.contains("hljs")).toBe( + true, + ); + expect(block.querySelector("button")).not.toBeNull(); + } + expect(blocks[0].querySelector("code")?.textContent).toBe( + "const value = '😀';\n console.log(value);\n", + ); + }); + + it("语言动作只改变当前块attrs,保留两块正文、选择区与全部资源", () => { + const { controller } = mount(); + const registry = controller.codeBlockNodeViewRegistry; + const before = controller.captureDraft(); + const revision = controller.getSnapshot().revision; + const [first, second] = registry.getSnapshot(); + expect(first).not.toBe(second); + act(() => expect(first.selectLanguage("typescript")).toBe(true)); + const after = controller.captureDraft(); + expect(after.editorDocument.content?.[1].attrs).toMatchObject({ + language: "typescript", + autoDetectLanguage: false, + }); + expect(after.editorDocument.content?.[2]).toEqual( + before.editorDocument.content?.[2], + ); + expect(after.text).toBe(before.text); + expect(after.attachments).toEqual(before.attachments); + expect(after.pathReferences).toEqual(before.pathReferences); + expect([after.selectionStart, after.selectionEnd]).toEqual([ + before.selectionStart, + before.selectionEnd, + ]); + expect(controller.getSnapshot().revision).toBe(revision + 1); + act(() => expect(first.selectLanguage("typescript")).toBe(false)); + expect(controller.getSnapshot().revision).toBe(revision + 1); + }); + + it("纯plugin meta刷新View但不推进草稿revision、不使接受回执失效", () => { + const { controller } = mount(); + const receipt = controller.submit("start"); + if (receipt.kind !== "accepted") + throw new Error("expected accepted receipt"); + const snapshot = controller.getSnapshot(); + const previousState = controller.getEditorState(); + act(() => + controller.applyTransaction( + previousState.tr.setMeta("composer-decoration-test-refresh", true), + ), + ); + expect(controller.getEditorState()).not.toBe(previousState); + expect(controller.getSnapshot()).toBe(snapshot); + act(() => expect(controller.commit(receipt)).toBe(true)); + }); + + it("显式语言改变后旧接受回执不可清掉新草稿", () => { + const { controller } = mount(); + const receipt = controller.submit("queue"); + if (receipt.kind !== "accepted") + throw new Error("expected accepted receipt"); + act(() => + controller.codeBlockNodeViewRegistry + .getSnapshot()[0] + .selectLanguage("python"), + ); + expect(controller.commit(receipt)).toBe(false); + expect(controller.getDocument().text).toContain("const value"); + expect(controller.getDocument().attachments).toHaveLength(1); + }); + + it("切换Controller销毁旧View并注销entry,旧动作不能写新旧任一草稿", () => { + const { controller, render } = mount(); + const previousEntry = controller.codeBlockNodeViewRegistry.getSnapshot()[0]; + const previousDraft = controller.captureDraft(); + const next = new ComposerController({ text: "next owner" }); + const nextDraft = next.captureDraft(); + render(next); + expect(controller.codeBlockNodeViewRegistry.getSnapshot()).toHaveLength(0); + expect(previousEntry.selectLanguage("python")).toBe(false); + expect(controller.captureDraft()).toEqual(previousDraft); + expect(next.captureDraft()).toEqual(nextDraft); + }); + + it("真实Editor disabled属性阻止toolbar动作及文档变更", () => { + const { controller, container } = mount(richController(), true); + const before = controller.captureDraft(); + const entry = controller.codeBlockNodeViewRegistry.getSnapshot()[0]; + expect( + container + .querySelector('[data-composer-code-block-toolbar="true"] button') + ?.hasAttribute("disabled"), + ).toBe(true); + expect(entry.selectLanguage("typescript")).toBe(false); + expect(controller.captureDraft()).toEqual(before); + }); + + it("真实parser通过PM Decoration显示token且保留emoji和首尾空白正文", async () => { + const { controller, container } = mount(); + const before = controller.captureDraft(); + await act(async () => { + await loadCodeBlockParser(); + await vi.waitFor(() => + expect(container.querySelector("code .hljs-keyword")?.textContent).toBe( + "const", + ), + ); + }); + expect(controller.captureDraft()).toEqual(before); + expect(container.querySelector("code")?.textContent).toBe( + "const value = '😀';\n console.log(value);\n", + ); + }); +}); diff --git a/src/components/input-kit/ComposerEditor.tsx b/src/components/input-kit/ComposerEditor.tsx index abb005407..643a717e7 100644 --- a/src/components/input-kit/ComposerEditor.tsx +++ b/src/components/input-kit/ComposerEditor.tsx @@ -3,11 +3,13 @@ import React, { useLayoutEffect, useMemo, useRef, + useState, } from "react"; import { EditorView } from "@tiptap/pm/view"; import type { ComposerController } from "./ComposerController"; import { handleComposerKeyDown, isComposerComposing } from "./composerKeymap"; import type { ComposerSelection } from "./types"; +import { CodeBlockToolbars } from "./CodeBlockToolbar"; export interface ComposerEditorHandle { getElement(): HTMLElement | null; @@ -61,6 +63,7 @@ export function ComposerEditor(props: ComposerEditorProps) { const containerRef = useRef(null); const viewRef = useRef(null); const composingRef = useRef(false); + const [composing, setComposing] = useState(false); const propsRef = useRef(props); propsRef.current = props; const handle = useMemo( @@ -101,10 +104,23 @@ export function ComposerEditor(props: ComposerEditorProps) { const updateComposition = (composing: boolean) => { if (composingRef.current === composing) return; composingRef.current = composing; + setComposing(composing); propsRef.current.onCompositionStateChange?.(composing); }; + const nodeViews = controller.createNodeViews(); + const createCodeBlock = nodeViews.code_block; + if (createCodeBlock) { + nodeViews.code_block = (node, currentView, getPos, ...decorations) => + createCodeBlock( + node, + currentView, + () => (composingRef.current ? undefined : getPos()), + ...decorations, + ); + } const view = new EditorView(container, { state: controller.getEditorState(), + nodeViews, editable: () => !propsRef.current.disabled, dispatchTransaction: (transaction) => { controller.applyTransaction(transaction); @@ -210,10 +226,16 @@ export function ComposerEditor(props: ComposerEditorProps) { ]); return ( -
+ <> +
+ + ); } diff --git a/src/components/input-kit/ComposerIntegration.test.tsx b/src/components/input-kit/ComposerIntegration.test.tsx new file mode 100644 index 000000000..d079c98b9 --- /dev/null +++ b/src/components/input-kit/ComposerIntegration.test.tsx @@ -0,0 +1,333 @@ +import React, { act, createRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ComposerController } from "./ComposerController"; +import { + ComposerEditor, + type ComposerEditorHandle, + type ComposerEditorProps, +} from "./ComposerEditor"; +import { useComposerController } from "./useComposerController"; +import type { ComposerControllerOptions, ComposerDocumentInput } from "./types"; +import { + codeBlockHighlightKey, + loadCodeBlockParser, +} from "./composerCodeBlockHighlight"; +import { changeLimeLocale } from "@/i18n/createI18n"; + +const mounted: { root: Root; host: HTMLElement }[] = []; +const descriptors = new Map( + ["getClientRects", "getBoundingClientRect"].map( + (name) => + [name, Object.getOwnPropertyDescriptor(Range.prototype, name)] as const, + ), +); +beforeEach(async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + await changeLimeLocale("en-US"); + // JSDOM does not establish native geometry or trusted input. + Object.defineProperty(Range.prototype, "getClientRects", { + configurable: true, + value: () => [], + }); + Object.defineProperty(Range.prototype, "getBoundingClientRect", { + configurable: true, + value: () => new DOMRect(0, 0, 1, 1), + }); +}); +afterEach(() => { + for (const { root, host } of mounted.splice(0)) { + act(() => root.unmount()); + host.remove(); + } + vi.restoreAllMocks(); + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(Range.prototype, name, descriptor); + else Reflect.deleteProperty(Range.prototype, name); + } +}); + +function codeDraft( + text = "const value = '😀';\n", + auto = false, +): ComposerDocumentInput { + return { + editorDocument: { + type: "doc", + content: [ + { + type: "code_block", + attrs: { language: "javascript", autoDetectLanguage: auto }, + content: [{ type: "text", text }], + }, + ], + }, + }; +} +function createHost() { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + mounted.push({ root, host }); + return { root, host }; +} +function mount( + controller = new ComposerController(codeDraft()), + props: Partial = {}, +) { + const { root, host } = createHost(); + const editorRef = createRef(); + const render = (next = controller, nextProps = props) => + act(() => + root.render( + , + ), + ); + render(); + return { host, controller, editorRef, render }; +} + +describe("Composer code block integration", () => { + it("defaults enable one Controller registry, two independent plugins and code NodeView", () => { + const { controller, host } = mount(); + expect(Object.keys(controller.createNodeViews())).toEqual(["code_block"]); + expect(controller.getEditorState().plugins).toHaveLength(5); + expect( + codeBlockHighlightKey.getState(controller.getEditorState()), + ).toBeDefined(); + expect(controller.codeBlockNodeViewRegistry.getSnapshot()).toHaveLength(1); + expect( + host.querySelector('[data-composer-code-block="true"]')?.tagName, + ).toBe("DIV"); + }); + + it.each([ + { enableRichText: false }, + { enableCodeBlocks: false }, + { plainTextMode: true }, + ] as ComposerControllerOptions[])( + "effective constructor options %j disable both plugins and NodeView", + (options) => { + const controller = new ComposerController(codeDraft(), options); + const { host, render } = mount(controller); + expect(controller.createNodeViews()).toEqual({}); + expect(controller.getEditorState().plugins).toHaveLength(3); + expect( + codeBlockHighlightKey.getState(controller.getEditorState()), + ).toBeUndefined(); + expect(controller.codeBlockNodeViewRegistry.getSnapshot()).toHaveLength( + 0, + ); + expect( + host.querySelector("[data-composer-code-block-toolbar]"), + ).toBeNull(); + render(controller, { disabled: false }); + expect(controller.getEditorState().plugins).toHaveLength(3); + expect(controller.createNodeViews()).toEqual({}); + }, + ); + + it("mutating constructor option objects and Editor props cannot change fixed capability gating", () => { + const options = { plainTextMode: true }; + const controller = new ComposerController(codeDraft(), options); + const { render } = mount(controller); + options.plainTextMode = false; + act(() => controller.restoreDraft(codeDraft())); + render(controller, { disabled: false, autoFocus: true }); + expect(controller.createNodeViews()).toEqual({}); + expect(controller.getEditorState().plugins).toHaveLength(3); + }); + + it("clear and each identical restore get new EditorState/plugin arrays without a second document owner", () => { + const { controller } = mount(); + const registry = controller.codeBlockNodeViewRegistry; + const first = controller.getEditorState(); + const draft = controller.captureDraft(); + act(() => controller.restoreDraft(draft)); + const restored = controller.getEditorState(); + expect(restored).not.toBe(first); + expect(restored.plugins).not.toBe(first.plugins); + restored.plugins.forEach((plugin, index) => + expect(plugin).not.toBe(first.plugins[index]), + ); + act(() => controller.restoreDraft(draft)); + expect(controller.getEditorState().plugins).not.toBe(restored.plugins); + expect(controller.captureDraft()).toEqual(draft); + act(() => controller.clear()); + expect(controller.getEditorState().plugins).not.toBe(restored.plugins); + expect(controller.codeBlockNodeViewRegistry).toBe(registry); + expect(registry.getSnapshot()).toHaveLength(0); + }); + + it("pure meta notifies View subscribers while document snapshot/revision and accepted receipt stay current", () => { + const controller = new ComposerController({ text: "keep receipt" }); + const receipt = controller.submit("start"); + if (receipt.kind !== "accepted") + throw new Error("Expected accepted receipt"); + const before = controller.getSnapshot(); + const state = controller.getEditorState(); + const notify = vi.fn(); + const unsubscribe = controller.subscribe(notify); + expect( + controller.applyTransaction(state.tr.setMeta("decoration-refresh", true)), + ).toBe(true); + expect(controller.getEditorState()).not.toBe(state); + expect(controller.getSnapshot()).toBe(before); + expect(notify).toHaveBeenCalledTimes(1); + expect(controller.commit(receipt)).toBe(true); + unsubscribe(); + }); + + it("automatic no-history attrs advance revision and reject stale receipt while keeping history navigation", () => { + const controller = new ComposerController({ text: "base draft" }); + controller.replaceHistory([ + codeDraft("older", true), + codeDraft("newer", true), + ]); + expect(controller.recallHistory("previous")?.text).toBe("newer"); + const receipt = controller.submit("queue"); + if (receipt.kind !== "accepted") + throw new Error("Expected accepted receipt"); + const before = controller.getSnapshot(); + const state = controller.getEditorState(); + controller.applyTransaction( + state.tr + .setNodeMarkup(0, undefined, { + ...state.doc.firstChild!.attrs, + language: "python", + }) + .setMeta("addToHistory", false), + ); + expect(controller.getSnapshot().revision).toBe(before.revision + 1); + expect(controller.commit(receipt)).toBe(false); + expect(controller.recallHistory("previous")?.text).toBe("older"); + expect(controller.recallHistory("next")?.text).toBe("newer"); + expect(controller.recallHistory("next")?.text).toBe("base draft"); + }); + + it("manual document edits reset history navigation; actual selection changes advance revision", () => { + const controller = new ComposerController({ text: "base" }); + controller.replaceHistory([{ text: "older" }, { text: "newer" }]); + controller.recallHistory("previous"); + const before = controller.getSnapshot(); + controller.setSelection(0); + expect(controller.getSnapshot().revision).toBe(before.revision + 1); + controller.applyTransaction( + controller.getEditorState().tr.insertText("!", 1), + ); + expect(controller.recallHistory("next")).toBeNull(); + }); + + it("same-attrs docChanged steps are View updates without a draft revision", () => { + const controller = new ComposerController(codeDraft()); + const before = controller.getSnapshot(); + const state = controller.getEditorState(); + const transaction = state.tr.setNodeMarkup( + 0, + undefined, + state.doc.firstChild!.attrs, + ); + expect(transaction.docChanged).toBe(true); + controller.applyTransaction(transaction); + expect(controller.getSnapshot()).toBe(before); + expect(controller.getEditorState()).not.toBe(state); + }); + + it("root composition guard blocks language actions before PM composing and preserves contentDOM/highlight updates", async () => { + const controller = new ComposerController(codeDraft()); + let blockedBeforePm = false; + const { host, editorRef } = mount(controller, { + onCompositionStateChange: (composing) => { + if (composing) + blockedBeforePm = !controller.codeBlockNodeViewRegistry + .getSnapshot()[0] + .selectLanguage("python"); + }, + }); + await loadCodeBlockParser(); + await act(async () => { + await vi.waitFor(() => + expect(host.querySelector("code .hljs-keyword")?.textContent).toBe( + "const", + ), + ); + }); + const dom = editorRef.current!.getElement()!; + const contentDOM = host.querySelector("pre > code"); + act(() => + dom.dispatchEvent( + new CompositionEvent("compositionstart", { bubbles: true }), + ), + ); + expect(blockedBeforePm).toBe(true); + expect(editorRef.current!.isComposing()).toBe(true); + expect( + host + .querySelector("[data-composer-code-block-toolbar] button") + ?.hasAttribute("disabled"), + ).toBe(true); + expect(dom.getAttribute("contenteditable")).toBe("true"); + act(() => + controller.applyTransaction( + controller.getEditorState().tr.insertText(" ", 1), + ), + ); + expect(host.querySelector("pre > code")).toBe(contentDOM); + expect(contentDOM?.textContent).toBe(" const value = '😀';\n"); + expect(contentDOM?.querySelector(".hljs-keyword")?.textContent).toBe( + "const", + ); + expect( + controller.codeBlockNodeViewRegistry + .getSnapshot()[0] + .selectLanguage("python"), + ).toBe(false); + act(() => + dom.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true }), + ), + ); + await act(async () => { + await vi.waitFor(() => + expect( + controller.codeBlockNodeViewRegistry + .getSnapshot()[0] + .selectLanguage("typescript"), + ).toBe(true), + ); + }); + expect( + host + .querySelector("[data-composer-code-block-toolbar] button") + ?.hasAttribute("disabled"), + ).toBe(false); + }); + + it("hook honors external Controller configuration and omits duplicate document callbacks on pure meta", () => { + const controller = new ComposerController(codeDraft()); + const changed = vi.fn(); + const { root } = createHost(); + function Probe() { + useComposerController({ + controller, + controllerOptions: { plainTextMode: true }, + onDocumentChange: changed, + }); + return null; + } + act(() => root.render()); + expect(Object.keys(controller.createNodeViews())).toEqual(["code_block"]); + act(() => + controller.applyTransaction( + controller.getEditorState().tr.setMeta("pure-plugin-meta", true), + ), + ); + expect(changed).not.toHaveBeenCalled(); + act(() => controller.setSelection(0)); + expect(changed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/input-kit/composerCodeBlockAutoDetect.test.ts b/src/components/input-kit/composerCodeBlockAutoDetect.test.ts new file mode 100644 index 000000000..b1500fbe3 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockAutoDetect.test.ts @@ -0,0 +1,398 @@ +import { history, undo, redo } from "@tiptap/pm/history"; +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCodeBlockAutoDetect } from "./composerCodeBlockAutoDetect"; +import { + createCodeBlockLoaders, + loadCodeBlockParser, +} from "./composerCodeBlockHighlight"; +import { detectCodeBlockLanguage } from "./composerCodeBlockLanguage"; +import { codeDoc, deferred, mountCodeView } from "./composerCodeBlockTestUtils"; + +const mounted: ReturnType[] = []; +afterEach(() => { + mounted.splice(0).forEach((item) => item.destroy()); + vi.useRealTimers(); +}); +const mount = ( + source: string, + options: Parameters[0] = {}, + editable = true, +) => { + const item = mountCodeView( + codeDoc(source), + [history(), createCodeBlockAutoDetect(options)], + { editable }, + ); + mounted.push(item); + return item; +}; + +describe("Auto metadata lifecycle", () => { + it("debounces to 150ms, preserves body/selection, and writes no-history attrs", async () => { + vi.useFakeTimers(); + const detect = vi.fn(async () => "typescript"); + const item = mount("const count: number = 1", { detect }); + item.selectCode(); + const selection = item.view.state.selection; + await vi.advanceTimersByTimeAsync(149); + expect(detect).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(detect).toHaveBeenCalledTimes(1); + expect(item.view.state.doc.firstChild!.attrs).toMatchObject({ + language: "typescript", + autoDetectLanguage: true, + }); + expect(item.view.state.doc.textContent).toBe("const count: number = 1"); + expect(item.view.state.selection.eq(selection)).toBe(true); + expect(item.transactions.filter((t) => t.docChanged)).toEqual([ + { docChanged: true, addToHistory: false }, + ]); + await vi.advanceTimersByTimeAsync(150); + expect(detect).toHaveBeenCalledTimes(1); + }); + + it("runs the real default detector against source code", async () => { + const item = mount("def hello():\n return 1"); + await vi.waitFor(() => + expect(item.view.state.doc.firstChild!.attrs.language).toBe("python"), + ); + expect(item.view.state.doc.textContent).toBe("def hello():\n return 1"); + const source = + "pub fn answer<'a>(value: &'a str) -> Option<&'a str> { Some(value) }"; + const unhinted = mount(source); + await vi.waitFor(() => + expect(unhinted.view.state.doc.firstChild!.attrs.language).toBe("rust"), + ); + expect(unhinted.view.state.doc.textContent).toBe(source); + }); + + it("rejects an old doc result after edits while the next generation stays usable", async () => { + vi.useFakeTimers(); + const old = deferred(), + current = deferred(); + const detect = vi + .fn() + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(current.promise); + const item = mount("original code", { detect }); + await vi.advanceTimersByTimeAsync(150); + item.view.dispatch(item.view.state.tr.insertText("new ", 1)); + old.resolve("javascript"); + await Promise.resolve(); + expect(item.view.state.doc.firstChild!.attrs.language).toBeNull(); + await vi.advanceTimersByTimeAsync(150); + current.resolve("rust"); + await vi.advanceTimersByTimeAsync(0); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("rust"); + }); + + it.each(["manual", "delete", "restore", "plain", "destroy"])( + "rejects pending results after %s", + async (action) => { + vi.useFakeTimers(); + let enabled = true; + const pending = deferred(); + const item = mount("pending source", { + detect: () => pending.promise, + enabled: () => enabled, + }); + await vi.advanceTimersByTimeAsync(150); + if (action === "manual") + item.view.dispatch( + item.view.state.tr.setNodeMarkup(0, undefined, { + language: "plaintext", + autoDetectLanguage: false, + }), + ); + if (action === "delete") + item.view.dispatch( + item.view.state.tr.replaceWith( + 0, + item.view.state.doc.content.size, + item.view.state.schema.node("paragraph"), + ), + ); + if (action === "restore") + item.view.updateState( + EditorState.create({ + doc: codeDoc("restored", "python", false), + plugins: item.view.state.plugins, + }), + ); + if (action === "plain") { + enabled = false; + item.view.updateState(item.view.state); + } + if (action === "destroy") item.destroy(); + const before = item.view.state.doc; + pending.resolve("typescript"); + await vi.advanceTimersByTimeAsync(0); + expect(item.view.state.doc).toBe(before); + }, + ); + + it("isolates two editor owners even at the same state revision", async () => { + vi.useFakeTimers(); + const first = deferred(), + second = deferred(); + const a = mount("same code", { detect: () => first.promise }), + b = mount("same code", { detect: () => second.promise }); + await vi.advanceTimersByTimeAsync(150); + first.resolve("java"); + await vi.advanceTimersByTimeAsync(0); + expect(a.view.state.doc.firstChild!.attrs.language).toBe("java"); + expect(b.view.state.doc.firstChild!.attrs.language).toBeNull(); + second.resolve("rust"); + await vi.advanceTimersByTimeAsync(0); + expect(b.view.state.doc.firstChild!.attrs.language).toBe("rust"); + }); + + it("invalidates the pending generation when restoring state with the same document identity", async () => { + vi.useFakeTimers(); + const pending = deferred(); + const item = mount("same document source", { + detect: () => pending.promise, + }); + await vi.advanceTimersByTimeAsync(150); + const doc = item.view.state.doc; + item.view.updateState( + EditorState.create({ doc, plugins: item.view.state.plugins }), + ); + pending.resolve("rust"); + await vi.advanceTimersByTimeAsync(0); + expect(item.view.state.doc).toBe(doc); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("rust"); + }); + + it("guards readonly and IME as Lime policy, then resumes current editable source", async () => { + vi.useFakeTimers(); + const detect = vi.fn(async () => "javascript"); + const readonly = mount("const readonly = 1", { detect }, false); + await vi.advanceTimersByTimeAsync(500); + expect(detect).not.toHaveBeenCalled(); + readonly.view.setProps({ editable: () => true }); + await vi.advanceTimersByTimeAsync(150); + expect(readonly.view.state.doc.firstChild!.attrs.language).toBe( + "javascript", + ); + const composing = mount("const ime = 1", { detect }); + Object.defineProperty(composing.view, "composing", { + configurable: true, + value: true, + }); + const calls = detect.mock.calls.length; + await vi.advanceTimersByTimeAsync(500); + expect(detect).toHaveBeenCalledTimes(calls); + Object.defineProperty(composing.view, "composing", { + configurable: true, + value: false, + }); + await vi.advanceTimersByTimeAsync(150); + expect(composing.view.state.doc.firstChild!.attrs.language).toBe( + "javascript", + ); + }); + + it("keeps manual language and handles rejected detection as plain without changing content", async () => { + vi.useFakeTimers(); + const item = mount("unknown content", { + detect: () => Promise.reject(new Error("bad parser")), + }); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("plaintext"); + item.view.dispatch( + item.view.state.tr.setNodeMarkup(0, undefined, { + language: "typescript", + autoDetectLanguage: false, + }), + ); + item.view.dispatch(item.view.state.tr.insertText("changed ", 1)); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("typescript"); + }); + + it("clears empty Auto language and leaves user undo/redo semantics intact", async () => { + vi.useFakeTimers(); + const item = mount("let x = 1", { + detect: async (content) => (content.trim() ? "javascript" : null), + }); + item.selectCode(); + await vi.advanceTimersByTimeAsync(150); + item.view.dispatch( + item.view.state.tr.delete(1, item.view.state.doc.content.size - 1), + ); + expect(item.view.state.doc.firstChild!.attrs.language).toBeNull(); + expect(undo(item.view.state, item.view.dispatch)).toBe(true); + expect(item.view.state.doc.textContent).toBe("let x = 1"); + expect(undo(item.view.state, item.view.dispatch)).toBe(false); + expect(redo(item.view.state, item.view.dispatch)).toBe(true); + expect(item.view.state.doc.textContent).toBe(""); + }); + + it.each(["neighbor", "attrs", "delete-before", "replace", "convert"])( + "does not clear unrelated or already empty metadata after %s", + (action) => { + vi.useFakeTimers(); + const empty = codeDoc("", "python").firstChild!; + const nonempty = codeDoc("original", "javascript").firstChild!; + const schema = empty.type.schema; + const paragraph = schema.node("paragraph", null, schema.text("outside")); + const blocks = + action === "delete-before" + ? [nonempty, empty] + : action === "replace" + ? [nonempty] + : action === "convert" + ? [schema.node("paragraph")] + : [empty, paragraph]; + const item = mountCodeView(schema.node("doc", null, blocks), [ + createCodeBlockAutoDetect({ detect: async () => "python" }), + ]); + mounted.push(item); + item.view.dispatch( + item.view.state.tr.setSelection( + TextSelection.create( + item.view.state.doc, + action === "delete-before" ? nonempty.nodeSize + 1 : 1, + ), + ), + ); + let transaction = item.view.state.tr; + if (action === "neighbor") + transaction = transaction.insertText( + "!", + transaction.doc.content.size - 1, + ); + if (action === "attrs") + transaction = transaction.setNodeMarkup(0, undefined, { + language: "rust", + autoDetectLanguage: true, + }); + if (action === "delete-before") + transaction = transaction.delete(0, nonempty.nodeSize); + if (action === "replace") + transaction = transaction.replaceWith(0, nonempty.nodeSize, empty); + if (action === "convert") + transaction = transaction.setNodeMarkup( + 0, + schema.nodes.code_block, + empty.attrs, + ); + item.view.dispatch(transaction); + expect(item.view.state.doc.firstChild!.attrs.language).toBe( + action === "attrs" ? "rust" : "python", + ); + expect( + item.transactions.filter( + (item) => item.docChanged && item.addToHistory === false, + ), + ).toEqual([]); + }, + ); + + it("maps selected code through earlier structural edits when clearing its text", () => { + vi.useFakeTimers(); + const item = mount("original", { detect: async () => "javascript" }); + item.view.dispatch( + item.view.state.tr.setNodeMarkup(0, undefined, { + language: "javascript", + autoDetectLanguage: true, + }), + ); + item.selectCode(); + const paragraph = item.view.state.schema.node( + "paragraph", + null, + item.view.state.schema.text("before"), + ); + const transaction = item.view.state.tr.insert(0, paragraph); + const pos = paragraph.nodeSize; + transaction.delete(pos + 1, pos + 1 + "original".length); + item.view.dispatch(transaction); + expect(item.view.state.doc.lastChild!.attrs.language).toBeNull(); + expect(item.view.state.doc.firstChild!.textContent).toBe("before"); + }); + + it("defers a pending result that resolves during composition and resumes without re-detecting", async () => { + vi.useFakeTimers(); + const pending = deferred(); + const detect = vi.fn(() => pending.promise); + const item = mount("current source", { detect }); + await vi.advanceTimersByTimeAsync(150); + Object.defineProperty(item.view, "composing", { + configurable: true, + value: true, + }); + pending.resolve("typescript"); + await vi.advanceTimersByTimeAsync(0); + expect(item.view.state.doc.firstChild!.attrs.language).toBeNull(); + Object.defineProperty(item.view, "composing", { + configurable: true, + value: false, + }); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("typescript"); + expect(detect).toHaveBeenCalledTimes(1); + }); + + it("recovers detector import failure on a new code node while keeping failed-node plain metadata cached", async () => { + const real = await loadCodeBlockParser(); + vi.useFakeTimers(); + const detector = vi + .fn() + .mockRejectedValueOnce(new Error("chunk unavailable")) + .mockResolvedValue(real); + const owner = createCodeBlockLoaders({ + detector, + parser: async () => real, + }); + const source = + "pub fn answer<'a>(value: &'a str) -> Option<&'a str> { Some(value) }"; + const item = mount(source, { + detect: (content) => + detectCodeBlockLanguage(content, async (text, subset) => + (await owner.loadDetector()).detect(text, subset), + ), + }); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("plaintext"); + await vi.advanceTimersByTimeAsync(150); + expect(detector).toHaveBeenCalledTimes(1); + item.view.dispatch(item.view.state.tr.insertText(" ", 1)); + await vi.advanceTimersByTimeAsync(150); + expect(item.view.state.doc.firstChild!.attrs.language).toBe("rust"); + expect(detector).toHaveBeenCalledTimes(2); + expect(item.view.state.doc.textContent).toBe(` ${source}`); + }); + + it("updates only Auto blocks while preserving all code and the current selection", async () => { + vi.useFakeTimers(); + const first = codeDoc("source one").firstChild!; + const manual = codeDoc("source manual", "rust", false).firstChild!; + const last = codeDoc("source two").firstChild!; + const detect = vi.fn(async (content: string) => + content === "source one" ? "javascript" : "python", + ); + const doc = first.type.schema.node("doc", null, [first, manual, last]); + const item = mountCodeView(doc, [createCodeBlockAutoDetect({ detect })]); + mounted.push(item); + item.view.dispatch( + item.view.state.tr.setSelection( + TextSelection.create(doc, first.nodeSize + 2), + ), + ); + const selection = item.view.state.selection; + await vi.advanceTimersByTimeAsync(150); + expect(detect).toHaveBeenCalledTimes(2); + expect(item.view.state.doc.child(0).attrs.language).toBe("javascript"); + expect(item.view.state.doc.child(1)).toBe(manual); + expect(item.view.state.doc.child(2).attrs.language).toBe("python"); + expect(item.view.state.doc.textContent).toBe(doc.textContent); + expect(item.view.state.selection.eq(selection)).toBe(true); + expect(item.transactions.filter((item) => item.docChanged)).toEqual([ + { docChanged: true, addToHistory: false }, + ]); + }); +}); diff --git a/src/components/input-kit/composerCodeBlockAutoDetect.ts b/src/components/input-kit/composerCodeBlockAutoDetect.ts new file mode 100644 index 000000000..5c8ad9b63 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockAutoDetect.ts @@ -0,0 +1,187 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin } from "@tiptap/pm/state"; +import { Mapping } from "@tiptap/pm/transform"; +import type { EditorView } from "@tiptap/pm/view"; +import { + detectCodeBlockLanguage, + normalizeCodeBlockLanguage, +} from "./composerCodeBlockLanguage"; +import { loadCodeBlockDetector } from "./composerCodeBlockHighlight"; + +export interface CodeBlockAutoDetectOptions { + readonly detect?: (content: string) => Promise; + readonly enabled?: () => boolean; + readonly delayMs?: number; +} + +async function detectLanguage(content: string): Promise { + return detectCodeBlockLanguage(content, async (source, subset) => { + const detector = await loadCodeBlockDetector(); + return detector.detect(source, subset); + }); +} + +export function createCodeBlockAutoDetect( + options: CodeBlockAutoDetectOptions = {}, +): Plugin { + const detect = options.detect ?? detectLanguage; + const delay = options.delayMs ?? 150; + const enabled = () => options.enabled?.() !== false; + let ownedView: EditorView | undefined; + return new Plugin({ + appendTransaction: (transactions, previous, state) => { + if ( + !ownedView?.editable || + ownedView.composing || + !enabled() || + !transactions.some((item) => item.docChanged) + ) + return null; + const selected = state.selection.$from; + if ( + !selected.parent.type.spec.code || + selected.parent.attrs.autoDetectLanguage === false || + selected.parent.textContent.trim() || + selected.parent.attrs.language == null + ) + return null; + const mapping = new Mapping(); + transactions.forEach((transaction) => + mapping.appendMapping(transaction.mapping), + ); + const priorPosition = mapping.invert().mapResult(selected.before(), 1); + if (priorPosition.deleted) return null; + const priorNode = previous.doc.nodeAt(priorPosition.pos); + if ( + !priorNode?.type.spec.code || + priorNode.type !== selected.parent.type || + !priorNode.textContent.trim() + ) + return null; + // Only clearing this existing block removes its detected metadata. + return state.tr + .setNodeMarkup(selected.before(), undefined, { + ...selected.parent.attrs, + language: null, + }) + .setMeta("addToHistory", false); + }, + view: (view) => { + ownedView = view; + const detections = new WeakMap>(); + let generation = 0; + let destroyed = false; + let timer: ReturnType | undefined; + let previousDoc = view.state.doc; + let previousPlugins = view.state.plugins; + let previousEditable = view.editable; + let previousEnabled = enabled(); + const canRun = () => + !destroyed && !view.isDestroyed && view.editable && enabled(); + const detectNode = (node: ProseMirrorNode) => { + let result = detections.get(node); + if (!result) { + result = Promise.resolve() + .then(() => detect(node.textContent)) + .then((language) => + language === null + ? null + : (normalizeCodeBlockLanguage(language) ?? "plaintext"), + ) + .catch(() => "plaintext"); + detections.set(node, result); + } + return result; + }; + const run = async (currentGeneration: number) => { + timer = undefined; + if (!canRun() || generation !== currentGeneration) return; + if (view.composing) { + timer = setTimeout(() => { + void run(currentGeneration); + }, delay); + return; + } + const capturedDoc = view.state.doc; + const candidates: { + node: ProseMirrorNode; + pos: number; + language: Promise; + }[] = []; + capturedDoc.descendants((node, pos) => { + if (!node.type.spec.code) return; + if (node.attrs.autoDetectLanguage !== false) + candidates.push({ node, pos, language: detectNode(node) }); + return false; + }); + const results = await Promise.all( + candidates.map((candidate) => candidate.language), + ); + if ( + !canRun() || + generation !== currentGeneration || + view.state.doc !== capturedDoc + ) + return; + if (view.composing) { + timer = setTimeout(() => { + void run(currentGeneration); + }, delay); + return; + } + const transaction = view.state.tr; + candidates.forEach(({ node, pos }, index) => { + const language = results[index]; + if ( + node.attrs.language === language || + node.attrs.autoDetectLanguage === false + ) + return; + transaction.setNodeMarkup(pos, undefined, { + ...node.attrs, + language, + }); + const updated = transaction.doc.nodeAt(pos); + if (updated) detections.set(updated, Promise.resolve(language)); + }); + if (transaction.docChanged) + view.dispatch(transaction.setMeta("addToHistory", false)); + }; + const schedule = () => { + generation += 1; + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + if (canRun()) + timer = setTimeout(() => { + void run(generation); + }, delay); + }; + schedule(); + return { + update: () => { + const currentDoc = view.state.doc; + const editable = view.editable; + const currentEnabled = enabled(); + if ( + previousDoc !== currentDoc || + previousPlugins !== view.state.plugins || + previousEditable !== editable || + previousEnabled !== currentEnabled + ) { + previousDoc = currentDoc; + previousPlugins = view.state.plugins; + previousEditable = editable; + previousEnabled = currentEnabled; + schedule(); + } + }, + destroy: () => { + destroyed = true; + generation += 1; + if (timer !== undefined) clearTimeout(timer); + if (ownedView === view) ownedView = undefined; + }, + }; + }, + }); +} diff --git a/src/components/input-kit/composerCodeBlockHighlight.test.ts b/src/components/input-kit/composerCodeBlockHighlight.test.ts new file mode 100644 index 000000000..3972a9691 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockHighlight.test.ts @@ -0,0 +1,573 @@ +import { EditorState, TextSelection } from "@tiptap/pm/state"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + loadCodeBlockParser, + createCodeBlockHighlight, + codeBlockHighlightKey, + createCodeBlockLoaders, + createTsrxGrammar, +} from "./composerCodeBlockHighlight"; +import { composerSchema } from "./composerSchema"; +import { + codeBlockLanguages, + detectCodeBlockLanguage, +} from "./composerCodeBlockLanguage"; +import { codeDoc, deferred, mountCodeView } from "./composerCodeBlockTestUtils"; +import referenceLanguages from "./composerCodeBlockReference.testFixtures.json"; + +const mounted: ReturnType[] = []; +afterEach(() => mounted.splice(0).forEach((item) => item.destroy())); + +describe("real parser and PM decorations", () => { + it("places real keyword/string tokens at UTF16 pos+1 and never rewrites code", async () => { + const parser = await loadCodeBlockParser(); + const source = '// 中😀\nconst name = "中😀";\n\n'; + const doc = codeDoc(source, "javascript", false); + const state = EditorState.create({ + doc, + plugins: [createCodeBlockHighlight({ parser })], + }); + const decorations = codeBlockHighlightKey + .getState(state)! + .decorations.find(); + const keyword = decorations.find( + (item) => doc.textBetween(item.from, item.to) === "const", + ); + expect(keyword).toBeDefined(); + expect(keyword!.from).toBe(1 + source.indexOf("const")); + expect( + decorations.some( + (item) => doc.textBetween(item.from, item.to) === '"中😀"', + ), + ).toBe(true); + expect(state.doc).toBe(doc); + expect(doc.textContent).toBe(source); + }); + + it("supports the confirmed grammar catalog and leaves mermaid/plain unhighlighted", async () => { + const parser = await loadCodeBlockParser(); + for (const language of codeBlockLanguages) { + expect(parser.supports(language.value), language.value).toBe( + language.value !== "mermaid", + ); + } + expect(parser.supports("graphql")).toBe(true); + expect(parser.supports("wasm")).toBe(true); + expect(parser.supports("tsrx")).toBe(true); + expect(parser.supports("mermaid")).toBe(false); + for (const language of ["plaintext", "mermaid", "unknown"]) { + expect( + parser.parse({ content: "const a = 1", language, pos: 0 }), + ).toEqual([]); + } + expect( + parser.parse({ + content: "query Foo { field }", + language: "graphql", + pos: 7, + }).length, + ).toBeGreaterThan(0); + expect( + parser.parse({ + content: '(module (func (export "main") (result i32) i32.const 1))', + language: "wasm", + pos: 0, + }).length, + ).toBeGreaterThan(0); + expect( + parser.parse({ content: "\\frac{1}{2}", language: "math", pos: 0 }) + .length, + ).toBeGreaterThan(0); + }); + + it.each([ + ["dockerfile", "FROM node:24\nRUN echo hello", "FROM"], + ["powershell", '$name = "hello"\nGet-Item $name', "Get-Item"], + ["commonlisp", '(defun greet (name) (format t "hello ~a" name))', "defun"], + ["wolfram", "Plot[Sin[x], {x, 0, Pi}]", "Plot"], + ])( + "executes the raw registered parser for %s while keeping extras out of GUI normalization", + async (language, source, token) => { + const parser = await loadCodeBlockParser(); + const doc = codeDoc(source, language, false); + expect(parser.supports(language)).toBe(true); + const decorations = parser.parse({ content: source, language, pos: 0 }); + expect(decorations.length).toBeGreaterThan(0); + expect( + decorations.map((decoration) => + doc.textBetween(decoration.from, decoration.to), + ), + ).toContain(token); + const state = EditorState.create({ + doc, + plugins: [createCodeBlockHighlight({ parser })], + }); + expect(codeBlockHighlightKey.getState(state)!.decorations.find()).toEqual( + [], + ); + expect(state.doc).toBe(doc); + }, + ); + + it("registers all 53 observed raw grammars and five parser alias groups without expanding the 41-item picker", async () => { + const parser = await loadCodeBlockParser(); + expect(referenceLanguages.registeredGrammars).toHaveLength(53); + expect(Object.keys(referenceLanguages.parserAliases)).toHaveLength(5); + for (const language of referenceLanguages.registeredGrammars) + expect(parser.supports(language), language).toBe(true); + for (const aliases of Object.values(referenceLanguages.parserAliases)) { + for (const alias of aliases) + expect(parser.supports(alias), alias).toBe(true); + } + expect(codeBlockLanguages).toHaveLength(41); + expect( + parser.parse({ content: "plain text", language: "plaintext", pos: 0 }), + ).toEqual([]); + }); + + it("uses real grammar relevance for unhinted source and excludes unregistered names", async () => { + const parser = await loadCodeBlockParser(); + const source = + "pub fn answer<'a>(value: &'a str) -> Option<&'a str> { Some(value) }"; + const detection = parser.detect(source, [ + "rust", + "tsrx", + "mermaid", + "unknown", + ]); + expect(detection.language).toBe("rust"); + expect(detection.relevance).toBeGreaterThanOrEqual(2); + expect(await detectCodeBlockLanguage(source, parser.detect)).toBe("rust"); + expect( + parser.detect("a long phrase with no registered grammar", [ + "tsrx", + "unknown", + ]).language, + ).toBeNull(); + }); + + it("parses TSRX directives, nested markup, expressions, and CSS with real tokens", async () => { + const parser = await loadCodeBlockParser(); + const source = + 'const App = (props: {name: string}) => @{
<>@if (props.name) { {props.name} } @else { 0 ? ">" : "no"} /> }
};'; + const item = mountCodeView(codeDoc(source, "tsrx", false), [ + createCodeBlockHighlight({ parser }), + ]); + mounted.push(item); + const code = item.view.dom.querySelector("code")!; + expect(code.textContent).toBe(source); + expect( + [...code.querySelectorAll(".hljs-keyword")].map( + (element) => element.textContent, + ), + ).toEqual(expect.arrayContaining(["const", "@{", "@if", "@else"])); + expect( + [...code.querySelectorAll(".hljs-name")].map( + (element) => element.textContent, + ), + ).toEqual(expect.arrayContaining(["div", "span", "Input", "style"])); + expect( + [...code.querySelectorAll(".hljs-tag")].some((element) => + element.textContent?.includes("className"), + ), + ).toBe(true); + expect(code.querySelector(".hljs-attr")).toBeNull(); + expect(code.querySelector(".hljs-attribute")).toBeNull(); + expect(code.querySelector(".css")?.textContent).toBe("p { color: red; }"); + expect(parser.detect(source, ["tsrx"]).language).toBeNull(); + }); + + it("produces nested attribute/CSS scopes in the real TSRX HAST grammar separately from PM root projection", async () => { + const [{ createLowlight }, { default: typescript }, { default: css }] = + await Promise.all([ + import("lowlight"), + import("highlight.js/lib/languages/typescript"), + import("highlight.js/lib/languages/css"), + ]); + const grammar = createLowlight({ + tsrx: createTsrxGrammar(typescript), + css, + }); + const source = + 'const App = () => @{
};'; + const tree = grammar.highlight("tsrx", source); + type Token = (typeof tree.children)[number]; + const scopes: string[] = []; + const cssProperties: string[] = []; + const read = (node: Token): string => { + if (node.type === "text") return node.value; + if (node.type !== "element") throw new Error("unexpected grammar token"); + const text = node.children.map(read).join(""); + const classes = node.properties.className; + if (Array.isArray(classes)) { + scopes.push(...classes.map(String)); + if (classes.includes("hljs-attribute")) cssProperties.push(text); + } + return text; + }; + expect(tree.children.map(read).join("")).toBe(source); + expect(scopes).toEqual( + expect.arrayContaining([ + "hljs-keyword", + "hljs-tag", + "hljs-name", + "hljs-attr", + "css", + ]), + ); + expect(cssProperties).toContain("color"); + }); + + it("keeps nested JavaScript callback scopes flat and does not fabricate keyword tokens inside comments or strings", async () => { + const parser = await loadCodeBlockParser(); + const source = + '// const fake = "comment";\nconst real = "const in string";\nconst mapped = values.map((value = "const nested") => value);'; + const item = mountCodeView(codeDoc(source, "javascript", false), [ + createCodeBlockHighlight({ parser }), + ]); + mounted.push(item); + const keywords = [...item.view.dom.querySelectorAll(".hljs-keyword")].map( + (element) => element.textContent, + ); + expect(keywords.filter((token) => token === "const")).toHaveLength(2); + expect(item.view.dom.querySelector(".hljs-params")).toBeNull(); + expect( + [...item.view.dom.querySelectorAll(".hljs-string")].map( + (element) => element.textContent, + ), + ).toEqual(['"const in string"']); + expect(item.view.dom.querySelector(".hljs-function")?.textContent).toBe( + '(value = "const nested") =>', + ); + expect(item.view.dom.querySelector("code")!.textContent).toBe(source); + }); + + it("keeps TypeScript generics/comparisons outside TSRX markup and bounds self-closing lookahead", async () => { + const parser = await loadCodeBlockParser(); + const generic = + "const values: Array = []; const less = left < right;"; + const item = mountCodeView(codeDoc(generic, "tsrx", false), [ + createCodeBlockHighlight({ parser }), + ]); + mounted.push(item); + expect(item.view.dom.querySelector(".hljs-tag")).toBeNull(); + expect(item.view.dom.querySelector("code")!.textContent).toBe(generic); + for (const length of [1, 4097]) { + const source = `const App = () => @{ 0 ? ">" : "no"} />; const after = 1; };`; + const result = mountCodeView(codeDoc(source, "tsrx", false), [ + createCodeBlockHighlight({ parser }), + ]); + mounted.push(result); + expect(result.view.dom.querySelector("code")!.textContent).toBe(source); + const keywords = [ + ...result.view.dom.querySelectorAll(".hljs-keyword"), + ].map((element) => element.textContent); + expect(keywords.filter((token) => token === "const")).toHaveLength( + length === 1 ? 2 : 1, + ); + } + }); + + it("maps unchanged moved code and invalidates changed attrs/body without stale offsets", async () => { + const real = await loadCodeBlockParser(); + const parser = { ...real, parse: vi.fn(real.parse) }; + const plugin = createCodeBlockHighlight({ parser }); + let state = EditorState.create({ + doc: codeDoc("const value = 1;", "javascript", false), + plugins: [plugin], + }); + expect(parser.parse).toHaveBeenCalledTimes(1); + const prior = codeBlockHighlightKey + .getState(state)! + .decorations.find() + .map((item) => [item.from, item.to]); + const before = composerSchema.node( + "paragraph", + null, + composerSchema.text("前缀😀"), + ); + state = state.applyTransaction(state.tr.insert(0, before)).state; + expect(parser.parse).toHaveBeenCalledTimes(1); + expect( + codeBlockHighlightKey + .getState(state)! + .decorations.find() + .map((item) => [item.from, item.to]), + ).toEqual( + prior.map(([a, b]) => [a + before.nodeSize, b + before.nodeSize]), + ); + state = state.applyTransaction( + state.tr.setNodeMarkup(before.nodeSize, undefined, { + language: "plaintext", + autoDetectLanguage: false, + }), + ).state; + expect(codeBlockHighlightKey.getState(state)!.decorations.find()).toEqual( + [], + ); + state = state.applyTransaction( + state.tr.setNodeMarkup(before.nodeSize, undefined, { + language: "javascript", + autoDetectLanguage: false, + }), + ).state; + state = state.applyTransaction( + state.tr.insertText(" // tail", state.doc.content.size - 1), + ).state; + expect(parser.parse).toHaveBeenCalledTimes(3); + expect(state.doc.lastChild!.textContent).toBe("const value = 1; // tail"); + }); + + it("projects real PM spans while selection/body stay PM-owned", async () => { + const parser = await loadCodeBlockParser(); + const item = mountCodeView( + codeDoc('const value = "😀";', "javascript", false), + [createCodeBlockHighlight({ parser })], + ); + mounted.push(item); + expect(item.view.dom.querySelector("code .hljs-keyword")?.textContent).toBe( + "const", + ); + item.view.dispatch( + item.view.state.tr + .setSelection(TextSelection.create(item.view.state.doc, 6)) + .insertText(" "), + ); + expect(item.view.dom.querySelector("code")!.textContent).toBe( + 'const value = "😀";', + ); + expect(item.view.state.selection.head).toBe(7); + }); + + it("refreshes the current doc after lazy load and never dispatches after destruction", async () => { + const parser = await loadCodeBlockParser(); + const pending = deferred(); + const item = mountCodeView(codeDoc("const old = 1", "javascript", false), [ + createCodeBlockHighlight({ parser: () => pending.promise }), + ]); + mounted.push(item); + item.view.dispatch( + item.view.state.tr.replaceWith( + 0, + item.view.state.doc.content.size, + codeDoc("const current = 2", "javascript", false).content, + ), + ); + pending.resolve(parser); + await vi.waitFor(() => + expect(item.view.dom.querySelector(".hljs-keyword")).not.toBeNull(), + ); + expect(item.view.state.doc.textContent).toBe("const current = 2"); + expect( + item.transactions + .filter((t) => !t.docChanged) + .every((t) => t.addToHistory === false), + ).toBe(true); + const abandoned = deferred(); + const dead = mountCodeView(codeDoc("const dead = 0", "javascript", false), [ + createCodeBlockHighlight({ parser: () => abandoned.promise }), + ]); + mounted.push(dead); + dead.destroy(); + abandoned.resolve(parser); + await Promise.resolve(); + await Promise.resolve(); + expect(dead.transactions).toEqual([]); + }); + + it("fails closed on parser rejection and respects plain mode without fabricated tokens", async () => { + const error = new Error("unavailable"), + onError = vi.fn(); + const item = mountCodeView( + codeDoc("const plain = 1", "javascript", false), + [ + createCodeBlockHighlight({ + parser: () => Promise.reject(error), + onError, + }), + ], + ); + mounted.push(item); + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error)); + expect(item.view.dom.querySelector(".hljs-keyword")).toBeNull(); + expect(item.view.state.doc.textContent).toBe("const plain = 1"); + const parser = await loadCodeBlockParser(); + const state = EditorState.create({ + doc: codeDoc("const hidden = 1", "javascript", false), + plugins: [createCodeBlockHighlight({ parser, enabled: () => false })], + }); + expect(codeBlockHighlightKey.getState(state)!.decorations.find()).toEqual( + [], + ); + }); + + it("retains the surviving block cache after deleting an earlier code block", async () => { + const real = await loadCodeBlockParser(); + const parser = { ...real, parse: vi.fn(real.parse) }; + const first = codeDoc("const first = 1", "javascript", false).firstChild!; + const second = codeDoc("fn main() {}", "rust", false).firstChild!; + let state = EditorState.create({ + doc: composerSchema.node("doc", null, [first, second]), + plugins: [createCodeBlockHighlight({ parser })], + }); + expect(parser.parse).toHaveBeenCalledTimes(2); + state = state.apply(state.tr.delete(0, first.nodeSize)); + expect(parser.parse).toHaveBeenCalledTimes(2); + const tokens = codeBlockHighlightKey.getState(state)!.decorations.find(); + expect( + tokens.some( + (token) => state.doc.textBetween(token.from, token.to) === "fn", + ), + ).toBe(true); + expect( + tokens.every( + (token) => token.from >= 1 && token.to <= second.nodeSize - 1, + ), + ).toBe(true); + }); + + it("holds loaded results in plain mode and renders current code when re-enabled", async () => { + const parser = await loadCodeBlockParser(); + const pending = deferred(); + let enabled = false; + const item = mountCodeView( + codeDoc("const current = 1", "javascript", false), + [ + createCodeBlockHighlight({ + parser: () => pending.promise, + enabled: () => enabled, + }), + ], + ); + mounted.push(item); + pending.resolve(parser); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(item.transactions).toEqual([]); + expect(item.view.dom.querySelector(".hljs-keyword")).toBeNull(); + enabled = true; + item.view.updateState(item.view.state); + await vi.waitFor(() => + expect(item.view.dom.querySelector(".hljs-keyword")?.textContent).toBe( + "const", + ), + ); + expect(item.transactions).toEqual([ + { docChanged: false, addToHistory: false }, + ]); + }); + + it("retries a failed detector while preserving the first highlight fallback", async () => { + const real = await loadCodeBlockParser(); + const detector = vi + .fn() + .mockRejectedValueOnce(new Error("detector chunk unavailable")) + .mockResolvedValue(real); + const parser = vi.fn(async () => real); + const owner = createCodeBlockLoaders({ detector, parser }); + const plain = await owner.loadParser(); + expect(plain.supports("javascript")).toBe(false); + expect(parser).not.toHaveBeenCalled(); + expect(await owner.loadDetector()).toBe(real); + expect(detector).toHaveBeenCalledTimes(2); + expect(await owner.loadParser()).toBe(plain); + expect(parser).not.toHaveBeenCalled(); + const item = mountCodeView( + codeDoc("const source = 1", "javascript", false), + [createCodeBlockHighlight({ parser: owner.loadParser })], + ); + mounted.push(item); + await vi.waitFor(() => expect(item.transactions).toHaveLength(1)); + expect(item.view.dom.querySelector(".hljs-keyword")).toBeNull(); + expect(item.view.state.doc.textContent).toBe("const source = 1"); + }); + + it("keeps detection usable after parser failure and does not retry highlight on edits or remount", async () => { + const real = await loadCodeBlockParser(); + const detector = vi.fn(async () => real); + const parser = vi + .fn() + .mockRejectedValueOnce(new Error("lowlight unavailable")) + .mockResolvedValue(real); + const owner = createCodeBlockLoaders({ detector, parser }); + const plain = await owner.loadParser(); + const source = + "pub fn answer<'a>(value: &'a str) -> Option<&'a str> { Some(value) }"; + expect((await owner.loadDetector()).detect(source, ["rust"]).language).toBe( + "rust", + ); + const item = mountCodeView( + codeDoc("const source = 1", "javascript", false), + [createCodeBlockHighlight({ parser: owner.loadParser })], + ); + mounted.push(item); + await vi.waitFor(() => expect(item.transactions).toHaveLength(1)); + item.view.dispatch(item.view.state.tr.insertText(" ", 1)); + const another = mountCodeView(item.view.state.doc, [ + createCodeBlockHighlight({ parser: owner.loadParser }), + ]); + mounted.push(another); + await vi.waitFor(() => expect(another.transactions).toHaveLength(1)); + expect(await owner.loadParser()).toBe(plain); + expect(parser).toHaveBeenCalledTimes(1); + expect(detector).toHaveBeenCalledTimes(1); + expect(item.view.dom.querySelector(".hljs-keyword")).toBeNull(); + expect(another.view.dom.querySelector(".hljs-keyword")).toBeNull(); + }); + + it("deduplicates concurrent successful loader requests", async () => { + const real = await loadCodeBlockParser(); + const pending = deferred(); + const detector = vi.fn(() => pending.promise); + const parser = vi.fn(async () => real); + const owner = createCodeBlockLoaders({ detector, parser }); + const first = owner.loadDetector(), + second = owner.loadParser(), + third = owner.loadParser(); + await Promise.resolve(); + expect(detector).toHaveBeenCalledTimes(1); + pending.resolve(real); + expect(await Promise.all([first, second, third])).toEqual([ + real, + real, + real, + ]); + expect(parser).toHaveBeenCalledTimes(1); + }); + + it("fails closed if a parser throws and still projects readonly code", async () => { + const real = await loadCodeBlockParser(); + const error = new Error("bad tokens"); + const onError = vi.fn(); + const state = EditorState.create({ + doc: codeDoc("const source = 1", "javascript", false), + plugins: [ + createCodeBlockHighlight({ + parser: { + ...real, + parse: () => { + throw error; + }, + }, + onError, + }), + ], + }); + expect(onError).toHaveBeenCalledWith(error); + expect(codeBlockHighlightKey.getState(state)!.decorations.find()).toEqual( + [], + ); + expect(state.doc.textContent).toBe("const source = 1"); + const item = mountCodeView( + state.doc, + [createCodeBlockHighlight({ parser: real })], + { editable: false }, + ); + mounted.push(item); + expect(item.view.dom.querySelector(".hljs-keyword")?.textContent).toBe( + "const", + ); + expect(item.transactions).toEqual([]); + }); +}); diff --git a/src/components/input-kit/composerCodeBlockHighlight.ts b/src/components/input-kit/composerCodeBlockHighlight.ts new file mode 100644 index 000000000..673266f55 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockHighlight.ts @@ -0,0 +1,508 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import type { LanguageFn, Mode } from "highlight.js"; +import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + codeBlockHighlightLanguage, + type CodeLanguageDetection, +} from "./composerCodeBlockLanguage"; + +export interface CodeBlockParseInput { + readonly content: string; + readonly language: string | null; + readonly pos: number; +} +export interface CodeBlockDetector { + detect(content: string, subset: readonly string[]): CodeLanguageDetection; +} +export interface CodeBlockParser extends CodeBlockDetector { + parse(input: CodeBlockParseInput): Decoration[]; + supports(language: string): boolean; +} + +const grammarLoaders = { + arduino: () => import("highlight.js/lib/languages/arduino"), + bash: () => import("highlight.js/lib/languages/bash"), + c: () => import("highlight.js/lib/languages/c"), + cpp: () => import("highlight.js/lib/languages/cpp"), + csharp: () => import("highlight.js/lib/languages/csharp"), + css: () => import("highlight.js/lib/languages/css"), + diff: () => import("highlight.js/lib/languages/diff"), + dockerfile: () => import("highlight.js/lib/languages/dockerfile"), + dos: () => import("highlight.js/lib/languages/dos"), + go: () => import("highlight.js/lib/languages/go"), + graphql: () => import("highlight.js/lib/languages/graphql"), + ini: () => import("highlight.js/lib/languages/ini"), + java: () => import("highlight.js/lib/languages/java"), + javascript: () => import("highlight.js/lib/languages/javascript"), + json: () => import("highlight.js/lib/languages/json"), + kotlin: () => import("highlight.js/lib/languages/kotlin"), + latex: () => import("highlight.js/lib/languages/latex"), + less: () => import("highlight.js/lib/languages/less"), + lua: () => import("highlight.js/lib/languages/lua"), + makefile: () => import("highlight.js/lib/languages/makefile"), + markdown: () => import("highlight.js/lib/languages/markdown"), + objectivec: () => import("highlight.js/lib/languages/objectivec"), + perl: () => import("highlight.js/lib/languages/perl"), + php: () => import("highlight.js/lib/languages/php"), + "php-template": () => import("highlight.js/lib/languages/php-template"), + plaintext: () => import("highlight.js/lib/languages/plaintext"), + powershell: () => import("highlight.js/lib/languages/powershell"), + python: () => import("highlight.js/lib/languages/python"), + "python-repl": () => import("highlight.js/lib/languages/python-repl"), + r: () => import("highlight.js/lib/languages/r"), + ruby: () => import("highlight.js/lib/languages/ruby"), + rust: () => import("highlight.js/lib/languages/rust"), + scss: () => import("highlight.js/lib/languages/scss"), + shell: () => import("highlight.js/lib/languages/shell"), + sql: () => import("highlight.js/lib/languages/sql"), + swift: () => import("highlight.js/lib/languages/swift"), + typescript: () => import("highlight.js/lib/languages/typescript"), + vbnet: () => import("highlight.js/lib/languages/vbnet"), + wasm: () => import("highlight.js/lib/languages/wasm"), + xml: () => import("highlight.js/lib/languages/xml"), + yaml: () => import("highlight.js/lib/languages/yaml"), + clojure: () => import("highlight.js/lib/languages/clojure"), + dart: () => import("highlight.js/lib/languages/dart"), + haskell: () => import("highlight.js/lib/languages/haskell"), + lisp: () => import("highlight.js/lib/languages/lisp"), + mathematica: () => import("highlight.js/lib/languages/mathematica"), + matlab: () => import("highlight.js/lib/languages/matlab"), + nginx: () => import("highlight.js/lib/languages/nginx"), + ocaml: () => import("highlight.js/lib/languages/ocaml"), + pgsql: () => import("highlight.js/lib/languages/pgsql"), + scala: () => import("highlight.js/lib/languages/scala"), + verilog: () => import("highlight.js/lib/languages/verilog"), +}; +type Lowlight = ReturnType; +type TokenNode = ReturnType["children"][number]; +let definitionsPromise: Promise> | undefined; + +/** A TypeScript grammar extension for template directives and markup expressions. */ +export function createTsrxGrammar(typescript: LanguageFn): LanguageFn { + return (api) => { + const base = typescript(api); + const directive: Mode = { + scope: "keyword", + match: + /@\{|@(?:if|else|for|empty|switch|case|default|try|pending|catch)\b/, + relevance: 0, + }; + const expression: Mode = { + begin: /\{/, + end: /\}/, + keywords: base.keywords, + relevance: 0, + contains: [], + }; + const attributes: Mode = { + begin: /(?<=<[A-Za-z][\w.:-]*)(?=[\s/>])/, + end: /\/?>/, + scope: "tag", + relevance: 0, + contains: [ + expression, + { scope: "attr", match: /[A-Za-z_$][\w$:.-]*/, relevance: 0 }, + api.APOS_STRING_MODE, + api.QUOTE_STRING_MODE, + ], + }; + const openTag = [/<(?=[A-Za-z])/, /[A-Za-z][\w.:-]*(?=[\s/>])/]; + const closeTag = [/<\/(?=[A-Za-z])/, /[A-Za-z][\w.:-]*/, /\s*>/]; + const selfClosing: Mode = { + begin: openTag, + beginScope: { 1: "tag", 2: "name" }, + end: /\/>/, + endScope: "tag", + relevance: 0, + contains: [ + { ...attributes, begin: /(?<=<[A-Za-z][\w.:-]*)\s+/, end: /(?=\/>)/ }, + ], + "on:begin": (match, response) => { + const source = match.input ?? ""; + const start = (match.index ?? 0) + match[0].length; + const limit = Math.min(source.length, start + 4096); + let quote = "", + depth = 0; + for (let offset = start; offset < limit; offset += 1) { + const character = source[offset]; + if (quote) { + if (character === "\\") offset += 1; + else if (character === quote) quote = ""; + } else if ( + character === "'" || + character === '"' || + character === "`" + ) + quote = character; + else if (character === "{") depth += 1; + else if (character === "}" && depth > 0) depth -= 1; + else if (character === ">" && depth === 0) { + if (source[offset - 1] !== "/") response.ignoreMatch(); + return; + } + } + response.ignoreMatch(); + }, + }; + const paired: Mode = { + begin: openTag, + beginScope: { 1: "tag", 2: "name" }, + end: closeTag, + endScope: { 1: "tag", 2: "name", 3: "tag" }, + relevance: 0, + contains: [], + "on:begin": (match, response) => { + if (/[\w$]/.test((match.input ?? "")[(match.index ?? 0) - 1] ?? "")) + response.ignoreMatch(); + }, + }; + const nested: Mode = { ...paired, "on:begin": undefined, contains: [] }; + const fragment: Mode = { + begin: /<>/, + end: /<\/>/, + beginScope: "tag", + endScope: "tag", + relevance: 0, + contains: [], + }; + const style: Mode = { + begin: [/<(?=style[\s>])/, /style(?=[\s>])/], + beginScope: { 1: "tag", 2: "name" }, + end: [/<\/(?=style\s*>)/, /style/, /\s*>/], + endScope: { 1: "tag", 2: "name", 3: "tag" }, + relevance: 0, + contains: [ + attributes, + { + begin: /(?<=>)(?!<\/style\s*>)/, + end: /(?=<\/style\s*>)/, + subLanguage: "css", + relevance: 0, + }, + ], + }; + const templates = [ + directive, + style, + selfClosing, + fragment, + paired, + expression, + ]; + const children = templates.map((mode) => (mode === paired ? nested : mode)); + paired.contains = [attributes, ...children]; + nested.contains = paired.contains; + fragment.contains = children; + expression.contains = [ + "self", + ...templates.filter((mode) => mode !== expression), + ...base.contains, + ]; + const contains = base.contains.map((mode) => + mode.contains?.some( + (child) => child !== "self" && child.subLanguage === "xml", + ) + ? { ...mode, contains: [...templates, ...mode.contains] } + : mode, + ); + return { + ...base, + name: "TSRX", + aliases: [], + disableAutodetect: true, + contains: [...templates, ...contains], + }; + }; +} + +function parseTokens( + parser: Lowlight, + input: CodeBlockParseInput, +): Decoration[] { + const language = codeBlockParserLanguage(input.language); + if (!language || !parser.registered(language) || !input.content) return []; + const result = parser.highlight(language, input.content); + const decorations: Decoration[] = []; + let offset = input.pos + 1; + let text = ""; + const tokenText = (node: TokenNode): string => { + if (node.type === "text") return node.value; + if (node.type === "element") return node.children.map(tokenText).join(""); + throw new Error("Unexpected code parser token"); + }; + // Each root scope spans its complete text; nested HAST scopes do not add PM decorations. + for (const node of result.children) { + const value = tokenText(node); + const end = offset + value.length; + if (node.type === "element" && end > offset) { + const { className, ...properties } = node.properties; + const attrs: Record = { nodeName: node.tagName }; + Object.entries(properties).forEach(([name, property]) => { + if (property != null) attrs[name] = String(property); + }); + if (className != null) + attrs.class = Array.isArray(className) + ? className.join(" ") + : String(className); + decorations.push(Decoration.inline(offset, end, attrs)); + } + offset = end; + text += value; + } + if (text !== input.content) + throw new Error("Code parser changed source content"); + return decorations; +} + +function codeBlockParserLanguage(value: unknown): string | null { + if (typeof value !== "string") return null; + const language = value.trim().toLowerCase(); + if (language === "math") return "latex"; + return language === "mermaid" ? null : language; +} + +function loadGrammarDefinitions(): Promise> { + definitionsPromise ??= Promise.all( + Object.entries(grammarLoaders).map( + async ([name, load]) => [name, (await load()).default] as const, + ), + ) + .then((grammars) => { + const typescript = grammars.find(([name]) => name === "typescript")![1]; + return { + ...Object.fromEntries(grammars), + tsrx: createTsrxGrammar(typescript), + }; + }) + .catch((error: unknown) => { + definitionsPromise = undefined; + throw error; + }); + return definitionsPromise; +} + +export interface CodeBlockLoaders { + loadParser(): Promise; + loadDetector(): Promise; +} + +/** Highlight failure stays plain; a detector import failure can retry on later input. */ +export function createCodeBlockLoaders(load: { + parser: (detector: CodeBlockDetector) => Promise; + detector: () => Promise; +}): CodeBlockLoaders { + let parserPromise: Promise | undefined; + let detectorPromise: Promise | undefined; + const fallback: CodeBlockParser = { + parse: () => [], + supports: () => false, + detect: () => ({ language: null, relevance: 0 }), + }; + const loadDetector = () => { + detectorPromise ??= Promise.resolve() + .then(load.detector) + .catch((error: unknown) => { + detectorPromise = undefined; + throw error; + }); + return detectorPromise; + }; + return { + loadDetector, + loadParser: () => { + parserPromise ??= loadDetector() + .then(load.parser) + .catch(() => fallback); + return parserPromise; + }, + }; +} + +const codeBlockLoaders = createCodeBlockLoaders({ + detector: async () => { + const [{ default: highlight }, definitions] = await Promise.all([ + import("highlight.js/lib/core"), + loadGrammarDefinitions(), + ]); + const detector = highlight.newInstance(); + Object.entries(definitions).forEach(([name, grammar]) => + detector.registerLanguage(name, grammar), + ); + detector.registerAliases("threejs", { languageName: "javascript" }); + detector.registerAliases("commonlisp", { languageName: "lisp" }); + detector.registerAliases("wolfram", { languageName: "mathematica" }); + detector.registerAliases("recharts", { languageName: "typescript" }); + detector.registerAliases("vue", { languageName: "xml" }); + return { + detect: (content, subset) => { + const result = detector.highlightAuto( + content, + subset.filter((language) => detector.getLanguage(language)), + ); + return { + language: result.language ?? null, + relevance: result.relevance, + }; + }, + }; + }, + parser: async (detector) => { + const [{ createLowlight }, definitions] = await Promise.all([ + import("lowlight"), + loadGrammarDefinitions(), + ]); + const parser = createLowlight(definitions); + parser.registerAlias({ + javascript: "threejs", + lisp: "commonlisp", + mathematica: "wolfram", + typescript: "recharts", + xml: "vue", + }); + return { + parse: (input) => parseTokens(parser, input), + supports: (value) => { + const language = codeBlockParserLanguage(value); + return language !== null && parser.registered(language); + }, + detect: detector.detect, + }; + }, +}); + +export function loadCodeBlockParser(): Promise { + return codeBlockLoaders.loadParser(); +} +export function loadCodeBlockDetector(): Promise { + return codeBlockLoaders.loadDetector(); +} + +interface CodeBlockHighlightCache { + readonly node: ProseMirrorNode; + readonly decorations: DecorationSet; +} +export interface CodeBlockHighlightState { + readonly decorations: DecorationSet; + readonly cache: ReadonlyMap; +} +export interface CodeBlockHighlightOptions { + readonly parser?: CodeBlockParser | (() => Promise); + readonly enabled?: () => boolean; + readonly onError?: (error: unknown) => void; +} +export const codeBlockHighlightKey = new PluginKey( + "composer-code-highlight", +); + +export function createCodeBlockHighlight( + options: CodeBlockHighlightOptions = {}, +): Plugin { + let parser = typeof options.parser === "object" ? options.parser : undefined; + const enabled = () => options.enabled?.() !== false; + const project = ( + doc: ProseMirrorNode, + previous?: CodeBlockHighlightState, + transaction?: Transaction, + ): CodeBlockHighlightState => { + const cache = new Map(); + if (!enabled() || !parser) + return { cache, decorations: DecorationSet.empty }; + const mappedCache = new Map(); + if (previous && transaction) { + previous.cache.forEach((entry, pos) => { + const mapped = transaction.mapping.mapResult(pos, 1); + if (!mapped.deleted) + mappedCache.set(mapped.pos, { + node: entry.node, + decorations: entry.decorations.map(transaction.mapping, doc), + }); + }); + } + const decorations: Decoration[] = []; + doc.descendants((node, pos) => { + if (!node.type.spec.code) return; + const prior = mappedCache.get(pos); + let set = prior?.node.eq(node) ? prior.decorations : undefined; + if (!set) { + try { + const language = codeBlockHighlightLanguage(node.attrs.language); + set = + language && parser!.supports(language) + ? DecorationSet.create( + doc, + parser!.parse({ + content: node.textContent, + language: node.attrs.language, + pos, + }), + ) + : DecorationSet.empty; + } catch (error) { + options.onError?.(error); + set = DecorationSet.empty; + } + } + cache.set(pos, { node, decorations: set }); + decorations.push(...set.find()); + return false; + }); + return { cache, decorations: DecorationSet.create(doc, decorations) }; + }; + return new Plugin({ + key: codeBlockHighlightKey, + state: { + init: (_, state) => project(state.doc), + apply: (transaction, state) => + transaction.docChanged || transaction.getMeta(codeBlockHighlightKey) + ? project(transaction.doc, state, transaction) + : state, + }, + props: { + decorations: (state) => + enabled() + ? codeBlockHighlightKey.getState(state)!.decorations + : DecorationSet.empty, + }, + view: (view) => { + let destroyed = false; + let wasEnabled = enabled(); + const refresh = () => { + if (!destroyed && !view.isDestroyed && enabled()) { + view.dispatch( + view.state.tr + .setMeta(codeBlockHighlightKey, true) + .setMeta("addToHistory", false), + ); + } + }; + if (!parser) { + Promise.resolve() + .then(() => + typeof options.parser === "function" + ? options.parser() + : loadCodeBlockParser(), + ) + .then((ready) => { + parser = ready; + refresh(); + }) + .catch((error: unknown) => { + if (!destroyed) options.onError?.(error); + }); + } + return { + update: () => { + const currentEnabled = enabled(); + if (wasEnabled !== currentEnabled) { + wasEnabled = currentEnabled; + if (parser) refresh(); + } + }, + destroy: () => { + destroyed = true; + }, + }; + }, + }); +} diff --git a/src/components/input-kit/composerCodeBlockLanguage.test.ts b/src/components/input-kit/composerCodeBlockLanguage.test.ts new file mode 100644 index 000000000..816bf4e33 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockLanguage.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from "vitest"; +import { + codeBlockLanguages, + normalizeCodeBlockLanguage, + codeBlockHighlightLanguage, + codeBlockLanguageLabelKey, + detectCodeBlockLanguage, +} from "./composerCodeBlockLanguage"; + +describe("code language semantics", () => { + it("keeps the shipped picker catalog distinct from parser extras", () => { + expect(codeBlockLanguages).toHaveLength(41); + expect(new Set(codeBlockLanguages.map((item) => item.value)).size).toBe(41); + expect(codeBlockLanguages.some((item) => item.value === "tsrx")).toBe(true); + expect(codeBlockLanguages.some((item) => item.value === "dockerfile")).toBe( + false, + ); + }); + + it.each([ + [" JSX ", "javascript"], + ["TSX", "typescript"], + ["html", "xml"], + ["zsh", "bash"], + ["C++", "cpp"], + ["text", "plaintext"], + ["unknown", null], + ["java script", null], + [null, null], + ])( + "canonicalizes %s without inventing a parser language", + (input, expected) => { + expect(normalizeCodeBlockLanguage(input)).toBe(expected); + }, + ); + + it("separates metadata names from display and highlighter language", () => { + expect(codeBlockHighlightLanguage("math")).toBe("latex"); + expect(codeBlockHighlightLanguage("mermaid")).toBeNull(); + expect(codeBlockHighlightLanguage("plaintext")).toBeNull(); + expect(codeBlockLanguageLabelKey("math")).toBe( + "composer.codeBlock.language.math", + ); + expect(codeBlockLanguageLabelKey("plaintext")).toBe( + "composer.codeBlock.language.plaintext", + ); + expect(codeBlockLanguageLabelKey("ts")).toBeNull(); + }); + + it.each([ + [" \n ", null], + ["https://example.test/path", "plaintext"], + ["[INFO] ready\n[INFO] done", "plaintext"], + ["Content Type: text/plain\nServer: test\nHost: test", "plaintext"], + ['{"emoji":"😀","value":1}', "json"], + ["
你好
", "xml"], + ["SELECT a FROM users", "sql"], + ["def hello():\n return 1", "python"], + ["const result: number = 1", "typescript"], + ["const App = () => @{
}", "tsrx"], + ["abc", "plaintext"], + ["example.test/path", "plaintext"], + ["localhost:8080/path", "plaintext"], + ["PS C:\\work> Get-Item .", "plaintext"], + ["2026-10-14T12:00:00 ready", "plaintext"], + ["Compiling application...", "plaintext"], + ["HTTP/1.1 200 OK", "plaintext"], + ["On branch main", "plaintext"], + ["Build log:\n at main (src.ts:1:2)", "plaintext"], + ["name: one\nversion: two\nstatus: active", "yaml"], + ["", "plaintext"], + ["diff --git a/a b/a\n--- a/a\n+++ b/a", "diff"], + ["query GetUser { user { id } }", "graphql"], + ["using System;\nConsole.WriteLine(1);", "csharp"], + ["public static void Main() {}", "csharp"], + ["package org.example;\nclass Main {}", "java"], + ["public static void main() {}", "java"], + ["#include ", "cpp"], + ["#include ", "c"], + ["export default async function App(): View @{
}", "tsrx"], + ["const $App = x => @{
}", "tsrx"], + ["console.log(1)", "javascript"], + ["func greet() {}", "swift"], + ["library(ggplot2)", "r"], + ["import Foundation", "python"], + ])("detects bounded syntax/log evidence for %s", async (source, expected) => { + const expensive = vi.fn(() => ({ language: "java", relevance: 99 })); + expect(await detectCodeBlockLanguage(source, expensive)).toBe(expected); + expect(expensive).not.toHaveBeenCalled(); + }); + + it("uses the real detector contract only above threshold and catches failure", async () => { + const source = "a lengthy unclassified fragment without syntax"; + expect( + await detectCodeBlockLanguage(source, () => ({ + language: "rust", + relevance: 2, + })), + ).toBe("rust"); + expect( + await detectCodeBlockLanguage(source, () => ({ + language: "rust", + relevance: 1.99, + })), + ).toBe("plaintext"); + expect( + await detectCodeBlockLanguage(source, () => ({ + language: "dockerfile", + relevance: 100, + })), + ).toBe("plaintext"); + expect( + await detectCodeBlockLanguage(source, () => { + throw new Error("parser unavailable"); + }), + ).toBe("plaintext"); + }); + + it.each([ + '{/* note */ "value": 1}', + '// note\n{"value":1}', + '/* note */ [1, {"value":true}]', + '{"value": "// not a comment", "other": "/* literal */"}', + ])( + "recognizes valid JSONC objects/arrays before grammar detection: %s", + async (source) => { + const detector = vi.fn(() => ({ language: "rust", relevance: 99 })); + expect(await detectCodeBlockLanguage(source, detector)).toBe("json"); + expect(detector).not.toHaveBeenCalled(); + }, + ); + + it.each([ + '{"value":1,}', + "[1,]", + '{unquoted: "JSON5"}', + "{'single':'quote'}", + '{"value":}', + '{"value":1} extra', + "/* unfinished", + "// comment only", + "/* note */ true", + "/* note */ [1, invalid]", + ])( + "rejects malformed, scalar, or overly permissive JSONC: %s", + async (source) => { + expect( + await detectCodeBlockLanguage(source, () => ({ + language: null, + relevance: 0, + })), + ).toBe("plaintext"); + }, + ); +}); diff --git a/src/components/input-kit/composerCodeBlockLanguage.ts b/src/components/input-kit/composerCodeBlockLanguage.ts new file mode 100644 index 000000000..9f374f3f1 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockLanguage.ts @@ -0,0 +1,283 @@ +import { parseTree, type ParseError } from "jsonc-parser"; + +export interface CodeBlockLanguage { + readonly value: string; + readonly label: string | null; +} + +export const codeBlockLanguages: readonly CodeBlockLanguage[] = [ + { value: "arduino", label: "Arduino" }, + { value: "bash", label: "Bash" }, + { value: "c", label: "C" }, + { value: "cpp", label: "C++" }, + { value: "csharp", label: "C#" }, + { value: "css", label: "CSS" }, + { value: "diff", label: "Diff" }, + { value: "go", label: "Go" }, + { value: "graphql", label: "GraphQL" }, + { value: "ini", label: "INI" }, + { value: "java", label: "Java" }, + { value: "javascript", label: "JavaScript" }, + { value: "json", label: "JSON" }, + { value: "kotlin", label: "Kotlin" }, + { value: "latex", label: "LaTeX" }, + { value: "less", label: "Less" }, + { value: "lua", label: "Lua" }, + { value: "makefile", label: "Makefile" }, + { value: "markdown", label: "Markdown" }, + { value: "math", label: null }, + { value: "mermaid", label: "Mermaid" }, + { value: "objectivec", label: "Objective-C" }, + { value: "perl", label: "Perl" }, + { value: "php", label: "PHP" }, + { value: "php-template", label: "PHP template" }, + { value: "plaintext", label: null }, + { value: "python", label: "Python" }, + { value: "python-repl", label: "Python REPL" }, + { value: "r", label: "R" }, + { value: "ruby", label: "Ruby" }, + { value: "rust", label: "Rust" }, + { value: "scss", label: "SCSS" }, + { value: "shell", label: "Shell" }, + { value: "sql", label: "SQL" }, + { value: "swift", label: "Swift" }, + { value: "tsrx", label: "TSRX" }, + { value: "typescript", label: "TypeScript" }, + { value: "vbnet", label: "Visual Basic .NET" }, + { value: "wasm", label: "WebAssembly" }, + { value: "xml", label: "XML" }, + { value: "yaml", label: "YAML" }, +]; + +const canonicalLanguages = new Set( + codeBlockLanguages.map(({ value }) => value), +); +const languageAliases: Readonly> = { + "c#": "csharp", + "c++": "cpp", + cs: "csharp", + gql: "graphql", + html: "xml", + js: "javascript", + jsx: "javascript", + md: "markdown", + objc: "objectivec", + patch: "diff", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + tex: "latex", + text: "plaintext", + ts: "typescript", + tsx: "typescript", + vb: "vbnet", + yml: "yaml", + zsh: "bash", +}; + +export function normalizeCodeBlockLanguage(value: unknown): string | null { + if (typeof value !== "string") return null; + const name = value.trim().toLowerCase(); + const language = languageAliases[name] ?? name; + return canonicalLanguages.has(language) ? language : null; +} + +export function codeBlockHighlightLanguage(value: unknown): string | null { + const language = normalizeCodeBlockLanguage(value); + if (language === "math") return "latex"; + return language === "plaintext" || language === "mermaid" ? null : language; +} + +export function codeBlockLanguageLabelKey( + value: unknown, +): + | "composer.codeBlock.language.math" + | "composer.codeBlock.language.plaintext" + | null { + const language = normalizeCodeBlockLanguage(value); + return language === "math" || language === "plaintext" + ? `composer.codeBlock.language.${language}` + : null; +} + +export const codeBlockAutoLanguages: readonly string[] = codeBlockLanguages + .map(({ value }) => value) + .filter((value) => value !== "math" && value !== "mermaid"); +const autoLanguages = new Set(codeBlockAutoLanguages); + +export interface CodeLanguageDetection { + readonly language: string | null; + readonly relevance: number; +} +export type CodeLanguageDetector = ( + content: string, + subset: readonly string[], +) => CodeLanguageDetection | Promise; + +const commandPrompt = + /^(?:[$%](?:[ \t]+\S.*)?|[^\s@]+@[^\s@]+(?:[ \t]+\S+)*[ \t]+[$%#](?:[ \t]+.*)?|PS [^>]*>.*|[A-Z]:\\[^>]*>.*)$/; +const outputHeads = [ + /^(?:(?:[\w.]*Error|[\w.]+Exception):|Exception in thread\b|Traceback \(most recent call last\):|node:internal\/|error\[[A-Z]\d+\]:)/, + /^(?:\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}|\[\d{2}:\d{2}:\d{2}\])/, + /^(?:\[(?:INFO|DEBUG|WARN|ERROR|TRACE|FATAL)\]|(?:INFO|DEBUG|WARN(?:ING)?|ERROR|TRACE|FATAL):?)\s+[^\s=({]/, + /^(?:HTTP\/\d(?:\.\d)? \d{3}\b|On branch |commit [a-f\d]{7,40}$|={3,})/, + /^\S+\.(?:c|cc|cpp|h): In (?:member )?function\b/, + /^(?:[^:\n]+:\d+(?::\d+)?|\S+\(\d+,\d+\)): (?:error|warning|fatal)\b/i, +]; +const outputDetails = [ + /^> \S+@\d\S* /, + /^\s+at (?:\S+ \([^\n]+:\d+(?::\d+)?\)|(?:file:\/\/|\S+\.)\S+)/, + /^\s*File "[^"]+", line \d+(?:, in .+)?$/, + /^\s*(?:error\[[A-Z]\d+\]:|stack backtrace:|Traceback \(most recent call last\):)/, + /^[-dlcbps][rwxstST-]{9}\s+\d+\s/, +]; + +function looksLikeOutput(content: string): boolean { + if ( + /^(?:https?:\/\/)?(?:localhost|(?:[a-z\d](?:[a-z\d-]*[a-z\d])?\.)+[a-z]{2,})(?::\d+)?(?:[/?#]\S*)?$/i.test( + content, + ) + ) + return true; + const lines = content.split(/\r?\n/); + const headers = lines.filter((line) => + /^[A-Z][A-Za-z0-9]*(?: [A-Za-z0-9]+)*:\s+\S/.test(line), + ); + if ( + headers.length >= 3 && + headers.some((line) => /^[^:\n]+ [^:\n]+:/.test(line)) + ) + return true; + const first = lines[0]; + const last = lines[lines.length - 1]; + if ( + commandPrompt.test(first) || + commandPrompt.test(last) || + [...outputHeads, ...outputDetails].some((pattern) => pattern.test(first)) + ) + return true; + const buildOutput = + /^(?:Building|Built|Compil(?:ing|ed)|Download(?:ing|ed)|Install(?:ing|ed)|Check(?:ing|ed)|Verif(?:ying|ied)|Unpack(?:ing|ed)|Found|Added|Removed)\s/i.test( + first, + ); + if (buildOutput && !/[=({]/.test(first)) return true; + const heading = + !/^(?:\/\/|\/\*|#|@|\[)/.test(first) && + !/[=({<>]/.test(first) && + (first.endsWith("...") || (first.endsWith(":") && /\s/.test(first))); + return ( + heading && + lines.some( + (line) => + commandPrompt.test(line.trim()) || + outputDetails.some((pattern) => pattern.test(line)), + ) + ); +} + +// Ordered metadata hints are separate from grammar registration and token production. +const syntaxHints: readonly (readonly [string, RegExp])[] = [ + ["diff", /^(?:diff\s|---\s.+\n\+\+\+\s)|^@@\s/m], + [ + "sql", + /^(?:SELECT\b.+\bFROM\b|INSERT\s+INTO\b|UPDATE\s+\S+\s+SET\b|CREATE\s+(?:TABLE|INDEX)\b)/im, + ], + [ + "graphql", + /^(?:query|mutation|subscription|fragment|type|schema)\b[^\n{]*\{/m, + ], + [ + "python", + /^(?:def\s+\w+\s*\(|from\s+[\w.]+\s+import\b|import\s+[\w.]+\s*$|class\s+\w+(?:\([^)]*\))?\s*:)|\b(?:print|range)\s*\(/m, + ], + [ + "csharp", + /^(?:using\s+System(?:\.\w+)*;|namespace\s+[\w.]+)|\b(?:Console\.WriteLine|public\s+static\s+void\s+Main)\b/m, + ], + [ + "java", + /^(?:package\s+[\w.]+;|import\s+java\.)|\b(?:[Ss]ystem\.out\.|public\s+static\s+void\s+main)\b/m, + ], + [ + "cpp", + /^#include\s*<(?:algorithm|array|deque|filesystem|iostream|map|memory|mutex|optional|queue|set|stack|string|thread|unordered_map|unordered_set|utility|variant|vector)>|\b(?:std::|cout\s*<<|using\s+namespace\s+std)\b/m, + ], + [ + "c", + /^#include\s*[<"](?:stdbool|stddef|stdint|stdio|stdlib|string)\.h[>"]|\b(?:printf|malloc|sizeof)\s*\(/m, + ], + [ + "tsrx", + /^(?:(?:export(?:[ \t]+default)?|async)[ \t]+)*function\b[^\n]*\)[ \t]*(?::[^\n{]+)?[ \t]*@\{(?=\s|<)/, + ], + [ + "tsrx", + /^(?:export[ \t]+)?(?:const|let)[ \t]+[$\w]+[ \t]*(?::[^=\n]+)?=[ \t]*(?:async[ \t]+)?(?:\([^\n)]*\)|[$\w]+)[ \t]*=>[ \t]*@\{(?=\s|<)/, + ], + [ + "typescript", + /\b(?:interface|type)\s+[$A-Z_a-z][$\w]*|\b(?:const|let|var)\s+[$A-Z_a-z][$\w]*\s*:\s*[$A-Z_a-z<{[(]|\b(?:as\s+const|satisfies)\b/m, + ], + [ + "javascript", + /^(?:import|export|const|let|var|function|class)\b|=>|\bconsole\.\w+\s*\(/m, + ], + [ + "swift", + /^(?:import\s+Foundation|func\s+\w+\s*\(|struct\s+\w+\s*\{|let\s+\w+\s*:)|\bguard\s+let/m, + ], + ["r", /^(?:library\s*\(|\w+\s*<-\s*)/m], + ["yaml", /^(?![\s\S]*[{};])[^\S\r\n]*[-\w.]+[^\S\r\n]*:[^\S\r\n]*\S.*$/m], +]; + +function syntaxLanguage(content: string): string | null { + if (["{", "[", "//", "/*"].some((prefix) => content.startsWith(prefix))) { + try { + const value: unknown = JSON.parse(content); + if (value !== null && typeof value === "object") return "json"; + } catch { + // JSONC permits comments, while malformed input and trailing commas remain invalid. + try { + const errors: ParseError[] = []; + const tree = parseTree(content, errors, { + disallowComments: false, + allowTrailingComma: false, + }); + if ( + errors.length === 0 && + (tree?.type === "object" || tree?.type === "array") + ) + return "json"; + } catch { + // Deep or malformed input is left to the bounded detector contract. + } + } + } + const html = + /^\s*(?: pattern.test(content))?.[0] ?? null; +} + +/** Syntax hints select metadata; only the parser can produce highlight tokens. */ +export async function detectCodeBlockLanguage( + content: string, + detect: CodeLanguageDetector, +): Promise { + const source = content.trim(); + if (!source) return null; + if (looksLikeOutput(source)) return "plaintext"; + const hint = syntaxLanguage(source); + if (hint) return hint; + if (source.length < 12) return "plaintext"; + try { + const result = await detect(content, codeBlockAutoLanguages); + const language = normalizeCodeBlockLanguage(result.language); + return language && autoLanguages.has(language) && result.relevance >= 2 + ? language + : "plaintext"; + } catch { + return "plaintext"; + } +} diff --git a/src/components/input-kit/composerCodeBlockReference.testFixtures.json b/src/components/input-kit/composerCodeBlockReference.testFixtures.json new file mode 100644 index 000000000..632ca215b --- /dev/null +++ b/src/components/input-kit/composerCodeBlockReference.testFixtures.json @@ -0,0 +1,256 @@ +{ + "schema": "reference-code-language-catalog-v1", + "languages": [ + { + "label": "Arduino", + "value": "arduino" + }, + { + "label": "Bash", + "value": "bash" + }, + { + "label": "C", + "value": "c" + }, + { + "label": "C++", + "value": "cpp" + }, + { + "label": "C#", + "value": "csharp" + }, + { + "label": "CSS", + "value": "css" + }, + { + "label": "Diff", + "value": "diff" + }, + { + "label": "Go", + "value": "go" + }, + { + "label": "GraphQL", + "value": "graphql" + }, + { + "label": "INI", + "value": "ini" + }, + { + "label": "Java", + "value": "java" + }, + { + "label": "JavaScript", + "value": "javascript" + }, + { + "label": "JSON", + "value": "json" + }, + { + "label": "Kotlin", + "value": "kotlin" + }, + { + "label": "LaTeX", + "value": "latex" + }, + { + "label": "Less", + "value": "less" + }, + { + "label": "Lua", + "value": "lua" + }, + { + "label": "Makefile", + "value": "makefile" + }, + { + "label": "Markdown", + "value": "markdown" + }, + { + "label": null, + "value": "math" + }, + { + "label": "Mermaid", + "value": "mermaid" + }, + { + "label": "Objective-C", + "value": "objectivec" + }, + { + "label": "Perl", + "value": "perl" + }, + { + "label": "PHP", + "value": "php" + }, + { + "label": "PHP template", + "value": "php-template" + }, + { + "label": null, + "value": "plaintext" + }, + { + "label": "Python", + "value": "python" + }, + { + "label": "Python REPL", + "value": "python-repl" + }, + { + "label": "R", + "value": "r" + }, + { + "label": "Ruby", + "value": "ruby" + }, + { + "label": "Rust", + "value": "rust" + }, + { + "label": "SCSS", + "value": "scss" + }, + { + "label": "Shell", + "value": "shell" + }, + { + "label": "SQL", + "value": "sql" + }, + { + "label": "Swift", + "value": "swift" + }, + { + "label": "TSRX", + "value": "tsrx" + }, + { + "label": "TypeScript", + "value": "typescript" + }, + { + "label": "Visual Basic .NET", + "value": "vbnet" + }, + { + "label": "WebAssembly", + "value": "wasm" + }, + { + "label": "XML", + "value": "xml" + }, + { + "label": "YAML", + "value": "yaml" + } + ], + "aliases": { + "c#": "csharp", + "c++": "cpp", + "cs": "csharp", + "gql": "graphql", + "html": "xml", + "js": "javascript", + "jsx": "javascript", + "md": "markdown", + "objc": "objectivec", + "patch": "diff", + "py": "python", + "rb": "ruby", + "rs": "rust", + "sh": "bash", + "tex": "latex", + "text": "plaintext", + "ts": "typescript", + "tsx": "typescript", + "vb": "vbnet", + "yml": "yaml", + "zsh": "bash" + }, + "registeredGrammars": [ + "arduino", + "bash", + "c", + "cpp", + "csharp", + "css", + "diff", + "dockerfile", + "dos", + "go", + "graphql", + "ini", + "java", + "javascript", + "json", + "kotlin", + "less", + "lua", + "makefile", + "markdown", + "objectivec", + "perl", + "php", + "php-template", + "plaintext", + "powershell", + "python", + "python-repl", + "r", + "ruby", + "rust", + "scss", + "shell", + "sql", + "swift", + "tsrx", + "typescript", + "vbnet", + "wasm", + "xml", + "yaml", + "clojure", + "dart", + "haskell", + "latex", + "lisp", + "mathematica", + "matlab", + "nginx", + "ocaml", + "pgsql", + "scala", + "verilog" + ], + "parserAliases": { + "javascript": ["threejs"], + "lisp": ["commonlisp"], + "mathematica": ["wolfram"], + "typescript": ["recharts"], + "xml": ["vue"] + }, + "targetExecuted": false, + "provenance": "New literal-only AST extraction from identity-checked shipped members; no historical manifest reconstructed." +} diff --git a/src/components/input-kit/composerCodeBlockTestUtils.ts b/src/components/input-kit/composerCodeBlockTestUtils.ts new file mode 100644 index 000000000..ccc18f961 --- /dev/null +++ b/src/components/input-kit/composerCodeBlockTestUtils.ts @@ -0,0 +1,64 @@ +import { EditorState, TextSelection, type Plugin } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { composerSchema } from "./composerSchema"; + +export function codeDoc( + text: string, + language: string | null = null, + autoDetectLanguage = true, +) { + return composerSchema.node("doc", null, [ + composerSchema.node( + "code_block", + { language, autoDetectLanguage }, + text ? composerSchema.text(text) : undefined, + ), + ]); +} + +export function mountCodeView( + doc: ProseMirrorNode, + plugins: Plugin[], + options: { editable?: boolean } = {}, +) { + const container = document.createElement("div"); + document.body.append(container); + const transactions: { docChanged: boolean; addToHistory: unknown }[] = []; + const view = new EditorView(container, { + state: EditorState.create({ doc, plugins }), + editable: () => options.editable !== false, + dispatchTransaction(transaction) { + const next = view.state.applyTransaction(transaction); + for (const item of next.transactions) + transactions.push({ + docChanged: item.docChanged, + addToHistory: item.getMeta("addToHistory"), + }); + view.updateState(next.state); + }, + }); + return { + view, + transactions, + selectCode() { + view.dispatch( + view.state.tr.setSelection(TextSelection.create(view.state.doc, 1)), + ); + }, + destroy() { + if (!view.isDestroyed) view.destroy(); + container.remove(); + }, + }; +} + +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} diff --git a/src/components/input-kit/composerDraftCodec.test.ts b/src/components/input-kit/composerDraftCodec.test.ts new file mode 100644 index 000000000..f04cd416a --- /dev/null +++ b/src/components/input-kit/composerDraftCodec.test.ts @@ -0,0 +1,508 @@ +import { describe, expect, it, vi } from "vitest"; +import { ComposerController } from "@/components/input-kit/ComposerController"; +import { buildMessageImageDataUrl } from "@/components/agent/chat/utils/imageAttachments"; +import type { + ComposerDocument, + ComposerEditorDocument, +} from "@/components/input-kit/types"; +import { PERSISTED_ATOM_LIMITS } from "@/lib/api/persistedAtomProtocol"; +import { + ComposerDraftCodecError, + decodeComposerDraft, + encodeComposerDraft, +} from "./composerDraftCodec"; + +// Small test budget injected at the shared owner; production has no override. +vi.mock("@/lib/api/persistedAtomProtocol", async (original) => { + const actual = + await original(); + return { + ...actual, + PERSISTED_ATOM_LIMITS: { + ...actual.PERSISTED_ATOM_LIMITS, + maxDraftBytes: 16_384, + }, + }; +}); + +const editorDocument: ComposerEditorDocument = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "前😀字 `原样` ", + marks: [ + { + type: "link", + attrs: { + href: "https://example.test/中文?key=1", + title: "标题 `😀`", + disabled: true, + isAutolink: false, + isGeneratedAutolink: false, + literalMarkdownOccurrenceId: 7, + literalMarkdownSource: + "[前😀字 `原样` ](https://example.test/中文?key=1)", + sourceAppId: "test-app", + }, + }, + ], + }, + { + type: "mention", + attrs: { + id: "file-1", + kind: "file", + name: "source.ts", + path: "/fixture/source.ts", + label: "@source.ts", + }, + }, + { type: "text", text: " " }, + { + type: "mention", + attrs: { + id: "skill-1", + kind: "skill", + name: "review", + path: "/fixture/review/SKILL.md", + label: "$review", + }, + }, + { type: "text", text: " " }, + { + type: "mention", + attrs: { + id: "plugin-1", + kind: "plugin", + name: "plugin", + path: "/fixture/plugin", + label: "@plugin", + }, + }, + { type: "hard_break" }, + { + type: "paste", + attrs: { + id: "paste-1", + placeholder: "[Pasted 中文😀]", + text: "第一行\n`raw` 😀\n第二行", + }, + }, + { + type: "text_element", + attrs: { text: "👩‍💻元素", placeholder: "[element]" }, + }, + ], + }, + { + type: "code_block", + attrs: { language: "typescript", autoDetectLanguage: false }, + content: [{ type: "text", text: "const 名称 = `😀`;\n```保留```\t\n" }], + }, + { type: "paragraph", content: [{ type: "text", text: "末尾 é\r未改写" }] }, + ], +}; + +function controller(plainTextMode = false) { + return new ComposerController( + { + editorDocument, + selectionStart: 1, + selectionEnd: 3, + attachments: [ + { + data: "aW1hZ2U=", + mediaType: "image/png", + sourceUri: "https://example.test/image.png", + sourcePath: "/fixture/image.png", + previewUrl: "blob:old-process-preview", + metadata: { + generated: false, + nested: { caption: "示例😀", values: [1, null, true] }, + }, + index: 4, + }, + ], + pathReferences: [ + { + id: "path-1", + path: "/fixture/missing.ts", + name: "missing.ts", + isDir: false, + size: 12, + mimeType: "text/plain", + source: "file_manager", + }, + { + id: "path-2", + path: "C:\\fixture\\folder", + name: "folder", + isDir: true, + size: null, + mimeType: null, + source: "system_drop", + }, + ], + inputCapabilityRoute: { + kind: "installed_skill", + skillId: "review", + config: { array: ["😀", 1, null] }, + }, + }, + { plainTextMode }, + ); +} + +function wire(draft = controller().captureDraft(), mode = false) { + return JSON.parse(JSON.stringify(encodeComposerDraft(draft, mode))); +} + +function withoutPreview(draft: ComposerDocument): ComposerDocument { + return { + ...draft, + attachments: draft.attachments.map( + ({ previewUrl: _preview, ...rest }) => rest, + ), + }; +} + +describe("renderer rich draft JSON codec", () => { + it.each([false, true])( + "round trips full captured PM/resources through JSON and real Controller restore with explicit mode %s", + (plainTextMode) => { + const original = controller(plainTextMode), + draft = original.captureDraft(); + const before = original.submit("start"); + const decoded = decodeComposerDraft(wire(draft, plainTextMode)); + expect(decoded.plainTextMode).toBe(plainTextMode); + expect(decoded.document).toEqual(withoutPreview(draft)); + const restored = new ComposerController( + {}, + { plainTextMode: decoded.plainTextMode }, + ); + restored.restoreDraft(decoded.document); + expect(restored.captureDraft()).toEqual(decoded.document); + const after = restored.submit("start"); + expect(after.kind).toBe(before.kind); + expect(after.submission).toEqual({ + ...before.submission, + attachments: before.submission.attachments.map( + ({ previewUrl: _preview, ...rest }) => rest, + ), + }); + expect(after.submission.text).toContain("第一行\n`raw` 😀\n第二行"); + expect(after.submission.inputMentions).toHaveLength(3); + expect(after.submission.textElements).toEqual( + before.submission.textElements, + ); + expect(decoded.document.editorDocument.content[1]).toEqual( + draft.editorDocument.content[1], + ); + expect(decoded.document.editorDocument.content[0]).toEqual( + draft.editorDocument.content[0], + ); + expect(decoded.document.selectionStart).toBe(1); + expect(decoded.document.selectionEnd).toBe(3); + expect(buildMessageImageDataUrl(decoded.document.attachments[0])).toBe( + "data:image/png;base64,aW1hZ2U=", + ); + expect(JSON.stringify(decoded.document)).not.toContain( + "blob:old-process-preview", + ); + }, + ); + + it("preserves empty drafts and uses the current typed schema rather than a text fallback", () => { + const empty = new ComposerController( + {}, + { plainTextMode: true }, + ).captureDraft(); + expect(decodeComposerDraft(wire(empty, true))).toEqual({ + document: empty, + plainTextMode: true, + }); + const restored = new ComposerController({}, { plainTextMode: true }); + restored.restoreDraft(decodeComposerDraft(wire(empty, true)).document); + expect(restored.submit("start").kind).toBe("empty"); + }); + + it("preserves valid atom boundary selections and refuses to clamp malformed offsets", () => { + const original = controller(); + const mention = original.captureDraft().mentionBindings[0]; + original.setSelection(mention.from, mention.to); + const decoded = decodeComposerDraft(wire(original.captureDraft())); + expect([ + decoded.document.selectionStart, + decoded.document.selectionEnd, + ]).toEqual([mention.from, mention.to]); + for (const [selectionStart, selectionEnd] of [ + [2, 2], + [mention.from + 1, mention.to - 1], + [-1, 0], + [0, 9999], + [3, 1], + [0.5, 1], + ]) { + const input = wire(); + Object.assign(input.document, { selectionStart, selectionEnd }); + expect(() => decodeComposerDraft(input)).toThrow(ComposerDraftCodecError); + } + }); + + it("accepts property order changes without dropping unknown schema fields", () => { + const original = wire(); + const reordered = Object.fromEntries(Object.entries(original).reverse()); + reordered.document = Object.fromEntries( + Object.entries(original.document).reverse(), + ); + expect(decodeComposerDraft(reordered)).toEqual( + decodeComposerDraft(original), + ); + const changed = wire(); + changed.document.editorDocument.content[0].content[0].marks[0].attrs.extra = + "unknown"; + expect(() => decodeComposerDraft(changed)).toThrow(ComposerDraftCodecError); + }); + + it("uses one source document: contradictory text/annotations fail instead of rebuilding from plain text", () => { + for (const field of [ + "text", + "textElements", + "mentionBindings", + "pendingPastes", + ]) { + const input = wire(); + input.document[field] = field === "text" ? "replacement" : []; + expect(() => decodeComposerDraft(input)).toThrow(ComposerDraftCodecError); + } + }); + + it.each([ + (input: any) => { + input.version = 2; + }, + (input: any) => { + input.format = "old-composer"; + }, + (input: any) => { + delete input.plainTextMode; + }, + (input: any) => { + input.plainTextMode = "true"; + }, + (input: any) => { + input.extra = true; + }, + (input: any) => { + delete input.document.editorDocument; + }, + (input: any) => { + input.document.editorDocument.type = "paragraph"; + }, + (input: any) => { + input.document.editorDocument.content = []; + }, + (input: any) => { + input.document.editorDocument.content[0].content[0].type = "unsupported"; + }, + (input: any) => { + input.document.editorDocument.content[0].content[0].marks[0].type = + "bold"; + }, + (input: any) => { + input.document.editorDocument.content[0].content[0].marks[0].attrs.href = + "javascript:private"; + }, + (input: any) => { + input.document.editorDocument.content[1].attrs.language = "bad language"; + }, + (input: any) => { + input.document.editorDocument.content[1].attrs.autoDetectLanguage = + "false"; + }, + (input: any) => { + input.document.editorDocument.content[0].content[1].attrs.kind = "remote"; + }, + (input: any) => { + input.document.editorDocument.content[0].content[7].attrs.text = 7; + }, + ])( + "rejects unknown versions and invalid current PM schemas as a whole", + (mutate) => { + const input = wire(); + mutate(input); + expect(() => decodeComposerDraft(input)).toThrow(ComposerDraftCodecError); + }, + ); + + it.each([ + (input: any) => { + input.document.attachments[0].previewUrl = "blob:stale"; + }, + (input: any) => { + input.document.attachments[0].data = 1; + }, + (input: any) => { + input.document.attachments[0].mediaType = ""; + }, + (input: any) => { + input.document.attachments[0].metadata = []; + }, + (input: any) => { + input.document.attachments[0].index = -1; + }, + (input: any) => { + input.document.attachments[0].sourceUri = "blob:stale"; + }, + (input: any) => { + input.document.attachments[0].unknown = "private"; + }, + (input: any) => { + input.document.pathReferences[0].isDir = 1; + }, + (input: any) => { + input.document.pathReferences[0].path = 7; + }, + (input: any) => { + input.document.pathReferences[0].size = -1; + }, + (input: any) => { + input.document.pathReferences[0].mimeType = 7; + }, + (input: any) => { + input.document.pathReferences[0].source = "remote"; + }, + ])( + "rejects invalid resource shapes and serialized transient previews", + (mutate) => { + const input = wire(); + mutate(input); + expect(() => decodeComposerDraft(input)).toThrow(ComposerDraftCodecError); + }, + ); + + it("retains original URI/path references without disk/network access and rejects blob-only backing", () => { + const original = controller(); + original.setAttachments([ + { + data: "", + mediaType: "image/jpeg", + sourceUri: "https://example.test/only-source.jpg", + }, + { + data: "", + mediaType: "image/png", + sourcePath: "C:\\missing\\image.png", + }, + ]); + expect( + decodeComposerDraft(wire(original.captureDraft())).document.attachments, + ).toEqual(original.captureDraft().attachments); + original.setAttachments([ + { data: "", mediaType: "image/png", previewUrl: "blob:only-backing" }, + ]); + expect(() => encodeComposerDraft(original.captureDraft(), false)).toThrow( + ComposerDraftCodecError, + ); + }); + + it("rejects non-JSON resources, getters, sparse arrays, cycles and hidden metadata without executing them", () => { + let called = false; + const getter = Object.defineProperty({}, "secret", { + enumerable: true, + get() { + called = true; + return "private"; + }, + }); + const cycle: any = {}; + cycle.self = cycle; + for (const route of [ + undefined, + () => "private", + BigInt(1), + NaN, + new Date(), + new Map(), + new Blob(["private"]), + getter, + cycle, + Array(1), + { [Symbol("secret")]: "private" }, + Object.defineProperty({}, "hidden", { value: "private" }), + ]) { + const input = controller().captureDraft(); + input.inputCapabilityRoute = route; + expect(() => encodeComposerDraft(input, false)).toThrow( + ComposerDraftCodecError, + ); + } + expect(called).toBe(false); + const input = wire(); + input.document.attachments[0].metadata = { + toJSON() { + called = true; + return "private"; + }, + }; + expect(() => decodeComposerDraft(input)).toThrow(ComposerDraftCodecError); + expect(called).toBe(false); + }); + + it("detaches encoded/decoded resources from the live Controller and retains all valid metadata", () => { + const original = controller(), + before = original.captureDraft(); + const encoded: any = encodeComposerDraft(before, false); + encoded.document.attachments[0].metadata.nested.caption = "mutated"; + expect(original.captureDraft()).toEqual(before); + const input = wire(), + decoded = decodeComposerDraft(input); + input.document.attachments[0].metadata.nested.caption = "mutated"; + input.document.inputCapabilityRoute.config.array[0] = "mutated"; + expect(decoded.document.attachments[0].metadata).toMatchObject({ + nested: { caption: "示例😀" }, + }); + expect(decoded.document.inputCapabilityRoute).toMatchObject({ + config: { array: ["😀", 1, null] }, + }); + }); + + it("checks complete UTF8 JSON against the shared budget and rejects whole rich drafts without truncation or mutation", () => { + const original = new ComposerController( + { text: "中".repeat(PERSISTED_ATOM_LIMITS.maxDraftBytes) }, + { plainTextMode: true }, + ); + const before = original.captureDraft(); + expect(() => encodeComposerDraft(before, true)).toThrow( + expect.objectContaining({ code: "capacity_exceeded" }), + ); + expect(original.captureDraft()).toEqual(before); + const input = wire(); + input.document.inputCapabilityRoute = { + text: "中".repeat(PERSISTED_ATOM_LIMITS.maxDraftBytes), + }; + expect(() => decodeComposerDraft(input)).toThrow( + expect.objectContaining({ code: "capacity_exceeded" }), + ); + }); + + it("uses sanitized errors and requires an explicit real surface mode", () => { + expect(() => + encodeComposerDraft( + controller().captureDraft(), + undefined as unknown as boolean, + ), + ).toThrow(ComposerDraftCodecError); + try { + decodeComposerDraft({ text: "private error content" }); + } catch (error) { + expect(error).toBeInstanceOf(ComposerDraftCodecError); + expect(String(error)).not.toContain("private error content"); + expect((error as Error).cause).toBeUndefined(); + } + }); +}); diff --git a/src/components/input-kit/composerDraftCodec.ts b/src/components/input-kit/composerDraftCodec.ts new file mode 100644 index 000000000..54da8e920 --- /dev/null +++ b/src/components/input-kit/composerDraftCodec.ts @@ -0,0 +1,280 @@ +import { + assertPersistedDraftPayload, + PERSISTED_ATOM_LIMITS, +} from "@/lib/api/persistedAtomProtocol"; +import type { JsonObject } from "@/lib/api/persistedAtomTypes"; +import { composerDocumentFromJSON } from "@/components/input-kit/composerSchema"; +import { projectComposerDocument } from "@/components/input-kit/composerSerialization"; +import { + composerPositionToOffset, + composerTextSelection, +} from "@/components/input-kit/composerPositions"; +import { resourcesFrom } from "@/components/input-kit/composerState"; +import type { + ComposerAttachment, + ComposerDocument, + ComposerEditorDocument, + ComposerPathReference, +} from "@/components/input-kit/types"; + +export class ComposerDraftCodecError extends Error { + constructor(readonly code: "invalid_draft" | "capacity_exceeded") { + super("Composer draft cannot be persisted or restored"); + this.name = "ComposerDraftCodecError"; + } +} + +export interface DecodedComposerDraft { + readonly document: ComposerDocument; + /** Immutable Controller option; restoreDraft does not change this mode. */ + readonly plainTextMode: boolean; +} + +function invalid(): never { + throw new ComposerDraftCodecError("invalid_draft"); +} + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + return value as Record; +} + +function exact( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + const result = record(value); + if ( + required.some((key) => !Object.hasOwn(result, key)) || + Object.keys(result).some( + (key) => !required.includes(key) && !optional.includes(key), + ) + ) + invalid(); + return result; +} + +function jsonEqual(left: unknown, right: unknown): boolean { + if (left === right) return true; + if (!left || !right || typeof left !== "object" || typeof right !== "object") + return false; + if (Array.isArray(left) !== Array.isArray(right)) return false; + const a = Object.keys(left), + b = Object.keys(right); + return ( + a.length === b.length && + a.every( + (key) => + Object.hasOwn(right, key) && + jsonEqual( + (left as Record)[key], + (right as Record)[key], + ), + ) + ); +} + +function strings( + value: Record, + keys: readonly string[], +): void { + if (keys.some((key) => typeof value[key] !== "string")) invalid(); +} + +function attachment(value: unknown, allowPreview: boolean): ComposerAttachment { + const data = exact( + value, + ["data", "mediaType"], + [ + "sourceUri", + "sourcePath", + "metadata", + "index", + ...(allowPreview ? ["previewUrl"] : []), + ], + ); + strings(data, ["data", "mediaType"]); + for (const key of ["sourceUri", "sourcePath", "previewUrl"]) + if (Object.hasOwn(data, key)) strings(data, [key]); + if (!(data.mediaType as string).trim()) invalid(); + if (Object.hasOwn(data, "metadata")) record(data.metadata); + if ( + Object.hasOwn(data, "index") && + (typeof data.index !== "number" || + !Number.isSafeInteger(data.index) || + data.index < 0) + ) + invalid(); + const sourceUri = data.sourceUri as string | undefined; + if (sourceUri?.trim().toLowerCase().startsWith("blob:")) invalid(); + if ( + !(data.data as string).trim() && + !(data.sourcePath as string | undefined)?.trim() && + !sourceUri?.trim() + ) + invalid(); + // Existing attachment owners rebuild previews from data/mediaType or sources. + const { previewUrl: _previewUrl, ...durable } = data; + return durable as unknown as ComposerAttachment; +} + +function pathReference(value: unknown): ComposerPathReference { + const data = exact( + value, + ["id", "path", "name", "isDir"], + ["size", "mimeType", "source"], + ); + strings(data, ["id", "path", "name"]); + if (typeof data.isDir !== "boolean") invalid(); + if ( + Object.hasOwn(data, "size") && + data.size !== null && + (typeof data.size !== "number" || + !Number.isFinite(data.size) || + data.size < 0) + ) + invalid(); + if (Object.hasOwn(data, "mimeType") && data.mimeType !== null) + strings(data, ["mimeType"]); + if ( + Object.hasOwn(data, "source") && + data.source !== "file_manager" && + data.source !== "system_drop" + ) + invalid(); + return data as unknown as ComposerPathReference; +} + +function document(value: unknown, allowPreview: boolean): ComposerDocument { + const data = exact( + value, + [ + "editorDocument", + "text", + "selectionStart", + "selectionEnd", + "textElements", + "mentionBindings", + "attachments", + "pathReferences", + "pendingPastes", + ], + ["inputCapabilityRoute"], + ); + const pm = composerDocumentFromJSON( + data.editorDocument as ComposerEditorDocument, + ); + const editorDocument = pm.toJSON() as ComposerEditorDocument; + // Accept the current canonical schema; never silently drop unknown PM fields. + if (!jsonEqual(data.editorDocument, editorDocument)) invalid(); + const projection = projectComposerDocument(pm); + for (const key of [ + "text", + "textElements", + "mentionBindings", + "pendingPastes", + ] as const) + if (!jsonEqual(data[key], projection[key])) invalid(); + const start = data.selectionStart, + end = data.selectionEnd; + if ( + typeof start !== "number" || + typeof end !== "number" || + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + start > end || + end > projection.text.length + ) + invalid(); + const selection = composerTextSelection(pm, { start, end }); + if ( + composerPositionToOffset(pm, selection.from) !== start || + composerPositionToOffset(pm, selection.to) !== end + ) + invalid(); + if (!Array.isArray(data.attachments) || !Array.isArray(data.pathReferences)) + invalid(); + const resources = resourcesFrom({ + attachments: data.attachments.map((item) => attachment(item, allowPreview)), + pathReferences: data.pathReferences.map(pathReference), + ...(Object.hasOwn(data, "inputCapabilityRoute") + ? { inputCapabilityRoute: data.inputCapabilityRoute } + : {}), + }); + return { + editorDocument, + text: projection.text, + selectionStart: start, + selectionEnd: end, + textElements: projection.textElements, + mentionBindings: projection.mentionBindings, + pendingPastes: projection.pendingPastes, + ...resources, + }; +} + +function json( + value: unknown, + enforceBudget: boolean, +): asserts value is JsonObject { + assertPersistedDraftPayload(value); + if ( + enforceBudget && + new TextEncoder().encode(JSON.stringify(value)).byteLength > + PERSISTED_ATOM_LIMITS.maxDraftBytes + ) + throw new ComposerDraftCodecError("capacity_exceeded"); +} + +function guarded(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof ComposerDraftCodecError) throw error; + // Do not include draft content or parser exception text in diagnostics. + return invalid(); + } +} + +/** Renderer-only codec. Main sees JSON and owns no PM, Controller or resources. */ +export function encodeComposerDraft( + value: ComposerDocument, + plainTextMode: boolean, +): JsonObject { + return guarded(() => { + json(value, false); + if (typeof plainTextMode !== "boolean") invalid(); + const payload = { + format: "composer-draft", + version: 1, + plainTextMode, + document: document(value, true), + }; + json(payload, true); + return payload; + }); +} + +export function decodeComposerDraft(value: unknown): DecodedComposerDraft { + return guarded(() => { + json(value, true); + const payload = exact(value, [ + "format", + "version", + "plainTextMode", + "document", + ]); + if ( + payload.format !== "composer-draft" || + payload.version !== 1 || + typeof payload.plainTextMode !== "boolean" + ) + invalid(); + return { + document: document(payload.document, false), + plainTextMode: payload.plainTextMode, + }; + }); +} diff --git a/src/components/input-kit/composerState.ts b/src/components/input-kit/composerState.ts new file mode 100644 index 000000000..e3573d308 --- /dev/null +++ b/src/components/input-kit/composerState.ts @@ -0,0 +1,89 @@ +import { EditorState } from "@tiptap/pm/state"; +import { history } from "@tiptap/pm/history"; +import { createComposerEditingKeymap } from "./composerKeymap"; +import { composerDocumentFromJSON } from "./composerSchema"; +import { + composerDocumentFromAnnotations, + projectComposerDocument, +} from "./composerSerialization"; +import { + composerPositionToOffset, + composerTextSelection, +} from "./composerPositions"; +import { createCodeBlockHighlight } from "./composerCodeBlockHighlight"; +import { createCodeBlockAutoDetect } from "./composerCodeBlockAutoDetect"; +import type { + ComposerAttachment, + ComposerDocument, + ComposerDocumentInput, + ComposerEditorDocument, + ComposerPathReference, +} from "./types"; + +export interface ComposerResources { + attachments: readonly ComposerAttachment[]; + pathReferences: readonly ComposerPathReference[]; + inputCapabilityRoute?: unknown; +} + +export const clone = (value: T): T => structuredClone(value); + +export function resourcesFrom(input: ComposerDocumentInput): ComposerResources { + return clone({ + attachments: [...(input.attachments ?? [])], + pathReferences: [...(input.pathReferences ?? [])], + ...(input.inputCapabilityRoute === undefined + ? {} + : { inputCapabilityRoute: input.inputCapabilityRoute }), + }); +} + +export function stateFrom( + input: ComposerDocumentInput, + codeBlocksEnabled = false, +): EditorState { + const doc = input.editorDocument + ? composerDocumentFromJSON(input.editorDocument) + : composerDocumentFromAnnotations(input); + // Validate programmatically supplied annotations as strictly as JSON restores. + composerDocumentFromJSON(doc.toJSON() as ComposerEditorDocument); + const text = projectComposerDocument(doc).text; + const start = input.selectionStart ?? text.length; + return EditorState.create({ + doc, + selection: composerTextSelection(doc, { + start, + end: input.selectionEnd ?? start, + }), + plugins: [ + history(), + ...createComposerEditingKeymap(), + ...(codeBlocksEnabled + ? [createCodeBlockHighlight(), createCodeBlockAutoDetect()] + : []), + ], + }); +} + +export function documentFrom( + state: EditorState, + resources: ComposerResources, +): ComposerDocument { + const projection = projectComposerDocument(state.doc); + return { + editorDocument: state.doc.toJSON() as ComposerEditorDocument, + text: projection.text, + selectionStart: composerPositionToOffset(state.doc, state.selection.from), + selectionEnd: composerPositionToOffset(state.doc, state.selection.to), + textElements: projection.textElements, + mentionBindings: projection.mentionBindings, + pendingPastes: projection.pendingPastes, + ...clone(resources), + }; +} + +export function buildDocument( + input: ComposerDocumentInput = {}, +): ComposerDocument { + return documentFrom(stateFrom(input), resourcesFrom(input)); +} diff --git a/src/components/input-kit/index.ts b/src/components/input-kit/index.ts index 1dd0526a2..efa8098e3 100644 --- a/src/components/input-kit/index.ts +++ b/src/components/input-kit/index.ts @@ -5,13 +5,20 @@ export type { } from "./BaseComposer"; export { ModelSelector } from "./ModelSelector"; export type { ModelSelectorProps } from "./ModelSelector"; -export { ComposerController, LARGE_PASTE_CHAR_THRESHOLD } from "./ComposerController"; -export type { ComposerDocumentInput } from "./ComposerController"; +export { + ComposerController, + LARGE_PASTE_CHAR_THRESHOLD, +} from "./ComposerController"; export { ComposerEditor } from "./ComposerEditor"; -export type { ComposerEditorHandle, ComposerEditorProps } from "./ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorProps, +} from "./ComposerEditor"; export { useComposerController } from "./useComposerController"; export type { ComposerControllerSnapshot, + ComposerControllerOptions, + ComposerDocumentInput, ComposerActions, ComposerAttachment, ComposerDocument, diff --git a/src/components/input-kit/types.ts b/src/components/input-kit/types.ts index 42e201d54..ac7e1d08d 100644 --- a/src/components/input-kit/types.ts +++ b/src/components/input-kit/types.ts @@ -101,6 +101,25 @@ export interface ComposerPendingPaste { text: string; } +export interface ComposerDocumentInput { + editorDocument?: ComposerEditorDocument; + text?: string; + selectionStart?: number; + selectionEnd?: number; + textElements?: readonly ComposerTextElement[]; + mentionBindings?: readonly ComposerMentionBinding[]; + attachments?: readonly ComposerAttachment[]; + pathReferences?: readonly ComposerPathReference[]; + pendingPastes?: readonly ComposerPendingPaste[]; + inputCapabilityRoute?: unknown; +} + +export interface ComposerControllerOptions { + enableRichText?: boolean; + enableCodeBlocks?: boolean; + plainTextMode?: boolean; +} + export interface ComposerDocument { editorDocument: ComposerEditorDocument; text: string; diff --git a/src/components/input-kit/useComposerController.ts b/src/components/input-kit/useComposerController.ts index 51d0a990d..bc4e714f5 100644 --- a/src/components/input-kit/useComposerController.ts +++ b/src/components/input-kit/useComposerController.ts @@ -1,10 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { - ComposerController, - type ComposerDocumentInput, -} from "./ComposerController"; + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; +import { ComposerController } from "./ComposerController"; import type { ComposerAttachment, + ComposerDocumentInput, + ComposerControllerOptions, ComposerMentionBinding, ComposerPathReference, ComposerSelection, @@ -14,6 +19,7 @@ import type { export interface UseComposerControllerOptions { controller?: ComposerController; + controllerOptions?: ComposerControllerOptions; initialDocument?: ComposerDocumentInput; externalDocument?: ComposerDocumentInput; onDocumentChange?: ( @@ -24,13 +30,20 @@ export interface UseComposerControllerOptions { export function useComposerController({ controller: externalController, initialDocument, + controllerOptions, externalDocument, onDocumentChange, }: UseComposerControllerOptions = {}) { const controllerRef = useRef(null); - const controller = useMemo(() => externalController ?? - (controllerRef.current ??= new ComposerController(initialDocument)), - [externalController, initialDocument]); + const controller = useMemo( + () => + externalController ?? + (controllerRef.current ??= new ComposerController( + initialDocument, + controllerOptions, + )), + [externalController, initialDocument, controllerOptions], + ); const onDocumentChangeRef = useRef(onDocumentChange); onDocumentChangeRef.current = onDocumentChange; const snapshot = useSyncExternalStore( @@ -39,9 +52,15 @@ export function useComposerController({ controller.getSnapshot, ); - useEffect(() => controller.subscribe(() => { - onDocumentChangeRef.current?.(controller.getDocument()); - }), [controller]); + useEffect(() => { + let previous = controller.getSnapshot(); + return controller.subscribe(() => { + const next = controller.getSnapshot(); + if (next === previous) return; + previous = next; + onDocumentChangeRef.current?.(controller.getDocument()); + }); + }, [controller]); useEffect(() => { if (externalDocument) { controller.replaceDocument(externalDocument); @@ -78,24 +97,39 @@ export function useComposerController({ }, [controller], ); - const setTextElements = useCallback((elements: readonly ComposerTextElement[]) => { - controller.setTextElements(elements); - }, [controller]); - const replaceDocument = useCallback((document: ComposerDocumentInput) => { - controller.replaceDocument(document); - }, [controller]); - const restoreDraft = useCallback((document: ComposerDocumentInput) => { - controller.restoreDraft(document); - }, [controller]); - const replaceRange = useCallback((range: ComposerSelection, text: string) => { - controller.replaceRange(range, text); - }, [controller]); - const insertMention = useCallback(( - binding: Omit, - range?: ComposerSelection, - ) => { - controller.insertMention(binding, range); - }, [controller]); + const setTextElements = useCallback( + (elements: readonly ComposerTextElement[]) => { + controller.setTextElements(elements); + }, + [controller], + ); + const replaceDocument = useCallback( + (document: ComposerDocumentInput) => { + controller.replaceDocument(document); + }, + [controller], + ); + const restoreDraft = useCallback( + (document: ComposerDocumentInput) => { + controller.restoreDraft(document); + }, + [controller], + ); + const replaceRange = useCallback( + (range: ComposerSelection, text: string) => { + controller.replaceRange(range, text); + }, + [controller], + ); + const insertMention = useCallback( + ( + binding: Omit, + range?: ComposerSelection, + ) => { + controller.insertMention(binding, range); + }, + [controller], + ); const submit = useCallback( (target: ComposerSubmitTarget, allowEmpty = false) => controller.submit(target, { allowEmpty }), diff --git a/src/components/settings-v2/_layout/SettingsNavigation.tsx b/src/components/settings-v2/_layout/SettingsNavigation.tsx index 1116b2188..5048ff8a7 100644 --- a/src/components/settings-v2/_layout/SettingsNavigation.tsx +++ b/src/components/settings-v2/_layout/SettingsNavigation.tsx @@ -110,7 +110,8 @@ interface SettingsNavigationProps { reserveWindowControls: boolean; onTabChange: (tab: SettingsTabs) => void; onTabPrefetch: (tab: SettingsTabs) => void; - onBackHome: () => void; + onBack: () => void; + onHome: () => void; onNavigate?: (page: Page, params?: PageParams) => void; } @@ -119,7 +120,8 @@ export function SettingsNavigation({ reserveWindowControls, onTabChange, onTabPrefetch, - onBackHome, + onBack, + onHome, onNavigate, }: SettingsNavigationProps) { const { t } = useTranslation(["navigation", "settings"]); @@ -160,7 +162,7 @@ export function SettingsNavigation({ ).map(localize)} settingsItem={settingsItem ? localize(settingsItem) : null} isActive={(item) => item.id === "settings"} - onHome={onBackHome} + onHome={onHome} onNavigate={(item) => { if (item.page && item.page !== "settings") onNavigate?.(item.page, item.params); @@ -202,14 +204,14 @@ export function SettingsNavigation({ > - {t("settings.layout.action.backHome", { ns: "settings" })} + {t("settings.layout.action.back", { ns: "settings" })} {t("navigation.sidebar.items.settings")} diff --git a/src/components/settings-v2/_layout/index.test.tsx b/src/components/settings-v2/_layout/index.test.tsx index e80f81502..211361386 100644 --- a/src/components/settings-v2/_layout/index.test.tsx +++ b/src/components/settings-v2/_layout/index.test.tsx @@ -1,9 +1,15 @@ -import { act } from "react"; +import { act, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { changeLimeLocale } from "@/i18n/createI18n"; import { SettingsTabs } from "@/types/settings"; -import type { ProviderSettingsFocusContext } from "@/types/page"; +import type { + ExecutionPolicyFocusContext, + Page, + PageParams, + ProviderSettingsFocusContext, + SettingsPageParams, +} from "@/types/page"; const { mockSettingsSidebar, @@ -90,8 +96,8 @@ vi.mock("../system/environment", () => ({ EnvironmentSettings: () =>
environment
, })); vi.mock("../system/execution-policy", () => ({ - ExecutionPolicySettings: () => { - mockExecutionPolicySettings(); + ExecutionPolicySettings: (props: unknown) => { + mockExecutionPolicySettings(props); return
execution-policy
; }, })); @@ -107,7 +113,7 @@ vi.mock("../home", () => ({ vi.mock("@/lib/api/oemCloudRuntime", () => ({ resolveOemCloudRuntimeContext: () => mockResolveOemCloudRuntimeContext(), })); -import { SettingsLayoutV2 } from "."; +import { SettingsPage } from ".."; interface Mounted { container: HTMLDivElement; @@ -118,23 +124,36 @@ const mounted: Mounted[] = []; function renderComponent( initialTab: SettingsTabs, - onNavigate?: (page: string) => void, + onNavigate?: (page: Page, params?: PageParams) => void, initialProviderView?: "settings" | "cloud", initialProviderFocus?: ProviderSettingsFocusContext | null, + initialExecutionPolicyFocus?: ExecutionPolicyFocusContext | null, + onBack = vi.fn(), ) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { - root.render( - , + function ControlledSettings() { + const [pageParams, setPageParams] = useState({ + tab: initialTab, + providerView: initialProviderView, + providerFocus: initialProviderFocus ?? undefined, + executionPolicyFocus: initialExecutionPolicyFocus ?? undefined, + }); + return ( + { + onNavigate?.(page, params); + if (page === "settings") setPageParams(params as SettingsPageParams); + }} + /> ); + } + act(() => { + root.render(); }); mounted.push({ container, root }); @@ -188,7 +207,7 @@ afterEach(async () => { await changeLimeLocale("zh-CN"); }); -describe("SettingsLayoutV2 Profile Tab", () => { +describe("SettingsPage Profile Tab", () => { it("OEM 运行时下只展示统一账户页,不再渲染本地资料编辑器,也不再注入壳层标题", async () => { const container = renderComponent(SettingsTabs.Profile); await flushEffects(); @@ -212,7 +231,7 @@ describe("SettingsLayoutV2 Profile Tab", () => { }); }); -describe("SettingsLayoutV2 Skills Tab", () => { +describe("SettingsPage Skills Tab", () => { it("技能页应从设置导航进入现有高级技能入口", async () => { const container = renderComponent(SettingsTabs.Skills); await flushEffects(); @@ -221,14 +240,16 @@ describe("SettingsLayoutV2 Skills Tab", () => { }); }); -describe("SettingsLayoutV2 Experimental Tab", () => { - it("旧的执行轨迹页入口应直接回退到首页", async () => { - const container = renderComponent(SettingsTabs.ExecutionTracker); +describe("SettingsPage Experimental Tab", () => { + it("未知设置页应回退到当前首页", async () => { + const container = renderComponent( + "unknown-settings-section" as SettingsTabs, + ); await flushEffects(); const text = container.textContent ?? ""; expect(text).toContain("home"); - expect(text).not.toContain("execution-tracker"); + expect(text).not.toContain("unknown-settings-section"); }); it("旧实验功能直达入口应切到开发者与实验功能合并页", async () => { @@ -251,22 +272,30 @@ describe("SettingsLayoutV2 Experimental Tab", () => { }); }); -describe("SettingsLayoutV2 Developer Tab", () => { - it("设置页应在上下文侧栏保留标题、返回首页和全局导航", async () => { +describe("SettingsPage Developer Tab", () => { + it("设置页应在上下文侧栏保留标题、返回和全局导航", async () => { vi.stubGlobal("navigator", { platform: "MacIntel", userAgent: "Mac OS X", }); const onNavigate = vi.fn(); - const container = renderComponent(SettingsTabs.Home, onNavigate); + const onBack = vi.fn(); + const container = renderComponent( + SettingsTabs.Home, + onNavigate, + undefined, + undefined, + undefined, + onBack, + ); await flushEffects(); const header = container.querySelector( '[data-testid="settings-top-header"]', ); const button = container.querySelector( - '[data-testid="settings-home-button"]', + '[data-testid="settings-back-button"]', ); expect(header).not.toBeNull(); @@ -275,8 +304,8 @@ describe("SettingsLayoutV2 Developer Tab", () => { expect(header?.classList.contains("lime-settings-theme-scope")).toBe(true); expect(header?.getAttribute("data-window-controls-reserved")).toBe("true"); expect(button).not.toBeNull(); - expect(button?.textContent ?? "").toContain("Back Home"); - expect(button?.getAttribute("aria-label")).toBe("Back Home"); + expect(button?.textContent ?? "").toContain("Back"); + expect(button?.getAttribute("aria-label")).toBe("Back"); const navigation = container.querySelector( '[data-testid="settings-navigation"]', ); @@ -296,8 +325,20 @@ describe("SettingsLayoutV2 Developer Tab", () => { await Promise.resolve(); }); - expect(onNavigate).toHaveBeenCalledTimes(1); - expect(onNavigate.mock.calls[0]?.[0]).toBe("agent"); + expect(onBack).toHaveBeenCalledTimes(1); + expect(onNavigate).not.toHaveBeenCalled(); + act(() => { + container + .querySelector( + '[data-testid="app-sidebar-rail-home"]', + ) + ?.click(); + }); + expect(onNavigate).toHaveBeenCalledWith( + "agent", + expect.objectContaining({ agentEntry: "new-task" }), + ); + expect(onBack).toHaveBeenCalledTimes(1); }); it("设置页的全局图标栏应能够切回插件上下文", async () => { @@ -392,7 +433,7 @@ describe("SettingsLayoutV2 Developer Tab", () => { }); }); -describe("SettingsLayoutV2 Provider Focus", () => { +describe("SettingsPage Provider Focus", () => { it("服务商设置页应透传 provider focus 给 Provider 设置组件", async () => { const providerFocus: ProviderSettingsFocusContext = { providerId: "custom-coder", @@ -415,3 +456,139 @@ describe("SettingsLayoutV2 Provider Focus", () => { }); }); }); + +describe("SettingsPage current sections", () => { + it("切章回写canonical参数并清理旧focus,重新进入不重放;点击当前章节保留focus", async () => { + const onNavigate = vi.fn(); + const focus = { providerId: "provider", requestKey: 42 }; + const container = renderComponent( + SettingsTabs.Providers, + onNavigate, + "cloud", + focus, + ); + await flushEffects(); + const select = async (tab: SettingsTabs) => { + const props = mockSettingsSidebar.mock.calls.at(-1)![0] as { + onTabChange: (tab: SettingsTabs) => void; + }; + await act(async () => { + props.onTabChange(tab); + }); + await flushEffects(); + }; + await select(SettingsTabs.Providers); + expect(onNavigate).not.toHaveBeenCalled(); + expect(mockCloudProviderSettings.mock.calls.at(-1)![0]).toMatchObject({ + initialView: "cloud", + initialFocus: focus, + }); + await select(SettingsTabs.Appearance); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Appearance, + }); + expect(container.textContent).toContain("appearance"); + await select(SettingsTabs.Providers); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Providers, + }); + expect(mockCloudProviderSettings.mock.calls.at(-1)![0]).toMatchObject({ + initialView: undefined, + initialFocus: undefined, + }); + await select(SettingsTabs.ExecutionPolicy); + expect(mockExecutionPolicySettings.mock.calls.at(-1)![0]).toEqual({ + focus: null, + }); + await select(SettingsTabs.Experimental); + expect(onNavigate).toHaveBeenLastCalledWith("settings", { + tab: SettingsTabs.Experimental, + }); + expect(mockSettingsSidebar.mock.calls.at(-1)![0]).toMatchObject({ + activeTab: SettingsTabs.Developer, + }); + expect(mockDeveloperLabSettings.mock.calls.at(-1)![0]).toEqual({ + initialTab: "experimental", + }); + await select(SettingsTabs.Developer); + expect(mockDeveloperLabSettings.mock.calls.at(-1)![0]).toEqual({ + initialTab: "developer", + }); + }); + + it.each([ + ["zh-CN", "返回"], + ["zh-TW", "返回"], + ["en-US", "Back"], + ["ja-JP", "戻る"], + ["ko-KR", "뒤로"], + ])("%s页头提供可访问的返回动作", async (locale, label) => { + await changeLimeLocale(locale as Parameters[0]); + const onBack = vi.fn(); + const container = renderComponent( + SettingsTabs.Home, + undefined, + undefined, + undefined, + undefined, + onBack, + ); + await flushEffects(); + const button = container.querySelector( + '[data-testid="settings-back-button"]', + )!; + expect(button.textContent).toBe(label); + expect(button.getAttribute("aria-label")).toBe(label); + act(() => { + button.click(); + }); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it.each([ + [SettingsTabs.Home, "home"], + [SettingsTabs.Profile, "USER_CENTER_SESSION"], + [SettingsTabs.Stats, "stats"], + [SettingsTabs.Appearance, "appearance"], + [SettingsTabs.Memory, "memory"], + [SettingsTabs.ArchivedConversations, "archived-conversations"], + [SettingsTabs.Providers, "providers"], + [SettingsTabs.Skills, "skills"], + [SettingsTabs.MediaServices, "media-services"], + [SettingsTabs.McpServer, "mcp"], + [SettingsTabs.WebSearch, "web-search"], + [SettingsTabs.Environment, "environment"], + [SettingsTabs.ExecutionPolicy, "execution-policy"], + [SettingsTabs.Developer, "developer-lab"], + [SettingsTabs.About, "about"], + ])("公开设置页应渲染 current %s 章节", async (tab, text) => { + const container = renderComponent(tab); + await flushEffects(); + expect(container.textContent).toContain(text); + expect(mockSettingsSidebar.mock.calls.at(-1)?.[0]).toMatchObject({ + activeTab: tab, + }); + }); + + it("执行策略直达应保留完整 focus 上下文", async () => { + const focus: ExecutionPolicyFocusContext = { + section: "network", + target: "host", + value: "example.invalid", + ruleId: "policy-rule", + reasonCode: "blocked_network_target", + requestKey: 17, + }; + renderComponent( + SettingsTabs.ExecutionPolicy, + undefined, + undefined, + undefined, + focus, + ); + await flushEffects(); + expect(mockExecutionPolicySettings.mock.calls.at(-1)?.[0]).toEqual({ + focus, + }); + }); +}); diff --git a/src/components/settings-v2/_layout/index.tsx b/src/components/settings-v2/_layout/index.tsx index 93266fe86..28dc4eb37 100644 --- a/src/components/settings-v2/_layout/index.tsx +++ b/src/components/settings-v2/_layout/index.tsx @@ -6,8 +6,6 @@ */ import { - useState, - lazy, Suspense, type ReactNode, useEffect, @@ -19,93 +17,12 @@ import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { SettingsNavigation } from "./SettingsNavigation"; import { SettingsTabs } from "@/types/settings"; -import { - Page, - PageParams, - type ExecutionPolicyFocusContext, - type ProviderSettingsFocusContext, - type SettingsProviderView, -} from "@/types/page"; +import { Page, PageParams, type SettingsPageParams } from "@/types/page"; import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { shouldReserveMacWindowControls } from "@/lib/windowControls"; +import { isSettingsSection, resolveSettingsSection } from "../sections"; import { SettingsHomePage } from "../home"; -import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; -const AppearanceSettings = lazy(() => - import("../general/appearance").then((module) => ({ - default: module.AppearanceSettings, - })), -); -const MemorySettings = lazy(() => - import("../general/memory").then((module) => ({ - default: module.MemorySettings, - })), -); -const ArchivedConversationsSettings = lazy(() => - import("../general/archived-conversations").then((module) => ({ - default: module.ArchivedConversationsSettings, - })), -); -const DeveloperLabSettings = lazy(() => - import("../system/developer-lab").then((module) => ({ - default: module.DeveloperLabSettings, - })), -); -const AboutSection = lazy(() => - import("../system/about").then((module) => ({ - default: module.AboutSection, - })), -); -const MediaServicesSettings = lazy(() => - import("../agent/media-services").then((module) => ({ - default: module.MediaServicesSettings, - })), -); -const SkillsSettings = lazy(() => - import("../agent/skills").then((module) => ({ - default: module.ExtensionsSettings, - })), -); -const StatsSettings = lazy(() => - import("../account/stats").then((module) => ({ - default: module.StatsSettings, - })), -); -const ProfileSettings = lazy(() => - import("../account/profile").then((module) => ({ - default: module.ProfileSettings, - })), -); -const UserCenterSessionSettings = lazy(() => - import("../account/user-center-session").then((module) => ({ - default: module.UserCenterSessionSettings, - })), -); -const CloudProviderSettings = lazy(() => - import("../agent/providers").then((module) => ({ - default: module.CloudProviderSettings, - })), -); -const McpPanel = lazy(() => - import("@/components/mcp").then((module) => ({ - default: module.McpPanel, - })), -); -const EnvironmentSettings = lazy(() => - import("../system/environment").then((module) => ({ - default: module.EnvironmentSettings, - })), -); -const ExecutionPolicySettings = lazy(() => - import("../system/execution-policy").then((module) => ({ - default: module.ExecutionPolicySettings, - })), -); -const WebSearchSettings = lazy(() => - import("../system/web-search").then((module) => ({ - default: module.WebSearchSettings, - })), -); const LayoutContainer = styled.div` display: flex; flex: 1; @@ -192,21 +109,6 @@ const ContentAtmosphere = styled.div` } `; -const PlaceholderPage = styled.div` - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 300px; - color: hsl(var(--muted-foreground)); - text-align: center; - - p { - margin-top: 8px; - font-size: 14px; - } -`; - const LoadingPanel = styled.div` border: 1px solid hsl(var(--border)); border-radius: 20px; @@ -241,266 +143,76 @@ function withSettingsContentFallback( ); } -const ACTIVE_SETTINGS_TABS = new Set([ - SettingsTabs.Home, - SettingsTabs.Profile, - SettingsTabs.Stats, - SettingsTabs.Appearance, - SettingsTabs.Memory, - SettingsTabs.ArchivedConversations, - SettingsTabs.Providers, - SettingsTabs.Skills, - SettingsTabs.MediaServices, - SettingsTabs.McpServer, - SettingsTabs.WebSearch, - SettingsTabs.Environment, - SettingsTabs.ExecutionPolicy, - SettingsTabs.Developer, - SettingsTabs.About, -]); - -function resolveActiveSettingsTab(tab?: SettingsTabs): SettingsTabs { - const canonicalTab = - tab === SettingsTabs.Experimental ? SettingsTabs.Developer : tab; - - if (!canonicalTab || !ACTIVE_SETTINGS_TABS.has(canonicalTab)) { - return SettingsTabs.Home; - } - return canonicalTab; -} - -function preloadSettingsTab(tab: SettingsTabs): Promise | null { - switch (resolveActiveSettingsTab(tab)) { - case SettingsTabs.Home: - return null; - case SettingsTabs.Profile: - return Promise.all([ - import("../account/profile"), - import("../account/user-center-session"), - ]); - case SettingsTabs.Stats: - return import("../account/stats"); - case SettingsTabs.Appearance: - return import("../general/appearance"); - case SettingsTabs.Memory: - return import("../general/memory"); - case SettingsTabs.ArchivedConversations: - return import("../general/archived-conversations"); - case SettingsTabs.Providers: - return import("../agent/providers"); - case SettingsTabs.Skills: - return import("../agent/skills"); - case SettingsTabs.MediaServices: - return import("../agent/media-services"); - case SettingsTabs.McpServer: - return import("@/components/mcp"); - case SettingsTabs.WebSearch: - return import("../system/web-search"); - case SettingsTabs.Environment: - return import("../system/environment"); - case SettingsTabs.ExecutionPolicy: - return import("../system/execution-policy"); - case SettingsTabs.Developer: - return Promise.all([ - import("../system/developer-lab"), - import("../system/developer/preload").then((module) => - module.preloadDeveloperDefaultSections(), - ), - ]); - case SettingsTabs.About: - return import("../system/about"); - default: - return null; - } -} - -/** - * 渲染设置内容 - */ - function renderSettingsContent( tab: SettingsTabs, onTabChange: (tab: SettingsTabs) => void, t: TFunction<"settings", undefined>, onTabPrefetch?: (tab: SettingsTabs) => void, onNavigate?: (page: Page, params?: PageParams) => void, - initialProviderView?: SettingsProviderView, - initialProviderFocus?: ProviderSettingsFocusContext | null, - initialExecutionPolicyFocus?: ExecutionPolicyFocusContext | null, - activeDeveloperLabTab: "developer" | "experimental" = "developer", + params: SettingsPageParams = {}, ): ReactNode { - const hasManagedAccountProfile = Boolean(resolveOemCloudRuntimeContext()); - - switch (tab) { - case SettingsTabs.Home: - return ( - - ); - - // 账号组 - case SettingsTabs.Profile: - return withSettingsContentFallback( - <> - - {!hasManagedAccountProfile ? : null} - , - t("settings.layout.loading.profile"), - ); - - case SettingsTabs.Stats: - return withSettingsContentFallback( - , - t("settings.layout.loading.stats"), - ); - - // 通用组 - case SettingsTabs.Appearance: - return withSettingsContentFallback( - , - t("settings.layout.loading.appearance"), - ); - - case SettingsTabs.Memory: - return withSettingsContentFallback( - , - t("settings.layout.loading.memory"), - ); - - case SettingsTabs.ArchivedConversations: - return withSettingsContentFallback( - , - t("settings.layout.loading.archivedConversations"), - ); - - // 智能体组 - case SettingsTabs.Providers: - return withSettingsContentFallback( - , - t("settings.layout.loading.providers"), - ); - - case SettingsTabs.Skills: - return withSettingsContentFallback( - , - t("settings.layout.loading.skills"), - ); - - case SettingsTabs.MediaServices: - return withSettingsContentFallback( - , - t("settings.layout.loading.mediaServices"), - ); - - // 系统组 - case SettingsTabs.McpServer: - return withSettingsContentFallback( - , - t("settings.layout.loading.mcpServer"), - ); - - case SettingsTabs.WebSearch: - return withSettingsContentFallback( - , - t("settings.layout.loading.webSearch"), - ); - - case SettingsTabs.Environment: - return withSettingsContentFallback( - , - t("settings.layout.loading.environment"), - ); - - case SettingsTabs.ExecutionPolicy: - return withSettingsContentFallback( - , - t("settings.layout.loading.executionPolicy"), - ); - - case SettingsTabs.Developer: - return withSettingsContentFallback( - , - t("settings.layout.loading.developerLab"), - ); - - case SettingsTabs.About: - return withSettingsContentFallback( - , - t("settings.layout.loading.about"), - ); - - default: - return ( - -

{t("settings.layout.placeholder.notFound")}

-
- ); - } + const section = resolveSettingsSection(tab); + const content = section.render({ + renderHome: () => ( + + ), + providerView: + tab === SettingsTabs.Providers + ? (params.providerView ?? + (params.providerFocus ? "settings" : undefined)) + : undefined, + providerFocus: + tab === SettingsTabs.Providers ? params.providerFocus : undefined, + executionPolicyFocus: + tab === SettingsTabs.ExecutionPolicy + ? params.executionPolicyFocus + : undefined, + developerLabTab: + params.tab === SettingsTabs.Experimental ? "experimental" : "developer", + }); + return section.loadingKey + ? withSettingsContentFallback(content, t(section.loadingKey)) + : content; } /** * 设置页面主组件 */ -interface SettingsLayoutV2Props { - onNavigate?: (page: Page, params?: PageParams) => void; - initialTab?: SettingsTabs; - initialProviderView?: SettingsProviderView; - initialProviderFocus?: ProviderSettingsFocusContext | null; - initialExecutionPolicyFocus?: ExecutionPolicyFocusContext | null; +interface SettingsPageProps { + onNavigate: (page: Page, params?: PageParams) => void; + onBack: () => void; + pageParams: SettingsPageParams; } -const WIDE_CONTENT_TABS = ACTIVE_SETTINGS_TABS; -type DeveloperLabInitialTab = "developer" | "experimental"; - -function resolveDeveloperLabInitialTab( - tab?: SettingsTabs, -): DeveloperLabInitialTab { - return tab === SettingsTabs.Experimental ? "experimental" : "developer"; -} - -export function SettingsLayoutV2({ +export function SettingsPage({ onNavigate, - initialTab, - initialProviderView, - initialProviderFocus, - initialExecutionPolicyFocus, -}: SettingsLayoutV2Props) { + onBack, + pageParams, +}: SettingsPageProps) { const { t } = useTranslation("settings"); - const [activeTab, setActiveTab] = useState( - resolveActiveSettingsTab(initialTab), - ); - const [activeProviderView, setActiveProviderView] = useState< - SettingsProviderView | undefined - >(initialProviderView); - const [activeDeveloperLabTab, setActiveDeveloperLabTab] = - useState(resolveDeveloperLabInitialTab(initialTab)); + const activeTab = resolveSettingsSection(pageParams.tab).key; const contentContainerRef = useRef(null); const prefetchedTabsRef = useRef>(new Set()); const reserveWindowControls = shouldReserveMacWindowControls(); - const handleTabChange = useCallback((tab: SettingsTabs) => { - const nextTab = resolveActiveSettingsTab(tab); - setActiveTab(nextTab); - if (nextTab === SettingsTabs.Developer) { - setActiveDeveloperLabTab(resolveDeveloperLabInitialTab(tab)); - } - if (nextTab !== SettingsTabs.Providers) { - setActiveProviderView(undefined); - } - }, []); + const handleTabChange = useCallback( + (tab: SettingsTabs) => { + if (tab === (pageParams.tab ?? SettingsTabs.Home)) return; + onNavigate("settings", { tab }); + }, + [onNavigate, pageParams.tab], + ); const handleTabPrefetch = useCallback((tab: SettingsTabs) => { if (prefetchedTabsRef.current.has(tab)) { return; } - const preloadTask = preloadSettingsTab(tab); + const preloadTask = resolveSettingsSection(tab).preload(); if (!preloadTask) { return; } @@ -515,26 +227,6 @@ export function SettingsLayoutV2({ onNavigate?.("agent", buildHomeAgentParams()); }, [onNavigate]); - useEffect(() => { - setActiveTab(resolveActiveSettingsTab(initialTab)); - setActiveDeveloperLabTab(resolveDeveloperLabInitialTab(initialTab)); - }, [initialTab]); - - useEffect(() => { - if (!initialTab && !initialProviderView && !initialProviderFocus) { - return; - } - - if ((initialTab ?? SettingsTabs.Providers) === SettingsTabs.Providers) { - setActiveProviderView( - initialProviderView ?? (initialProviderFocus ? "settings" : undefined), - ); - return; - } - - setActiveProviderView(undefined); - }, [initialProviderFocus, initialProviderView, initialTab]); - useEffect(() => { contentContainerRef.current?.scrollTo?.({ top: 0, behavior: "auto" }); }, [activeTab]); @@ -546,7 +238,8 @@ export function SettingsLayoutV2({ reserveWindowControls={reserveWindowControls} onTabChange={handleTabChange} onTabPrefetch={handleTabPrefetch} - onBackHome={handleBackHome} + onBack={onBack} + onHome={handleBackHome} onNavigate={onNavigate} /> - + {renderSettingsContent( activeTab, handleTabChange, t, handleTabPrefetch, onNavigate, - activeProviderView, - initialProviderFocus, - initialExecutionPolicyFocus, - activeDeveloperLabTab, + pageParams, )} diff --git a/src/components/settings-v2/agent/image-gen/index.test.tsx b/src/components/settings-v2/agent/image-gen/index.test.tsx index a6f7fc2f6..aab0c7e47 100644 --- a/src/components/settings-v2/agent/image-gen/index.test.tsx +++ b/src/components/settings-v2/agent/image-gen/index.test.tsx @@ -372,11 +372,73 @@ afterEach(async () => { }); target.container.remove(); } + vi.useRealTimers(); vi.clearAllMocks(); await changeLimeLocale("zh-CN"); }); describe("ImageGenSettings", () => { + it("卸载后应取消保存消息的自动关闭", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const container = renderComponent(); + await flushEffects(3); + await act(async () => { + findButton(container, "Restore defaults").click(); + await flushEffects(2); + }); + expect(container.textContent).toContain("Settings saved"); + const target = mounted.pop()!; + act(() => target.root.unmount()); + target.container.remove(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("新保存消息应保留自己的完整显示时间", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const container = renderComponent(); + await flushEffects(3); + const select = + mockModelSelectorRender.mock.calls.at(-1)![0].setProviderAndModel; + await act(async () => { + select("agnes", "agnes-image-2.1-flash"); + await flushEffects(3); + }); + act(() => vi.advanceTimersByTime(2000)); + await act(async () => { + select("agnes", "agnes-image-2.0-flash"); + await flushEffects(3); + }); + act(() => vi.advanceTimersByTime(1000)); + expect(container.textContent).toContain("Settings saved"); + act(() => vi.advanceTimersByTime(2000)); + expect(container.textContent).not.toContain("Settings saved"); + }); + + it("卸载后完成保存不应重新创建消息定时器", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + let resolveSave!: (config: Config) => void; + mockUpdateConfig.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const container = renderComponent(); + await flushEffects(3); + await act(async () => { + findButton(container, "Restore defaults").click(); + await flushEffects(2); + }); + const target = mounted.pop()!; + act(() => target.root.unmount()); + target.container.remove(); + await act(async () => { + resolveSave(persistedConfig); + await flushEffects(3); + }); + expect(vi.getTimerCount()).toBe(0); + }); + it("应加载简化后的图片服务模型设置,并保留 gpt-images-2 选择", async () => { const container = renderComponent(); await flushEffects(3); diff --git a/src/components/settings-v2/agent/image-gen/index.tsx b/src/components/settings-v2/agent/image-gen/index.tsx index 6dc5d619a..4da29f22b 100644 --- a/src/components/settings-v2/agent/image-gen/index.tsx +++ b/src/components/settings-v2/agent/image-gen/index.tsx @@ -34,6 +34,7 @@ import { } from "@/lib/imageGen/catalog"; import { MediaPreferenceSection } from "../shared/MediaPreferenceSection"; import { updateMediaPreference } from "../shared/mediaPreferencePersistence"; +import { useAutoDismissMessage } from "../shared/useAutoDismissMessage"; const DEFAULT_MEDIA_PREFERENCE: MediaGenerationPreference = { allowFallback: true, @@ -86,10 +87,7 @@ function buildImageModelMetadata( export function ImageGenSettings() { const { t } = useTranslation("settings"); const [config, setConfig] = useState(null); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); + const { message, showMessage } = useAutoDismissMessage(); const [globalImagePreference, setGlobalImagePreference] = useState(DEFAULT_MEDIA_PREFERENCE); const { providers, loading: providersLoading } = useConfiguredProviders(); @@ -212,11 +210,6 @@ export function ImageGenSettings() { }) : undefined; - const showMessage = (type: "success" | "error", text: string) => { - setMessage({ type, text }); - setTimeout(() => setMessage(null), 3000); - }; - const savePreference = async ( updater: (current: MediaGenerationPreference) => MediaGenerationPreference, ) => { diff --git a/src/components/settings-v2/agent/media-services/index.tsx b/src/components/settings-v2/agent/media-services/index.tsx index 1d82fad98..1f1b9df81 100644 --- a/src/components/settings-v2/agent/media-services/index.tsx +++ b/src/components/settings-v2/agent/media-services/index.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { ImageGenSettings } from "../image-gen"; import { SettingModelSelectorField } from "../shared/SettingModelSelectorField"; +import { useAutoDismissMessage } from "../shared/useAutoDismissMessage"; import { VideoGenSettings } from "../video-gen"; import { VoiceSettings } from "../voice"; @@ -220,10 +221,7 @@ function SettingRow({ export function MediaServicesSettings() { const { t } = useTranslation("settings"); const [config, setConfig] = useState(null); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); + const { message, showMessage } = useAutoDismissMessage(); const [promptEditors, setPromptEditors] = useState< Partial> >({}); @@ -268,11 +266,6 @@ export function MediaServicesSettings() { }; }, []); - const showMessage = (type: "success" | "error", text: string) => { - setMessage({ type, text }); - setTimeout(() => setMessage(null), 3000); - }; - const persistConfig = async (updater: (current: Config) => Config) => { if (!configRef.current) { return; diff --git a/src/components/settings-v2/agent/voice/useAutoDismissMessage.ts b/src/components/settings-v2/agent/shared/useAutoDismissMessage.ts similarity index 77% rename from src/components/settings-v2/agent/voice/useAutoDismissMessage.ts rename to src/components/settings-v2/agent/shared/useAutoDismissMessage.ts index 88570522b..fbdaae37f 100644 --- a/src/components/settings-v2/agent/voice/useAutoDismissMessage.ts +++ b/src/components/settings-v2/agent/shared/useAutoDismissMessage.ts @@ -10,6 +10,7 @@ export function useAutoDismissMessage(timeoutMs = 3000): { showMessage: (type: "success" | "error", text: string) => void; } { const [message, setMessage] = useState(null); + const mountedRef = useRef(true); const timeoutRef = useRef | null>(null); const clearDismissTimer = useCallback(() => { @@ -20,15 +21,22 @@ export function useAutoDismissMessage(timeoutMs = 3000): { timeoutRef.current = null; }, []); - useEffect(() => clearDismissTimer, [clearDismissTimer]); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearDismissTimer(); + }; + }, [clearDismissTimer]); const showMessage = useCallback( (type: "success" | "error", text: string) => { + if (!mountedRef.current) return; clearDismissTimer(); setMessage({ type, text }); timeoutRef.current = setTimeout(() => { timeoutRef.current = null; - setMessage(null); + if (mountedRef.current) setMessage(null); }, timeoutMs); }, [clearDismissTimer, timeoutMs], diff --git a/src/components/settings-v2/agent/video-gen/index.tsx b/src/components/settings-v2/agent/video-gen/index.tsx index 1d2bbbca6..f7d2107ac 100644 --- a/src/components/settings-v2/agent/video-gen/index.tsx +++ b/src/components/settings-v2/agent/video-gen/index.tsx @@ -15,6 +15,7 @@ import { } from "@/lib/mediaGeneration"; import { MediaPreferenceSection } from "../shared/MediaPreferenceSection"; import { updateMediaPreference } from "../shared/mediaPreferencePersistence"; +import { useAutoDismissMessage } from "../shared/useAutoDismissMessage"; const DEFAULT_PREFERENCE: MediaGenerationPreference = { allowFallback: true, @@ -26,10 +27,7 @@ export function VideoGenSettings() { const [config, setConfig] = useState(null); const [videoPreference, setVideoPreference] = useState(DEFAULT_PREFERENCE); - const [message, setMessage] = useState<{ - type: "success" | "error"; - text: string; - } | null>(null); + const { message, showMessage } = useAutoDismissMessage(); useEffect(() => { void (async () => { @@ -96,18 +94,10 @@ export function VideoGenSettings() { await updateMediaPreference("video", updater); setConfig(updatedConfig); setVideoPreference(nextPreference); - setMessage({ - type: "success", - text: t("settings.mediaGeneration.message.saved"), - }); - setTimeout(() => setMessage(null), 3000); + showMessage("success", t("settings.mediaGeneration.message.saved")); } catch (error) { console.error("保存视频服务配置失败:", error); - setMessage({ - type: "error", - text: t("settings.mediaGeneration.message.saveFailed"), - }); - setTimeout(() => setMessage(null), 3000); + showMessage("error", t("settings.mediaGeneration.message.saveFailed")); } }; diff --git a/src/components/settings-v2/agent/voice/index.tsx b/src/components/settings-v2/agent/voice/index.tsx index 5f4edd26d..747f34750 100644 --- a/src/components/settings-v2/agent/voice/index.tsx +++ b/src/components/settings-v2/agent/voice/index.tsx @@ -64,7 +64,7 @@ import { import { MediaPreferenceSection } from "../shared/MediaPreferenceSection"; import { updateMediaPreference } from "../shared/mediaPreferencePersistence"; import { SettingModelSelectorField } from "../shared/SettingModelSelectorField"; -import { useAutoDismissMessage } from "./useAutoDismissMessage"; +import { useAutoDismissMessage } from "../shared/useAutoDismissMessage"; const DEFAULT_MEDIA_PREFERENCE: MediaGenerationPreference = { allowFallback: true, diff --git a/src/components/settings-v2/hooks/useSettingsCategory.test.tsx b/src/components/settings-v2/hooks/useSettingsCategory.test.tsx index 685502126..c09493c5e 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.test.tsx +++ b/src/components/settings-v2/hooks/useSettingsCategory.test.tsx @@ -127,3 +127,28 @@ describe("useSettingsCategory", () => { expect(generalKeys).toContain(SettingsTabs.ArchivedConversations); }); }); + +describe("current settings navigation translations", () => { + it.each(["zh-CN", "zh-TW", "en-US", "ja-JP", "ko-KR"])( + "%s 下全部 current 章节应继续显示既有翻译", + async (locale) => { + await changeLimeLocale(locale); + let groups: CategoryGroup[] = []; + renderHookProbe((nextGroups) => { + groups = nextGroups; + }); + const items = groups.flatMap((group) => group.items); + expect(groups).toHaveLength(5); + expect(items).toHaveLength(15); + expect(new Set(items.map((item) => item.key)).size).toBe(15); + for (const group of groups) { + expect(group.title.trim()).not.toBe(""); + expect(group.title).not.toContain("settings.group."); + for (const item of group.items) { + expect(item.label.trim()).not.toBe(""); + expect(item.label).not.toContain("settings.tab."); + } + } + }, + ); +}); diff --git a/src/components/settings-v2/hooks/useSettingsCategory.ts b/src/components/settings-v2/hooks/useSettingsCategory.ts index 3b3e0cdd9..09a0dddea 100644 --- a/src/components/settings-v2/hooks/useSettingsCategory.ts +++ b/src/components/settings-v2/hooks/useSettingsCategory.ts @@ -1,34 +1,12 @@ /** - * 设置分类 Hook - * - * 定义设置页面的分组和导航项 - * 参考成熟产品的分组导航设计 + * 将 current 设置章节投影为已翻译的分组导航。 */ - import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - Home, - Archive, - User, - BarChart3, - Palette, - Brain, - Image, - Plug, - Puzzle, - Search, - ShieldCheck, - Variable, - Code, - Info, - LucideIcon, -} from "lucide-react"; -import { SettingsGroupKey, SettingsTabs } from "@/types/settings"; +import type { LucideIcon } from "lucide-react"; +import type { SettingsGroupKey, SettingsTabs } from "@/types/settings"; +import { settingsSections } from "../sections"; -/** - * 分类项定义 - */ export interface CategoryItem { key: SettingsTabs; label: string; @@ -36,141 +14,33 @@ export interface CategoryItem { experimental?: boolean; } -/** - * 分类组定义 - */ export interface CategoryGroup { key: SettingsGroupKey; title: string; items: CategoryItem[]; } -/** - * 设置分类 Hook - * - * 返回按分组组织的设置导航项 - */ export function useSettingsCategory(): CategoryGroup[] { const { t } = useTranslation("settings"); - return useMemo(() => { - const groups: CategoryGroup[] = []; - - groups.push({ - key: SettingsGroupKey.Overview, - title: t("settings.group.overview"), - items: [ - { - key: SettingsTabs.Home, - label: t("settings.tab.home"), - icon: Home, - }, - ], - }); - - // 账号组 - groups.push({ - key: SettingsGroupKey.Account, - title: t("settings.group.account"), - items: [ - { - key: SettingsTabs.Profile, - label: t("settings.tab.profile"), - icon: User, - }, - { - key: SettingsTabs.Stats, - label: t("settings.tab.stats"), - icon: BarChart3, - }, - ], - }); - - // 通用组 - groups.push({ - key: SettingsGroupKey.General, - title: t("settings.group.general"), - items: [ - { - key: SettingsTabs.Appearance, - label: t("settings.tab.appearance"), - icon: Palette, - }, - { - key: SettingsTabs.Memory, - label: t("settings.tab.memory"), - icon: Brain, - }, - { - key: SettingsTabs.ArchivedConversations, - label: t("settings.tab.archivedConversations"), - icon: Archive, - }, - ], - }); - - // 智能体组 - groups.push({ - key: SettingsGroupKey.Agent, - title: t("settings.group.agent"), - items: [ - { - key: SettingsTabs.Providers, - label: t("settings.tab.providers"), - icon: Brain, - }, - { - key: SettingsTabs.Skills, - label: t("settings.tab.skills"), - icon: Puzzle, - }, - { - key: SettingsTabs.MediaServices, - label: t("settings.tab.mediaServices"), - icon: Image, - }, - ], - }); - - // 系统组 - groups.push({ - key: SettingsGroupKey.System, - title: t("settings.group.system"), - items: [ - { - key: SettingsTabs.McpServer, - label: t("settings.tab.mcpServer"), - icon: Plug, - }, - { - key: SettingsTabs.WebSearch, - label: t("settings.tab.webSearch"), - icon: Search, - }, - { - key: SettingsTabs.Environment, - label: t("settings.tab.environment"), - icon: Variable, - }, - { - key: SettingsTabs.ExecutionPolicy, - label: t("settings.tab.executionPolicy"), - icon: ShieldCheck, - }, - { - key: SettingsTabs.Developer, - label: t("settings.tab.developerLab"), - icon: Code, - experimental: true, - }, - { - key: SettingsTabs.About, - label: t("settings.tab.about"), - icon: Info, - }, - ], - }); - - return groups; + const groups = new Map(); + for (const section of settingsSections) { + let group = groups.get(section.group); + if (!group) { + group = { + key: section.group, + title: t(`settings.group.${section.group}`), + items: [], + }; + groups.set(section.group, group); + } + group.items.push({ + key: section.key, + label: t(section.labelKey), + icon: section.icon, + ...(section.experimental ? { experimental: true } : {}), + }); + } + return [...groups.values()]; }, [t]); } diff --git a/src/components/settings-v2/index.ts b/src/components/settings-v2/index.ts index 4a4c693f1..3b73381ce 100644 --- a/src/components/settings-v2/index.ts +++ b/src/components/settings-v2/index.ts @@ -1,7 +1,4 @@ /** - * 设置页面 V2 导出 - * - * 新版设置页面,采用统一侧边栏布局 + * 设置页面公开入口 */ - -export { SettingsLayoutV2 as SettingsPageV2 } from "./_layout"; +export { SettingsPage } from "./_layout"; diff --git a/src/components/settings-v2/sections.tsx b/src/components/settings-v2/sections.tsx new file mode 100644 index 000000000..2bad01299 --- /dev/null +++ b/src/components/settings-v2/sections.tsx @@ -0,0 +1,305 @@ +import { lazy, type ReactNode } from "react"; +import { + Home, + Archive, + User, + BarChart3, + Palette, + Brain, + Image, + Plug, + Puzzle, + Search, + ShieldCheck, + Variable, + Code, + Info, + type LucideIcon, +} from "lucide-react"; +import { SettingsGroupKey, SettingsTabs } from "@/types/settings"; +import type { + ExecutionPolicyFocusContext, + ProviderSettingsFocusContext, + SettingsProviderView, +} from "@/types/page"; +import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; + +export interface SettingsSectionContext { + renderHome: () => ReactNode; + providerView?: SettingsProviderView; + providerFocus?: ProviderSettingsFocusContext | null; + executionPolicyFocus?: ExecutionPolicyFocusContext | null; + developerLabTab: "developer" | "experimental"; +} + +export interface SettingsSection { + key: Exclude; + group: SettingsGroupKey; + labelKey: string; + icon: LucideIcon; + experimental?: boolean; + loadingKey?: string; + preload: () => Promise | null; + render: (context: SettingsSectionContext) => ReactNode; +} + +const loadAppearance = () => import("./general/appearance"); +const AppearanceSettings = lazy(() => + loadAppearance().then((module) => ({ default: module.AppearanceSettings })), +); + +const loadMemory = () => import("./general/memory"); +const MemorySettings = lazy(() => + loadMemory().then((module) => ({ default: module.MemorySettings })), +); + +const loadArchivedConversations = () => + import("./general/archived-conversations"); +const ArchivedConversationsSettings = lazy(() => + loadArchivedConversations().then((module) => ({ + default: module.ArchivedConversationsSettings, + })), +); + +const loadDeveloperLab = () => import("./system/developer-lab"); +const DeveloperLabSettings = lazy(() => + loadDeveloperLab().then((module) => ({ + default: module.DeveloperLabSettings, + })), +); + +const loadAbout = () => import("./system/about"); +const AboutSection = lazy(() => + loadAbout().then((module) => ({ default: module.AboutSection })), +); + +const loadMediaServices = () => import("./agent/media-services"); +const MediaServicesSettings = lazy(() => + loadMediaServices().then((module) => ({ + default: module.MediaServicesSettings, + })), +); + +const loadSkills = () => import("./agent/skills"); +const ExtensionsSettings = lazy(() => + loadSkills().then((module) => ({ default: module.ExtensionsSettings })), +); + +const loadStats = () => import("./account/stats"); +const StatsSettings = lazy(() => + loadStats().then((module) => ({ default: module.StatsSettings })), +); + +const loadProfile = () => import("./account/profile"); +const ProfileSettings = lazy(() => + loadProfile().then((module) => ({ default: module.ProfileSettings })), +); + +const loadUserCenterSession = () => import("./account/user-center-session"); +const UserCenterSessionSettings = lazy(() => + loadUserCenterSession().then((module) => ({ + default: module.UserCenterSessionSettings, + })), +); + +const loadProviders = () => import("./agent/providers"); +const CloudProviderSettings = lazy(() => + loadProviders().then((module) => ({ default: module.CloudProviderSettings })), +); + +const loadMcp = () => import("@/components/mcp"); +const McpPanel = lazy(() => + loadMcp().then((module) => ({ default: module.McpPanel })), +); + +const loadEnvironment = () => import("./system/environment"); +const EnvironmentSettings = lazy(() => + loadEnvironment().then((module) => ({ default: module.EnvironmentSettings })), +); + +const loadExecutionPolicy = () => import("./system/execution-policy"); +const ExecutionPolicySettings = lazy(() => + loadExecutionPolicy().then((module) => ({ + default: module.ExecutionPolicySettings, + })), +); + +const loadWebSearch = () => import("./system/web-search"); +const WebSearchSettings = lazy(() => + loadWebSearch().then((module) => ({ default: module.WebSearchSettings })), +); + +// current 章节在此登记一次;导航、可用性、预取与渲染共享同一事实。 +export const settingsSections: readonly SettingsSection[] = [ + { + key: SettingsTabs.Home, + group: SettingsGroupKey.Overview, + labelKey: "settings.tab.home", + icon: Home, + preload: () => null, + render: (context) => context.renderHome(), + }, + { + key: SettingsTabs.Profile, + group: SettingsGroupKey.Account, + labelKey: "settings.tab.profile", + icon: User, + loadingKey: "settings.layout.loading.profile", + preload: () => Promise.all([loadProfile(), loadUserCenterSession()]), + render: () => ( + <> + + {!resolveOemCloudRuntimeContext() ? : null} + + ), + }, + { + key: SettingsTabs.Stats, + group: SettingsGroupKey.Account, + labelKey: "settings.tab.stats", + icon: BarChart3, + loadingKey: "settings.layout.loading.stats", + preload: loadStats, + render: () => , + }, + { + key: SettingsTabs.Appearance, + group: SettingsGroupKey.General, + labelKey: "settings.tab.appearance", + icon: Palette, + loadingKey: "settings.layout.loading.appearance", + preload: loadAppearance, + render: () => , + }, + { + key: SettingsTabs.Memory, + group: SettingsGroupKey.General, + labelKey: "settings.tab.memory", + icon: Brain, + loadingKey: "settings.layout.loading.memory", + preload: loadMemory, + render: () => , + }, + { + key: SettingsTabs.ArchivedConversations, + group: SettingsGroupKey.General, + labelKey: "settings.tab.archivedConversations", + icon: Archive, + loadingKey: "settings.layout.loading.archivedConversations", + preload: loadArchivedConversations, + render: () => , + }, + { + key: SettingsTabs.Providers, + group: SettingsGroupKey.Agent, + labelKey: "settings.tab.providers", + icon: Brain, + loadingKey: "settings.layout.loading.providers", + preload: loadProviders, + render: (context) => ( + + ), + }, + { + key: SettingsTabs.Skills, + group: SettingsGroupKey.Agent, + labelKey: "settings.tab.skills", + icon: Puzzle, + loadingKey: "settings.layout.loading.skills", + preload: loadSkills, + render: () => , + }, + { + key: SettingsTabs.MediaServices, + group: SettingsGroupKey.Agent, + labelKey: "settings.tab.mediaServices", + icon: Image, + loadingKey: "settings.layout.loading.mediaServices", + preload: loadMediaServices, + render: () => , + }, + { + key: SettingsTabs.McpServer, + group: SettingsGroupKey.System, + labelKey: "settings.tab.mcpServer", + icon: Plug, + loadingKey: "settings.layout.loading.mcpServer", + preload: loadMcp, + render: () => , + }, + { + key: SettingsTabs.WebSearch, + group: SettingsGroupKey.System, + labelKey: "settings.tab.webSearch", + icon: Search, + loadingKey: "settings.layout.loading.webSearch", + preload: loadWebSearch, + render: () => , + }, + { + key: SettingsTabs.Environment, + group: SettingsGroupKey.System, + labelKey: "settings.tab.environment", + icon: Variable, + loadingKey: "settings.layout.loading.environment", + preload: loadEnvironment, + render: () => , + }, + { + key: SettingsTabs.ExecutionPolicy, + group: SettingsGroupKey.System, + labelKey: "settings.tab.executionPolicy", + icon: ShieldCheck, + loadingKey: "settings.layout.loading.executionPolicy", + preload: loadExecutionPolicy, + render: (context) => ( + + ), + }, + { + key: SettingsTabs.Developer, + group: SettingsGroupKey.System, + labelKey: "settings.tab.developerLab", + icon: Code, + experimental: true, + loadingKey: "settings.layout.loading.developerLab", + preload: () => + Promise.all([ + loadDeveloperLab(), + import("./system/developer/preload").then((module) => + module.preloadDeveloperDefaultSections(), + ), + ]), + render: (context) => ( + + ), + }, + { + key: SettingsTabs.About, + group: SettingsGroupKey.System, + labelKey: "settings.tab.about", + icon: Info, + loadingKey: "settings.layout.loading.about", + preload: loadAbout, + render: () => , + }, +]; + +const sectionsByKey = new Map( + settingsSections.map((section) => [section.key, section]), +); + +export function isSettingsSection(tab: SettingsTabs): boolean { + return sectionsByKey.has(tab); +} + +export function resolveSettingsSection(tab?: SettingsTabs): SettingsSection { + const canonicalTab = + tab === SettingsTabs.Experimental ? SettingsTabs.Developer : tab; + return ( + (canonicalTab && sectionsByKey.get(canonicalTab)) || settingsSections[0] + ); +} diff --git a/src/hooks/useAppNavigation.test.tsx b/src/hooks/useAppNavigation.test.tsx index 6ccd69970..57c4e8039 100644 --- a/src/hooks/useAppNavigation.test.tsx +++ b/src/hooks/useAppNavigation.test.tsx @@ -6,6 +6,7 @@ import { buildHomeAgentParams, } from "@/lib/workspace/navigation"; import { useAppNavigation } from "./useAppNavigation"; +import { SettingsTabs } from "@/types/settings"; const NAVIGATION_RESTORE_STORAGE_KEY = "lime.appNavigation.restore.v1"; @@ -322,4 +323,176 @@ describe("useAppNavigation", () => { expect(latestNavigation?.navigationRequestId).toBe(2); expect(latestNavigation?.isNavigating).toBe(false); }); + + it("首页首发后按实际可见会话和工作区返回,切章不覆盖来处或重放一次性输入", async () => { + await renderProbe(); + const navigate = latestNavigation!.handleNavigate; + const back = latestNavigation!.returnFromSettings; + await act(async () => { + latestNavigation!.startNewConversation({ + projectId: "old-workspace", + freshDraft: true, + }); + }); + await act(async () => { + latestNavigation!.reportAgentLocation({ + sessionId: " materialized-thread ", + workspaceId: " actual-workspace ", + }); + }); + await act(async () => { + navigate("settings", { tab: SettingsTabs.Providers }); + }); + await act(async () => { + navigate("settings", { tab: SettingsTabs.Appearance }); + }); + await act(async () => { + back(); + }); + expect(latestNavigation!.pageParams).toEqual( + buildClawAgentParams({ + initialSessionId: "materialized-thread", + projectId: "actual-workspace", + contentId: undefined, + }), + ); + expect(latestNavigation!.handleNavigate).toBe(navigate); + expect(latestNavigation!.returnFromSettings).toBe(back); + const persisted = JSON.parse( + window.sessionStorage.getItem(NAVIGATION_RESTORE_STORAGE_KEY)!, + ); + expect(persisted.params.initialSessionId).toBe("materialized-thread"); + expect(persisted.params.newConversationRequest).toBeUndefined(); + }); + + it("已有会话未发布位置时使用最小恢复参数,丢弃发送、附件、技能与新建意图", async () => { + await renderProbe(); + await act(async () => { + latestNavigation!.handleNavigate( + "agent", + buildClawAgentParams({ + projectId: "workspace", + initialSessionId: "thread", + contentId: "content", + initialUserPrompt: "must-not-replay", + initialUserImages: [{ data: "image", mediaType: "image/png" }], + initialSiteSkillLaunch: { adapterName: "must-not-run" }, + newConversationRequest: { + id: 1, + workspaceId: "workspace", + freshDraft: true, + }, + }), + ); + }); + await act(async () => { + latestNavigation!.handleNavigate("settings"); + }); + await act(async () => { + latestNavigation!.handleNavigate("settings", { tab: SettingsTabs.About }); + }); + await act(async () => { + latestNavigation!.returnFromSettings(); + }); + expect(latestNavigation!.pageParams).toEqual( + buildClawAgentParams({ + initialSessionId: "thread", + projectId: "workspace", + contentId: "content", + }), + ); + }); + + it("快速导航后的旧位置回调和设置期间的迟到回调不能夺取返回目标", async () => { + await renderProbe(); + const stale = latestNavigation!.reportAgentLocation; + await act(async () => { + latestNavigation!.handleNavigate( + "agent", + buildClawAgentParams({ + initialSessionId: "new-thread", + projectId: "new-workspace", + }), + ); + }); + await act(async () => { + latestNavigation!.reportAgentLocation({ + sessionId: "new-thread", + workspaceId: "visible-workspace", + }); + stale({ sessionId: "old-thread", workspaceId: "old-workspace" }); + }); + const departing = latestNavigation!.reportAgentLocation; + await act(async () => { + latestNavigation!.handleNavigate("settings", { + tab: SettingsTabs.Memory, + }); + departing({ sessionId: "late-thread", workspaceId: "late-workspace" }); + }); + await act(async () => { + latestNavigation!.returnFromSettings(); + }); + expect(latestNavigation!.pageParams).toMatchObject({ + initialSessionId: "new-thread", + projectId: "visible-workspace", + }); + }); + + it("同批未提交导航不替换实际来处,重复报告也不触发重渲染", async () => { + await renderProbe(); + const location = { + sessionId: "visible-thread", + workspaceId: "visible-workspace", + }; + await act(async () => { + latestNavigation!.reportAgentLocation(location); + }); + const calls = readyCallCount; + await act(async () => { + latestNavigation!.reportAgentLocation({ ...location }); + }); + expect(readyCallCount).toBe(calls); + await act(async () => { + latestNavigation!.handleNavigate("plugins", { query: "not-yet-visible" }); + latestNavigation!.handleNavigate("settings"); + }); + await act(async () => { + latestNavigation!.returnFromSettings(); + }); + expect(latestNavigation!.pageParams).toMatchObject({ + initialSessionId: "visible-thread", + projectId: "visible-workspace", + }); + }); + + it("无会话来处返回首页,下一次从其它页面进入设置更新来处", async () => { + await renderProbe(); + await act(async () => { + latestNavigation!.handleNavigate("settings", { tab: SettingsTabs.About }); + }); + await act(async () => { + latestNavigation!.returnFromSettings(); + }); + expect(latestNavigation!.pageParams).toMatchObject({ + agentEntry: "new-task", + }); + expect(latestNavigation!.pageParams).not.toHaveProperty("initialSessionId"); + await act(async () => { + latestNavigation!.handleNavigate("plugins", { + query: "retained", + currentProjectId: "project", + }); + }); + await act(async () => { + latestNavigation!.handleNavigate("settings"); + }); + await act(async () => { + latestNavigation!.returnFromSettings(); + }); + expect(latestNavigation!.currentPage).toBe("plugins"); + expect(latestNavigation!.pageParams).toEqual({ + query: "retained", + currentProjectId: "project", + }); + }); }); diff --git a/src/hooks/useAppNavigation.ts b/src/hooks/useAppNavigation.ts index 4a3b5c42d..fd3ce8314 100644 --- a/src/hooks/useAppNavigation.ts +++ b/src/hooks/useAppNavigation.ts @@ -1,13 +1,20 @@ -import { useCallback, useLayoutEffect, useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { buildClawAgentParams, buildHomeAgentParams, + type StartNewConversation, } from "@/lib/workspace/navigation"; -import type { AgentPageParams, Page, PageParams } from "@/types/page"; +import type { + AgentLocation, + AgentPageParams, + Page, + PageParams, +} from "@/types/page"; const NAVIGATION_RESTORE_STORAGE_KEY = "lime.appNavigation.restore.v1"; interface UseAppNavigationResult { + startNewConversation: StartNewConversation; currentPage: Page; pageParams: PageParams; requestedPage: Page; @@ -15,6 +22,27 @@ interface UseAppNavigationResult { navigationRequestId: number; isNavigating: boolean; handleNavigate: (page: Page, params?: PageParams) => void; + returnFromSettings: () => void; + agentLocation: AgentLocation | null; + reportAgentLocation: (location: AgentLocation) => void; +} + +interface NavigationTarget { + page: Page; + params: PageParams; +} + +interface NavigationState { + currentPage: Page; + currentPageParams: PageParams; + currentPageParamsKey: string; + currentRequestId: number; + requestedPage: Page; + requestedPageParams: PageParams; + requestedPageParamsKey: string; + navigationRequestId: number; + agentLocation: { requestId: number; value: AgentLocation } | null; + settingsReturn: NavigationTarget | null; } function normalizePageParams(params?: PageParams): PageParams { @@ -196,71 +224,165 @@ function persistRestorableNavigation(page: Page, params: PageParams): void { } } -export function useAppNavigation(): UseAppNavigationResult { - const [navigationState, setNavigationState] = useState<{ - currentPage: Page; - currentPageParams: PageParams; - currentPageParamsKey: string; - requestedPage: Page; - requestedPageParams: PageParams; - requestedPageParamsKey: string; - navigationRequestId: number; - }>(() => { - const restored = readRestoredNavigationState(); - if (restored) { - const restoredPageParamsKey = serializePageParams(restored.params); - return { - currentPage: restored.page, - currentPageParams: restored.params, - currentPageParamsKey: restoredPageParamsKey, - requestedPage: restored.page, - requestedPageParams: restored.params, - requestedPageParamsKey: restoredPageParamsKey, - navigationRequestId: 0, - }; - } +function settingsReturnTarget(current: NavigationState): NavigationTarget { + if (current.currentPage !== "agent") { + return { page: current.currentPage, params: current.currentPageParams }; + } - const initialPageParams = buildHomeAgentParams(); - const initialPageParamsKey = serializePageParams(initialPageParams); - return { - currentPage: "agent", - currentPageParams: initialPageParams, - currentPageParamsKey: initialPageParamsKey, - requestedPage: "agent", - requestedPageParams: initialPageParams, - requestedPageParamsKey: initialPageParamsKey, - navigationRequestId: 0, - }; + const params = current.currentPageParams as AgentPageParams; + const location = + current.agentLocation?.requestId === current.currentRequestId + ? current.agentLocation.value + : null; + const restorable = pickRestorableAgentParams({ + ...params, + ...(location + ? { + initialSessionId: location.sessionId, + projectId: location.workspaceId, + } + : {}), }); + return { + page: "agent", + params: + restorable ?? + buildHomeAgentParams({ + projectId: location?.workspaceId ?? params.projectId, + }), + }; +} - const handleNavigate = useCallback((page: Page, params?: PageParams) => { - const nextPageParams = normalizePageParams(params); - const nextPageParamsKey = serializePageParams(nextPageParams); - persistRestorableNavigation(page, nextPageParams); +function requestNavigation( + current: NavigationState, + page: Page, + params?: PageParams, +): NavigationState { + const nextParams = normalizePageParams(params); + const nextKey = serializePageParams(nextParams); + if ( + current.requestedPage === page && + current.requestedPageParamsKey === nextKey + ) { + return current; + } + return { + ...current, + requestedPage: page, + requestedPageParams: nextParams, + requestedPageParamsKey: nextKey, + navigationRequestId: current.navigationRequestId + 1, + settingsReturn: + page !== "settings" + ? null + : current.requestedPage === "settings" + ? current.settingsReturn + : settingsReturnTarget(current), + }; +} - setNavigationState((current) => { - if ( - current.requestedPage === page && - current.requestedPageParamsKey === nextPageParamsKey - ) { - return current; +export function useAppNavigation(): UseAppNavigationResult { + const lastNewConversationRequest = useRef(0); + const [navigationState, setNavigationState] = useState( + () => { + const restored = readRestoredNavigationState(); + if (restored) { + const restoredPageParamsKey = serializePageParams(restored.params); + return { + currentPage: restored.page, + currentPageParams: restored.params, + currentPageParamsKey: restoredPageParamsKey, + requestedPage: restored.page, + requestedPageParams: restored.params, + requestedPageParamsKey: restoredPageParamsKey, + navigationRequestId: 0, + currentRequestId: 0, + agentLocation: null, + settingsReturn: null, + }; } + const initialPageParams = buildHomeAgentParams(); + const initialPageParamsKey = serializePageParams(initialPageParams); return { - ...current, - requestedPage: page, - requestedPageParams: nextPageParams, - requestedPageParamsKey: nextPageParamsKey, - navigationRequestId: current.navigationRequestId + 1, + currentPage: "agent", + currentPageParams: initialPageParams, + currentPageParamsKey: initialPageParamsKey, + requestedPage: "agent", + requestedPageParams: initialPageParams, + requestedPageParamsKey: initialPageParamsKey, + navigationRequestId: 0, + currentRequestId: 0, + agentLocation: null, + settingsReturn: null, }; + }, + ); + + const handleNavigate = useCallback((page: Page, params?: PageParams) => { + setNavigationState((current) => requestNavigation(current, page, params)); + }, []); + + const returnFromSettings = useCallback(() => { + setNavigationState((current) => { + if (current.requestedPage !== "settings") return current; + const target = current.settingsReturn; + return requestNavigation( + current, + target?.page ?? "agent", + target?.params ?? buildHomeAgentParams(), + ); }); }, []); + const requestId = navigationState.navigationRequestId; + const reportAgentLocation = useCallback( + (location: AgentLocation) => { + setNavigationState((current) => { + if ( + current.navigationRequestId !== requestId || + current.requestedPage !== "agent" + ) + return current; + const value = { + sessionId: readString(location.sessionId) ?? null, + workspaceId: readString(location.workspaceId) ?? null, + }; + if ( + current.agentLocation?.requestId === requestId && + current.agentLocation.value.sessionId === value.sessionId && + current.agentLocation.value.workspaceId === value.workspaceId + ) + return current; + return { ...current, agentLocation: { requestId, value } }; + }); + }, + [requestId], + ); + + const startNewConversation = useCallback( + ({ projectId, freshDraft }) => { + const id = Math.max(Date.now(), lastNewConversationRequest.current + 1); + lastNewConversationRequest.current = id; + const workspaceId = projectId?.trim() || null; + handleNavigate( + "agent", + buildHomeAgentParams({ + projectId: workspaceId ?? undefined, + newChatAt: id, + newConversationRequest: { id, workspaceId, freshDraft }, + }), + ); + }, + [handleNavigate], + ); + useLayoutEffect(() => { setNavigationState((current) => { if ( current.currentPage === current.requestedPage && - current.currentPageParamsKey === current.requestedPageParamsKey + current.currentPageParamsKey === current.requestedPageParamsKey && + current.currentRequestId === current.navigationRequestId ) { return current; } @@ -270,9 +392,21 @@ export function useAppNavigation(): UseAppNavigationResult { currentPage: current.requestedPage, currentPageParams: current.requestedPageParams, currentPageParamsKey: current.requestedPageParamsKey, + currentRequestId: current.navigationRequestId, }; }); - }, [navigationState.requestedPage, navigationState.requestedPageParamsKey]); + }, [ + navigationState.requestedPage, + navigationState.requestedPageParamsKey, + requestId, + ]); + + useLayoutEffect(() => { + persistRestorableNavigation( + navigationState.requestedPage, + navigationState.requestedPageParams, + ); + }, [navigationState.requestedPage, navigationState.requestedPageParams]); const isNavigating = navigationState.currentPage !== navigationState.requestedPage || @@ -287,5 +421,12 @@ export function useAppNavigation(): UseAppNavigationResult { navigationRequestId: navigationState.navigationRequestId, isNavigating, handleNavigate, + startNewConversation, + returnFromSettings, + reportAgentLocation, + agentLocation: + navigationState.agentLocation?.requestId === requestId + ? navigationState.agentLocation.value + : null, }; } diff --git a/src/i18n/__tests__/loadNamespace.test.ts b/src/i18n/__tests__/loadNamespace.test.ts index 5af5d7455..32aebfd9a 100644 --- a/src/i18n/__tests__/loadNamespace.test.ts +++ b/src/i18n/__tests__/loadNamespace.test.ts @@ -933,7 +933,7 @@ describe("i18n namespace loader", () => { "settings.layout.sidebar.experimentalBadge", ); expect(resources[locale].settings).toHaveProperty( - "settings.layout.action.backHome", + "settings.layout.action.back", ); expect(resources[locale].settings).toHaveProperty( "settings.layout.loading.profile", @@ -990,7 +990,7 @@ describe("i18n namespace loader", () => { "settings.profile.usage.tipTags", ); expect(resources[locale].settings).toHaveProperty( - "settings.layout.placeholder.notFound", + "settings.layout.loading.skills", ); expect(resources[locale].settings).toHaveProperty( "settings.layout.sidebar.floatingNav.openAria", diff --git a/src/i18n/__tests__/types.test.ts b/src/i18n/__tests__/types.test.ts index 47b874ab0..93bf38cc2 100644 --- a/src/i18n/__tests__/types.test.ts +++ b/src/i18n/__tests__/types.test.ts @@ -874,7 +874,7 @@ function assertI18nKeyTypes() { mimeType: "application/octet-stream", }); i18n.t("settings.layout.sidebar.experimentalBadge", { ns: "settings" }); - i18n.t("settings.layout.action.backHome", { ns: "settings" }); + i18n.t("settings.layout.action.back", { ns: "settings" }); i18n.t("settings.layout.loading.profile", { ns: "settings" }); i18n.t("settings.profile.field.nickname.label", { ns: "settings" }); i18n.t("settings.profile.field.tipAria", { @@ -921,7 +921,7 @@ function assertI18nKeyTypes() { tag: "Programming", }); i18n.t("settings.profile.usage.tipTags", { ns: "settings" }); - i18n.t("settings.layout.placeholder.notFound", { ns: "settings" }); + i18n.t("settings.layout.loading.skills", { ns: "settings" }); i18n.t("settings.layout.sidebar.floatingNav.openAria", { ns: "settings" }); i18n.t("settings.about.version.label", { ns: "settings", diff --git a/src/i18n/agentChatCopy.ts b/src/i18n/agentChatCopy.ts index 44ec01c62..58fd1f25b 100644 --- a/src/i18n/agentChatCopy.ts +++ b/src/i18n/agentChatCopy.ts @@ -1,6 +1,10 @@ import i18n from "i18next"; -import { loadNamespaceResource } from "./loadNamespace"; -import { FALLBACK_LOCALE, normalizeLocale } from "./locales"; +import { limeI18nResources } from "./createI18n"; +import { + FALLBACK_LOCALE, + normalizeLocale, + type SupportedLocale, +} from "./locales"; function interpolateCopy( text: string, @@ -13,7 +17,7 @@ function interpolateCopy( ); } -function resolveCurrentLocale(): string { +function resolveCurrentLocale(): SupportedLocale { const documentLocale = typeof document !== "undefined" ? document.documentElement.lang : ""; return normalizeLocale( @@ -46,7 +50,7 @@ export function resolveAgentChatCopy( ): string { const fullKey = `agentChat.${key}`; const locale = resolveCurrentLocale(); - const localeResource = loadNamespaceResource(locale, "agent"); + const localeResource = limeI18nResources[locale].agent; const translated = localeResource[fullKey as keyof typeof localeResource]; const text = normalizeTranslatedCopy(translated, fullKey) ?? defaultValue; return interpolateCopy(text, values); @@ -58,11 +62,11 @@ export function resolveRequiredAgentChatCopy( ): string { const fullKey = `agentChat.${key}`; const locale = resolveCurrentLocale(); - const localeResource = loadNamespaceResource(locale, "agent"); + const localeResource = limeI18nResources[locale].agent; const sourceResource = locale === FALLBACK_LOCALE ? localeResource - : loadNamespaceResource(FALLBACK_LOCALE, "agent"); + : limeI18nResources[FALLBACK_LOCALE].agent; const resourceFallback = localeResource[fullKey] ?? sourceResource[fullKey] ?? fullKey; const translated = localeResource[fullKey] ?? resourceFallback; diff --git a/src/i18n/resources/en-US/agentInputbar.json b/src/i18n/resources/en-US/agentInputbar.json index b0521cc08..542724442 100644 --- a/src/i18n/resources/en-US/agentInputbar.json +++ b/src/i18n/resources/en-US/agentInputbar.json @@ -246,5 +246,23 @@ "agentChat.inputbar.approval.expand": "Expand", "agentChat.inputbar.approval.collapse": "Collapse", "agentChat.inputbar.approval.submissionError": "Could not submit. Try again.", - "agentChat.inputbar.approval.scopeOptions": "Choose approval scope" + "agentChat.inputbar.approval.scopeOptions": "Choose approval scope", + "composer.codeBlock.language.none": "Plain text", + "composer.codeBlock.language.auto": "Auto detect", + "composer.codeBlock.languagePickerLabel": "Code language: {{language}}", + "composer.codeBlock.autoLanguagePickerLabel": "Auto detect code language", + "composer.codeBlock.autoPlainTextLanguage": "Auto (Plain text)", + "composer.codeBlock.autoPlainTextLanguagePickerLabel": "Auto detected code language: Plain text", + "composer.codeBlock.autoDetectedLanguage": "Auto ({{language}})", + "composer.codeBlock.autoDetectedLanguagePickerLabel": "Auto detected code language: {{language}}", + "composer.codeBlock.searchLanguages": "Search languages", + "composer.codeBlock.noLanguagesFound": "No languages found", + "composer.codeBlock.language.math": "Math", + "composer.codeBlock.language.plaintext": "Plain text", + "agentChat.inputbar.draftPersistence.restoring": "Restoring draft…", + "agentChat.inputbar.draftPersistence.conflict": "The draft changed. Your current input is preserved.", + "agentChat.inputbar.draftPersistence.error": "Unable to save or restore the draft. Your input is preserved.", + "agentChat.inputbar.draftPersistence.unavailable": "Draft saving is unavailable. Your current input is preserved.", + "agentChat.inputbar.draftPersistence.saving": "Saving draft…", + "agentChat.inputbar.draftPersistence.saved": "Draft saved" } diff --git a/src/i18n/resources/en-US/settings.json b/src/i18n/resources/en-US/settings.json index c9610a0e1..83b9b0de3 100644 --- a/src/i18n/resources/en-US/settings.json +++ b/src/i18n/resources/en-US/settings.json @@ -311,7 +311,7 @@ "settings.userCenterSession.form.optional.placeholder": "Optional", "settings.userCenterSession.form.username.label": "First sign-in account", "settings.userCenterSession.login.emailCode.submit": "Verify and sync account", - "settings.layout.action.backHome": "Back Home", + "settings.layout.action.back": "Back", "settings.layout.loading.profile": "Loading account profile...", "settings.layout.loading.stats": "Loading usage stats...", "settings.layout.loading.appearance": "Loading appearance settings...", @@ -325,7 +325,6 @@ "settings.layout.loading.executionPolicy": "Loading execution policy...", "settings.layout.loading.developerLab": "Loading developer and labs...", "settings.layout.loading.about": "Loading about page...", - "settings.layout.placeholder.notFound": "Page not found", "settings.developerLab.title": "Developer & Labs", "settings.developerLab.description": "Keep debugging, diagnostics, and unstable capabilities together; leave them off by default, then turn them off again after use.", "settings.developerLab.tabs.developer": "Developer Tools", diff --git a/src/i18n/resources/ja-JP/agentInputbar.json b/src/i18n/resources/ja-JP/agentInputbar.json index 78e596d8e..40fb6a399 100644 --- a/src/i18n/resources/ja-JP/agentInputbar.json +++ b/src/i18n/resources/ja-JP/agentInputbar.json @@ -246,5 +246,23 @@ "agentChat.inputbar.approval.expand": "展開", "agentChat.inputbar.approval.collapse": "折りたたむ", "agentChat.inputbar.approval.submissionError": "送信できませんでした。もう一度お試しください。", - "agentChat.inputbar.approval.scopeOptions": "許可範囲を選択" + "agentChat.inputbar.approval.scopeOptions": "許可範囲を選択", + "composer.codeBlock.language.none": "プレーンテキスト", + "composer.codeBlock.language.auto": "自動検出", + "composer.codeBlock.languagePickerLabel": "コードの言語: {{language}}", + "composer.codeBlock.autoLanguagePickerLabel": "コードの言語を自動検出", + "composer.codeBlock.autoPlainTextLanguage": "自動(プレーンテキスト)", + "composer.codeBlock.autoPlainTextLanguagePickerLabel": "自動検出したコードの言語: プレーンテキスト", + "composer.codeBlock.autoDetectedLanguage": "自動({{language}})", + "composer.codeBlock.autoDetectedLanguagePickerLabel": "自動検出したコードの言語: {{language}}", + "composer.codeBlock.searchLanguages": "言語を検索", + "composer.codeBlock.noLanguagesFound": "言語が見つかりません", + "composer.codeBlock.language.math": "数式", + "composer.codeBlock.language.plaintext": "プレーンテキスト", + "agentChat.inputbar.draftPersistence.restoring": "下書きを復元中…", + "agentChat.inputbar.draftPersistence.conflict": "下書きが更新されています。現在の入力は保持されています。", + "agentChat.inputbar.draftPersistence.error": "下書きを保存または復元できません。入力は保持されています。", + "agentChat.inputbar.draftPersistence.unavailable": "下書きの保存を利用できません。現在の入力は保持されています。", + "agentChat.inputbar.draftPersistence.saving": "下書きを保存中…", + "agentChat.inputbar.draftPersistence.saved": "下書きを保存しました" } diff --git a/src/i18n/resources/ja-JP/settings.json b/src/i18n/resources/ja-JP/settings.json index c183baa1e..b6736f8d9 100644 --- a/src/i18n/resources/ja-JP/settings.json +++ b/src/i18n/resources/ja-JP/settings.json @@ -311,7 +311,7 @@ "settings.userCenterSession.form.optional.placeholder": "任意", "settings.userCenterSession.form.username.label": "初回ログイン時のアカウント", "settings.userCenterSession.login.emailCode.submit": "確認してアカウントを同期", - "settings.layout.action.backHome": "ホームへ戻る", + "settings.layout.action.back": "戻る", "settings.layout.loading.profile": "アカウントプロフィールを読み込み中...", "settings.layout.loading.stats": "利用統計を読み込み中...", "settings.layout.loading.appearance": "外観設定を読み込み中...", @@ -325,7 +325,6 @@ "settings.layout.loading.executionPolicy": "実行ポリシーを読み込み中...", "settings.layout.loading.developerLab": "開発者と実験機能を読み込み中...", "settings.layout.loading.about": "概要ページを読み込み中...", - "settings.layout.placeholder.notFound": "ページが見つかりません", "settings.developerLab.title": "開発者と実験機能", "settings.developerLab.description": "デバッグ、診断、不安定な機能を一か所にまとめます。既定ではオフにし、使い終えたら再度オフに戻します。", "settings.developerLab.tabs.developer": "開発者ツール", diff --git a/src/i18n/resources/ko-KR/agentInputbar.json b/src/i18n/resources/ko-KR/agentInputbar.json index b3d44b716..1521cbe6c 100644 --- a/src/i18n/resources/ko-KR/agentInputbar.json +++ b/src/i18n/resources/ko-KR/agentInputbar.json @@ -246,5 +246,23 @@ "agentChat.inputbar.approval.expand": "펼치기", "agentChat.inputbar.approval.collapse": "접기", "agentChat.inputbar.approval.submissionError": "제출하지 못했습니다. 다시 시도하세요.", - "agentChat.inputbar.approval.scopeOptions": "허용 범위 선택" + "agentChat.inputbar.approval.scopeOptions": "허용 범위 선택", + "composer.codeBlock.language.none": "일반 텍스트", + "composer.codeBlock.language.auto": "자동 감지", + "composer.codeBlock.languagePickerLabel": "코드 언어: {{language}}", + "composer.codeBlock.autoLanguagePickerLabel": "코드 언어 자동 감지", + "composer.codeBlock.autoPlainTextLanguage": "자동(일반 텍스트)", + "composer.codeBlock.autoPlainTextLanguagePickerLabel": "자동 감지된 코드 언어: 일반 텍스트", + "composer.codeBlock.autoDetectedLanguage": "자동({{language}})", + "composer.codeBlock.autoDetectedLanguagePickerLabel": "자동 감지된 코드 언어: {{language}}", + "composer.codeBlock.searchLanguages": "언어 검색", + "composer.codeBlock.noLanguagesFound": "언어를 찾을 수 없습니다", + "composer.codeBlock.language.math": "수식", + "composer.codeBlock.language.plaintext": "일반 텍스트", + "agentChat.inputbar.draftPersistence.restoring": "초안 복원 중…", + "agentChat.inputbar.draftPersistence.conflict": "초안이 변경되었습니다. 현재 입력은 유지됩니다.", + "agentChat.inputbar.draftPersistence.error": "초안을 저장하거나 복원할 수 없습니다. 입력은 유지됩니다.", + "agentChat.inputbar.draftPersistence.unavailable": "초안 저장을 사용할 수 없습니다. 현재 입력은 유지됩니다.", + "agentChat.inputbar.draftPersistence.saving": "초안 저장 중…", + "agentChat.inputbar.draftPersistence.saved": "초안 저장됨" } diff --git a/src/i18n/resources/ko-KR/settings.json b/src/i18n/resources/ko-KR/settings.json index 370d893f1..35d2092d6 100644 --- a/src/i18n/resources/ko-KR/settings.json +++ b/src/i18n/resources/ko-KR/settings.json @@ -311,7 +311,7 @@ "settings.userCenterSession.form.optional.placeholder": "선택 사항", "settings.userCenterSession.form.username.label": "첫 로그인 계정", "settings.userCenterSession.login.emailCode.submit": "인증하고 계정 동기화", - "settings.layout.action.backHome": "홈으로 돌아가기", + "settings.layout.action.back": "뒤로", "settings.layout.loading.profile": "계정 프로필을 불러오는 중...", "settings.layout.loading.stats": "사용 통계를 불러오는 중...", "settings.layout.loading.appearance": "외관 설정을 불러오는 중...", @@ -325,7 +325,6 @@ "settings.layout.loading.executionPolicy": "실행 정책을 불러오는 중...", "settings.layout.loading.developerLab": "개발자 및 실험 기능을 불러오는 중...", "settings.layout.loading.about": "정보 페이지를 불러오는 중...", - "settings.layout.placeholder.notFound": "페이지를 찾을 수 없습니다", "settings.developerLab.title": "개발자 및 실험 기능", "settings.developerLab.description": "디버깅, 진단, 불안정한 기능을 한곳에 모읍니다. 기본적으로 꺼 두고 사용 후 다시 끕니다.", "settings.developerLab.tabs.developer": "개발자 도구", diff --git a/src/i18n/resources/zh-CN/agentInputbar.json b/src/i18n/resources/zh-CN/agentInputbar.json index e5cad729b..3bf8c14fc 100644 --- a/src/i18n/resources/zh-CN/agentInputbar.json +++ b/src/i18n/resources/zh-CN/agentInputbar.json @@ -246,5 +246,23 @@ "agentChat.inputbar.approval.expand": "展开", "agentChat.inputbar.approval.collapse": "收起", "agentChat.inputbar.approval.submissionError": "提交失败,请重试。", - "agentChat.inputbar.approval.scopeOptions": "选择允许范围" + "agentChat.inputbar.approval.scopeOptions": "选择允许范围", + "composer.codeBlock.language.none": "纯文本", + "composer.codeBlock.language.auto": "自动检测", + "composer.codeBlock.languagePickerLabel": "代码语言:{{language}}", + "composer.codeBlock.autoLanguagePickerLabel": "自动检测代码语言", + "composer.codeBlock.autoPlainTextLanguage": "自动(纯文本)", + "composer.codeBlock.autoPlainTextLanguagePickerLabel": "自动检测的代码语言:纯文本", + "composer.codeBlock.autoDetectedLanguage": "自动({{language}})", + "composer.codeBlock.autoDetectedLanguagePickerLabel": "自动检测的代码语言:{{language}}", + "composer.codeBlock.searchLanguages": "搜索语言", + "composer.codeBlock.noLanguagesFound": "未找到语言", + "composer.codeBlock.language.math": "数学公式", + "composer.codeBlock.language.plaintext": "纯文本", + "agentChat.inputbar.draftPersistence.restoring": "正在恢复草稿…", + "agentChat.inputbar.draftPersistence.conflict": "草稿存在更新,已保留当前输入", + "agentChat.inputbar.draftPersistence.error": "草稿暂时无法保存或恢复,已保留当前输入", + "agentChat.inputbar.draftPersistence.unavailable": "草稿保存暂不可用,当前输入仍在", + "agentChat.inputbar.draftPersistence.saving": "正在保存草稿…", + "agentChat.inputbar.draftPersistence.saved": "草稿已保存" } diff --git a/src/i18n/resources/zh-CN/settings.json b/src/i18n/resources/zh-CN/settings.json index 725a0a8ae..3490cf7b4 100644 --- a/src/i18n/resources/zh-CN/settings.json +++ b/src/i18n/resources/zh-CN/settings.json @@ -311,7 +311,7 @@ "settings.userCenterSession.form.optional.placeholder": "选填", "settings.userCenterSession.form.username.label": "首次登录账号", "settings.userCenterSession.login.emailCode.submit": "验证并同步账户", - "settings.layout.action.backHome": "回到首页", + "settings.layout.action.back": "返回", "settings.layout.loading.profile": "正在加载账号资料...", "settings.layout.loading.stats": "正在加载数据统计...", "settings.layout.loading.appearance": "正在加载外观设置...", @@ -325,7 +325,6 @@ "settings.layout.loading.executionPolicy": "正在加载执行策略...", "settings.layout.loading.developerLab": "正在加载开发者与实验功能...", "settings.layout.loading.about": "正在加载关于页面...", - "settings.layout.placeholder.notFound": "页面不存在", "settings.developerLab.title": "开发者与实验功能", "settings.developerLab.description": "调试、诊断和不稳定能力集中在同一处;默认关闭,用完关回。", "settings.developerLab.tabs.developer": "开发者工具", diff --git a/src/i18n/resources/zh-TW/agentInputbar.json b/src/i18n/resources/zh-TW/agentInputbar.json index 71c630d4a..101f81632 100644 --- a/src/i18n/resources/zh-TW/agentInputbar.json +++ b/src/i18n/resources/zh-TW/agentInputbar.json @@ -246,5 +246,23 @@ "agentChat.inputbar.approval.expand": "展開", "agentChat.inputbar.approval.collapse": "收合", "agentChat.inputbar.approval.submissionError": "提交失敗,請重試。", - "agentChat.inputbar.approval.scopeOptions": "選擇允許範圍" + "agentChat.inputbar.approval.scopeOptions": "選擇允許範圍", + "composer.codeBlock.language.none": "純文字", + "composer.codeBlock.language.auto": "自動偵測", + "composer.codeBlock.languagePickerLabel": "程式碼語言:{{language}}", + "composer.codeBlock.autoLanguagePickerLabel": "自動偵測程式碼語言", + "composer.codeBlock.autoPlainTextLanguage": "自動(純文字)", + "composer.codeBlock.autoPlainTextLanguagePickerLabel": "自動偵測的程式碼語言:純文字", + "composer.codeBlock.autoDetectedLanguage": "自動({{language}})", + "composer.codeBlock.autoDetectedLanguagePickerLabel": "自動偵測的程式碼語言:{{language}}", + "composer.codeBlock.searchLanguages": "搜尋語言", + "composer.codeBlock.noLanguagesFound": "找不到語言", + "composer.codeBlock.language.math": "數學公式", + "composer.codeBlock.language.plaintext": "純文字", + "agentChat.inputbar.draftPersistence.restoring": "正在還原草稿…", + "agentChat.inputbar.draftPersistence.conflict": "草稿有更新,已保留目前輸入", + "agentChat.inputbar.draftPersistence.error": "暫時無法儲存或還原草稿,已保留目前輸入", + "agentChat.inputbar.draftPersistence.unavailable": "草稿儲存暫時無法使用,目前輸入仍在", + "agentChat.inputbar.draftPersistence.saving": "正在儲存草稿…", + "agentChat.inputbar.draftPersistence.saved": "草稿已儲存" } diff --git a/src/i18n/resources/zh-TW/settings.json b/src/i18n/resources/zh-TW/settings.json index a26c8f8bd..892959b70 100644 --- a/src/i18n/resources/zh-TW/settings.json +++ b/src/i18n/resources/zh-TW/settings.json @@ -311,7 +311,7 @@ "settings.userCenterSession.form.optional.placeholder": "選填", "settings.userCenterSession.form.username.label": "首次登入帳號", "settings.userCenterSession.login.emailCode.submit": "驗證並同步帳戶", - "settings.layout.action.backHome": "回到首頁", + "settings.layout.action.back": "返回", "settings.layout.loading.profile": "正在載入帳號資料...", "settings.layout.loading.stats": "正在載入資料統計...", "settings.layout.loading.appearance": "正在載入外觀設定...", @@ -325,7 +325,6 @@ "settings.layout.loading.executionPolicy": "正在載入執行策略...", "settings.layout.loading.developerLab": "正在載入開發者與實驗功能...", "settings.layout.loading.about": "正在載入關於頁面...", - "settings.layout.placeholder.notFound": "頁面不存在", "settings.developerLab.title": "開發者與實驗功能", "settings.developerLab.description": "調試、診斷和不穩定能力集中在同一處;預設關閉,用完再關回。", "settings.developerLab.tabs.developer": "開發者工具", diff --git a/src/lib/api/agentRuntime/clientFactory.d.ts b/src/lib/api/agentRuntime/clientFactory.d.ts index d8e3c3e02..9b30242a4 100644 --- a/src/lib/api/agentRuntime/clientFactory.d.ts +++ b/src/lib/api/agentRuntime/clientFactory.d.ts @@ -68,7 +68,16 @@ export declare function createAgentRuntimeClient({ >; submitAgentRuntimeTurn: ( request: import("@limecloud/app-server-client").TurnStartParams, - ) => Promise; + onAccepted?: ( + response: import("@limecloud/app-server-client").AppServerRequestResult< + import("@limecloud/app-server-client").TurnStartResponse + >, + ) => void, + ) => Promise< + import("@limecloud/app-server-client").AppServerRequestResult< + import("@limecloud/app-server-client").TurnStartResponse + > + >; submitAgentRuntimeReview: ( request: import("../appServer").AppServerReviewStartParams, ) => Promise< diff --git a/src/lib/api/agentRuntime/clientFactory.test.ts b/src/lib/api/agentRuntime/clientFactory.test.ts index 8ebadb7af..40380d77a 100644 --- a/src/lib/api/agentRuntime/clientFactory.test.ts +++ b/src/lib/api/agentRuntime/clientFactory.test.ts @@ -5,6 +5,14 @@ import { } from "./clientFactory"; import type { AgentRuntimeLifecycleClient } from "./threadClient"; +const acceptedTurnResult = { + id: 1, + result: { turn: { id: "turn-1", status: "inProgress" } }, + response: { id: 1, result: {} }, + messages: [], + notifications: [], +}; + const appServerCheckpointSummary = { checkpointId: "checkpoint-1", turnId: "turn-1", @@ -220,7 +228,7 @@ function appServerClientMock(): AgentRuntimeAppServerClient { messages: [], notifications: [], }), - startTurn: vi.fn().mockResolvedValue({}), + startTurn: vi.fn().mockResolvedValue(acceptedTurnResult), cancelTurn: vi.fn().mockResolvedValue({}), startThreadCompaction: vi.fn().mockResolvedValue({ id: 1, @@ -532,20 +540,7 @@ function appServerClientMock(): AgentRuntimeAppServerClient { function standardRuntimeClientMock(): AgentRuntimeLifecycleClient { return { - startTurn: vi.fn().mockResolvedValue({ - id: 1, - result: { - turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", - }, - }, - response: { id: 1, result: {} }, - messages: [], - notifications: [], - }), + startTurn: vi.fn().mockResolvedValue(acceptedTurnResult), cancelTurn: vi.fn().mockResolvedValue({ id: 2, result: {}, @@ -755,11 +750,13 @@ describe("agentRuntime clientFactory", () => { isAppServerTurnLifecycleAvailable: () => true, }); - await client.submitAgentRuntimeTurn({ - threadId: "thread-1", - input: [{ type: "text", text: "继续" }], - additionalContext: { workspaceId: "workspace-1" }, - }); + await expect( + client.submitAgentRuntimeTurn({ + threadId: "thread-1", + input: [{ type: "text", text: "继续" }], + additionalContext: { workspaceId: "workspace-1" }, + }), + ).resolves.toBe(acceptedTurnResult); expect(appServerClient.startTurn).toHaveBeenCalledWith({ threadId: "thread-1", @@ -785,7 +782,7 @@ describe("agentRuntime clientFactory", () => { threadId: "thread-1", input: [{ type: "text", text: "继续" }], }), - ).resolves.toBeUndefined(); + ).resolves.toBe(acceptedTurnResult); await expect( client.getAgentRuntimeThreadRead("session-1"), ).resolves.toMatchObject({ diff --git a/src/lib/api/agentRuntime/runtimeAcceptance.test.ts b/src/lib/api/agentRuntime/runtimeAcceptance.test.ts new file mode 100644 index 000000000..7f4c733a5 --- /dev/null +++ b/src/lib/api/agentRuntime/runtimeAcceptance.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import type { createThreadClient as SourceThreadClient } from "./threadClient"; +import type { createThreadClient as DeclaredThreadClient } from "./threadClient.d"; +import type { createAgentRuntimeClient as SourceRuntimeClient } from "./clientFactory"; +import type { createAgentRuntimeClient as DeclaredRuntimeClient } from "./clientFactory.d"; +import type { useAgentStream } from "@/components/agent/chat/hooks/useAgentStream"; +import { + createTurnStartAcceptance, + createTurnSteerAcceptance, + requireRuntimeAcceptanceReceipt, + runRuntimeAcceptanceEffect, + type RuntimeAcceptanceReceipt, +} from "./runtimeAcceptance"; +const { logMock } = vi.hoisted(() => ({ logMock: vi.fn() })); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: logMock })); +afterEach(() => vi.clearAllMocks()); +const context = { + sessionId: "session-captured", + threadId: "thread-canonical", + clientUserMessageId: "client-input", +}; +describe("runtime acceptance receipt", () => { + it("captures real start identity, keeps session distinct from thread and is immutable", () => { + const receipt = createTurnStartAcceptance(context, { + turn: { id: "turn-start", status: "inProgress" }, + }); + expect(receipt).toEqual({ + ...context, + kind: "start", + turnId: "turn-start", + }); + expect(Object.isFrozen(receipt)).toBe(true); + expect(() => requireRuntimeAcceptanceReceipt(receipt)).not.toThrow(); + expectTypeOf< + ReturnType["submitAgentRuntimeTurn"] + >().toEqualTypeOf< + ReturnType["submitAgentRuntimeTurn"] + >(); + expectTypeOf< + ReturnType["submitAgentRuntimeTurn"] + >().toEqualTypeOf< + ReturnType["submitAgentRuntimeTurn"] + >(); + expectTypeOf< + Awaited["sendMessage"]>> + >().toEqualTypeOf(); + }); + it.each([ + undefined, + null, + true, + false, + {}, + { turn: { turnId: "old", status: "accepted" } }, + { turn: { id: "", status: "inProgress" } }, + { turn: { id: "unknown", status: "dispatched" } }, + ])("does not infer acceptance from %j", (response) => { + expect(() => + createTurnStartAcceptance(context, response as never), + ).toThrow(); + }); + it("validates steer response and never creates a receipt for a mismatch", () => { + expect( + createTurnSteerAcceptance(context, "turn-active", { + turnId: "turn-active", + }), + ).toEqual({ ...context, kind: "steer", turnId: "turn-active" }); + expect(() => + createTurnSteerAcceptance(context, "turn-active", { turnId: "another" }), + ).toThrow("mismatched turnId"); + }); + it("reports observer failures without logging their text or changing receipt", () => { + const receipt = createTurnStartAcceptance(context, { + turn: { id: "turn-start", status: "inProgress" }, + }); + runRuntimeAcceptanceEffect(receipt, "observer", () => { + throw new Error("PRIVATE USER BODY"); + }); + expect(logMock).toHaveBeenCalledWith( + "AgentRuntime", + "acceptance.postProcessingFailed", + { + phase: "observer", + sessionId: context.sessionId, + threadId: context.threadId, + turnId: "turn-start", + }, + { level: "error" }, + ); + expect(JSON.stringify(logMock.mock.calls)).not.toContain( + "PRIVATE USER BODY", + ); + }); +}); diff --git a/src/lib/api/agentRuntime/runtimeAcceptance.ts b/src/lib/api/agentRuntime/runtimeAcceptance.ts new file mode 100644 index 000000000..624bfb1c6 --- /dev/null +++ b/src/lib/api/agentRuntime/runtimeAcceptance.ts @@ -0,0 +1,136 @@ +import type { + TurnStartResponse, + TurnSteerResponse, +} from "@limecloud/app-server-client"; +import { logAgentDebug } from "@/lib/agentDebug"; + +export interface RuntimeAcceptanceContext { + readonly sessionId: string; + readonly threadId: string; + readonly clientUserMessageId?: string; +} + +export type RuntimeAcceptanceReceipt = RuntimeAcceptanceContext & { + readonly kind: "start" | "steer"; + readonly turnId: string; +}; + +function requireId(value: unknown): string { + if (typeof value !== "string" || !value.trim() || value.trim() !== value) { + throw new Error("Invalid runtime acceptance identity"); + } + return value; +} + +export function requireTurnStartResponse(value: unknown): TurnStartResponse { + const turn = (value as TurnStartResponse | null | undefined)?.turn; + requireId(turn?.id); + if ( + !turn || + !["completed", "failed", "inProgress", "interrupted"].includes(turn.status) + ) { + throw new Error("Invalid turn/start acceptance response"); + } + return value as TurnStartResponse; +} + +export function createRuntimeAcceptanceReceipt( + context: RuntimeAcceptanceContext, + kind: RuntimeAcceptanceReceipt["kind"], + turnId: unknown, +): RuntimeAcceptanceReceipt { + return Object.freeze({ + kind, + sessionId: requireId(context.sessionId), + threadId: requireId(context.threadId), + turnId: requireId(turnId), + ...(context.clientUserMessageId === undefined + ? {} + : { + clientUserMessageId: requireId(context.clientUserMessageId), + }), + }); +} + +export function createTurnStartAcceptance( + context: RuntimeAcceptanceContext, + response: TurnStartResponse, +): RuntimeAcceptanceReceipt { + return createRuntimeAcceptanceReceipt( + context, + "start", + requireTurnStartResponse(response).turn.id, + ); +} + +export function createTurnSteerAcceptance( + context: RuntimeAcceptanceContext, + expectedTurnId: string, + response: TurnSteerResponse, +): RuntimeAcceptanceReceipt { + if (requireId(response?.turnId) !== requireId(expectedTurnId)) { + throw new Error("turn/steer returned mismatched turnId"); + } + return createRuntimeAcceptanceReceipt(context, "steer", expectedTurnId); +} + +export function requireRuntimeAcceptanceReceipt( + value: RuntimeAcceptanceReceipt, +): RuntimeAcceptanceReceipt { + if (!value || (value.kind !== "start" && value.kind !== "steer")) { + throw new Error("Invalid runtime acceptance receipt"); + } + return createRuntimeAcceptanceReceipt(value, value.kind, value.turnId); +} + +export type RuntimeAcceptancePostProcessingPhase = + | "start_callback" + | "start_notifications" + | "observer" + | "accepted_time" + | "accepted_submit" + | "accepted_metric" + | "accepted_log" + | "terminal_recovery_poll" + | "steer_read_model" + | "steer_projection"; + +export function reportRuntimeAcceptancePostProcessingFailure( + phase: RuntimeAcceptancePostProcessingPhase, + context: { + readonly threadId: string; + readonly sessionId?: string; + readonly turnId?: string; + }, +): void { + try { + // 只记录请求 identity 和固定调用点,不回显异常正文或用户输入。 + logAgentDebug( + "AgentRuntime", + "acceptance.postProcessingFailed", + { + phase, + threadId: context.threadId, + ...(context.sessionId ? { sessionId: context.sessionId } : {}), + ...(context.turnId ? { turnId: context.turnId } : {}), + }, + { level: "error" }, + ); + } catch { + console.error( + "[AgentRuntime] acceptance post-processing diagnostic failed", + ); + } +} + +export function runRuntimeAcceptanceEffect( + receipt: RuntimeAcceptanceReceipt, + phase: RuntimeAcceptancePostProcessingPhase, + effect: () => void, +): void { + try { + effect(); + } catch { + reportRuntimeAcceptancePostProcessingFailure(phase, receipt); + } +} diff --git a/src/lib/api/agentRuntime/threadClient.acceptance.test.ts b/src/lib/api/agentRuntime/threadClient.acceptance.test.ts new file mode 100644 index 000000000..7fe7bce0b --- /dev/null +++ b/src/lib/api/agentRuntime/threadClient.acceptance.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + AppServerRequestResult, + TurnStartResponse, +} from "@limecloud/app-server-client"; +import { + createThreadClient, + type AgentRuntimeAppServerClient, + type AgentRuntimeLifecycleClient, +} from "./threadClient"; +const { publishMock, rejectMock, logMock } = vi.hoisted(() => ({ + publishMock: vi.fn(), + rejectMock: vi.fn(), + logMock: vi.fn(), +})); +vi.mock("@/lib/agentDebug", () => ({ logAgentDebug: logMock })); +vi.mock("./appServerEventStream", () => ({ + AppServerAgentSessionEventDrainRouter: class { + register() { + return { publish: publishMock }; + } + }, + publishAppServerAgentSessionNotifications: publishMock, + publishAppServerRpcErrorNotifications: rejectMock, + projectAppServerAgentEventPayload: vi.fn(), +})); +afterEach(() => vi.resetAllMocks()); +function result(): AppServerRequestResult { + const body: TurnStartResponse = { + turn: { id: "turn-real", status: "inProgress" }, + }; + return { + id: 1, + result: body, + response: { id: 1, result: body }, + notifications: [], + messages: [], + }; +} +function setup() { + const response = result(); + const startTurn = vi.fn().mockResolvedValue(response); + const client = createThreadClient({ + appServerClient: {} as AgentRuntimeAppServerClient, + standardRuntimeClient: { + startTurn, + } as unknown as AgentRuntimeLifecycleClient, + isAppServerTurnLifecycleAvailable: () => true, + }); + return { response, startTurn, client }; +} +const request = { + threadId: "thread-canonical", + input: [{ type: "text" as const, text: "private draft" }], +}; +describe("turn/start acceptance before notifications", () => { + it("signals the real response before publish and returns the same result", async () => { + const { client, response } = setup(); + const order: string[] = []; + publishMock.mockImplementation(() => order.push("publish")); + const accepted = vi.fn((actual) => { + expect(actual).toBe(response); + order.push("accepted"); + }); + await expect( + client.submitAgentRuntimeTurn(request, accepted), + ).resolves.toBe(response); + expect(order).toEqual(["accepted", "publish"]); + expect(accepted).toHaveBeenCalledTimes(1); + }); + it("notification failure cannot withdraw accepted response", async () => { + const { client, response } = setup(); + const accepted = vi.fn(); + publishMock.mockImplementation(() => { + throw new Error("PRIVATE USER BODY"); + }); + await expect( + client.submitAgentRuntimeTurn(request, accepted), + ).resolves.toBe(response); + expect(accepted).toHaveBeenCalledWith(response); + expect(rejectMock).not.toHaveBeenCalled(); + expect(logMock).toHaveBeenCalledWith( + "AgentRuntime", + "acceptance.postProcessingFailed", + { phase: "start_notifications", threadId: "thread-canonical" }, + { level: "error" }, + ); + expect(JSON.stringify(logMock.mock.calls)).not.toContain( + "PRIVATE USER BODY", + ); + }); + it("acceptance observer failure is diagnosed and notifications still publish", async () => { + const { client, response } = setup(); + await expect( + client.submitAgentRuntimeTurn(request, () => { + throw new Error("private observer"); + }), + ).resolves.toBe(response); + expect(publishMock).toHaveBeenCalledTimes(1); + expect(logMock).toHaveBeenCalledWith( + "AgentRuntime", + "acceptance.postProcessingFailed", + { phase: "start_callback", threadId: "thread-canonical" }, + { level: "error" }, + ); + }); + it.each([ + undefined, + {}, + { result: {} }, + { result: { turn: { turnId: "legacy", status: "accepted" } } }, + ])("rejects unknown response %j without acceptance", async (unknown) => { + const { client, startTurn } = setup(); + startTurn.mockResolvedValue(unknown); + const accepted = vi.fn(); + await expect( + client.submitAgentRuntimeTurn(request, accepted), + ).rejects.toThrow(); + expect(accepted).not.toHaveBeenCalled(); + expect(publishMock).not.toHaveBeenCalled(); + }); + it("preserves the original request rejection even when error notification throws", async () => { + const { client, startTurn } = setup(); + const error = new Error("runtime rejected"); + const accepted = vi.fn(); + startTurn.mockRejectedValue(error); + rejectMock.mockImplementation(() => { + throw new Error("diagnostic failed"); + }); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + await expect(client.submitAgentRuntimeTurn(request, accepted)).rejects.toBe( + error, + ); + expect(accepted).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + "[AgentRuntime] turn/start rejection notification failed", + ); + consoleError.mockRestore(); + }); +}); diff --git a/src/lib/api/agentRuntime/threadClient.d.ts b/src/lib/api/agentRuntime/threadClient.d.ts index 88424fb94..8d1d15694 100644 --- a/src/lib/api/agentRuntime/threadClient.d.ts +++ b/src/lib/api/agentRuntime/threadClient.d.ts @@ -14,6 +14,7 @@ import type { ThreadResumeResponse, ThreadSetNameParams, TurnStartParams, + TurnStartResponse, TurnSteerParams, TurnSteerResponse, } from "@limecloud/app-server-client"; @@ -124,7 +125,10 @@ export declare function createThreadClient( submitAgentRuntimeReview: ( request: AppServerReviewStartParams, ) => Promise>; - submitAgentRuntimeTurn: (request: TurnStartParams) => Promise; + submitAgentRuntimeTurn: ( + request: TurnStartParams, + onAccepted?: (response: AppServerRequestResult) => void, + ) => Promise>; }; export declare function publishAppServerAgentSessionNotifications( eventName: string | undefined, diff --git a/src/lib/api/agentRuntime/threadClient.test.ts b/src/lib/api/agentRuntime/threadClient.test.ts index d642a109e..02cbaf927 100644 --- a/src/lib/api/agentRuntime/threadClient.test.ts +++ b/src/lib/api/agentRuntime/threadClient.test.ts @@ -5,7 +5,11 @@ import { type AppServerJsonRpcNotification, type AppServerRequestResult, } from "@/lib/api/appServer"; -import type { TurnStartParams } from "@limecloud/app-server-client"; +import type { + Thread, + ThreadReadResponse, + TurnStartParams, +} from "@limecloud/app-server-client"; import { isAppServerBridgeAvailable } from "@/lib/api/appServerBridgeAvailability"; import { resetDefaultAppServerEventBusForTests } from "@/lib/api/appServerEventBus"; import { @@ -142,7 +146,13 @@ function appServerClientMock(): AgentRuntimeAppServerClient { messages: [], notifications: [], }), - startTurn: vi.fn().mockResolvedValue({}), + startTurn: vi.fn().mockResolvedValue({ + id: 1, + result: { turn: { id: "turn-1", status: "inProgress" } }, + response: { id: 1, result: {} }, + notifications: [], + messages: [], + }), steerTurn: vi.fn().mockResolvedValue({ id: 3, result: { turnId: "turn-1" }, @@ -243,20 +253,16 @@ function standardRuntimeClientMock(): AgentRuntimeLifecycleClient { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, response: { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, }, @@ -402,19 +408,47 @@ const checkpointRestore: AgentRuntimeFileCheckpointRestoreResult = { restored_at: "2026-06-06T00:01:00.000Z", }; +function appServerResult(result: T, id = 1): AppServerRequestResult { + return { + id, + result, + response: { id, result }, + messages: [], + notifications: [], + configWarnings: [], + }; +} + +function threadReadResult( + overrides: Partial, + id = 3, +): AppServerRequestResult { + return appServerResult( + { + thread: { + id: "thread-1", + sessionId: "session-1", + cliVersion: "fixture", + cwd: "/tmp/workspace", + ephemeral: false, + modelProvider: "openai", + preview: "", + projectId: null, + source: "appServer", + createdAt: 0.1, + updatedAt: 0.2, + status: { type: "active", activeFlags: [] }, + ...overrides, + }, + }, + id, + ); +} + function malformedAppServerResult( result: unknown, ): AppServerRequestResult { - return { - id: 1, - result: result as T, - response: { - id: 1, - result: result as T, - }, - messages: [], - notifications: [], - }; + return appServerResult(result as T); } describe("agentRuntime threadClient", () => { @@ -741,7 +775,8 @@ describe("agentRuntime threadClient", () => { metadata: { source: "chat" }, }), }); - const startTurnParams = appServerClient.startTurn.mock.calls[0]?.[0]; + const startTurnParams = vi.mocked(appServerClient.startTurn).mock + .calls[0]?.[0]; expect(startTurnParams).not.toHaveProperty("sessionId"); expect(startTurnParams).not.toHaveProperty("runtimeOptions"); expect(invokeCommand).not.toHaveBeenCalled(); @@ -765,7 +800,9 @@ describe("agentRuntime threadClient", () => { eventName: "agentSession/event/session-1", }), ), - ).resolves.toBeUndefined(); + ).resolves.toMatchObject({ + result: { turn: { id: "turn-1", status: "inProgress" } }, + }); await expect( client.interruptAgentRuntimeTurn({ session_id: "session-1", @@ -873,21 +910,9 @@ describe("agentRuntime threadClient", () => { it("canonical child Thread 导航应只读取身份并严格解析 sessionId", async () => { const appServerClient = appServerClientMock(); const readThread = vi.mocked(appServerClient.readThread); - readThread.mockResolvedValueOnce({ - id: 3, - result: { - thread: { - createdAt: 0.1, - id: "thread-child", - sessionId: "agent-child", - status: { type: "active", activeFlags: [] }, - updatedAt: 0.2, - }, - }, - response: { id: 3, result: {} }, - messages: [], - notifications: [], - }); + readThread.mockResolvedValueOnce( + threadReadResult({ id: "thread-child", sessionId: "agent-child" }, 3), + ); const client = createThreadClient({ appServerClient, isAppServerTurnLifecycleAvailable: () => true, @@ -901,40 +926,16 @@ describe("agentRuntime threadClient", () => { includeTurns: false, }); - readThread.mockResolvedValueOnce({ - id: 4, - result: { - thread: { - createdAt: 0.1, - id: "thread-other", - sessionId: "agent-other", - status: { type: "active", activeFlags: [] }, - updatedAt: 0.2, - }, - }, - response: { id: 4, result: {} }, - messages: [], - notifications: [], - }); + readThread.mockResolvedValueOnce( + threadReadResult({ id: "thread-other", sessionId: "agent-other" }, 4), + ); await expect(client.readThreadSessionId("thread-child")).rejects.toThrow( "mismatched threadId", ); - readThread.mockResolvedValueOnce({ - id: 5, - result: { - thread: { - createdAt: 0.1, - id: "thread-child", - sessionId: " ", - status: { type: "active", activeFlags: [] }, - updatedAt: 0.2, - }, - }, - response: { id: 5, result: {} }, - messages: [], - notifications: [], - }); + readThread.mockResolvedValueOnce( + threadReadResult({ id: "thread-child", sessionId: " " }, 5), + ); await expect(client.readThreadSessionId("thread-child")).rejects.toThrow( "empty sessionId", ); @@ -1064,23 +1065,20 @@ describe("agentRuntime threadClient", () => { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, response: { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, }, + configWarnings: [], messages: [], notifications: [ { @@ -1133,23 +1131,20 @@ describe("agentRuntime threadClient", () => { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, response: { id: 1, result: { turn: { - turnId: "turn-1", - sessionId: "session-1", - threadId: "thread-1", - status: "accepted", + id: "turn-1", + status: "inProgress", }, }, }, + configWarnings: [], messages: [], notifications: [ { @@ -1224,7 +1219,9 @@ describe("agentRuntime threadClient", () => { .mockResolvedValueOnce([notification]) .mockResolvedValue([]); vi.mocked(appServerClient.startTurn).mockResolvedValueOnce({ - ...malformedAppServerResult({}), + ...malformedAppServerResult({ + turn: { id: "turn-notification", status: "inProgress" }, + }), notifications: [notification], }); const client = createThreadClient({ @@ -1277,7 +1274,9 @@ describe("agentRuntime threadClient", () => { .mockResolvedValueOnce([notification]) .mockResolvedValue([]); vi.mocked(appServerClient.startTurn).mockResolvedValueOnce({ - ...malformedAppServerResult({}), + ...malformedAppServerResult({ + turn: { id: "turn-notification", status: "inProgress" }, + }), notifications: [], }); const client = createThreadClient({ @@ -1312,7 +1311,9 @@ describe("agentRuntime threadClient", () => { it("App Server submit 应立即投递请求结果中的 direct v2 notification", async () => { const appServerClient = appServerClientMock(); vi.mocked(appServerClient.startTurn).mockResolvedValueOnce({ - ...malformedAppServerResult({}), + ...malformedAppServerResult({ + turn: { id: "turn-notification", status: "inProgress" }, + }), notifications: [ { method: "item/agentMessage/delta", @@ -1414,7 +1415,19 @@ describe("agentRuntime threadClient", () => { status: "inProgress", }, }, - response: { id: 8, result: {} }, + response: { + id: 8, + result: { + reviewThreadId: "thread-review", + turn: { + id: "turn-review", + items: [], + itemsView: "notLoaded", + status: "inProgress", + }, + }, + }, + configWarnings: [], messages: [], notifications: [ { @@ -1563,12 +1576,11 @@ describe("agentRuntime threadClient", () => { invokeCommand: invokeCommand as unknown as AgentRuntimeCommandInvoke, isAppServerTurnLifecycleAvailable: () => false, }); - const request = { - message: "继续", - session_id: "session-1", - event_name: "event-1", - workspace_id: "workspace-1", - }; + const request = turnStartParams({ + text: "继续", + threadId: "session-1", + eventName: "event-1", + }); await expect(client.submitAgentRuntimeTurn(request)).rejects.toThrow( "App Server turn lifecycle is unavailable; Agent Runtime requires the App Server current lifecycle channel.", @@ -1661,26 +1673,13 @@ describe("agentRuntime threadClient", () => { it("App Server 可用时 thread read 应进入 thread/read 并投影 read model", async () => { const appServerClient = appServerClientMock(); - vi.mocked(appServerClient.readThread).mockResolvedValueOnce({ - id: 3, - result: { - thread: { - id: "thread-1", - sessionId: "session-1", - cwd: "/tmp/workspace", - createdAt: 0.1, - updatedAt: 0.3, - status: { type: "active", activeFlags: ["waitingOnUserInput"] }, - turns: [{ id: "turn-1", status: "inProgress", items: [] }], - }, - }, - response: { - id: 3, - result: {}, - }, - messages: [], - notifications: [], - }); + vi.mocked(appServerClient.readThread).mockResolvedValueOnce( + threadReadResult({ + updatedAt: 0.3, + status: { type: "active", activeFlags: ["waitingOnUserInput"] }, + turns: [{ id: "turn-1", status: "inProgress", items: [] }], + }), + ); const invokeCommand = vi.fn().mockResolvedValue({}); const client = createThreadClient({ appServerClient, diff --git a/src/lib/api/agentRuntime/threadClient.ts b/src/lib/api/agentRuntime/threadClient.ts index 94e1637f0..1adf31a1f 100644 --- a/src/lib/api/agentRuntime/threadClient.ts +++ b/src/lib/api/agentRuntime/threadClient.ts @@ -21,9 +21,14 @@ import type { ThreadSetNameParams, TurnInterruptParams, TurnStartParams, + TurnStartResponse, TurnSteerParams, TurnSteerResponse, } from "@limecloud/app-server-client"; +import { + requireTurnStartResponse, + reportRuntimeAcceptancePostProcessingFailure, +} from "./runtimeAcceptance"; import { isAppServerBridgeAvailable } from "@/lib/api/appServerBridgeAvailability"; import type { AgentRuntimeClient as StandardAgentRuntimeClient } from "@limecloud/agent-runtime-client"; import { @@ -133,7 +138,8 @@ export function createThreadClient(deps: AgentRuntimeThreadClientDeps = {}) { async function submitAgentRuntimeTurn( request: TurnStartParams, - ): Promise { + onAccepted?: (response: AppServerRequestResult) => void, + ): Promise> { assertAppServerTurnLifecycleAvailable(isAppServerTurnLifecycleAvailable); const threadId = request.threadId.trim(); if (!threadId) { @@ -144,23 +150,43 @@ export function createThreadClient(deps: AgentRuntimeThreadClientDeps = {}) { eventName, sessionId: threadId, }); + let result: AppServerRequestResult; try { - const result = await standardRuntimeClient.startTurn(params); - if (route) { - route.publish(result.notifications); - } else { + result = await standardRuntimeClient.startTurn(params); + requireTurnStartResponse(result?.result); + } catch (error) { + try { + publishAppServerRpcErrorNotifications(error, { + eventName, + sessionId: threadId, + }); + } catch { + console.error( + "[AgentRuntime] turn/start rejection notification failed", + ); + } + throw error; + } + try { + onAccepted?.(result); + } catch { + reportRuntimeAcceptancePostProcessingFailure("start_callback", { + threadId, + }); + } + try { + if (route) route.publish(result.notifications); + else publishAppServerAgentSessionNotifications( eventName, result.notifications, ); - } - } catch (error) { - publishAppServerRpcErrorNotifications(error, { - eventName, - sessionId: threadId, + } catch { + reportRuntimeAcceptancePostProcessingFailure("start_notifications", { + threadId, }); - throw error; } + return result; } async function submitAgentRuntimeReview( diff --git a/src/lib/api/persistedAtomIndexProtocol.test.ts b/src/lib/api/persistedAtomIndexProtocol.test.ts new file mode 100644 index 000000000..088e6cc69 --- /dev/null +++ b/src/lib/api/persistedAtomIndexProtocol.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from "vitest"; +import { + PERSISTED_ATOM_LIMITS, + parsePersistedAtomReadQuery, + parsePersistedAtomReadRequest, + parsePersistedAtomReadResult, +} from "./persistedAtomProtocol"; +import type { PersistedAtomReadRequest } from "./persistedAtomTypes"; + +const scope = { workspaceId: "w", kind: "draft", id: "tab-1" } as const; +const item = { + scope, + identity: { draftId: "d1", scopeVersion: "v1" }, + updatedAt: 1, +} as const; +const request = { + key: "composer-drafts-v1", + query: { kind: "workspace-index", workspaceId: "w" }, +} as const; +const metadata = { + key: "composer-drafts-v1", + storeRevision: 3, + persistence: { state: "saved", savedRevision: 3 }, +} as const; +const index = { + ...metadata, + outcome: "ready", + data: { queryKind: "workspace-index", workspaceId: "w", items: [item] }, +} as const; + +describe("strict workspace draft discovery transport", () => { + it("shares one exact read request/query validator for Host and consumers", () => { + expect(parsePersistedAtomReadRequest(request)).toEqual(request); + expect(parsePersistedAtomReadQuery(request.query)).toEqual(request.query); + const scopeRequest = { + key: request.key, + query: { kind: "scope", scope }, + } as const; + expect(parsePersistedAtomReadRequest(scopeRequest)).toEqual(scopeRequest); + expect( + parsePersistedAtomReadRequest({ + ...request, + query: { kind: "workspace-index", workspaceId: null }, + }), + ).toMatchObject({ query: { workspaceId: null } }); + }); + + it.each([ + { key: request.key, scope }, + { ...request, key: "other" }, + { ...request, path: "/tmp/arbitrary.json" }, + { ...request, query: null }, + { ...request, query: { kind: "workspace-index" } }, + { ...request, query: { kind: "workspace-index", workspaceId: 1 } }, + { ...request, query: { kind: "workspace-index", workspaceId: "" } }, + { ...request, query: { kind: "workspace-index", workspaceId: "a\u0000b" } }, + { + ...request, + query: { kind: "workspace-index", workspaceId: "x".repeat(4_097) }, + }, + { ...request, query: { kind: "workspace-index", workspaceId: "w", scope } }, + { ...request, query: { kind: "scope", scope, workspaceId: "w" } }, + { + ...request, + query: { kind: "scope", scope: { ...scope, path: "secret" } }, + }, + { + ...request, + query: { kind: "scope", scope: { ...scope, kind: "remote" } }, + }, + { ...request, query: { kind: "other", workspaceId: "w" } }, + ])( + "rejects old scope-only requests, unknown fields and invalid namespaces", + (input) => { + expect(() => parsePersistedAtomReadRequest(input)).toThrow( + "invalid persisted atom transport", + ); + }, + ); + + it("accepts complete metadata-only discovery, null namespace and an empty index", () => { + expect(parsePersistedAtomReadResult(index, request)).toEqual(index); + const empty = { + ...index, + data: { ...index.data, workspaceId: null, items: [] }, + }; + expect( + parsePersistedAtomReadResult(empty, { + ...request, + query: { kind: "workspace-index", workspaceId: null }, + }), + ).toEqual(empty); + const local = { + ...item, + scope: { workspaceId: "w", kind: "local", id: "session" }, + sessionId: "session", + threadId: "canonical-thread", + }; + expect( + parsePersistedAtomReadResult( + { ...index, data: { ...index.data, items: [local] } }, + request, + ), + ).toMatchObject({ data: { items: [local] } }); + }); + + it("correlates queryKind and exact request scope/workspace including null", () => { + const response = { + ...metadata, + outcome: "ready", + data: { queryKind: "scope", view: { scope, entry: null } }, + }; + const scopeRequest = { + key: request.key, + query: { kind: "scope", scope }, + } as const; + expect(parsePersistedAtomReadResult(response, scopeRequest)).toEqual( + response, + ); + expect(() => parsePersistedAtomReadResult(response, request)).toThrow(); + expect(() => parsePersistedAtomReadResult(index, scopeRequest)).toThrow(); + for (const workspaceId of [null, "other"]) + expect(() => + parsePersistedAtomReadResult(index, { + ...request, + query: { kind: "workspace-index", workspaceId }, + }), + ).toThrow(); + for (const changed of [ + { ...scope, workspaceId: null }, + { ...scope, kind: "local" as const }, + { ...scope, id: "other" }, + ]) + expect(() => + parsePersistedAtomReadResult(response, { + ...scopeRequest, + query: { kind: "scope", scope: changed }, + }), + ).toThrow(); + for (const invalid of [null, false, { key: request.key, scope }]) + expect(() => + parsePersistedAtomReadResult( + index, + invalid as unknown as PersistedAtomReadRequest, + ), + ).toThrow(); + }); + + it.each([ + { ...index, view: { scope, entry: null } }, + { ...index, data: null }, + { ...index, data: { ...index.data, queryKind: "unknown" } }, + { ...index, data: { ...index.data, view: { scope, entry: null } } }, + { ...index, data: { ...index.data, title: "private" } }, + { ...index, data: { ...index.data, workspaceId: "other" } }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, scope: { ...scope, workspaceId: null } }], + }, + }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, scope: { ...scope, workspaceId: "other" } }], + }, + }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, scope: { ...scope, kind: "home" } }], + }, + }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, identity: { ...item.identity, scopeVersion: "" } }], + }, + }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, identity: { ...item.identity, extra: true } }], + }, + }, + { ...index, data: { ...index.data, items: [{ ...item, updatedAt: -1 }] } }, + { ...index, data: { ...index.data, items: [{ ...item, updatedAt: NaN }] } }, + { ...index, data: { ...index.data, items: [{ ...item, sessionId: "" }] } }, + { ...index, data: { ...index.data, items: [{ ...item, threadId: 7 }] } }, + { + ...index, + data: { + ...index.data, + items: [{ ...item, scope: { ...scope, kind: "local", id: "session" } }], + }, + }, + { + ...index, + data: { + ...index.data, + items: [ + { + ...item, + scope: { ...scope, kind: "local", id: "session" }, + sessionId: "thread-id", + }, + ], + }, + }, + { ...index, data: { ...index.data, items: [item, item] } }, + { + ...index, + data: { + ...index.data, + items: [ + item, + { ...item, identity: { draftId: "other", scopeVersion: "v1" } }, + ], + }, + }, + { + ...index, + data: { + ...index.data, + items: [ + item, + { + ...item, + scope: { ...scope, id: "tab-2" }, + identity: { ...item.identity, scopeVersion: "v2" }, + }, + ], + }, + }, + { + ...index, + data: { + ...index.data, + items: [item, { ...item, scope: { ...scope, id: "tab-2" } }], + }, + }, + { ...index, data: { ...index.data, items: {} } }, + { + ...index, + data: { + ...index.data, + items: Array(PERSISTED_ATOM_LIMITS.maxBindings + 1).fill(item), + }, + }, + { ...index, storeRevision: -1 }, + { ...index, persistence: { state: "saved", savedRevision: 2 } }, + { ...index, persistence: { state: "loading", savedRevision: null } }, + { ...index, persistence: { state: "pending", savedRevision: 4 } }, + { ...index, reason: "invalid_request" }, + { ...index, outcome: "invalid", reason: "invalid_request" }, + { ...index, outcome: "unavailable" }, + ])( + "rejects malformed, mismatched, duplicate or impossible discovery projections", + (input) => { + expect(() => parsePersistedAtomReadResult(input, request)).toThrow( + "invalid persisted atom transport", + ); + }, + ); + + it.each([ + "draft", + "entry", + "title", + "editorDocument", + "attachments", + "media", + "inputCapabilityRoute", + "originScopeKey", + "state", + ])("rejects content or extra item field %s", (field) => { + const input = { + ...index, + data: { ...index.data, items: [{ ...item, [field]: "private" }] }, + }; + expect(() => parsePersistedAtomReadResult(input, request)).toThrow(); + }); + + it("distinguishes invalid/unavailable data=null from ready empty discovery", () => { + const invalid = { + ...metadata, + outcome: "invalid", + reason: "invalid_request", + data: null, + }; + const unavailable = { + ...metadata, + outcome: "unavailable", + reason: "closed", + data: null, + }; + expect(parsePersistedAtomReadResult(invalid, request)).toEqual(invalid); + expect(parsePersistedAtomReadResult(unavailable, request)).toEqual( + unavailable, + ); + expect(() => + parsePersistedAtomReadResult({ ...invalid, reason: undefined }, request), + ).toThrow(); + expect(() => + parsePersistedAtomReadResult( + { ...metadata, outcome: "ready", view: null }, + request, + ), + ).toThrow(); + }); + + it("rejects request/query/item/array accessors, sparse arrays, symbols and hidden fields without executing getters", () => { + let called = 0; + const accessor = (field: string) => + Object.defineProperty({}, field, { + enumerable: true, + get() { + called += 1; + return "private"; + }, + }); + for (const input of [ + accessor("query"), + { ...request, query: accessor("workspaceId") }, + { ...request, query: { kind: "scope", scope: accessor("id") } }, + { ...request, [Symbol("private")]: "value" }, + Object.defineProperty({ ...request }, "hidden", { value: "private" }), + ]) + expect(() => parsePersistedAtomReadRequest(input)).toThrow(); + const getItem = Object.defineProperty([], "0", { + enumerable: true, + get() { + called += 1; + return item; + }, + }); + const hidden = Object.defineProperty([item], "hidden", { + value: "private", + }); + const symbol = Object.assign([item], { [Symbol("private")]: "private" }); + for (const items of [ + [accessor("identity")], + getItem, + Array(1), + hidden, + symbol, + ]) + expect(() => + parsePersistedAtomReadResult( + { ...index, data: { ...index.data, items } }, + request, + ), + ).toThrow(); + expect(called).toBe(0); + }); +}); diff --git a/src/lib/api/persistedAtomProtocol.test.ts b/src/lib/api/persistedAtomProtocol.test.ts new file mode 100644 index 000000000..1122a812f --- /dev/null +++ b/src/lib/api/persistedAtomProtocol.test.ts @@ -0,0 +1,364 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { + PersistedAtomReadRequest, + PersistedAtomUpdateRequest, +} from "./persistedAtomTypes"; +import { + assertPersistedDraftPayload, + parsePersistedAtomReadResult, + parsePersistedAtomUpdateEvent, + parsePersistedAtomUpdateResult, +} from "./persistedAtomProtocol"; +import { + cleanup, + createStore, + home, + identity, + local, + promotion, + put, +} from "../../../electron/persistedAtomStore.test-support"; + +afterEach(cleanup); +const metadata = { + key: "composer-drafts-v1", + storeRevision: 1, + persistence: { state: "pending", savedRevision: 0 }, +} as const; +const draft = { + ...identity, + originScopeKey: JSON.stringify([null, "home", "new-conversation"]), + state: "draft", + updatedAt: 1, + draft: { + text: "fixture😀", + attachments: [{ data: "aW1hZ2U=", mediaType: "image/png" }], + }, +} as const; +const read = { + ...metadata, + outcome: "ready", + data: { queryKind: "scope", view: { scope: home, entry: draft } }, +} as const; +const update = { + ...metadata, + outcome: "applied", + changed: true, + entry: draft, +} as const; +const event = { ...metadata, event: "persisted-atom-update" } as const; + +describe("strict shared persisted atom transport", () => { + it("accepts valid complete read/update projections and metadata-only notifications", () => { + expect(parsePersistedAtomReadResult(read)).toEqual(read); + expect(parsePersistedAtomUpdateResult(update)).toEqual(update); + expect(parsePersistedAtomUpdateEvent(event)).toEqual(event); + }); + + it.each([ + { ...event, key: "other-atom" }, + { ...event, draft }, + { ...event, entry: draft }, + { ...event, threadId: "thread" }, + { ...event, event: "other" }, + { ...event, storeRevision: NaN }, + { ...event, storeRevision: -1 }, + { ...event, persistence: { state: "saved", savedRevision: 0 } }, + { ...event, persistence: { state: "pending", savedRevision: 2 } }, + { ...event, persistence: { state: "pending", savedRevision: null } }, + { ...event, persistence: { state: "error", savedRevision: 0 } }, + { + ...event, + persistence: { + state: "unavailable", + savedRevision: 0, + diagnostic: { phase: "load", code: "INVALID_STATE" }, + }, + }, + { + ...event, + persistence: { + state: "error", + savedRevision: 0, + diagnostic: { phase: "primary", code: "raw private error text" }, + }, + }, + { + ...event, + persistence: { + state: "error", + savedRevision: 0, + diagnostic: { phase: "primary", code: "EIO", message: "private" }, + }, + }, + ])( + "rejects event payload leakage and impossible persistence receipts", + (input) => { + expect(() => parsePersistedAtomUpdateEvent(input)).toThrow( + "invalid persisted atom transport", + ); + }, + ); + + it("accepts sanitized save errors without requiring a send/Quit readiness gate", () => { + const persistence = { + state: "error", + savedRevision: 0, + diagnostic: { phase: "primary", code: "ENOSPC" }, + }; + expect( + parsePersistedAtomReadResult({ ...read, persistence }), + ).toMatchObject({ + outcome: "ready", + data: { queryKind: "scope", view: { entry: draft } }, + }); + expect( + parsePersistedAtomUpdateResult({ + ...update, + persistence, + changed: false, + }), + ).toMatchObject({ outcome: "applied", persistence }); + }); + + it.each([ + { ...read, data: { queryKind: "scope", view: null } }, + { ...read, reason: "invalid_request" }, + { + ...read, + data: { + queryKind: "scope", + view: { scope: { ...home, kind: "cloud" }, entry: draft }, + }, + }, + { ...read, outcome: "invalid", data: { queryKind: "scope", view: null } }, + { ...read, outcome: "invalid", reason: "invalid_request" }, + { + ...read, + data: { + queryKind: "scope", + view: { scope: local, entry: { ...draft, sessionId: "thread-id" } }, + }, + }, + { + ...read, + data: { + queryKind: "scope", + view: { scope: home, entry: { ...draft, draft: { text: undefined } } }, + }, + }, + { + ...read, + data: { + queryKind: "scope", + view: { scope: home, entry: { ...draft, extra: true } }, + }, + }, + ])( + "rejects malformed read state rather than using an empty fixture fallback", + (input) => { + expect(() => parsePersistedAtomReadResult(input)).toThrow( + "invalid persisted atom transport", + ); + }, + ); + + it.each([ + { ...update, changed: "yes" }, + { ...update, entry: null }, + { ...update, reason: "invalid_payload" }, + { ...update, outcome: "conflict" }, + { ...update, outcome: "conflict", changed: false }, + { ...update, outcome: "invalid", reason: "invalid_payload" }, + { ...update, outcome: "unavailable", changed: true }, + { ...update, outcome: "unknown" }, + ])("rejects invalid update outcomes and changes", (input) => { + expect(() => parsePersistedAtomUpdateResult(input)).toThrow( + "invalid persisted atom transport", + ); + }); + + it("accepts payload-free consumption and rejects retained sent content", () => { + const { draft: _draft, ...base } = draft; + const consumed = { + ...base, + state: "consumed", + consumption: "accepted", + consumedAt: 2, + }; + expect( + parsePersistedAtomUpdateResult({ ...update, entry: consumed }), + ).toMatchObject({ entry: consumed }); + expect(() => + parsePersistedAtomUpdateResult({ + ...update, + entry: { ...consumed, draft: { text: "sent" } }, + }), + ).toThrow(); + expect(() => + parsePersistedAtomReadResult({ + ...read, + data: { queryKind: "scope", view: { scope: home, entry: consumed } }, + }), + ).toThrow(); + }); + + it("checks scope and accepted identity/version/state against the originating request", () => { + const readRequest: PersistedAtomReadRequest = { + key: "composer-drafts-v1", + query: { kind: "scope", scope: home }, + }; + expect(parsePersistedAtomReadResult(read, readRequest)).toEqual(read); + expect(() => + parsePersistedAtomReadResult(read, { + ...readRequest, + query: { kind: "scope", scope: { ...home, workspaceId: "other" } }, + }), + ).toThrow(); + const request: PersistedAtomUpdateRequest = { + key: "composer-drafts-v1", + mutation: put(), + }; + expect(parsePersistedAtomUpdateResult(update, request)).toEqual(update); + expect(() => + parsePersistedAtomUpdateResult( + { ...update, entry: { ...draft, draftId: "other" } }, + request, + ), + ).toThrow(); + expect(() => + parsePersistedAtomUpdateResult( + { ...update, entry: { ...draft, scopeVersion: "old" } }, + request, + ), + ).toThrow(); + expect(() => + parsePersistedAtomUpdateResult(update, { + key: "composer-drafts-v1", + mutation: { op: "consume", identity }, + }), + ).toThrow(); + const conflict = { + ...update, + outcome: "conflict", + changed: false, + reason: "version_conflict", + entry: { ...draft, scopeVersion: "newer" }, + }; + expect(parsePersistedAtomUpdateResult(conflict, request)).toMatchObject({ + outcome: "conflict", + }); + }); + + it("does not execute malformed transport accessors or lose non-JSON content", () => { + let called = false; + const malicious = Object.defineProperty({}, "key", { + enumerable: true, + get() { + called = true; + return "composer-drafts-v1"; + }, + }); + for (const parse of [ + parsePersistedAtomReadResult, + parsePersistedAtomUpdateResult, + parsePersistedAtomUpdateEvent, + ]) + expect(() => parse(malicious)).toThrow(); + expect(called).toBe(false); + const payload = Object.defineProperty({}, "text", { + enumerable: true, + get() { + called = true; + return "secret"; + }, + }); + expect(() => assertPersistedDraftPayload(payload)).toThrow(); + expect(called).toBe(false); + expect(() => assertPersistedDraftPayload({ text: BigInt(1) })).toThrow(); + const readContext = Object.defineProperty({}, "query", { + enumerable: true, + get() { + called = true; + return home; + }, + }); + const updateContext = Object.defineProperty({}, "mutation", { + enumerable: true, + get() { + called = true; + return put(); + }, + }); + expect(() => + parsePersistedAtomReadResult( + read, + readContext as PersistedAtomReadRequest, + ), + ).toThrow(); + expect(() => + parsePersistedAtomUpdateResult( + update, + updateContext as PersistedAtomUpdateRequest, + ), + ).toThrow(); + expect(called).toBe(false); + }); + + it("parses actual Main owner lifecycle responses through put/promotion/metadata/consume and conflict", async () => { + const { store } = await createStore(); + const readRequest: PersistedAtomReadRequest = { + key: "composer-drafts-v1", + query: { kind: "scope", scope: home }, + }; + expect( + parsePersistedAtomReadResult( + store.sendPersistedAtomState({ kind: "scope", scope: home }), + readRequest, + ), + ).toMatchObject({ data: { queryKind: "scope", view: { entry: null } } }); + for (const mutation of [ + put(), + promotion(), + { + ...promotion(local, local), + expectedTarget: identity, + threadId: "thread-canonical", + }, + { op: "consume", identity }, + ] as const) { + const request = { key: "composer-drafts-v1", mutation } as const; + expect( + parsePersistedAtomUpdateResult( + store.updatePersistedAtomState(mutation), + request, + ).outcome, + ).toBe("applied"); + expect( + parsePersistedAtomUpdateEvent({ + ...store.metadata(), + event: "persisted-atom-update", + }).storeRevision, + ).toBe(store.metadata().storeRevision); + } + expect( + parsePersistedAtomUpdateResult(store.updatePersistedAtomState(put()), { + key: "composer-drafts-v1", + mutation: put(), + }), + ).toMatchObject({ outcome: "conflict", reason: "retired_draft" }); + await store.flush(); + expect( + parsePersistedAtomReadResult( + store.sendPersistedAtomState({ kind: "scope", scope: local }), + { + key: "composer-drafts-v1", + query: { kind: "scope", scope: local }, + }, + ), + ).toMatchObject({ + data: { queryKind: "scope", view: { entry: null } }, + persistence: { state: "saved", savedRevision: 4 }, + }); + }); +}); diff --git a/src/lib/api/persistedAtomProtocol.ts b/src/lib/api/persistedAtomProtocol.ts new file mode 100644 index 000000000..5930e16d0 --- /dev/null +++ b/src/lib/api/persistedAtomProtocol.ts @@ -0,0 +1,527 @@ +import type { + DraftIdentity, + DraftScope, + JsonObject, + PersistedAtomDiagnostic, + PersistedAtomMetadata, + PersistedAtomPersistence, + PersistedAtomReadResult, + PersistedAtomReadQuery, + PersistedDraftIndexItem, + PersistedAtomReadRequest, + PersistedAtomReason, + PersistedAtomUpdateEvent, + PersistedAtomUpdateResult, + PersistedAtomUpdateRequest, + PersistedDraftEntry, +} from "./persistedAtomTypes"; + +export interface PersistedAtomLimits { + readonly maxDraftBytes: number; + readonly maxFileBytes: number; + readonly maxEntries: number; + readonly maxBindings: number; +} + +// Current composer: 20 * 3,932,160 decoded bytes = 100 MiB base64. +export const PERSISTED_ATOM_LIMITS: PersistedAtomLimits = Object.freeze({ + maxDraftBytes: 128 * 1024 * 1024, + maxFileBytes: 256 * 1024 * 1024, + maxEntries: 10_000, + maxBindings: 10_000, +}); + +function reject(): never { + throw new Error("invalid persisted atom transport"); +} +function object(value: unknown): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) + reject(); + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Object.getOwnPropertySymbols(value).length || + Object.values(descriptors).some( + (descriptor) => !descriptor.enumerable || !("value" in descriptor), + ) + ) + reject(); + return value as Record; +} +function exact( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { + const result = object(value); + if ( + required.some((key) => !Object.hasOwn(result, key)) || + Object.keys(result).some( + (key) => !required.includes(key) && !optional.includes(key), + ) + ) + reject(); + return result; +} +function id(value: unknown, max = 256): string { + if ( + typeof value !== "string" || + !value.trim() || + value.length > max || + [...value].some((character) => character.charCodeAt(0) < 32) + ) + reject(); + return value; +} +function integer(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + reject(); + return value; +} +function member(value: unknown, choices: readonly T[]): T { + if (typeof value !== "string" || !choices.includes(value as T)) reject(); + return value as T; +} + +export function parsePersistedDraftScope(value: unknown): DraftScope { + const scope = exact(value, ["workspaceId", "kind", "id"]); + const kind = member(scope.kind, ["home", "draft", "local"]); + const scopeId = id(scope.id, 8_192); + if (kind === "home" && scopeId !== "new-conversation") reject(); + return { + workspaceId: + scope.workspaceId === null ? null : id(scope.workspaceId, 4_096), + kind, + id: scopeId, + }; +} + +export function parsePersistedAtomReadQuery( + value: unknown, +): PersistedAtomReadQuery { + const kind = member(object(value).kind, ["scope", "workspace-index"]); + if (kind === "scope") { + const query = exact(value, ["kind", "scope"]); + return { kind, scope: parsePersistedDraftScope(query.scope) }; + } + const query = exact(value, ["kind", "workspaceId"]); + return { + kind, + workspaceId: + query.workspaceId === null ? null : id(query.workspaceId, 4_096), + }; +} + +export function parsePersistedAtomReadRequest( + value: unknown, +): PersistedAtomReadRequest { + const request = exact(value, ["key", "query"]); + if (request.key !== "composer-drafts-v1") reject(); + return { + key: "composer-drafts-v1", + query: parsePersistedAtomReadQuery(request.query), + }; +} + +export function parsePersistedDraftIdentity(value: unknown): DraftIdentity { + const identity = exact(value, ["draftId", "scopeVersion"]); + return { + draftId: id(identity.draftId), + scopeVersion: id(identity.scopeVersion), + }; +} + +/** One JSON shape validator shared by the Host and transport consumers. */ +export function assertPersistedDraftPayload( + value: unknown, +): asserts value is JsonObject { + let nodes = 0; + const ancestors = new Set(); + const visit = (item: unknown, depth: number): void => { + nodes += 1; + if (depth > 64 || nodes > 1_000_000) reject(); + if ( + item === null || + typeof item === "boolean" || + typeof item === "string" || + (typeof item === "number" && Number.isFinite(item)) + ) + return; + if (!item || typeof item !== "object" || ancestors.has(item)) reject(); + const array = Array.isArray(item); + if ( + (Object.getPrototypeOf(item) !== + (array ? Array.prototype : Object.prototype) && + Object.getPrototypeOf(item) !== null) || + Object.getOwnPropertySymbols(item).length + ) + reject(); + const descriptors = Object.getOwnPropertyDescriptors(item); + const keys = Object.keys(descriptors).filter( + (key) => !(array && key === "length"), + ); + if ( + keys.some( + (key) => !descriptors[key].enumerable || !("value" in descriptors[key]), + ) || + (array && + (keys.length !== item.length || + keys.some((key, index) => key !== String(index)))) + ) + reject(); + ancestors.add(item); + for (const key of keys) visit(descriptors[key].value, depth + 1); + ancestors.delete(item); + }; + if (!value || typeof value !== "object" || Array.isArray(value)) reject(); + visit(value, 0); +} + +const diagnosticCodes = [ + "ENOENT", + "EACCES", + "EPERM", + "EBUSY", + "ENOSPC", + "EIO", + "EEXIST", + "ETIMEDOUT", + "IO_ERROR", + "INVALID_STATE", + "INVALID_JSON", + "CAPACITY_EXCEEDED", +] as const; +function diagnostic(value: unknown): PersistedAtomDiagnostic { + const data = exact(value, ["phase", "code"]); + return { + phase: member(data.phase, ["load", "serialize", "primary", "backup"]), + code: member(data.code, diagnosticCodes), + }; +} +function persistence( + value: unknown, + revision: number, +): PersistedAtomPersistence { + const data = exact(value, ["state", "savedRevision"], ["diagnostic"]); + const state = member(data.state, [ + "loading", + "saved", + "pending", + "error", + "unavailable", + ]); + const savedRevision = + data.savedRevision === null ? null : integer(data.savedRevision); + if ( + (savedRevision !== null && savedRevision > revision) || + (state === "saved" && savedRevision !== revision) || + ((state === "loading" || state === "unavailable") && + savedRevision !== null) || + ((state === "saved" || state === "pending") && savedRevision === null) + ) + reject(); + if ( + (state === "error" || state === "unavailable") !== + Object.hasOwn(data, "diagnostic") + ) + reject(); + return { + state, + savedRevision, + ...(Object.hasOwn(data, "diagnostic") + ? { diagnostic: diagnostic(data.diagnostic) } + : {}), + }; +} +function metadata(value: Record): PersistedAtomMetadata { + if (value.key !== "composer-drafts-v1") reject(); + const storeRevision = integer(value.storeRevision); + return { + key: "composer-drafts-v1", + storeRevision, + persistence: persistence(value.persistence, storeRevision), + }; +} +function entry(value: unknown): PersistedDraftEntry { + const state = member(object(value).state, ["draft", "consumed"]); + const data = exact( + value, + [ + "draftId", + "scopeVersion", + "originScopeKey", + "updatedAt", + "state", + ...(state === "draft" ? ["draft"] : ["consumption", "consumedAt"]), + ], + ["sessionId", "threadId"], + ); + const originScopeKey = id(data.originScopeKey, 16_384); + let origin: unknown; + try { + origin = JSON.parse(originScopeKey); + } catch { + return reject(); + } + if (!Array.isArray(origin) || origin.length !== 3) reject(); + const originScope = parsePersistedDraftScope({ + workspaceId: origin[0], + kind: origin[1], + id: origin[2], + }); + if ( + JSON.stringify([ + originScope.workspaceId, + originScope.kind, + originScope.id, + ]) !== originScopeKey + ) + reject(); + const base = { + draftId: id(data.draftId), + scopeVersion: id(data.scopeVersion), + originScopeKey, + updatedAt: integer(data.updatedAt), + ...(Object.hasOwn(data, "sessionId") + ? { sessionId: id(data.sessionId) } + : {}), + ...(Object.hasOwn(data, "threadId") ? { threadId: id(data.threadId) } : {}), + }; + if (state === "consumed") + return { + ...base, + state, + consumption: member(data.consumption, ["accepted", "clear"]), + consumedAt: integer(data.consumedAt), + }; + assertPersistedDraftPayload(data.draft); + if ( + new TextEncoder().encode(JSON.stringify(data.draft)).byteLength > + PERSISTED_ATOM_LIMITS.maxDraftBytes + ) + reject(); + return { ...base, state, draft: data.draft }; +} +const reasons: readonly PersistedAtomReason[] = [ + "invalid_request", + "invalid_scope", + "invalid_payload", + "capacity_exceeded", + "identity_mismatch", + "version_conflict", + "target_conflict", + "retired_draft", + "closed", +]; +function optionalReason(data: Record): { + reason?: PersistedAtomReason; +} { + return Object.hasOwn(data, "reason") + ? { reason: member(data.reason, reasons) } + : {}; +} + +function indexItem(value: unknown): PersistedDraftIndexItem { + const item = exact( + value, + ["scope", "identity", "updatedAt"], + ["sessionId", "threadId"], + ); + const scope = parsePersistedDraftScope(item.scope); + const sessionId = Object.hasOwn(item, "sessionId") + ? id(item.sessionId) + : undefined; + if (scope.kind === "local" && sessionId !== scope.id) reject(); + return { + scope, + identity: parsePersistedDraftIdentity(item.identity), + updatedAt: integer(item.updatedAt), + ...(sessionId !== undefined ? { sessionId } : {}), + ...(Object.hasOwn(item, "threadId") ? { threadId: id(item.threadId) } : {}), + }; +} + +function indexItems(value: unknown): readonly PersistedDraftIndexItem[] { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + value.length > PERSISTED_ATOM_LIMITS.maxBindings + ) + reject(); + // Validate descriptors before reading any item; never execute accessors. + assertPersistedDraftPayload({ items: value }); + const scopes = new Set(), + identities = new Set(); + return value.map((raw) => { + const item = indexItem(raw); + const key = JSON.stringify([ + item.scope.workspaceId, + item.scope.kind, + item.scope.id, + ]); + if (scopes.has(key) || identities.has(item.identity.draftId)) reject(); + scopes.add(key); + identities.add(item.identity.draftId); + return item; + }); +} + +export function parsePersistedAtomReadResult( + value: unknown, + request?: PersistedAtomReadRequest, +): PersistedAtomReadResult { + const expected = + request === undefined + ? undefined + : parsePersistedAtomReadRequest(request).query; + const response = exact( + value, + ["key", "storeRevision", "persistence", "outcome", "data"], + ["reason"], + ); + const meta = metadata(response), + outcome = member(response.outcome, ["ready", "invalid", "unavailable"]); + const reason = optionalReason(response); + if (outcome !== "ready") { + if (response.data !== null || (outcome === "invalid" && !reason.reason)) + reject(); + return { ...meta, outcome, ...reason, data: null }; + } + if ( + reason.reason || + meta.persistence.state === "loading" || + meta.persistence.state === "unavailable" + ) + reject(); + const queryKind = member(object(response.data).queryKind, [ + "scope", + "workspace-index", + ]); + if (expected && queryKind !== expected.kind) reject(); + if (queryKind === "workspace-index") { + const data = exact(response.data, ["queryKind", "workspaceId", "items"]); + const workspaceId = + data.workspaceId === null ? null : id(data.workspaceId, 4_096); + if ( + expected?.kind === "workspace-index" && + workspaceId !== expected.workspaceId + ) + reject(); + const items = indexItems(data.items); + if (items.some((item) => item.scope.workspaceId !== workspaceId)) reject(); + return { ...meta, outcome, data: { queryKind, workspaceId, items } }; + } + const data = exact(response.data, ["queryKind", "view"]); + const view = exact(data.view, ["scope", "entry"]); + const scope = parsePersistedDraftScope(view.scope), + draft = view.entry === null ? null : entry(view.entry); + if ( + expected?.kind === "scope" && + (scope.workspaceId !== expected.scope.workspaceId || + scope.kind !== expected.scope.kind || + scope.id !== expected.scope.id) + ) + reject(); + if ( + draft?.state === "consumed" || + (draft && scope.kind === "local" && draft.sessionId !== scope.id) + ) + reject(); + return { + ...meta, + outcome, + data: { queryKind, view: { scope, entry: draft } }, + }; +} + +export function parsePersistedAtomUpdateResult( + value: unknown, + request?: PersistedAtomUpdateRequest, +): PersistedAtomUpdateResult { + const context = request ? exact(request, ["key", "mutation"]) : undefined; + if (context && context.key !== "composer-drafts-v1") reject(); + const requestedMutation = context ? object(context.mutation) : undefined; + const data = exact( + value, + ["key", "storeRevision", "persistence", "outcome", "changed", "entry"], + ["reason"], + ); + const meta = metadata(data), + outcome = member(data.outcome, [ + "applied", + "conflict", + "invalid", + "unavailable", + ]); + const reason = optionalReason(data); + if ( + typeof data.changed !== "boolean" || + (outcome !== "applied" && data.changed) || + ((outcome === "invalid" || outcome === "conflict") && !reason.reason) || + (outcome === "applied" && + (reason.reason || + data.entry === null || + meta.persistence.state === "loading" || + meta.persistence.state === "unavailable")) + ) + reject(); + const result = { + ...meta, + outcome, + changed: data.changed, + ...reason, + entry: data.entry === null ? null : entry(data.entry), + }; + if (requestedMutation && result.outcome === "applied") { + if (!result.entry) reject(); + const op = member(requestedMutation.op, [ + "put", + "promote", + "consume", + "clear", + ]); + const mutation = requestedMutation; + const expected = + op === "put" + ? { + draftId: id(mutation.draftId), + scopeVersion: id(mutation.nextVersion), + } + : parsePersistedDraftIdentity(mutation.identity); + if ( + result.entry.draftId !== expected.draftId || + result.entry.scopeVersion !== expected.scopeVersion || + result.entry.state !== + (op === "consume" || op === "clear" ? "consumed" : "draft") + ) + reject(); + const target = + op === "put" + ? parsePersistedDraftScope(mutation.scope) + : op === "promote" + ? parsePersistedDraftScope(mutation.target) + : undefined; + if (target?.kind === "local" && result.entry.sessionId !== target.id) + reject(); + if ( + op === "promote" && + mutation.threadId && + result.entry.threadId !== id(mutation.threadId) + ) + reject(); + } + return result; +} + +export function parsePersistedAtomUpdateEvent( + value: unknown, +): PersistedAtomUpdateEvent { + const data = exact(value, ["key", "storeRevision", "persistence", "event"]); + if (data.event !== "persisted-atom-update") reject(); + return { ...metadata(data), event: "persisted-atom-update" }; +} diff --git a/src/lib/api/persistedAtomTypes.ts b/src/lib/api/persistedAtomTypes.ts new file mode 100644 index 000000000..7824e11d8 --- /dev/null +++ b/src/lib/api/persistedAtomTypes.ts @@ -0,0 +1,165 @@ +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | JsonObject; + +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +export type PersistedAtomKey = "composer-drafts-v1"; + +export interface DraftScope { + readonly workspaceId: string | null; + readonly kind: "home" | "draft" | "local"; + readonly id: string; +} + +export interface DraftIdentity { + readonly draftId: string; + readonly scopeVersion: string; +} + +interface DraftEntryMetadata extends DraftIdentity { + readonly originScopeKey: string; + readonly updatedAt: number; + readonly sessionId?: string; + readonly threadId?: string; +} + +export type PersistedDraftEntry = + | (DraftEntryMetadata & { + readonly state: "draft"; + readonly draft: JsonObject; + }) + | (DraftEntryMetadata & { + readonly state: "consumed"; + readonly consumption: "accepted" | "clear"; + readonly consumedAt: number; + }); + +export type PersistedAtomMutation = + | { + readonly op: "put"; + readonly scope: DraftScope; + readonly draftId: string; + readonly expectedVersion: string | null; + readonly nextVersion: string; + readonly draft: JsonObject; + } + | { + readonly op: "promote"; + readonly source: DraftScope; + readonly target: DraftScope; + readonly identity: DraftIdentity; + readonly expectedTarget: DraftIdentity | null; + readonly sessionId?: string; + readonly threadId?: string; + } + | { + readonly op: "consume" | "clear"; + readonly identity: DraftIdentity; + }; + +export type PersistedAtomReadQuery = + | { readonly kind: "scope"; readonly scope: DraftScope } + | { + readonly kind: "workspace-index"; + readonly workspaceId: string | null; + }; + +export interface PersistedAtomReadRequest { + readonly key: PersistedAtomKey; + readonly query: PersistedAtomReadQuery; +} + +export interface PersistedAtomUpdateRequest { + readonly key: PersistedAtomKey; + readonly mutation: PersistedAtomMutation; +} + +export interface PersistedAtomDiagnostic { + readonly phase: "load" | "serialize" | "primary" | "backup"; + readonly code: string; +} + +export interface PersistedAtomPersistence { + readonly state: "loading" | "saved" | "pending" | "error" | "unavailable"; + readonly savedRevision: number | null; + readonly diagnostic?: PersistedAtomDiagnostic; +} + +export interface PersistedAtomMetadata { + readonly key: PersistedAtomKey; + readonly storeRevision: number; + readonly persistence: PersistedAtomPersistence; +} + +export interface PersistedDraftView { + readonly scope: DraftScope; + readonly entry: PersistedDraftEntry | null; +} + +export type PersistedAtomReason = + | "invalid_request" + | "invalid_scope" + | "invalid_payload" + | "capacity_exceeded" + | "identity_mismatch" + | "version_conflict" + | "target_conflict" + | "retired_draft" + | "closed"; + +export interface PersistedDraftIndexItem { + readonly scope: DraftScope; + readonly identity: DraftIdentity; + readonly updatedAt: number; + readonly sessionId?: string; + readonly threadId?: string; +} + +export type PersistedAtomReadData = + | { readonly queryKind: "scope"; readonly view: PersistedDraftView } + | { + readonly queryKind: "workspace-index"; + readonly workspaceId: string | null; + readonly items: readonly PersistedDraftIndexItem[]; + }; + +export type PersistedAtomReadResult = PersistedAtomMetadata & + ( + | { readonly outcome: "ready"; readonly data: PersistedAtomReadData } + | { + readonly outcome: "invalid" | "unavailable"; + readonly reason?: PersistedAtomReason; + readonly data: null; + } + ); + +export interface PersistedAtomUpdateResult extends PersistedAtomMetadata { + readonly outcome: "applied" | "conflict" | "invalid" | "unavailable"; + readonly changed: boolean; + readonly reason?: PersistedAtomReason; + readonly entry: PersistedDraftEntry | null; +} + +/** Notifications contain metadata only; drafts never enter diagnostics. */ +export interface PersistedAtomUpdateEvent extends PersistedAtomMetadata { + readonly event: "persisted-atom-update"; +} + +export interface PersistedAtomEnvelope { + readonly format: "electron-persisted-atom-state"; + readonly version: 1; + readonly storeRevision: number; + readonly atoms: { + readonly "composer-drafts-v1": { + readonly bindings: Readonly>; + readonly entries: Readonly>; + }; + }; +} diff --git a/src/lib/api/persistedAtoms.test.ts b/src/lib/api/persistedAtoms.test.ts new file mode 100644 index 000000000..3d6683c4b --- /dev/null +++ b/src/lib/api/persistedAtoms.test.ts @@ -0,0 +1,484 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import type { + PersistedAtomReadRequest, + PersistedAtomUpdateRequest, +} from "@/lib/api/persistedAtomTypes"; +import { + PERSISTED_ATOM_UPDATE_EVENT, + PersistedAtomBridgeError, + listenPersistedAtomUpdates, + sendPersistedAtomState, + updatePersistedAtomState, +} from "./persistedAtoms"; + +vi.mock("@/lib/dev-bridge", () => ({ + safeInvoke: vi.fn(), + safeListen: vi.fn(), +})); + +const scope = { + workspaceId: "workspace", + kind: "home", + id: "new-conversation", +} as const; +const metadata = { + key: "composer-drafts-v1", + storeRevision: 3, + persistence: { state: "saved", savedRevision: 3 }, +} as const; +const readRequest: PersistedAtomReadRequest = { + key: metadata.key, + query: { kind: "scope", scope }, +}; +const updateRequest: PersistedAtomUpdateRequest = { + key: metadata.key, + mutation: { + op: "put", + scope, + draftId: "draft", + expectedVersion: "v1", + nextVersion: "v2", + draft: { text: "private draft 中😀" }, + }, +}; +const entry = { + draftId: "draft", + scopeVersion: "v2", + originScopeKey: JSON.stringify([scope.workspaceId, scope.kind, scope.id]), + updatedAt: 100, + state: "draft", + draft: { text: "private draft 中😀" }, +} as const; +const readResult = () => ({ + ...metadata, + outcome: "ready", + data: { queryKind: "scope", view: { scope, entry } }, +}); +const updateResult = () => ({ + ...metadata, + outcome: "applied", + changed: true, + entry, +}); +const updateEvent = () => ({ ...metadata, event: PERSISTED_ATOM_UPDATE_EVENT }); + +describe("persisted atom typed gateway (explicit fake bridge unit evidence)", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(safeListen).mockResolvedValue(vi.fn()); + }); + + it("uses the real safeInvoke command boundary with direct read args", async () => { + vi.mocked(safeInvoke).mockResolvedValue(readResult()); + await expect(sendPersistedAtomState(readRequest)).resolves.toEqual( + readResult(), + ); + expect(safeInvoke).toHaveBeenCalledExactlyOnceWith( + "persisted_atom_read", + readRequest, + ); + }); + + it("sends a conditional mutation rather than a replacement atom", async () => { + vi.mocked(safeInvoke).mockResolvedValue(updateResult()); + await expect(updatePersistedAtomState(updateRequest)).resolves.toEqual( + updateResult(), + ); + expect(safeInvoke).toHaveBeenCalledExactlyOnceWith( + "persisted_atom_update", + updateRequest, + ); + }); + + it.each([ + null, + undefined, + [], + {}, + { success: true }, + { diagnostic: { source: "electron-host-diagnostic" } }, + ])("rejects non-protocol read receipt %j", async (response) => { + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect(sendPersistedAtomState(readRequest)).rejects.toMatchObject({ + code: "invalid_response", + }); + }); + + it.each([ + { ...readResult(), key: "unknown" }, + { ...readResult(), storeRevision: -1 }, + { ...readResult(), persistence: { state: "saved", savedRevision: 4 } }, + { + ...readResult(), + data: { + queryKind: "scope", + view: { scope: { ...scope, workspaceId: "other" }, entry }, + }, + }, + { + ...readResult(), + data: { + queryKind: "scope", + view: { scope, entry: { ...entry, draft: undefined } }, + }, + }, + { + ...readResult(), + data: { + queryKind: "scope", + view: { + scope, + entry: { + ...entry, + state: "consumed", + consumption: "accepted", + consumedAt: 100, + }, + }, + }, + }, + ])("rejects malformed or foreign read receipt", async (response) => { + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect(sendPersistedAtomState(readRequest)).rejects.toMatchObject({ + code: "invalid_response", + }); + }); + + it.each([ + { ...updateResult(), changed: "yes" }, + { ...updateResult(), outcome: "conflict", reason: "version_conflict" }, + { ...updateResult(), entry: { ...entry, draftId: "other" } }, + { ...updateResult(), entry: { ...entry, scopeVersion: "old" } }, + { ...updateResult(), entry: null }, + { ...updateResult(), payload: "unexpected" }, + ])("rejects malformed or mismatched mutation receipt", async (response) => { + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect(updatePersistedAtomState(updateRequest)).rejects.toMatchObject( + { code: "invalid_response" }, + ); + }); + + it("preserves valid conflict and unavailable states without fake saved success", async () => { + const conflict = { + ...metadata, + outcome: "conflict", + changed: false, + reason: "version_conflict", + entry, + }; + const unavailable = { + ...metadata, + persistence: { + state: "unavailable", + savedRevision: null, + diagnostic: { phase: "load", code: "INVALID_STATE" }, + }, + outcome: "unavailable", + data: null, + }; + vi.mocked(safeInvoke) + .mockResolvedValueOnce(conflict) + .mockResolvedValueOnce(unavailable); + await expect(updatePersistedAtomState(updateRequest)).resolves.toEqual( + conflict, + ); + await expect(sendPersistedAtomState(readRequest)).resolves.toEqual( + unavailable, + ); + }); + + it.each(["read", "update"])( + "exposes %s transport failure without the echoed draft or cause", + async (mode) => { + const failure = new Error("private draft 中😀 transport failed"); + vi.mocked(safeInvoke).mockRejectedValue(failure); + const promise = + mode === "read" + ? sendPersistedAtomState(readRequest) + : updatePersistedAtomState(updateRequest); + const error = await promise.catch((value: unknown) => value); + expect(error).toBeInstanceOf(PersistedAtomBridgeError); + expect(error).toMatchObject({ code: "unavailable" }); + expect(String(error)).not.toContain("private draft"); + expect(error).not.toHaveProperty("cause"); + expect(safeInvoke).toHaveBeenCalledTimes(1); + }, + ); + + it("delivers only a strict metadata event and returns the actual unsubscribe", async () => { + const unsubscribe = vi.fn(); + vi.mocked(safeListen).mockResolvedValue(unsubscribe); + const handler = vi.fn(); + await expect(listenPersistedAtomUpdates(handler)).resolves.toBe( + unsubscribe, + ); + expect(safeListen).toHaveBeenCalledWith( + PERSISTED_ATOM_UPDATE_EVENT, + expect.any(Function), + ); + const receive = vi.mocked(safeListen).mock.calls[0][1]; + receive({ payload: updateEvent() }); + expect(handler).toHaveBeenCalledExactlyOnceWith(updateEvent()); + }); + + it.each([ + null, + {}, + { ...updateEvent(), event: "other" }, + { ...updateEvent(), entry }, + { ...updateEvent(), draft: entry.draft }, + { ...updateEvent(), storeRevision: NaN }, + { ...updateEvent(), persistence: { state: "saved", savedRevision: 99 } }, + ])( + "drops malformed or content-bearing event without publishing it", + async (payload) => { + const handler = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + await listenPersistedAtomUpdates(handler); + vi.mocked(safeListen).mock.calls[0][1]({ payload }); + expect(handler).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + warn.mockRestore(); + error.mockRestore(); + }, + ); + + it("does not swallow errors from a subscriber handling a valid event", async () => { + const failure = new Error("subscriber failed"); + await listenPersistedAtomUpdates(() => { + throw failure; + }); + expect(() => + vi.mocked(safeListen).mock.calls[0][1]({ payload: updateEvent() }), + ).toThrow(failure); + }); + + it("rejects native listener failure with a fixed unavailable error", async () => { + vi.mocked(safeListen).mockRejectedValue( + new Error("private draft listener error"), + ); + await expect(listenPersistedAtomUpdates(vi.fn())).rejects.toMatchObject({ + code: "unavailable", + message: "Desktop persisted atom bridge unavailable", + }); + }); +}); + +const indexRequest = ( + workspaceId: string | null, +): PersistedAtomReadRequest => ({ + key: metadata.key, + query: { kind: "workspace-index", workspaceId }, +}); +const indexItem = (workspaceId: string | null = scope.workspaceId) => ({ + scope: { ...scope, workspaceId, kind: "draft" }, + identity: { draftId: "draft", scopeVersion: "v2" }, + updatedAt: 100, +}); +const indexResult = (workspaceId: string | null = scope.workspaceId) => ({ + ...metadata, + outcome: "ready", + data: { + queryKind: "workspace-index", + workspaceId, + items: [indexItem(workspaceId)], + }, +}); + +describe("same-atom scope and workspace discovery through the shared parser", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it.each(["workspace", null])( + "preserves the exact index query including %s", + async (workspaceId) => { + const request = indexRequest(workspaceId); + const response = indexResult(workspaceId); + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect(sendPersistedAtomState(request)).resolves.toEqual(response); + expect(safeInvoke).toHaveBeenCalledExactlyOnceWith( + "persisted_atom_read", + request, + ); + }, + ); + + it.each([ + { ...indexResult(), data: { ...indexResult().data, workspaceId: "other" } }, + { ...indexResult(), data: { ...indexResult().data, workspaceId: null } }, + { + ...indexResult(), + data: { ...indexResult().data, items: [indexItem("other")] }, + }, + { + ...indexResult(), + data: { ...indexResult().data, items: [indexItem(null)] }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [ + indexItem(), + { + ...indexItem(), + identity: { draftId: "second", scopeVersion: "v1" }, + }, + ], + }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [ + indexItem(), + { ...indexItem(), scope: { ...indexItem().scope, id: "other" } }, + ], + }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [{ ...indexItem(), draft: entry.draft }], + }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [{ ...indexItem(), title: "private title" }], + }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [{ ...indexItem(), updatedAt: -1 }], + }, + }, + { + ...indexResult(), + data: { + ...indexResult().data, + items: [ + { ...indexItem(), identity: { draftId: "draft", scopeVersion: "" } }, + ], + }, + }, + { + ...indexResult(), + data: { ...indexResult().data, items: [indexItem()], attachments: [] }, + }, + { ...indexResult(), view: { scope, entry } }, + readResult(), + ])( + "rejects foreign, duplicate or content-bearing index receipts", + async (response) => { + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect( + sendPersistedAtomState(indexRequest("workspace")), + ).rejects.toMatchObject({ code: "invalid_response" }); + }, + ); + + it("rejects index data for a scope read and the removed top-level view", async () => { + vi.mocked(safeInvoke) + .mockResolvedValueOnce(indexResult()) + .mockResolvedValueOnce({ + ...metadata, + outcome: "ready", + view: { scope, entry }, + }); + await expect(sendPersistedAtomState(readRequest)).rejects.toMatchObject({ + code: "invalid_response", + }); + await expect(sendPersistedAtomState(readRequest)).rejects.toMatchObject({ + code: "invalid_response", + }); + }); + + it.each([readRequest, indexRequest("workspace"), indexRequest(null)])( + "keeps loading data null without inventing an empty result", + async (request) => { + const response = { + ...metadata, + persistence: { state: "loading", savedRevision: null }, + outcome: "unavailable", + data: null, + }; + vi.mocked(safeInvoke).mockResolvedValue(response); + await expect(sendPersistedAtomState(request)).resolves.toEqual(response); + }, + ); + + it.each([ + { key: metadata.key, scope }, + { key: metadata.key, query: { kind: "scope", scope }, scope }, + { key: metadata.key, query: { kind: "workspace-index" } }, + { + key: metadata.key, + query: { kind: "workspace-index", workspaceId: null, scope }, + }, + { key: metadata.key, query: { kind: "workspace-index", workspaceId: 1 } }, + { + key: metadata.key, + query: { kind: "scope", scope: { ...scope, path: "/private" } }, + }, + { key: "other", query: { kind: "workspace-index", workspaceId: null } }, + ])( + "rejects invalid read requests before any bridge call", + async (request) => { + await expect( + sendPersistedAtomState(request as unknown as PersistedAtomReadRequest), + ).rejects.toMatchObject({ + code: "invalid_request", + message: "Desktop persisted atom request invalid", + }); + expect(safeInvoke).not.toHaveBeenCalled(); + }, + ); + + it("does not execute a query getter or leak its contents", async () => { + const getter = vi.fn(() => { + throw new Error("private request"); + }); + const request = Object.defineProperty({ key: metadata.key }, "query", { + enumerable: true, + get: getter, + }); + const error = await sendPersistedAtomState( + request as PersistedAtomReadRequest, + ).catch((value: unknown) => value); + expect(error).toMatchObject({ code: "invalid_request" }); + expect(String(error)).not.toContain("private request"); + expect(getter).not.toHaveBeenCalled(); + expect(safeInvoke).not.toHaveBeenCalled(); + }); + + it("correlates an awaited reply to the detached original query", async () => { + const request = { + key: metadata.key, + query: { kind: "workspace-index" as const, workspaceId: "workspace" }, + }; + let reply!: (value: unknown) => void; + vi.mocked(safeInvoke).mockImplementation( + () => + new Promise((resolve) => { + reply = resolve; + }), + ); + const pending = sendPersistedAtomState(request); + request.query.workspaceId = "other"; + expect(vi.mocked(safeInvoke).mock.calls[0][1]).toEqual( + indexRequest("workspace"), + ); + reply(indexResult("workspace")); + await expect(pending).resolves.toEqual(indexResult("workspace")); + }); +}); diff --git a/src/lib/api/persistedAtoms.ts b/src/lib/api/persistedAtoms.ts new file mode 100644 index 000000000..eef5c89b8 --- /dev/null +++ b/src/lib/api/persistedAtoms.ts @@ -0,0 +1,105 @@ +import type { UnlistenFn } from "@/lib/desktop-host/event"; +import { safeInvoke, safeListen } from "@/lib/dev-bridge"; +import { + parsePersistedAtomReadRequest, + parsePersistedAtomReadResult, + parsePersistedAtomUpdateEvent, + parsePersistedAtomUpdateResult, +} from "@/lib/api/persistedAtomProtocol"; +import type { + PersistedAtomReadRequest, + PersistedAtomReadResult, + PersistedAtomUpdateEvent, + PersistedAtomUpdateRequest, + PersistedAtomUpdateResult, +} from "@/lib/api/persistedAtomTypes"; + +export const PERSISTED_ATOM_UPDATE_EVENT = "persisted-atom-update"; + +export class PersistedAtomBridgeError extends Error { + constructor( + readonly code: "unavailable" | "invalid_request" | "invalid_response", + ) { + super( + { + unavailable: "Desktop persisted atom bridge unavailable", + invalid_request: "Desktop persisted atom request invalid", + invalid_response: "Desktop persisted atom response invalid", + }[code], + ); + this.name = "PersistedAtomBridgeError"; + } +} + +export async function sendPersistedAtomState( + request: PersistedAtomReadRequest, +): Promise { + const parsedRequest = parseResponse( + () => parsePersistedAtomReadRequest(request), + "invalid_request", + ); + const response = await invokePersistedAtom("persisted_atom_read", { + key: parsedRequest.key, + query: parsedRequest.query, + }); + return parseResponse(() => + parsePersistedAtomReadResult(response, parsedRequest), + ); +} + +export async function updatePersistedAtomState( + request: PersistedAtomUpdateRequest, +): Promise { + const response = await invokePersistedAtom("persisted_atom_update", { + key: request.key, + mutation: request.mutation, + }); + return parseResponse(() => parsePersistedAtomUpdateResult(response, request)); +} + +export async function listenPersistedAtomUpdates( + handler: (event: PersistedAtomUpdateEvent) => void, +): Promise { + try { + return await safeListen( + PERSISTED_ATOM_UPDATE_EVENT, + ({ payload }) => { + let event: PersistedAtomUpdateEvent | null; + try { + event = parsePersistedAtomUpdateEvent(payload); + } catch { + return; + } + // Invalid events cannot update a projection or report a saved receipt. + if (event !== null) handler(event); + }, + ); + } catch { + throw new PersistedAtomBridgeError("unavailable"); + } +} + +async function invokePersistedAtom( + command: "persisted_atom_read" | "persisted_atom_update", + args: Record, +): Promise { + try { + return await safeInvoke(command, args); + } catch { + // Bridge errors may echo arguments. Do not expose their text or cause. + throw new PersistedAtomBridgeError("unavailable"); + } +} + +function parseResponse( + parse: () => T | null, + code: "invalid_request" | "invalid_response" = "invalid_response", +): T { + try { + const response = parse(); + if (response !== null) return response; + } catch { + // The shared protocol owner is the only response validator. + } + throw new PersistedAtomBridgeError(code); +} diff --git a/src/lib/api/promptHistory.current-boundary.test.ts b/src/lib/api/promptHistory.current-boundary.test.ts index c4739f0c5..e5b72c0ec 100644 --- a/src/lib/api/promptHistory.current-boundary.test.ts +++ b/src/lib/api/promptHistory.current-boundary.test.ts @@ -5,9 +5,17 @@ import { describe, expect, it } from "vitest"; describe("prompt history canonical identity", () => { it("keeps both GUI append callers on the canonical v2 field", () => { - for (const file of ["src/components/agent/chat/components/EmptyState.tsx", "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts"]) { + for (const file of [ + "src/components/agent/chat/components/EmptyState.tsx", + "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts", + ]) { const source = readFileSync(path.resolve(process.cwd(), file), "utf8"); - expect(source, file).toMatch(/appendPromptHistory\(\{\s*threadId: sessionId,/u); + expect(source, file).toMatch( + /appendPromptHistory\(\{\s*threadId: result\.receipt\.threadId,/u, + ); + expect(source, file).not.toMatch( + /appendPromptHistory\(\{\s*threadId: sessionId,/u, + ); expect(source, file).not.toMatch(/appendPromptHistory\(\{\s*sessionId,/u); } }); diff --git a/src/lib/dev-bridge/http-client.persistedAtom.contract.test.ts b/src/lib/dev-bridge/http-client.persistedAtom.contract.test.ts new file mode 100644 index 000000000..002c844f6 --- /dev/null +++ b/src/lib/dev-bridge/http-client.persistedAtom.contract.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("@/lib/desktop-runtime", () => ({ + hasDesktopHostInvokeCapability: () => false, + hasDesktopHostRuntimeMarkers: () => false, +})); +import { + __resetDevBridgeHttpStateForTests, + invokeViaHttp, +} from "./http-client"; + +const privateText = "PRIVATE_HTTP_DRAFT_中😀"; +const args = { + key: "composer-drafts-v1", + mutation: { op: "put", draft: { text: privateText } }, +}; +function healthy() { + return new Response( + JSON.stringify({ status: "ok", transport: "electron-host" }), + { status: 200 }, + ); +} + +describe("persisted atom HTTP diagnostic privacy", () => { + beforeEach(() => { + __resetDevBridgeHttpStateForTests(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "debug").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + __resetDevBridgeHttpStateForTests(); + }); + + it.each([ + { + kind: "scope", + scope: { workspaceId: privateText, kind: "draft", id: privateText }, + }, + { kind: "workspace-index", workspaceId: privateText }, + { kind: "workspace-index", workspaceId: null }, + ])( + "preserves the read query/data without logging scope or workspace content", + async (query) => { + const request = { key: "composer-drafts-v1", query }; + const response = { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + outcome: "ready", + data: + query.kind === "scope" + ? { queryKind: "scope", view: { scope: query.scope, entry: null } } + : { + queryKind: "workspace-index", + workspaceId: query.workspaceId, + items: [], + }, + }; + const fetch = vi + .fn() + .mockResolvedValueOnce(healthy()) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: response }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetch); + await expect( + invokeViaHttp("persisted_atom_read", request), + ).resolves.toEqual(response); + expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ + cmd: "persisted_atom_read", + args: request, + }); + expect(console.log).toHaveBeenCalledWith( + "[DevBridge] HTTP 调用: persisted_atom_read", + { key: "composer-drafts-v1" }, + ); + expect(JSON.stringify(vi.mocked(console.log).mock.calls)).not.toContain( + privateText, + ); + }, + ); + + it("keeps the real wire payload and logs only fixed metadata", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(healthy()) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: { outcome: "applied" } }), { + status: 200, + }), + ); + vi.stubGlobal("fetch", fetch); + await expect(invokeViaHttp("persisted_atom_update", args)).resolves.toEqual( + { outcome: "applied" }, + ); + expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ + cmd: "persisted_atom_update", + args, + }); + expect(console.log).toHaveBeenCalledWith( + "[DevBridge] HTTP 调用: persisted_atom_update", + { key: "composer-drafts-v1", op: "put" }, + ); + expect(JSON.stringify(vi.mocked(console.log).mock.calls)).not.toContain( + privateText, + ); + }); + + it("does not echo rejected draft content to HTTP console diagnostics", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(healthy()) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: privateText }), { status: 200 }), + ), + ); + await expect(invokeViaHttp("persisted_atom_update", args)).rejects.toThrow( + privateText, + ); + const messages = [ + ...vi.mocked(console.log).mock.calls, + ...vi.mocked(console.debug).mock.calls, + ...vi.mocked(console.error).mock.calls, + ]; + expect( + messages + .flat() + .map((value) => + value instanceof Error ? value.message : JSON.stringify(value), + ) + .join("\n"), + ).not.toContain(privateText); + expect(console.error).toHaveBeenCalledWith( + "[DevBridge] HTTP 调用失败: persisted_atom_update", + "Persisted atom IPC request failed", + ); + }); + + it("preserves existing console arguments for other commands", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(healthy()) + .mockResolvedValueOnce( + new Response(JSON.stringify({ result: [] }), { status: 200 }), + ), + ); + const request = { workspaceId: "workspace-1" }; + await invokeViaHttp("workspace_list", request); + expect(vi.mocked(console.log).mock.calls[0][1]).toBe(request); + }); +}); diff --git a/src/lib/dev-bridge/http-client.ts b/src/lib/dev-bridge/http-client.ts index e2235263d..f2a614f31 100644 --- a/src/lib/dev-bridge/http-client.ts +++ b/src/lib/dev-bridge/http-client.ts @@ -18,6 +18,11 @@ import { shouldBypassDevBridgeCooldown, shouldRetryDevBridgeReadCommand, } from "./commandPolicy"; +import { + invokeArgsPreview, + invokeConsoleError, + isPersistedAtomCommand, +} from "./invokeDiagnostics"; const BRIDGE_URL = "http://127.0.0.1:3030/invoke"; const BRIDGE_HEALTH_URL = "http://127.0.0.1:3030/health"; @@ -717,7 +722,12 @@ export async function invokeViaHttp( cmd: string, args?: unknown, ): Promise { - console.log(`[DevBridge] HTTP 调用: ${cmd}`, args); + console.log( + `[DevBridge] HTTP 调用: ${cmd}`, + isPersistedAtomCommand(cmd) + ? invokeArgsPreview(cmd, args as Record | undefined) + : args, + ); const timeoutMs = resolveBridgeRequestTimeoutMs(cmd, args); const bypassCooldown = shouldBypassBridgeCooldown(cmd); @@ -747,7 +757,10 @@ export async function invokeViaHttp( if (!shouldRetryBridgeInvoke(cmd, message)) { throw error; } - console.debug(`[DevBridge] HTTP 读命令重试: ${cmd}`, error); + console.debug( + `[DevBridge] HTTP 读命令重试: ${cmd}`, + invokeConsoleError(cmd, error), + ); await ensureBridgeReachable({ bypassCooldown: true, forceProbe: true, @@ -773,9 +786,15 @@ export async function invokeViaHttp( markBridgeUnavailable(); } if (isBridgeConnectionError(message)) { - console.debug(`[DevBridge] HTTP 暂不可用: ${cmd}`, e); + console.debug( + `[DevBridge] HTTP 暂不可用: ${cmd}`, + invokeConsoleError(cmd, e), + ); } else { - console.error(`[DevBridge] HTTP 调用失败: ${cmd}`, e); + console.error( + `[DevBridge] HTTP 调用失败: ${cmd}`, + invokeConsoleError(cmd, e), + ); } throw e; } diff --git a/src/lib/dev-bridge/invokeDiagnostics.ts b/src/lib/dev-bridge/invokeDiagnostics.ts new file mode 100644 index 000000000..f82e154cb --- /dev/null +++ b/src/lib/dev-bridge/invokeDiagnostics.ts @@ -0,0 +1,108 @@ +const INVOKE_ERROR_TEXT_LIMIT = 800; + +const SECRET_PATTERNS: Array<[RegExp, string]> = [ + [/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"], + [/\bapi[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "api_key=***"], + [ + /\baccess[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, + "access_token=***", + ], + [ + /\brefresh[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, + "refresh_token=***", + ], + [/\btoken\s*[:=]\s*["']?[A-Za-z0-9._-]{10,}["']?/gi, "token=***"], + [/\bsk-[A-Za-z0-9]{12,}\b/g, "sk-***"], +]; + +export function sanitizeText(input: string): string { + let sanitized = input; + for (const [pattern, replacement] of SECRET_PATTERNS) { + sanitized = sanitized.replace(pattern, replacement); + } + return sanitized; +} + +function sanitizeValue(value: unknown, depth = 0): unknown { + if (depth > 4) { + return "[depth_limited]"; + } + if (value === null || value === undefined) { + return value; + } + if (typeof value === "string") { + return sanitizeText(value); + } + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => sanitizeValue(item, depth + 1)); + } + if (typeof value === "object") { + const output: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + output[key] = sanitizeValue(nestedValue, depth + 1); + } + return output; + } + return sanitizeText(String(value)); +} + +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + const core = error.message || error.name || "Unknown error"; + return sanitizeText(core).slice(0, INVOKE_ERROR_TEXT_LIMIT); + } + return sanitizeText(String(error || "Unknown error")).slice( + 0, + INVOKE_ERROR_TEXT_LIMIT, + ); +} + +export function isPersistedAtomCommand(command: string): boolean { + return ( + command === "persisted_atom_read" || command === "persisted_atom_update" + ); +} + +function ownDataValue(value: unknown, key: string): unknown { + if (!value || typeof value !== "object") return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +export function invokeArgsPreview( + command: string, + args: Record | undefined, +): Record | undefined { + if (!args) return undefined; + if (!isPersistedAtomCommand(command)) + return sanitizeValue(args) as Record; + // 草稿、媒体、scope 与任意身份文本不能进入持久化诊断缓冲。 + const key = + ownDataValue(args, "key") === "composer-drafts-v1" + ? "composer-drafts-v1" + : "unknown"; + if (command === "persisted_atom_read") return { key }; + const candidate = ownDataValue(ownDataValue(args, "mutation"), "op"); + const op = + typeof candidate === "string" && + ["put", "promote", "consume", "clear"].includes(candidate) + ? candidate + : "unknown"; + return { key, op }; +} + +export function invokeDiagnosticError(command: string, error: unknown): string { + // 不检查草稿调用错误的 message/data;它们可能回显完整用户内容。 + return isPersistedAtomCommand(command) + ? "Persisted atom IPC request failed" + : toErrorMessage(error); +} + +export function invokeConsoleError(command: string, error: unknown): unknown { + return isPersistedAtomCommand(command) + ? invokeDiagnosticError(command, error) + : error; +} diff --git a/src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts b/src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts new file mode 100644 index 000000000..f45c2fcca --- /dev/null +++ b/src/lib/dev-bridge/safeInvoke.persistedAtom.contract.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ElectronHostBridge } from "@/lib/electron-host"; + +const transport = vi.hoisted(() => ({ + invokeViaHttp: vi.fn(), + isDevBridgeAvailable: vi.fn(() => false), +})); +vi.mock("./http-client", () => ({ + ...transport, + hasDevBridgeEventListenerCapability: () => false, + listenViaHttpEvent: vi.fn(), + normalizeDevBridgeError: (_command: string, error: unknown) => error, + resolveBridgeRequestTimeoutMs: () => 0, +})); +vi.mock("./mockPriorityCommands", () => ({ + shouldDisallowMockEventFallbackInBrowser: () => false, +})); +import { + getInvokeErrorBuffer, + getInvokeTraceBuffer, + safeInvoke, + safeListen, +} from "./safeInvoke"; + +const privateText = "PRIVATE_DRAFT_TEXT_中😀"; +const privateMedia = "PRIVATE_BASE64_BYTES"; +const request = { + key: "composer-drafts-v1", + mutation: { + op: "put", + scope: { workspaceId: privateText, kind: "local", id: privateText }, + draftId: privateText, + expectedVersion: null, + nextVersion: privateText, + draft: { text: privateText, images: [{ data: privateMedia }] }, + }, +}; + +function installBridge(invoke = vi.fn().mockResolvedValue({ saved: true })) { + window.electronAPI = { + invoke, + supportsCommand: () => true, + listen: vi.fn(() => () => undefined), + emit: vi.fn(), + } as ElectronHostBridge; + return invoke; +} + +function buffers() { + return JSON.stringify({ + traces: getInvokeTraceBuffer(), + errors: getInvokeErrorBuffer(), + storage: { ...window.localStorage }, + }); +} + +describe("persisted atom current bridge diagnostics", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + delete window.electronAPI; + transport.isDevBridgeAvailable.mockReturnValue(false); + }); + + it("真实 IPC 原样发送富草稿,诊断仅保存有限元数据", async () => { + const invoke = installBridge(); + await expect(safeInvoke("persisted_atom_update", request)).resolves.toEqual( + { saved: true }, + ); + expect(invoke).toHaveBeenCalledWith("persisted_atom_update", request); + expect(getInvokeTraceBuffer()).toEqual([ + expect.objectContaining({ + command: "persisted_atom_update", + transport: "electron-ipc", + status: "success", + args_preview: { key: "composer-drafts-v1", op: "put" }, + }), + ]); + expect(buffers()).not.toContain(privateText); + expect(buffers()).not.toContain(privateMedia); + }); + + it("读取 scope 与返回草稿均不进入诊断", async () => { + const response = { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + outcome: "ready", + data: { + queryKind: "scope", + view: { scope: request.mutation.scope, entry: null }, + }, + }; + installBridge(vi.fn().mockResolvedValue(response)); + await expect( + safeInvoke("persisted_atom_read", { + key: "composer-drafts-v1", + query: { kind: "scope", scope: request.mutation.scope }, + }), + ).resolves.toBe(response); + expect(getInvokeTraceBuffer()[0].args_preview).toEqual({ + key: "composer-drafts-v1", + }); + expect(buffers()).not.toContain(privateText); + }); + + it.each([privateText, null])( + "workspace-index query/data 保持原样且工作区不进入诊断", + async (workspaceId) => { + const response = { + key: "composer-drafts-v1", + storeRevision: 0, + persistence: { state: "saved", savedRevision: 0 }, + outcome: "ready", + data: { queryKind: "workspace-index", workspaceId, items: [] }, + }; + const invoke = vi.fn().mockResolvedValue(response); + installBridge(invoke); + const args = { + key: "composer-drafts-v1", + query: { kind: "workspace-index", workspaceId }, + }; + await expect(safeInvoke("persisted_atom_read", args)).resolves.toBe( + response, + ); + expect(invoke).toHaveBeenCalledExactlyOnceWith( + "persisted_atom_read", + args, + ); + expect(getInvokeTraceBuffer()[0].args_preview).toEqual({ + key: "composer-drafts-v1", + }); + expect(buffers()).not.toContain(privateText); + }, + ); + + it.each(["electron-ipc", "http-bridge"])( + "%s 错误正文不落盘且保留原始调用失败", + async (source) => { + const error = new Error(privateText + privateMedia); + if (source === "electron-ipc") + installBridge(vi.fn().mockRejectedValue(error)); + else { + transport.isDevBridgeAvailable.mockReturnValue(true); + transport.invokeViaHttp.mockRejectedValue(error); + } + await expect(safeInvoke("persisted_atom_update", request)).rejects.toBe( + error, + ); + expect(getInvokeTraceBuffer()[0]).toMatchObject({ + command: "persisted_atom_update", + transport: source, + status: "error", + }); + expect(getInvokeErrorBuffer()[0]).toMatchObject({ + command: "persisted_atom_update", + transport: source, + }); + expect(buffers()).not.toContain(privateText); + expect(buffers()).not.toContain(privateMedia); + }, + ); + + it("无可用通道失败仍记录 command/status,不收录草稿", async () => { + await expect(safeInvoke("persisted_atom_update", request)).rejects.toThrow( + "IPC 不可用", + ); + expect(getInvokeTraceBuffer()[0]).toMatchObject({ + command: "persisted_atom_update", + transport: "unavailable", + status: "error", + }); + expect(buffers()).not.toContain(privateText); + }); + + it("diagnostic preview 不执行不可信请求的 getter", async () => { + const getter = vi.fn(() => privateText); + const args = Object.defineProperty({}, "key", { + enumerable: true, + get: getter, + }); + installBridge(); + await safeInvoke("persisted_atom_update", args); + expect(getter).not.toHaveBeenCalled(); + expect(buffers()).not.toContain(privateText); + }); + + it("非法 key/op 被固定标签替代,不写任意文本", async () => { + installBridge(); + await safeInvoke("persisted_atom_update", { + key: privateText, + mutation: { op: privateMedia }, + }); + expect(getInvokeTraceBuffer()[0].args_preview).toEqual({ + key: "unknown", + op: "unknown", + }); + expect(buffers()).not.toContain(privateText); + expect(buffers()).not.toContain(privateMedia); + }); + + it("原生保存通知订阅失败明确抛出,不能返回空取消函数", async () => { + installBridge(); + const failure = new Error("native event bridge unavailable"); + window.electronAPI!.listen = vi.fn().mockRejectedValue(failure); + await expect(safeListen("persisted-atom-update", vi.fn())).rejects.toThrow( + "原生事件桥不可用", + ); + }); +}); diff --git a/src/lib/dev-bridge/safeInvoke.ts b/src/lib/dev-bridge/safeInvoke.ts index 628cb4967..db4f2e70b 100644 --- a/src/lib/dev-bridge/safeInvoke.ts +++ b/src/lib/dev-bridge/safeInvoke.ts @@ -23,6 +23,13 @@ import { isElectronDevBridgeFallbackAvailable, } from "@/lib/electron-host"; +import { + invokeArgsPreview, + invokeDiagnosticError, + sanitizeText, + toErrorMessage, +} from "./invokeDiagnostics"; + export interface InvokeErrorBufferEntry { timestamp: string; command: string; @@ -45,69 +52,8 @@ const INVOKE_ERROR_BUFFER_KEY = "lime_invoke_error_buffer_v1"; const INVOKE_ERROR_BUFFER_LIMIT = 120; const INVOKE_TRACE_BUFFER_KEY = "lime_invoke_trace_buffer_v1"; const INVOKE_TRACE_BUFFER_LIMIT = 240; -const INVOKE_ERROR_TEXT_LIMIT = 800; const USER_TIMING_PREFIX = "lime:safeInvoke"; -const SECRET_PATTERNS: Array<[RegExp, string]> = [ - [/\bBearer\s+[A-Za-z0-9._-]+\b/gi, "Bearer ***"], - [/\bapi[_-]?key\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, "api_key=***"], - [ - /\baccess[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, - "access_token=***", - ], - [ - /\brefresh[_-]?token\s*[:=]\s*["']?[A-Za-z0-9._-]+["']?/gi, - "refresh_token=***", - ], - [/\btoken\s*[:=]\s*["']?[A-Za-z0-9._-]{10,}["']?/gi, "token=***"], - [/\bsk-[A-Za-z0-9]{12,}\b/g, "sk-***"], -]; - -function sanitizeText(input: string): string { - let sanitized = input; - for (const [pattern, replacement] of SECRET_PATTERNS) { - sanitized = sanitized.replace(pattern, replacement); - } - return sanitized; -} - -function sanitizeValue(value: unknown, depth = 0): unknown { - if (depth > 4) { - return "[depth_limited]"; - } - if (value === null || value === undefined) { - return value; - } - if (typeof value === "string") { - return sanitizeText(value); - } - if (typeof value === "number" || typeof value === "boolean") { - return value; - } - if (Array.isArray(value)) { - return value.map((item) => sanitizeValue(item, depth + 1)); - } - if (typeof value === "object") { - const output: Record = {}; - for (const [key, nestedValue] of Object.entries(value)) { - output[key] = sanitizeValue(nestedValue, depth + 1); - } - return output; - } - return sanitizeText(String(value)); -} - -function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - const core = error.message || error.name || "Unknown error"; - return sanitizeText(core).slice(0, INVOKE_ERROR_TEXT_LIMIT); - } - return sanitizeText(String(error || "Unknown error")).slice( - 0, - INVOKE_ERROR_TEXT_LIMIT, - ); -} - function supportsUserTiming(): boolean { return ( typeof performance !== "undefined" && @@ -151,7 +97,10 @@ function normalizeDesktopHostListenError(event: string, error: unknown): Error { } function shouldFailClosedOnMissingNativeEventBridge(event: string): boolean { - return shouldDisallowMockEventFallbackInBrowser(event); + return ( + event === "persisted-atom-update" || + shouldDisallowMockEventFallbackInBrowser(event) + ); } function electronUnsupportedCommandError(command: string): Error { @@ -340,10 +289,8 @@ function recordInvokeError( timestamp: new Date().toISOString(), command: sanitizeText(command), transport, - error: toErrorMessage(error), - args_preview: args - ? (sanitizeValue(args) as Record) - : undefined, + error: invokeDiagnosticError(command, error), + args_preview: invokeArgsPreview(command, args), }; current.push(entry); writeInvokeErrorBuffer(current); @@ -364,10 +311,8 @@ function recordInvokeTrace( transport, status, duration_ms: Math.max(0, Date.now() - startedAt), - error: error ? toErrorMessage(error) : undefined, - args_preview: args - ? (sanitizeValue(args) as Record) - : undefined, + error: error ? invokeDiagnosticError(command, error) : undefined, + args_preview: invokeArgsPreview(command, args), }; current.push(entry); writeInvokeTraceBuffer(current); diff --git a/src/lib/governance/agentCommandCatalog.json b/src/lib/governance/agentCommandCatalog.json index 136c13fa9..65ac148aa 100644 --- a/src/lib/governance/agentCommandCatalog.json +++ b/src/lib/governance/agentCommandCatalog.json @@ -17,6 +17,7 @@ "open_browser_remote_debugging_page_cmd", "open_browser_connector_guide_window" ], + "persistedAtomCommands": ["persisted_atom_read", "persisted_atom_update"], "systemUtilityCommands": [ "open_external_url", "show_desktop_notification", diff --git a/src/lib/governance/appServerRuntimeBoundary.testSupport.ts b/src/lib/governance/appServerRuntimeBoundary.testSupport.ts index 69af8c327..c5d05aacf 100644 --- a/src/lib/governance/appServerRuntimeBoundary.testSupport.ts +++ b/src/lib/governance/appServerRuntimeBoundary.testSupport.ts @@ -206,6 +206,7 @@ export const ALLOWED_EXTERNAL_BACKEND_LAUNCH_FILES = new Set([ "scripts/electron/codex-import-continuation-fixture-smoke.test.mjs", "scripts/electron/code-artifact-workbench-fixture-smoke.mjs", "scripts/electron/code-artifact-workbench-fixture-smoke.test.mjs", + "scripts/electron/lib/composer-draft-fixture-driver.mjs", "scripts/electron/local-history-import-real-sample-visual-audit-smoke.test.mjs", "scripts/electron/session-history-fixture-smoke.test.mjs", "scripts/electron/strict-review-gate-b.mjs", diff --git a/src/lib/workspace/navigation.ts b/src/lib/workspace/navigation.ts index 03e5a3b2b..63c152a2a 100644 --- a/src/lib/workspace/navigation.ts +++ b/src/lib/workspace/navigation.ts @@ -1,5 +1,16 @@ import type { AgentPageParams } from "@/types/page"; +export interface NewConversationRequest { + readonly id: number; + readonly workspaceId: string | null; + readonly freshDraft: boolean; +} + +export type StartNewConversation = (options: { + projectId: string | null; + freshDraft: boolean; +}) => void; + export function buildHomeAgentParams( overrides: Partial = {}, ): AgentPageParams { @@ -9,7 +20,7 @@ export function buildHomeAgentParams( immersiveHome: overrides.immersiveHome ?? false, theme: "general", lockTheme: false, - newChatAt: Date.now(), + newChatAt: overrides.newChatAt ?? Date.now(), }; } diff --git a/src/types/page.ts b/src/types/page.ts index 23bfdf222..9f3bcbfad 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -13,6 +13,12 @@ import type { InputCapabilitySendRoute } from "@/components/agent/chat/skill-sel export type WorkspaceTheme = "general"; +/** 工作区发布的可见位置;仅用于导航,不持有会话业务状态。 */ +export interface AgentLocation { + sessionId: string | null; + workspaceId: string | null; +} + export type Page = | "agent" | "experts" @@ -55,6 +61,7 @@ export interface AgentInitialKnowledgePackSelectionParams { * 用于从项目入口跳转到创作界面时传递项目上下文 */ export interface AgentPageParams { + newConversationRequest?: import("@/lib/workspace/navigation").NewConversationRequest; projectId?: string; contentId?: string; /** 进入 Agent 后优先恢复到指定会话 */ diff --git a/src/types/settings.ts b/src/types/settings.ts index 8240e451a..50c148b8b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -39,7 +39,6 @@ export enum SettingsTabs { WebSearch = "web-search", Environment = "environment", ExecutionPolicy = "execution-policy", - ExecutionTracker = "execution-tracker", Experimental = "experimental", Developer = "developer", @@ -63,29 +62,3 @@ export interface SettingsTabInfo { group: SettingsGroupKey; experimental?: boolean; } - -/** - * 分组到标签页的映射 - */ -export const SETTINGS_GROUPS: Record = { - [SettingsGroupKey.Overview]: [SettingsTabs.Home], - [SettingsGroupKey.Account]: [SettingsTabs.Profile, SettingsTabs.Stats], - [SettingsGroupKey.General]: [ - SettingsTabs.Appearance, - SettingsTabs.Memory, - SettingsTabs.ArchivedConversations, - ], - [SettingsGroupKey.Agent]: [ - SettingsTabs.Providers, - SettingsTabs.Skills, - SettingsTabs.MediaServices, - ], - [SettingsGroupKey.System]: [ - SettingsTabs.McpServer, - SettingsTabs.WebSearch, - SettingsTabs.Environment, - SettingsTabs.ExecutionPolicy, - SettingsTabs.Developer, - SettingsTabs.About, - ], -};