From 25d0a198f16515750169f4dceadc488db0ecad0d Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:39:29 -0700 Subject: [PATCH 1/7] feat(conversations): unify native session continuation --- .../managed-cloud-collaboration.md | 54 +- ...ersation-events-plane-design-2026-08-21.md | 178 +- scripts/tauri/instance-profile.cjs | 29 +- scripts/tauri/instance-profile.test.cjs | 12 + scripts/tauri/open-instance.cjs | 41 + src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 5 +- .../src/core/session/persistence/messages.rs | 819 ++- .../src/core/session/persistence/mod.rs | 14 +- .../src/state/commands/session/compaction.rs | 92 +- src-tauri/crates/app-paths/src/home.rs | 40 + .../integrations/src/cli_binary_resolver.rs | 38 + .../src/key_store/agent_env_builder.rs | 33 +- .../key-vault/src/key_store/tests/tests.rs | 28 + .../src/sources/claude_code/history.rs | 8 +- .../sources/claude_code/history/cache_sync.rs | 10 +- .../sources/claude_code/history/discovery.rs | 10 +- .../sources/claude_code/history/metadata.rs | 13 +- .../src/sources/claude_code/history/replay.rs | 108 +- .../src/sources/claude_code/history/tools.rs | 7 +- .../src/sources/claude_code/history/types.rs | 10 + .../claude_code/history/windows/index.rs | 9 +- .../src/sources/claude_code/history_tests.rs | 234 +- .../src/sources/codex/app/index.rs | 15 +- .../src/sources/codex/app/normalize.rs | 4 +- .../src/sources/codex/app/transcript.rs | 2 + .../sources/codex/app/transcript/messages.rs | 153 + .../sources/codex/app/transcript/parser.rs | 349 +- .../src/sources/codex/app/transcript/tests.rs | 285 + .../transcript/tool_calls/normalization.rs | 34 +- .../src/sources/codex/app_tests.rs | 161 + .../imported_history/managed_mirror.rs | 100 + .../src/sources/imported_history/mod.rs | 35 +- .../crates/session-persistence/src/editing.rs | 126 + .../crates/session-persistence/src/lib.rs | 4 +- .../crates/session-persistence/src/schema.rs | 53 +- .../session-persistence/src/turn_index.rs | 32 +- .../cli/commands/resume_delete.rs | 26 +- .../src/agent_sessions/cli/commands/run.rs | 164 +- .../agent_sessions/cli/commands/transcript.rs | 340 +- src-tauri/src/agent_sessions/cli/mod.rs | 87 + src-tauri/src/agent_sessions/cli/native_ir.rs | 1158 ++++ .../agent_sessions/cli/native_materializer.rs | 4708 +++++++++++++++++ .../src/agent_sessions/cli/native_store.rs | 397 ++ .../agent_sessions/cli/native_transcript.rs | 35 + .../agent_sessions/cli/parsers/claude_code.rs | 43 +- .../cli/parsers/codex_app_server.rs | 645 ++- .../cli/parsers/codex_app_server/catalog.rs | 581 ++ .../parsers/tests/codex_app_server_tests.rs | 218 +- .../parsers/tests/parser_integration_tests.rs | 65 + .../cli/persistence/resume_state_tests.rs | 193 + .../cli/persistence/session_crud.rs | 7 +- .../cli/persistence/session_crud/create.rs | 154 +- .../persistence/session_crud/field_updates.rs | 8 +- .../persistence/session_crud/resume_state.rs | 217 +- .../cli/session_runner/command.rs | 47 +- .../cli/session_runner/env_setup.rs | 4 +- .../cli/session_runner/finalize.rs | 63 +- .../cli/session_runner/helpers.rs | 202 +- .../cli/session_runner/input_assembly.rs | 263 +- .../cli/session_runner/lifecycle.rs | 319 +- .../agent_sessions/cli/session_runner/mod.rs | 14 +- .../cli/session_runner/session.rs | 205 +- .../cli/session_runner/session/mcp_inject.rs | 50 + .../cli/session_runner/session/tests.rs | 177 + .../session_runner/session/transport_acp.rs | 3 +- .../session/transport_app_server.rs | 82 +- .../session/transport_standard.rs | 31 +- .../src/agent_sessions/cli/skill_sync.rs | 3 + src-tauri/src/agent_sessions/cli/tests/mod.rs | 1 - .../cli/tests/runner_command_tests.rs | 81 +- .../agent_sessions/cli/tests/runner_tests.rs | 52 - .../event_pipeline/commands/batch_update.rs | 45 +- .../commands/cache_bridge_tests.rs | 40 + .../commands/event_conversion.rs | 61 +- .../event_pipeline/commands/store_commands.rs | 129 +- .../agent_sessions/event_pipeline/derived.rs | 18 +- .../event_pipeline/ingestion/consolidator.rs | 36 +- .../event_pipeline/ingestion/normalizer.rs | 47 +- .../ingestion/tests/consolidator_tests.rs | 87 +- .../ingestion/tests/normalizer_tests.rs | 62 + .../event_pipeline/store/event_ops.rs | 104 +- .../event_pipeline/store/helpers.rs | 161 +- .../event_pipeline/store/hydration.rs | 16 +- .../event_pipeline/tests/derived_tests.rs | 11 + .../event_pipeline/tests/store_tests.rs | 178 +- src-tauri/src/agent_sessions/mod.rs | 1 + .../agent_sessions/session_directory/patch.rs | 17 +- src-tauri/src/agent_sessions/turn_intents.rs | 99 + src-tauri/src/app/setup_hook/services.rs | 63 +- src-tauri/src/commands/handler_list.inc | 6 + .../src/infrastructure/dev_bundled_auth.rs | 6 +- .../src/orgtrack/history_commands/scan.rs | 292 +- src/api/tauri/externalHistory/appOpen.ts | 14 +- src/api/tauri/rpc/procedures/cli.ts | 4 + src/api/tauri/rpc/procedures/sessionCore.ts | 12 + .../__tests__/agentSessionMessages.test.ts | 41 + src/api/tauri/rpc/schemas/agentSession.ts | 13 +- src/api/tauri/rpc/schemas/cli.ts | 6 + src/api/tauri/rpc/schemas/sessionCore.ts | 27 + .../tauri/session/__tests__/session.test.ts | 14 + src/api/tauri/session/index.ts | 2 + src/app/root/e2e/helpers/cloud.ts | 2 + .../sessionHelpers/inspectChatState.ts | 3 - src/app/root/e2e/helpers/sessions.ts | 2 - src/app/root/e2e/types.ts | 1 - .../root/services/GlobalSessionSync/index.tsx | 3 +- src/components/ModelSelectorPill/index.tsx | 21 +- .../ChatPanel/ChatFloatingComposer.tsx | 3 + .../ChatHistory/ChatHistory.types.ts | 13 + .../chatItemPipeline/__tests__/dedup.test.ts | 64 + .../ChatHistory/chatItemPipeline/dedup.ts | 41 +- .../ChatHistory/chatItemPipeline/pipeline.ts | 4 +- .../components/ChatHistoryListEquality.ts | 7 + .../components/ChatHistoryView.tsx | 11 +- .../components/PlanningIndicatorBridge.tsx | 76 +- .../__tests__/ChatHistoryListIdentity.test.ts | 31 + .../__tests__/PlanningIndicatorBridge.test.ts | 119 + .../__tests__/useChatGroupsProjection.test.ts | 80 + .../useChatScroll.followIntent.test.ts | 328 ++ .../__tests__/useEditUserMessage.test.ts | 530 +- .../useGroupHeaderRenderer.truncation.test.ts | 78 +- .../hooks/useChatGroupsProjection.ts | 51 +- .../hooks/useChatHistoryItemActions.ts | 10 +- .../ChatHistory/hooks/useChatScroll.ts | 41 +- .../ChatHistory/hooks/useChatScrollPin.ts | 113 +- .../hooks/useChatViewportController.ts | 3 + .../ChatHistory/hooks/useEditUserMessage.ts | 201 +- .../hooks/useGroupHeaderRenderer.tsx | 25 +- src/engines/ChatPanel/ChatHistory/index.tsx | 138 +- .../renderers/GroupItemRenderer.tsx | 7 + .../ConversationSenderMetadataContext.tsx | 54 + .../ChatItems/ParentAgentSenderContext.tsx | 4 +- .../SharedConversationSenderContext.tsx | 16 - .../ChatPanel/ChatItems/UserChatItem.tsx | 360 +- .../ChatItems/__tests__/UserChatItem.test.ts | 222 +- .../useUserMessageDeliveryActions.ts | 102 + .../ChatPanel/ChatPanelContent.test.ts | 1 - src/engines/ChatPanel/ChatPanelContent.tsx | 8 +- src/engines/ChatPanel/ChatPanelHeader.tsx | 3 + src/engines/ChatPanel/ChatView.tsx | 334 +- .../ChatViewComposerSection.types.ts | 2 + .../ChatPanel/ChatViewHistorySurface.tsx | 146 +- src/engines/ChatPanel/ChatViewLiveRegion.tsx | 44 +- .../ChatPanel/ChatViewPostHistoryOverlays.tsx | 87 +- src/engines/ChatPanel/ChatViewTypes.ts | 14 +- .../ConversationExecutionBindingContext.ts | 18 + .../ConversationStreamProvider.test.ts | 399 ++ .../ChatPanel/ConversationStreamProvider.tsx | 698 ++- .../InputArea/components/ModelPill.test.ts | 327 ++ .../InputArea/components/ModelPill.tsx | 260 +- .../components/QueuedMessageItem.tsx | 19 +- .../components/QueuedMessages.test.ts | 23 +- .../InputArea/components/QueuedMessages.tsx | 13 +- .../hooks/__tests__/useEditMode.test.ts | 1 + .../InputArea/hooks/useComposerSections.ts | 19 +- .../ChatPanel/InputArea/hooks/useEditMode.ts | 10 +- src/engines/ChatPanel/InputArea/index.tsx | 44 +- src/engines/ChatPanel/SideChat/index.tsx | 87 +- .../chatViewComposerVisibility.test.ts | 8 +- .../ChatPanel/chatViewComposerVisibility.ts | 8 +- .../SessionHeaderActionsMenu.test.ts | 71 +- .../components/SessionHeaderActionsMenu.tsx | 6 +- .../components/SessionOpenInAppMenuItem.tsx | 46 +- .../conversationTargetSelection.test.ts | 446 ++ .../ChatPanel/conversationTargetSelection.ts | 356 ++ .../ChatPanel/externalHistoryFork.test.ts | 223 - src/engines/ChatPanel/externalHistoryFork.ts | 166 - .../useConversationSubmitRouter.test.ts | 118 + .../useConversationSubmitRouter.ts | 201 + .../importedSessionSubmitReadiness.test.ts | 92 + .../hooks/importedSessionSubmitReadiness.ts | 18 + .../useAgentOrgGroupChatLiveSessions.tsx | 23 +- .../hooks/useChatViewAgentOrgSurface.tsx | 24 +- .../hooks/useChatViewMessageQueue.test.ts | 74 + .../hooks/useChatViewMessageQueue.ts | 89 +- .../useConversationTargetBinding.test.ts | 312 ++ .../hooks/useConversationTargetBinding.ts | 711 +++ .../hooks/useImportedSessionSubmitOverride.ts | 558 -- .../__tests__/inputAreaEventSelectors.test.ts | 59 +- .../__tests__/useSubmitMessage.test.ts | 4 +- .../ChatPanel/hooks/useInputArea/index.ts | 24 +- .../useInputArea/inputAreaEventSelectors.ts | 25 + .../ChatPanel/hooks/useInputArea/types.ts | 18 +- .../hooks/useInputArea/useAtMention.ts | 8 +- .../hooks/useInputArea/useSubmitMessage.ts | 21 +- .../useWorkspaceChat/useMessageDispatch.ts | 145 +- .../useWorkspaceChat/useSessionActions.ts | 53 +- .../useUserIntentSubmit.intervention.test.ts | 234 +- .../useWorkspaceChat/useUserIntentSubmit.ts | 164 +- .../useWorkspaceChat/useWorkspaceChat.test.ts | 57 + .../useWorkspaceChat/useWorkspaceChat.ts | 58 +- src/engines/ChatPanel/index.tsx | 7 +- .../CloudOrgSyncSection.test.ts | 2 + .../__tests__/sessionTimelineBoundary.test.ts | 22 + .../control/__tests__/turnLifecycle.test.ts | 28 + .../control/sessionTimelineBoundary.ts | 37 +- .../SessionCore/control/turnLifecycle.ts | 46 +- .../canonicalConversationEvents.test.ts | 129 + .../canonicalConversationEvents.ts | 63 + .../conversationSenderMetadata.test.ts | 94 + .../conversationSenderMetadata.ts | 102 + .../conversations/conversationTypes.test.ts | 87 + .../conversations/conversationTypes.ts | 117 + .../localConversationContinuation.test.ts | 2950 +++++++++++ .../localConversationContinuation.ts | 1462 +++++ .../localConversationExecutionTail.test.ts | 1077 ++++ .../localConversationExecutionTail.ts | 551 ++ .../nativeConversationMaterializer.test.ts | 845 +++ .../nativeConversationMaterializer.ts | 842 +++ .../queuedConversationContract.ts | 156 + .../core/atoms/__tests__/actions.test.ts | 44 +- src/engines/SessionCore/core/atoms/actions.ts | 43 +- .../core/atoms/actions.userMessageSync.ts | 40 +- .../SessionCore/core/atoms/metadata.ts | 7 +- .../SessionCore/core/store/EventStoreProxy.ts | 1 + .../core/store/eventStoreEvents.ts | 18 +- .../derived/__tests__/chatEvents.test.ts | 203 +- .../queueDispatchSyncInputsAtom.test.ts | 11 +- src/engines/SessionCore/derived/chatEvents.ts | 164 +- .../derived/queueDispatchSyncInputsAtom.ts | 15 +- .../derived/sessionScopedChatEvents.ts | 7 +- .../session/__tests__/launchPayload.test.ts | 21 + .../__tests__/messageQueuePersistence.test.ts | 588 +- .../useQueueDispatch.intervention.test.ts | 1259 ++++- .../hooks/session/messageQueuePersistence.ts | 628 ++- .../hooks/session/useQueueDispatch.ts | 1067 +++- .../useSessionLaunch/index.tsx | 1 + .../useSessionLaunch/launchPayload.ts | 5 + .../hooks/session/useSessionDiscovery.ts | 18 +- .../ingestion/visibilityFilters.ts | 25 +- .../SessionCore/services/SessionService.ts | 40 +- .../optimisticOutgoingDelivery.test.ts | 45 + .../services/optimisticOutgoingDelivery.ts | 49 + src/engines/SessionCore/services/types.ts | 8 + .../services/userIntentDispatch.test.ts | 687 +++ .../services/userIntentDispatch.ts | 559 ++ .../authoritativeSessionEvents.test.ts | 123 + .../nativeTranscriptReconcile.test.ts | 530 +- .../sessionSwitchOrchestrator.test.ts | 47 +- .../__tests__/sessionSyncReconcile.test.ts | 72 +- ...SyncStateHelpers.sessionListStatus.test.ts | 26 +- .../__tests__/sessionSyncStateHelpers.test.ts | 65 +- .../sync/__tests__/sessionSyncUtils.test.ts | 134 +- .../useSessionEventIngestion.test.ts | 135 + .../externalHistoryAdapter.loading.test.ts | 33 + .../adapters/cli/__tests__/cliHistory.test.ts | 39 + .../__tests__/createCliEventHandler.test.ts | 408 +- .../sync/adapters/cli/cliHistory.ts | 19 +- .../sync/adapters/cli/cliLifecycle.ts | 157 +- .../sync/adapters/cli/cliTransport.ts | 4 + .../adapters/cli/createCliEventHandler.ts | 254 +- .../sync/adapters/externalHistoryAdapter.ts | 25 + .../sync/adapters/shared/eventFactories.ts | 30 +- .../sync/authoritativeSessionEvents.ts | 78 + .../sync/nativeTranscriptReconcile.ts | 329 +- .../sync/sessionSwitchOrchestrator.ts | 44 +- .../SessionCore/sync/sessionSyncReconcile.ts | 33 +- .../sync/sessionSyncStateHelpers.ts | 116 +- .../SessionCore/sync/sessionSyncUtils.ts | 74 +- src/engines/SessionCore/sync/types.ts | 17 + .../sync/useSessionEventIngestion.ts | 70 + .../SessionCore/sync/useSessionSync.ts | 15 +- ...calConversationDispatcher.recovery.test.ts | 279 + .../canonicalConversationDispatcher.ts | 258 + .../externalHistoryContinuation.test.ts | 162 + .../externalHistoryContinuation.ts | 97 + .../CloudSessionDownloadProgressCard.test.ts | 11 + .../SessionCommentsContext.test.ts | 350 +- .../SessionCommentsContext.tsx | 270 +- .../SessionCommentsRetryProjection.test.ts | 248 + .../ConversationModePill.test.ts | 33 + ...ConversationSenderMetadataProvider.test.ts | 113 + ...Org2ConversationSenderMetadataProvider.tsx | 257 + .../activeConversationRunnersAtom.test.ts | 51 - .../activeConversationRunnersAtom.ts | 53 - .../canonicalConversationTimeline.test.ts | 424 ++ .../canonicalConversationTimeline.ts | 249 + .../cloudConversationAuthority.ts | 25 + .../cloudConversationQueueAdapter.test.ts | 1009 ++++ .../cloudConversationQueueAdapter.ts | 759 +++ .../continuationEvents.test.ts | 57 +- .../SessionConversation/continuationEvents.ts | 127 +- .../conversationOwnerPublisher.test.ts | 93 - .../conversationOwnerPublisher.ts | 237 - .../conversationPlaneAtom.test.ts | 340 +- .../conversationPlaneAtom.ts | 696 ++- .../conversationPlaneEvents.test.ts | 64 + .../conversationPlaneEvents.ts | 28 +- .../conversationRunnerOverlay.test.ts | 370 ++ .../conversationRunnerOverlay.ts | 118 + .../conversationRunnerScope.tsx | 24 - .../conversationTimeline.test.ts | 284 +- .../conversationTimeline.ts | 102 +- .../conversationTurnRunner.test.ts | 332 ++ .../conversationTurnRunner.ts | 542 +- .../discussionEvents.test.ts | 36 +- .../SessionConversation/discussionEvents.ts | 56 +- .../teamChatMentions.test.ts | 140 + .../SessionConversation/teamChatMentions.ts | 176 +- .../useCloudConversationSource.test.ts | 145 + .../useCloudConversationSource.ts | 231 + .../useConversationComposer.test.ts | 65 +- .../useConversationComposer.ts | 57 +- .../useConversationSetupPillBinding.ts | 111 - .../useEnsureFamilyLoaded.test.ts | 238 + .../useEnsureFamilyLoaded.ts | 171 +- .../SessionConversation/usePinnedSession.ts | 34 - .../cloudSessionDownloadControlAtoms.test.ts | 15 + .../cloudSessionDownloadControlAtoms.ts | 6 + .../cloudSessionDownloadProgressAtom.test.ts | 3 + .../cloudSessionDownloadProgressAtom.ts | 5 + .../cloudSessionReplayLifecycle.test.ts | 38 +- .../Org2Cloud/cloudSessionReplayLifecycle.ts | 7 +- .../memberRuntimePushScheduler.test.ts | 8 + .../Org2Cloud/org2CloudCapabilities.test.ts | 40 + .../Org2Cloud/org2CloudCapabilities.ts | 12 + .../Org2Cloud/org2CloudCommentsClient.test.ts | 67 +- .../Org2Cloud/org2CloudCommentsClient.ts | 137 +- .../org2CloudConversationEventsClient.test.ts | 294 + .../org2CloudConversationEventsClient.ts | 478 +- .../org2CloudConversationTurnClient.test.ts | 166 + .../org2CloudConversationTurnClient.ts | 261 + .../Org2Cloud/org2CloudFetchRetry.test.ts | 30 + src/features/Org2Cloud/org2CloudFetchRetry.ts | 27 + ...2CloudRemoteSessionsAtom.lifecycle.test.ts | 81 + .../Org2Cloud/org2CloudRemoteSessionsAtom.ts | 4 + ...2CloudSessionCommentsAtom.delivery.test.ts | 89 +- .../Org2Cloud/org2CloudSessionCommentsAtom.ts | 199 +- .../org2CloudSessionCommentsAtom.types.ts | 57 +- .../org2CloudSessionSync.continuation.test.ts | 304 ++ .../org2CloudSessionSync.metadata.ts | 8 +- .../org2CloudSessionSync.pushEvents.ts | 95 +- .../Org2Cloud/org2CloudSessionSync.state.ts | 21 +- .../Org2Cloud/org2CloudSessionSync.ts | 32 +- .../Org2Cloud/org2CloudSessionSync.types.ts | 4 + .../Org2Cloud/org2CloudSyncClient.test.ts | 6 + .../Org2Cloud/org2CloudSyncCoverage.ts | 1 + .../org2CloudSyncEngine.metadata.test.ts | 52 +- .../org2CloudSyncEngine.testUtils.ts | 25 + .../Org2Cloud/sessionCommentTarget.test.ts | 111 + .../Org2Cloud/sessionCommentTarget.ts | 139 +- .../Org2Cloud/useCloudSessionActions.ts | 16 + .../useCloudSessionDownloadSurface.test.ts | 157 + .../useCloudSessionDownloadSurface.ts | 59 +- .../Org2Cloud/useOrg2CloudRealtime.test.ts | 133 + .../Org2Cloud/useOrg2CloudRealtime.ts | 74 +- .../SessionCreator/agentRuntimeConfig.test.ts | 174 + .../SessionCreator/agentRuntimeConfig.ts | 190 + .../ChatPanel/SessionCreatorChatPanelView.tsx | 50 +- .../useSessionCreatorChatPanelHandlers.ts | 53 +- .../engine/collabImportIdentity.test.ts | 68 + .../engine/collabImportIdentity.ts | 43 +- .../cliSession/cliTurnLifecycleCoordinator.ts | 25 +- .../models/useAgentCompatibility.test.ts | 62 + src/hooks/models/useAgentCompatibility.ts | 4 +- src/hooks/models/useModelAccountLookup.ts | 6 +- .../useNativeSessionStatusMonitor.test.ts | 16 +- .../session/useNativeSessionStatusMonitor.ts | 37 +- src/modules/SessionWindow/index.tsx | 10 +- .../TabContent/renderers/chatSession.tsx | 5 +- .../components/SpotlightAccountFooter.tsx | 21 +- .../DispatchCategoryDropdown.tsx | 21 +- .../DispatchCategoryPicker.tsx | 36 + .../cliAgentCapability.test.ts | 29 + .../cliAgentCapability.ts | 15 + .../credentialedAccounts.test.ts | 17 + .../credentialedAccounts.ts | 6 + .../DispatchCategoryPalette/index.tsx | 54 +- .../palettes/DispatchCategoryPalette/types.ts | 8 + .../useDispatchCategoryOptions.tsx | 35 +- .../palettes/UnifiedModelPalette/index.tsx | 4 +- .../palettes/UnifiedModelPalette/types.ts | 5 + ...udSessionsSection.autoReplayReveal.test.ts | 12 + .../cloudSessionsSection.autoReplayReveal.ts | 8 +- .../cloudSessionsSection.tsx | 5 - src/store/session/agentRegistryAtom.ts | 6 + .../canonicalConversationTurnLock.test.ts | 140 + .../__tests__/conversationTargetAtom.test.ts | 54 + .../ui/__tests__/messageQueueAtom.test.ts | 84 +- .../__tests__/messageQueueRepository.test.ts | 491 ++ src/store/ui/conversationTargetAtom.ts | 67 + src/store/ui/messageQueueAtom.ts | 273 +- src/store/ui/messageQueueRepository.ts | 703 ++- .../__tests__/sessionDisplayMetadata.test.ts | 25 + .../__tests__/sessionVisibility.test.ts | 14 + src/util/session/sessionDispatch.ts | 15 + src/util/session/sessionDisplayMetadata.ts | 4 + src/util/session/sessionVisibility.ts | 10 + .../e2e/specs/core/chat-rendering-ui.spec.mjs | 92 + .../core/cloud-dual-instance-ui.spec.mjs | 347 +- tests/e2e/specs/core/cloud-org-ui.spec.mjs | 2 +- .../core/session-account-switch.spec.mjs | 360 ++ tests/e2e/support/core/cloudOrgUiDriver.mjs | 58 +- tests/e2e/support/core/dualCloudHarness.mjs | 31 +- .../core/session/accountSwitchDriver.mjs | 85 +- .../session/agentQueuedControlScenarios.mjs | 40 +- .../session/agentQueuedFollowupDriver.mjs | 1 - tests/e2e/wdio.conf.mjs | 78 +- 399 files changed, 52856 insertions(+), 6121 deletions(-) create mode 100644 src-tauri/src/agent_sessions/cli/native_ir.rs create mode 100644 src-tauri/src/agent_sessions/cli/native_materializer.rs create mode 100644 src-tauri/src/agent_sessions/cli/native_store.rs create mode 100644 src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs delete mode 100644 src-tauri/src/agent_sessions/cli/tests/runner_tests.rs create mode 100644 src-tauri/src/agent_sessions/turn_intents.rs create mode 100644 src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts create mode 100644 src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts create mode 100644 src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts create mode 100644 src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx delete mode 100644 src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx create mode 100644 src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts create mode 100644 src/engines/ChatPanel/ConversationExecutionBindingContext.ts create mode 100644 src/engines/ChatPanel/ConversationStreamProvider.test.ts create mode 100644 src/engines/ChatPanel/InputArea/components/ModelPill.test.ts create mode 100644 src/engines/ChatPanel/conversationTargetSelection.test.ts create mode 100644 src/engines/ChatPanel/conversationTargetSelection.ts delete mode 100644 src/engines/ChatPanel/externalHistoryFork.test.ts delete mode 100644 src/engines/ChatPanel/externalHistoryFork.ts create mode 100644 src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts create mode 100644 src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts create mode 100644 src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.test.ts create mode 100644 src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.ts create mode 100644 src/engines/ChatPanel/hooks/useChatViewMessageQueue.test.ts create mode 100644 src/engines/ChatPanel/hooks/useConversationTargetBinding.test.ts create mode 100644 src/engines/ChatPanel/hooks/useConversationTargetBinding.ts delete mode 100644 src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts create mode 100644 src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.test.ts create mode 100644 src/engines/SessionCore/conversations/canonicalConversationEvents.test.ts create mode 100644 src/engines/SessionCore/conversations/canonicalConversationEvents.ts create mode 100644 src/engines/SessionCore/conversations/conversationSenderMetadata.test.ts create mode 100644 src/engines/SessionCore/conversations/conversationSenderMetadata.ts create mode 100644 src/engines/SessionCore/conversations/conversationTypes.test.ts create mode 100644 src/engines/SessionCore/conversations/conversationTypes.ts create mode 100644 src/engines/SessionCore/conversations/localConversationContinuation.test.ts create mode 100644 src/engines/SessionCore/conversations/localConversationContinuation.ts create mode 100644 src/engines/SessionCore/conversations/localConversationExecutionTail.test.ts create mode 100644 src/engines/SessionCore/conversations/localConversationExecutionTail.ts create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.ts create mode 100644 src/engines/SessionCore/conversations/queuedConversationContract.ts create mode 100644 src/engines/SessionCore/services/optimisticOutgoingDelivery.test.ts create mode 100644 src/engines/SessionCore/services/optimisticOutgoingDelivery.ts create mode 100644 src/engines/SessionCore/services/userIntentDispatch.test.ts create mode 100644 src/engines/SessionCore/services/userIntentDispatch.ts create mode 100644 src/engines/SessionCore/sync/__tests__/authoritativeSessionEvents.test.ts create mode 100644 src/engines/SessionCore/sync/__tests__/useSessionEventIngestion.test.ts create mode 100644 src/engines/SessionCore/sync/adapters/cli/__tests__/cliHistory.test.ts create mode 100644 src/engines/SessionCore/sync/authoritativeSessionEvents.ts create mode 100644 src/engines/SessionCore/sync/useSessionEventIngestion.ts create mode 100644 src/features/ConversationContinuation/canonicalConversationDispatcher.recovery.test.ts create mode 100644 src/features/ConversationContinuation/canonicalConversationDispatcher.ts create mode 100644 src/features/ConversationContinuation/externalHistoryContinuation.test.ts create mode 100644 src/features/ConversationContinuation/externalHistoryContinuation.ts create mode 100644 src/features/Org2Cloud/SessionComments/SessionCommentsRetryProjection.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx delete mode 100644 src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts create mode 100644 src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.ts create mode 100644 src/features/Org2Cloud/SessionConversation/cloudConversationAuthority.ts create mode 100644 src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.test.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx create mode 100644 src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts create mode 100644 src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.test.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/usePinnedSession.ts create mode 100644 src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts create mode 100644 src/features/Org2Cloud/org2CloudConversationTurnClient.test.ts create mode 100644 src/features/Org2Cloud/org2CloudConversationTurnClient.ts create mode 100644 src/features/Org2Cloud/org2CloudRemoteSessionsAtom.lifecycle.test.ts create mode 100644 src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts create mode 100644 src/features/Org2Cloud/useCloudSessionDownloadSurface.test.ts create mode 100644 src/features/SessionCreator/agentRuntimeConfig.test.ts create mode 100644 src/features/TeamCollaboration/engine/collabImportIdentity.test.ts create mode 100644 src/hooks/models/useAgentCompatibility.test.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker.tsx create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.test.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.test.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.ts create mode 100644 src/store/ui/__tests__/canonicalConversationTurnLock.test.ts create mode 100644 src/store/ui/__tests__/conversationTargetAtom.test.ts create mode 100644 src/store/ui/__tests__/messageQueueRepository.test.ts create mode 100644 src/store/ui/conversationTargetAtom.ts diff --git a/docs/architecture/managed-cloud-collaboration.md b/docs/architecture/managed-cloud-collaboration.md index debb88e269..14246c5e9c 100644 --- a/docs/architecture/managed-cloud-collaboration.md +++ b/docs/architecture/managed-cloud-collaboration.md @@ -23,12 +23,13 @@ a link. The recipient sees that session in **Shared directly with me** without copying a URL. Link generation remains an explicit action and always exposes a Copy control. -An imported shared session or local Codex/Claude/Cursor history is immutable -at its source. The user may inspect and comment where cloud authorization -exists. On the first attempt to continue the conversation, ORGII asks for a -local repository/workspace with the same Git remote plus the local account and -model, then creates a writable ORGII-owned fork and sends the message there. -Cancelling the picker preserves the unsent message. +An imported shared session or local provider history remains immutable at its +source. Continuing it does not create a product-level fork or flatten history +into a prompt. ORGII rebuilds the canonical role/tool transcript in the chosen +Codex, Claude Code, or native Agent runtime, automatically reuses a valid local +workspace, and sends through the ordinary durable message queue. Compatible +runtime/account/workspace bindings retain their native UUID, so switching back +synchronizes the missing suffix instead of starting over. ## Ownership and authorization @@ -64,27 +65,30 @@ deletion may remain recoverable, so the sync worker uses a distinct purge path. Deleting a Project also deletes its child Work Items; children must never be silently converted into standalone items by an FK default. -### Comments and owner-local agent follow-up +### Conversation, comments, and local execution Session comments are durable cloud rows. Replies retain their thread root, -edits and deletes converge live, and status is a typed tri-state value. The -literal `@agent ` prefix is stored verbatim and rendered as a pill. It starts -work only when submitted on the original cloud session by that session's -owner; on another member's import, a read-only replay, or a writable fork it -is ordinary comment text with no suggestion, assignment, toast, or agent side -effect. - -There is no cloud task/lease/claim plane. An owner submission enters the same -local queue/send path as an ordinary message and therefore uses the owner's -locally authenticated account and selected model. The backend returns a -viewer-derived ownership capability, the UI and runner both fail closed on -it, and only the owner may stamp the resulting `agent_report`. The Address -Comments action operates on an explicit selection and links agent output back -to the originating comment using the exact dispatched turn generation. -Top-level comments have exactly one scope: no event anchor means a session -note applying to the session as a whole; an event anchor means a round comment. -Address Comments groups both scopes, selects both by default, permits -scope-level selection, and carries the scope into the agent briefing. +edits and deletes converge live, and delivery is pending/sent/failed on the +same visible message. Human Team Chat comments also project into the canonical +conversation as user-role events with structured sender identity. Mentions +select a notification audience; they do not create a separate transcript. + +Provider execution remains local and uses the sender's explicitly selected +local account/model. Cloud never receives a provider key and does not host the +runtime. When the backend advertises `conversationTurnCoordination`, admitting +an Agent-directed user event also creates one per-canonical-root FIFO turn. +The author's device claims the FIFO head with a renewable lease before local +provider dispatch, marks it `accepted` immediately before that dispatch, and +finishes it only after the provider tail is durably published. An expired +`claimed` turn may be reclaimed, but an `accepted` turn cannot be stolen by a +different device; renewal failure after acceptance therefore cannot start a +second provider run. The same device must recover and finish that accepted +turn. Backends without the capability retain the existing idempotent +conversation-event push/list path and do not use coordination RPCs. The +ordinary durable queue remains the only client dispatcher and owns local +ordering and restart recovery. Agent reports remain system cards. Top-level +comments have exactly one scope: no event anchor means a session note applying +to the session as a whole; an event anchor means a round comment. ### Background upload policy diff --git a/docs/conversation-events-plane-design-2026-08-21.md b/docs/conversation-events-plane-design-2026-08-21.md index 2d0dec58d1..c411c70366 100644 --- a/docs/conversation-events-plane-design-2026-08-21.md +++ b/docs/conversation-events-plane-design-2026-08-21.md @@ -1,102 +1,94 @@ -# Conversation Events Plane — the real fix for "it's just one session" +# Canonical conversation continuation -2026-08-21. User directive: chatting in a conversation must NOT be a fork — -forks exist only behind the explicit Fork button. This design removes the -fork machinery from implicit continuation entirely by giving conversations -their own **multi-writer event plane** on the cloud, mirroring the proven -session-comments wire. +This document records the current continuation contract. A conversation is a +single canonical event history that can be resumed by any supported native +runtime. Switching runtime is not a fork and does not flatten history into a +prompt. -## Model +## Authority and projection -- A **conversation** is keyed by `(org_id, root_session_id)` — the family - root's bare session id. It OUTLIVES the root session row (retention - expiry of the oldest segment must never mute the conversation — observed - live 2026-08-21 with ORG2_RETENTION_EXPIRED). -- The owner's own session transcript stays the base timeline (owner-only - push unchanged) — AND every owner turn is ALSO published to the plane - (user row at dispatch, agent tail at terminal, one turnId) under the - local event ids, so the plane carries every turn of the conversation and - its seq is the one total order. Clients fold plane rows onto their local - twins (owner transcript, imported replay copies) by turn-intent id for - user rows and by source event id for the rest; pre-plane history keeps - the timestamp merge. -- Any other member's turn runs on THEIR machine (sender-runs/sender-pays) - in a **local runner session** that is: created empty (external-history - fork pattern — context injected, never copied), per-session sync OFF - (never pushed as a session row), invisible in every session list. -- On turn completion the runner's new events are pushed to - `cloud_conversation_events` with the author's identity; every client - merges `owner transcript + conversation plane + discussion` into ONE - stream (the merge/attribution/rendering pipeline from the fork-stitching - work is reused verbatim — turn-plane events are normalized SessionEvents - with a `conversationSender` stamp). -- Context continuity: EVERY send (owner included) prefixes the agent - content with a rendered delta of conversation events the executing - session has not yet seen (per-runner cursor). Display text stays the - user's words; the delta rides agentContent (the projection contract from - the external-history fork path). +- `SessionEvent[]` is the provider-neutral authority for roles, completed tool + call/result pairs, images, compaction summaries, delivery state and sender + provenance. +- Codex and Claude Code histories are projections of that authority into each + provider's native role/tool transcript format. +- A target provider receives the complete verified canonical prefix as native + messages. The new user turn is delivered once through the provider's normal + send path. +- Provider-private reasoning and policy are not portable. Interrupted turns + retain the accepted user event, completed assistant output and closed tool + pairs; unresolved tool calls are not projected into another provider. +- Round-trip parsing must reproduce the same portable semantic items before a + materialization can be used. -## Cloud (migration 0024_conversation_events.sql) +## Identity and runtime switching -- Table `cloud_conversation_events(id, org_id, root_session_id, -author_user_id, turn_id, seq, event jsonb, created_at)`. - - `seq` server-assigned per conversation under - `pg_advisory_xact_lock(hash(org_id, root_session_id))` (0015 pattern). - - Event cap 64KB each, ≤200 events per push call; oversized payloads are - truncated client-side before push with a marker. - - No FK to cloud_sessions: the plane outlives the root row. -- Counters table `cloud_conversations(org_id, root_session_id, event_count, -prompt_count, last_event_at)` maintained under the same lock — feeds - listing badges without count(\*) scans. -- RPCs (definer, RPC-only posture, org-membership asserted; visibility - honors the root session's access ladder WHILE the row exists, falls back - to org-wide once it ages out; read-time retention on event created_at — - soft, Slack model): - - `cloud_push_conversation_events(p_org_id, p_root_session_id, p_turn_id, -p_events jsonb[])` → `{firstSeq, lastSeq}`; batch-append so live - streaming of a running turn is a client cadence choice, not a schema - change. - - `cloud_list_conversation_events(p_org_id, p_root_session_id, -p_after_seq, p_limit)` → ordered rows + authors. -- Signal: new kind `conversationEvents` via `nudge_org_signal` (dedicated - trigger fn, 0015 precedent) + client presence-channel broadcast - (comments-bus pattern) for sub-second delivery. -- `cloud_list_org_sessions`: additive per-row `conversationEventCount` / - `conversationPromptCount` (joined from the counters table by - root_session_id == sourceSessionId). -- `get_cloud_capabilities()` gains `conversationEvents: true` — the client - feature gate; pre-plane backends keep the fork-wire fallback. -- GDPR: export includes authored events; account deletion removes them - (cloud_session_comments precedent for personal content). Both functions - recreated from their LATEST bodies (delete: 0016, export: 0003) with - additive blocks. +- A canonical root identifies the conversation independently of any execution + episode. +- Each compatible runtime/account/workspace binding may keep its own native + UUID. Switching `Codex -> Claude Code -> Codex` synchronizes only the missing + canonical suffix and reuses the earlier Codex UUID when it is still valid. +- The normal New Session runtime/model selectors choose the next target. No + continuation-only workspace dialog or model registry exists. +- Native transcripts and the provider application catalog are published as one + lifecycle. A native-format JSONL file alone is not advertised as visible in + Codex or Claude Desktop. -## Client (ORGII) +## Delivery and concurrency -1. Protocol: `org2CloudConversationEventsClient` + per-conversation atom - (after_seq cursor, LWW merge), realtime bump on the `conversationEvents` - signal kind + broadcast bus. -2. Read: ConversationStreamProvider merges plane events (author-stamped) - after the base segments; dedup by turn against optimistic local copies. -3. Write: `conversation runner` — registry `rootSessionId → runner session` - (per device); created via the continuation setup flow (setup memory - applies, so no dialog after the first time anywhere in the org repo - scope); per-session sync forced OFF; hidden from session lists. - Turn watch = event-marker based (never bare terminal status — the - stale-reply race), then push the turn's events. -4. Send routing (capability-gated): implicit sends in any conversation - surface go to the runner+plane; the fork-before-send and tip-follow - paths remain ONLY as the fallback for pre-plane backends. The explicit - Fork button keeps real forking (a deliberate branch = a new - conversation). -5. Unread: family badge adds conversationPromptCount to the aggregate; - seen watermark unchanged (counts ride the same ratchet). +- `messageQueueAtom` is the only durable client dispatcher for ordinary sends, + imported histories, My Sessions and Team Sessions. +- A queued row owns one stable `turnIntentId` across optimistic display, + provider acceptance, restart recovery and Cloud publication. +- The existing queue FSM owns queued/preparing/accepted state, retry deadlines, + Stop/Send Now behavior and follow-up ordering. Continuation code does not add + a second wake counter, queue, footer FSM or scroll/follow implementation. +- A Web Lock only prevents two webviews on the same app instance from mutating + one canonical root concurrently. For a backend that advertises + `conversationTurnCoordination`, Cloud additionally admits Agent-directed + turns into a per-canonical-root FIFO and lets the author's device claim only + the head. The claim lease is renewed while the local provider run proceeds. +- A `claimed` turn whose lease expires before provider acceptance may be + reclaimed. Immediately before provider dispatch, the owner marks the turn + `accepted`; accepted ownership cannot move to another device. A renewal + failure after that point therefore fails closed instead of causing a second + provider run, and the accepting device must recover publication/finalization. +- Backends without the capability keep the existing idempotent event push/list + workflow. The client does not call coordination RPCs or add a fallback + dispatcher, poller, or watcher. +- Failed outgoing messages remain visible with their original body, images and + mentions and can be retried or edited. Pre-send validation failures leave the + composer unchanged. -## Explicitly deferred +## Team Sessions and Team Chat -- Live streaming of in-flight turns to OTHER clients (plane supports it; - client pushes at turn completion in v1). The sender's own surface overlays - the runner's live events and scopes the working indicator to the runner. -- Migrating Team chat (comments) onto the same plane. -- Backfilling legacy fork families into planes (they keep the stitched - read path indefinitely). +- Cloud stores the shared canonical event plane and assigns a monotonic + per-conversation sequence under the existing advisory lock. +- Push idempotency is `(org, root, turnIntentId, event.id)`. The Cloud never + receives a user's provider key and does not execute a native runtime. +- Human Team Chat comments are canonical user-role events with structured + sender provenance. `@member` and `@all` determine human notification audience; + they do not create a second transcript. +- Agent reports remain non-portable system cards. +- The event plane remains multi-writer for durable transcript publication. + Capability-gated turn coordination orders Agent execution per root without + turning Cloud into an execution host; Team Chat remains outside that claim + lifecycle. + +## Context exhaustion + +- Compaction is triggered only after the provider reports context exhaustion. +- If the accepted attempt has no replay-unsafe tool or assistant side effects, + the provider may use its native compact/rollover capability. +- Otherwise ORG2 creates a fresh native episode from the structured canonical + role/tool list and retries the accepted user turn once. It never works around + exhaustion by embedding the transcript in one user prompt. +- The new native UUID remains attached to the same canonical root. + +## Surface adapters + +- My Session, imported history and Team Session surfaces provide only root + identity, event loading/publication and target selection. +- Work Item comments may trigger this mechanism in the future, but Work Item + code must remain a thin adapter and cannot own continuation, queue or provider + materialization semantics. diff --git a/scripts/tauri/instance-profile.cjs b/scripts/tauri/instance-profile.cjs index dc005dfa83..d6c2a59e09 100644 --- a/scripts/tauri/instance-profile.cjs +++ b/scripts/tauri/instance-profile.cjs @@ -3,11 +3,14 @@ const os = require("os"); const PRIMARY_IDE_PORT = 13847; const PRIMARY_PROXY_PORT = 17888; +const MAX_INSTANCE_ID = 99; function parseInstanceId(value) { const id = Number(value); - if (!Number.isInteger(id) || id < 2 || id > 99) { - throw new Error("--instance must be an integer from 2 through 99"); + if (!Number.isInteger(id) || id < 2 || id > MAX_INSTANCE_ID) { + throw new Error( + `--instance must be an integer from 2 through ${MAX_INSTANCE_ID}` + ); } return id; } @@ -27,4 +30,24 @@ function createInstanceProfile(value) { }; } -module.exports = { createInstanceProfile, parseInstanceId }; +function createInstanceProfileFromIdeServerPort(value) { + const port = Number(value); + const instanceId = port - PRIMARY_IDE_PORT + 1; + if (!Number.isInteger(port) || !Number.isInteger(instanceId)) { + throw new Error(`IDE server port must be an integer, got ${value}`); + } + try { + return createInstanceProfile(instanceId); + } catch { + throw new Error( + `IDE server port ${port} must identify an instance from 2 through ${MAX_INSTANCE_ID} ` + + `(${PRIMARY_IDE_PORT + 1}-${PRIMARY_IDE_PORT + MAX_INSTANCE_ID - 1})` + ); + } +} + +module.exports = { + createInstanceProfile, + createInstanceProfileFromIdeServerPort, + parseInstanceId, +}; diff --git a/scripts/tauri/instance-profile.test.cjs b/scripts/tauri/instance-profile.test.cjs index 2f778c3d83..3793923f6a 100644 --- a/scripts/tauri/instance-profile.test.cjs +++ b/scripts/tauri/instance-profile.test.cjs @@ -3,6 +3,7 @@ const test = require("node:test"); const { createInstanceProfile, + createInstanceProfileFromIdeServerPort, parseInstanceId, } = require("./instance-profile.cjs"); @@ -20,3 +21,14 @@ test("primary and unbounded instance ids are rejected", () => { assert.throws(() => parseInstanceId(value)); } }); + +test("IDE server ports resolve through the canonical instance profile", () => { + const profile = createInstanceProfileFromIdeServerPort("13848"); + assert.equal(profile.id, 2); + assert.equal(profile.productName, "ORG2 Instance 2"); + assert.equal(profile.cliProxyPort, 17889); + + for (const value of [undefined, "13847", "13946", "not-a-port"]) { + assert.throws(() => createInstanceProfileFromIdeServerPort(value)); + } +}); diff --git a/scripts/tauri/open-instance.cjs b/scripts/tauri/open-instance.cjs index acfc544ba4..b8701c4410 100644 --- a/scripts/tauri/open-instance.cjs +++ b/scripts/tauri/open-instance.cjs @@ -2,6 +2,7 @@ const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { createInstanceProfile } = require("./instance-profile.cjs"); @@ -27,6 +28,38 @@ const appPath = path.resolve( ); const dataHome = path.resolve(optionValue("--data-home") ?? profile.dataHome); const externalHistoryHome = path.join(dataHome, "external-history-home"); +// Publishing into a real provider profile is an explicit opt-in. Independent +// dev/E2E instances otherwise keep both discovery and materialization inside +// their isolated data home and cannot mutate one another's native catalogs. +const nativeTranscriptHomeOption = + optionValue("--native-transcript-home") ?? + process.env.ORGII_NATIVE_TRANSCRIPT_HOME; +const nativeTranscriptHome = nativeTranscriptHomeOption + ? path.resolve(nativeTranscriptHomeOption) + : null; +const requiresOfficialNativeHome = + process.env.E2E_NATIVE_PROVIDER_SWITCH_LIVE === "1"; +const officialNativeHome = path.resolve( + process.env.E2E_NATIVE_PROVIDER_SWITCH_OFFICIAL_HOME ?? os.homedir() +); + +if (requiresOfficialNativeHome && !nativeTranscriptHome) { + console.error( + "E2E_NATIVE_PROVIDER_SWITCH_LIVE=1 requires --native-transcript-home " + + "(or ORGII_NATIVE_TRANSCRIPT_HOME) so provider App proof cannot publish " + + "into the disposable external-history home." + ); + process.exit(1); +} +if ( + requiresOfficialNativeHome && + nativeTranscriptHome !== officialNativeHome +) { + console.error( + `Native provider App proof requires ${officialNativeHome}, got ${nativeTranscriptHome}.` + ); + process.exit(1); +} if (!fs.existsSync(appPath)) { console.error(`Instance app not found: ${appPath}`); @@ -34,10 +67,16 @@ if (!fs.existsSync(appPath)) { } fs.mkdirSync(dataHome, { recursive: true }); fs.mkdirSync(externalHistoryHome, { recursive: true }); +if (nativeTranscriptHome) { + fs.mkdirSync(nativeTranscriptHome, { recursive: true }); +} const instanceEnv = { ORGII_HOME: dataHome, ORGII_EXTERNAL_HISTORY_HOME: externalHistoryHome, + ...(nativeTranscriptHome + ? { ORGII_NATIVE_TRANSCRIPT_HOME: nativeTranscriptHome } + : {}), ORGII_IDE_SERVER_PORT: String(profile.ideServerPort), ORGII_CLI_PROXY_PORT: String(profile.cliProxyPort), ORGII_DEEP_LINK_SCHEME: profile.authDeepLinkScheme, @@ -56,6 +95,7 @@ if (process.platform === "win32") { `[instance ${profile.id}] started ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome ?? "isolated"}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); process.exit(0); @@ -73,5 +113,6 @@ console.log( `[instance ${profile.id}] opened ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome ?? "isolated"}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6a4afe91c..957f2b12c1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7191,6 +7191,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -9292,6 +9298,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6eaff33877..2c4889bdc4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -150,7 +150,7 @@ authors = ["you"] license = "AGPL-3.0-or-later" repository = "" edition = "2021" -rust-version = "1.85.0" +rust-version = "1.89.0" default-run = "org2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -201,7 +201,7 @@ tauri-plugin-notification = "2" tauri-plugin-webdriver-automation = { version = "0.1", optional = true } portable-pty = "0.9" tokio = { workspace = true } -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "v5"] } flate2 = "1" tar = "0.4" plist = "1" @@ -525,6 +525,7 @@ tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Dwm", + "Win32_Storage_FileSystem", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 7cad86184d..7fdb8298ff 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -1,5 +1,7 @@ //! Message persistence — insertion, loading, truncation, history building. +use std::collections::{HashMap, HashSet}; + use chrono::Utc; use rusqlite::{params, OptionalExtension, Result as SqliteResult}; use uuid::Uuid; @@ -25,6 +27,48 @@ pub struct AgentOrgInboxTranscriptMaterialization { pub content: String, } +/// One provider-neutral history row used to seed or extend an Agent session. +/// +/// Materialization identity is carried beside the content instead of being +/// hidden inside provider-style JSON. This keeps LLM/tool payloads free of +/// ORG2-only fields while preserving deterministic, retry-safe row ids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializedHistorySeed { + pub id: String, + pub created_at: String, + pub content: MaterializedHistoryContent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaterializedHistoryContent { + Message { + role: MaterializedHistoryRole, + text: String, + images: Vec, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + name: String, + output: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaterializedHistoryRole { + User, + Assistant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaterializedHistoryReceipt { + pub row_count: usize, +} + /// Load the transcript batches already materialized for the supplied unread /// Inbox rows in this exact Session. A row stays unread until a successful /// provider turn, but its durable receipt prevents it from being appended to @@ -545,6 +589,186 @@ fn compacted_history_rows( rows } +fn materialized_history_rows( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult> { + seeds + .iter() + .map(|seed| { + if seed.id.trim().is_empty() || seed.created_at.trim().is_empty() { + return Err(history_append_constraint( + "materialized history requires a stable id and timestamp".to_string(), + )); + } + let mut row = match &seed.content { + MaterializedHistoryContent::Message { role, text, images } => { + let role = match role { + MaterializedHistoryRole::User => shared::message_role::USER, + MaterializedHistoryRole::Assistant => shared::message_role::ASSISTANT, + }; + let images = (!images.is_empty()).then(|| { + serde_json::to_string(images) + .expect("Vec serialization is infallible") + }); + message_row(session_id, role, text.clone(), images) + } + MaterializedHistoryContent::ToolCall { + call_id, + name, + arguments, + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err(history_append_constraint( + "materialized tool call requires a call id and name".to_string(), + )); + } + serde_json::from_str::(arguments).map_err(|error| { + history_append_constraint(format!( + "materialized tool call {call_id} has invalid JSON arguments: {error}" + )) + })?; + let mut row = message_row( + session_id, + shared::message_role::TOOL_CALL, + format!("Tool call: {name}"), + None, + ); + row.tool_call_id = Some(call_id.clone()); + row.tool_name = Some(name.clone()); + row.tool_input = Some(arguments.clone()); + row + } + MaterializedHistoryContent::ToolResult { + call_id, + name, + output, + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err(history_append_constraint( + "materialized tool result requires a call id and name".to_string(), + )); + } + let mut row = message_row( + session_id, + shared::message_role::TOOL_RESULT, + crate::utils::safe_truncate_chars_to_string(output, 2000), + None, + ); + row.tool_call_id = Some(call_id.clone()); + row.tool_name = Some(name.clone()); + row.tool_output = Some(output.clone()); + row + } + }; + row.id = seed.id.clone(); + row.created_at = seed.created_at.clone(); + Ok(row) + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HistoryRowsValidation { + None, + MaterializedToolGraph, +} + +fn apply_tool_graph_row( + role: &str, + call_id: Option<&str>, + tool_name: Option<&str>, + open_calls: &mut HashMap, + completed_calls: &mut HashSet, +) -> SqliteResult<()> { + match role { + shared::message_role::TOOL_CALL => { + let call_id = call_id.ok_or_else(|| { + history_append_constraint("materialized tool call is missing call id".to_string()) + })?; + let tool_name = tool_name.ok_or_else(|| { + history_append_constraint("materialized tool call is missing name".to_string()) + })?; + if open_calls.contains_key(call_id) || completed_calls.contains(call_id) { + return Err(history_append_constraint(format!( + "materialized history contains duplicate tool call id {call_id}" + ))); + } + open_calls.insert(call_id.to_string(), tool_name.to_string()); + } + shared::message_role::TOOL_RESULT => { + let call_id = call_id.ok_or_else(|| { + history_append_constraint("materialized tool result is missing call id".to_string()) + })?; + let tool_name = tool_name.ok_or_else(|| { + history_append_constraint("materialized tool result is missing name".to_string()) + })?; + let expected_name = open_calls.remove(call_id).ok_or_else(|| { + history_append_constraint(format!( + "materialized tool result {call_id} has no prior unresolved tool call" + )) + })?; + if expected_name != tool_name { + return Err(history_append_constraint(format!( + "materialized tool result {call_id} names {tool_name}, expected {expected_name}" + ))); + } + completed_calls.insert(call_id.to_string()); + } + _ => {} + } + Ok(()) +} + +/// Validate the complete persisted-prefix + candidate-suffix tool graph while +/// holding the same SQLite write transaction that appends the suffix. An open +/// call at the end is valid partial-turn state; a later synchronization may +/// close it with a result in its next suffix. +fn validate_materialized_tool_graph( + tx: &rusqlite::Transaction<'_>, + session_id: &str, + include_persisted_prefix: bool, + rows: &[shared::AgentMessageRow], +) -> SqliteResult<()> { + let mut open_calls = HashMap::new(); + let mut completed_calls = HashSet::new(); + if include_persisted_prefix { + let mut statement = tx.prepare( + "SELECT role, tool_call_id, tool_name + FROM agent_messages + WHERE session_id = ?1 + ORDER BY sequence ASC", + )?; + let persisted = statement.query_map([session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for row in persisted { + let (role, call_id, tool_name) = row?; + apply_tool_graph_row( + &role, + call_id.as_deref(), + tool_name.as_deref(), + &mut open_calls, + &mut completed_calls, + )?; + } + } + for row in rows { + apply_tool_graph_row( + &row.role, + row.tool_call_id.as_deref(), + row.tool_name.as_deref(), + &mut open_calls, + &mut completed_calls, + )?; + } + Ok(()) +} + fn message_row( session_id: &str, role: &str, @@ -570,52 +794,166 @@ fn message_row( } } -/// Replace a session's persisted transcript with a compacted LLM history view. -/// -/// **Seeding only.** This is the durable bootstrap used by compact-fork: -/// it writes an initial transcript into a *fresh* session id. It refuses -/// to run against a session that already has messages — in-place -/// compaction must use [`append_compact_boundary`] instead, which never -/// rewrites or deletes existing rows (immutable transcript invariant). -/// The destructive DELETE+INSERT variant of this function is what -/// destroyed session transcripts when `created_at`-based truncation met -/// rewritten timestamps (2026-06-11 incident). -pub fn seed_session_with_messages( +fn history_append_constraint(message: String) -> rusqlite::Error { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), + Some(message), + ) +} + +fn persisted_history_row_matches( + persisted: &shared::AgentMessageRow, + expected: &shared::AgentMessageRow, +) -> bool { + persisted.session_id == expected.session_id + && persisted.role == expected.role + && persisted.content == expected.content + && persisted.tool_name == expected.tool_name + && persisted.tool_call_id == expected.tool_call_id + && persisted.tool_input == expected.tool_input + && persisted.tool_output == expected.tool_output + && persisted.model == expected.model + && persisted.created_at == expected.created_at + && persisted.images == expected.images + && match expected.compact_from_sequence { + Some(_) => { + persisted.compact_from_sequence == Some(persisted.sequence.saturating_add(1)) + } + None => persisted.compact_from_sequence.is_none(), + } +} + +fn persisted_history_row( + tx: &rusqlite::Transaction<'_>, + id: &str, +) -> SqliteResult> { + tx.query_row( + "SELECT session_id, role, content, tool_name, tool_call_id, + tool_input, tool_output, model, sequence, created_at, + images, compact_from_sequence + FROM agent_messages WHERE id = ?1", + params![id], + |row| { + Ok(shared::AgentMessageRow { + id: id.to_string(), + session_id: row.get(0)?, + role: row.get(1)?, + content: row.get(2)?, + tool_name: row.get(3)?, + tool_call_id: row.get(4)?, + tool_input: row.get(5)?, + tool_output: row.get(6)?, + model: row.get(7)?, + sequence: row.get(8)?, + created_at: row.get(9)?, + images: row.get(10)?, + compact_from_sequence: row.get(11)?, + compact_tokens_before: None, + compact_tokens_after: None, + }) + }, + ) + .optional() +} + +fn persist_history_rows( session_id: &str, - compacted_messages: &[serde_json::Value], + rows: &[shared::AgentMessageRow], + require_empty: bool, + validation: HistoryRowsValidation, ) -> SqliteResult<()> { - let rows = compacted_history_rows(session_id, compacted_messages); with_sessions_writer(|| -> SqliteResult<()> { - let conn = get_connection()?; - let now = Utc::now().to_rfc3339(); - conn.execute_batch("BEGIN IMMEDIATE")?; - - let existing: i64 = match conn.query_row( - "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1", - [session_id], - |row| row.get(0), - ) { - Ok(count) => count, - Err(err) => { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); + let mut conn = get_connection()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let next_sequence = if require_empty { + let existing: i64 = tx.query_row( + "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + if existing == 0 { + 0 + } else { + let mut exact_rows = 0usize; + for (offset, expected) in rows.iter().enumerate() { + let Some(persisted) = persisted_history_row(&tx, &expected.id)? else { + continue; + }; + exact_rows += 1; + if persisted.sequence != offset as i64 + || !persisted_history_row_matches(&persisted, expected) + { + return Err(history_append_constraint(format!( + "seed_session_with_messages conflict: native row {} already exists with different content, ownership, or sequence", + expected.id + ))); + } + } + if exact_rows == rows.len() && existing as usize == rows.len() { + // A previous seed committed the complete deterministic + // native transcript but lost its response. The exact rows + // are the durable receipt, so retry is a no-op. + return tx.commit(); + } + return Err(history_append_constraint(format!( + "seed_session_with_messages conflict: {exact_rows} of {} expected native rows exist among {existing} session row(s); transcripts are immutable, refusing a mixed or unrelated seed", + rows.len() + ))); + } + } else { + let next_sequence = tx.query_row( + "SELECT COALESCE(MAX(sequence), -1) + 1 FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + let mut existing_count = 0usize; + let mut first_existing_sequence = None; + for (offset, expected) in rows.iter().enumerate() { + let persisted = persisted_history_row(&tx, &expected.id)?; + let Some(persisted) = persisted else { + continue; + }; + existing_count += 1; + let first_sequence = *first_existing_sequence.get_or_insert(persisted.sequence); + let expected_sequence = first_sequence.saturating_add(offset as i64); + if persisted.sequence != expected_sequence + || !persisted_history_row_matches(&persisted, expected) + { + let message = format!( + "history append conflict: native row {} already exists with different content, ownership, or sequence", + expected.id + ); + return Err(history_append_constraint(message)); + } + } + if existing_count == rows.len() { + // A previous attempt committed the entire deterministic + // suffix but lost its response. Treat the exact durable rows + // as the authoritative receipt and do not append them again. + return tx.commit(); } + if existing_count > 0 { + return Err(history_append_constraint(format!( + "history append conflict: {existing_count} of {} native rows already exist; refusing a mixed suffix", + rows.len() + ))); + } + next_sequence }; - if existing > 0 { - let _ = conn.execute_batch("ROLLBACK"); - return Err(rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), - Some(format!( - "seed_session_with_messages refused: session {session_id} already has {existing} message row(s); transcripts are immutable — use append_compact_boundary" - )), - )); + + if validation == HistoryRowsValidation::MaterializedToolGraph { + validate_materialized_tool_graph(&tx, session_id, !require_empty, rows)?; } - for (sequence, row) in rows.iter().enumerate() { - let result = conn.execute( + for (offset, row) in rows.iter().enumerate() { + let sequence = next_sequence + offset as i64; + let compact_from_sequence = row + .compact_from_sequence + .map(|_| sequence.saturating_add(1)); + tx.execute( "INSERT INTO agent_messages - (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images, compact_from_sequence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ row.id, row.session_id, @@ -626,27 +964,79 @@ pub fn seed_session_with_messages( row.tool_input, row.tool_output, row.model, - sequence as i64, + sequence, row.created_at, row.images, + compact_from_sequence, ], - ); - if let Err(err) = result { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } + )?; } - if let Err(err) = conn.execute( + let now = Utc::now().to_rfc3339(); + tx.execute( "UPDATE agent_sessions SET updated_at = ?2 WHERE session_id = ?1", params![session_id, now], - ) { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } + )?; + tx.commit() + }) +} - conn.execute_batch("COMMIT")?; - Ok(()) +/// Replace a session's persisted transcript with a compacted LLM history view. +/// +/// **Seeding only.** This is the durable bootstrap used by compact-fork: +/// it writes an initial transcript into a *fresh* session id. It refuses +/// to run against a session that already has messages — in-place +/// compaction must use [`append_compact_boundary`] instead, which never +/// rewrites or deletes existing rows (immutable transcript invariant). +/// The destructive DELETE+INSERT variant of this function is what +/// destroyed session transcripts when `created_at`-based truncation met +/// rewritten timestamps (2026-06-11 incident). +pub fn seed_session_with_messages( + session_id: &str, + compacted_messages: &[serde_json::Value], +) -> SqliteResult<()> { + let rows = compacted_history_rows(session_id, compacted_messages); + persist_history_rows(session_id, &rows, true, HistoryRowsValidation::None) +} + +/// Seed a fresh Agent session from typed canonical history. +/// +/// The returned row count is the durable materialization receipt. Exact +/// retries are accepted by [`persist_history_rows`]; mixed or conflicting +/// retries fail without appending a partial suffix. +pub fn seed_session_with_materialized_history( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult { + let rows = materialized_history_rows(session_id, seeds)?; + persist_history_rows( + session_id, + &rows, + true, + HistoryRowsValidation::MaterializedToolGraph, + )?; + Ok(MaterializedHistoryReceipt { + row_count: rows.len(), + }) +} + +/// Append typed canonical history to an existing Agent session atomically. +pub fn append_session_with_materialized_history( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult { + if seeds.is_empty() { + return Ok(MaterializedHistoryReceipt { row_count: 0 }); + } + let rows = materialized_history_rows(session_id, seeds)?; + persist_history_rows( + session_id, + &rows, + false, + HistoryRowsValidation::MaterializedToolGraph, + )?; + Ok(MaterializedHistoryReceipt { + row_count: rows.len(), }) } @@ -944,6 +1334,57 @@ mod tests { use database::db::get_connection; use test_helpers::test_env; + fn materialized_message( + id: &str, + created_at: &str, + role: MaterializedHistoryRole, + text: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::Message { + role, + text: text.to_string(), + images: Vec::new(), + }, + } + } + + fn materialized_tool_call( + id: &str, + created_at: &str, + call_id: &str, + name: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::ToolCall { + call_id: call_id.to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + }, + } + } + + fn materialized_tool_result( + id: &str, + created_at: &str, + call_id: &str, + name: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::ToolResult { + call_id: call_id.to_string(), + name: name.to_string(), + output: "done".to_string(), + }, + } + } + fn seed_session_for_message_tests(session_id: &str) { let conn = get_connection().expect("get_connection in seed_session_for_message_tests"); crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); @@ -1163,6 +1604,282 @@ mod tests { assert_eq!(history[2]["content"], "recent assistant"); } + #[test] + fn native_materialization_preserves_portable_message_identity() { + let _sandbox = test_env::sandbox(); + let session_id = "seed-native-identity-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_message( + "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce", + "2026-08-29T00:00:00Z", + MaterializedHistoryRole::User, + "continue", + )], + ) + .expect("seed native identity"); + + let rows = load_messages(session_id).expect("load native identity"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce"); + assert_eq!(rows[0].created_at, "2026-08-29T00:00:00Z"); + } + + #[test] + fn native_materialization_accepts_an_exact_fully_seeded_retry() { + let _sandbox = test_env::sandbox(); + let session_id = "seed-native-idempotent-retry-test"; + seed_session_for_message_tests(session_id); + let transcript = [materialized_message( + "org2-native-v1.c291cmNlLTE.target", + "2026-08-29T00:00:00Z", + MaterializedHistoryRole::User, + "continue", + )]; + + seed_session_with_materialized_history(session_id, &transcript) + .expect("seed native transcript"); + seed_session_with_materialized_history(session_id, &transcript) + .expect("retry exact native seed"); + + let rows = load_messages(session_id).expect("load native transcript"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-native-v1.c291cmNlLTE.target"); + assert_eq!(rows[0].sequence, 0); + } + + #[test] + fn typed_materialization_preserves_native_role_and_tool_order() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-history-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_message( + "user-1", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + )], + ) + .expect("seed prefix"); + + append_session_with_materialized_history( + session_id, + &[ + materialized_message( + "assistant-1", + "2026-08-30T00:00:01Z", + MaterializedHistoryRole::Assistant, + "answer", + ), + MaterializedHistorySeed { + id: "tool-call-1".to_string(), + created_at: "2026-08-30T00:00:02Z".to_string(), + content: MaterializedHistoryContent::ToolCall { + call_id: "call-1".to_string(), + name: "read_file".to_string(), + arguments: "{\"path\":\"README.md\"}".to_string(), + }, + }, + MaterializedHistorySeed { + id: "tool-result-1".to_string(), + created_at: "2026-08-30T00:00:03Z".to_string(), + content: MaterializedHistoryContent::ToolResult { + call_id: "call-1".to_string(), + name: "read_file".to_string(), + output: "contents".to_string(), + }, + }, + ], + ) + .expect("append native suffix"); + + let history = load_llm_history(session_id).expect("load appended history"); + assert_eq!(history.len(), 4); + assert_eq!(history[0]["content"], "first"); + assert_eq!(history[1]["content"], "answer"); + assert_eq!(history[2]["tool_calls"][0]["id"], "call-1"); + assert_eq!(history[3]["tool_call_id"], "call-1"); + let rows = load_messages(session_id).expect("load raw rows"); + assert_eq!( + rows.iter().map(|row| row.sequence).collect::>(), + vec![0, 1, 2, 3] + ); + } + + #[test] + fn typed_materialization_preserves_partial_call_then_closes_it_in_a_suffix() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-partial-tool-call-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_tool_call( + "tool-call-1", + "2026-08-30T00:00:00Z", + "call-1", + "read_file", + )], + ) + .expect("an unresolved call is valid partial-turn history"); + + append_session_with_materialized_history( + session_id, + &[materialized_tool_result( + "tool-result-1", + "2026-08-30T00:00:01Z", + "call-1", + "read_file", + )], + ) + .expect("a later suffix may close the persisted unresolved call"); + + let rows = load_messages(session_id).expect("load partial call history"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].role, shared::message_role::TOOL_CALL); + assert_eq!(rows[1].role, shared::message_role::TOOL_RESULT); + } + + #[test] + fn typed_materialization_rejects_orphan_and_mismatched_tool_results() { + let _sandbox = test_env::sandbox(); + let orphan_session = "append-native-orphan-tool-result-test"; + seed_session_for_message_tests(orphan_session); + let orphan = materialized_tool_result( + "tool-result-orphan", + "2026-08-30T00:00:00Z", + "missing-call", + "read_file", + ); + assert!(seed_session_with_materialized_history(orphan_session, &[orphan]).is_err()); + assert!(load_messages(orphan_session) + .expect("load rejected orphan history") + .is_empty()); + + let mismatch_session = "append-native-mismatched-tool-result-test"; + seed_session_for_message_tests(mismatch_session); + seed_session_with_materialized_history( + mismatch_session, + &[materialized_tool_call( + "tool-call-1", + "2026-08-30T00:00:00Z", + "call-1", + "read_file", + )], + ) + .expect("seed unresolved call"); + let mismatch = materialized_tool_result( + "tool-result-1", + "2026-08-30T00:00:01Z", + "call-1", + "write_file", + ); + assert!(append_session_with_materialized_history(mismatch_session, &[mismatch]).is_err()); + assert_eq!( + load_messages(mismatch_session) + .expect("load history after name mismatch") + .len(), + 1, + "the invalid suffix must be rejected atomically" + ); + } + + #[test] + fn typed_materialization_rejects_duplicate_tool_call_ids() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-duplicate-tool-call-test"; + seed_session_for_message_tests(session_id); + let duplicate_calls = [ + materialized_tool_call("tool-call-1", "2026-08-30T00:00:00Z", "call-1", "read_file"), + materialized_tool_call("tool-call-2", "2026-08-30T00:00:01Z", "call-1", "read_file"), + ]; + + assert!(seed_session_with_materialized_history(session_id, &duplicate_calls).is_err()); + assert!(load_messages(session_id) + .expect("load rejected duplicate-call history") + .is_empty()); + } + + #[test] + fn typed_materialization_accepts_a_fully_applied_suffix_once() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-idempotent-suffix-test"; + seed_session_for_message_tests(session_id); + let suffix = [materialized_message( + "org2-native-v1.c291cmNlLTE.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::Assistant, + "answer", + )]; + + append_session_with_materialized_history(session_id, &suffix) + .expect("append native suffix"); + append_session_with_materialized_history(session_id, &suffix) + .expect("retry committed suffix"); + + let rows = load_messages(session_id).expect("load idempotent suffix"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-native-v1.c291cmNlLTE.target"); + assert_eq!(rows[0].sequence, 0); + } + + #[test] + fn typed_materialization_rejects_missing_identity_metadata() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-missing-identity-test"; + seed_session_for_message_tests(session_id); + let missing_id = materialized_message( + "", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + ); + + assert!(append_session_with_materialized_history(session_id, &[missing_id]).is_err()); + assert!(load_messages(session_id) + .expect("load rows after rejected append") + .is_empty()); + } + + #[test] + fn typed_materialization_rejects_mixed_or_conflicting_suffixes() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-conflicting-suffix-test"; + seed_session_for_message_tests(session_id); + let first = materialized_message( + "org2-native-v1.Zmlyc3Q.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + ); + append_session_with_materialized_history(session_id, std::slice::from_ref(&first)) + .expect("append first native row"); + + let mixed = [ + first.clone(), + materialized_message( + "org2-native-v1.c2Vjb25k.target", + "2026-08-30T00:00:01Z", + MaterializedHistoryRole::Assistant, + "second", + ), + ]; + assert!(append_session_with_materialized_history(session_id, &mixed).is_err()); + + let conflict = [materialized_message( + "org2-native-v1.Zmlyc3Q.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "different", + )]; + assert!(append_session_with_materialized_history(session_id, &conflict).is_err()); + let rows = load_messages(session_id).expect("load rows after rejected suffixes"); + assert_eq!(rows.len(), 1, "failed retries must not append partial rows"); + assert_eq!(rows[0].content, "first"); + } + #[test] fn truncate_anchor_resolution_fails_loud_for_missing_rows() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index ffaf217141..98a0277344 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -43,17 +43,19 @@ pub use sidebar::{ }; pub use messages::{ - anchor_at_or_after_created_at, append_compact_boundary, clear_messages, - clear_session_memory_state, compact_cutoff_sequence, - load_agent_org_inbox_transcript_materializations, load_llm_history, + anchor_at_or_after_created_at, append_compact_boundary, + append_session_with_materialized_history, clear_messages, clear_session_memory_state, + compact_cutoff_sequence, load_agent_org_inbox_transcript_materializations, load_llm_history, load_llm_history_start_sequences, load_llm_history_text_only, load_llm_history_text_only_bounded, load_messages, load_session_memory_state, mark_turn_cancelled, materialize_agent_org_inbox_transcript, message_anchor, message_created_at, save_assistant_msg, save_compact_summary_msg, save_session_memory_state, save_snapshot, save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, - save_user_msg, save_user_msg_with_id, seed_session_with_messages, take_turn_cancelled, - truncate_messages_from_sequence, update_compact_boundary_token_delta, - AgentOrgInboxTranscriptMaterialization, MessageAnchor, + save_user_msg, save_user_msg_with_id, seed_session_with_materialized_history, + seed_session_with_messages, take_turn_cancelled, truncate_messages_from_sequence, + update_compact_boundary_token_delta, AgentOrgInboxTranscriptMaterialization, + MaterializedHistoryContent, MaterializedHistoryReceipt, MaterializedHistoryRole, + MaterializedHistorySeed, MessageAnchor, }; use rusqlite::{Connection, Result as SqliteResult}; diff --git a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs index 40d8236ed9..df6af7c8e3 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs @@ -82,6 +82,44 @@ impl ManualCompactCommandResult { } } +/// Return the canonical in-memory session whose scheduler owns maintenance +/// exclusion, initializing an old persisted session exactly like the normal +/// send path when necessary. Callers must enqueue their mutation on the +/// returned session's [`crate::session::DialogScheduler`]; merely obtaining +/// the handle is not an exclusion boundary. +pub async fn prepare_session_for_scheduler_maintenance( + state: &AgentAppState, + session_id: &str, +) -> Result, String> { + let needs_init = match state.get_session(session_id).await { + Some(session) => session.get_runtime().await.is_none(), + None => true, + }; + if needs_init { + let identity = super::identity::resolve_session_identity( + state, + session_id, + super::identity::IdentityOverrides::default(), + ) + .await?; + let launch_spec = crate::init::launch_spec::AgentLaunchSpec::from_session_sources( + state, + session_id, + identity.workspace_root, + identity.account_id, + Some(identity.model), + identity.native_harness_type, + ) + .await?; + crate::init::init_session(state, launch_spec).await?; + } + + state + .get_session(session_id) + .await + .ok_or_else(|| format!("Session {session_id} missing after runtime initialization")) +} + /// Desktop-only manual compaction. Unlike gateway `/compact`, this rewrites the /// visible durable transcript in-place by appending a compact boundary and does /// not fork the session. @@ -119,56 +157,14 @@ pub async fn agent_session_manual_compact( // resolved config. Initialize on demand exactly like `agent_send_message` // does (idempotent fast-path when already live) instead of bouncing the // user with "send a message first". - let needs_init = match state.get_session(&session_id).await { - Some(session) => session.get_runtime().await.is_none(), - None => true, - }; - if needs_init { - let identity = match super::identity::resolve_session_identity( - state.inner(), - &session_id, - super::identity::IdentityOverrides::default(), - ) - .await - { - Ok(identity) => identity, - Err(err) => { - return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); - } - }; - let launch_spec = match crate::init::launch_spec::AgentLaunchSpec::from_session_sources( - state.inner(), - &session_id, - identity.workspace_root, - identity.account_id, - Some(identity.model), - identity.native_harness_type, - ) - .await - { - Ok(spec) => spec, - Err(err) => { - return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); - } - }; - if let Err(err) = crate::init::init_session(state.inner(), launch_spec).await { + let session = match prepare_session_for_scheduler_maintenance(state.inner(), &session_id).await + { + Ok(session) => session, + Err(err) => { return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); + "session runtime init failed: {err}" + ))) } - } - - let Some(session) = state.get_session(&session_id).await else { - return Ok(ManualCompactCommandResult::status( - ManualCompactStatus::NoRuntime, - )); }; // Always enqueue maintenance, even while a turn is running. The scheduler diff --git a/src-tauri/crates/app-paths/src/home.rs b/src-tauri/crates/app-paths/src/home.rs index f1cad7660a..d1de864686 100644 --- a/src-tauri/crates/app-paths/src/home.rs +++ b/src-tauri/crates/app-paths/src/home.rs @@ -20,6 +20,21 @@ pub fn external_history_home_dir() -> PathBuf { external_history_home_override().unwrap_or_else(home_dir) } +/// User-home root where newly materialized provider-native transcripts live. +/// +/// Production shares the ordinary external-history home so continuations are +/// visible in the provider's native app. Tests may separate bounded discovery +/// from publication with `ORGII_NATIVE_TRANSCRIPT_HOME`. +pub fn native_transcript_home_dir() -> PathBuf { + native_transcript_home_override().unwrap_or_else(external_history_home_dir) +} + +fn native_transcript_home_override() -> Option { + std::env::var_os("ORGII_NATIVE_TRANSCRIPT_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + fn external_history_home_override() -> Option { std::env::var_os("ORGII_EXTERNAL_HISTORY_HOME") .filter(|value| !value.is_empty()) @@ -188,6 +203,31 @@ mod tests { ); } + #[test] + fn native_transcript_home_defaults_to_external_history_home() { + let _lock = env_lock(); + let _native = EnvVarGuard::unset("ORGII_NATIVE_TRANSCRIPT_HOME"); + let _external = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", "/tmp/orgii-discovery"); + + assert_eq!( + native_transcript_home_dir(), + PathBuf::from("/tmp/orgii-discovery") + ); + } + + #[test] + fn native_transcript_home_can_be_separate_from_discovery() { + let _lock = env_lock(); + let _external = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", "/tmp/orgii-discovery"); + let _native = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", "/Users/tester"); + + assert_eq!(native_transcript_home_dir(), PathBuf::from("/Users/tester")); + assert_eq!( + external_history_home_dir(), + PathBuf::from("/tmp/orgii-discovery") + ); + } + #[test] fn xdg_config_dir_is_none_under_isolation_override() { let _lock = env_lock(); diff --git a/src-tauri/crates/integrations/src/cli_binary_resolver.rs b/src-tauri/crates/integrations/src/cli_binary_resolver.rs index 8fedf1cb0a..5d2398b9dd 100644 --- a/src-tauri/crates/integrations/src/cli_binary_resolver.rs +++ b/src-tauri/crates/integrations/src/cli_binary_resolver.rs @@ -406,6 +406,23 @@ pub fn resolve_cli_binary_command(id: CliBinaryId) -> String { resolve_cli_binary(id).command } +/// Resolve a CLI command after checking caller-owned, higher-priority paths. +/// +/// Product-specific callers own which paths are preferred (for example an +/// executable bundled inside a native desktop App). This shared resolver +/// remains the single owner of executable validation and of the ordinary +/// PATH/login-shell/known-location fallback chain. +pub fn resolve_cli_binary_command_preferring( + id: CliBinaryId, + preferred_paths: impl IntoIterator, +) -> String { + preferred_paths + .into_iter() + .find(|path| is_executable_file(path)) + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| resolve_cli_binary_command(id)) +} + /// Best-effort ` --version` probe. /// /// Callers own the cache policy. This function resolves no credentials and @@ -804,6 +821,27 @@ mod tests { assert!(resolution.installed()); } + #[test] + fn preferred_paths_reuse_executable_validation_before_normal_resolution() { + let temp_dir = tempfile::tempdir().unwrap(); + let non_executable = temp_dir.path().join("old-codex"); + let executable = temp_dir.path().join("app-bundled-codex"); + fs::write(&non_executable, "not executable").unwrap(); + make_executable(&executable); + + assert_eq!( + resolve_cli_binary_command_preferring( + CliBinaryId::Codex, + [ + temp_dir.path().join("missing"), + non_executable, + executable.clone(), + ], + ), + executable.to_string_lossy() + ); + } + #[test] fn cursor_known_location_fallback_is_preserved() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs index d8db5cc0c6..baf07afd48 100644 --- a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs +++ b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs @@ -11,6 +11,14 @@ const ZENMUX_ANTHROPIC_BASE_URL: &str = "https://zenmux.ai/api/anthropic"; const LONGCAT_OPENAI_BASE_URL: &str = "https://api.longcat.chat/openai"; const LONGCAT_ANTHROPIC_BASE_URL: &str = "https://api.longcat.chat/anthropic"; const ATLASCLOUD_ANTHROPIC_BASE_URL: &str = "https://api.atlascloud.ai"; +const CLAUDE_CROSS_TYPE_MODEL_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + "DISABLE_INTERLEAVED_THINKING", +]; impl KeyService { /// Get environment variables for running an agent @@ -73,6 +81,14 @@ impl KeyService { } }, ModelType::ClaudeCode => { + // Rebuild Claude routing from the selected account instead of + // trusting an old env mirror. Auth methods are exclusive, the + // endpoint comes from the account's canonical base_url, and + // compatible-provider model overrides never belong to a + // native Claude account. + let stale_env_base_url = env.remove("ANTHROPIC_BASE_URL"); + env.remove("ANTHROPIC_API_KEY"); + env.remove("ANTHROPIC_AUTH_TOKEN"); if entry.auth_method == AuthMethod::Oauth { if let Some(token) = entry .session_token @@ -81,8 +97,15 @@ impl KeyService { { env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), token.to_string()); } - } else if let Some(ref key) = entry.api_key { - env.insert("ANTHROPIC_API_KEY".to_string(), key.clone()); + } else { + if let Some(ref key) = entry.api_key { + env.insert("ANTHROPIC_API_KEY".to_string(), key.clone()); + } + } + if !is_cross_type { + for key in CLAUDE_CROSS_TYPE_MODEL_ENV_KEYS { + env.remove(*key); + } } // Official Claude OAuth tokens (sk-ant-oat…) only authenticate // at api.anthropic.com. A non-official base_url on such a row @@ -97,16 +120,14 @@ impl KeyService { .as_deref() .is_some_and(is_claude_official_oauth_token); if official_oauth - && !is_official_anthropic_endpoint( - env.get("ANTHROPIC_BASE_URL").map(String::as_str), - ) + && !is_official_anthropic_endpoint(stale_env_base_url.as_deref()) + && stale_env_base_url.is_some() { tracing::warn!( "[agent_env_builder] Claude OAuth key {} has a non-official ANTHROPIC_BASE_URL env var; \ official OAuth tokens only authenticate at api.anthropic.com — dropping it", entry.id ); - env.remove("ANTHROPIC_BASE_URL"); } let official_oauth_with_stale_base_url = official_oauth && !is_official_anthropic_endpoint(entry.base_url.as_deref()); diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index e9d4c84d16..55329832bf 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -862,6 +862,21 @@ fn test_claude_code_official_oauth_env_drops_stale_relay_base_url() { "ANTHROPIC_BASE_URL".to_string(), "https://relay.example.com/v1".to_string(), ); + claude_key.env_vars.insert( + "ANTHROPIC_API_KEY".to_string(), + "stale-atlas-key".to_string(), + ); + claude_key + .env_vars + .insert("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()); + claude_key.env_vars.insert( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + "zai-org/glm-5.2".to_string(), + ); + claude_key.env_vars.insert( + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS".to_string(), + "1".to_string(), + ); let key_id = claude_key.id.clone(); service.save_key(claude_key).unwrap(); @@ -871,6 +886,10 @@ fn test_claude_code_official_oauth_env_drops_stale_relay_base_url() { Some("sk-ant-oat01-abc"), ); assert!(!env.contains_key("ANTHROPIC_BASE_URL")); + assert!(!env.contains_key("ANTHROPIC_API_KEY")); + assert!(!env.contains_key("ANTHROPIC_MODEL")); + assert!(!env.contains_key("ANTHROPIC_DEFAULT_OPUS_MODEL")); + assert!(!env.contains_key("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS")); } #[test] @@ -1507,6 +1526,13 @@ fn test_cross_type_exact_match_takes_priority() { let mut claude_key = ModelKey::new(ModelType::ClaudeCode); claude_key.api_key = Some("sk-ant-native".to_string()); + claude_key.env_vars.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "stale-oauth".to_string(), + ); + claude_key + .env_vars + .insert("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()); let claude_id = claude_key.id.clone(); service.save_key(claude_key).unwrap(); @@ -1515,6 +1541,8 @@ fn test_cross_type_exact_match_takes_priority() { env.get("ANTHROPIC_API_KEY").map(|v| v.as_str()), Some("sk-ant-native"), ); + assert!(!env.contains_key("ANTHROPIC_AUTH_TOKEN")); + assert!(!env.contains_key("ANTHROPIC_MODEL")); } #[test] diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index 8bb862a40b..984347e716 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -28,7 +28,9 @@ const CLAUDE_CODE_PROVIDER_SLUG: &str = "claudecode"; // survive Claude Code rewriting the first user message during compaction. // v12: name subagent rows from their small `.meta.json` sidecar instead of // the shared beginning of each child prompt. -const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 14; +// v15: compact summaries are provider context metadata, not human turns or +// first-prompt title candidates. +const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 15; const MAX_COMPACT_BOUNDARY_MARKERS: usize = crate::sources::imported_history::cache::MAX_CONTINUATION_MARKERS - 1; @@ -38,7 +40,7 @@ pub type ClaudeCodeHistorySessionPage = pub type ClaudeCodeRecentPath = crate::sources::imported_history::ImportedHistoryRecentPath; pub use cache_sync::{list_claude_code_history_sessions_paginated, list_claude_code_recent_paths}; -pub use replay::load_claude_code_history_for_session; +pub use replay::{load_claude_code_history_for_session, load_claude_code_history_from_path}; pub use windows::{ load_claude_code_cloud_turn_windows_for_session, load_claude_code_initial_window_for_session, load_claude_code_turn_ids_for_session, load_claude_code_turn_index_for_session, @@ -74,8 +76,6 @@ use metadata::{ parse_claude_session_meta_with_title, session_meta_to_cache_input, }; #[cfg(test)] -use replay::load_claude_code_history_from_path; -#[cfg(test)] use windows::{ claude_window_turn_id, index_claude_user_turns, load_claude_code_cloud_turn_windows_from_path, load_claude_code_initial_window_from_path, load_claude_turn_range, overlay_indexed_body_counts, diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs index c27970c53b..9e9f8d94d6 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs @@ -95,10 +95,14 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { &parse.watermark, )?; if let Some(mut meta) = parse.meta { - let is_managed_history_mirror = managed_ids.contains(&meta.source_session_id); reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); + let is_managed_history_mirror = managed_mirror::is_managed_history_mirror( + &managed_ids, + &input.source_session_id, + input.client_origin, + ); input.listable = input.listable && !is_managed_history_mirror; inputs.push(input); } @@ -109,6 +113,10 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { imported_cache::live_ids_from_signatures(&signatures), inputs, )?; + // Provenance is stored in the transcript and therefore outlives the + // local binding ledger. Repair older cached mirrors even when their files + // are unchanged and the incremental parser correctly skipped them. + managed_mirror::demote_org2_origin_mirrors_from_conn(conn, SOURCE_CLAUDE_CODE)?; imported_cache::write_session_rounds_from_conn(conn, &reparsed_ids, &rounds)?; // Context-window continuations rewrite the conversation into a new // session file with the same first-user-message uuid; keep only the diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs index 2a7f14c7ce..af4f4fbbda 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs @@ -47,7 +47,15 @@ pub(super) fn discover_claude_code_history_records( continue; }; let (source_mtime_ms, source_size_bytes) = - imported_paths::file_metadata_signature(&path, "Claude")?; + match imported_paths::file_metadata_signature(&path, "Claude") { + Ok(signature) => signature, + // Files can disappear between directory enumeration and + // metadata lookup, and old native-materialization runs + // may leave a broken transcript symlink behind. Neither + // makes the other Claude sessions unreadable. + Err(_) if !path.exists() => continue, + Err(error) => return Err(error), + }; let subagent_title = claude_subagent_metadata_title(&path); if let Some(title) = subagent_title.as_ref() { external_titles.insert(file_stem.clone(), title.clone()); diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs index e136ec9648..91d33c38ce 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs @@ -17,7 +17,10 @@ use crate::sources::imported_history::{ use super::discovery::claude_session_title_for_record; use super::replay::{claude_content_items, claude_content_text}; use super::tools::{collect_claude_impact_from_item, collect_claude_impact_from_tool_result}; -use super::types::{is_harness_injected_user_line, ClaudeCodeHistoryMeta, ClaudeJsonlLine}; +use super::types::{ + is_claude_compact_summary, is_harness_injected_user_line, ClaudeCodeHistoryMeta, + ClaudeJsonlLine, +}; use super::{ CLAUDE_CODE_METADATA_PARSER_VERSION, CLAUDE_CODE_SESSION_PREFIX, MAX_COMPACT_BOUNDARY_MARKERS, }; @@ -141,8 +144,10 @@ impl ClaudeSessionMetaState { &mut self.touched_files, ); } + let compact_summary = is_claude_compact_summary(&parsed); if self.first_user_uuid.is_none() && parsed.r#type == "user" + && !compact_summary && !parsed.uuid.trim().is_empty() { self.first_user_uuid = Some(parsed.uuid.trim().to_string()); @@ -165,7 +170,11 @@ impl ClaudeSessionMetaState { } let harness_injected = is_harness_injected_user_line(&parsed); if let Some(message) = parsed.message { - if self.first_prompt.is_empty() && parsed.r#type == "user" && !harness_injected { + if self.first_prompt.is_empty() + && parsed.r#type == "user" + && !compact_summary + && !harness_injected + { if let Some(text) = claude_content_text(&message.content) { // GUI-launched runs prefix the first prompt with the // exec-mode briefing; bridge-only text is no title diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs index 390ab34164..60e12e9672 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs @@ -10,7 +10,7 @@ use crate::sources::imported_history::{self, ImportedToolCall}; use super::discovery::{claude_file_stem_from_session_id, resolve_claude_session_path}; use super::tools::{apply_claude_edit_diff, claude_tool_call_from_item}; -use super::types::{is_harness_injected_user_line, ClaudeJsonlLine}; +use super::types::{is_claude_compact_summary, is_harness_injected_user_line, ClaudeJsonlLine}; use super::CLAUDE_CODE_PROVIDER_SLUG; pub fn load_claude_code_history_for_session( @@ -22,7 +22,7 @@ pub fn load_claude_code_history_for_session( load_claude_code_history_from_path(session_id, &path) } -pub(super) fn load_claude_code_history_from_path( +pub fn load_claude_code_history_from_path( session_id: &str, path: &Path, ) -> Result, String> { @@ -42,6 +42,7 @@ pub(super) fn load_claude_code_history_from_reader( imported_history::PendingCallMap::new(); let mut sequence = start_sequence; let mut forced_first_user_id = forced_first_user_id; + let mut pending_compact_boundary: Option<(String, String)> = None; for line in reader.lines() { let line = line.map_err(|err| format!("Failed to read Claude history line: {err}"))?; @@ -58,6 +59,61 @@ pub(super) fn load_claude_code_history_from_reader( .as_deref() .map(imported_history::normalize_created_at) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + if parsed.r#type == "system" && parsed.subtype == "compact_boundary" { + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + sequence += 1; + } + let boundary_id = if parsed.uuid.trim().is_empty() { + format!("boundary-{sequence}") + } else { + parsed.uuid.clone() + }; + pending_compact_boundary = Some((boundary_id, created_at)); + continue; + } + if is_claude_compact_summary(&parsed) { + let summary = parsed + .message + .as_ref() + .and_then(|message| claude_content_text(&message.content)); + let (boundary_id, boundary_created_at) = + pending_compact_boundary.take().unwrap_or_else(|| { + let id = if parsed.uuid.trim().is_empty() { + format!("summary-{sequence}") + } else { + parsed.uuid.clone() + }; + (id, created_at.clone()) + }); + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + summary.as_deref(), + )); + sequence += 1; + continue; + } + if parsed.message.is_some() { + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + sequence += 1; + } + } let harness_injected = is_harness_injected_user_line(&parsed); let Some(message) = parsed.message else { continue; @@ -66,7 +122,7 @@ pub(super) fn load_claude_code_history_from_reader( match parsed.r#type.as_str() { "user" => { if let Some(tool_result_output) = claude_tool_result_text(&message.content) { - if let Some((call_id, output)) = tool_result_output { + if let Some((call_id, output, is_error)) = tool_result_output { if let Some(call) = pending_tool_calls.remove(&call_id) { let mut chunk = imported_history::tool_call_chunk( session_id, @@ -75,6 +131,11 @@ pub(super) fn load_claude_code_history_from_reader( &call, &output, ); + if is_error { + chunk.result["success"] = Value::Bool(false); + chunk.result["status"] = Value::String("failed".to_string()); + chunk.result["is_error"] = Value::Bool(true); + } // Edit/MultiEdit/Write results carry a // `structuredPatch`; attach it as the exact diff so // the edit card renders the real change. @@ -152,13 +213,22 @@ pub(super) fn load_claude_code_history_from_reader( } } + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + } + for call in pending_tool_calls.drain_in_file_order() { - chunks.push(imported_history::tool_call_chunk( + chunks.push(imported_history::unresolved_tool_call_chunk( session_id, CLAUDE_CODE_PROVIDER_SLUG, sequence, &call, - "", )); sequence += 1; } @@ -166,6 +236,26 @@ pub(super) fn load_claude_code_history_from_reader( Ok(chunks) } +fn claude_context_compacted_chunk( + session_id: &str, + sequence: usize, + boundary_id: &str, + created_at: &str, + summary: Option<&str>, +) -> ActivityChunk { + let mut chunk = ActivityChunk::new(session_id, "context_compacted", "context_compacted"); + chunk.chunk_id = format!("claude-context-compacted-{boundary_id}-{sequence}"); + chunk.created_at = created_at.to_string(); + chunk.result = json!({ + "success": true, + "native": true, + "provider": "claude_code", + "header": "Context compacted", + "observation": summary.unwrap_or(""), + }); + chunk +} + pub(super) fn claude_content_items(content: &Value) -> Vec<&Value> { match content { Value::Array(items) => items.iter().collect(), @@ -218,7 +308,7 @@ fn claude_content_image_data_urls(content: &Value) -> Vec { .collect() } -pub(super) fn claude_tool_result_text(content: &Value) -> Option> { +pub(super) fn claude_tool_result_text(content: &Value) -> Option> { let Value::Array(items) = content else { return None; }; @@ -236,5 +326,9 @@ pub(super) fn claude_tool_result_text(content: &Value) -> Option other.to_string(), None => String::new(), }; - Some(Some((call_id, output))) + let is_error = result_item + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(Some((call_id, output, is_error))) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs index 87d37ecdaa..4d6371e60d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs @@ -134,7 +134,12 @@ fn normalize_claude_tool_call(raw_name: &str, args: Value) -> (String, Value) { imported_history::FUNCTION_EDIT_FILE.to_string(), normalize_edit_args(raw_name, args), ), - _ => (raw_name.to_string(), args), + _ => ( + core_types::cli_alias::resolve_cli_alias(raw_name) + .map(|(storage_name, _)| storage_name.to_string()) + .unwrap_or_else(|| raw_name.to_lowercase()), + args, + ), } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs index 2ee3ae19d9..555f25b9de 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs @@ -94,6 +94,12 @@ pub(super) struct ClaudeJsonlLine { /// loop ticks) that Claude Code's own UI hides from the conversation. #[serde(default)] pub(super) is_meta: bool, + /// Claude Code writes the model-facing summary immediately after a + /// `system/compact_boundary` row as a `user` record. It is provider + /// context metadata, not a human-authored turn and must never render as + /// "Shared user" or enter ORGII's portable role transcript. + #[serde(default)] + pub(super) is_compact_summary: bool, /// Provenance of a user line. Observed kinds: `human` (typed prompt) and /// `task-notification` (background-task completion wake). #[serde(default)] @@ -113,6 +119,10 @@ pub(super) fn is_harness_injected_user_line(parsed: &ClaudeJsonlLine) -> bool { ) } +pub(super) fn is_claude_compact_summary(parsed: &ClaudeJsonlLine) -> bool { + parsed.r#type == "user" && parsed.is_compact_summary +} + #[derive(Debug, Deserialize)] pub(super) struct ClaudeMessage { /// Assistant API-response id (`msg_…`). One response is written across diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs index cc05f14dfb..2e4b8ddaaa 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs @@ -8,7 +8,9 @@ use crate::projectors::turn_metadata::ProjectedTurnMetadata; use crate::sources::imported_history; use super::super::replay::{claude_content_text, claude_tool_result_text}; -use super::super::types::{is_harness_injected_user_line, ClaudeJsonlLine}; +use super::super::types::{ + is_claude_compact_summary, is_harness_injected_user_line, ClaudeJsonlLine, +}; use super::super::CLAUDE_CODE_PROVIDER_SLUG; pub(in crate::sources::claude_code::history) const CLAUDE_WINDOW_TURN_ID_PREFIX: &str = @@ -149,7 +151,10 @@ pub(in crate::sources::claude_code::history) fn index_claude_user_turns( count_toward_previous_turn(&mut turns); continue; }; - if parsed.r#type != "user" || is_harness_injected_user_line(&parsed) { + if parsed.r#type != "user" + || is_claude_compact_summary(&parsed) + || is_harness_injected_user_line(&parsed) + { count_toward_previous_turn(&mut turns); continue; } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs index 8848195574..dc33ed226a 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs @@ -79,6 +79,161 @@ fn parses_claude_jsonl_into_replay_chunks() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn normalizes_claude_read_tool_to_the_shared_storage_identity() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-read-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-read.jsonl"); + let content = r#"{"type":"assistant","sessionId":"abc","timestamp":"2026-09-04T19:14:56Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{"file_path":"/tmp/CLAUDE.md","limit":10}}]}} +{"type":"user","sessionId":"abc","timestamp":"2026-09-04T19:14:57Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read","content":"contents"}]}}"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-read", &path) + .expect("parse read tool transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("read tool call"); + + assert_eq!(tool.function, imported_history::FUNCTION_READ_FILE); + assert_eq!(tool.result["raw_tool_name"], "Read"); + assert_eq!(tool.result["call_id"], "toolu_read"); + assert_eq!(tool.args["file_path"], "/tmp/CLAUDE.md"); + assert_eq!(tool.args["limit"], 10); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn marks_an_unresolved_claude_tool_as_interrupted_not_completed() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-interrupted-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-interrupted.jsonl"); + let content = r#"{"type":"user","sessionId":"abc","timestamp":"2026-08-30T01:00:00Z","message":{"role":"user","content":"inspect"}} +{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I found one thing."}]}} +{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_interrupted","name":"Bash","input":{"command":"sleep 30"}}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-interrupted", &path) + .expect("parse interrupted transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("interrupted tool is diagnostic history"); + assert_eq!(tool.result["status"], "pending"); + assert_eq!(tool.result["interrupted"], true); + assert!(chunks.iter().any(|chunk| { + chunk.function == "assistant" + && chunk.result["content"].as_str() == Some("I found one thing.") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn preserves_claude_native_tool_error_status() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-tool-error-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-tool-error.jsonl"); + let content = r#"{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_failed","name":"Bash","input":{"command":"false"}}]}} +{"type":"user","sessionId":"abc","timestamp":"2026-08-30T01:00:03Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_failed","content":"exit code 1","is_error":true}]}}"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-tool-error", &path) + .expect("parse failed tool result"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("failed tool is preserved"); + assert_eq!(tool.result["success"], false); + assert_eq!(tool.result["status"], "failed"); + assert_eq!(tool.result["is_error"], true); + assert_eq!(tool.result["output"], "exit code 1"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn compact_summary_is_system_metadata_not_a_shared_user_turn() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-compact-history-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-compact-replay.jsonl"); + let content = r#"{"type":"user","uuid":"u-before","timestamp":"2026-08-29T07:00:00Z","message":{"role":"user","content":"inspect the repo"}} +{"type":"assistant","uuid":"a-tool","timestamp":"2026-08-29T07:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_before_compact","name":"Bash","input":{"command":"pwd"}}]}} +{"type":"user","uuid":"tool-result","timestamp":"2026-08-29T07:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_before_compact","content":"/repo"}]}} +{"type":"system","subtype":"compact_boundary","uuid":"compact-boundary-1","parentUuid":null,"timestamp":"2026-08-29T07:00:03Z","compactMetadata":{"trigger":"auto"}} +{"type":"queue-operation","operation":"dequeue","timestamp":"2026-08-29T07:00:03Z"} +{"type":"user","uuid":"compact-summary-1","parentUuid":"compact-boundary-1","isCompactSummary":true,"timestamp":"2026-08-29T07:00:03Z","message":{"role":"user","content":"Native compact summary; this is not a human prompt."}} +{"type":"user","uuid":"u-after","timestamp":"2026-08-29T07:00:04Z","message":{"role":"user","content":"continue after compact"}} +{"type":"assistant","uuid":"a-after","timestamp":"2026-08-29T07:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"continued"}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-compact", &path) + .expect("parse compact transcript"); + let human_messages = chunks + .iter() + .filter(|chunk| chunk.function == imported_history::FUNCTION_USER_MESSAGE) + .map(|chunk| { + chunk.result["message"]["content"] + .as_str() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!( + human_messages, + vec!["inspect the repo", "continue after compact"] + ); + assert!(!human_messages + .iter() + .any(|message| message.contains("Native compact summary"))); + let boundary = chunks + .iter() + .find(|chunk| chunk.function == "context_compacted") + .expect("compact boundary marker"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .count(), + 1 + ); + assert_eq!(boundary.action_type, "context_compacted"); + assert_eq!( + boundary.result["observation"].as_str(), + Some("Native compact summary; this is not a human prompt.") + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("tool pair before compact"); + assert_eq!(tool.args["command"], "pwd"); + assert_eq!(tool.result["output"], "/repo"); + + let indexed = + index_claude_user_turns("claudecodeapp-compact", &path).expect("index compact transcript"); + assert_eq!(indexed.len(), 2, "compact summary is not a turn header"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn byte_index_discovers_rounds_without_parsing_tool_result_bodies() { let temp_dir = std::env::temp_dir().join(format!( @@ -320,8 +475,10 @@ fn harness_injected_first_line_does_not_title_session() { )); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("claude-synthetic-title.jsonl"); - let content = r#"{"type":"user","timestamp":"2026-04-01T07:00:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: the following was run"}} -{"type":"user","timestamp":"2026-04-01T07:00:01Z","origin":{"kind":"human"},"message":{"role":"user","content":"actual request"}} + let content = r#"{"type":"system","subtype":"compact_boundary","uuid":"title-boundary","timestamp":"2026-04-01T06:59:59Z"} +{"type":"user","uuid":"title-compact-summary","isCompactSummary":true,"timestamp":"2026-04-01T06:59:59Z","message":{"role":"user","content":"provider compact summary"}} +{"type":"user","timestamp":"2026-04-01T07:00:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: the following was run"}} +{"type":"user","uuid":"actual-user-uuid","timestamp":"2026-04-01T07:00:01Z","origin":{"kind":"human"},"message":{"role":"user","content":"actual request"}} {"type":"assistant","timestamp":"2026-04-01T07:00:02Z","message":{"role":"assistant","model":"claude-sonnet-4","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":1,"output_tokens":1}}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -342,6 +499,7 @@ fn harness_injected_first_line_does_not_title_session() { .expect("session meta"); assert_eq!(meta.name, "actual request"); + assert_eq!(meta.first_user_uuid.as_deref(), Some("actual-user-uuid")); std::fs::remove_file(&path).expect("remove fixture"); std::fs::remove_dir(&temp_dir).expect("remove temp dir"); @@ -366,9 +524,8 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { } std::fs::write(&path, content).expect("write fixture"); - let window = - load_claude_code_initial_window_from_path("claudecodeapp-counts", &path, 1) - .expect("load initial window"); + let window = load_claude_code_initial_window_from_path("claudecodeapp-counts", &path, 1) + .expect("load initial window"); assert_eq!(window.total_turn_count, 3); assert_eq!(window.loaded_turn_count, 1); @@ -391,7 +548,10 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { Some(&Value::Bool(true)) ); assert_eq!( - placeholder.result.get("observation").and_then(Value::as_str), + placeholder + .result + .get("observation") + .and_then(Value::as_str), Some(format!("round {round} done").as_str()) ); // …and a real end timestamp so the collapse bar shows the round's @@ -402,7 +562,10 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { let ended_at = placeholder.result["unloadedTurn"]["endedAt"] .as_str() .expect("endedAt"); - assert!(ended_at > started_at, "{ended_at} must be after {started_at}"); + assert!( + ended_at > started_at, + "{ended_at} must be after {started_at}" + ); } // The loaded newest round keeps its exact projected counts (no overlay). assert_eq!(window.turns[2].body_event_count, 2); @@ -450,7 +613,10 @@ fn claude_initial_window_previews_skip_tool_use_only_assistant_lines() { .find(|chunk| chunk.chunk_id.starts_with("imported-unloaded-turn-")) .expect("round 1 placeholder"); assert_eq!( - placeholder.result.get("observation").and_then(Value::as_str), + placeholder + .result + .get("observation") + .and_then(Value::as_str), Some("first reply") ); // No stray body chunks may survive next to an unloaded round: its user @@ -459,8 +625,10 @@ fn claude_initial_window_previews_skip_tool_use_only_assistant_lines() { window .chunks .iter() - .filter(|chunk| chunk.function != imported_history::FUNCTION_USER_MESSAGE - && !chunk.chunk_id.starts_with("imported-unloaded-turn-")) + .filter( + |chunk| chunk.function != imported_history::FUNCTION_USER_MESSAGE + && !chunk.chunk_id.starts_with("imported-unloaded-turn-") + ) .count(), 1 // the loaded newest round's single assistant reply ); @@ -823,6 +991,44 @@ fn prefers_claude_subagent_metadata_description_over_prompt() { std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); } +#[cfg(unix)] +#[test] +fn claude_discovery_skips_broken_transcript_symlink() { + use std::os::unix::fs::symlink; + + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-broken-symlink-test-{}", + std::process::id() + )); + std::fs::remove_dir_all(&temp_dir).ok(); + let projects_dir = temp_dir.join("projects/project"); + std::fs::create_dir_all(&projects_dir).expect("create projects dir"); + let live_id = "11111111-1111-1111-1111-111111111111"; + std::fs::write( + projects_dir.join(format!("{live_id}.jsonl")), + format!( + r#"{{"type":"user","sessionId":"{live_id}","timestamp":"2026-08-28T00:00:00Z","message":{{"role":"user","content":"live"}}}} +"# + ), + ) + .expect("write live transcript"); + symlink( + temp_dir.join("missing-native-transcript.jsonl"), + projects_dir.join("22222222-2222-2222-2222-222222222222.jsonl"), + ) + .expect("create broken transcript symlink"); + + let previous = HashMap::new(); + let mut walker = + imported_history::scan_snapshot::SnapshotDirWalker::new(&previous, "jsonl", "Claude"); + let discovery = discover_claude_code_history_records(&[temp_dir.join("projects")], &mut walker) + .expect("broken symlink must not abort Claude discovery"); + + assert_eq!(discovery.records.len(), 1); + assert_eq!(discovery.records[0].source_session_id, live_id); + std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); +} + #[test] fn claude_subagent_metadata_change_invalidates_fingerprint() { let temp_dir = std::env::temp_dir().join(format!( @@ -1050,6 +1256,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { let content = r#"{"type":"custom-title","customTitle":"My convo","sessionId":"d0641111-1111-1111-1111-111111111111"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:00:00.000Z","message":{"role":"user","content":"first message"}} {"type":"system","subtype":"compact_boundary","uuid":"eeb66522-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z"} +{"type":"user","uuid":"compact-summary-not-a-family-key","isCompactSummary":true,"sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z","message":{"role":"user","content":"provider compact summary"}} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000002","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:01:00.000Z","message":{"role":"user","content":"second message"}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -1105,7 +1312,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { } #[test] -fn strips_ide_context_from_claude_replay() { +fn strips_all_orgii_context_wrappers_from_claude_replay() { let temp_dir = std::env::temp_dir().join(format!( "orgii-claude-history-ide-context-test-{}", std::process::id() @@ -1113,9 +1320,10 @@ fn strips_ide_context_from_claude_replay() { std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("claude-ide-context.jsonl"); // Line 1: ide_context-only user message (no user-authored text at all). - // Line 2: bridge + ide_context prefixed user message with real text. + // Line 2 matches a real continuation prompt: provider context + execution + // bridge + IDE context followed by the user-authored text. let content = r#"{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:46.543Z","message":{"role":"user","content":"\nopen file: src/app.ts\n"}} -{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:47.000Z","message":{"role":"user","content":"\ninternal briefing\n\n\n\nopen file: src/app.ts\n\n\nfix the login bug"}} +{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:47.000Z","message":{"role":"user","content":"\nrepository rules\n\n\n\ninternal briefing\n\n\n\nopen file: src/app.ts\n\n\nfix the login bug"}} {"type":"assistant","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:49.000Z","message":{"role":"assistant","model":"claude-sonnet-4","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":3,"output_tokens":5}}} "#; std::fs::write(&path, content).expect("write fixture"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs index a4c5b27d2f..b376a5a0a9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs @@ -368,14 +368,15 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { &parse.watermark, )?; if let Some(mut meta) = parse.meta { - let is_managed_history_mirror = - crate::sources::imported_history::managed_mirror::is_managed_source_session_id( - &managed_ids, - &meta.source_session_id, - ); reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); + let is_managed_history_mirror = + crate::sources::imported_history::managed_mirror::is_managed_history_mirror( + &managed_ids, + &input.source_session_id, + input.client_origin, + ); input.listable = input.listable && !is_managed_history_mirror; inputs.push(input); } @@ -386,6 +387,10 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { imported_cache::live_ids_from_signatures(&signatures), inputs, )?; + crate::sources::imported_history::managed_mirror::demote_org2_origin_mirrors_from_conn( + conn, + SOURCE_CODEX_APP, + )?; imported_cache::write_session_rounds_from_conn(conn, &reparsed_ids, &rounds) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs index 52a2194840..834cb5d0ea 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs @@ -9,5 +9,7 @@ mod shell_tokenizer; mod tool_args; pub(crate) use dispatch::normalize_codex_tool_calls; -pub(in crate::sources::codex::app) use dispatch::{is_codex_shell_tool_key, normalize_tool_name_key}; +pub(in crate::sources::codex::app) use dispatch::{ + is_codex_shell_tool_key, normalize_tool_name_key, +}; pub(in crate::sources::codex::app) use tool_args::normalize_web_search_args; diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs index 1b66259cfa..daa3ce967d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs @@ -9,6 +9,8 @@ mod reader; mod tool_calls; const CODEX_PROVIDER_SLUG: &str = "codex"; +const NATIVE_SOURCE_EVENT_ID_ARG: &str = "__orgiiSourceEventId"; +const NATIVE_SOURCE_EVENT_ID_PREFIX: &str = "orgii_evt_"; pub use reader::{ load_codex_app_from_path, load_codex_app_initial_window_from_path, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs index 19f482950b..a4651a4cff 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs @@ -1,4 +1,5 @@ use core_types::activity::ActivityChunk; +use serde::Deserialize; use serde_json::{json, Value}; use crate::sources::imported_history::{self, strip_orgii_exec_mode_bridge}; @@ -15,6 +16,14 @@ const CODEX_OMITTED_IMAGE_VALUE: &str = "[embedded image omitted]"; /// churn. Remove the ignored payload in-place before JSON parsing while /// preserving the surrounding output array and text parts. pub(crate) fn strip_ignored_embedded_images(line: &mut String) { + // User-authored image blocks are part of the portable conversation and + // must survive a Codex -> canonical -> target-native round trip. Only + // provider/tool output images are projection-irrelevant. Inspect the + // compact JSON envelope before the first image rather than deserializing + // every repeated screenshot payload just to classify the line. + if preserves_user_embedded_images(line) { + return; + } let mut search_from = 0usize; while let Some(relative_marker) = line[search_from..].find(CODEX_EMBEDDED_IMAGE_MARKER) { let marker_start = search_from + relative_marker; @@ -28,6 +37,74 @@ pub(crate) fn strip_ignored_embedded_images(line: &mut String) { } } +fn preserves_user_embedded_images(line: &str) -> bool { + if !line.contains(CODEX_EMBEDDED_IMAGE_MARKER) { + return false; + } + + #[derive(Deserialize)] + struct Envelope<'a> { + #[serde(borrow)] + payload: Option>, + } + + #[derive(Deserialize)] + struct Payload<'a> { + #[serde(rename = "type", borrow)] + kind: Option<&'a str>, + #[serde(borrow)] + role: Option<&'a str>, + #[serde(borrow)] + item: Option>, + } + + #[derive(Deserialize)] + struct Item<'a> { + #[serde(rename = "type", borrow)] + kind: Option<&'a str>, + } + + let Ok(envelope) = serde_json::from_str::>(line) else { + return false; + }; + let Some(payload) = envelope.payload else { + return false; + }; + match payload.kind { + Some("message") => payload.role == Some("user"), + Some("user_message") => true, + Some("item_completed") => payload.item.and_then(|item| item.kind) == Some("UserMessage"), + _ => false, + } +} + +#[cfg(test)] +mod embedded_image_tests { + use super::*; + + #[test] + fn preserves_user_image_data_for_native_transfer() { + let mut line = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,USER"}]}}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(line.contains("data:image/png;base64,USER")); + } + + #[test] + fn preserves_reordered_user_image_envelope_for_native_transfer() { + let mut line = r#"{"payload":{"content":[{"image_url":"data:image/png;base64,USER","type":"input_image"}],"role":"user","type":"message"},"type":"response_item"}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(line.contains("data:image/png;base64,USER")); + } + + #[test] + fn strips_projection_irrelevant_tool_output_images() { + let mut line = r#"{"type":"response_item","payload":{"type":"custom_tool_call_output","output":[{"type":"input_image","image_url":"data:image/png;base64,TOOL"}]}}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(!line.contains("base64,TOOL")); + assert!(line.contains(CODEX_OMITTED_IMAGE_VALUE)); + } +} + pub(crate) fn legacy_user_message_text_from_payload(payload: &Value) -> Option { let raw = payload.get("message").and_then(Value::as_str)?; let stripped = strip_orgii_exec_mode_bridge(raw); @@ -60,6 +137,82 @@ pub(super) fn user_message_from_line(parsed: &CodexJsonlLine) -> Option Vec { + if payload.get("type").and_then(Value::as_str) != Some("message") + || payload.get("role").and_then(Value::as_str) != Some("user") + { + return Vec::new(); + } + + let mut refs = Vec::new(); + let Some(content) = payload.get("content").and_then(Value::as_array) else { + return refs; + }; + for part in content { + if part.get("type").and_then(Value::as_str) != Some("input_image") { + continue; + } + let Some(image_url) = part + .get("image_url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with("data:image/")) + else { + continue; + }; + if !refs.iter().any(|existing| existing == image_url) { + refs.push(image_url.to_string()); + } + } + refs +} + +/// User rows injected through Codex app-server's supported +/// `thread/inject_items` API have no later `event_msg/UserMessage` mirror. +/// Injected response items carry Codex's native stable `id`; the user-role +/// system/context prefix rows do not. Use that provider-owned distinction +/// instead of adding ORG2-only metadata to the transcript. +pub(super) fn injected_user_message_chunk_from_response_message( + session_id: &str, + sequence: usize, + created_at: &str, + payload: &Value, +) -> Option { + let has_native_item_id = payload + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()); + if !has_native_item_id + || payload.get("type").and_then(Value::as_str) != Some("message") + || payload.get("role").and_then(Value::as_str) != Some("user") + { + return None; + } + + let raw_text = content_text_from_payload(payload).unwrap_or_default(); + let text = strip_orgii_exec_mode_bridge(&raw_text).to_string(); + let images = user_image_data_urls_from_response_message(payload); + if text.trim().is_empty() && images.is_empty() { + return None; + } + let mut chunk = imported_history::user_message_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + created_at, + &text, + ); + if !images.is_empty() { + chunk.result["images"] = json!(images); + } + Some(chunk) +} + pub(in crate::sources::codex::app) fn user_message_text_from_line( parsed: &CodexJsonlLine, ) -> Option { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs index b2f4af087a..53f27505ed 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs @@ -13,8 +13,9 @@ use super::super::CodexJsonlLine; use super::cache::CodexTurnOffset; use super::collector::{CodexTranscriptCollectionMode, CodexTranscriptCollector}; use super::messages::{ - content_text_from_payload, reasoning_text_from_payload, strip_ignored_embedded_images, - user_message_chunk_from_line, + content_text_from_payload, injected_user_message_chunk_from_response_message, + reasoning_text_from_payload, strip_ignored_embedded_images, + user_image_data_urls_from_response_message, user_message_chunk_from_line, }; use super::tool_calls::{ attach_subagent_activity_to_pending_call, background_cell_id, background_cell_key, @@ -23,7 +24,7 @@ use super::tool_calls::{ pending_tool_calls_from_payload, resolve_codex_tool_outputs, wait_cell_id, web_search_call_from_payload, PendingBackgroundToolCall, }; -use super::CODEX_PROVIDER_SLUG; +use super::{CODEX_PROVIDER_SLUG, NATIVE_SOURCE_EVENT_ID_ARG, NATIVE_SOURCE_EVENT_ID_PREFIX}; type CodexTranscriptLoad = ( Vec, @@ -31,6 +32,78 @@ type CodexTranscriptLoad = ( Vec, ); +#[derive(Debug)] +struct PendingCompactionMirror { + window_id: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AssistantMirrorKind { + EventMessage, + ResponseItem, +} + +struct PendingAssistantMirror { + kind: AssistantMirrorKind, + message: String, +} + +fn attach_native_source_event_id(chunk: &mut ActivityChunk, payload: &Value) { + let Some(source_event_id) = payload + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some(args) = chunk.args.as_object_mut() else { + return; + }; + args.insert( + NATIVE_SOURCE_EVENT_ID_ARG.to_string(), + Value::String(source_event_id.to_string()), + ); +} + +fn compacted_window_id(payload: &Value) -> Option { + payload + .get("window_id") + .or_else(|| payload.get("first_window_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn compacted_extends_pending_window(pending: &PendingCompactionMirror, payload: &Value) -> bool { + let Some(previous_window_id) = payload + .get("previous_window_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return false; + }; + pending.window_id.as_deref() == Some(previous_window_id) +} + +fn should_emit_assistant_mirror( + pending: &mut Option, + kind: AssistantMirrorKind, + message: &str, +) -> bool { + let is_mirror = pending + .as_ref() + .is_some_and(|previous| previous.kind != kind && previous.message == message); + if is_mirror { + *pending = None; + return false; + } + *pending = Some(PendingAssistantMirror { + kind, + message: message.to_string(), + }); + true +} + pub(super) fn parse_codex_app_from_path_with_mode<'a>( session_id: &'a str, path: &Path, @@ -59,6 +132,30 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( let mut pending_task_turn_offset: Option = None; let mut active_task_turn_id: Option = None; let mut sequence = initial_sequence; + // Current Codex rollouts write one or more chained top-level `compacted` + // window checkpoints followed immediately by an `event_msg/context_compacted` + // UI mirror. Track that provider-owned window chain instead of guessing + // identity from timestamps: two real compactions may legitimately happen + // within a few seconds of each other. + let mut pending_compacted_mirror: Option = None; + // Codex writes one assistant message twice: once as model-context + // `response_item/message` and once as visible `event_msg/agent_message`. + // Either representation can be first and their timestamps can differ by + // a millisecond, so pair only consecutive cross-representation records. + let mut pending_assistant_mirror: Option = None; + // The model-context response item carries portable image data, while the + // following UI projection may carry only a source-machine local path. + // Pair them without emitting the response item as a duplicate user turn. + let mut pending_user_image_data_urls: Vec = Vec::new(); + // `thread/start` may persist user-role provider bootstrap (for example + // plugin/runtime context) before the first native `turn_context`. It is + // model setup, not a conversational user turn. ORG2 app-server injection + // is the one exception: its globally scoped item id is canonical history + // even when an older provider writes it before `turn_context`. + // A non-zero window starts after an already-catalogued native boundary; + // never classify its first row as thread bootstrap merely because the + // preceding `turn_context` lives outside this bounded read. + let mut before_first_turn_context = start_offset == 0; let mut line = String::new(); let mut next_byte_offset = start_offset; @@ -79,17 +176,125 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } let parsed: CodexJsonlLine = match serde_json::from_str(trimmed) { Ok(parsed) => parsed, - Err(_) => continue, + Err(_) => { + pending_compacted_mirror = None; + pending_assistant_mirror = None; + continue; + } }; let created_at = parsed .timestamp .as_deref() .map(imported_history::normalize_created_at) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + if parsed.line_type == "turn_context" { + before_first_turn_context = false; + } + if parsed.line_type == "compacted" { + pending_assistant_mirror = None; + let marker_id = parsed + .payload + .get("window_id") + .or_else(|| parsed.payload.get("first_window_id")) + .and_then(Value::as_str) + .unwrap_or("checkpoint"); + let summary = parsed + .payload + .get("message") + .and_then(Value::as_str) + .filter(|summary| !summary.trim().is_empty()); + let belongs_to_open_window_batch = pending_compacted_mirror + .as_ref() + .is_some_and(|pending| compacted_extends_pending_window(pending, &parsed.payload)); + if belongs_to_open_window_batch { + if let Some(existing) = collector + .current + .last_mut() + .filter(|chunk| chunk.function == "context_compacted") + { + // A single Codex compaction can persist several adjacent + // window checkpoints before its event_msg UI mirror. They + // are one logical boundary, not repeated compactions. + *existing = codex_context_compacted_chunk( + session_id, + sequence.saturating_sub(1), + marker_id, + &created_at, + summary, + ); + } + } else { + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + marker_id, + &created_at, + summary, + )); + sequence += 1; + } + pending_compacted_mirror = Some(PendingCompactionMirror { + window_id: compacted_window_id(&parsed.payload), + }); + continue; + } let Some(payload_type) = parsed.payload.get("type").and_then(Value::as_str) else { + pending_compacted_mirror = None; + pending_assistant_mirror = None; continue; }; + let is_assistant_mirror_record = payload_type == "agent_message" + || (payload_type == "message" + && parsed.payload.get("role").and_then(Value::as_str) == Some("assistant")); + if !is_assistant_mirror_record { + pending_assistant_mirror = None; + } + + if payload_type == "context_compacted" { + if pending_compacted_mirror.take().is_some() { + continue; + } + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + "event", + &created_at, + parsed + .payload + .get("message") + .and_then(Value::as_str) + .filter(|summary| !summary.trim().is_empty()), + )); + sequence += 1; + continue; + } + + // `token_count` is the only provider-owned padding observed between a + // compact checkpoint and its UI mirror. Any conversational/lifecycle + // record closes the batch, so a later nearby compaction remains a + // distinct canonical boundary. + if payload_type != "token_count" { + pending_compacted_mirror = None; + } + + if payload_type == "context_compaction" { + let marker_id = parsed + .payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("context-compaction"); + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + marker_id, + &created_at, + None, + )); + sequence += 1; + continue; + } + match payload_type { // Codex writes task_started immediately before its user_message. // Hold it until the user chunk exists so the projector can attach @@ -103,9 +308,13 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( pending_task_turn_offset = Some(line_start_offset); } "user_message" | "item_completed" => { - if let Some(user_chunk) = + if let Some(mut user_chunk) = user_message_chunk_from_line(session_id, sequence, &created_at, &parsed) { + if !pending_user_image_data_urls.is_empty() { + user_chunk.result["images"] = + json!(std::mem::take(&mut pending_user_image_data_urls)); + } let user_sequence = sequence; sequence += 1; if collector.start_turn(user_chunk) { @@ -134,21 +343,17 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } "agent_message" => { if let Some(message) = parsed.payload.get("message").and_then(Value::as_str) { - collector - .current - .push(imported_history::assistant_message_chunk( - session_id, - CODEX_PROVIDER_SLUG, - sequence, - &created_at, - message, - )); - sequence += 1; - } - } - "message" => { - if parsed.payload.get("role").and_then(Value::as_str) == Some("assistant") { - if let Some(text) = content_text_from_payload(&parsed.payload) { + // Synthesized/native Codex rollouts carry both the + // response_item (model context) and event_msg (visible + // thread mirror). They describe one assistant message, + // not two conversation turns. Either representation can + // be written first, so both branches use the same mirror + // predicate. + if should_emit_assistant_mirror( + &mut pending_assistant_mirror, + AssistantMirrorKind::EventMessage, + message, + ) { collector .current .push(imported_history::assistant_message_chunk( @@ -156,12 +361,68 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( CODEX_PROVIDER_SLUG, sequence, &created_at, - &text, + message, )); sequence += 1; } } } + "message" => { + let role = parsed.payload.get("role").and_then(Value::as_str); + if role == Some("user") { + let is_orgii_injected = parsed + .payload + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX)); + let has_portable_user_images = + !user_image_data_urls_from_response_message(&parsed.payload).is_empty(); + if before_first_turn_context && !is_orgii_injected && !has_portable_user_images + { + continue; + } + if let Some(mut user_chunk) = injected_user_message_chunk_from_response_message( + session_id, + sequence, + &created_at, + &parsed.payload, + ) { + attach_native_source_event_id(&mut user_chunk, &parsed.payload); + let user_sequence = sequence; + sequence += 1; + if collector.start_turn(user_chunk) { + break; + } + collector.record_turn_offset( + format!("codex-user-{user_sequence}"), + line_start_offset, + user_sequence, + ); + } else { + pending_user_image_data_urls = + user_image_data_urls_from_response_message(&parsed.payload); + } + } else if role == Some("assistant") { + if let Some(text) = content_text_from_payload(&parsed.payload) { + if should_emit_assistant_mirror( + &mut pending_assistant_mirror, + AssistantMirrorKind::ResponseItem, + &text, + ) { + let mut chunk = imported_history::assistant_message_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + &created_at, + &text, + ); + attach_native_source_event_id(&mut chunk, &parsed.payload); + collector.current.push(chunk); + sequence += 1; + } + } + } + } "reasoning" | "agent_reasoning" => { if let Some(text) = reasoning_text_from_payload(&parsed.payload) { collector.current.push(imported_history::thinking_chunk( @@ -323,7 +584,12 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( for call in calls { collector .current - .push(codex_tool_call_chunk(session_id, sequence, &call, "", None)); + .push(imported_history::unresolved_tool_call_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + &call, + )); sequence += 1; } } @@ -337,12 +603,45 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } let outputs = output_parts_for_tool_calls(&background.calls, &background.latest_output); for (call, output) in background.calls.iter().zip(outputs.iter()) { - collector.current.push(codex_tool_call_chunk( - session_id, sequence, call, output, None, - )); + let mut interrupted = imported_history::unresolved_tool_call_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + call, + ); + interrupted.result["output"] = Value::String(output.clone()); + interrupted.result["observation"] = Value::String(output.clone()); + if !output.is_empty() { + // This is no longer a grammar-dangling call: Codex exposed + // durable stdout before the process was interrupted. Pair it + // with an explicit interrupted result during cross-runtime + // materialization instead of dropping already-visible work. + interrupted.result["status"] = Value::String("interrupted".to_string()); + } + collector.current.push(interrupted); sequence += 1; } } Ok(collector.finish()) } + +fn codex_context_compacted_chunk( + session_id: &str, + sequence: usize, + marker_id: &str, + created_at: &str, + summary: Option<&str>, +) -> ActivityChunk { + let mut chunk = ActivityChunk::new(session_id, "context_compacted", "context_compacted"); + chunk.chunk_id = format!("codex-context-compacted-{marker_id}-{sequence}"); + chunk.created_at = created_at.to_string(); + chunk.result = json!({ + "success": true, + "native": true, + "provider": "codex", + "header": "Context compacted", + "observation": summary.unwrap_or(""), + }); + chunk +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs index 1749b1c30c..aef2975f83 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs @@ -10,6 +10,291 @@ use super::{ load_codex_app_turn_ids_from_path, }; +#[test] +fn preserves_codex_user_image_data_url_for_native_transfer() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-user-image-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-user-image.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,QUJD"}]}} +{"timestamp":"2026-08-30T01:00:00Z","type":"event_msg","payload":{"type":"item_completed","item":{"type":"UserMessage","id":"user-1","content":[{"type":"text","text":"inspect","text_elements":[]},{"type":"local_image","path":"/source-machine/image.png"}]}}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-user-image", &path) + .expect("parse user image transcript"); + let user = chunks + .iter() + .find(|chunk| chunk.function == "user_message") + .expect("user message"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 1 + ); + assert_eq!(user.result["message"]["content"], "inspect"); + assert_eq!(user.result["images"][0], "data:image/png;base64,QUJD"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn preserves_app_server_injected_user_rows_without_ui_mirrors() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-injected-user-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-injected-user.jsonl"); + // `thread/start` establishes the native turn-context boundary before its + // supported `thread/inject_items` history. Provider bootstrap may precede + // it, while these injected rows deliberately follow it. + let content = r#"{"timestamp":"2026-08-30T00:59:59Z","type":"turn_context","payload":{"turn_id":"materialization"}} +{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"message","id":"user-1","role":"user","content":[{"type":"input_text","text":"first"},{"type":"input_image","image_url":"data:image/png;base64,QUJD"}]}} +{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"message","id":"assistant-1","role":"assistant","content":[{"type":"output_text","text":"answer"}]}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"message","id":"user-2","role":"user","content":[{"type":"input_text","text":"second"}]}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-injected-user", &path) + .expect("parse app-server injected transcript"); + let users = chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .collect::>(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].result["message"]["content"], "first"); + assert_eq!(users[0].result["images"][0], "data:image/png;base64,QUJD"); + assert_eq!(users[0].args["__orgiiSourceEventId"], "user-1"); + assert_eq!(users[1].result["message"]["content"], "second"); + assert_eq!(users[1].args["__orgiiSourceEventId"], "user-2"); + let assistants = chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .collect::>(); + assert_eq!(assistants.len(), 1); + assert_eq!(assistants[0].args["__orgiiSourceEventId"], "assistant-1"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn drops_pre_turn_app_server_bootstrap_without_trimming_later_native_turns() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-app-server-bootstrap-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-app-server-bootstrap.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"session_meta","payload":{"id":"native-1"}} +{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"message","id":"msg-bootstrap","role":"user","content":[{"type":"input_text","text":"provider bootstrap"}]}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"message","id":"orgii_evt_fedcba9876543210fedcba9876543210","role":"user","content":[{"type":"input_text","text":"early canonical team message"}]}} +{"timestamp":"2026-08-30T01:00:03Z","type":"turn_context","payload":{"turn_id":"turn-native"}} +{"timestamp":"2026-08-30T01:00:04Z","type":"event_msg","payload":{"type":"user_message","message":"real native question","images":[],"local_images":[]}} +{"timestamp":"2026-08-30T01:00:05Z","type":"event_msg","payload":{"type":"agent_message","message":"real native answer"}} +{"timestamp":"2026-08-30T01:00:06Z","type":"response_item","payload":{"type":"message","id":"orgii_evt_0123456789abcdef0123456789abcdef","role":"user","content":[{"type":"input_text","text":"later canonical team message"}]}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-bootstrap-boundary", &path) + .expect("parse app-server bootstrap transcript"); + let messages = chunks + .iter() + .filter(|chunk| chunk.function == "user_message" || chunk.function == "assistant") + .collect::>(); + + assert_eq!(messages.len(), 4); + assert_eq!( + messages[0].result["message"]["content"], + "early canonical team message" + ); + assert_eq!( + messages[0].args["__orgiiSourceEventId"], + "orgii_evt_fedcba9876543210fedcba9876543210" + ); + assert_eq!( + messages[1].result["message"]["content"], + "real native question" + ); + assert_eq!(messages[2].result["content"], "real native answer"); + assert_eq!( + messages[3].result["message"]["content"], + "later canonical team message" + ); + assert_eq!( + messages[3].args["__orgiiSourceEventId"], + "orgii_evt_0123456789abcdef0123456789abcdef" + ); + assert!(chunks.iter().all(|chunk| { + chunk.result["message"]["content"].as_str() + != Some("provider bootstrap") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn marks_an_unresolved_codex_tool_as_interrupted_not_completed() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-interrupted-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-interrupted.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-30T01:00:01Z","type":"event_msg","payload":{"type":"agent_message","message":"I found one thing."}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"function_call","name":"read_file","arguments":"{\"path\":\"/repo/README.md\"}","call_id":"call_interrupted"}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-interrupted", &path) + .expect("parse interrupted transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("interrupted tool is diagnostic history"); + assert_eq!(tool.result["status"], "pending"); + assert_eq!(tool.result["interrupted"], true); + assert!(chunks.iter().any(|chunk| { + chunk.function == "assistant" + && chunk.result["content"].as_str() == Some("I found one thing.") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn native_compaction_is_one_system_marker_not_replacement_user_history() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-compact-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-compact.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"inspect the repo","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:01Z","type":"response_item","payload":{"type":"function_call","name":"read_file","arguments":"{\"path\":\"/repo/README.md\"}","call_id":"call_before_compact"}} +{"timestamp":"2026-08-29T07:00:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_before_compact","output":"contents"}} +{"timestamp":"2026-08-29T07:00:03Z","type":"event_msg","payload":{"type":"agent_message","message":"done"}} +{"timestamp":"2026-08-29T07:00:04Z","type":"compacted","payload":{"message":"Native Codex summary","replacement_history":[{"item":{"type":"message","role":"user","content":[{"type":"input_text","text":"replacement history copy"}]}},{"item":{"type":"compaction","encrypted_content":"opaque-provider-state"}}],"window_number":2,"first_window_id":"window-1","previous_window_id":"window-1","window_id":"window-2"}} +{"timestamp":"2026-08-29T07:00:04Z","type":"event_msg","payload":{"type":"token_count","info":null}} +{"timestamp":"2026-08-29T07:00:04Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:05Z","type":"event_msg","payload":{"type":"user_message","message":"continue after compact","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:06Z","type":"event_msg","payload":{"type":"agent_message","message":"continued"}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-compact", &path) + .expect("parse native compact transcript"); + let human_messages = chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .map(|chunk| { + chunk.result["message"]["content"] + .as_str() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!( + human_messages, + vec!["inspect the repo", "continue after compact"] + ); + assert!(!serde_json::to_string(&chunks) + .expect("serialize chunks") + .contains("replacement history copy")); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 1); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("Native Codex summary") + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("paired tool call"); + assert_eq!(tool.args["path"], "/repo/README.md"); + assert_eq!(tool.result["output"], "contents"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn adjacent_native_compaction_windows_form_one_logical_boundary() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-compact-windows-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-compact-windows.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:04.000Z","type":"compacted","payload":{"message":"","window_number":152,"window_id":"window-152","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.020Z","type":"compacted","payload":{"message":"","window_number":153,"previous_window_id":"window-152","window_id":"window-153","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.040Z","type":"compacted","payload":{"message":"final summary","window_number":154,"previous_window_id":"window-153","window_id":"window-154","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.050Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:05.000Z","type":"event_msg","payload":{"type":"user_message","message":"continue","images":[],"local_images":[]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-compact-windows", &path) + .expect("parse native compact windows"); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 1); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("final summary") + ); + assert!(compact_markers[0].chunk_id.contains("window-154")); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn nearby_distinct_native_compactions_are_not_merged() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-distinct-native-compacts-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-distinct-compacts.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:01.000Z","type":"compacted","payload":{"message":"first summary","window_number":2,"previous_window_id":"window-1","window_id":"window-2","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:01.010Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:02.000Z","type":"compacted","payload":{"message":"second summary","window_number":3,"previous_window_id":"window-2","window_id":"window-3","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:02.010Z","type":"event_msg","payload":{"type":"context_compacted"}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-distinct-native-compacts", &path) + .expect("parse distinct nearby native compactions"); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 2); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("first summary") + ); + assert_eq!( + compact_markers[1].result["observation"].as_str(), + Some("second summary") + ); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn cloud_turn_ids_are_source_offsets_in_transcript_order() { let temp_dir = std::env::temp_dir().join(format!( diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs index 00ab1699de..d54c5eb7d9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs @@ -6,6 +6,7 @@ use super::super::super::desktop_exec::normalize_codex_exec_tool_calls; use super::super::super::normalize::{ normalize_codex_tool_calls, normalize_tool_name_key, normalize_web_search_args, }; +use super::super::NATIVE_SOURCE_EVENT_ID_ARG; pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payload( payload: &Value, @@ -13,11 +14,42 @@ pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payloa ) -> Option<(String, Vec)> { let call_id = payload.get("call_id")?.as_str()?.to_string(); let raw_name = payload.get("name")?.as_str()?.to_string(); - let arguments = payload + let mut arguments = payload .get("arguments") .and_then(Value::as_str) .map(imported_history::parse_inner_json) .unwrap_or_else(|| json!({})); + // `thread/inject_items` preserves the native response-item id supplied by + // the materializer. Canonical tool calls injected through that supported + // API must not be normalized a second time; ordinary Codex rollout tool + // calls have only `call_id` in the currently supported transcript schema. + if let Some(source_item_id) = payload + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + if let Some(args) = arguments.as_object_mut() { + args.insert( + NATIVE_SOURCE_EVENT_ID_ARG.to_string(), + Value::String( + source_item_id + .strip_suffix(":call") + .unwrap_or(source_item_id) + .to_string(), + ), + ); + } + return Some(( + call_id.clone(), + vec![ImportedToolCall { + call_id, + raw_name: raw_name.clone(), + canonical_name: raw_name, + args: arguments, + created_at: created_at.to_string(), + }], + )); + } let normalized_calls = normalize_codex_tool_calls(&raw_name, arguments); let call_count = normalized_calls.len(); if call_count == 0 { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs index 08a3494f62..2c5cc2002d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs @@ -201,6 +201,50 @@ fn parses_codex_jsonl_into_replay_chunks() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn deduplicates_native_assistant_context_and_visible_event_mirror() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-mirror-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-native-mirror.jsonl"); + let content = r#"{"timestamp":"2026-08-26T06:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"hello","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-08-26T06:00:01.000Z","type":"response_item","payload":{"type":"message","id":"a1","role":"assistant","content":[{"type":"output_text","text":"one answer"}]}} +{"timestamp":"2026-08-26T06:00:01.001Z","type":"event_msg","payload":{"type":"agent_message","message":"one answer","phase":"final_answer","memory_citation":null}} +{"timestamp":"2026-08-26T06:00:02.000Z","type":"event_msg","payload":{"type":"user_message","message":"continue","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-08-26T06:00:03.000Z","type":"event_msg","payload":{"type":"agent_message","message":"two answer","phase":"final_answer","memory_citation":null}} +{"timestamp":"2026-08-26T06:00:03.001Z","type":"response_item","payload":{"type":"message","id":"a2","role":"assistant","content":[{"type":"output_text","text":"two answer"}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-mirror", &path).expect("parse"); + let assistant = chunks + .iter() + .filter(|chunk| chunk.function == imported_history::FUNCTION_ASSISTANT) + .collect::>(); + assert_eq!(assistant.len(), 2); + assert_eq!( + assistant[0] + .result + .get("observation") + .or_else(|| assistant[0].result.get("content")) + .and_then(Value::as_str), + Some("one answer") + ); + assert_eq!( + assistant[1] + .result + .get("observation") + .or_else(|| assistant[1].result.get("content")) + .and_then(Value::as_str), + Some("two answer") + ); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn parses_paginated_codex_user_items_without_model_context_duplicates() { let temp_dir = std::env::temp_dir().join(format!( @@ -1320,6 +1364,58 @@ fn codex_write_stdin_polls_merge_into_originating_exec_command() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn codex_background_command_partial_output_is_an_interrupted_result() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-background-partial-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-background-partial.jsonl"); + let content = [ + json!({ + "timestamp": "2026-07-18T01:00:00Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "exec", + "call_id": "call_shell", + "input": r#"const r = await tools.exec_command({cmd:"cargo test",workdir:"/tmp/project",yield_time_ms:10000,max_output_tokens:3000}); text(r)"#, + } + }), + json!({ + "timestamp": "2026-07-18T01:00:10Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call_shell", + "output": [ + { "type": "input_text", "text": "Script running with session ID 82118\n" }, + { "type": "input_text", "text": r#"{"session_id":82118,"output":"Compiling\n"}"# }, + ], + } + }), + ] + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-background-partial", &path).expect("parse"); + assert_eq!(chunks.len(), 1); + assert_eq!( + chunks[0].function, + imported_history::FUNCTION_RUN_COMMAND_LINE + ); + assert_eq!(chunks[0].result["status"], "interrupted"); + assert_eq!(chunks[0].result["interrupted"], true); + assert_eq!(chunks[0].result["output"], "Compiling\n"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn codex_write_stdin_cell_wait_still_merges_into_originating_command() { let temp_dir = std::env::temp_dir().join(format!( @@ -1604,6 +1700,58 @@ fn codex_desktop_exec_unwraps_web_search_query() { assert_eq!(calls[0].args["query"], "Codex app event format"); } +#[test] +fn codex_native_canonical_tool_args_are_not_normalized_twice() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-materialized-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-materialized-tool.jsonl"); + let canonical_args = json!({ + "action": "search", + "query": "Codex app event format", + "queries": [], + "url": "", + "pattern": "", + "payload": {"search_query": [{"q": "Codex app event format"}]} + }); + let payload = json!({ + "type": "function_call", + "id": "tool-item-1", + "name": "web_search", + "arguments": canonical_args.to_string(), + "call_id": "call_materialized_web", + }); + let output = json!({ + "type": "function_call_output", + "call_id": "call_materialized_web", + "output": "search result", + }); + std::fs::write( + &path, + format!( + "{}\n{}\n", + json!({"timestamp": "2026-08-26T00:00:01Z", "type": "response_item", "payload": payload}), + json!({"timestamp": "2026-08-26T00:00:02Z", "type": "response_item", "payload": output}) + ), + ) + .expect("write materialized canonical tool fixture"); + + let chunks = load_codex_app_from_path("codexapp-materialized-tool", &path) + .expect("parse materialized canonical tool call"); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "web_search"); + assert_eq!(chunks[0].args["action"], canonical_args["action"]); + assert_eq!(chunks[0].args["query"], canonical_args["query"]); + assert_eq!(chunks[0].args["payload"], canonical_args["payload"]); + assert_eq!(chunks[0].args["__orgiiSourceEventId"], "tool-item-1"); + assert_eq!(chunks[0].result["output"], "search result"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn codex_first_class_web_search_calls_render_as_web_activity() { let temp_dir = std::env::temp_dir().join(format!( @@ -2436,6 +2584,19 @@ fn strips_orgii_exec_mode_bridge_from_codex_user_text() { ); } +#[test] +fn strips_orgii_provider_context_from_codex_user_text() { + let wrapped = "\nworkspace instructions\n\n\n\nbuild mode\n\n\n\nopen file: src/app.ts\n\n\ncontinue the shared session"; + assert_eq!( + strip_orgii_exec_mode_bridge(wrapped), + "continue the shared session" + ); + + let provider_only = + "\nworkspace instructions\n"; + assert_eq!(strip_orgii_exec_mode_bridge(provider_only), ""); +} + #[test] fn strips_ide_context_from_codex_user_text() { // Bridge + ide_context prefixes followed by the real user text → only diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs index a9ffdfbec5..c5a17fa643 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs @@ -17,6 +17,8 @@ use std::collections::HashSet; use rusqlite::Connection; +use super::client_origin::ImportedClientOrigin; + fn table_exists(conn: &Connection, name: &str) -> bool { conn.query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -103,6 +105,41 @@ pub fn is_managed_source_session_id( }) } +/// Whether an imported transcript is only the native-provider mirror of a +/// session ORGII already owns. +/// +/// The binding ledger is the strongest signal, but it cannot be the only +/// signal: an isolated test home, a moved profile, or a rebuilt local DB can +/// leave an ORGII-authored transcript in the provider's real native store +/// after the ledger row is gone. Both Codex (`originator`) and Claude +/// (`entrypoint` / managed profile path) persist ORGII provenance in the +/// transcript itself, so that provenance is the durable fallback. +pub fn is_managed_history_mirror( + managed_ids: &HashSet, + source_session_id: &str, + client_origin: Option, +) -> bool { + is_managed_source_session_id(managed_ids, source_session_id) + || client_origin == Some(ImportedClientOrigin::Org2) +} + +/// Repair already-cached ORGII mirrors without requiring their native file to +/// change and trigger a reparse. New parses are hidden by +/// [`is_managed_history_mirror`]; this closes the same invariant for cache rows +/// written by an older build or by a process whose binding ledger disappeared. +pub fn demote_org2_origin_mirrors_from_conn( + conn: &Connection, + source: &str, +) -> Result { + conn.execute( + "UPDATE imported_history_session_cache + SET listable = 0 + WHERE source = ?1 AND client_origin = 'org2' AND listable != 0", + [source], + ) + .map_err(|err| format!("Failed to demote ORGII history mirrors: {err}")) +} + /// Fold the managed verdict into a discovery fingerprint so a session that /// becomes managed (or stops being) re-parses on the next scan and its /// `listable` flag flips. @@ -141,6 +178,69 @@ mod tests { assert!(!is_managed_source_session_id(&HashSet::new(), "anything")); } + #[test] + fn org2_provenance_survives_a_missing_binding_ledger() { + let no_ids = HashSet::new(); + assert!(is_managed_history_mirror( + &no_ids, + "native-id-from-an-isolated-run", + Some(ImportedClientOrigin::Org2), + )); + assert!(!is_managed_history_mirror( + &no_ids, + "ordinary-cli-session", + Some(ImportedClientOrigin::Cli), + )); + assert!(!is_managed_history_mirror( + &no_ids, + "unknown-origin-session", + None, + )); + } + + #[test] + fn repairs_cached_org2_mirror_without_hiding_other_clients() { + let conn = Connection::open_in_memory().expect("open"); + conn.execute_batch( + "CREATE TABLE imported_history_session_cache ( + source TEXT NOT NULL, + source_session_id TEXT NOT NULL, + client_origin TEXT NOT NULL, + listable INTEGER NOT NULL + ); + INSERT INTO imported_history_session_cache VALUES + ('claude_code', 'org2-copy', 'org2', 1), + ('claude_code', 'terminal-session', 'cli', 1), + ('codex_app', 'other-source', 'org2', 1);", + ) + .expect("seed"); + + assert_eq!( + demote_org2_origin_mirrors_from_conn(&conn, "claude_code").expect("demote"), + 1 + ); + let rows = conn + .prepare( + "SELECT source_session_id, listable + FROM imported_history_session_cache ORDER BY source_session_id", + ) + .expect("prepare") + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .expect("query") + .collect::, _>>() + .expect("rows"); + assert_eq!( + rows, + vec![ + ("org2-copy".to_string(), 0), + ("other-source".to_string(), 1), + ("terminal-session".to_string(), 1), + ] + ); + } + #[test] fn unions_current_binding_and_ledger() { let conn = Connection::open_in_memory().expect("open"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index 673b259484..6a0b8d9375 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -648,12 +648,11 @@ pub fn recent_paths_from_paths( recent_paths } -/// Internal wrapper blocks ORGII prepends to the prompt it hands the CLI: -/// the GUI exec-mode briefing and the IDE-context injection -/// (`inject_ide_context_into_prompt`). The CLI's native transcript stores -/// the full prompt verbatim, so replay readers must strip these to recover -/// what the user actually typed. +/// Internal wrapper blocks ORGII prepends to the prompt it hands the CLI. +/// The CLI's native transcript stores the full prompt verbatim, so replay +/// readers must strip these to recover what the user actually typed. const INTERNAL_CONTEXT_BLOCKS: &[(&str, &str)] = &[ + ("", ""), ( "", "", @@ -693,8 +692,8 @@ pub fn strip_internal_context_blocks(text: &str) -> &str { } } -/// GUI-launched runs prefix the task with an internal exec-mode briefing; -/// strip it so titles/replay show only what the user typed. +/// GUI-launched runs prefix the task with internal provider, exec-mode, and +/// IDE context; strip them so titles/replay show only what the user typed. /// /// Back-compat name: now also strips the `` injection via /// [`strip_internal_context_blocks`]. @@ -811,6 +810,28 @@ pub fn tool_call_chunk( chunk } +/// A provider-native transcript ended with a tool call but no matching result. +/// Keep it visible as interrupted diagnostics, while making the missing result +/// machine-readable so cross-provider projection can exclude the invalid tail. +pub fn unresolved_tool_call_chunk( + session_id: &str, + provider_slug: &str, + sequence: usize, + call: &ImportedToolCall, +) -> ActivityChunk { + let mut chunk = tool_call_chunk(session_id, provider_slug, sequence, call, ""); + chunk.result = json!({ + "success": false, + "status": "pending", + "call_id": call.call_id, + "output": "", + "observation": "", + "raw_tool_name": call.raw_name, + "interrupted": true, + }); + chunk +} + /// Derive conservative file-impact metadata from normalized edit tool calls. /// /// Source loaders remain responsible for recognizing their native tool names and diff --git a/src-tauri/crates/session-persistence/src/editing.rs b/src-tauri/crates/session-persistence/src/editing.rs index eb8d1b3225..b7a3be6bb0 100644 --- a/src-tauri/crates/session-persistence/src/editing.rs +++ b/src-tauri/crates/session-persistence/src/editing.rs @@ -203,6 +203,39 @@ pub fn delete_event(session_id: &str, event_id: &str) -> SqliteResult { }) } +/// Delete an exact event set in one transaction. +/// +/// Callers that mirror a prefix removal in memory first resolve that prefix to +/// stable IDs, then use this operation so a mid-batch SQLite failure cannot +/// leave only part of the durable set deleted. +pub fn delete_events_by_ids(session_id: &str, event_ids: &[String]) -> SqliteResult { + if event_ids.is_empty() { + return Ok(0); + } + let deleted = with_sessions_writer(|| { + let conn = get_connection()?; + let tx = begin_immediate(&conn)?; + let deleted = { + let mut statement = + tx.prepare_cached("DELETE FROM events WHERE session_id = ?1 AND id = ?2")?; + let mut deleted = 0usize; + for event_id in event_ids { + deleted += statement.execute(params![session_id, event_id])?; + } + deleted + }; + if deleted > 0 { + update_session_metadata(&conn, session_id)?; + } + tx.commit()?; + Ok::(deleted) + })?; + if deleted > 0 { + super::turn_index_debounce::schedule(session_id); + } + Ok(deleted) +} + /// Update an existing event by ID pub fn update_event(session_id: &str, event: &CachedEvent) -> SqliteResult { with_sessions_writer(|| { @@ -277,3 +310,96 @@ pub fn clear_session_history(session_id: &str) -> SqliteResult { deleted_sequences, }) } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::{get_session_metadata, init_session_tables, load_events, save_events, CachedEvent}; + + fn cached_event(session_id: &str, id: &str) -> CachedEvent { + CachedEvent { + id: id.to_string(), + session_id: session_id.to_string(), + event_type: "raw".to_string(), + function_name: Some("user_message".to_string()), + thread_id: None, + args_json: "{}".to_string(), + result_json: "{}".to_string(), + content: id.to_string(), + created_at: "2026-09-05T00:00:00Z".to_string(), + meta_json: None, + history_sequence: None, + } + } + + #[test] + fn batch_delete_rolls_back_every_id_when_one_delete_fails() { + let _guard = crate::ORGII_HOME_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous_home = std::env::var_os("ORGII_HOME"); + let root = std::env::temp_dir().join(format!( + "orgii-session-delete-batch-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create test home"); + std::env::set_var("ORGII_HOME", &root); + + let session_id = "atomic-prefix-delete"; + let ids = vec!["prefix-a".to_string(), "prefix-b".to_string()]; + let conn = get_connection().expect("open session database"); + init_session_tables(&conn).expect("initialize session schema"); + drop(conn); + save_events( + session_id, + &[ + cached_event(session_id, &ids[0]), + cached_event(session_id, &ids[1]), + cached_event(session_id, "keep"), + ], + ) + .expect("seed events"); + let conn = get_connection().expect("reopen session database"); + conn.execute_batch( + "CREATE TRIGGER abort_second_batch_delete + BEFORE DELETE ON events WHEN OLD.id = 'prefix-b' + BEGIN SELECT RAISE(ABORT, 'blocked delete'); END;", + ) + .expect("install aborting delete trigger"); + drop(conn); + + assert!(delete_events_by_ids(session_id, &ids).is_err()); + let remaining = load_events(session_id).expect("reload rolled-back events"); + assert!(remaining.iter().any(|event| event.id == "prefix-a")); + assert!(remaining.iter().any(|event| event.id == "prefix-b")); + assert!(remaining.iter().any(|event| event.id == "keep")); + + let conn = get_connection().expect("reopen session database after rollback"); + conn.execute_batch("DROP TRIGGER abort_second_batch_delete") + .expect("remove aborting delete trigger"); + drop(conn); + assert_eq!( + delete_events_by_ids(session_id, &ids).expect("retry atomic batch delete"), + 2 + ); + let remaining = load_events(session_id).expect("reload successfully deleted events"); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "keep"); + assert_eq!( + get_session_metadata(session_id) + .expect("load session metadata") + .expect("session metadata exists") + .event_count, + 1 + ); + + match previous_home { + Some(value) => std::env::set_var("ORGII_HOME", value), + None => std::env::remove_var("ORGII_HOME"), + } + let _ = fs::remove_dir_all(root); + } +} diff --git a/src-tauri/crates/session-persistence/src/lib.rs b/src-tauri/crates/session-persistence/src/lib.rs index 13ab5b9941..e4ad436920 100644 --- a/src-tauri/crates/session-persistence/src/lib.rs +++ b/src-tauri/crates/session-persistence/src/lib.rs @@ -62,7 +62,9 @@ pub use crud::{ get_cache_stats, get_event, get_session_metadata, load_events, load_session, save_events, save_events_deferred, save_session, search_all_sessions, search_events, update_session_specs, }; -pub use editing::{clear_session_history, delete_event, truncate_after_event, update_event}; +pub use editing::{ + clear_session_history, delete_event, delete_events_by_ids, truncate_after_event, update_event, +}; // Tauri commands — registered in `app::commands::handler_list.inc` as // `session_persistence::cache_*` (formerly `session::cache::cache_*`). diff --git a/src-tauri/crates/session-persistence/src/schema.rs b/src-tauri/crates/session-persistence/src/schema.rs index 67822b04d3..802e0bd7b2 100644 --- a/src-tauri/crates/session-persistence/src/schema.rs +++ b/src-tauri/crates/session-persistence/src/schema.rs @@ -468,8 +468,8 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { /// /// Triggers must go in the same batch: an insert into `events` with a /// surviving trigger referencing the dropped vtable would fail. `DROP TABLE` -/// on an FTS5 vtable removes all of its shadow tables. Marker-gated so the -/// batch runs once (a failed attempt retries next startup); best-effort — +/// on an FTS5 vtable removes all of its shadow tables. Skip cleanup only when +/// the marker and actual schema agree (failed attempts retry); best-effort — /// schema init must never fail over cleanup. fn drop_events_fts(conn: &Connection) { const MARKER: &str = "events_fts_dropped_2026_07"; @@ -483,7 +483,17 @@ fn drop_events_fts(conn: &Connection) { .and_then(|mut stmt| stmt.query_row([MARKER], |row| row.get::<_, i64>(0))) .unwrap_or(0) > 0; - if already_dropped { + // An older executable can recreate these objects after the marker was + // recorded. The schema, not the historical marker, owns this invariant. + let legacy_objects_remain = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name IN + ('events_fts', 'events_ai', 'events_ad', 'events_au'))", + [], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(true); + if already_dropped && !legacy_objects_remain { return; } @@ -697,6 +707,43 @@ mod tests { .expect("query trigger existence") } + #[test] + fn drop_events_fts_rechecks_schema_after_recorded_migration() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE events (id TEXT PRIMARY KEY, content TEXT); + INSERT INTO events VALUES ('retained', 'keep this conversation'); + CREATE TABLE _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL); + INSERT INTO _migrations VALUES ('events_fts_dropped_2026_07', 'old'); + CREATE VIRTUAL TABLE events_fts USING fts5( + content, content='events', content_rowid='rowid' + ); + CREATE TRIGGER events_ad AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, content) + VALUES ('delete', OLD.rowid, OLD.content); + END;", + ) + .unwrap(); + + // Recreated external-content FTS has no entry for the existing row. + // Its delete trigger breaks ordinary cache eviction before recovery. + assert!(conn.execute("DELETE FROM events", []).is_err()); + drop_events_fts(&conn); + + assert!(!table_exists(&conn, "events_fts")); + assert!(!trigger_exists(&conn, "events_ad")); + let content: String = conn + .query_row( + "SELECT content FROM events WHERE id = 'retained'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(content, "keep this conversation"); + drop_events_fts(&conn); + assert_eq!(conn.execute("DELETE FROM events", []).unwrap(), 1); + } + #[test] fn init_session_tables_drops_legacy_events_fts_and_records_marker() { let conn = Connection::open_in_memory().expect("open in-memory sqlite"); diff --git a/src-tauri/crates/session-persistence/src/turn_index.rs b/src-tauri/crates/session-persistence/src/turn_index.rs index c950037b55..f4c8fdc241 100644 --- a/src-tauri/crates/session-persistence/src/turn_index.rs +++ b/src-tauri/crates/session-persistence/src/turn_index.rs @@ -13,6 +13,7 @@ use super::crud::normalize_session_sequences; const USER_MESSAGE_FUNCTION: &str = "user_message"; const IMPORTED_USER_MESSAGE_FUNCTION: &str = "user"; +const CANONICAL_USER_INPUT_FUNCTION: &str = "user_input"; const TURN_STATUS_PENDING: &str = "pending"; const TURN_STATUS_COMPLETED: &str = "completed"; const TURN_STATUS_FAILED: &str = "failed"; @@ -30,7 +31,10 @@ const TURN_STATUS_FAILED: &str = "failed"; /// v11: treat the normalized imported-history `user` function as the same /// turn boundary as the native `user_message` function. /// v12: materialize the canonical `turn_intent_id` carried by the user row. -const TURN_INDEX_VERSION: i64 = 12; +/// v13: treat provider-native canonical `user_input` events as the same turn +/// boundary. These are emitted by the shared role/tool transcript adapter and +/// can arrive through Team Session, personal Cloud sync, or runtime migration. +const TURN_INDEX_VERSION: i64 = 13; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,7 +163,9 @@ fn turn_intent_id_for_row(row: &IndexEventRow) -> Option { fn is_user_message(row: &IndexEventRow) -> bool { matches!( row.function_name.as_deref(), - Some(USER_MESSAGE_FUNCTION | IMPORTED_USER_MESSAGE_FUNCTION) + Some( + USER_MESSAGE_FUNCTION | IMPORTED_USER_MESSAGE_FUNCTION | CANONICAL_USER_INPUT_FUNCTION + ) ) && !is_synthetic_user_input(row) } @@ -253,7 +259,7 @@ fn load_existing_user_event_keys( let mut stmt = conn.prepare_cached( "SELECT id, content, result_json FROM events - WHERE session_id = ?1 AND function_name IN ('user_message', 'user') + WHERE session_id = ?1 AND function_name IN ('user_message', 'user', 'user_input') ORDER BY COALESCE(history_sequence, rowid) ASC, created_at ASC, id ASC", )?; let mut ids = std::collections::HashSet::new(); @@ -283,6 +289,7 @@ fn load_existing_user_event_keys( let preview = content .strip_prefix("user_message ") .or_else(|| content.strip_prefix("user ")) + .or_else(|| content.strip_prefix("user_input ")) .unwrap_or(&content) .to_string(); *content_counts @@ -1015,6 +1022,25 @@ mod tests { assert_eq!(drafts[0].body_event_count, 1); } + #[test] + fn provider_native_user_input_starts_turn() { + let rows = vec![ + row( + "canonical-user-input", + Some(CANONICAL_USER_INPUT_FUNCTION), + "{}", + 1, + ), + row("assistant-event", Some("assistant_message"), "{}", 2), + ]; + + let drafts = build_turn_drafts(&rows, &StaleIntentIds::new()); + + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].turn_id, "canonical-user-input"); + assert_eq!(drafts[0].body_event_count, 1); + } + #[test] fn consecutive_user_messages_do_not_materialize_ghost_pending_turns() { let rows = vec![ diff --git a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs index acac8f10cf..030a0bfb5b 100644 --- a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs +++ b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs @@ -12,6 +12,12 @@ use git::worktree; /// the CLI agent with the resume flag, continuing the previous conversation. #[tauri::command] pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { + // Resume owns the same short lifecycle boundary as create/follow-up. It + // checks for a live runner before waiting for provider identity, preserving + // the global invariant that no control holder waits on an active + // finalizer's identity guard. + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Load session to get the original user_input, current stage, and CLI session ID let session = tokio::task::spawn_blocking({ let sid = session_id.clone(); @@ -81,18 +87,26 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { // Stop any stale per-session proxy from a previous run integrations::proxy::server::stop_session_proxy(&session_id).await; + // Resume participates in the same provider-identity boundary as a normal + // turn so runtime/account patches cannot retarget the active UUID. + let identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Accept the resumed turn exactly like the create path: session + intent go // Running together and the frontend gets a `running` event carrying the // intent, so the terminal event below can be attributed to this turn. let turn_intent_id = super::run::new_turn_intent_id(); let accept_session_id = session_id.clone(); let accept_turn_intent_id = turn_intent_id.clone(); - tokio::task::spawn_blocking(move || { + let accept_result = tokio::task::spawn_blocking(move || { persistence::accept_cli_resume_turn(&accept_session_id, &accept_turn_intent_id) .map_err(|err| format!("failed to accept CLI resume turn lifecycle: {err}")) }) .await - .map_err(|err| format!("Task error: {err}"))??; + .map_err(|err| format!("Task error: {err}")) + .and_then(|result| result); + accept_result?; let mut running_msg = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, @@ -106,6 +120,7 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { let runner_turn_intent_id = turn_intent_id.clone(); let handle = tokio::spawn(async move { + let _identity_guard = identity_guard; if let Err(e) = session_runner::run_session( sid.clone(), input, @@ -113,6 +128,7 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { None, None, Some(&runner_turn_intent_id), + false, ) .await { @@ -184,8 +200,14 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { /// cleans up the persistent Cursor config directory, and removes any worktree. #[tauri::command] pub async fn cli_agent_delete(session_id: String) -> Result { + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Kill the agent process, Tokio task, and per-session proxy session_runner::kill_running_agent(&session_id).await; + let _identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Release proxy token BEFORE deleting the DB row — after deletion, // release_proxy_token_for_session can't find the session to read the token. diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index c33b6f6042..4197391b48 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -1,6 +1,6 @@ //! `cli_agent_run` / `cli_agent_message` / `cli_agent_approval_response` — -//! spawning and driving the background CLI agent runner, plus IDE-context -//! injection and TUI-pane release. +//! spawning and driving the background CLI agent runner, plus typed IDE +//! context forwarding and TUI-pane release. use super::super::persistence; use super::super::session_runner; @@ -37,6 +37,8 @@ pub struct CliRunRequest { /// entry points such as Mobile Remote must request the authoritative row. #[serde(default)] pub materialize_user_message_event: bool, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Send a follow-up message on an existing session, optionally switching the @@ -58,6 +60,8 @@ pub struct CliMessageRequest { /// True when the caller has no desktop-side optimistic EventStore row. #[serde(default)] pub materialize_user_message_event: bool, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Identity of a single turn. `turn_intent_id` keys the `turn_intents` row and @@ -89,24 +93,6 @@ pub(super) fn new_turn_intent_id() -> String { new_id() } -/// Prepend IDE context (open files, git status, etc.) to the user prompt -/// so external CLI agents are aware of the user's IDE state. -fn inject_ide_context_into_prompt(user_input: &str, ide_context: Option<&IdeContext>) -> String { - let Some(ctx) = ide_context else { - return user_input.to_string(); - }; - - let section = agent_core::core::session::prompt::ide_context::format_ide_context(ctx); - if section.is_empty() { - return user_input.to_string(); - } - - format!( - "\n{}\n\n\n{}", - section, user_input - ) -} - /// Park a TUI-hosted session when its terminal pane goes away (PTY exit or /// tab close). Non-TUI sessions and already-terminal rows are left alone. #[tauri::command] @@ -260,6 +246,7 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri turn_intent_id: _, client_message_id: _, materialize_user_message_event, + allow_native_context_recovery, } = request; let TurnIdentity { turn_intent_id, @@ -311,11 +298,35 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri return Ok(()); } - // Hold the registry lock across acceptance persistence + spawn so two - // concurrent calls cannot both create a running intent for one session. - let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; + // Reject an active runner before waiting for provider identity. The + // current finalizer owns identity and then needs the caller-held control + // lock, so reversing that order would deadlock a duplicate start. Do not + // retain the global registry lock while a background finalizer may + // still own identity for this one session. + { + let sessions = session_runner::RUNNING_SESSIONS.lock().await; + if let Some(handle) = sessions.get(&session_id) { + if !handle.is_finished() { + return Err(format!( + "Session {} already has a running agent. Cancel it first.", + session_id + )); + } + } + } - // Guard: prevent duplicate parallel agents for the same session + // Freeze runtime/account/native binding through the complete background + // turn. `session_patch` waits on this guard and therefore applies picker + // changes to the next turn instead of retargeting the active runner. + let identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; + + // Hold the registry lock across acceptance persistence + spawn so an old + // resume entry point that does not share the caller's control guard cannot + // race this turn between the optimistic check above and registration. + let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; if let Some(handle) = sessions.get(&session_id) { if !handle.is_finished() { return Err(format!( @@ -332,11 +343,10 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri } else { None }; - let persist_session_id = session_id.clone(); let persist_turn_intent_id = turn_intent_id.clone(); let persist_client_message_id = client_message_id.clone(); - tokio::task::spawn_blocking(move || { + let accept_result = tokio::task::spawn_blocking(move || { persistence::accept_cli_turn( &persist_session_id, &persist_turn_intent_id, @@ -345,7 +355,9 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri .map_err(|err| format!("failed to accept CLI turn lifecycle: {err}")) }) .await - .map_err(|err| format!("Task error: {err}"))??; + .map_err(|err| format!("Task error: {err}")) + .and_then(|result| result); + accept_result?; // The desktop composer appends a synthetic user event before dispatch and // native-transcript sessions intentionally avoid echoing another chunk. @@ -402,7 +414,6 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri crate::api::websocket_handler::broadcast(running_msg.to_string()); let sid = session_id.clone(); - let cli_input = inject_ide_context_into_prompt(&user_input, ide_context.as_ref()); let resume_id = cli_resume_id.clone(); let agent_mode = mode.clone(); let runner_turn_intent_id = turn_intent_id.clone(); @@ -411,13 +422,16 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri // Spawn as background task let handle = tokio::spawn(async move { - if let Err(e) = session_runner::run_session( + let _identity_guard = identity_guard; + if let Err(e) = session_runner::run_session_with_ide_context( sid.clone(), - cli_input, + user_input, + ide_context, resume_id, agent_mode.as_deref(), images, Some(&runner_turn_intent_id), + allow_native_context_recovery, ) .await { @@ -524,6 +538,7 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result Result Result Result Result, + managed_session_id: &str, +) -> Vec { + for chunk in &mut chunks { + chunk.session_id = managed_session_id.to_string(); + } + chunks +} + +/// Read the exact provider path first, then its imported-history discovery +/// path. A readable exact transcript is authoritative and never consults the +/// eventually-consistent discovery cache. If either candidate exists but its +/// reader fails, propagate that error unless the other candidate succeeds; +/// silently falling back to DB chunks would certify a shorter history against +/// the still-stable native file revision. +fn load_native_transcript_candidate( + managed_session_id: &str, + imported_id: &str, + exact: Exact, + discovery: Discovery, +) -> Result>, String> +where + Exact: FnOnce() -> Result>, String>, + Discovery: FnOnce() -> Result>, String>, +{ + let exact_error = match exact() { + Ok(Some(chunks)) if !chunks.is_empty() => { + return Ok(Some(stamp_managed_session_id(chunks, managed_session_id))); + } + Ok(_) => None, + Err(error) => Some(error), + }; + + let discovery_error = match discovery() { + Ok(Some(chunks)) if !chunks.is_empty() => { + return Ok(Some(stamp_managed_session_id(chunks, managed_session_id))); + } + Ok(_) => None, + Err(error) => Some(error), + }; + + match (exact_error, discovery_error) { + (Some(exact), Some(discovery)) => Err(format!( + "Native transcript load failed for {imported_id}: exact={exact}; discovery={discovery}" + )), + (Some(exact), None) => Err(format!( + "Exact native transcript load failed for {imported_id}: {exact}" + )), + (None, Some(discovery)) => Err(format!( + "Native transcript discovery failed for {imported_id}: {discovery}" + )), + (None, None) => Ok(None), + } +} + /// Resolve and parse a native-mode session's transcript from the CLI's own -/// store through the imported-history loaders. `None` falls back to legacy -/// chunks — covering pre-migration sessions, crash-before-native-write, and -/// a store the reader can't currently open. -fn load_native_transcript_chunks(session: &CodeSession) -> Option> { +/// store through the imported-history loaders. `Ok(None)` falls back to legacy +/// chunks only when no native candidate exists (pre-migration or a first turn +/// before its native file is created). Existing-but-unreadable native state is +/// an error and must never degrade to a shorter DB replay. +fn load_native_transcript_chunks( + session: &CodeSession, +) -> Result>, String> { use super::super::native_transcript; if session.transcript_source != native_transcript::TRANSCRIPT_SOURCE_NATIVE { - return None; + return Ok(None); } - let agent = session - .cli_agent_type - .as_deref() - .and_then(key_vault::key_store::ModelType::from_str)?; - let binding = native_transcript::native_transcript_binding(&agent)?; - // Walk the binding ledger newest→oldest instead of trusting only the - // newest id: an aborted follow-up can bind a fork whose file the killed - // CLI never flushed, and replaying "nothing" would blank turns that a - // superseded fork still holds. - let mut candidate_ids = - persistence::native_transcript_ids_newest_first(&session.session_id, binding.source) - .unwrap_or_default(); - if let Some(cli_session_id) = session.cli_session_id.clone() { - if !candidate_ids.contains(&cli_session_id) { - candidate_ids.push(cli_session_id); - } + // UI replay and provider resume must use the same account-scoped native + // UUID. The historical ledger is source-wide and may contain another + // account's newest UUID after A→B→A; consulting it here would render B's + // transcript while the next send resumes A. Until the ledger itself is + // profile-scoped, fail closed to the exact current account mapping. + let Some((binding, cli_session_id)) = + native_transcript::current_native_store_key_for_session(session)? + else { + return Ok(None); + }; + let imported_id = binding.imported_session_id(&cli_session_id); + load_native_transcript_candidate( + &session.session_id, + &imported_id, + || { + // A managed native session already has an exact provider UUID and + // execution workspace. Read that authoritative file first: it is + // available synchronously after materialization and does not + // require the eventually-consistent imported-history cache. + super::super::native_materializer::load_materialized_cli_transcript( + session, + &cli_session_id, + ) + }, + || { + // Discovery covers legacy/provider files that moved away from the + // bound workspace. The exact success path above stays independent + // of this cache and its database connection. + let conn = database::db::get_connection() + .map_err(|error| format!("Failed to open imported history DB: {error}"))?; + orgtrack_core::sources::imported_history::load_activity_chunks_for_session( + &conn, + &imported_id, + ) + }, + ) +} + +#[cfg(test)] +mod native_transcript_resolution_tests { + use super::*; + + fn one_chunk(session_id: &str) -> Vec { + vec![ActivityChunk::new(session_id, "raw", "assistant_message")] } - let conn = database::db::get_connection().ok()?; - for cli_session_id in candidate_ids { - let imported_id = binding.imported_session_id(&cli_session_id); - match orgtrack_core::sources::imported_history::load_activity_chunks_for_session( - &conn, - &imported_id, - ) { - Ok(Some(mut chunks)) if !chunks.is_empty() => { - // Loaders stamp the imported id; the frontend event store, - // WS merge, and snapshot keys all key on the managed id. - for chunk in &mut chunks { - chunk.session_id = session.session_id.clone(); - } - return Some(chunks); - } - Ok(_) => continue, - Err(err) => { - tracing::warn!( - "[cli_agent_chunks] Native transcript load failed for {imported_id}: {err}" - ); - continue; - } - } + + #[test] + fn exact_success_is_authoritative_and_skips_discovery() { + let chunks = load_native_transcript_candidate( + "managed", + "codex:native", + || Ok(Some(one_chunk("native"))), + || -> Result>, String> { + panic!("discovery must not run after an exact transcript succeeds") + }, + ) + .expect("exact transcript should load") + .expect("exact transcript should be present"); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].session_id, "managed"); + } + + #[test] + fn healthy_discovery_recovers_an_unreadable_exact_candidate() { + let chunks = load_native_transcript_candidate( + "managed", + "claude-code:native", + || Err("exact parse failed".to_string()), + || Ok(Some(one_chunk("imported"))), + ) + .expect("discovery transcript should recover exact failure") + .expect("discovery transcript should be present"); + + assert_eq!(chunks[0].session_id, "managed"); + } + + #[test] + fn unreadable_native_candidate_fails_closed_instead_of_falling_back() { + let error = load_native_transcript_candidate( + "managed", + "codex:native", + || Err("invalid jsonl".to_string()), + || Ok(None), + ) + .expect_err("an unreadable native file must not fall back to DB chunks"); + + assert!(error.contains("invalid jsonl")); + } + + #[test] + fn absent_native_candidates_allow_the_legacy_fallback() { + let chunks = + load_native_transcript_candidate("managed", "codex:native", || Ok(None), || Ok(None)) + .expect("absence is not a read failure"); + + assert!(chunks.is_none()); } - None } /// Where a managed session's transcript of record lives, for display @@ -73,6 +190,119 @@ pub struct CliTranscriptLocation { pub path: Option, } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliTranscriptRevision { + /// False for legacy DB-chunk sessions, which have no provider file. + native: bool, + /// Opaque provider-file-set token. `None` for an unbound or unavailable + /// native transcript; callers must not treat that snapshot as stable. + revision: Option, +} + +fn cached_native_transcript_path( + conn: &rusqlite::Connection, + source: &str, + native_id: &str, +) -> Result, String> { + orgtrack_core::sources::imported_history::cache::get_cached_source_path_from_conn( + conn, source, native_id, + ) + .and_then(|path| { + if path.is_some() { + Ok(path) + } else { + orgtrack_core::sources::imported_history::cache:: + get_cached_source_path_by_suffix_from_conn(conn, source, native_id) + } + }) +} + +fn load_cli_transcript_revision(session_id: &str) -> Result { + use super::super::native_transcript; + + let legacy = || CliTranscriptRevision { + native: false, + revision: None, + }; + let unavailable = || CliTranscriptRevision { + native: true, + revision: None, + }; + + let Some(session) = + persistence::get_session(session_id).map_err(|error| format!("DB error: {error}"))? + else { + return Ok(legacy()); + }; + if session.transcript_source != native_transcript::TRANSCRIPT_SOURCE_NATIVE { + return Ok(legacy()); + } + let Some(binding) = session + .cli_agent_type + .as_deref() + .and_then(key_vault::key_store::ModelType::from_str) + .and_then(|agent| native_transcript::native_transcript_binding(&agent)) + else { + return Ok(unavailable()); + }; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let Some(native_id) = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + else { + return Ok(unavailable()); + }; + + let exact_revision = super::super::native_materializer::materialized_cli_transcript_revision( + &session, &native_id, + ) + .ok() + .flatten(); + + let discovery_revision = database::db::get_connection() + .ok() + .and_then(|conn| { + cached_native_transcript_path(&conn, binding.source, &native_id) + .ok() + .flatten() + }) + .map(std::path::PathBuf::from) + .filter(|path| path.is_file()) + .and_then(|path| native_transcript_revision(&path).ok()); + // The transcript reader tries the exact materialized path and then its + // imported-history discovery path. Track both candidates: if the exact + // file is unreadable and replay falls back to discovery, an external App + // append to either possible source still invalidates the snapshot. + let revision = if exact_revision.is_none() && discovery_revision.is_none() { + None + } else { + Some( + serde_json::to_string(&("native-file-set-v1", exact_revision, discovery_revision)) + .map_err(|error| format!("serialize native transcript revision: {error}"))?, + ) + }; + Ok(CliTranscriptRevision { + native: true, + revision, + }) +} + +/// Return the provider-file-set revision through the same native binding and +/// path resolution used by transcript replay. The token is opaque to +/// TypeScript; callers may only compare it for equality around a canonical +/// read. +#[tauri::command] +pub async fn cli_agent_transcript_revision( + session_id: String, +) -> Result { + tokio::task::spawn_blocking(move || load_cli_transcript_revision(&session_id)) + .await + .map_err(|error| format!("Task error: {error}"))? +} + /// Resolve the storage location of a session's transcript of record. /// Chunks-mode (legacy) sessions report `native: false` — the caller keeps /// showing `sessions.db`. Native sessions report the CLI store file path when @@ -108,16 +338,7 @@ pub async fn cli_agent_transcript_path( .map_err(|err| format!("Failed to open orgtrack source cache DB: {err}"))?; // Exact match first; Codex caches key on the rollout file stem, which // only the `-`-bounded suffix variant matches. - let mut path = - orgtrack_core::sources::imported_history::cache::get_cached_source_path_from_conn( - &conn, - binding.source, - &cli_session_id, - )?; - if path.is_none() { - path = orgtrack_core::sources::imported_history::cache:: - get_cached_source_path_by_suffix_from_conn(&conn, binding.source, &cli_session_id)?; - } + let path = cached_native_transcript_path(&conn, binding.source, &cli_session_id)?; Ok(CliTranscriptLocation { native: true, path }) }) .await @@ -154,7 +375,7 @@ pub async fn cli_agent_chunks(session_id: String) -> Result, let session = persistence::get_session(&session_id).map_err(|e| format!("DB error: {}", e))?; if let Some(session) = session.as_ref() { - if let Some(chunks) = load_native_transcript_chunks(session) { + if let Some(chunks) = load_native_transcript_chunks(session)? { return Ok(chunks); } } @@ -195,9 +416,14 @@ pub async fn cli_agent_truncate_after_chunk( created_at: String, revert_files: Option, ) -> Result { + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Kill any running agent first to prevent it from writing new chunks session_runner::kill_running_agent(&session_id).await; - + let _identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Wipe the Cursor config dir so the agent starts fresh — legacy chunk mode // ONLY. Under `transcript_source = 'native'` that directory IS the // transcript of record (hosted-key Cursor stores its chats under the diff --git a/src-tauri/src/agent_sessions/cli/mod.rs b/src-tauri/src/agent_sessions/cli/mod.rs index e975925c99..adffe4a486 100644 --- a/src-tauri/src/agent_sessions/cli/mod.rs +++ b/src-tauri/src/agent_sessions/cli/mod.rs @@ -15,6 +15,9 @@ pub mod agent_core_bridge; pub mod commands; pub mod hook_approvals; pub mod launch_profile_store; +mod native_ir; +pub mod native_materializer; +mod native_store; pub mod native_transcript; pub mod parsers; pub mod persistence; @@ -85,6 +88,8 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { session_id TEXT NOT NULL REFERENCES code_sessions(session_id) ON DELETE CASCADE, profile_key TEXT NOT NULL, cli_session_id TEXT NOT NULL, + native_catalog_requested_revision INTEGER NOT NULL DEFAULT 0, + native_catalog_applied_revision INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (session_id, profile_key) ); @@ -111,6 +116,14 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { ", )?; + // Existing installations predate durable native-App catalog receipts. + // These additive columns deliberately live on the resume binding owner: + // they describe whether that exact provider UUID still needs its native + // application's discovery metadata refreshed. Do not suppress arbitrary + // ALTER failures here; only the standard duplicate-column race is safe to + // treat as an already-applied migration. + ensure_native_catalog_revision_columns(conn)?; + conn.execute("ALTER TABLE code_session_chunks DROP COLUMN stage_name", []) .ok(); @@ -334,6 +347,40 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { Ok(()) } +fn ensure_native_catalog_revision_columns(conn: &Connection) -> SqliteResult<()> { + for (column, statement) in [ + ( + "native_catalog_requested_revision", + "ALTER TABLE code_session_cli_resume_state + ADD COLUMN native_catalog_requested_revision INTEGER NOT NULL DEFAULT 0", + ), + ( + "native_catalog_applied_revision", + "ALTER TABLE code_session_cli_resume_state + ADD COLUMN native_catalog_applied_revision INTEGER NOT NULL DEFAULT 0", + ), + ] { + let present = conn + .prepare("PRAGMA table_info(code_session_cli_resume_state)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()? + .iter() + .any(|candidate| candidate == column); + if present { + continue; + } + match conn.execute(statement, []) { + Ok(_) => {} + Err(rusqlite::Error::SqliteFailure(_, Some(message))) + if message + .to_ascii_lowercase() + .contains("duplicate column name") => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + /// One-time migration: remove `...` blocks from stored /// user input and user_message chunks so they don't appear in chat history UI. fn migrate_strip_ide_context(conn: &Connection) { @@ -401,3 +448,43 @@ fn migrate_strip_ide_context(conn: &Connection) { ) .ok(); } + +#[cfg(test)] +mod native_catalog_revision_migration_tests { + use super::*; + + #[test] + fn upgrades_legacy_resume_state_idempotently() { + let conn = Connection::open_in_memory().expect("open legacy database"); + init_cli_agent_tables(&conn).expect("prime surrounding CLI schema"); + conn.execute_batch( + "DROP TABLE code_session_cli_resume_state; + CREATE TABLE code_session_cli_resume_state ( + session_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + cli_session_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (session_id, profile_key) + ); + INSERT INTO code_session_cli_resume_state + (session_id, profile_key, cli_session_id, updated_at) + VALUES ('session-1', 'account-1', 'native-1', '2026-09-04T00:00:00Z');", + ) + .expect("create legacy resume-state schema"); + + init_cli_agent_tables(&conn).expect("upgrade legacy schema"); + init_cli_agent_tables(&conn).expect("repeat upgrade"); + + let revisions = conn + .query_row( + "SELECT native_catalog_requested_revision, + native_catalog_applied_revision + FROM code_session_cli_resume_state + WHERE session_id = 'session-1' AND profile_key = 'account-1'", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .expect("read migrated binding"); + assert_eq!(revisions, (0, 0)); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_ir.rs b/src-tauri/src/agent_sessions/cli/native_ir.rs new file mode 100644 index 0000000000..915b893f24 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_ir.rs @@ -0,0 +1,1158 @@ +//! Canonical provider-neutral role/tool conversation IR. +//! +//! This module owns validation and projections from provider/Agent history. +//! Native store mutation and provider serialization remain in the materializer. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; + +use core_types::activity::ActivityChunk; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +pub(super) const MAX_ITEMS: usize = 100_000; +const MAX_SERIALIZED_BYTES: usize = 64 * 1024 * 1024; +const MAX_PORTABLE_TOOL_CALL_ID_LENGTH: usize = 64; +const PORTABLE_TOOL_CALL_NAMESPACE: Uuid = Uuid::from_u128(0x9e7db8a394bf5c589416a244ba6e30d3); + +fn is_portable_tool_call_id(value: &str) -> bool { + !value.is_empty() + && value.chars().count() <= MAX_PORTABLE_TOOL_CALL_ID_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn portable_tool_call_id(value: &str) -> String { + let value = value.trim(); + if is_portable_tool_call_id(value) { + return value.to_string(); + } + + format!( + "call_{}", + Uuid::new_v5(&PORTABLE_TOOL_CALL_NAMESPACE, value.as_bytes()).simple() + ) +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum NativeConversationItem { + Message { + id: String, + role: String, + text: String, + #[serde(default)] + images: Vec, + created_at: String, + #[serde(default)] + turn_id: Option, + }, + ToolCall { + id: String, + call_id: String, + name: String, + arguments: String, + created_at: String, + }, + ToolResult { + id: String, + call_id: String, + name: String, + output: String, + #[serde(default)] + is_error: bool, + #[serde(default)] + interrupted: bool, + created_at: String, + }, + /// Provider-owned effective-context boundary. The full SessionEvent log + /// remains available for UI/history; materialization uses this typed + /// summary plus the structured suffix instead of replaying the superseded + /// pre-compaction model context. + ContextSummary { + id: String, + summary: String, + created_at: String, + }, +} + +impl NativeConversationItem { + pub(super) fn id(&self) -> &str { + match self { + Self::Message { id, .. } + | Self::ToolCall { id, .. } + | Self::ToolResult { id, .. } + | Self::ContextSummary { id, .. } => id, + } + } + + pub(super) fn created_at(&self) -> &str { + match self { + Self::Message { created_at, .. } + | Self::ToolCall { created_at, .. } + | Self::ToolResult { created_at, .. } + | Self::ContextSummary { created_at, .. } => created_at, + } + } +} + +pub(super) fn validate_items(items: &[NativeConversationItem]) -> Result<(), String> { + if items.len() > MAX_ITEMS { + return Err(format!( + "native transcript has {} items; limit is {MAX_ITEMS}", + items.len() + )); + } + struct SerializedSize(usize); + + impl Write for SerializedSize { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("native transcript size overflow"))?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + // Measure the wire representation without allocating a second copy of a + // potentially 64 MiB transcript on every materialize/synchronize call. + let mut encoded_size = SerializedSize(0); + serde_json::to_writer(&mut encoded_size, items) + .map_err(|err| format!("serialize native transcript input: {err}"))?; + if encoded_size.0 > MAX_SERIALIZED_BYTES { + return Err(format!( + "native transcript is {} bytes; limit is {MAX_SERIALIZED_BYTES}", + encoded_size.0 + )); + } + let mut item_ids = HashSet::with_capacity(items.len()); + for item in items { + if item.id().trim().is_empty() { + return Err("native transcript item id is required".to_string()); + } + if !item_ids.insert(item.id()) { + return Err(format!( + "native transcript contains duplicate canonical item id {:?}", + item.id() + )); + } + match item { + NativeConversationItem::Message { + id, role, images, .. + } => { + if !matches!(role.as_str(), "user" | "assistant") { + return Err(format!("unsupported native message role {role:?}")); + } + if role == "assistant" && !images.is_empty() { + return Err(format!( + "assistant historical images cannot be transferred losslessly to this native target: item={id:?}, images={}", + images.len() + )); + } + for image in images { + if !image.starts_with("data:image/") { + return Err(format!( + "historical images must be embedded data URLs for exact native transfer: item={id:?}" + )); + } + } + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err("native tool call requires callId and name".to_string()); + } + if !is_portable_tool_call_id(call_id) { + return Err(format!( + "native tool call id must match [A-Za-z0-9_-] and be at most {MAX_PORTABLE_TOOL_CALL_ID_LENGTH} characters" + )); + } + serde_json::from_str::(arguments).map_err(|err| { + format!("native tool call {call_id} has invalid JSON arguments: {err}") + })?; + } + NativeConversationItem::ToolResult { call_id, name, .. } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err("native tool result requires callId and name".to_string()); + } + if !is_portable_tool_call_id(call_id) { + return Err(format!( + "native tool result id must match [A-Za-z0-9_-] and be at most {MAX_PORTABLE_TOOL_CALL_ID_LENGTH} characters" + )); + } + } + NativeConversationItem::ContextSummary { summary, .. } => { + if summary.trim().is_empty() { + return Err("native context summary cannot be empty".to_string()); + } + } + } + } + Ok(()) +} + +fn json_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(Value::as_str) + .or_else(|| part.get("content").and_then(Value::as_str)) + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn chunk_text(chunk: &ActivityChunk) -> String { + chunk + .result + .get("message") + .and_then(|message| message.get("content")) + .map(json_text) + .filter(|text| !text.is_empty()) + .or_else(|| { + ["content", "observation", "output"] + .into_iter() + .find_map(|field| chunk.result.get(field).and_then(Value::as_str)) + .map(str::to_string) + }) + .unwrap_or_default() +} + +fn transferable_tool_args(chunk: &ActivityChunk) -> Value { + let mut args = chunk.args.clone(); + if let Some(object) = args.as_object_mut() { + object.retain(|key, _| { + key != "conversationTurnId" + && key != "conversationSender" + && !key.starts_with("__orgii") + }); + } + args +} + +fn agent_message_images(message: &Value) -> Vec { + message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|part| { + let image = part.get("image_url")?; + image + .as_str() + .or_else(|| image.get("url").and_then(Value::as_str)) + .filter(|url| url.starts_with("data:image/")) + .map(str::to_string) + }) + .collect() +} + +/// Project the provider reader's authoritative transcript back into the same +/// portable role/tool IR accepted by the materializer. Native lifecycle, +/// usage, reasoning, and compact markers deliberately stay outside this +/// projection; compaction remains owned by the live target provider. +pub(super) fn native_items_from_chunks(chunks: &[ActivityChunk]) -> Vec { + let mut items = Vec::new(); + for chunk in chunks { + match chunk.function.as_str() { + "context_compacted" => { + let summary = chunk_text(chunk); + if !summary.trim().is_empty() { + // Only the latest compact boundary is effective model + // context. Superseded rows remain in SessionEvents for UI + // history but must not be fed to the next provider. + items.clear(); + items.push(NativeConversationItem::ContextSummary { + id: chunk.chunk_id.clone(), + summary, + created_at: chunk.created_at.clone(), + }); + } + } + orgtrack_core::sources::imported_history::FUNCTION_USER_MESSAGE => { + let images = chunk + .result + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + items.push(NativeConversationItem::Message { + id: chunk.chunk_id.clone(), + role: "user".to_string(), + text: chunk_text(chunk), + images, + created_at: chunk.created_at.clone(), + turn_id: None, + }); + } + orgtrack_core::sources::imported_history::FUNCTION_ASSISTANT => { + let text = chunk_text(chunk); + if !text.is_empty() { + items.push(NativeConversationItem::Message { + id: chunk.chunk_id.clone(), + role: "assistant".to_string(), + text, + images: Vec::new(), + created_at: chunk.created_at.clone(), + turn_id: None, + }); + } + } + _ if chunk.action_type == "tool_call" => { + // A provider-native interrupt is recorded as a tool call with + // no result. That is not a portable conversation boundary: + // the canonical projection drops it, so reading the provider + // store back must drop it too instead of inventing a result + // the provider never wrote. + let status_is_pending = chunk + .result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "pending" | "running")); + let interrupted = + chunk.result.get("interrupted").and_then(Value::as_bool) == Some(true); + let output = chunk_text(chunk); + // A call with no provider result cannot cross a runtime + // boundary. If Stop already observed durable output, however, + // carry an honest interrupted result: both native writers can + // encode it as failure (Claude is_error / Codex exit 130). + if (status_is_pending || interrupted) && (!interrupted || output.is_empty()) { + continue; + } + let is_error = chunk.result.get("is_error").and_then(Value::as_bool) == Some(true) + || chunk.result.get("success").and_then(Value::as_bool) == Some(false) + || interrupted + || chunk + .result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "failed" | "error" | "cancelled")); + let raw_call_id = chunk + .result + .get("call_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&chunk.chunk_id); + let call_id = portable_tool_call_id(raw_call_id); + let name = chunk.function.clone(); + items.push(NativeConversationItem::ToolCall { + id: format!("{}:call", chunk.chunk_id), + call_id: call_id.clone(), + name: name.clone(), + arguments: transferable_tool_args(chunk).to_string(), + created_at: chunk.created_at.clone(), + }); + items.push(NativeConversationItem::ToolResult { + id: format!("{}:result", chunk.chunk_id), + call_id, + name, + output, + is_error, + interrupted, + created_at: chunk.created_at.clone(), + }); + } + _ => {} + } + } + items +} + +pub(super) fn native_items_from_agent_history(history: &[Value]) -> Vec { + let mut items = Vec::new(); + // The persisted LLM history serializes a tool result as + // `{"role":"tool","tool_call_id","content"}`; the tool name lives only on + // the assistant `tool_calls` entry that opened the pair. + let mut call_names: std::collections::HashMap = + std::collections::HashMap::new(); + let mut index = 0; + while index < history.len() { + let message = &history[index]; + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or_default(); + let created_at = message + .get("created_at") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + match role { + "user" | "assistant" => { + let text = message.get("content").map(json_text).unwrap_or_default(); + let images = agent_message_images(message); + if !text.is_empty() || !images.is_empty() { + items.push(NativeConversationItem::Message { + id: format!("agent-history-{index}"), + role: role.to_string(), + text, + images, + created_at: created_at.clone(), + turn_id: None, + }); + } + if role != "assistant" { + index += 1; + continue; + } + let tool_calls: Vec<&Value> = message + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + .collect(); + if tool_calls.is_empty() { + index += 1; + continue; + } + // The OpenAI-style history batches every call of one assistant + // step ahead of all of its results. The canonical conversation + // (and the rows this history was reconstructed from) interleave + // each call with its own result, so re-pair the batch here: + // call, result, call, result. Only the immediately following + // `tool` messages belong to this batch. + let mut results: Vec<(usize, &Value)> = Vec::new(); + let mut next = index + 1; + while next < history.len() + && history[next].get("role").and_then(Value::as_str) == Some("tool") + { + results.push((next, &history[next])); + next += 1; + } + for (tool_index, tool) in tool_calls.iter().enumerate() { + let raw_call_id = tool.get("id").and_then(Value::as_str).unwrap_or_default(); + let call_id = portable_tool_call_id(raw_call_id); + let function = tool.get("function").unwrap_or(tool); + let name = function + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool"); + call_names.insert(raw_call_id.to_string(), name.to_string()); + let arguments = function + .get("arguments") + .and_then(Value::as_str) + .unwrap_or("{}"); + items.push(NativeConversationItem::ToolCall { + id: format!("agent-history-{index}-tool-{tool_index}"), + call_id, + name: name.to_string(), + arguments: arguments.to_string(), + created_at: created_at.clone(), + }); + if let Some(position) = results.iter().position(|(_, result)| { + result.get("tool_call_id").and_then(Value::as_str) == Some(raw_call_id) + }) { + let (result_index, result) = results.remove(position); + items.push(tool_result_item(result, result_index, &call_names)); + } + } + // Results whose call is not in this batch keep their history order. + for (result_index, result) in results { + items.push(tool_result_item(result, result_index, &call_names)); + } + index = next; + continue; + } + "tool" => { + items.push(tool_result_item(message, index, &call_names)); + } + _ => {} + } + index += 1; + } + items +} + +fn tool_result_item( + message: &Value, + index: usize, + call_names: &std::collections::HashMap, +) -> NativeConversationItem { + let raw_call_id = message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let call_id = portable_tool_call_id(raw_call_id); + let name = message + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| call_names.get(raw_call_id).cloned()) + .unwrap_or_else(|| "tool".to_string()); + NativeConversationItem::ToolResult { + id: format!("agent-history-{index}-result"), + call_id, + name, + output: message.get("content").map(json_text).unwrap_or_default(), + is_error: message + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + interrupted: message + .get("interrupted") + .and_then(Value::as_bool) + .unwrap_or(false), + created_at: message + .get("created_at") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + } +} + +pub(super) fn native_item_semantically_equal( + left: &NativeConversationItem, + right: &NativeConversationItem, +) -> bool { + match (left, right) { + ( + NativeConversationItem::Message { + role: left_role, + text: left_text, + images: left_images, + .. + }, + NativeConversationItem::Message { + role: right_role, + text: right_text, + images: right_images, + .. + }, + ) => left_role == right_role && left_text == right_text && left_images == right_images, + ( + NativeConversationItem::ToolCall { + call_id: left_id, + name: left_name, + arguments: left_arguments, + .. + }, + NativeConversationItem::ToolCall { + call_id: right_id, + name: right_name, + arguments: right_arguments, + .. + }, + ) => { + left_id == right_id + && left_name == right_name + && tool_arguments_semantically_equal(left_arguments, right_arguments) + } + ( + NativeConversationItem::ToolResult { + call_id: left_id, + name: left_name, + output: left_output, + is_error: left_is_error, + .. + }, + NativeConversationItem::ToolResult { + call_id: right_id, + name: right_name, + output: right_output, + is_error: right_is_error, + .. + }, + ) => { + // `interrupted` refines `is_error` for ORG2 diagnostics only. No + // supported provider transcript carries it: an Anthropic + // `tool_result` block is content plus `is_error`, and a Codex + // `function_call_output` is text. Comparing it here would make + // every transcript ORG2 wrote diverge from itself on read-back. + left_id == right_id + && left_name == right_name + && left_output == right_output + && left_is_error == right_is_error + } + ( + NativeConversationItem::ContextSummary { + summary: left_summary, + .. + }, + NativeConversationItem::ContextSummary { + summary: right_summary, + .. + }, + ) => left_summary == right_summary, + _ => false, + } +} + +/// Structural description used in diagnostics. Content is summarized by +/// length only so provider transcripts never leak into error strings. +fn native_item_shape(item: &NativeConversationItem) -> String { + match item { + NativeConversationItem::Message { + role, text, images, .. + } => format!( + "message:{role}:text={}:images={}", + text.chars().count(), + images.len() + ), + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => format!( + "tool_call:{name}:call={call_id}:arguments={}", + arguments.chars().count() + ), + NativeConversationItem::ToolResult { + call_id, + name, + output, + is_error, + .. + } => format!( + "tool_result:{name}:call={call_id}:output={}:is_error={is_error}", + output.chars().count() + ), + NativeConversationItem::ContextSummary { summary, .. } => { + format!("context_summary:text={}", summary.chars().count()) + } + } +} + +fn tool_arguments_semantically_equal(left: &str, right: &str) -> bool { + match ( + serde_json::from_str::(left), + serde_json::from_str::(right), + ) { + (Ok(left), Ok(right)) => left == right, + // Provider readers should not normally produce invalid JSON, but a + // corrupt native row must not compare equal to a different corrupt + // row merely because both failed to parse. + (Err(_), Err(_)) => left == right, + _ => false, + } +} + +#[derive(Debug)] +struct PortableToolCallBinding { + native_call_id: String, + name: String, + has_result: bool, +} + +#[derive(Default)] +struct PortableToolCallBindings { + canonical_to_native: HashMap, + native_to_canonical: HashMap, +} + +impl PortableToolCallBindings { + fn bind_call( + &mut self, + native_call_id: &str, + canonical_call_id: &str, + name: &str, + ) -> Result<(), String> { + if self.canonical_to_native.contains_key(canonical_call_id) { + return Err("canonical tool call id is reused".to_string()); + } + if self.native_to_canonical.contains_key(native_call_id) { + return Err("provider tool call id is reused".to_string()); + } + self.native_to_canonical + .insert(native_call_id.to_string(), canonical_call_id.to_string()); + self.canonical_to_native.insert( + canonical_call_id.to_string(), + PortableToolCallBinding { + native_call_id: native_call_id.to_string(), + name: name.to_string(), + has_result: false, + }, + ); + Ok(()) + } + + fn match_result( + &mut self, + native_call_id: &str, + canonical_call_id: &str, + name: &str, + ) -> Result<(), String> { + let binding = self + .canonical_to_native + .get_mut(canonical_call_id) + .ok_or_else(|| "tool result has no preceding canonical call".to_string())?; + if binding.native_call_id != native_call_id { + return Err("tool result does not match the provider call alias".to_string()); + } + if binding.name != name { + return Err("tool result name does not match its call".to_string()); + } + if binding.has_result { + return Err("tool call has more than one result".to_string()); + } + binding.has_result = true; + Ok(()) + } + + fn native_call_id_for_result( + &mut self, + canonical_call_id: &str, + name: &str, + ) -> Result { + let native_call_id = self + .canonical_to_native + .get(canonical_call_id) + .ok_or_else(|| "tool result has no preceding canonical call".to_string())? + .native_call_id + .clone(); + self.match_result(&native_call_id, canonical_call_id, name)?; + Ok(native_call_id) + } +} + +/// Compare an authoritative provider transcript with a canonical prefix while +/// preserving provider-local tool-call aliases. If the prefix is valid, return +/// the canonical suffix rewritten so results that cross the prefix boundary +/// still target the provider's accepted call id. +pub(super) fn provider_portable_append_suffix( + authoritative: &[NativeConversationItem], + complete: &[NativeConversationItem], +) -> Result, String> { + if authoritative.len() > complete.len() { + let first_divergence = authoritative + .iter() + .zip(complete) + .position(|(native, canonical)| !native_item_semantically_equal(native, canonical)) + .map(|index| { + format!( + "; first divergence at item {index}: native={} canonical={}", + native_item_shape(&authoritative[index]), + native_item_shape(&complete[index]) + ) + }) + .unwrap_or_default(); + let extra = authoritative[complete.len()..] + .iter() + .take(4) + .map(native_item_shape) + .collect::>() + .join(", "); + return Err(format!( + "provider transcript is longer than the canonical conversation{first_divergence}; native items beyond the canonical end: [{extra}]" + )); + } + + let mut bindings = PortableToolCallBindings::default(); + for (index, (native, canonical)) in authoritative.iter().zip(complete).enumerate() { + let comparison = match (native, canonical) { + ( + NativeConversationItem::ToolCall { + call_id: native_call_id, + name: native_name, + arguments: native_arguments, + .. + }, + NativeConversationItem::ToolCall { + call_id: canonical_call_id, + name: canonical_name, + arguments: canonical_arguments, + .. + }, + ) if native_name == canonical_name + && tool_arguments_semantically_equal(native_arguments, canonical_arguments) => + { + bindings.bind_call(native_call_id, canonical_call_id, canonical_name) + } + ( + NativeConversationItem::ToolResult { + call_id: native_call_id, + name: native_name, + output: native_output, + is_error: native_is_error, + .. + }, + NativeConversationItem::ToolResult { + call_id: canonical_call_id, + name: canonical_name, + output: canonical_output, + is_error: canonical_is_error, + .. + }, + ) if native_name == canonical_name + && native_output == canonical_output + && native_is_error == canonical_is_error => + { + bindings.match_result(native_call_id, canonical_call_id, canonical_name) + } + _ if native_item_semantically_equal(native, canonical) => Ok(()), + _ => Err(format!( + "item semantics differ: native={} canonical={}", + native_item_shape(native), + native_item_shape(canonical) + )), + }; + comparison.map_err(|reason| format!("item {index}: {reason}"))?; + } + + let mut append = Vec::with_capacity(complete.len() - authoritative.len()); + for (index, item) in complete.iter().enumerate().skip(authoritative.len()) { + let mut item = item.clone(); + match &mut item { + NativeConversationItem::ToolCall { call_id, name, .. } => bindings + .bind_call(call_id, call_id, name) + .map_err(|reason| format!("item {index}: {reason}"))?, + NativeConversationItem::ToolResult { call_id, name, .. } => { + *call_id = bindings + .native_call_id_for_result(call_id, name) + .map_err(|reason| format!("item {index}: {reason}"))?; + } + NativeConversationItem::Message { .. } + | NativeConversationItem::ContextSummary { .. } => {} + } + append.push(item); + } + Ok(append) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_tool_call(call_id: &str, name: &str, arguments: &str) -> NativeConversationItem { + NativeConversationItem::ToolCall { + id: format!("call-item-{call_id}"), + call_id: call_id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + created_at: "2026-09-07T00:00:00Z".to_string(), + } + } + + fn test_tool_result(call_id: &str, name: &str) -> NativeConversationItem { + NativeConversationItem::ToolResult { + id: format!("result-item-{call_id}"), + call_id: call_id.to_string(), + name: name.to_string(), + output: "result".to_string(), + is_error: false, + interrupted: false, + created_at: "2026-09-07T00:00:01Z".to_string(), + } + } + + #[test] + fn provider_portable_prefix_rewrites_a_cross_boundary_result() { + let authoritative = vec![test_tool_call( + "provider_call_a", + "read_file", + r#"{"path":"README.md"}"#, + )]; + let complete = vec![ + test_tool_call("canonical_call_a", "read_file", r#"{"path":"README.md"}"#), + test_tool_result("canonical_call_a", "read_file"), + ]; + + let suffix = provider_portable_append_suffix(&authoritative, &complete) + .expect("provider aliases preserve a semantic prefix"); + assert!(matches!( + suffix.as_slice(), + [NativeConversationItem::ToolResult { call_id, .. }] + if call_id == "provider_call_a" + )); + } + + #[test] + fn provider_portable_prefix_rejects_swapped_or_reused_tool_aliases() { + let complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("canonical_b", "read_file", r#"{"path":"b"}"#), + test_tool_result("canonical_a", "read_file"), + test_tool_result("canonical_b", "read_file"), + ]; + let swapped = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("provider_b", "read_file", r#"{"path":"b"}"#), + test_tool_result("provider_b", "read_file"), + test_tool_result("provider_a", "read_file"), + ]; + assert!(provider_portable_append_suffix(&swapped, &complete).is_err()); + + let reused = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("provider_a", "read_file", r#"{"path":"b"}"#), + ]; + assert!(provider_portable_append_suffix(&reused, &complete).is_err()); + + let collision_prefix = vec![test_tool_call( + "canonical_b", + "read_file", + r#"{"path":"a"}"#, + )]; + let colliding_complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("canonical_b", "read_file", r#"{"path":"b"}"#), + ]; + assert!(provider_portable_append_suffix(&collision_prefix, &colliding_complete).is_err()); + } + + #[test] + fn provider_portable_prefix_ignores_provider_unrepresentable_interrupted_refinement() { + let authoritative = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_result("provider_a", "read_file"), + ]; + let mut complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_result("canonical_a", "read_file"), + ]; + if let NativeConversationItem::ToolResult { interrupted, .. } = &mut complete[1] { + *interrupted = true; + } + + assert_eq!( + provider_portable_append_suffix(&authoritative, &complete) + .expect("interrupted is not provider-portable"), + Vec::::new() + ); + } + + #[test] + fn invalid_tool_arguments_compare_by_exact_raw_text() { + let left = test_tool_call("call_a", "read_file", "{invalid-left"); + let same = test_tool_call("call_a", "read_file", "{invalid-left"); + let different = test_tool_call("call_a", "read_file", "{invalid-right"); + + assert!(native_item_semantically_equal(&left, &same)); + assert!(!native_item_semantically_equal(&left, &different)); + assert!(provider_portable_append_suffix(&[left], &[different]).is_err()); + } + + #[test] + fn provider_tool_ids_use_the_portable_conversation_identity() { + let raw_call_id = "call_ZQuKyGuKN6l4aFX6Kg6trDeR:part-0"; + let expected = "call_0b3a8cc5654a5208989d80ed5659c267"; + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = format!("codex-tool-7-{raw_call_id}"); + chunk.args = json!({"path": "CLAUDE.md"}); + chunk.result = json!({ + "call_id": raw_call_id, + "output": "contents", + "status": "completed", + "success": true + }); + + let chunk_items = native_items_from_chunks(&[chunk]); + assert!(matches!( + chunk_items.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + .. + } + ] if call_id == expected && result_call_id == expected + )); + + let history_items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": raw_call_id, + "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": raw_call_id, + "name": "read_file", + "content": "contents" + }), + ]); + assert!(matches!( + history_items.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + .. + } + ] if call_id == expected && result_call_id == expected + )); + + assert_eq!( + portable_tool_call_id("call_already_portable"), + "call_already_portable" + ); + } + + #[test] + fn persisted_tool_result_without_a_name_inherits_the_paired_call_name() { + let items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "call_0b3a8cc5654a5208989d80ed5659c267", + "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "call_0b3a8cc5654a5208989d80ed5659c267", + "content": "Script completed" + }), + ]); + let canonical = vec![ + NativeConversationItem::ToolCall { + id: "canonical-call".to_string(), + call_id: "call_0b3a8cc5654a5208989d80ed5659c267".to_string(), + name: "read_file".to_string(), + arguments: "{\"path\":\"CLAUDE.md\"}".to_string(), + created_at: String::new(), + }, + NativeConversationItem::ToolResult { + id: "canonical-result".to_string(), + call_id: "call_0b3a8cc5654a5208989d80ed5659c267".to_string(), + name: "read_file".to_string(), + output: "Script completed".to_string(), + is_error: false, + interrupted: false, + created_at: String::new(), + }, + ]; + assert!(matches!( + items.as_slice(), + [_, NativeConversationItem::ToolResult { name, .. }] if name == "read_file" + )); + assert!(items + .iter() + .zip(&canonical) + .all(|(left, right)| native_item_semantically_equal(left, right))); + assert!(provider_portable_append_suffix(&items, &canonical) + .expect("a persisted history must be a prefix of the conversation it was built from") + .is_empty()); + } + + #[test] + fn batched_agent_history_tool_calls_are_re_paired_with_their_results() { + // load_llm_history batches every call of one assistant step before all + // of its results; the canonical conversation interleaves them. + let items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "content": "I'm using read-only inspection only.", + "tool_calls": [ + {"id": "call_a", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"}}, + {"id": "call_b", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"package.json\"}"}} + ] + }), + json!({"role": "tool", "tool_call_id": "call_a", "content": "claude"}), + json!({"role": "tool", "tool_call_id": "call_b", "content": "package"}), + json!({"role": "user", "content": "next question"}), + ]); + let shapes: Vec = items + .iter() + .map(|item| match item { + NativeConversationItem::Message { role, .. } => format!("message:{role}"), + NativeConversationItem::ToolCall { call_id, .. } => format!("call:{call_id}"), + NativeConversationItem::ToolResult { call_id, name, .. } => { + format!("result:{call_id}:{name}") + } + NativeConversationItem::ContextSummary { .. } => "summary".to_string(), + }) + .collect(); + assert_eq!( + shapes, + vec![ + "message:assistant", + "call:call_a", + "result:call_a:read_file", + "call:call_b", + "result:call_b:read_file", + "message:user", + ] + ); + } + + #[test] + fn interrupted_tool_output_is_portable_but_an_empty_dangling_call_is_not() { + let interrupted = |output: &str| { + let mut chunk = ActivityChunk::new("source", "tool_call", "run_command_line"); + chunk.chunk_id = "interrupted-command".to_string(); + chunk.args = json!({"command": "pnpm test"}); + chunk.result = json!({ + "call_id": "call_interrupted", + "status": "pending", + "success": false, + "interrupted": true, + "output": output, + "observation": output + }); + chunk + }; + + let partial = native_items_from_chunks(&[interrupted("Tests 12 passed\n")]); + assert!(matches!( + partial.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + output, + is_error: true, + interrupted: true, + .. + } + ] if call_id == "call_interrupted" + && result_call_id == "call_interrupted" + && output == "Tests 12 passed\n" + )); + + assert!(native_items_from_chunks(&[interrupted("")]).is_empty()); + } + + #[test] + fn transport_metadata_is_not_part_of_portable_tool_arguments() { + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = "materialized-tool".to_string(); + chunk.args = json!({ + "path": "README.md", + "conversationTurnId": "turn-1", + "conversationSender": {"memberId": "member-1"}, + "__orgiiSourceEventId": "orgii_evt_source" + }); + chunk.result = json!({ + "call_id": "call_read", + "output": "contents", + "status": "completed", + "success": true + }); + + let items = native_items_from_chunks(&[chunk]); + assert!(matches!( + items.first(), + Some(NativeConversationItem::ToolCall { arguments, .. }) + if serde_json::from_str::(arguments).ok() + == Some(json!({"path": "README.md"})) + )); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_materializer.rs b/src-tauri/src/agent_sessions/cli/native_materializer.rs new file mode 100644 index 0000000000..a937324693 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_materializer.rs @@ -0,0 +1,4708 @@ +//! Structured conversation -> provider-native transcript materialization. +//! +//! This is deliberately not a prompt bridge. Every supported target gets the +//! role/tool records its own resume protocol reads. Unsupported targets fail +//! closed before a process is launched. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use agent_core::session::persistence::{ + MaterializedHistoryContent, MaterializedHistoryRole, MaterializedHistorySeed, +}; +use agent_core::session::{ScheduledKind, ScheduledMessage}; +use agent_core::state::AgentAppState; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use core_types::activity::ActivityChunk; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::oneshot; +use uuid::Uuid; + +#[cfg(test)] +use super::native_ir::native_item_semantically_equal; +pub use super::native_ir::NativeConversationItem; +use super::native_ir::{ + native_items_from_agent_history, native_items_from_chunks, provider_portable_append_suffix, + validate_items, MAX_ITEMS, +}; +use super::native_store::{ + append_suffix_atomically, copy_file_atomically, lock_claude_transcript, + native_transcript_revision, replace_file_link_atomically, write_file_atomically, +}; +use super::native_transcript::TRANSCRIPT_SOURCE_NATIVE; +use super::parsers::codex_app_server as codex_native_catalog; +use super::persistence; + +const CODEX_NATIVE_PATH_CACHE_MAX_ENTRIES: usize = 512; +const CLAUDE_PROJECT_INDEX_VERSION: u64 = 1; +const CLAUDE_DESKTOP_ACCOUNT_SCAN_LIMIT: usize = 64; +const CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT: usize = 2_048; +const CLAUDE_DESKTOP_METADATA_SCAN_LIMIT: usize = 10_000; +// Codex stores rollouts in a date-sharded directory tree. Resolving the same +// native UUID by walking that tree on every turn makes a long-running session +// progressively more expensive even though its path is immutable. Cache only +// successful resolutions and validate the provider file still exists before +// reusing one; deletion or profile cleanup naturally falls back to discovery. +static CODEX_NATIVE_PATH_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Claude's project index is shared by every ORG2 instance that points at the +/// same native history root. The adjacent advisory lock keeps the complete +/// read-modify-write transaction ordered across independently launched ORG2 +/// processes. Locking +/// the index file itself would be incorrect because `atomic_json` replaces its +/// inode. +struct ClaudeProjectIndexGuard { + lock_file: fs::File, +} + +impl Drop for ClaudeProjectIndexGuard { + fn drop(&mut self) { + // Releasing an advisory lock during Drop is best effort. Closing the + // descriptor releases it as well, including after an unlock error. + let _ = self.lock_file.unlock(); + } +} + +fn lock_claude_project_index(index_path: &Path) -> Result { + let parent = index_path.parent().ok_or_else(|| { + format!( + "Claude project index has no parent directory: {}", + index_path.display() + ) + })?; + + let lock_path = parent.join(".orgii-sessions-index.lock"); + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| { + format!( + "open Claude project index lock {}: {error}", + lock_path.display() + ) + })?; + lock_file + .lock() + .map_err(|error| format!("lock Claude project index {}: {error}", lock_path.display()))?; + Ok(ClaudeProjectIndexGuard { lock_file }) +} + +#[derive(Debug, Clone)] +struct NativeTranscriptPaths { + /// Durable transcript discovered by the provider's real native App. + native_path: PathBuf, + /// Account-profile alias used by ORG2's isolated provider runner. + runner_path: PathBuf, +} + +/// Filesystem/native-binding mutations need both short lifecycle exclusion and +/// provider-identity exclusion. Never wait for identity while a runner is +/// alive: its finalizer already owns identity and briefly takes control for +/// terminal persistence, so doing so would invert the lock order. +struct NativeMutationGuards { + _control: tokio::sync::OwnedMutexGuard<()>, + _identity: tokio::sync::OwnedMutexGuard<()>, +} + +async fn lock_idle_native_mutation(session_id: &str) -> Result { + let control = super::session_runner::session_control_lock(session_id) + .await + .lock_owned() + .await; + let has_live_runner = { + let sessions = super::session_runner::RUNNING_SESSIONS.lock().await; + sessions + .get(session_id) + .is_some_and(|handle| !handle.is_finished()) + }; + if has_live_runner { + return Err(format!( + "Session {session_id} still has a running provider turn" + )); + } + let identity = super::session_runner::session_identity_lock(session_id) + .await + .lock_owned() + .await; + Ok(NativeMutationGuards { + _control: control, + _identity: identity, + }) +} + +/// Run an Agent transcript mutation through the same FIFO owner as ordinary +/// Agent turns. This is deliberately separate from the CLI lock path: an +/// Agent session has no CLI runner entry, so taking CLI locks provides no +/// exclusion from its live `DialogScheduler` turn. +async fn run_agent_native_maintenance( + state: &AgentAppState, + session_id: String, + operation: F, +) -> Result +where + F: FnOnce() -> Result + Send + 'static, +{ + let session = + agent_core::state::commands::prepare_session_for_scheduler_maintenance(state, &session_id) + .await?; + enqueue_agent_native_maintenance(session, session_id, operation).await +} + +async fn enqueue_agent_native_maintenance( + session: Arc, + session_id: String, + operation: F, +) -> Result +where + F: FnOnce() -> Result + Send + 'static, +{ + let (result_tx, result_rx) = oneshot::channel(); + let maintenance_id = format!("native-materialization-{}", Uuid::new_v4()); + session + .scheduler + .enqueue(ScheduledMessage { + kind: ScheduledKind::Maintenance, + message_id: maintenance_id, + generation: 0, + client_message_id: None, + turn_intent_id: String::new(), + org_run_id: None, + content: "[native transcript materialization]".to_string(), + execute: Box::new(move || { + Box::pin(async move { + let result = tokio::task::spawn_blocking(operation) + .await + .map_err(|error| format!("native materialization task failed: {error}")) + .and_then(|result| result); + let _ = result_tx.send(result); + // Maintenance failures travel through the command reply; + // returning Ok prevents the scheduler from manufacturing + // a user-visible Agent error for a non-turn operation. + Ok(String::new()) + }) + }), + }) + .await?; + result_rx.await.map_err(|_| { + format!("native materialization scheduler stopped before completing {session_id}") + })? +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeMaterializationReceipt { + native_session_id: String, + item_count: usize, +} + +fn authoritative_native_items(session_id: &str) -> Result, String> { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} has no native resume binding"))?; + let chunks = load_materialized_cli_transcript(&session, &native_id)? + .ok_or_else(|| format!("provider-native transcript {native_id} was not found"))?; + Ok(native_items_from_chunks(&chunks)) + } else { + let history = agent_core::session::persistence::load_llm_history(session_id) + .map_err(|error| format!("load native Agent transcript {session_id}: {error}"))?; + Ok(native_items_from_agent_history(&history)) + } +} + +fn authoritative_append_suffix( + session_id: &str, + complete: &[NativeConversationItem], +) -> Result, String> { + let authoritative = authoritative_native_items(session_id)?; + provider_portable_append_suffix(&authoritative, complete).map_err(|reason| { + format!( + "provider-native transcript is not a semantic prefix of the canonical conversation: native={} canonical={} ({reason})", + authoritative.len(), + complete.len() + ) + }) +} + +fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + write_file_atomically(path, "jsonl.tmp", "native transcript", |file| { + for record in records { + serde_json::to_writer(&mut *file, record) + .map_err(|error| format!("serialize native transcript: {error}"))?; + std::io::Write::write_all(file, b"\n") + .map_err(|error| format!("serialize native transcript: {error}"))?; + } + Ok(()) + }) +} + +fn serialize_jsonl(records: &[Value]) -> Result, String> { + let mut payload = Vec::new(); + for record in records { + serde_json::to_writer(&mut payload, record) + .map_err(|err| format!("serialize native transcript suffix: {err}"))?; + payload.push(b'\n'); + } + Ok(payload) +} + +fn remove_file_if_present(path: &Path) -> Result { + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "remove native transcript {}: {error}", + path.display() + )), + } +} + +fn atomic_json(path: &Path, value: &Value) -> Result<(), String> { + write_file_atomically(path, "json.tmp", "native metadata", |file| { + serde_json::to_writer_pretty(&mut *file, value) + .map_err(|error| format!("serialize native metadata: {error}"))?; + std::io::Write::write_all(file, b"\n") + .map_err(|error| format!("serialize native metadata: {error}")) + }) +} + +fn replace_runner_link(native_path: &Path, runner_path: &Path) -> Result<(), String> { + replace_file_link_atomically(native_path, runner_path, "native runner transcript link") +} + +fn validate_provider_jsonl(path: &Path, expected_native_id: &str) -> Result<(), String> { + let file = fs::File::open(path) + .map_err(|error| format!("open provider transcript {}: {error}", path.display()))?; + let mut records = 0usize; + let mut identity_seen = false; + for (index, line) in BufReader::new(file).lines().enumerate() { + if index >= MAX_ITEMS { + return Err(format!( + "provider transcript {} exceeds {MAX_ITEMS} records", + path.display() + )); + } + let line = + line.map_err(|error| format!("read provider transcript {}: {error}", path.display()))?; + if line.trim().is_empty() { + continue; + } + let record: Value = serde_json::from_str(&line).map_err(|error| { + format!( + "provider transcript {} has invalid JSON at line {}: {error}", + path.display(), + index + 1 + ) + })?; + records += 1; + identity_seen |= record["sessionId"].as_str() == Some(expected_native_id) + || record["session_id"].as_str() == Some(expected_native_id) + || record["payload"]["session_id"].as_str() == Some(expected_native_id) + || record["payload"]["id"].as_str() == Some(expected_native_id); + } + if records == 0 { + return Err(format!("provider transcript {} is empty", path.display())); + } + if !identity_seen { + return Err(format!( + "provider transcript {} does not contain expected native id {expected_native_id}", + path.display() + )); + } + Ok(()) +} + +fn file_is_byte_prefix(prefix: &Path, complete: &Path) -> Result { + let mut prefix_file = fs::File::open(prefix) + .map_err(|error| format!("open transcript {}: {error}", prefix.display()))?; + let mut complete_file = fs::File::open(complete) + .map_err(|error| format!("open transcript {}: {error}", complete.display()))?; + let mut left = [0u8; 64 * 1024]; + let mut right = [0u8; 64 * 1024]; + loop { + let left_len = prefix_file + .read(&mut left) + .map_err(|error| format!("read transcript {}: {error}", prefix.display()))?; + if left_len == 0 { + return Ok(true); + } + let mut right_len = 0usize; + while right_len < left_len { + let read = complete_file + .read(&mut right[right_len..left_len]) + .map_err(|error| format!("read transcript {}: {error}", complete.display()))?; + if read == 0 { + return Ok(false); + } + right_len += read; + } + if left[..left_len] != right[..left_len] { + return Ok(false); + } + } +} + +fn copy_transcript_atomically(source: &Path, destination: &Path) -> Result<(), String> { + copy_file_atomically(source, destination, "native transcript") +} + +/// Converge legacy profile-only and dual-root layouts on one durable native +/// App transcript plus one account-profile runner alias. Returns true only +/// when runner bytes were promoted into the native App store. +fn ensure_durable_runner_alias( + paths: &NativeTranscriptPaths, + expected_native_id: &str, +) -> Result { + if paths.native_path == paths.runner_path { + validate_provider_jsonl(&paths.native_path, expected_native_id)?; + return Ok(false); + } + + let native_exists = paths.native_path.is_file(); + let runner_exists = paths.runner_path.is_file(); + if !native_exists && !runner_exists { + return Err(format!( + "provider-native transcript {expected_native_id} was not found" + )); + } + if !native_exists { + validate_provider_jsonl(&paths.runner_path, expected_native_id)?; + copy_transcript_atomically(&paths.runner_path, &paths.native_path)?; + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(true); + } + if !runner_exists { + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(false); + } + if paths_match(&paths.native_path, &paths.runner_path) { + return Ok(false); + } + + validate_provider_jsonl(&paths.runner_path, expected_native_id)?; + if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { + copy_transcript_atomically(&paths.runner_path, &paths.native_path)?; + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(true); + } + if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(false); + } + Err(format!( + "provider-native transcript conflict for {expected_native_id}: native App and isolated runner both advanced" + )) +} + +fn write_native_store_jsonl( + paths: &NativeTranscriptPaths, + records: &[Value], +) -> Result<(), String> { + atomic_jsonl(&paths.native_path, records)?; + replace_runner_link(&paths.native_path, &paths.runner_path) +} + +fn stable_uuid(namespace: &str, native_id: &str, item_id: &str) -> String { + let mut digest = Sha256::new(); + digest.update(namespace.as_bytes()); + digest.update([0]); + digest.update(native_id.as_bytes()); + digest.update([0]); + digest.update(item_id.as_bytes()); + let hash = digest.finalize(); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&hash[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes).to_string() +} + +fn image_block(data_url: &str) -> Result { + let Some((header, data)) = data_url.split_once(',') else { + return Err("historical image data URL is malformed".to_string()); + }; + let media_type = header + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")) + .filter(|value| value.starts_with("image/")) + .ok_or_else(|| "historical image must be a base64 image data URL".to_string())?; + Ok(json!({ + "type": "image", + "source": {"type": "base64", "media_type": media_type, "data": data} + })) +} + +fn native_agent_seeds( + target_session_id: &str, + items: &[NativeConversationItem], +) -> Vec { + items + .iter() + .map(|item| match item { + NativeConversationItem::Message { + id, + role, + text, + images, + created_at, + turn_id, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, turn_id.as_deref()), + created_at: created_at.clone(), + content: MaterializedHistoryContent::Message { + role: if role == "user" { + MaterializedHistoryRole::User + } else { + MaterializedHistoryRole::Assistant + }, + text: text.clone(), + images: images.clone(), + }, + }, + NativeConversationItem::ToolCall { + id, + call_id, + name, + arguments, + created_at, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::ToolCall { + call_id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + }, + }, + NativeConversationItem::ToolResult { + id, + call_id, + name, + output, + created_at, + .. + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::ToolResult { + call_id: call_id.clone(), + name: name.clone(), + output: output.clone(), + }, + }, + NativeConversationItem::ContextSummary { + id, + summary, + created_at, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::Message { + role: MaterializedHistoryRole::User, + text: summary.clone(), + images: Vec::new(), + }, + }, + }) + .collect() +} + +fn native_agent_row_id(target_session_id: &str, source_id: &str, turn_id: Option<&str>) -> String { + let source = URL_SAFE_NO_PAD.encode(source_id.as_bytes()); + // The target is part of the stable suffix because agent_messages.id is a + // database-wide primary key: importing the same canonical source into two + // different execution Sessions must not collide, while retrying the same + // target append must resolve to the exact same durable rows. + let target_tag = stable_uuid("orgii-agent-native-row", target_session_id, source_id); + match turn_id.filter(|value| !value.is_empty()) { + Some(turn_id) => format!( + "org2-turn-v1.{}.{}.{}", + URL_SAFE_NO_PAD.encode(turn_id.as_bytes()), + source, + target_tag + ), + None => format!("org2-native-v1.{source}.{target_tag}"), + } +} + +fn sanitize_claude_project_name(path: &Path) -> String { + path.to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect() +} + +fn claude_native_paths( + account_id: Option<&str>, + cwd: &Path, + native_id: &str, +) -> NativeTranscriptPaths { + let relative = PathBuf::from("projects") + .join(sanitize_claude_project_name(cwd)) + .join(format!("{native_id}.jsonl")); + let native_path = app_paths::native_transcript_home_dir() + .join(".claude") + .join(&relative); + NativeTranscriptPaths { + runner_path: account_id + .map(|account_id| app_paths::claude_code_cli_profile_dir(account_id).join(relative)) + .unwrap_or_else(|| native_path.clone()), + native_path, + } +} + +/// Resolve an already-bound Claude transcript without assuming the current +/// account-profile layout is the only layout that has ever been published. +/// +/// The returned pair is canonical even when only the profile-only path created +/// by an intermediate release exists. Mutation code can then promote that file +/// without teaching every caller a second storage layout. +fn existing_claude_native_paths( + account_id: Option<&str>, + cwd: &Path, + native_id: &str, +) -> Option { + let paths = claude_native_paths(account_id, cwd, native_id); + (paths.native_path.is_file() || paths.runner_path.is_file()).then_some(paths) +} + +fn codex_profile_sessions_root(account_id: &str) -> PathBuf { + app_paths::codex_cli_profile_dir(account_id).join("sessions") +} + +fn codex_native_app_home() -> PathBuf { + app_paths::native_transcript_home_dir().join(".codex") +} + +fn codex_native_app_sessions_root() -> PathBuf { + codex_native_app_home().join("sessions") +} + +fn codex_native_paths_for_relative(account_id: &str, relative: &Path) -> NativeTranscriptPaths { + NativeTranscriptPaths { + native_path: codex_native_app_sessions_root().join(relative), + runner_path: codex_profile_sessions_root(account_id).join(relative), + } +} + +fn cache_codex_native_paths(account_id: &str, native_id: &str, paths: &NativeTranscriptPaths) { + let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() else { + return; + }; + let key = (account_id.to_string(), native_id.to_string()); + if cache.len() >= CODEX_NATIVE_PATH_CACHE_MAX_ENTRIES && !cache.contains_key(&key) { + if let Some(evicted) = cache.keys().next().cloned() { + cache.remove(&evicted); + } + } + cache.insert(key, paths.clone()); +} + +fn existing_codex_native_paths( + account_id: &str, + native_id: &str, +) -> Result, String> { + let cache_key = (account_id.to_string(), native_id.to_string()); + if let Some(paths) = CODEX_NATIVE_PATH_CACHE + .lock() + .ok() + .and_then(|cache| cache.get(&cache_key).cloned()) + { + if paths.native_path.is_file() || paths.runner_path.is_file() { + return Ok(Some(paths)); + } + if let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() { + cache.remove(&cache_key); + } + } + + let profile_root = codex_profile_sessions_root(account_id); + let native_app_root = codex_native_app_sessions_root(); + let found = find_codex_materialization(&native_app_root, native_id)? + .map(|path| (path, native_app_root)); + let (found, root) = match found { + Some(found) => found, + None => match find_codex_materialization(&profile_root, native_id)? { + Some(path) => (path, profile_root), + None => return Ok(None), + }, + }; + let relative = found.strip_prefix(&root).map_err(|error| { + format!( + "resolved Codex rollout {} outside scanned root {}: {error}", + found.display(), + root.display() + ) + })?; + let paths = codex_native_paths_for_relative(account_id, relative); + cache_codex_native_paths(account_id, native_id, &paths); + Ok(Some(paths)) +} + +fn registered_codex_native_paths( + account_id: &str, + native_path: &Path, +) -> Result { + let root = codex_native_app_sessions_root(); + let relative = match native_path.strip_prefix(&root) { + Ok(relative) => relative.to_path_buf(), + Err(_) => { + let canonical_root = fs::canonicalize(&root).map_err(|error| { + format!( + "canonicalize Codex sessions root {}: {error}", + root.display() + ) + })?; + let canonical_path = fs::canonicalize(native_path).map_err(|error| { + format!( + "canonicalize Codex rollout {}: {error}", + native_path.display() + ) + })?; + canonical_path + .strip_prefix(&canonical_root) + .map(Path::to_path_buf) + .map_err(|error| { + format!( + "Codex app-server registered rollout outside the native App store: path={} root={} ({error})", + native_path.display(), + root.display() + ) + })? + } + }; + Ok(codex_native_paths_for_relative(account_id, &relative)) +} + +/// Resolve one freshly bound provider transcript directly by its exact UUID. +/// +/// The imported-history cache is eventually refreshed and remains the normal +/// reader. Materialization, however, must prove its write synchronously before +/// the provider process starts. Requiring a global history scan here makes a +/// single continuation depend on every unrelated native transcript on disk. +fn materialized_cli_transcript_paths( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let cwd = execution_cwd(session)?; + let paths = match agent { + "claude_code" => { + let Some(paths) = existing_claude_native_paths(account_id, &cwd, native_id) else { + return Ok(None); + }; + paths + } + "codex" => { + let account_id = account_id.ok_or_else(|| { + "native Codex transcript read requires an explicit local account".to_string() + })?; + let Some(paths) = existing_codex_native_paths(account_id, native_id)? else { + return Ok(None); + }; + paths + } + _ => return Ok(None), + }; + Ok(Some((agent.to_string(), paths))) +} + +fn materialized_cli_transcript_path( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((agent, paths)) = materialized_cli_transcript_paths(session, native_id)? else { + return Ok(None); + }; + let Some(path) = preferred_materialized_transcript_path(&paths)? else { + return Ok(None); + }; + Ok(Some((agent, path.to_path_buf()))) +} + +/// Resolve only the transcript path the vendor's native App owns. +/// +/// The isolated runner alias is valid for ORG2 resume/replay, but cannot make +/// an official App deep link open successfully. App-open availability must +/// therefore require this exact native-store copy. +pub(crate) fn native_app_transcript_path( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((_agent, paths)) = materialized_cli_transcript_paths(session, native_id)? else { + return Ok(None); + }; + Ok(paths.native_path.is_file().then_some(paths.native_path)) +} + +pub(super) fn load_materialized_cli_transcript( + session: &persistence::CodeSession, + native_id: &str, +) -> Result>, String> { + let Some((agent, path)) = materialized_cli_transcript_path(session, native_id)? else { + return Ok(None); + }; + let chunks = match agent.as_str() { + "claude_code" => { + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session.session_id, + &path, + )? + } + "codex" => orgtrack_core::sources::codex::app::load_codex_app_from_path( + &session.session_id, + &path, + )?, + _ => unreachable!("unsupported targets returned above"), + }; + Ok(Some(chunks)) +} + +/// Current revision of the exact provider transcript selected by the same +/// resolver as [`load_materialized_cli_transcript`]. +pub(super) fn materialized_cli_transcript_revision( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((_agent, path)) = materialized_cli_transcript_path(session, native_id)? else { + return Ok(None); + }; + native_transcript_revision(&path).map(Some) +} + +/// Resolve the authoritative copy without guessing from timestamps. Two +/// independent regular files are safe only when one is the exact byte-prefix +/// of the other; otherwise both sides advanced and the caller must fail closed. +fn preferred_materialized_transcript_path( + paths: &NativeTranscriptPaths, +) -> Result, String> { + let native_metadata = fs::metadata(&paths.native_path).ok(); + let runner_metadata = fs::metadata(&paths.runner_path).ok(); + match (native_metadata, runner_metadata) { + (None, None) => Ok(None), + (Some(_), None) => Ok(Some(&paths.native_path)), + (None, Some(_)) => Ok(Some(&paths.runner_path)), + (Some(_), Some(_)) => { + if paths_match(&paths.native_path, &paths.runner_path) { + return Ok(Some(&paths.native_path)); + } + if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { + return Ok(Some(&paths.runner_path)); + } + if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { + return Ok(Some(&paths.native_path)); + } + Err(format!( + "provider-native transcript conflict: native App {} and runner {} both advanced", + paths.native_path.display(), + paths.runner_path.display() + )) + } + } +} + +fn paths_match(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn first_user_title(items: &[NativeConversationItem]) -> String { + let title = items.iter().find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } if role == "user" => Some(text.trim()), + _ => None, + }); + let title = title + .filter(|value| !value.is_empty()) + .unwrap_or("Imported conversation"); + title.chars().take(120).collect() +} + +fn claude_session_title( + session: &persistence::CodeSession, + items: &[NativeConversationItem], +) -> String { + if session.name.trim().is_empty() { + first_user_title(items) + } else { + session.name.trim().chars().take(120).collect() + } +} + +fn validate_claude_project_index(index_path: &Path, index: &Value) -> Result<(), String> { + let object = index.as_object().ok_or_else(|| { + format!( + "Claude project index is not an object: {}", + index_path.display() + ) + })?; + let version = object + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| { + format!( + "Claude project index has no numeric schema version: {}", + index_path.display() + ) + })?; + if version != CLAUDE_PROJECT_INDEX_VERSION { + return Err(format!( + "unsupported Claude project index schema version {version} in {}; expected {CLAUDE_PROJECT_INDEX_VERSION}", + index_path.display() + )); + } + if !object.get("entries").is_some_and(Value::is_array) { + return Err(format!( + "Claude project index entries are not an array: {}", + index_path.display() + )); + } + Ok(()) +} + +fn read_claude_project_index(index_path: &Path) -> Result, String> { + match fs::read_to_string(index_path) { + Ok(raw) => { + let index = serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode existing Claude project index {}: {error}", + index_path.display() + ) + })?; + validate_claude_project_index(index_path, &index)?; + Ok(Some(index)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "read Claude project index {}: {error}", + index_path.display() + )), + } +} + +fn transcript_modified_metadata(path: &Path) -> Result<(i64, String), String> { + let modified = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .map_err(|error| format!("read transcript metadata {}: {error}", path.display()))?; + let file_mtime = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| format!("invalid transcript mtime {}: {error}", path.display()))? + .as_millis() + .try_into() + .map_err(|_| format!("transcript mtime overflows i64: {}", path.display()))?; + let modified = chrono::DateTime::::from(modified) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + Ok((file_mtime, modified)) +} + +/// Maintain Claude Code's native project catalog next to the durable JSONL. +/// The transcript remains the source of truth; this is only the provider-owned +/// discovery projection required by the native App. +fn publish_claude_project_index( + cwd: &Path, + native_id: &str, + items: &[NativeConversationItem], + git_branch: Option<&str>, +) -> Result<(), String> { + let transcript_path = claude_native_paths(None, cwd, native_id).native_path; + let project_dir = transcript_path.parent().ok_or_else(|| { + format!( + "Claude native transcript has no project directory: {}", + transcript_path.display() + ) + })?; + let index_path = project_dir.join("sessions-index.json"); + fs::create_dir_all(project_dir).map_err(|error| { + format!( + "create Claude project index directory {}: {error}", + project_dir.display() + ) + })?; + let _guard = lock_claude_project_index(&index_path)?; + let mut index = read_claude_project_index(&index_path)? + .unwrap_or_else(|| json!({"version": CLAUDE_PROJECT_INDEX_VERSION, "entries": []})); + let entries = index + .get_mut("entries") + .and_then(Value::as_array_mut) + .expect("validated/new Claude project index has an entries array"); + let previous = entries + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + + let now = Utc::now(); + let now_iso = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let created = previous + .as_ref() + .and_then(|entry| entry["created"].as_str()) + .unwrap_or(&now_iso) + .to_string(); + let first_prompt = items + .iter() + .find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } if role == "user" => { + Some(text.trim()) + } + _ => None, + }) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + previous + .as_ref() + .and_then(|entry| entry["firstPrompt"].as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "Imported conversation".to_string()); + let projected_message_count = items + .iter() + .filter(|item| matches!(item, NativeConversationItem::Message { .. })) + .count(); + let previous_message_count = previous + .as_ref() + .and_then(|entry| entry["messageCount"].as_u64()) + .unwrap_or_default() as usize; + let mut entry = previous.unwrap_or_else(|| json!({})); + let entry = entry.as_object_mut().ok_or_else(|| { + format!( + "Claude project index entry {native_id} is not an object: {}", + index_path.display() + ) + })?; + entry.insert("sessionId".to_string(), json!(native_id)); + entry.insert("fullPath".to_string(), json!(transcript_path)); + entry.insert("fileMtime".to_string(), json!(now.timestamp_millis())); + entry.insert("firstPrompt".to_string(), json!(first_prompt)); + entry.insert( + "messageCount".to_string(), + json!(projected_message_count.max(previous_message_count)), + ); + entry.insert("created".to_string(), json!(created)); + entry.insert("modified".to_string(), json!(now_iso)); + entry.insert( + "gitBranch".to_string(), + json!(git_branch.unwrap_or_default()), + ); + entry.insert("workspacePath".to_string(), json!(cwd)); + entries.push(Value::Object(entry.clone())); + atomic_json(&index_path, &index) +} + +fn remove_claude_project_index_entry(cwd: &Path, native_id: &str) -> Result<(), String> { + let index_path = claude_native_paths(None, cwd, native_id) + .native_path + .parent() + .map(|project| project.join("sessions-index.json")) + .ok_or_else(|| "Claude native transcript has no project directory".to_string())?; + if !index_path.parent().is_some_and(Path::is_dir) { + return Ok(()); + } + let _guard = lock_claude_project_index(&index_path)?; + let Some(mut index) = read_claude_project_index(&index_path)? else { + return Ok(()); + }; + let entries = index["entries"] + .as_array_mut() + .expect("validated Claude project index has an entries array"); + let previous_len = entries.len(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + if entries.len() != previous_len { + atomic_json(&index_path, &index)?; + } + Ok(()) +} + +/// Claude Desktop keeps a small discovery row for every Claude Code session +/// it exposes in the Code tab. The row points at the real CLI UUID; it is not +/// a transcript copy. Keep this projection beside the provider-owned JSONL +/// catalog so Desktop can discover materialized sessions without fabricating +/// another conversation history. +fn claude_desktop_sessions_root() -> PathBuf { + let home = app_paths::native_transcript_home_dir(); + #[cfg(target_os = "windows")] + let data_dir = home.join("AppData").join("Roaming"); + #[cfg(target_os = "macos")] + let data_dir = home.join("Library").join("Application Support"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let data_dir = home.join(".config"); + data_dir.join("Claude").join("claude-code-sessions") +} + +fn claude_desktop_active_account_id(sessions_root: &Path) -> Option { + let config_path = sessions_root.parent()?.join("config.json"); + let config = serde_json::from_slice::(&fs::read(config_path).ok()?).ok()?; + let account_id = config["lastKnownAccountUuid"].as_str()?; + Uuid::parse_str(account_id).ok()?; + Some(account_id.to_string()) +} + +/// Read at most `budget` directory entries. The budget counts failed and +/// non-directory entries too, so a noisy provider-owned catalog cannot turn +/// discovery into an unbounded scan. Sort the accepted prefix so its later +/// processing is deterministic. +fn bounded_directory_paths(root: &Path, budget: &mut usize) -> Vec { + if *budget == 0 { + return Vec::new(); + } + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + let mut paths = Vec::new(); + for entry in entries { + if *budget == 0 { + break; + } + *budget -= 1; + if let Ok(entry) = entry { + paths.push(entry.path()); + } + } + paths.sort(); + paths +} + +fn claude_desktop_session_path( + sessions_root: &Path, + cwd: &Path, + native_id: &str, +) -> Option { + let mut account_budget = CLAUDE_DESKTOP_ACCOUNT_SCAN_LIMIT; + let account_dirs = bounded_directory_paths(sessions_root, &mut account_budget) + .into_iter() + .filter(|path| path.is_dir()) + .collect::>(); + let expected_filename = format!("local_{native_id}.json"); + + // An existing UUID remains owned by the account that first registered it, + // even after the user switches Claude Desktop accounts. Search every + // account before deciding where a new discovery row should be placed. + let mut project_budget = CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT; + let mut metadata_budget = CLAUDE_DESKTOP_METADATA_SCAN_LIMIT; + 'account_scan: for account_dir in &account_dirs { + for project_dir in bounded_directory_paths(account_dir, &mut project_budget) + .into_iter() + .filter(|path| path.is_dir()) + { + let exact_path = project_dir.join(&expected_filename); + if exact_path.is_file() { + let is_same_session = fs::read(&exact_path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + .and_then(|value| value["cliSessionId"].as_str().map(str::to_string)) + .as_deref() + == Some(native_id); + if is_same_session { + return Some(exact_path); + } + } + for path in bounded_directory_paths(&project_dir, &mut metadata_budget) { + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(value) = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + else { + continue; + }; + if value["cliSessionId"].as_str() == Some(native_id) { + return Some(path); + } + } + if metadata_budget == 0 { + break 'account_scan; + } + } + if project_budget == 0 { + break; + } + } + + // A brand-new row must be registered under the currently active Desktop + // account. Never silently place it into an arbitrary inactive account. + let active_account_dir = sessions_root.join(claude_desktop_active_account_id(sessions_root)?); + if !active_account_dir.is_dir() { + return None; + } + let mut matching_project: Option<(i64, PathBuf)> = None; + let mut project_budget = CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT; + let mut metadata_budget = CLAUDE_DESKTOP_METADATA_SCAN_LIMIT; + for project_dir in bounded_directory_paths(&active_account_dir, &mut project_budget) + .into_iter() + .filter(|path| path.is_dir()) + { + for path in bounded_directory_paths(&project_dir, &mut metadata_budget) { + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(value) = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + else { + continue; + }; + let matches_cwd = ["cwd", "originCwd"].into_iter().any(|field| { + value[field] + .as_str() + .is_some_and(|value| paths_match(Path::new(value), cwd)) + }); + if !matches_cwd { + continue; + } + let activity = value["lastActivityAt"] + .as_i64() + .or_else(|| value["createdAt"].as_i64()) + .unwrap_or_default(); + if matching_project + .as_ref() + .is_none_or(|(best_activity, _)| activity > *best_activity) + { + matching_project = Some((activity, project_dir.clone())); + } + } + if metadata_budget == 0 { + break; + } + } + matching_project.map(|(_, path)| path.join(expected_filename)) +} + +fn assistant_turn_count(items: &[NativeConversationItem]) -> usize { + items + .iter() + .filter(|item| { + matches!(item, NativeConversationItem::Message { role, .. } if role == "assistant") + }) + .count() +} + +#[cfg(test)] +fn publish_claude_desktop_session( + session: &persistence::CodeSession, + cwd: &Path, + native_id: &str, + native_path: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + if !native_path.is_file() { + return Err(format!( + "refusing to publish Claude Desktop metadata without transcript {}", + native_path.display() + )); + } + let sessions_root = claude_desktop_sessions_root(); + if !sessions_root.is_dir() { + return Ok(None); + } + let Some(path) = claude_desktop_session_path(&sessions_root, cwd, native_id) else { + // Account/project UUIDs belong to Desktop. A machine with no existing + // matching project must use Claude's own import flow instead of ORG2 + // inventing identifiers that the App has never registered. + return Ok(None); + }; + publish_claude_desktop_session_at_path(session, cwd, native_id, native_path, items, path) +} + +fn publish_claude_desktop_session_at_path( + session: &persistence::CodeSession, + cwd: &Path, + native_id: &str, + native_path: &Path, + items: &[NativeConversationItem], + path: PathBuf, +) -> Result, String> { + let _guard = lock_claude_project_index(&path)?; + let previous = match fs::read(&path) { + Ok(raw) => Some(serde_json::from_slice::(&raw).map_err(|error| { + format!( + "decode existing Claude Desktop metadata {}: {error}", + path.display() + ) + })?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "read Claude Desktop metadata {}: {error}", + path.display() + )) + } + }; + let mut metadata = previous.clone().unwrap_or_else(|| json!({})); + let object = metadata.as_object_mut().ok_or_else(|| { + format!( + "Claude Desktop metadata is not an object: {}", + path.display() + ) + })?; + let (file_mtime, _) = transcript_modified_metadata(native_path)?; + let title = claude_session_title(session, items); + let is_new = previous.is_none(); + object + .entry("sessionId".to_string()) + .or_insert_with(|| json!(format!("local_{native_id}"))); + object.insert("cliSessionId".to_string(), json!(native_id)); + object.insert("cwd".to_string(), json!(cwd)); + object + .entry("originCwd".to_string()) + .or_insert_with(|| json!(cwd)); + object + .entry("createdAt".to_string()) + .or_insert_with(|| json!(file_mtime)); + object + .entry("lastFocusedAt".to_string()) + .or_insert_with(|| json!(file_mtime)); + object.insert("lastActivityAt".to_string(), json!(file_mtime)); + object.insert("title".to_string(), json!(title)); + object + .entry("titleSource".to_string()) + .or_insert_with(|| json!("orgii")); + object + .entry("permissionMode".to_string()) + .or_insert_with(|| json!("default")); + object + .entry("isArchived".to_string()) + .or_insert_with(|| json!(false)); + object + .entry("remoteMcpServersConfig".to_string()) + .or_insert_with(|| json!([])); + object.insert( + "completedTurns".to_string(), + json!(assistant_turn_count(items)), + ); + object + .entry("alwaysAllowedReasons".to_string()) + .or_insert_with(|| json!([])); + object + .entry("sessionPermissionUpdates".to_string()) + .or_insert_with(|| json!([])); + object + .entry("classifierSummaryEnabled".to_string()) + .or_insert_with(|| json!(true)); + if let Some(model) = session + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + object.insert("model".to_string(), json!(model)); + } + if is_new { + object.insert("orgiiMaterialization".to_string(), json!(true)); + } + atomic_json(&path, &metadata)?; + let published: Value = serde_json::from_slice( + &fs::read(&path).map_err(|error| format!("read back Claude Desktop metadata: {error}"))?, + ) + .map_err(|error| format!("decode published Claude Desktop metadata: {error}"))?; + if published["cliSessionId"].as_str() != Some(native_id) + || published["sessionId"].as_str().is_none() + || !published["completedTurns"].is_number() + || !["cwd", "originCwd"].into_iter().any(|field| { + published[field] + .as_str() + .is_some_and(|value| paths_match(Path::new(value), cwd)) + }) + { + return Err(format!( + "Claude Desktop metadata read-back rejected {}", + path.display() + )); + } + Ok(Some(path)) +} + +fn remove_orgii_claude_desktop_session(cwd: &Path, native_id: &str) -> Result<(), String> { + let sessions_root = claude_desktop_sessions_root(); + let Some(path) = claude_desktop_session_path(&sessions_root, cwd, native_id) else { + return Ok(()); + }; + let _guard = lock_claude_project_index(&path)?; + let metadata = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()); + if metadata.as_ref().is_some_and(|value| { + value["cliSessionId"].as_str() == Some(native_id) + && value["orgiiMaterialization"].as_bool() == Some(true) + }) { + remove_file_if_present(&path)?; + } + Ok(()) +} + +/// Refresh the native Claude App catalog from metadata written by the actual +/// Claude process in its isolated account profile. This path reads two small +/// index files and transcript stat metadata only; it never reparses a large +/// JSONL. Unknown provider fields are retained so ORG2 does not downgrade a +/// newer-but-still-v1 entry shape. +fn refresh_claude_project_index_from_provider( + cwd: &Path, + native_id: &str, + native_path: &Path, + runner_path: &Path, + git_branch: Option<&str>, +) -> Result { + let native_index_path = native_path + .parent() + .ok_or_else(|| { + format!( + "Claude native transcript has no project directory: {}", + native_path.display() + ) + })? + .join("sessions-index.json"); + let runner_index_path = runner_path + .parent() + .ok_or_else(|| { + format!( + "Claude runner transcript has no project directory: {}", + runner_path.display() + ) + })? + .join("sessions-index.json"); + + let Some(provider_index) = read_claude_project_index(&runner_index_path)? else { + return Ok(false); + }; + let Some(provider_entry) = provider_index["entries"] + .as_array() + .expect("validated Claude provider index has an entries array") + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned() + else { + return Ok(false); + }; + let provider_entry = provider_entry.as_object().ok_or_else(|| { + format!( + "Claude provider index entry {native_id} is not an object: {}", + runner_index_path.display() + ) + })?; + let (file_mtime, modified) = transcript_modified_metadata(native_path)?; + let provider_mtime = provider_entry + .get("fileMtime") + .and_then(Value::as_i64) + .ok_or_else(|| { + format!( + "Claude provider index entry {native_id} has no numeric fileMtime: {}", + runner_index_path.display() + ) + })?; + if provider_mtime < file_mtime { + // The provider index snapshot predates the durable transcript. Its + // messageCount may therefore be stale; let the deferred fallback parse + // derive a correct projection instead of publishing a false count. + return Ok(false); + } + if !provider_entry + .get("messageCount") + .is_some_and(Value::is_u64) + { + return Err(format!( + "Claude provider index entry {native_id} has no numeric messageCount: {}", + runner_index_path.display() + )); + } + + fs::create_dir_all( + native_index_path + .parent() + .expect("Claude native project index has a parent"), + ) + .map_err(|error| { + format!( + "create Claude native project index directory {}: {error}", + native_index_path.display() + ) + })?; + let _guard = lock_claude_project_index(&native_index_path)?; + let mut native_index = read_claude_project_index(&native_index_path)? + .unwrap_or_else(|| json!({"version": CLAUDE_PROJECT_INDEX_VERSION, "entries": []})); + let entries = native_index["entries"] + .as_array_mut() + .expect("validated/new Claude native index has an entries array"); + let previous = entries + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + + let mut merged = previous + .and_then(|entry| entry.as_object().cloned()) + .unwrap_or_default(); + merged.extend(provider_entry.clone()); + merged.insert("sessionId".to_string(), json!(native_id)); + merged.insert("fullPath".to_string(), json!(native_path)); + merged.insert("fileMtime".to_string(), json!(file_mtime)); + merged.insert("modified".to_string(), json!(modified)); + merged.insert("workspacePath".to_string(), json!(cwd)); + if merged + .get("gitBranch") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + merged.insert( + "gitBranch".to_string(), + json!(git_branch.unwrap_or_default()), + ); + } + entries.push(Value::Object(merged)); + atomic_json(&native_index_path, &native_index)?; + Ok(true) +} + +fn claude_records( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + let mut records = Vec::with_capacity(items.len().saturating_mul(2)); + let mut parent_uuid: Option = None; + for item in items { + if let NativeConversationItem::ContextSummary { + id, + summary, + created_at, + } = item + { + let boundary_uuid = stable_uuid("orgii-claude-compact-boundary", native_id, id); + records.push(json!({ + "type": "system", + "subtype": "compact_boundary", + "uuid": boundary_uuid, + "parentUuid": parent_uuid, + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "compactMetadata": {"trigger": "import"} + })); + let summary_uuid = stable_uuid("orgii-claude-compact-summary", native_id, id); + records.push(json!({ + "type": "user", + "uuid": summary_uuid, + "parentUuid": boundary_uuid, + "isCompactSummary": true, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "message": {"role": "user", "content": summary}, + "entrypoint": "orgii" + })); + parent_uuid = Some(summary_uuid); + continue; + } + let record_uuid = stable_uuid("orgii-claude-native", native_id, item.id()); + let (record_type, message, extra) = match item { + NativeConversationItem::Message { + role, text, images, .. + } => { + let content = if role == "assistant" { + Value::Array(vec![json!({"type": "text", "text": text})]) + } else if images.is_empty() { + Value::String(text.clone()) + } else { + let mut blocks = vec![json!({"type": "text", "text": text})]; + for image in images { + blocks.push(image_block(image)?); + } + Value::Array(blocks) + }; + ( + role.clone(), + json!({"role": role, "content": content}), + None, + ) + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => ( + "assistant".to_string(), + json!({ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": call_id, + "name": name, + "input": serde_json::from_str::(arguments) + .map_err(|err| format!("parse tool arguments: {err}"))? + }] + }), + None, + ), + NativeConversationItem::ToolResult { + call_id, + output, + is_error, + interrupted, + .. + } => ( + "user".to_string(), + json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": call_id, + "content": output, + "is_error": *is_error || *interrupted + }] + }), + Some(json!({"toolUseResult": output})), + ), + NativeConversationItem::ContextSummary { .. } => { + unreachable!("context summaries are emitted before ordinary Claude records") + } + }; + let mut record = json!({ + "type": record_type, + "uuid": record_uuid, + "parentUuid": parent_uuid, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": cwd, + "timestamp": item.created_at(), + "message": message, + "entrypoint": "orgii" + }); + if let Some(Value::Object(extra)) = extra { + record.as_object_mut().expect("record object").extend(extra); + } + parent_uuid = Some(record_uuid); + records.push(record); + } + Ok(records) +} + +fn claude_resume_checkpoint( + native_id: &str, + leaf_uuid: &str, + items: &[NativeConversationItem], +) -> Value { + let last_prompt = items + .iter() + .rev() + .find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } + if role == "user" && !text.trim().is_empty() => + { + Some(text.as_str()) + } + _ => None, + }) + .unwrap_or_default(); + json!({ + "type": "last-prompt", + "lastPrompt": last_prompt, + "leafUuid": leaf_uuid, + "sessionId": native_id, + }) +} + +fn claude_custom_title(native_id: &str, title: &str) -> Value { + json!({ + "type": "custom-title", + "customTitle": title, + "sessionId": native_id, + }) +} + +fn claude_materialization_records( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], + title: &str, +) -> Result, String> { + let mut records = claude_records_with_resume_checkpoint(native_id, cwd, items)?; + records.insert(0, claude_custom_title(native_id, title)); + Ok(records) +} + +fn claude_records_with_resume_checkpoint( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + let mut records = claude_records(native_id, cwd, items)?; + if let Some(leaf_uuid) = records + .last() + .and_then(|record| record["uuid"].as_str()) + .map(str::to_string) + { + records.push(claude_resume_checkpoint(native_id, &leaf_uuid, items)); + } + Ok(records) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeSuffixApplication { + Missing, + AlreadyApplied, +} + +fn inspect_claude_suffix_application( + path: &Path, + expected_records: &[Value], +) -> Result<(NativeSuffixApplication, Option), String> { + let mut expected_records_by_id = HashMap::with_capacity(expected_records.len()); + for record in expected_records { + let id = record["uuid"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "projected Claude native suffix record has no stable uuid".to_string() + })?; + let mut normalized = record.clone(); + normalized + .as_object_mut() + .ok_or_else(|| "projected Claude native suffix record is not an object".to_string())? + .remove("parentUuid"); + if expected_records_by_id + .insert(id.to_string(), normalized) + .is_some() + { + return Err(format!( + "projected Claude native suffix contains duplicate uuid {id}" + )); + } + } + if expected_records_by_id.is_empty() { + return Err("projected Claude native suffix is empty".to_string()); + } + let file = fs::File::open(path) + .map_err(|error| format!("open Claude native transcript {}: {error}", path.display()))?; + let mut found_ids = HashSet::with_capacity(expected_records_by_id.len()); + let mut active_leaf_uuid = None; + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if record["type"] == "last-prompt" { + if let Some(leaf_uuid) = record["leafUuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + active_leaf_uuid = Some(leaf_uuid.to_string()); + } + } else if let Some(uuid) = record["uuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + active_leaf_uuid = Some(uuid.to_string()); + if let Some(expected) = expected_records_by_id.get(uuid) { + let mut normalized = record.clone(); + normalized + .as_object_mut() + .ok_or_else(|| { + format!( + "Claude native transcript {} contains non-object stable suffix record {uuid}", + path.display() + ) + })? + .remove("parentUuid"); + if &normalized != expected { + return Err(format!( + "Claude native transcript {} contains stable suffix uuid {uuid} with conflicting content", + path.display() + )); + } + if !found_ids.insert(uuid.to_string()) { + return Err(format!( + "Claude native transcript {} contains duplicate stable suffix uuid {uuid}", + path.display() + )); + } + } + } + } + + if found_ids.is_empty() { + Ok((NativeSuffixApplication::Missing, active_leaf_uuid)) + } else if found_ids.len() == expected_records_by_id.len() { + Ok((NativeSuffixApplication::AlreadyApplied, active_leaf_uuid)) + } else { + Err(format!( + "Claude native transcript {} contains {} of {} stable suffix records; refusing a mixed retry", + path.display(), + found_ids.len(), + expected_records_by_id.len() + )) + } +} + +fn ensure_claude_native_metadata( + path: &Path, + native_id: &str, + complete_items: &[NativeConversationItem], + title: &str, +) -> Result<(), String> { + let file = fs::File::open(path) + .map_err(|error| format!("open Claude native transcript {}: {error}", path.display()))?; + let mut last_message_uuid: Option = None; + let mut last_message_is_orgii = false; + let mut last_checkpoint_leaf: Option = None; + let mut has_custom_title = false; + let mut has_orgii_record = false; + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + has_custom_title |= record["type"] == "custom-title" + && record["customTitle"] + .as_str() + .is_some_and(|value| !value.trim().is_empty()); + has_orgii_record |= record["entrypoint"].as_str() == Some("orgii"); + if record["type"] == "last-prompt" { + last_checkpoint_leaf = record["leafUuid"].as_str().map(str::to_string); + } else if let Some(uuid) = record["uuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + last_message_uuid = Some(uuid.to_string()); + last_message_is_orgii = record["entrypoint"].as_str() == Some("orgii"); + } + } + let mut metadata = Vec::with_capacity(2); + if has_orgii_record && !has_custom_title && !title.trim().is_empty() { + metadata.push(claude_custom_title(native_id, title)); + } + if let Some(leaf_uuid) = last_message_uuid.filter(|_| last_message_is_orgii) { + if last_checkpoint_leaf.as_deref() != Some(leaf_uuid.as_str()) { + metadata.push(claude_resume_checkpoint( + native_id, + &leaf_uuid, + complete_items, + )); + } + } + if metadata.is_empty() { + return Ok(()); + } + append_suffix_atomically(path, &serialize_jsonl(&metadata)?) +} + +/// Codex exit codes for a tool output ORG2 injects. A `function_call_output` +/// carries text, so the only failure channel the Codex rollout has is the +/// exec envelope its own shell tools emit. Writing the bare output instead +/// tells the resumed model a killed or failed command succeeded. +const CODEX_TOOL_FAILURE_EXIT_CODE: i64 = 1; +const CODEX_TOOL_INTERRUPT_EXIT_CODE: i64 = 130; + +fn codex_function_call_output(output: &str, is_error: bool, interrupted: bool) -> Value { + if !is_error && !interrupted { + return Value::String(output.to_string()); + } + let exit_code = if interrupted { + CODEX_TOOL_INTERRUPT_EXIT_CODE + } else { + CODEX_TOOL_FAILURE_EXIT_CODE + }; + Value::String(json!({"exit_code": exit_code, "output": output}).to_string()) +} + +fn codex_response_items(items: &[NativeConversationItem]) -> Vec { + let mut projected = Vec::with_capacity(items.len()); + for item in items { + match item { + NativeConversationItem::Message { + id, + role, + text, + images, + .. + } => { + let text_type = if role == "user" { + "input_text" + } else { + "output_text" + }; + let mut content = vec![json!({"type": text_type, "text": text})]; + if role == "user" { + content.extend( + images + .iter() + .map(|image| json!({"type": "input_image", "image_url": image})), + ); + } + // `id` is part of Codex's native response-item schema and is + // preserved by `thread/inject_items`. Unlike Codex's + // user-role system/context prefix rows, an injected canonical + // user message therefore has a stable native item id without + // needing ORG2-only metadata inside the provider transcript. + projected + .push(json!({"type": "message", "id": id, "role": role, "content": content})); + } + NativeConversationItem::ToolCall { + id, + call_id, + name, + arguments, + .. + } => projected.push(json!({ + "type": "function_call", + "id": id, + "name": name, + "arguments": arguments, + "call_id": call_id + })), + NativeConversationItem::ToolResult { + call_id, + output, + is_error, + interrupted, + .. + } => projected.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": codex_function_call_output(output, *is_error, *interrupted) + })), + NativeConversationItem::ContextSummary { id, summary, .. } => projected.push(json!({ + "type": "message", + "id": id, + "role": "user", + "content": [{"type": "input_text", "text": summary}] + })), + } + } + projected +} + +fn provider_canonical_cwd(cwd: PathBuf) -> PathBuf { + fs::canonicalize(&cwd).unwrap_or(cwd) +} + +fn execution_cwd(session: &persistence::CodeSession) -> Result { + // Keep this selection identical to the CLI runner. A removed session + // worktree is no longer an executable workspace: the runner falls back + // to repo_path, and Claude keys its native store by that effective cwd. + // Materializing under the stale worktree key would therefore publish a + // valid UUID that `claude --resume` cannot find from the runner's cwd. + let value = session + .worktree_path + .as_deref() + .filter(|value| !value.trim().is_empty() && Path::new(value).is_dir()) + .or_else(|| { + session + .repo_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + }); + let cwd = match value { + Some(value) => PathBuf::from(value), + None => std::env::current_dir().map_err(|err| format!("resolve execution cwd: {err}"))?, + }; + + // Provider CLIs identify projects by the canonical working directory. + // This matters on macOS where `/tmp` is a symlink to `/private/tmp`: + // writing a Claude transcript below `projects/-tmp-...` looks correct to + // our reader, but `claude --resume` searches `projects/-private-tmp-...` + // and rejects the freshly materialized UUID. Use the same identity the + // child process observes, while retaining the configured path for a + // not-yet-created repository so materialization still fails/rolls back at + // the normal launch boundary. + Ok(provider_canonical_cwd(cwd)) +} + +fn find_codex_materialization(root: &Path, native_id: &str) -> Result, String> { + let suffix = format!("-{native_id}.jsonl"); + let mut pending = vec![root.to_path_buf()]; + let mut visited = 0usize; + while let Some(directory) = pending.pop() { + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && directory == root => { + return Ok(None) + } + Err(error) => { + return Err(format!( + "scan Codex transcript directory {}: {error}", + directory.display() + )) + } + }; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "read Codex transcript directory entry {}: {error}", + directory.display() + ) + })?; + visited += 1; + if visited > MAX_ITEMS { + return Err(format!( + "Codex transcript scan under {} exceeded {MAX_ITEMS} entries", + root.display() + )); + } + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + format!("inspect Codex transcript path {}: {error}", path.display()) + })?; + if file_type.is_dir() { + pending.push(path); + } else if file_type.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Ok(Some(path)); + } + } + } + Ok(None) +} + +fn discard_cli_materialization(session_id: &str, native_id: &str) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let bound = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|err| format!("read native binding for {session_id}: {err}"))?; + if bound.as_deref() != Some(native_id) { + return Err( + "refusing to remove a native transcript that is not the episode's current binding" + .to_string(), + ); + } + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let cwd = execution_cwd(&session)?; + let paths = match agent { + "claude_code" => existing_claude_native_paths(account_id, &cwd, native_id) + .unwrap_or_else(|| claude_native_paths(account_id, &cwd, native_id)), + "codex" => { + let account_id = account_id + .ok_or_else(|| "native Codex materialization has no account binding".to_string())?; + let Some(paths) = existing_codex_native_paths(account_id, native_id)? else { + // A previous rollback may have removed the rollout and then + // failed while clearing the DB binding. Treat the missing + // marked artifact as already removed so retry can finish the + // durable state transition instead of wedging the episode. + persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") + .map_err(|err| format!("clear native materialization binding: {err}"))?; + return Ok(false); + }; + paths + } + _ => return Ok(false), + }; + let removed = match agent { + "codex" => { + if paths.native_path.is_file() { + codex_native_catalog::archive_thread( + &codex_native_app_home(), + &paths.native_path, + native_id, + &cwd, + )?; + } + let runner_removed = remove_file_if_present(&paths.runner_path)?; + let native_removed = remove_file_if_present(&paths.native_path)?; + runner_removed || native_removed + } + "claude_code" => { + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + // Remove ORG2's discovery projection before the transcript so a + // partial rollback cannot leave a visible Desktop row whose CLI + // UUID no longer exists on disk. + remove_orgii_claude_desktop_session(&cwd, native_id)?; + let runner_removed = remove_file_if_present(&paths.runner_path)?; + let native_removed = remove_file_if_present(&paths.native_path)?; + remove_claude_project_index_entry(&cwd, native_id)?; + runner_removed || native_removed + } + _ => false, + }; + persistence::clear_staged_cli_session_id_for_account(session_id, account_id, native_id) + .map_err(|err| format!("clear native materialization binding: {err}"))?; + Ok(removed) +} + +fn materialize_cli( + session_id: &str, + items: &[NativeConversationItem], +) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Err(format!( + "CLI target {:?} has no native transcript reader/writer contract", + session.cli_agent_type + )); + } + if session.cli_session_id.is_some() { + return Err("native materialization requires a fresh empty execution episode".to_string()); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let cwd = execution_cwd(&session)?; + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let (native_id, paths) = match agent { + "claude_code" => { + let native_id = Uuid::new_v4().to_string(); + let paths = claude_native_paths(account_id, &cwd, &native_id); + let title = claude_session_title(&session, items); + let bound = + persistence::stage_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|err| format!("record pending Claude materialization: {err}"))?; + if !bound { + return Err(format!( + "record pending Claude materialization: target session {session_id} disappeared" + )); + } + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + if let Err(error) = write_native_store_jsonl( + &paths, + &claude_materialization_records(&native_id, &cwd, items, &title)?, + ) { + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, &native_id, + ); + return Err(error); + } + (native_id, paths) + } + "codex" => { + let account_id = account_id.ok_or_else(|| { + "native Codex materialization requires an explicit local account".to_string() + })?; + let title = if session.name.trim().is_empty() { + first_user_title(items) + } else { + session.name.clone() + }; + let codex_home = codex_native_app_home(); + let registered = codex_native_catalog::register_thread( + &codex_home, + &cwd, + &title, + &codex_response_items(items), + )?; + let staged = persistence::stage_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ) + .map_err(|err| format!("record pending Codex materialization: {err}"))?; + if !staged { + let _ = codex_native_catalog::archive_thread( + &codex_home, + ®istered.path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(®istered.path); + return Err(format!( + "record pending Codex materialization: target session {session_id} disappeared" + )); + } + let paths = match registered_codex_native_paths(account_id, ®istered.path) { + Ok(paths) => paths, + Err(error) => { + let _ = codex_native_catalog::archive_thread( + &codex_home, + ®istered.path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(®istered.path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ); + return Err(error); + } + }; + cache_codex_native_paths(account_id, ®istered.id, &paths); + if let Err(error) = replace_runner_link(&paths.native_path, &paths.runner_path) { + let _ = codex_native_catalog::archive_thread( + &codex_home, + &paths.native_path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ); + return Err(error); + } + (registered.id, paths) + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + if agent == "claude_code" { + if let Err(error) = + publish_claude_project_index(&cwd, &native_id, items, session.branch.as_deref()) + { + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = remove_claude_project_index_entry(&cwd, &native_id); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, &native_id, + ); + return Err(error); + } + } + let published = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|error| format!("publish native materialization binding: {error}"))?; + if !published { + return Err(format!( + "publish native materialization binding: target session {session_id} disappeared" + )); + } + tracing::info!( + session_id, + native_session_id = native_id, + target = agent, + native_path = %paths.native_path.display(), + runner_path = %paths.runner_path.display(), + item_count = items.len(), + "materialized provider-native conversation transcript" + ); + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: items.len(), + }) +} + +fn materialize_native_agent( + session_id: &str, + items: &[NativeConversationItem], +) -> Result { + agent_core::session::persistence::get_session(session_id) + .map_err(|err| format!("load native Agent session {session_id}: {err}"))? + .ok_or_else(|| format!("native Agent session {session_id} does not exist"))?; + let receipt = agent_core::session::persistence::seed_session_with_materialized_history( + session_id, + &native_agent_seeds(session_id, items), + ) + .map_err(|err| format!("seed native Agent transcript {session_id}: {err}"))?; + if receipt.row_count != items.len() { + return Err(format!( + "native Agent seed persisted {} of {} canonical items", + receipt.row_count, + items.len() + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: items.len(), + }) +} + +fn synchronize_cli( + session_id: &str, + complete_items: &[NativeConversationItem], + append_items: &[NativeConversationItem], +) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Err(format!( + "CLI target {:?} has no native transcript reader/writer contract", + session.cli_agent_type + )); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|err| format!("read native binding for {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} has no native resume binding"))?; + let cwd = execution_cwd(&session)?; + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let paths = match agent { + "claude_code" => existing_claude_native_paths(account_id, &cwd, &native_id) + .unwrap_or_else(|| claude_native_paths(account_id, &cwd, &native_id)), + "codex" => { + let account_id = account_id + .ok_or_else(|| "native Codex synchronization has no account binding".to_string())?; + existing_codex_native_paths(account_id, &native_id)? + .ok_or_else(|| format!("materialized Codex transcript {native_id} was not found"))? + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + // A provider UUID is append-only after its first materialization. Claude + // Rust has already proved the exact provider transcript is a semantic + // prefix. Append only the verified suffix so provider-private state such + // as usage and native compact checkpoints remains untouched. + match agent { + "claude_code" => { + // Inspection and suffix commit are one cross-process transaction. + // Lock the stable adjacent lock file, not the replaceable JSONL + // inode, so another ORG2 process cannot race this mutation. + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + ensure_durable_runner_alias(&paths, &native_id)?; + if !append_items.is_empty() { + let mut records = claude_records(&native_id, &cwd, append_items)?; + let (suffix_application, parent_uuid) = + inspect_claude_suffix_application(&paths.native_path, &records)?; + let appended = suffix_application == NativeSuffixApplication::Missing; + if appended { + if let Some(first) = records.first_mut() { + first["parentUuid"] = parent_uuid.map(Value::String).unwrap_or(Value::Null); + } + if let Some(leaf_uuid) = records + .last() + .and_then(|record| record["uuid"].as_str()) + .map(str::to_string) + { + records.push(claude_resume_checkpoint( + &native_id, + &leaf_uuid, + complete_items, + )); + } + let payload = serialize_jsonl(&records)?; + append_suffix_atomically(&paths.native_path, &payload)?; + } + } + let title = claude_session_title(&session, complete_items); + ensure_claude_native_metadata(&paths.native_path, &native_id, complete_items, &title)?; + publish_claude_project_index( + &cwd, + &native_id, + complete_items, + session.branch.as_deref(), + )?; + } + "codex" => { + let promoted_to_native_app = ensure_durable_runner_alias(&paths, &native_id)?; + let title = if session.name.trim().is_empty() { + first_user_title(complete_items) + } else { + session.name.clone() + }; + if promoted_to_native_app || !append_items.is_empty() { + codex_native_catalog::synchronize_thread( + &codex_native_app_home(), + &paths.native_path, + &native_id, + &cwd, + &title, + &codex_response_items(append_items), + )?; + } + } + _ => unreachable!("unsupported targets returned above"), + } + let published = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|error| format!("publish synchronized native binding: {error}"))?; + if !published { + return Err(format!( + "publish synchronized native binding: target session {session_id} disappeared" + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: complete_items.len(), + }) +} + +#[derive(Debug)] +enum BoundNativeCatalogRefresh { + Claude { + receipt: persistence::NativeCatalogRefreshReceipt, + session_id: String, + cwd: PathBuf, + native_id: String, + native_path: PathBuf, + runner_path: PathBuf, + branch: Option, + }, + Codex { + receipt: persistence::NativeCatalogRefreshReceipt, + cwd: PathBuf, + native_id: String, + native_path: PathBuf, + title: String, + }, +} + +/// Converge the provider-written transcript back to the native application's +/// durable file after a CLI turn exits. The provider may replace the isolated +/// profile symlink with a regular file, so this copy/relink step remains inside +/// the provider-identity boundary. Catalog/index refresh is deliberately +/// returned as deferred work: it must not hold that boundary or block the next +/// turn. +fn converge_bound_native_transcript( + session_id: &str, +) -> Result, String> { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Ok(None); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let Some(native_id) = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + else { + return Ok(None); + }; + let agent = session.cli_agent_type.clone().unwrap_or_default(); + if !matches!(agent.as_str(), "claude_code" | "codex") { + return Ok(None); + } + + let cwd = execution_cwd(&session)?; + // Convergence runs for every native-transcript session that carries a + // provider binding, not only the ones ORG2 materialized. A binding whose + // provider store this host cannot address at all — an ambient-auth Codex + // session with no local account, or a rollout that lives outside the + // scanned roots — is missing evidence, not proof of divergence, so it + // must not fail the turn closed. + let paths = match agent.as_str() { + "claude_code" => match existing_claude_native_paths(account_id, &cwd, &native_id) { + Some(paths) => paths, + None => { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: no Claude transcript on this host" + ); + return Ok(None); + } + }, + "codex" => { + let Some(account_id) = account_id else { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: Codex session has no local account" + ); + return Ok(None); + }; + match existing_codex_native_paths(account_id, &native_id)? { + Some(paths) => paths, + None => { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: no Codex rollout on this host" + ); + return Ok(None); + } + } + } + _ => unreachable!("provider checked before acquiring publication owner"), + }; + // Claude may replace the isolated alias with a regular file while it + // exits. Converging that file is a provider-store mutation and therefore + // shares the same cross-process UUID lock as fresh/suffix/discard. + let _claude_transcript_guard = if agent == "claude_code" { + Some(lock_claude_transcript(&paths.native_path)?) + } else { + None + }; + // Persist intent before the transcript alias mutation. A crash after this + // point can therefore leave, at worst, a dirty receipt that startup will + // retry; it cannot silently lose a required native-App catalog refresh. + let receipt = persistence::request_native_catalog_refresh(session_id, account_id, &native_id) + .map_err(|error| format!("request native App catalog refresh: {error}"))? + .ok_or_else(|| { + format!( + "request native App catalog refresh: binding {native_id} for {session_id} changed" + ) + })?; + let promoted = ensure_durable_runner_alias(&paths, &native_id)?; + if !promoted && agent == "codex" { + // The provider wrote through the existing native-App alias. Initial + // Codex's supported app-server registration already owns its catalog. + persistence::acknowledge_native_catalog_refresh(&receipt) + .map_err(|error| format!("acknowledge native App catalog refresh: {error}"))?; + return Ok(None); + } + Ok(Some(match agent.as_str() { + "claude_code" => BoundNativeCatalogRefresh::Claude { + receipt, + session_id: session_id.to_string(), + cwd, + native_id, + native_path: paths.native_path, + runner_path: paths.runner_path, + branch: session.branch, + }, + "codex" => BoundNativeCatalogRefresh::Codex { + receipt, + cwd, + native_id, + native_path: paths.native_path, + title: if session.name.trim().is_empty() { + "Imported conversation".to_string() + } else { + session.name + }, + }, + _ => unreachable!("provider checked before acquiring publication owner"), + })) +} + +fn refresh_bound_native_catalog(refresh: BoundNativeCatalogRefresh) -> Result<(), String> { + let receipt = match refresh { + BoundNativeCatalogRefresh::Claude { + receipt, + session_id, + cwd, + native_id, + native_path, + runner_path, + branch, + } => { + let mut parsed_items = None; + if !refresh_claude_project_index_from_provider( + &cwd, + &native_id, + &native_path, + &runner_path, + branch.as_deref(), + )? { + // Old Claude versions and profile repairs may not publish an + // index entry. This fallback stays outside the turn/identity + // boundary so a large JSONL cannot delay the footer. + let chunks = orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session_id, + &native_path, + )?; + let items = native_items_from_chunks(&chunks); + publish_claude_project_index(&cwd, &native_id, &items, branch.as_deref())?; + parsed_items = Some(items); + } + + // Claude Desktop does not watch Claude Code's sessions-index.json. + // Its Code tab discovers the provider JSONL through this separate + // metadata row. Publish it from the same deferred owner and only + // into an existing provider-registered account/project directory. + let desktop_root = claude_desktop_sessions_root(); + let desktop_path = desktop_root + .is_dir() + .then(|| claude_desktop_session_path(&desktop_root, &cwd, &native_id)) + .flatten(); + if let Some(desktop_path) = desktop_path { + let items = match parsed_items { + Some(items) => items, + None => { + let chunks = orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session_id, + &native_path, + )?; + native_items_from_chunks(&chunks) + } + }; + let session = persistence::get_session(&session_id) + .map_err(|error| { + format!("load CLI session {session_id} for Claude Desktop catalog: {error}") + })? + .ok_or_else(|| { + format!("CLI session {session_id} disappeared before Claude Desktop catalog refresh") + })?; + publish_claude_desktop_session_at_path( + &session, + &cwd, + &native_id, + &native_path, + &items, + desktop_path, + )?; + } + receipt + } + BoundNativeCatalogRefresh::Codex { + receipt, + cwd, + native_id, + native_path, + title, + } => { + codex_native_catalog::synchronize_thread( + &codex_native_app_home(), + &native_path, + &native_id, + &cwd, + &title, + &[], + )?; + receipt + } + }; + // Generation-CAS: an older successful task never clears a newer terminal + // request for the same native binding. + persistence::acknowledge_native_catalog_refresh(&receipt) + .map_err(|error| format!("acknowledge native App catalog refresh: {error}"))?; + Ok(()) +} + +fn prepare_pending_native_catalog_refresh( + pending: persistence::PendingNativeCatalogRefresh, +) -> Result { + let session_id = pending.receipt.session_id.clone(); + let native_id = pending.receipt.cli_session_id.clone(); + let account_id = pending.receipt.account_id().map(str::to_string); + let session = persistence::get_session(&session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let cwd = execution_cwd(&session)?; + + match pending.source.as_str() { + "claude_code" => { + let paths = existing_claude_native_paths(account_id.as_deref(), &cwd, &native_id) + .ok_or_else(|| { + format!("no Claude transcript for pending native binding {native_id}") + })?; + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + ensure_durable_runner_alias(&paths, &native_id)?; + Ok(BoundNativeCatalogRefresh::Claude { + receipt: pending.receipt, + session_id, + cwd, + native_id, + native_path: paths.native_path, + runner_path: paths.runner_path, + branch: session.branch, + }) + } + "codex_app" => { + let account_id = account_id.as_deref().ok_or_else(|| { + format!("pending Codex native binding {native_id} has no local account") + })?; + let paths = existing_codex_native_paths(account_id, &native_id)?.ok_or_else(|| { + format!("no Codex rollout for pending native binding {native_id}") + })?; + ensure_durable_runner_alias(&paths, &native_id)?; + Ok(BoundNativeCatalogRefresh::Codex { + receipt: pending.receipt, + cwd, + native_id, + native_path: paths.native_path, + title: if session.name.trim().is_empty() { + "Imported conversation".to_string() + } else { + session.name + }, + }) + } + source => Err(format!( + "unsupported pending native App catalog source {source}" + )), + } +} + +const STARTUP_NATIVE_CATALOG_REPAIR_LIMIT: usize = 64; + +/// One bounded, pending-only reconciliation pass on app startup. This is a +/// durable retry point for terminal fire-and-forget catalog work, not a poller: +/// no clean session or provider transcript is scanned. +pub(crate) async fn reconcile_pending_native_catalog_refreshes_on_startup() -> (usize, usize) { + let pending = match tokio::task::spawn_blocking(|| { + persistence::pending_native_catalog_refreshes(STARTUP_NATIVE_CATALOG_REPAIR_LIMIT) + }) + .await + { + Ok(Ok(pending)) => pending, + Ok(Err(error)) => { + tracing::warn!(error = %error, "failed to load pending native App catalog refreshes"); + return (0, 1); + } + Err(error) => { + tracing::warn!(error = %error, "pending native App catalog query task failed"); + return (0, 1); + } + }; + + let mut succeeded = 0usize; + let mut failed = 0usize; + for pending in pending { + let session_id = pending.receipt.session_id.clone(); + let mutation_guards = match lock_idle_native_mutation(&session_id).await { + Ok(guards) => guards, + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "deferred pending native App catalog repair"); + continue; + } + }; + let prepared = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + prepare_pending_native_catalog_refresh(pending) + }) + .await; + let refresh = match prepared { + Ok(Ok(refresh)) => refresh, + Ok(Err(error)) => { + failed += 1; + tracing::warn!(session_id, error = %error, "failed to prepare pending native App catalog repair"); + continue; + } + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "pending native App catalog preparation task failed"); + continue; + } + }; + match tokio::task::spawn_blocking(move || refresh_bound_native_catalog(refresh)).await { + Ok(Ok(())) => succeeded += 1, + Ok(Err(error)) => { + failed += 1; + tracing::warn!(session_id, error = %error, "failed to repair pending native App catalog"); + } + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "pending native App catalog repair task failed"); + } + } + } + (succeeded, failed) +} + +/// Finalize the durable transcript now and refresh discovery metadata after the +/// current runner releases its identity guard. The background refresh is +/// idempotent and never delays the footer or the next provider turn. +pub(super) async fn converge_bound_native_transcript_and_schedule_catalog( + session_id: &str, +) -> Result { + let converge_session_id = session_id.to_string(); + let refresh = + tokio::task::spawn_blocking(move || converge_bound_native_transcript(&converge_session_id)) + .await + .map_err(|error| format!("native transcript convergence task failed: {error}"))??; + let Some(refresh) = refresh else { + return Ok(false); + }; + + let boundary_session_id = session_id.to_string(); + tokio::spawn(async move { + // The caller's runner owns this lock. Waiting for and immediately + // dropping it establishes an after-finalization boundary without + // holding the lock during catalog I/O. If the next turn wins the race, + // its provider work remains authoritative and refresh waits harmlessly. + let identity = super::session_runner::session_identity_lock(&boundary_session_id) + .await + .lock_owned() + .await; + drop(identity); + match tokio::task::spawn_blocking(move || refresh_bound_native_catalog(refresh)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!( + session_id = %boundary_session_id, + error = %error, + "failed to refresh provider-native App catalog" + ), + Err(error) => tracing::warn!( + session_id = %boundary_session_id, + error = %error, + "native App catalog refresh task failed" + ), + } + }); + Ok(true) +} + +#[cfg(test)] +fn publish_bound_native_transcript(session_id: &str) -> Result { + let Some(refresh) = converge_bound_native_transcript(session_id)? else { + return Ok(false); + }; + refresh_bound_native_catalog(refresh)?; + Ok(true) +} + +fn synchronize_native_agent( + session_id: &str, + complete_items: &[NativeConversationItem], + append_items: &[NativeConversationItem], +) -> Result { + agent_core::session::persistence::get_session(session_id) + .map_err(|err| format!("load native Agent session {session_id}: {err}"))? + .ok_or_else(|| format!("native Agent session {session_id} does not exist"))?; + let receipt = agent_core::session::persistence::append_session_with_materialized_history( + session_id, + &native_agent_seeds(session_id, append_items), + ) + .map_err(|err| format!("append native Agent transcript {session_id}: {err}"))?; + if receipt.row_count != append_items.len() { + return Err(format!( + "native Agent append persisted {} of {} canonical suffix items", + receipt.row_count, + append_items.len() + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }) +} + +async fn materialize_native_conversation_with_owner( + state: Option<&AgentAppState>, + session_id: String, + items: Vec, +) -> Result { + validate_items(&items)?; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + // Move both guards into the blocking mutation. If the IPC future is + // cancelled after spawning, filesystem work stays serialized until + // it actually finishes instead of racing a follow-up. + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + return tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + materialize_cli(&session_id, &items) + }) + .await + .map_err(|err| format!("native materialization task failed: {err}"))?; + } + + let state = state + .ok_or_else(|| format!("Agent materialization for {session_id} requires AgentAppState"))?; + let operation_session_id = session_id.clone(); + run_agent_native_maintenance(state, session_id, move || { + materialize_native_agent(&operation_session_id, &items) + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn materialize_native_conversation( + state: tauri::State<'_, AgentAppState>, + session_id: String, + items: Vec, +) -> Result { + materialize_native_conversation_with_owner(Some(state.inner()), session_id, items).await +} + +fn synchronize_native_conversation_blocking( + session_id: &str, + complete_items: &[NativeConversationItem], +) -> Result { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))?; + if native_id.is_none() { + if complete_items.is_empty() { + // An empty canonical prefix has nothing to materialize. + // Keep the fresh episode unbound so its first real user + // turn lets the provider create a valid native UUID. + return Ok(NativeMaterializationReceipt { + native_session_id: String::new(), + item_count: 0, + }); + } + // A freshly created execution episode has no provider UUID yet, + // so there is no authoritative native prefix to compare. + return materialize_cli(session_id, complete_items); + } + if let Some(native_id) = native_id.as_deref() { + if load_materialized_cli_transcript(&session, native_id)?.is_none() { + // The resume row doubles as the materialization intent. A + // missing artifact means the process died before publication; + // clear that incomplete intent and replay through the ordinary + // materializer instead of leaving the episode permanently + // bound to a UUID that no provider can open. + persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, native_id, + ) + .map_err(|error| { + format!("clear incomplete native materialization intent: {error}") + })?; + return materialize_cli(session_id, complete_items); + } + } + } + let append_items = authoritative_append_suffix(session_id, complete_items)?; + if append_items.is_empty() { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + return synchronize_cli(session_id, complete_items, &[]); + } + return Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }); + } + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + synchronize_cli(session_id, complete_items, &append_items) + } else { + synchronize_native_agent(session_id, complete_items, &append_items) + } +} + +async fn synchronize_native_conversation_with_owner( + state: Option<&AgentAppState>, + session_id: String, + complete_items: Vec, +) -> Result { + validate_items(&complete_items)?; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + return tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + synchronize_native_conversation_blocking(&session_id, &complete_items) + }) + .await + .map_err(|err| format!("native synchronization task failed: {err}"))?; + } + + let state = state + .ok_or_else(|| format!("Agent synchronization for {session_id} requires AgentAppState"))?; + let operation_session_id = session_id.clone(); + run_agent_native_maintenance(state, session_id, move || { + synchronize_native_conversation_blocking(&operation_session_id, &complete_items) + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn synchronize_native_conversation( + state: tauri::State<'_, AgentAppState>, + session_id: String, + complete_items: Vec, +) -> Result { + synchronize_native_conversation_with_owner(Some(state.inner()), session_id, complete_items) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn discard_native_conversation_materialization( + session_id: String, + native_session_id: String, +) -> Result { + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let result = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + discard_cli_materialization(&session_id, &native_session_id) + }) + .await + .map_err(|err| format!("native materialization rollback task failed: {err}"))?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_env; + use std::ffi::OsString; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_CWD: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_CWD"; + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_READY: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_READY"; + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_ACTION: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_ACTION"; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + fn message(id: &str, role: &str, text: &str) -> NativeConversationItem { + NativeConversationItem::Message { + id: id.to_string(), + role: role.to_string(), + text: text.to_string(), + images: Vec::new(), + created_at: "2026-09-02T00:00:00Z".to_string(), + turn_id: None, + } + } + + fn tool_call(call_id: &str, name: &str, arguments: &str) -> NativeConversationItem { + NativeConversationItem::ToolCall { + id: format!("{call_id}:call"), + call_id: call_id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + created_at: "2026-09-02T00:00:01Z".to_string(), + } + } + + fn tool_result( + call_id: &str, + name: &str, + output: &str, + is_error: bool, + interrupted: bool, + ) -> NativeConversationItem { + NativeConversationItem::ToolResult { + id: format!("{call_id}:result"), + call_id: call_id.to_string(), + name: name.to_string(), + output: output.to_string(), + is_error, + interrupted, + created_at: "2026-09-02T00:00:02Z".to_string(), + } + } + + fn create_native_claude_session(session_id: &str, account_id: &str, repo_path: &Path) { + create_native_session(session_id, "claude_code", Some(account_id), repo_path); + } + + fn create_native_session( + session_id: &str, + cli_agent_type: &str, + account_id: Option<&str>, + repo_path: &Path, + ) { + persistence::create_session( + session_id, + &persistence::CreateCodeSessionParams { + name: Some("Native synchronization fixture".to_string()), + flow: None, + runner: None, + cli_agent_type: cli_agent_type.to_string(), + model: Some("claude-sonnet-4-6".to_string()), + tier: None, + account_id: account_id.map(str::to_string), + repo_path: Some(repo_path.to_string_lossy().into_owned()), + branch: None, + worktree_path: None, + worktree_base_ref: None, + proxy_token: None, + proxy_url: None, + hosted_token: None, + proxy_session_id: None, + isolate: None, + background: Some(false), + key_source: Some("own_key".to_string()), + additional_directories: None, + parent_session_id: None, + org_member_id: None, + agent_definition_id: None, + org_id: None, + project_id: None, + project_name: None, + project_slug: None, + work_item_id: None, + agent_role: None, + product_mode: None, + }, + ) + .expect("create fresh native CLI episode"); + } + + #[test] + fn unresolved_provider_tool_call_is_not_a_portable_item() { + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = "partial-tool".to_string(); + chunk.args = json!({"path": "README.md"}); + chunk.result = json!({ + "status": "pending", + "interrupted": true, + "success": false, + "call_id": "call_partial" + }); + + assert!(native_items_from_chunks(&[chunk]).is_empty()); + } + + #[test] + fn dangling_claude_tool_use_does_not_forge_a_result_item() { + let sandbox = test_env::sandbox(); + let native_id = "77777777-1111-4222-8333-999999999999"; + let path = sandbox.path().join("dangling-tool-use.jsonl"); + let items = vec![ + message("dangling-user", "user", "run the suite"), + tool_call("call_resolved", "list_files", "{\"path\":\".\"}"), + tool_result("call_resolved", "list_files", "README.md", false, false), + ]; + let mut records = claude_records(native_id, Path::new("/repo"), &items) + .expect("render resolved Claude pair"); + // Claude Code records a user interrupt as a `tool_use` its transcript + // never answers, so reading one back must not invent a result. + records.push(json!({ + "type": "assistant", + "uuid": "aaaaaaaa-2222-4333-8444-bbbbbbbbbbbb", + "parentUuid": Value::Null, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": "/repo", + "timestamp": "2026-09-02T00:00:05Z", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "call_interrupted", + "name": "list_files", + "input": {"path": "src"} + }] + } + })); + atomic_jsonl(&path, &records).expect("write Claude transcript with a dangling tool_use"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read Claude transcript back"); + let round_tripped = native_items_from_chunks(&chunks); + + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn interrupted_tool_result_round_trips_through_the_claude_transcript() { + let sandbox = test_env::sandbox(); + let native_id = "88888888-1111-4222-8333-cccccccccccc"; + let path = sandbox.path().join("interrupted-tool.jsonl"); + let items = vec![ + message("interrupt-user", "user", "run the suite"), + tool_call( + "call_killed", + "run_command_line", + "{\"command\":\"cargo test\"}", + ), + tool_result( + "call_killed", + "run_command_line", + "compiling org2\n", + true, + true, + ), + message("interrupt-follow-up", "user", "stop and summarize"), + ]; + let records = + claude_records(native_id, Path::new("/repo"), &items).expect("render Claude records"); + atomic_jsonl(&path, &records).expect("write Claude transcript"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read Claude transcript back"); + let round_tripped = native_items_from_chunks(&chunks); + + assert_eq!(round_tripped.len(), items.len()); + assert!(matches!( + &round_tripped[2], + NativeConversationItem::ToolResult { + output, + is_error: true, + .. + } if output == "compiling org2\n" + )); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn failed_codex_tool_output_round_trips_as_a_failed_tool() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("rollout-failed-tool.jsonl"); + let items = vec![ + tool_call( + "call_failed", + "run_command_line", + "{\"command\":\"cargo test\"}", + ), + tool_result("call_failed", "run_command_line", "boom\n", true, false), + tool_call("call_ok", "list_files", "{\"path\":\".\"}"), + tool_result("call_ok", "list_files", "README.md", false, false), + ]; + let projected = codex_response_items(&items); + assert_eq!(projected[3]["output"], "README.md"); + + let rollout = projected + .iter() + .map(|payload| { + json!({ + "timestamp": "2026-09-02T00:00:03Z", + "type": "response_item", + "payload": payload + }) + .to_string() + }) + .collect::>() + .join("\n"); + fs::write(&path, format!("{rollout}\n")).expect("write Codex rollout"); + + let chunks = orgtrack_core::sources::codex::app::load_codex_app_from_path( + "codexapp-failed-tool", + &path, + ) + .expect("read Codex rollout back"); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].result["is_error"], true); + assert_eq!(chunks[0].result["output"], "boom\n"); + assert_eq!(chunks[1].result["success"], true); + + let round_tripped = native_items_from_chunks(&chunks); + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn interrupted_codex_tool_output_is_not_injected_as_a_success() { + let items = vec![tool_result( + "call_killed", + "run_command_line", + "compiling org2\n", + true, + true, + )]; + let projected = codex_response_items(&items); + assert_eq!( + projected[0]["output"], + json!({"exit_code": 130, "output": "compiling org2\n"}).to_string() + ); + } + + #[test] + fn latest_compact_boundary_replaces_superseded_effective_context() { + let mut before = ActivityChunk::new("source", "message", "user_message"); + before.chunk_id = "before".to_string(); + before.result = json!({"content": "superseded prompt"}); + let mut compact = ActivityChunk::new("source", "context_compacted", "context_compacted"); + compact.chunk_id = "compact-1".to_string(); + compact.result = json!({"observation": "repository summary"}); + let mut after = ActivityChunk::new("source", "message", "user_message"); + after.chunk_id = "after".to_string(); + after.result = json!({"content": "continue"}); + + let items = native_items_from_chunks(&[before, compact, after]); + assert_eq!(items.len(), 2); + assert!(matches!( + &items[0], + NativeConversationItem::ContextSummary { summary, .. } + if summary == "repository summary" + )); + let records = claude_records("native-compact", Path::new("/repo"), &items) + .expect("serialize effective Claude context"); + assert_eq!(records[0]["subtype"], "compact_boundary"); + assert_eq!(records[1]["isCompactSummary"], true); + assert_eq!(records[1]["message"]["content"], "repository summary"); + } + + #[test] + fn missing_claude_resume_checkpoint_is_repaired_idempotently() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("missing-checkpoint.jsonl"); + let native_id = "99999999-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("checkpoint-user", "user", "continue")]; + let records = + claude_records(native_id, Path::new("/repo"), &items).expect("serialize Claude rows"); + atomic_jsonl(&path, &records).expect("write rows without checkpoint"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "") + .expect("repair missing checkpoint"); + let once = fs::read_to_string(&path).expect("read repaired transcript"); + ensure_claude_native_metadata(&path, native_id, &items, "") + .expect("repeat checkpoint repair"); + let twice = fs::read_to_string(&path).expect("read idempotent transcript"); + + assert_eq!(once, twice); + assert_eq!(once.lines().count(), 2); + let checkpoint: Value = + serde_json::from_str(once.lines().last().unwrap()).expect("decode repaired checkpoint"); + assert_eq!(checkpoint["type"], "last-prompt"); + assert_eq!( + checkpoint["leafUuid"], + records.last().expect("message record")["uuid"] + ); + } + + #[test] + fn claude_materialization_title_is_native_metadata_not_a_conversation_item() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("materialized-title.jsonl"); + let native_id = "12345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![ + message("title-user", "user", "inspect the repository"), + message("title-assistant", "assistant", "I will inspect it."), + ]; + let records = claude_materialization_records( + native_id, + Path::new("/repo"), + &items, + "Canonical conversation title", + ) + .expect("render titled Claude materialization"); + + assert_eq!(records[0]["type"], "custom-title"); + assert_eq!(records[0]["customTitle"], "Canonical conversation title"); + assert_eq!(records[0]["sessionId"], native_id); + assert_eq!( + records + .iter() + .filter(|record| record["type"] == "custom-title") + .count(), + 1 + ); + + atomic_jsonl(&path, &records).expect("write titled Claude transcript"); + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read titled Claude transcript"); + let round_tripped = native_items_from_chunks(&chunks); + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn missing_claude_custom_title_is_repaired_once_for_orgii_materialization() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("missing-title.jsonl"); + let native_id = "22345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("title-user", "user", "continue")]; + let records = claude_records_with_resume_checkpoint(native_id, Path::new("/repo"), &items) + .expect("serialize untitled Claude materialization"); + atomic_jsonl(&path, &records).expect("write untitled Claude materialization"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Canonical title") + .expect("repair missing title"); + let once = fs::read_to_string(&path).expect("read repaired transcript"); + ensure_claude_native_metadata(&path, native_id, &items, "Canonical title") + .expect("repeat title repair"); + let twice = fs::read_to_string(&path).expect("read idempotent transcript"); + + assert_eq!(once, twice); + let custom_titles = once + .lines() + .map(|line| serde_json::from_str::(line).expect("decode Claude record")) + .filter(|record| record["type"] == "custom-title") + .collect::>(); + assert_eq!(custom_titles.len(), 1); + assert_eq!(custom_titles[0]["customTitle"], "Canonical title"); + assert_eq!(custom_titles[0]["sessionId"], native_id); + } + + #[test] + fn claude_metadata_repair_does_not_title_unmanaged_native_transcript() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("provider-owned.jsonl"); + let native_id = "32345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("provider-user", "user", "provider-owned")]; + let mut records = claude_records(native_id, Path::new("/repo"), &items) + .expect("serialize provider transcript fixture"); + for record in &mut records { + record + .as_object_mut() + .expect("Claude record object") + .remove("entrypoint"); + } + atomic_jsonl(&path, &records).expect("write provider-owned transcript"); + let before = fs::read(&path).expect("read provider-owned transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Must not be applied") + .expect("inspect provider-owned transcript"); + + assert_eq!( + fs::read(&path).expect("read untouched provider transcript"), + before + ); + } + + #[test] + fn claude_metadata_repair_preserves_existing_custom_title() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("existing-title.jsonl"); + let native_id = "42345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("existing-title-user", "user", "continue")]; + let mut records = + claude_records_with_resume_checkpoint(native_id, Path::new("/repo"), &items) + .expect("serialize titled Claude materialization"); + records.insert(0, claude_custom_title(native_id, "Existing title")); + atomic_jsonl(&path, &records).expect("write titled Claude materialization"); + let before = fs::read(&path).expect("read titled transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Replacement title") + .expect("inspect existing title"); + + assert_eq!( + fs::read(&path).expect("read preserved titled transcript"), + before + ); + } + + #[cfg(unix)] + #[test] + #[ignore = "launched by claude_project_index_rmw_is_locked_across_processes"] + fn claude_project_index_lock_child() { + let Some(cwd) = std::env::var_os(CLAUDE_INDEX_LOCK_CHILD_CWD).map(PathBuf::from) else { + return; + }; + let ready = PathBuf::from( + std::env::var_os(CLAUDE_INDEX_LOCK_CHILD_READY) + .expect("cross-process lock child ready marker"), + ); + fs::write(&ready, b"ready").expect("write cross-process lock child ready marker"); + let native_id = "44444444-5555-4666-8777-888888888888"; + match std::env::var(CLAUDE_INDEX_LOCK_CHILD_ACTION).as_deref() { + Ok("remove") => remove_claude_project_index_entry(&cwd, native_id) + .expect("child removes Claude project index entry"), + _ => publish_claude_project_index( + &cwd, + native_id, + &[message("child-user", "user", "published by child")], + None, + ) + .expect("child publishes Claude project index entry"), + } + } + + #[cfg(unix)] + #[test] + fn claude_project_index_rmw_is_locked_across_processes() { + use std::process::Command; + use std::thread; + use std::time::{Duration, Instant}; + + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("cross-process-claude-worktree"); + fs::create_dir_all(&cwd).expect("create cross-process Claude workspace"); + let native_id = "44444444-5555-4666-8777-888888888888"; + let index_path = claude_native_paths(None, &cwd, native_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + fs::create_dir_all(index_path.parent().expect("Claude index parent")) + .expect("create Claude index parent"); + let ready = sandbox.path().join("claude-index-child-ready"); + let guard = lock_claude_project_index(&index_path).expect("lock Claude project index"); + + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_project_index_lock_child") + .arg("--ignored") + .env(CLAUDE_INDEX_LOCK_CHILD_CWD, &cwd) + .env(CLAUDE_INDEX_LOCK_CHILD_READY, &ready) + .spawn() + .expect("launch cross-process Claude index writer"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.exists(), "child must reach the locked RMW boundary"); + thread::sleep(Duration::from_millis(100)); + assert!( + !index_path.exists(), + "another process must not mutate the index while the advisory lock is held" + ); + assert!( + child.try_wait().expect("inspect child process").is_none(), + "child should still be waiting for the cross-process lock" + ); + + drop(guard); + let status = child.wait().expect("wait for cross-process index writer"); + assert!(status.success(), "cross-process index writer failed"); + let index: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read child-published Claude project index"), + ) + .expect("decode child-published Claude project index"); + assert_eq!(index["entries"][0]["sessionId"].as_str(), Some(native_id)); + + let survivor_id = "55555555-6666-4777-8888-999999999999"; + publish_claude_project_index( + &cwd, + survivor_id, + &[message("survivor-user", "user", "must survive remove")], + None, + ) + .expect("publish peer index entry"); + let remove_ready = sandbox.path().join("claude-index-remove-child-ready"); + let guard = lock_claude_project_index(&index_path).expect("relock Claude project index"); + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_project_index_lock_child") + .arg("--ignored") + .env(CLAUDE_INDEX_LOCK_CHILD_CWD, &cwd) + .env(CLAUDE_INDEX_LOCK_CHILD_READY, &remove_ready) + .env(CLAUDE_INDEX_LOCK_CHILD_ACTION, "remove") + .spawn() + .expect("launch cross-process Claude index remover"); + let deadline = Instant::now() + Duration::from_secs(5); + while !remove_ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!( + remove_ready.exists(), + "remove child must reach the locked RMW boundary" + ); + thread::sleep(Duration::from_millis(100)); + let locked_index = fs::read_to_string(&index_path).expect("read index while remove waits"); + assert!( + locked_index.contains(native_id), + "another process must not remove an entry while the advisory lock is held" + ); + assert!( + child.try_wait().expect("inspect remove child").is_none(), + "remove child should still be waiting for the cross-process lock" + ); + + drop(guard); + let status = child.wait().expect("wait for cross-process index remover"); + assert!(status.success(), "cross-process index remover failed"); + let index = fs::read_to_string(&index_path).expect("read index after child remove"); + assert!(!index.contains(native_id)); + assert!( + index.contains(survivor_id), + "removing one entry must preserve peer updates" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn agent_native_maintenance_waits_for_the_session_scheduler_owner() { + let session_id = "sdeagent-native-maintenance-owner"; + let session = Arc::new(agent_core::state::AgentSession::new( + session_id.to_string(), + agent_core::definitions::sde_agent(), + )); + let release_first = Arc::new(tokio::sync::Notify::new()); + let release_first_task = Arc::clone(&release_first); + let (first_started_tx, first_started_rx) = oneshot::channel(); + session + .scheduler + .enqueue(ScheduledMessage { + kind: ScheduledKind::Maintenance, + message_id: "blocking-maintenance".to_string(), + generation: 0, + client_message_id: None, + turn_intent_id: String::new(), + org_run_id: None, + content: "[test maintenance]".to_string(), + execute: Box::new(move || { + Box::pin(async move { + let _ = first_started_tx.send(()); + release_first_task.notified().await; + Ok(String::new()) + }) + }), + }) + .await + .expect("enqueue blocking maintenance"); + first_started_rx + .await + .expect("blocking maintenance should start"); + + let mutation_ran = Arc::new(AtomicBool::new(false)); + let mutation_ran_task = Arc::clone(&mutation_ran); + let session_task = Arc::clone(&session); + let mutation = tokio::spawn(enqueue_agent_native_maintenance( + session_task, + session_id.to_string(), + move || { + mutation_ran_task.store(true, Ordering::Release); + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: 1, + }) + }, + )); + tokio::task::yield_now().await; + assert!( + !mutation_ran.load(Ordering::Acquire), + "native mutation must not bypass an active scheduler job" + ); + + release_first.notify_one(); + let receipt = mutation + .await + .expect("join native maintenance") + .expect("native maintenance succeeds"); + assert!(mutation_ran.load(Ordering::Acquire)); + assert_eq!(receipt.native_session_id, session_id); + assert_eq!(receipt.item_count, 1); + } + + #[test] + fn semantic_identity_ignores_provider_ids_and_timestamps() { + let left = message("canonical", "user", "hello"); + let right = NativeConversationItem::Message { + id: "provider".to_string(), + role: "user".to_string(), + text: "hello".to_string(), + images: Vec::new(), + created_at: "2027-01-01T00:00:00Z".to_string(), + turn_id: None, + }; + assert!(native_item_semantically_equal(&left, &right)); + } + + #[test] + fn agent_history_preserves_embedded_user_images() { + let history = vec![json!({ + "role": "user", + "content": [ + {"type": "text", "text": "inspect"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJD"}} + ] + })]; + let projected = native_items_from_agent_history(&history); + assert!(matches!( + &projected[0], + NativeConversationItem::Message { text, images, .. } + if text == "inspect" && images == &["data:image/png;base64,QUJD"] + )); + } + + #[test] + fn codex_tool_arguments_are_not_polluted_with_orgii_fields() { + let item = NativeConversationItem::ToolCall { + id: "call-item".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"README.md"}"#.to_string(), + created_at: "2026-09-02T00:00:00Z".to_string(), + }; + let projected = codex_response_items(&[item]); + assert_eq!(projected[0]["arguments"], r#"{"path":"README.md"}"#); + assert!(!projected[0]["arguments"] + .as_str() + .unwrap_or_default() + .contains("__orgii")); + } + + #[test] + fn claude_project_index_preserves_unknown_fields_and_rejects_unknown_schema() { + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("claude-index-schema-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let native_id = "99999999-aaaa-4bbb-8ccc-dddddddddddd"; + let index_path = claude_native_paths(None, &cwd, native_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + fs::create_dir_all(index_path.parent().expect("Claude index parent")) + .expect("create Claude index parent"); + fs::write( + &index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "providerTopLevel": {"keep": true}, + "entries": [{ + "sessionId": native_id, + "messageCount": 1, + "providerEntryField": {"keep": true} + }] + })) + .expect("encode Claude index fixture"), + ) + .expect("write Claude index fixture"); + + publish_claude_project_index( + &cwd, + native_id, + &[message("index-user", "user", "updated")], + Some("feature/native-index"), + ) + .expect("update supported Claude index schema"); + let updated: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read updated Claude project index"), + ) + .expect("decode updated Claude project index"); + assert_eq!(updated["providerTopLevel"]["keep"], true); + assert_eq!(updated["entries"][0]["providerEntryField"]["keep"], true); + assert_eq!(updated["entries"][0]["gitBranch"], "feature/native-index"); + + let unsupported = json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION + 1, + "entries": updated["entries"].clone() + }); + fs::write( + &index_path, + serde_json::to_vec(&unsupported).expect("encode unsupported Claude index"), + ) + .expect("write unsupported Claude index"); + let error = publish_claude_project_index( + &cwd, + native_id, + &[message("new-user", "user", "must not overwrite")], + None, + ) + .expect_err("unknown Claude index schemas must fail closed"); + assert!(error.contains("unsupported Claude project index schema version")); + let unchanged: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read rejected Claude project index"), + ) + .expect("decode rejected Claude project index"); + assert_eq!(unchanged, unsupported); + } + + #[test] + fn claude_catalog_refresh_uses_provider_metadata_without_parsing_transcript() { + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("claude-provider-index-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let native_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let paths = claude_native_paths(Some("anthropic-provider-index"), &cwd, native_id); + assert_ne!(paths.native_path, paths.runner_path); + fs::create_dir_all( + paths + .native_path + .parent() + .expect("native transcript parent"), + ) + .expect("create native transcript parent"); + fs::create_dir_all( + paths + .runner_path + .parent() + .expect("runner transcript parent"), + ) + .expect("create runner transcript parent"); + // Deliberately not valid Claude JSONL: taking the provider metadata path + // must succeed without falling back to a transcript parse. + fs::write(&paths.native_path, b"not-json\n").expect("write native transcript fixture"); + replace_runner_link(&paths.native_path, &paths.runner_path) + .expect("link isolated runner to native transcript"); + let (file_mtime, _) = + transcript_modified_metadata(&paths.native_path).expect("read fixture mtime"); + + let native_index_path = paths + .native_path + .parent() + .expect("native project directory") + .join("sessions-index.json"); + fs::write( + &native_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "messageCount": 1, + "nativeUnknown": "keep" + }] + })) + .expect("encode native index fixture"), + ) + .expect("write native index fixture"); + let runner_index_path = paths + .runner_path + .parent() + .expect("runner project directory") + .join("sessions-index.json"); + fs::write( + &runner_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "fullPath": paths.runner_path, + "fileMtime": file_mtime, + "firstPrompt": "provider prompt", + "messageCount": 7, + "providerUnknown": "keep" + }] + })) + .expect("encode provider index fixture"), + ) + .expect("write provider index fixture"); + + assert!(refresh_claude_project_index_from_provider( + &cwd, + native_id, + &paths.native_path, + &paths.runner_path, + Some("feature/provider-index") + ) + .expect("refresh native catalog from provider metadata")); + let refreshed: Value = serde_json::from_slice( + &fs::read(&native_index_path).expect("read refreshed native index"), + ) + .expect("decode refreshed native index"); + let entry = &refreshed["entries"][0]; + assert_eq!(entry["messageCount"], 7); + assert_eq!(entry["fileMtime"], file_mtime); + assert_eq!(entry["fullPath"], json!(paths.native_path)); + assert_eq!(entry["workspacePath"], json!(cwd)); + assert_eq!(entry["nativeUnknown"], "keep"); + assert_eq!(entry["providerUnknown"], "keep"); + assert_eq!(entry["gitBranch"], "feature/provider-index"); + } + + #[test] + fn cold_claude_lookup_falls_back_to_the_native_app_transcript() { + let sandbox = test_env::sandbox(); + let account_id = "anthropic-cold-native-root"; + let native_id = "11111111-2222-4333-8444-555555555555"; + let cwd = sandbox.path().join("legacy-claude-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + + let paths = claude_native_paths(Some(account_id), &cwd, native_id); + assert_ne!(paths.native_path, paths.runner_path); + fs::create_dir_all(paths.native_path.parent().expect("native Claude parent")) + .expect("create legacy Claude transcript parent"); + fs::write(&paths.native_path, b"{}\n").expect("write legacy Claude transcript"); + + let resolved = existing_claude_native_paths(Some(account_id), &cwd, native_id) + .expect("cold lookup should retain an existing native-App transcript"); + assert_eq!(resolved.native_path, paths.native_path); + assert_eq!(resolved.runner_path, paths.runner_path); + } + + #[test] + fn profile_only_claude_transcript_is_promoted_without_losing_bytes() { + let sandbox = test_env::sandbox(); + let account_id = "anthropic-profile-only"; + let native_id = "22222222-3333-4444-8555-666666666666"; + let cwd = sandbox.path().join("profile-only-claude-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let paths = claude_native_paths(Some(account_id), &cwd, native_id); + fs::create_dir_all(paths.runner_path.parent().expect("runner parent")) + .expect("create profile-only runner parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("profile-user", "user", "preserve me")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.runner_path, &payload).expect("write profile-only transcript"); + + assert!(ensure_durable_runner_alias(&paths, native_id) + .expect("promote profile-only transcript")); + assert_eq!( + fs::read(&paths.native_path).expect("read durable transcript"), + payload + ); + assert_eq!( + fs::read(&paths.runner_path).expect("read runner alias"), + payload + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + } + + #[test] + fn claude_desktop_directory_scan_has_a_hard_entry_budget() { + let sandbox = test_env::sandbox(); + let root = sandbox.path().join("claude-desktop-catalog"); + fs::create_dir_all(root.join("a-directory")).expect("create first catalog directory"); + fs::create_dir_all(root.join("b-directory")).expect("create second catalog directory"); + fs::write(root.join("c-row.json"), b"{}").expect("create first catalog row"); + fs::write(root.join("d-row.json"), b"{}").expect("create second catalog row"); + + let mut budget = 2; + let paths = bounded_directory_paths(&root, &mut budget); + assert_eq!(paths.len(), 2); + assert_eq!(budget, 0); + assert!(bounded_directory_paths(&root, &mut budget).is_empty()); + } + + #[test] + fn claude_desktop_uuid_lookup_crosses_accounts_but_new_rows_use_active_account() { + let sandbox = test_env::sandbox(); + let _native_history = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", sandbox.path()); + let cwd = sandbox.path().join("shared-worktree"); + fs::create_dir_all(&cwd).expect("create shared Claude workspace"); + + let sessions_root = claude_desktop_sessions_root(); + let inactive_project = sessions_root + .join("11111111-1111-4111-8111-111111111111") + .join("22222222-2222-4222-8222-222222222222"); + let active_project = sessions_root + .join("33333333-3333-4333-8333-333333333333") + .join("44444444-4444-4444-8444-444444444444"); + fs::create_dir_all(&inactive_project).expect("create inactive account project"); + fs::create_dir_all(&active_project).expect("create active account project"); + fs::write( + sessions_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "33333333-3333-4333-8333-333333333333" + })) + .expect("encode active account config"), + ) + .expect("write active account config"); + + let existing_native_id = "55555555-5555-4555-8555-555555555555"; + let existing_path = inactive_project.join(format!("local_{existing_native_id}.json")); + fs::write( + &existing_path, + serde_json::to_vec(&json!({ + "sessionId": format!("local_{existing_native_id}"), + "cliSessionId": existing_native_id, + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode inactive account row"), + ) + .expect("write inactive account row"); + fs::write( + active_project.join("local-active-seed.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-active-seed", + "cliSessionId": "active-seed", + "cwd": cwd, + "createdAt": 2, + "lastActivityAt": 2 + })) + .expect("encode active account seed"), + ) + .expect("write active account seed"); + + assert_eq!( + claude_desktop_session_path(&sessions_root, &cwd, existing_native_id), + Some(existing_path), + "an existing UUID must remain in its original account" + ); + + let new_native_id = "66666666-6666-4666-8666-666666666666"; + assert_eq!( + claude_desktop_session_path(&sessions_root, &cwd, new_native_id), + Some(active_project.join(format!("local_{new_native_id}.json"))), + "a new row must be placed under the active account" + ); + } + + #[test] + fn finalizer_publication_promotes_fresh_claude_session_and_catalog() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-finalizer-publish"; + let account_id = "anthropic-finalizer-publish"; + let native_id = "33333333-4444-4555-8666-777777777777"; + let cwd = sandbox.path().join("finalizer-publish-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load fresh native session") + .expect("fresh native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + fs::create_dir_all(paths.runner_path.parent().expect("runner parent")) + .expect("create isolated runner transcript parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("fresh-user", "user", "visible in Claude")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.runner_path, &payload).expect("write isolated provider transcript"); + + let desktop_root = claude_desktop_sessions_root(); + let desktop_project = desktop_root + .join("11111111-1111-4111-8111-111111111111") + .join("22222222-2222-4222-8222-222222222222"); + fs::create_dir_all(&desktop_project).expect("create provider-owned Desktop project"); + fs::write( + desktop_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "11111111-1111-4111-8111-111111111111" + })) + .expect("encode Desktop config"), + ) + .expect("write Desktop config"); + fs::write( + desktop_project.join("local-existing.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode existing Desktop row"), + ) + .expect("seed provider-owned Desktop project row"); + + assert!(publish_bound_native_transcript(session_id).expect("publish native transcript")); + assert_eq!( + fs::read(&paths.native_path).expect("read native App transcript"), + payload + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + let index = fs::read_to_string( + paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"), + ) + .expect("read Claude project index"); + assert!(index.contains(native_id)); + assert!(index.contains("visible in Claude")); + let desktop_row: Value = serde_json::from_slice( + &fs::read(desktop_project.join(format!("local_{native_id}.json"))) + .expect("read Claude Desktop discovery row"), + ) + .expect("decode Claude Desktop discovery row"); + assert_eq!(desktop_row["sessionId"], format!("local_{native_id}")); + assert_eq!(desktop_row["cliSessionId"], native_id); + assert!(paths_match( + Path::new(desktop_row["cwd"].as_str().expect("Desktop cwd")), + &cwd + )); + assert_eq!(desktop_row["completedTurns"], 0); + assert_eq!(desktop_row["orgiiMaterialization"], true); + + assert!(discard_cli_materialization(session_id, native_id) + .expect("discard materialized Claude transcript")); + assert!( + !desktop_project + .join(format!("local_{native_id}.json")) + .exists(), + "rollback must not leave a Desktop row pointing at a removed JSONL" + ); + } + + #[test] + fn explicit_native_home_survives_discovery_home_cleanup() { + let sandbox = test_env::sandbox(); + let discovery_home = sandbox.path().join("disposable-discovery-home"); + let official_home = sandbox.path().join("official-provider-home"); + let cwd = sandbox.path().join("durable-native-worktree"); + fs::create_dir_all(&discovery_home).expect("create isolated discovery home"); + fs::create_dir_all(&official_home).expect("create official provider home"); + fs::create_dir_all(&cwd).expect("create Claude workspace"); + let _external_history = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", &discovery_home); + let _native_history = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", &official_home); + let native_id = "34343434-5656-4787-8989-abababababab"; + let items = vec![ + message("durable-user", "user", "survive discovery cleanup"), + message("durable-assistant", "assistant", "still openable"), + ]; + let paths = claude_native_paths(None, &cwd, native_id); + let records = claude_records_with_resume_checkpoint(native_id, &cwd, &items) + .expect("render Claude transcript"); + + let desktop_root = claude_desktop_sessions_root(); + let desktop_project = desktop_root + .join("33333333-3333-4333-8333-333333333333") + .join("44444444-4444-4444-8444-444444444444"); + fs::create_dir_all(&desktop_project).expect("create official Desktop project"); + fs::write( + desktop_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "33333333-3333-4333-8333-333333333333" + })) + .expect("encode official Desktop config"), + ) + .expect("write official Desktop config"); + fs::write( + desktop_project.join("local-existing.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode official Desktop seed"), + ) + .expect("seed official Desktop project"); + let session_id = "cliagent-durable-native-desktop-proof"; + create_native_claude_session(session_id, "anthropic-durable-proof", &cwd); + let session = persistence::get_session(session_id) + .expect("load durable Desktop proof session") + .expect("durable Desktop proof session exists"); + + write_native_store_jsonl(&paths, &records).expect("publish official Claude transcript"); + publish_claude_project_index(&cwd, native_id, &items, Some("feature/native-proof")) + .expect("publish official Claude project index"); + publish_claude_desktop_session(&session, &cwd, native_id, &paths.native_path, &items) + .expect("publish official Claude Desktop metadata") + .expect("matching provider-owned Desktop project"); + fs::remove_dir_all(&discovery_home).expect("delete isolated discovery home"); + + assert!(paths.native_path.starts_with(&official_home)); + assert!(paths.native_path.is_file()); + let parsed = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + "claudecodeapp-durable-proof", + &paths.native_path, + ) + .expect("open published Claude transcript after discovery cleanup"); + assert!(parsed.iter().any(|chunk| { + chunk.function == "assistant" && chunk.result.to_string().contains("still openable") + })); + + let index_path = paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + let index: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read durable Claude project index"), + ) + .expect("decode durable Claude project index"); + let entry = index["entries"] + .as_array() + .expect("Claude project index entries") + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .expect("durable Claude project index entry"); + assert_eq!(entry["fullPath"], json!(paths.native_path)); + assert!(Path::new(entry["fullPath"].as_str().expect("indexed path")).is_file()); + assert_eq!(entry["workspacePath"], json!(cwd)); + let desktop_row = desktop_project.join(format!("local_{native_id}.json")); + assert!(desktop_row.starts_with(&official_home)); + let desktop_row: Value = serde_json::from_slice( + &fs::read(desktop_row).expect("read durable Claude Desktop metadata"), + ) + .expect("decode durable Claude Desktop metadata"); + assert_eq!(desktop_row["cliSessionId"], native_id); + assert!(paths_match( + Path::new(desktop_row["cwd"].as_str().expect("Desktop cwd")), + &cwd + )); + assert_eq!(desktop_row["completedTurns"], 1); + } + + #[test] + fn finalizer_refreshes_claude_catalog_after_normal_linked_append() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-finalizer-linked-append"; + let account_id = "anthropic-finalizer-linked-append"; + let native_id = "44444444-5555-4666-8777-888888888888"; + let cwd = sandbox.path().join("finalizer-linked-append-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load native session") + .expect("native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + fs::create_dir_all( + paths + .native_path + .parent() + .expect("native transcript parent"), + ) + .expect("create native transcript parent"); + fs::create_dir_all( + paths + .runner_path + .parent() + .expect("runner transcript parent"), + ) + .expect("create runner transcript parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("linked-user", "user", "normal linked append")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.native_path, &payload).expect("write native provider transcript"); + replace_runner_link(&paths.native_path, &paths.runner_path) + .expect("link isolated runner to native transcript"); + let (file_mtime, _) = + transcript_modified_metadata(&paths.native_path).expect("read transcript mtime"); + let runner_index_path = paths + .runner_path + .parent() + .expect("runner project directory") + .join("sessions-index.json"); + fs::write( + &runner_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "fullPath": paths.runner_path, + "fileMtime": file_mtime, + "firstPrompt": "normal linked append", + "messageCount": 9 + }] + })) + .expect("encode runner index"), + ) + .expect("write runner index"); + + // The alias already points at the durable transcript, so convergence + // performs no promotion. Claude catalog refresh must still be returned. + assert!(publish_bound_native_transcript(session_id) + .expect("publish normal linked provider append")); + let native_index_path = paths + .native_path + .parent() + .expect("native project directory") + .join("sessions-index.json"); + let index: Value = serde_json::from_slice( + &fs::read(native_index_path).expect("read refreshed native index"), + ) + .expect("decode refreshed native index"); + assert_eq!(index["entries"][0]["sessionId"], native_id); + assert_eq!(index["entries"][0]["messageCount"], 9); + assert_eq!(index["entries"][0]["fileMtime"], file_mtime); + } + + #[test] + fn divergent_native_and_runner_transcripts_fail_closed() { + let sandbox = test_env::sandbox(); + let paths = NativeTranscriptPaths { + native_path: sandbox.path().join("native.jsonl"), + runner_path: sandbox.path().join("runner.jsonl"), + }; + fs::write( + &paths.native_path, + b"{\"sessionId\":\"native-1\"}\n{\"message\":\"left\"}\n", + ) + .expect("write native transcript"); + fs::write( + &paths.runner_path, + b"{\"sessionId\":\"native-1\"}\n{\"message\":\"right\"}\n", + ) + .expect("write runner transcript"); + + let error = preferred_materialized_transcript_path(&paths) + .expect_err("two independently advanced transcripts must not be guessed by mtime"); + assert!(error.contains("both advanced")); + } + + #[test] + fn convergence_skips_a_codex_session_without_a_local_account() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-codex-ambient"; + let native_id = "bbbbbbbb-1111-4222-8333-dddddddddddd"; + create_native_session(session_id, "codex", None, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, None, native_id) + .expect("bind provider UUID without a local account"); + + assert!(converge_bound_native_transcript(session_id) + .expect("a hosted-key Codex session is missing evidence, not diverged") + .is_none()); + } + + #[test] + fn convergence_skips_a_bound_session_with_no_provider_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-no-transcript"; + let account_id = "anthropic-converge-no-transcript"; + let native_id = "cccccccc-1111-4222-8333-eeeeeeeeeeee"; + create_native_claude_session(session_id, account_id, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + + assert!(converge_bound_native_transcript(session_id) + .expect("an unwritten provider transcript must not fail the turn closed") + .is_none()); + } + + #[test] + fn convergence_still_fails_closed_on_a_divergent_bound_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-divergent"; + let account_id = "anthropic-converge-divergent"; + let native_id = "dddddddd-1111-4222-8333-ffffffffffff"; + let cwd = sandbox.path().join("converge-divergent-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load bound native session") + .expect("bound native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + for path in [&paths.native_path, &paths.runner_path] { + fs::create_dir_all(path.parent().expect("transcript parent")) + .expect("create transcript parent"); + } + fs::write( + &paths.native_path, + format!("{{\"sessionId\":\"{native_id}\"}}\n{{\"message\":\"left\"}}\n"), + ) + .expect("write native App transcript"); + fs::write( + &paths.runner_path, + format!("{{\"sessionId\":\"{native_id}\"}}\n{{\"message\":\"right\"}}\n"), + ) + .expect("write isolated runner transcript"); + + let error = converge_bound_native_transcript(session_id) + .expect_err("two independently advanced copies of one UUID are a proven divergence"); + assert!(error.contains("both advanced"), "unexpected error: {error}"); + } + + #[test] + fn cold_codex_lookup_falls_back_to_the_native_app_transcript() { + let sandbox = test_env::sandbox(); + let account_id = "codex-cold-native-root"; + let native_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let legacy_path = codex_native_app_sessions_root() + .join("2026") + .join("09") + .join("03") + .join(format!("rollout-2026-09-03T00-00-00-{native_id}.jsonl")); + assert!(legacy_path.starts_with(sandbox.path())); + fs::create_dir_all(legacy_path.parent().expect("legacy Codex parent")) + .expect("create legacy Codex transcript parent"); + fs::write(&legacy_path, b"{}\n").expect("write legacy Codex transcript"); + + let cache_key = (account_id.to_string(), native_id.to_string()); + CODEX_NATIVE_PATH_CACHE + .lock() + .expect("lock Codex native path cache") + .remove(&cache_key); + let resolved = existing_codex_native_paths(account_id, native_id) + .expect("scan Codex native roots") + .expect("cold lookup should retain an existing native-App rollout"); + assert_eq!(resolved.native_path, legacy_path); + assert_eq!( + resolved.runner_path, + codex_profile_sessions_root(account_id) + .join("2026") + .join("09") + .join("03") + .join(format!("rollout-2026-09-03T00-00-00-{native_id}.jsonl")) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_materializes_unbound_then_preserves_existing_cli_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-fresh"; + let account_id = "anthropic-native-sync-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + let complete_items = vec![ + message("user-1", "user", "Inspect the repository"), + NativeConversationItem::ToolCall { + id: "tool-call-1".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"README.md"}"#.to_string(), + created_at: "2026-09-02T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-result-1".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + output: "repository read".to_string(), + is_error: false, + interrupted: false, + created_at: "2026-09-02T00:00:02Z".to_string(), + }, + message("assistant-1", "assistant", "Inspection complete"), + ]; + + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("first synchronization should materialize the unbound episode"); + + assert_eq!(receipt.item_count, complete_items.len()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read native binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + let authoritative = + authoritative_native_items(session_id).expect("round-trip native transcript"); + assert_eq!(authoritative.len(), complete_items.len()); + assert!(authoritative + .iter() + .zip(&complete_items) + .all(|(native, canonical)| native_item_semantically_equal(native, canonical))); + + let session = persistence::get_session(session_id) + .expect("load materialized CLI episode") + .expect("materialized CLI episode exists"); + let cwd = execution_cwd(&session).expect("resolve materialized episode cwd"); + let paths = claude_native_paths(Some(account_id), &cwd, &receipt.native_session_id); + let transcript_before = fs::read(&paths.native_path).expect("read materialized transcript"); + assert_eq!( + fs::read(&paths.runner_path).expect("read runner transcript alias"), + transcript_before + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + let project_index = fs::read_to_string( + paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"), + ) + .expect("read Claude project index"); + assert!(project_index.contains(&receipt.native_session_id)); + + let second_receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("an existing complete native transcript is already synchronized"); + + assert_eq!(second_receipt.native_session_id, receipt.native_session_id); + assert_eq!(second_receipt.item_count, complete_items.len()); + assert_eq!( + fs::read(&paths.native_path).expect("read synchronized transcript"), + transcript_before, + "synchronizing an existing complete transcript must not rewrite provider-native state" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_repairs_a_bound_but_unpublished_claude_intent() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-repair-intent"; + let account_id = "anthropic-native-repair-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + let abandoned_id = "aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb"; + persistence::update_cli_session_id_for_account(session_id, Some(account_id), abandoned_id) + .expect("record incomplete materialization intent"); + + let complete_items = vec![message("repair-user", "user", "continue safely")]; + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items, + ) + .await + .expect("repair incomplete intent"); + + assert_ne!(receipt.native_session_id, abandoned_id); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read repaired binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_rebuilds_claude_binding_when_recorded_worktree_was_removed() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-removed-worktree"; + let account_id = "anthropic-native-removed-worktree"; + let repo_path = sandbox.path().join("repo"); + let removed_worktree = sandbox.path().join("removed-worktree"); + fs::create_dir_all(&repo_path).expect("create fallback repository"); + create_native_claude_session(session_id, account_id, &repo_path); + persistence::update_worktree_info( + session_id, + removed_worktree + .to_str() + .expect("removed worktree path is utf-8"), + "agent/removed-worktree", + "develop", + ) + .expect("record removed worktree"); + + let stale_native_id = "03b9bc8b-1111-4222-8333-bbbbbbbbbbbb"; + let complete_items = vec![ + message("user-1", "user", "Inspect the repository"), + tool_call("call_1", "read_file", r#"{"path":"README.md"}"#), + tool_result("call_1", "read_file", "repository read", false, false), + message("assistant-1", "assistant", "Inspection complete"), + ]; + let stale_paths = claude_native_paths(Some(account_id), &removed_worktree, stale_native_id); + write_native_store_jsonl( + &stale_paths, + &claude_records_with_resume_checkpoint( + stale_native_id, + &removed_worktree, + &complete_items, + ) + .expect("render stale-worktree transcript"), + ) + .expect("publish stale-worktree transcript"); + persistence::update_cli_session_id_for_account( + session_id, + Some(account_id), + stale_native_id, + ) + .expect("record stale-worktree binding"); + + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("rebuild under the runner's fallback cwd"); + + assert_ne!(receipt.native_session_id, stale_native_id); + assert_eq!(receipt.item_count, complete_items.len()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read repaired binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + let repaired_paths = claude_native_paths( + Some(account_id), + &provider_canonical_cwd(repo_path), + &receipt.native_session_id, + ); + assert!(repaired_paths.native_path.is_file()); + assert!(repaired_paths.runner_path.is_file()); + let authoritative = + authoritative_native_items(session_id).expect("round-trip rebuilt transcript"); + assert_eq!(authoritative.len(), complete_items.len()); + assert!(authoritative + .iter() + .zip(&complete_items) + .all(|(native, canonical)| native_item_semantically_equal(native, canonical))); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_leaves_an_empty_cli_episode_unbound() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-empty"; + let account_id = "anthropic-native-sync-empty-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + + let receipt = + synchronize_native_conversation_with_owner(None, session_id.to_string(), Vec::new()) + .await + .expect("an empty canonical prefix is already synchronized"); + + assert_eq!(receipt.item_count, 0); + assert!(receipt.native_session_id.is_empty()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read native binding"), + None + ); + assert!(!app_paths::claude_code_cli_profile_dir(account_id) + .join("projects") + .exists()); + } + + #[test] + fn failed_catalog_refresh_keeps_its_durable_receipt_pending() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-catalog-failure"; + let account_id = "anthropic-native-catalog-failure"; + let native_id = "eeeeeeee-1111-4222-8333-ffffffffffff"; + create_native_claude_session(session_id, account_id, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("publish provider UUID"); + let receipt = + persistence::request_native_catalog_refresh(session_id, Some(account_id), native_id) + .expect("request catalog refresh") + .expect("binding exists"); + let native_path = sandbox + .path() + .join("native") + .join(format!("{native_id}.jsonl")); + let runner_path = sandbox + .path() + .join("runner") + .join(format!("{native_id}.jsonl")); + fs::create_dir_all(native_path.parent().expect("native parent")) + .expect("create native parent"); + fs::create_dir_all(runner_path.parent().expect("runner parent")) + .expect("create runner parent"); + fs::write(&native_path, b"{}\n").expect("write native transcript"); + fs::write(&runner_path, b"{}\n").expect("write runner transcript"); + fs::write( + runner_path + .parent() + .expect("runner parent") + .join("sessions-index.json"), + b"not valid json", + ) + .expect("write invalid provider index"); + + let error = refresh_bound_native_catalog(BoundNativeCatalogRefresh::Claude { + receipt: receipt.clone(), + session_id: session_id.to_string(), + cwd: sandbox.path().to_path_buf(), + native_id: native_id.to_string(), + native_path, + runner_path, + branch: None, + }) + .expect_err("invalid provider index must fail refresh"); + assert!(error.contains("Claude"), "unexpected error: {error}"); + assert_eq!( + persistence::pending_native_catalog_refreshes(8).expect("load pending receipt")[0] + .receipt, + receipt, + "a failed refresh must remain durable for startup retry" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn startup_catalog_repair_is_pending_only_and_idempotent() { + let sandbox = test_env::sandbox(); + let dirty_session_id = "cliagent-native-catalog-startup-dirty"; + let clean_session_id = "cliagent-native-catalog-startup-clean"; + let dirty_account_id = "anthropic-native-catalog-startup-dirty"; + let clean_account_id = "anthropic-native-catalog-startup-clean"; + let items = vec![message("startup-user", "user", "repair native catalog")]; + + for (session_id, account_id) in [ + (dirty_session_id, dirty_account_id), + (clean_session_id, clean_account_id), + ] { + create_native_claude_session(session_id, account_id, sandbox.path()); + synchronize_native_conversation_with_owner(None, session_id.to_string(), items.clone()) + .await + .expect("materialize startup fixture"); + } + let dirty_native_id = + persistence::get_cli_session_id_for_account(dirty_session_id, Some(dirty_account_id)) + .expect("load dirty native binding") + .expect("dirty native binding exists"); + persistence::request_native_catalog_refresh( + dirty_session_id, + Some(dirty_account_id), + &dirty_native_id, + ) + .expect("request startup repair") + .expect("dirty binding exists"); + + assert_eq!( + reconcile_pending_native_catalog_refreshes_on_startup().await, + (1, 0), + "startup visits only the dirty receipt" + ); + assert!(persistence::pending_native_catalog_refreshes(8) + .expect("load pending after repair") + .is_empty()); + assert_eq!( + reconcile_pending_native_catalog_refreshes_on_startup().await, + (0, 0), + "a completed startup repair is idempotent" + ); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_store.rs b/src-tauri/src/agent_sessions/cli/native_store.rs new file mode 100644 index 0000000000..aeb27c6915 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_store.rs @@ -0,0 +1,397 @@ +//! Provider-native transcript filesystem primitives. +//! +//! These operations are deliberately below the materialization coordinator: +//! they own cross-process exclusion and crash-safe replacement of a provider +//! transcript, but know nothing about canonical conversations or bindings. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use uuid::Uuid; + +/// Opaque change token for one provider-native transcript file. +/// +/// This intentionally exposes no path to the frontend. Nanosecond mtime plus +/// byte length is the same filesystem identity used by imported-history scan +/// invalidation, and lets canonical readers detect an external native App +/// append even when the managed Session row itself did not change. +pub(super) fn native_transcript_revision(path: &Path) -> Result { + let (modified_at_ns, size_bytes) = + orgtrack_core::sources::imported_history::paths::file_metadata_signature( + path, + "provider-native transcript", + )?; + Ok(format!("native-file-v1:{modified_at_ns}:{size_bytes}")) +} + +pub(super) struct ClaudeTranscriptGuard { + lock_file: fs::File, +} + +impl Drop for ClaudeTranscriptGuard { + fn drop(&mut self) { + // Closing the descriptor also releases the lock; explicit unlock keeps + // lock ownership obvious to readers and is best effort during Drop. + let _ = self.lock_file.unlock(); + } +} + +/// Serialize every mutation of one Claude native UUID across ORG2 processes. +/// The adjacent lock file is stable even when the transcript inode is replaced. +pub(super) fn lock_claude_transcript(path: &Path) -> Result { + let parent = path.parent().ok_or_else(|| { + format!( + "Claude transcript has no parent directory: {}", + path.display() + ) + })?; + fs::create_dir_all(parent).map_err(|error| { + format!( + "create Claude transcript directory {}: {error}", + parent.display() + ) + })?; + + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("transcript"); + let lock_path = parent.join(format!(".{file_name}.orgii.lock")); + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| { + format!( + "open Claude transcript lock {}: {error}", + lock_path.display() + ) + })?; + lock_file + .lock() + .map_err(|error| format!("lock Claude transcript {}: {error}", lock_path.display()))?; + Ok(ClaudeTranscriptGuard { lock_file }) +} + +#[cfg(windows)] +fn atomic_replace_file(staged: &Path, destination: &Path, label: &str) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let staged_wide = staged + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(staged_wide.as_ptr()), + PCWSTR(destination_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(|error| { + format!( + "commit {label} {} -> {}: {error}", + staged.display(), + destination.display() + ) + }) +} + +#[cfg(not(windows))] +fn atomic_replace_file(staged: &Path, destination: &Path, label: &str) -> Result<(), String> { + fs::rename(staged, destination).map_err(|error| { + format!( + "commit {label} {} -> {}: {error}", + staged.display(), + destination.display() + ) + }) +} + +#[cfg(unix)] +fn sync_parent(path: &Path, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} has no parent: {}", path.display()))?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync {label} directory {}: {error}", parent.display())) +} + +#[cfg(not(unix))] +fn sync_parent(_path: &Path, _label: &str) -> Result<(), String> { + Ok(()) +} + +fn staged_path(destination: &Path, extension: &str) -> PathBuf { + destination.with_extension(format!("{extension}-{}", Uuid::new_v4().simple())) +} + +fn ensure_parent(path: &Path, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("create {label} directory {}: {error}", parent.display())) +} + +fn replace_with_staged_path( + destination: &Path, + staged_extension: &str, + label: &str, + sync_contents: bool, + prepare: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), String> { + ensure_parent(destination, label)?; + let staged = staged_path(destination, staged_extension); + let result = (|| -> Result<(), String> { + prepare(&staged)?; + if sync_contents { + fs::File::open(&staged) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("sync staged {label} {}: {error}", staged.display()))?; + } + atomic_replace_file(&staged, destination, label)?; + sync_parent(destination, label) + })(); + if result.is_err() { + let _ = fs::remove_file(&staged); + } + result +} + +/// Crash-safe whole-file replacement. The caller serializes content into the +/// provided staged file, while this filesystem owner handles directory setup, +/// fsync, atomic replacement, parent sync, and failed-stage cleanup. +pub(super) fn write_file_atomically( + destination: &Path, + staged_extension: &str, + label: &str, + write: impl FnOnce(&mut fs::File) -> Result<(), String>, +) -> Result<(), String> { + replace_with_staged_path(destination, staged_extension, label, true, |staged| { + let mut file = fs::File::create(staged) + .map_err(|error| format!("create staged {label} {}: {error}", staged.display()))?; + write(&mut file) + }) +} + +/// Crash-safe copy into a provider-owned destination. +pub(super) fn copy_file_atomically( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), String> { + replace_with_staged_path(destination, "jsonl.tmp", label, true, |staged| { + fs::copy(source, staged).map(|_| ()).map_err(|error| { + format!( + "copy {label} {} -> {}: {error}", + source.display(), + staged.display() + ) + }) + }) +} + +/// Atomically replace an account-profile runner alias with a link to the +/// provider-native transcript. +pub(super) fn replace_file_link_atomically( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), String> { + if source == destination { + return Ok(()); + } + replace_with_staged_path(destination, "jsonl.link", label, false, |staged| { + #[cfg(unix)] + return std::os::unix::fs::symlink(source, staged).map_err(|error| { + format!( + "link {label} {} -> {}: {error}", + staged.display(), + source.display() + ) + }); + + #[cfg(not(unix))] + fs::hard_link(source, staged).map_err(|error| { + format!( + "link {label} {} -> {}: {error}", + staged.display(), + source.display() + ) + }) + }) +} + +/// Append a serialized JSONL suffix without ever exposing a partially written +/// provider transcript. The caller must hold [`lock_claude_transcript`] from +/// inspection through this commit. +pub(super) fn append_suffix_atomically(path: &Path, suffix: &[u8]) -> Result<(), String> { + replace_with_staged_path(path, "jsonl.tmp", "Claude transcript", true, |staged| { + let mut source = fs::File::open(path) + .map_err(|error| format!("open Claude transcript {}: {error}", path.display()))?; + let source_permissions = source + .metadata() + .map_err(|error| { + format!( + "read Claude transcript metadata {}: {error}", + path.display() + ) + })? + .permissions(); + let mut output = fs::File::create(staged).map_err(|error| { + format!( + "create staged Claude transcript {}: {error}", + staged.display() + ) + })?; + fs::set_permissions(staged, source_permissions).map_err(|error| { + format!( + "preserve Claude transcript permissions on {}: {error}", + staged.display() + ) + })?; + let mut last_byte = None; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|error| format!("read Claude transcript {}: {error}", path.display()))?; + if read == 0 { + break; + } + last_byte = Some(buffer[read - 1]); + output.write_all(&buffer[..read]).map_err(|error| { + format!("copy Claude transcript into {}: {error}", staged.display()) + })?; + } + if last_byte.is_some_and(|byte| byte != b'\n') { + output.write_all(b"\n").map_err(|error| { + format!( + "terminate staged Claude transcript {}: {error}", + staged.display() + ) + })?; + } + output.write_all(suffix).map_err(|error| { + format!( + "append staged Claude transcript {}: {error}", + staged.display() + ) + })?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK_CHILD_PATH: &str = "ORGII_CLAUDE_TRANSCRIPT_LOCK_CHILD_PATH"; + const LOCK_CHILD_READY: &str = "ORGII_CLAUDE_TRANSCRIPT_LOCK_CHILD_READY"; + + #[test] + fn native_revision_changes_when_an_external_app_appends() { + let temp = tempfile::tempdir().expect("temp native transcript root"); + let path = temp.path().join("session.jsonl"); + fs::write(&path, b"{\"type\":\"user\"}\n").expect("seed transcript"); + let before = native_transcript_revision(&path).expect("initial revision"); + + fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open transcript for append") + .write_all(b"{\"type\":\"assistant\"}\n") + .expect("append transcript"); + + let after = native_transcript_revision(&path).expect("appended revision"); + assert_ne!(before, after); + } + + #[test] + fn atomic_suffix_commit_preserves_prefix_and_repairs_missing_newline() { + let temp = tempfile::tempdir().expect("temp Claude transcript root"); + let path = temp.path().join("session.jsonl"); + fs::write(&path, br#"{"type":"user"}"#).expect("seed transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + append_suffix_atomically(&path, b"{\"type\":\"assistant\"}\n") + .expect("append suffix atomically"); + + assert_eq!( + fs::read_to_string(&path).expect("read transcript"), + "{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n" + ); + } + + #[test] + #[ignore = "launched by claude_transcript_mutation_is_locked_across_processes"] + fn claude_transcript_lock_child() { + let Some(path) = std::env::var_os(LOCK_CHILD_PATH).map(std::path::PathBuf::from) else { + return; + }; + let ready = std::path::PathBuf::from( + std::env::var_os(LOCK_CHILD_READY).expect("lock child ready marker"), + ); + fs::write(&ready, b"ready").expect("write child ready marker"); + let _guard = lock_claude_transcript(&path).expect("child lock transcript"); + append_suffix_atomically(&path, b"{\"type\":\"assistant\"}\n") + .expect("child append transcript"); + } + + #[test] + fn claude_transcript_mutation_is_locked_across_processes() { + use std::process::Command; + use std::thread; + use std::time::{Duration, Instant}; + + let temp = tempfile::tempdir().expect("temp Claude transcript root"); + let path = temp.path().join("session.jsonl"); + let ready = temp.path().join("child-ready"); + fs::write(&path, b"{\"type\":\"user\"}\n").expect("seed transcript"); + let guard = lock_claude_transcript(&path).expect("parent lock transcript"); + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_transcript_lock_child") + .arg("--ignored") + .env(LOCK_CHILD_PATH, &path) + .env(LOCK_CHILD_READY, &ready) + .spawn() + .expect("launch transcript lock child"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.exists(), "child must reach transcript lock boundary"); + thread::sleep(Duration::from_millis(100)); + assert_eq!( + fs::read_to_string(&path).expect("read locked transcript"), + "{\"type\":\"user\"}\n" + ); + assert!( + child.try_wait().expect("inspect child").is_none(), + "child must wait for the cross-process lock" + ); + + drop(guard); + assert!(child.wait().expect("wait for child").success()); + assert_eq!( + fs::read_to_string(&path).expect("read committed transcript"), + "{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n" + ); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_transcript.rs b/src-tauri/src/agent_sessions/cli/native_transcript.rs index 5104395ab1..c0dfe1caa3 100644 --- a/src-tauri/src/agent_sessions/cli/native_transcript.rs +++ b/src-tauri/src/agent_sessions/cli/native_transcript.rs @@ -94,6 +94,41 @@ pub fn native_store_key_for_managed_session( Some((binding, cli_session_id)) } +/// Resolve the provider-native transcript currently selected by this managed +/// session's account/profile. Unlike the append-only transcript ledger used +/// for history discovery and deduplication, this is the binding the next CLI +/// turn will actually resume. +pub fn current_native_store_key_for_session( + session: &super::persistence::CodeSession, +) -> Result, String> { + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Ok(None); + } + let Some(agent) = session + .cli_agent_type + .as_deref() + .and_then(ModelType::from_str) + else { + return Ok(None); + }; + let Some(binding) = native_transcript_binding(&agent) else { + return Ok(None); + }; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = + super::persistence::get_cli_session_id_for_account(&session.session_id, account_id) + .map_err(|error| { + format!( + "Failed to read native transcript binding for {}: {error}", + session.session_id + ) + })?; + Ok(native_id.map(|native_id| (binding, native_id))) +} + /// Managed session id → imported-history transcript id, when the session is /// native-mode and a CLI-native id has been bound. Used by cross-provider /// projections (turn metadata, exporter) to route a managed id into the diff --git a/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs b/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs index 6d1cf8da93..95b31c5c1a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs @@ -588,11 +588,50 @@ impl CliAgentParser for ClaudeCodeParser { .or_else(|| data.get("stopReason")) .or_else(|| data.get("subtype")) .and_then(|v| v.as_str()); + // A run that filled its context can report success with a + // structured `terminal_reason` — surface it as a failure so + // the run record classifies the overflow instead of + // treating the truncated answer as a delivered result. + let terminal_reason = data.get("terminal_reason").and_then(|v| v.as_str()); + let result_error = data + .get("result") + .and_then(|v| v.as_str()) + .filter(|text| !text.trim().is_empty()); + // Claude-compatible gateways do not all use Anthropic's + // `prompt_too_long` terminal reason. Some return a generic + // `blocking_limit` while preserving the classifiable provider + // message in `result`. Keep that specific message instead of + // replacing it with the generic terminal code so every + // runtime shares the same context-exhaustion classifier. + let context_exhausted = terminal_reason == Some("prompt_too_long") + || result_error + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message); + let error_message = data + .get("error") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + (is_error || context_exhausted) + .then(|| { + result_error.map(|text| text.chars().take(320).collect::()) + }) + .flatten() + }) + .or_else(|| { + (is_error || context_exhausted) + .then(|| { + terminal_reason + .map(|reason| format!("{{\"terminal_reason\":\"{reason}\"}}")) + }) + .flatten() + }) + .or_else(|| is_error.then(|| stop_reason.map(str::to_string)).flatten()); let mut chunk = ActivityChunk::new(&self.session_id, "session_end", "session_end"); chunk.result = serde_json::json!({ - "success": !is_error, - "error_message": data.get("error").and_then(|v| v.as_str()), + "success": !is_error && !context_exhausted, + "error_message": error_message, "stop_reason": stop_reason, + "terminal_reason": terminal_reason, }); vec![chunk] } diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs index b7fdf431a7..204d57d59a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs @@ -1,11 +1,9 @@ -//! Codex `app-server` JSON-RPC transport (experimental). +//! Codex `app-server` JSON-RPC transport. //! -//! Alternative to the per-turn `codex exec --json` shell-out: spawns +//! Native alternative to the per-turn `codex exec --json` shell-out: spawns //! `codex app-server` (a JSON-RPC-over-stdio server) and drives one turn per -//! managed-session message. Default OFF — enabled only when the codex CLI -//! launch profile carries `"transport": "app-server"` -//! (see `launch_profiles::uses_codex_app_server`). Shell-out stays the -//! fallback whenever the flag is absent. +//! managed-session message. It is the default Codex transport; a launch +//! profile may explicitly select `"transport": "exec"` as a recovery hatch. //! //! ## Verified protocol (codex-cli 0.143.0) //! @@ -17,16 +15,19 @@ //! Client → server requests: //! - `initialize` `{clientInfo: {name, title?, version}}` → `{userAgent, codexHome, ...}`; //! then the client sends the `initialized` notification. -//! - `thread/start` `{cwd?, model?, approvalPolicy?, sandbox?, ...}` → +//! - `thread/start` `{cwd?, model?, developerInstructions?, approvalPolicy?, sandbox?, ...}` → //! `{thread: {id, ...}, model, ...}`. `thread.id` (UUIDv7) is the rollout //! file stem suffix (`CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl`) //! — verified live: a non-ephemeral thread materializes the rollout on //! disk, so native-transcript replay and managed-mirror suffix dedup keep //! working unchanged. -//! - `thread/resume` `{threadId, cwd?, model?, approvalPolicy?, sandbox?}` → -//! same response shape; falls back to `thread/start` here on error. +//! - `thread/resume` `{threadId, cwd?, model?, developerInstructions?, approvalPolicy?, sandbox?}` → +//! same response shape. Resume failures are terminal: silently starting a +//! fresh thread would discard native conversation history. //! - `turn/start` `{threadId, input: [{type:"text",text} | {type:"localImage",path}]}` //! → `{turn: {id, status: "inProgress"}}`. +//! - A user-only context-overflow turn is recovered once with native +//! `thread/rollback` → `thread/compact/start` → the same `turn/start`. //! - `turn/interrupt` `{threadId, turnId}` → `{}`. //! //! Server → client notifications (subset we map): @@ -54,11 +55,14 @@ //! profile's permission mode and surfaced as `approval_response` chunks. use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; use std::sync::{LazyLock, Mutex as StdMutex}; +use std::time::Duration; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{ChildStdin, ChildStdout}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::mpsc; use core_types::activity::ActivityChunk; @@ -68,10 +72,19 @@ use super::normalizer::{normalize_tool_name, unwrap_codex_command}; use super::types::{CliAgentType, TokenUsage}; use crate::agent_sessions::cli::session_runner::launch_profiles::CliPermissionMode; +mod catalog; +pub(crate) use catalog::{archive_thread, register_thread, synchronize_thread}; + /// How long to keep draining after `turn/interrupt` before giving up on a /// graceful `turn/completed`. const INTERRUPT_DRAIN_SECS: u64 = 10; +/// Keep provider-native context recovery bounded. Real large transcripts can +/// take well over a minute to compact even after the provider has accepted the +/// request, so this budget must not race Codex's own successful compactor. The +/// owning conversation turn still has its stricter end-to-end deadline. +const CONTEXT_RECOVERY_TIMEOUT_SECS: u64 = 180; + // ============================================ // Interrupt registry (session_id → signal) // ============================================ @@ -124,13 +137,21 @@ fn interrupt_registered(session_id: &str) -> bool { .contains_key(session_id) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GracefulInterruptOutcome { + NotRunning, + Completed, + TimedOut, +} + /// Ask a running app-server turn to interrupt gracefully and wait (bounded) /// for it to finish so codex can finalize the rollout before the caller -/// kills the process tree. No-op (returns false immediately) when the -/// session has no registered app-server turn. -pub async fn interrupt_session_gracefully(session_id: &str) -> bool { +/// kills the process tree. A timeout is deliberately distinct from success: +/// the runner JSONL may be syntactically valid while its current turn is only +/// partially flushed, so callers must not publish it over the native App copy. +pub async fn interrupt_session_gracefully(session_id: &str) -> GracefulInterruptOutcome { let Some(tx) = interrupt_sender(session_id) else { - return false; + return GracefulInterruptOutcome::NotRunning; }; if tx.try_send(()).is_err() { // Full (already signalled) or closed — either way just wait below. @@ -139,10 +160,13 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { session_id ); } - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + // The transport itself drains for INTERRUPT_DRAIN_SECS. Give its task one + // extra second to unregister after receiving turn/completed. + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(INTERRUPT_DRAIN_SECS + 1); while tokio::time::Instant::now() < deadline { if !interrupt_registered(session_id) { - return true; + return GracefulInterruptOutcome::Completed; } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } @@ -150,7 +174,7 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { "[CodexAppServer] Graceful interrupt window elapsed for {}; caller will kill", session_id ); - true + GracefulInterruptOutcome::TimedOut } // ============================================ @@ -160,14 +184,22 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { /// Per-turn configuration for the app-server transport. pub struct CodexAppServerTurn { pub session_id: String, - pub task: String, + /// Literal user-authored text rendered in the native Codex transcript. + pub user_input: String, + /// ORGII execution/workspace/IDE context carried on Codex's native + /// developer channel. Never copied into `turn/start.input`. + pub developer_instructions: Option, pub working_dir: String, /// Stored codex thread id to resume; `None` starts a fresh thread. pub resume_thread_id: Option, /// Base model name for `thread/start` (already variant-mapped). pub model: Option, pub permission_mode: CliPermissionMode, + /// Secret-bearing MCP/session overrides sent only over JSON-RPC. + pub config: Option, pub image_paths: Vec, + /// Enabled only on a fresh episode rebuilt from canonical SessionEvents. + pub allow_native_context_recovery: bool, } /// Result of a completed app-server turn. @@ -193,6 +225,56 @@ pub(crate) fn thread_permission_params(mode: CliPermissionMode) -> (&'static str } } +/// Build the strict fresh/resume request for one app-server launch. +/// +/// `developerInstructions` is deliberately separate from `baseInstructions`: +/// Codex appends/overrides the caller-owned developer layer while retaining +/// its provider base prompt. The complete current context is sent on every +/// launch, including resume, so a per-launch replacement cannot drop prior +/// ORGII workspace instructions. +pub(crate) fn build_thread_launch_request(turn: &CodexAppServerTurn) -> (&'static str, Value) { + let (approval_policy, sandbox) = thread_permission_params(turn.permission_mode); + let mut params = serde_json::json!({ + "cwd": &turn.working_dir, + "approvalPolicy": approval_policy, + "sandbox": sandbox, + }); + if let Some(ref model) = turn.model { + params["model"] = Value::String(model.clone()); + } + if let Some(ref config) = turn.config { + params["config"] = config.clone(); + } + if let Some(instructions) = turn + .developer_instructions + .as_deref() + .filter(|instructions| !instructions.trim().is_empty()) + { + params["developerInstructions"] = Value::String(instructions.to_string()); + } + if let Some(ref resume_id) = turn.resume_thread_id { + params["threadId"] = Value::String(resume_id.clone()); + ("thread/resume", params) + } else { + ("thread/start", params) + } +} + +/// Build only the native user turn items. Provider context belongs on the +/// thread's developer channel and must never become a `userMessage` item. +pub(crate) fn build_turn_input(turn: &CodexAppServerTurn) -> Vec { + let mut input = vec![serde_json::json!({ + "type": "text", + "text": &turn.user_input, + })]; + input.extend( + turn.image_paths + .iter() + .map(|path| serde_json::json!({"type": "localImage", "path": path})), + ); + input +} + /// Whether an approval request is auto-accepted for this permission mode. /// Only FullPermission auto-accepts (mirroring exec's bypass flag). Manual /// and Plan follow codex default-deny semantics — the denial is surfaced as @@ -224,6 +306,10 @@ pub(crate) struct CodexAppServerEventParser { /// `turn/completed` that reports failure without an error body leaves the /// turn with no message at all, and this is the only thing left to say. last_retry_notice: Option, + /// `thread/rollback` removes history, not filesystem changes. Automatic + /// replay is therefore allowed only before output or tools have started. + replay_unsafe_output_seen: bool, + compaction_marker_emitted: bool, } impl CodexAppServerEventParser { @@ -239,6 +325,8 @@ impl CodexAppServerEventParser { error_deduper: super::BoundedCliErrorDeduper::default(), pending_error_message: None, last_retry_notice: None, + replay_unsafe_output_seen: false, + compaction_marker_emitted: false, } } @@ -262,6 +350,59 @@ impl CodexAppServerEventParser { self.turn_error.as_deref() } + fn completed_turn_error<'a>(&'a self, params: &'a Value) -> Option<&'a str> { + params + .get("turn") + .and_then(|turn| turn.get("error")) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .or(self.pending_error_message.as_deref()) + .or(self.last_retry_notice.as_deref()) + } + + fn should_recover_context_exhaustion(&self, params: &Value) -> bool { + params + .get("turn") + .and_then(|turn| turn.get("status")) + .and_then(Value::as_str) + == Some("failed") + // A provider-observed compaction already advanced this logical + // turn's native history. Never issue ORG2's recovery compact on + // the same turn as well: that would roll twice and can discard + // the first compacted episode's resume boundary. + && !self.compaction_marker_emitted + && !self.replay_unsafe_output_seen + && self + .completed_turn_error(params) + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message) + } + + fn reset_turn_state(&mut self) { + self.turn_id = None; + self.usage = None; + self.turn_status = None; + self.turn_error = None; + self.pending_error_message = None; + self.last_retry_notice = None; + self.error_deduper = super::BoundedCliErrorDeduper::default(); + self.replay_unsafe_output_seen = false; + } + + fn native_compaction_marker(&mut self) -> Vec { + if self.compaction_marker_emitted { + return vec![]; + } + self.compaction_marker_emitted = true; + let mut chunk = + ActivityChunk::new(&self.session_id, "context_compacted", "context_compacted"); + chunk.result = serde_json::json!({ + "success": true, + "native": true, + "provider": "codex", + }); + vec![chunk] + } + /// Record the thread id from a `thread/start` / `thread/resume` response /// and emit the `session_start` chunk (carrying `thread_id` so the /// runner can early-bind the rollout-compatible id). @@ -276,6 +417,32 @@ impl CodexAppServerEventParser { self.emit_session_start() } + /// Publish a provider-native UUID rollover immediately. + /// + /// A context-recovery fork happens inside one app-server transport turn, + /// after the ordinary `session_start` was already emitted. Waiting for + /// finalization to persist the fork id leaves a short but real window in + /// which an immediate follow-up resumes the overflowing source UUID and + /// compacts again. A lifecycle-only session_start chunk reuses the normal + /// CLI binding channel without adding a chat-visible transcript row. + fn on_thread_rebound(&mut self, result: &Value) -> Vec { + let tid = result + .get("thread") + .and_then(|thread| thread.get("id")) + .and_then(Value::as_str); + let Some(tid) = tid else { + return vec![]; + }; + self.thread_id = Some(tid.to_string()); + let mut chunk = ActivityChunk::new(&self.session_id, "session_start", "session_start"); + chunk.result = serde_json::json!({ + "success": true, + "native_rollover": true, + }); + chunk.thread_id = Some(tid.to_string()); + vec![chunk] + } + fn emit_session_start(&mut self) -> Vec { if self.session_start_emitted { return vec![]; @@ -315,6 +482,7 @@ impl CodexAppServerEventParser { "item/started" => self.parse_item(params, false), "item/completed" => self.parse_item(params, true), "item/agentMessage/delta" => { + self.replay_unsafe_output_seen = true; let text = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { return vec![]; @@ -327,6 +495,7 @@ impl CodexAppServerEventParser { vec![chunk] } "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" => { + self.replay_unsafe_output_seen = true; let text = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { return vec![]; @@ -340,6 +509,7 @@ impl CodexAppServerEventParser { vec![chunk] } "turn/plan/updated" => { + self.replay_unsafe_output_seen = true; let todos: Vec = params .get("plan") .and_then(|v| v.as_array()) @@ -394,6 +564,7 @@ impl CodexAppServerEventParser { } vec![] } + "thread/compacted" => self.native_compaction_marker(), "turn/completed" => { let status = params .get("turn") @@ -496,6 +667,10 @@ impl CodexAppServerEventParser { .and_then(|v| v.as_str()) .filter(|id| !id.is_empty()); + if !matches!(v2_type, "userMessage" | "hookPrompt" | "contextCompaction") { + self.replay_unsafe_output_seen = true; + } + match item_type { // The runner already emits the user bubble; codex echoes it back. "userMessage" | "hookPrompt" => vec![], @@ -624,6 +799,12 @@ impl CodexAppServerEventParser { Self::stamp_tool_call_identity(&mut chunk, call_id); vec![chunk] } + "contextCompaction" => { + if !completed { + return vec![]; + } + self.native_compaction_marker() + } other => { tracing::debug!("[CodexAppServer] Ignoring item type: {}", other); vec![] @@ -718,6 +899,117 @@ async fn read_message( } } +/// Reusable app-server RPC owner for non-turn operations such as native +/// thread registration. It shares the exact JSON-RPC codec used by managed +/// turns; callers no longer spawn a second blocking protocol client. +pub(crate) struct CodexAppServerRpcClient { + _child: Child, + stdin: ChildStdin, + reader: BufReader, + buffer: String, + next_id: u64, +} + +impl CodexAppServerRpcClient { + pub(crate) async fn launch( + command_path: &Path, + codex_home: &Path, + cwd: &Path, + ) -> Result { + std::fs::create_dir_all(codex_home).map_err(|error| { + format!( + "create Codex native profile {}: {error}", + codex_home.display() + ) + })?; + // This client publishes the *Codex App* catalog. It is intentionally + // independent from the CLI runner launch profile: that profile may + // select an older shell-installed binary or inject a session-scoped + // provider environment that the native App cannot read later. + let mut command = Command::new(command_path); + command + .arg("app-server") + .env("CODEX_HOME", codex_home) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = command.spawn().map_err(|error| { + format!( + "start Codex app-server {} for native profile {}: {error}", + command_path.display(), + codex_home.display() + ) + })?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "Codex app-server stdin was not piped".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Codex app-server stdout was not piped".to_string())?; + let mut client = Self { + _child: child, + stdin, + reader: BufReader::new(stdout), + buffer: String::new(), + next_id: 0, + }; + client + .request( + "initialize", + serde_json::json!({ + "clientInfo": { + "name": "orgii", + "title": "ORGII", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": {"experimentalApi": true} + }), + Duration::from_secs(20), + ) + .await?; + client.notify("initialized").await?; + Ok(client) + } + + pub(crate) async fn notify(&mut self, method: &str) -> Result<(), String> { + rpc_notify(&mut self.stdin, method).await + } + + pub(crate) async fn request( + &mut self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { + self.next_id += 1; + let request_id = self.next_id; + rpc_send(&mut self.stdin, request_id, method, params).await?; + tokio::time::timeout(timeout, async { + loop { + let response = read_message(&mut self.reader, &mut self.buffer).await?; + if response.get("id").and_then(Value::as_u64) != Some(request_id) + || response.get("method").is_some() + { + continue; + } + if let Some(error) = response.get("error") { + return Err(format!("Codex app-server {method} failed: {error}")); + } + return response + .get("result") + .cloned() + .ok_or_else(|| format!("Codex app-server {method} returned no result")); + } + }) + .await + .map_err(|_| format!("Codex app-server {method} reached its request deadline"))? + } +} + /// Await the response for `request_id`, feeding any interleaved /// notifications / server requests through the parser. async fn await_response( @@ -833,6 +1125,159 @@ async fn emit_approval_chunk( let _ = chunk_tx.send(chunk).await; } +struct ContextRecovery<'a> { + stdin: &'a mut ChildStdin, + reader: &'a mut BufReader, + buf: &'a mut String, + request_id: &'a mut u64, + parser: &'a mut CodexAppServerEventParser, + chunk_tx: &'a mpsc::Sender, + mode: CliPermissionMode, +} + +impl ContextRecovery<'_> { + async fn rollback_failed_turn(&mut self, thread_id: &str) -> Result<(), String> { + *self.request_id += 1; + rpc_send( + self.stdin, + *self.request_id, + "thread/rollback", + serde_json::json!({"threadId": thread_id, "numTurns": 1}), + ) + .await?; + match await_response( + self.reader, + self.stdin, + self.buf, + *self.request_id, + self.parser, + self.chunk_tx, + self.mode, + ) + .await? + { + Ok(_) => Ok(()), + Err(error) => Err(format!("app-server thread/rollback error: {error}")), + } + } + + /// Run Codex's provider-native compactor and drain its internal turn + /// without exposing that turn as the user's terminal `session_end`. + async fn compact_native_thread(&mut self, thread_id: &str) -> Result<(), String> { + *self.request_id += 1; + let compact_request_id = *self.request_id; + rpc_send( + self.stdin, + compact_request_id, + "thread/compact/start", + serde_json::json!({"threadId": thread_id}), + ) + .await?; + + let mut response_received = false; + let mut turn_completed = false; + while !response_received || !turn_completed { + let message = read_message(self.reader, self.buf).await?; + if message.get("id").and_then(Value::as_u64) == Some(compact_request_id) + && message.get("method").is_none() + { + if let Some(error) = message.get("error") { + return Err(format!("app-server thread/compact/start error: {error}")); + } + response_received = true; + continue; + } + + if message.get("method").and_then(Value::as_str) == Some("turn/completed") { + let params = message.get("params").cloned().unwrap_or(Value::Null); + // Record the compactor's terminal state, but suppress its + // session_end: the original user turn is still running. + let _ = self.parser.handle_notification("turn/completed", ¶ms); + if self.parser.turn_status() != Some("completed") { + return Err(format!( + "Codex native compaction ended with status {}: {}", + self.parser.turn_status().unwrap_or("unknown"), + self.parser.turn_error().unwrap_or("no error details") + )); + } + turn_completed = true; + continue; + } + + dispatch_server_message(&message, self.stdin, self.parser, self.chunk_tx, self.mode) + .await; + } + Ok(()) + } + + /// Fork the compacted provider thread before replaying the user's turn. + /// + /// Codex keeps cumulative window accounting on the source UUID. Resuming + /// that UUID after a successful compact can therefore auto-compact again + /// at the beginning of every later turn even though the replacement + /// history is small. `thread/fork` is Codex's native rollover primitive: + /// it carries the structured compacted history (including encrypted + /// provider state) into a fresh UUID without rendering it into a prompt. + async fn fork_compacted_thread(&mut self, thread_id: &str) -> Result { + *self.request_id += 1; + rpc_send( + self.stdin, + *self.request_id, + "thread/fork", + serde_json::json!({"threadId": thread_id}), + ) + .await?; + let result = match await_response( + self.reader, + self.stdin, + self.buf, + *self.request_id, + self.parser, + self.chunk_tx, + self.mode, + ) + .await? + { + Ok(result) => result, + Err(error) => return Err(format!("app-server thread/fork error: {error}")), + }; + for chunk in self.parser.on_thread_rebound(&result) { + let _ = self.chunk_tx.send(chunk).await; + } + self.parser + .thread_id() + .filter(|forked| *forked != thread_id) + .map(str::to_string) + .ok_or_else(|| "app-server thread/fork returned no fresh thread id".to_string()) + } + + async fn run(&mut self, thread_id: &str) -> Result { + self.rollback_failed_turn(thread_id).await?; + self.parser.reset_turn_state(); + self.compact_native_thread(thread_id).await?; + self.parser.reset_turn_state(); + self.fork_compacted_thread(thread_id).await + } +} + +async fn start_turn( + stdin: &mut ChildStdin, + request_id: &mut u64, + thread_id: &str, + input: &[Value], +) -> Result { + *request_id += 1; + let turn_request_id = *request_id; + rpc_send( + stdin, + turn_request_id, + "turn/start", + serde_json::json!({"threadId": thread_id, "input": input}), + ) + .await?; + Ok(turn_request_id) +} + // ============================================ // Protocol flow // ============================================ @@ -895,69 +1340,29 @@ pub async fn run_app_server_turn( } rpc_notify(&mut stdin, "initialized").await?; - // ── Step 2: thread/resume (with fallback) or thread/start ── - let (approval_policy, sandbox) = thread_permission_params(mode); - let mut thread_params = serde_json::json!({ - "cwd": &turn.working_dir, - "approvalPolicy": approval_policy, - "sandbox": sandbox, - }); - if let Some(ref model) = turn.model { - thread_params["model"] = Value::String(model.clone()); - } - - let mut thread_result: Option = None; - if let Some(ref resume_id) = turn.resume_thread_id { - let mut resume_params = thread_params.clone(); - resume_params["threadId"] = Value::String(resume_id.clone()); - request_id += 1; - rpc_send(&mut stdin, request_id, "thread/resume", resume_params).await?; - match await_response( - &mut reader, - &mut stdin, - &mut buf, - request_id, - &mut parser, - &chunk_tx, - mode, - ) - .await? - { - Ok(result) => thread_result = Some(result), - Err(err) => { - tracing::warn!( - "[CodexAppServer] thread/resume failed ({}); starting fresh thread", - err - ); - } - } - } - let thread_result = match thread_result { - Some(result) => result, - None => { - request_id += 1; - rpc_send(&mut stdin, request_id, "thread/start", thread_params).await?; - match await_response( - &mut reader, - &mut stdin, - &mut buf, - request_id, - &mut parser, - &chunk_tx, - mode, - ) - .await? - { - Ok(result) => result, - Err(err) => return Err(format!("app-server thread/start error: {}", err)), - } - } + // ── Step 2: strict thread/resume or explicit fresh thread/start ── + let (thread_method, thread_params) = build_thread_launch_request(&turn); + request_id += 1; + rpc_send(&mut stdin, request_id, thread_method, thread_params).await?; + let thread_result = match await_response( + &mut reader, + &mut stdin, + &mut buf, + request_id, + &mut parser, + &chunk_tx, + mode, + ) + .await? + { + Ok(result) => result, + Err(err) => return Err(format!("app-server {thread_method} error: {err}")), }; for chunk in parser.on_thread_response(&thread_result) { let _ = chunk_tx.send(chunk).await; } - let thread_id = parser + let mut thread_id = parser .thread_id() .ok_or_else(|| "app-server: thread response carried no thread id".to_string())? .to_string(); @@ -973,24 +1378,14 @@ pub async fn run_app_server_turn( ); // ── Step 3: turn/start ── - let mut input: Vec = vec![serde_json::json!({"type": "text", "text": &turn.task})]; - for path in &turn.image_paths { - input.push(serde_json::json!({"type": "localImage", "path": path})); - } - request_id += 1; - let turn_req_id = request_id; - rpc_send( - &mut stdin, - turn_req_id, - "turn/start", - serde_json::json!({"threadId": &thread_id, "input": input}), - ) - .await?; + let input = build_turn_input(&turn); + let mut turn_req_id = start_turn(&mut stdin, &mut request_id, &thread_id, &input).await?; // ── Step 4: notification loop until turn/completed ── let mut turn_started = false; let mut interrupt_sent = false; let mut interrupt_deadline: Option = None; + let mut context_recovery_attempted = false; loop { // After turn/interrupt is sent, drain with a bounded deadline so a @@ -1048,7 +1443,77 @@ pub async fn run_app_server_turn( continue; } - dispatch_server_message(&msg, &mut stdin, &mut parser, &chunk_tx, mode).await; + if msg.get("method").and_then(Value::as_str) == Some("turn/completed") { + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let original_terminal_error = parser.completed_turn_error(¶ms).map(str::to_string); + let should_recover = turn.allow_native_context_recovery + && !context_recovery_attempted + && parser.should_recover_context_exhaustion(¶ms); + if should_recover { + context_recovery_attempted = true; + tracing::info!( + thread_id, + "Codex context exhausted before output; applying native compaction" + ); + let recovery = { + let mut recovery = ContextRecovery { + stdin: &mut stdin, + reader: &mut reader, + buf: &mut buf, + request_id: &mut request_id, + parser: &mut parser, + chunk_tx: &chunk_tx, + mode, + }; + tokio::time::timeout( + tokio::time::Duration::from_secs(CONTEXT_RECOVERY_TIMEOUT_SECS), + recovery.run(&thread_id), + ) + .await + }; + match recovery { + Ok(Ok(forked_thread_id)) => { + thread_id = forked_thread_id; + turn_req_id = + start_turn(&mut stdin, &mut request_id, &thread_id, &input).await?; + turn_started = false; + interrupt_deadline = None; + tracing::info!( + thread_id, + "Codex native compaction rolled to a fresh thread; retrying original turn" + ); + continue; + } + Ok(Err(error)) => tracing::warn!( + thread_id, + error = %error, + "Codex native context recovery failed" + ), + Err(_) => tracing::warn!( + thread_id, + timeout_secs = CONTEXT_RECOVERY_TIMEOUT_SECS, + "Codex native context recovery timed out" + ), + } + // Recovery maintenance turns reset parser-local state. If the + // authoritative failed completion carried its error through a + // preceding `error` notification rather than `turn.error`, + // restore it before parsing that original terminal event. + parser.pending_error_message = original_terminal_error; + } + // Successful recovery deliberately suppresses the overflowing + // attempt's terminal event. If rollback/compact/fork fails, parse + // the original authoritative completion only now. Recovery resets + // parser turn state while driving its maintenance turns; parsing + // up front used to lose the failed status and leave this loop + // waiting forever after a maintenance error. + let terminal_chunks = parser.handle_notification("turn/completed", ¶ms); + for chunk in terminal_chunks { + let _ = chunk_tx.send(chunk).await; + } + } else { + dispatch_server_message(&msg, &mut stdin, &mut parser, &chunk_tx, mode).await; + } if parser.turn_status().is_some() { break; diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs new file mode 100644 index 0000000000..d5312d093a --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs @@ -0,0 +1,581 @@ +//! Supported Codex app-server registration for provider-native continuations. +//! +//! A rollout file alone is not a Codex App conversation: the App reads its +//! catalog through the app-server, and intentionally hides catalog rows that +//! have never acquired a user turn. This module owns the supported JSON-RPC +//! path used to create/resume the real profile and to inject canonical raw +//! response items. It never reads or writes Codex's private SQLite state. + +use std::collections::{HashMap, HashSet}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{json, Value}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CODEX_NATIVE_MODEL_PROVIDER: &str = "openai"; + +fn native_codex_app_server_command() -> PathBuf { + let mut preferred = Vec::new(); + if let Some(explicit) = std::env::var_os("ORGII_NATIVE_CODEX_APP_BINARY") { + preferred.push(PathBuf::from(explicit)); + } + + #[cfg(target_os = "macos")] + { + preferred.extend([ + PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]); + if let Some(home) = dirs::home_dir() { + preferred.push(home.join("Applications/ChatGPT.app/Contents/Resources/codex")); + preferred.push(home.join("Applications/Codex.app/Contents/Resources/codex")); + } + } + + // A machine without the desktop App can still use a compatible Codex CLI + // native store. Keep that fallback explicit and free of runner profile + // env/argument overrides. + PathBuf::from( + integrations::cli_binary_resolver::resolve_cli_binary_command_preferring( + integrations::cli_binary_resolver::CliBinaryId::Codex, + preferred, + ), + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexCatalogEntry { + pub id: String, + pub path: PathBuf, + pub title: String, + pub cwd: PathBuf, + pub model_provider: String, +} +fn with_rpc( + codex_home: &Path, + cwd: &Path, + operation: impl FnOnce( + &tokio::runtime::Runtime, + &mut super::CodexAppServerRpcClient, + ) -> Result, +) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("create Codex app-server runtime: {error}"))?; + let command = native_codex_app_server_command(); + let mut client = runtime.block_on(super::CodexAppServerRpcClient::launch( + &command, codex_home, cwd, + ))?; + operation(&runtime, &mut client) +} + +fn request( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + method: &str, + params: Value, +) -> Result { + runtime.block_on(client.request(method, params, REQUEST_TIMEOUT)) +} +fn entry_from_thread(thread: &Value) -> Result { + let id = thread["id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Codex app-server thread has no id".to_string())?; + let path = thread["path"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no rollout path"))?; + let title = thread["name"] + .as_str() + .or_else(|| thread["title"].as_str()) + .unwrap_or_default(); + let cwd = thread["cwd"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no cwd"))?; + let model_provider = thread["modelProvider"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no model provider"))?; + Ok(CodexCatalogEntry { + id: id.to_string(), + path: PathBuf::from(path), + title: title.to_string(), + cwd: PathBuf::from(cwd), + model_provider: model_provider.to_string(), + }) +} + +fn effective_model_provider( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + cwd: &Path, +) -> Result { + let result = request( + runtime, + client, + "config/read", + json!({"cwd": cwd, "includeLayers": false}), + )?; + Ok(allowlisted_native_model_provider(&result)) +} + +fn allowlisted_native_model_provider(config_result: &Value) -> String { + let configured = config_result["config"]["model_provider"] + .as_str() + .filter(|value| !value.is_empty()) + // `openai` is Codex's built-in provider when config.toml omits an + // explicit provider. Keep that default local to the native profile; + // never borrow the ORGII runner profile's custom provider here. + .unwrap_or(CODEX_NATIVE_MODEL_PROVIDER); + if configured != CODEX_NATIVE_MODEL_PROVIDER { + tracing::warn!( + configured_provider = configured, + native_provider = CODEX_NATIVE_MODEL_PROVIDER, + "ignoring non-native Codex runner provider while publishing App catalog" + ); + } + // Native App artifacts must only reference providers the real Codex home + // can always resolve. Session-scoped ORGII compatible providers belong to + // the isolated runner profile and must never leak into this catalog. + CODEX_NATIVE_MODEL_PROVIDER.to_string() +} + +fn validate_target_profile( + entry: CodexCatalogEntry, + expected_id: &str, + expected_cwd: &Path, + expected_title: &str, + expected_provider: &str, +) -> Result { + if entry.id != expected_id + || !paths_have_same_identity(&entry.cwd, expected_cwd) + || entry.title != expected_title + || entry.model_provider != expected_provider + { + return Err(format!( + "Codex native profile mismatch: expected id={expected_id} cwd={} title={expected_title:?} provider={expected_provider:?}, got id={} cwd={} title={:?} provider={:?}", + expected_cwd.display(), + entry.id, + entry.cwd.display(), + entry.title, + entry.model_provider + )); + } + Ok(entry) +} + +fn paths_have_same_identity(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn read_thread( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, +) -> Result { + let result = request( + runtime, + client, + "thread/read", + // Catalog validation only needs id/path/name/cwd/provider metadata. + // Loading every turn here makes a runtime switch O(full transcript) + // for exactly the large conversations this adapter must support. + json!({"threadId": thread_id, "includeTurns": false}), + )?; + entry_from_thread(&result["thread"]) +} + +fn set_thread_name( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, + title: &str, +) -> Result<(), String> { + request( + runtime, + client, + "thread/name/set", + json!({"threadId": thread_id, "name": title}), + )?; + Ok(()) +} + +fn inject_items( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, + items: &[Value], +) -> Result<(), String> { + if items.is_empty() { + return Ok(()); + } + request( + runtime, + client, + "thread/inject_items", + json!({"threadId": thread_id, "items": items}), + )?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SuffixApplication { + Missing, + AlreadyApplied, +} + +fn response_item_identity(item: &Value) -> Option { + let item_type = item["type"].as_str()?; + match item_type { + "message" => item["id"] + .as_str() + .filter(|value| !value.is_empty()) + .map(|id| format!("{item_type}:{id}")), + "function_call" | "function_call_output" => item["call_id"] + .as_str() + .filter(|value| !value.is_empty()) + .map(|call_id| format!("{item_type}:{call_id}")), + _ => None, + } +} + +fn inspect_suffix_application( + path: &Path, + expected_items: &[Value], +) -> Result { + if expected_items.is_empty() { + return Ok(SuffixApplication::AlreadyApplied); + } + let mut expected = HashMap::with_capacity(expected_items.len()); + for item in expected_items { + let identity = response_item_identity(item).ok_or_else(|| { + format!( + "Codex native suffix item has no stable identity: type={:?}", + item["type"].as_str() + ) + })?; + if expected.insert(identity.clone(), item.clone()).is_some() { + return Err(format!( + "Codex native suffix contains duplicate stable identity {identity}" + )); + } + } + + let file = std::fs::File::open(path) + .map_err(|error| format!("open Codex rollout {}: {error}", path.display()))?; + let mut found = HashSet::with_capacity(expected.len()); + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Codex rollout {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Codex rollout {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if record["type"] != "response_item" { + continue; + } + let Some(identity) = response_item_identity(&record["payload"]) else { + continue; + }; + if let Some(expected_item) = expected.get(&identity) { + if &record["payload"] != expected_item { + return Err(format!( + "Codex rollout {} contains stable suffix identity {identity} with conflicting content", + path.display() + )); + } + if !found.insert(identity.clone()) { + return Err(format!( + "Codex rollout {} contains duplicate stable suffix identity {identity}", + path.display() + )); + } + } + } + + if found.is_empty() { + Ok(SuffixApplication::Missing) + } else if found.len() == expected.len() { + Ok(SuffixApplication::AlreadyApplied) + } else { + Err(format!( + "Codex rollout {} contains {} of {} stable suffix items; refusing a mixed retry", + path.display(), + found.len(), + expected.len() + )) + } +} + +pub(crate) fn register_thread( + codex_home: &Path, + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/start", + json!({ + "cwd": cwd, + "modelProvider": model_provider, + "ephemeral": false, + "historyMode": "legacy", + "experimentalRawEvents": false + }), + )?; + let started_id = result["thread"]["id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Codex app-server thread/start returned no thread id".to_string())? + .to_string(); + let registered = (|| -> Result { + set_thread_name(runtime, client, &started_id, title)?; + let registered = read_thread(runtime, client, &started_id)?; + let registered = + validate_target_profile(registered, &started_id, cwd, title, &model_provider)?; + // Injection is deliberately last. Once this request succeeds there + // are no later fallible validation steps that could make a caller + // retry and duplicate the same canonical suffix. + inject_items(runtime, client, &started_id, items)?; + Ok(registered) + })(); + if registered.is_err() { + let _ = request( + runtime, + client, + "thread/archive", + json!({"threadId": &started_id}), + ); + } + registered + }) +} + +pub(crate) fn synchronize_thread( + codex_home: &Path, + path: &Path, + expected_id: &str, + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + // Inspect the durable rollout before any app-server mutation. A timed-out + // `thread/inject_items` may have committed even when ORGII lost the reply; + // retries must therefore prove all-missing or all-applied, never inject a + // mixed/unknown suffix blindly. + let suffix_application = inspect_suffix_application(path, items)?; + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/resume", + json!({ + "threadId": expected_id, + "path": path, + "cwd": cwd, + "modelProvider": model_provider + }), + )?; + let resumed = entry_from_thread(&result["thread"])?; + if resumed.id != expected_id { + return Err(format!( + "Codex resumed the wrong native thread: expected {expected_id}, got {}", + resumed.id + )); + } + set_thread_name(runtime, client, expected_id, title)?; + let synchronized = read_thread(runtime, client, expected_id)?; + let synchronized = + validate_target_profile(synchronized, expected_id, cwd, title, &model_provider)?; + // Keep injection as the terminal mutation. If its response is lost, the + // next call re-inspects the durable rollout before deciding to inject. + if suffix_application == SuffixApplication::Missing { + inject_items(runtime, client, expected_id, items)?; + } + Ok(synchronized) + }) +} + +pub(crate) fn archive_thread( + codex_home: &Path, + path: &Path, + expected_id: &str, + cwd: &Path, +) -> Result<(), String> { + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/resume", + json!({ + "threadId": expected_id, + "path": path, + "cwd": cwd, + "modelProvider": model_provider + }), + )?; + let resumed = entry_from_thread(&result["thread"])?; + if resumed.id != expected_id { + return Err(format!( + "refusing to archive Codex thread {} while rolling back {expected_id}", + resumed.id + )); + } + request( + runtime, + client, + "thread/archive", + json!({"threadId": expected_id}), + )?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_thread_catalog_shape() { + let entry = entry_from_thread(&json!({ + "id": "thread-1", + "path": "/tmp/rollout-thread-1.jsonl", + "name": "Native title", + "cwd": "/tmp/repo", + "modelProvider": "openai" + })) + .expect("catalog entry"); + assert_eq!(entry.id, "thread-1"); + assert_eq!(entry.title, "Native title"); + assert_eq!(entry.cwd, PathBuf::from("/tmp/repo")); + assert_eq!(entry.model_provider, "openai"); + } + + #[test] + fn rejects_catalog_rows_without_provider_identity() { + let error = entry_from_thread(&json!({"cwd": "/tmp/repo"})) + .expect_err("missing identity must fail"); + assert!(error.contains("no id")); + } + + #[test] + fn rejects_runner_provider_identity_in_native_profile() { + let entry = CodexCatalogEntry { + id: "thread-1".to_string(), + path: PathBuf::from("/tmp/rollout-thread-1.jsonl"), + title: "Native title".to_string(), + cwd: PathBuf::from("/tmp/repo"), + model_provider: "orgii_compatible".to_string(), + }; + let error = validate_target_profile( + entry, + "thread-1", + Path::new("/tmp/repo"), + "Native title", + "openai", + ) + .expect_err("runner-only provider must not enter the native catalog"); + assert!(error.contains("orgii_compatible")); + assert!(error.contains("openai")); + } + + #[test] + fn native_catalog_provider_is_a_builtin_allowlisted_identity() { + assert_eq!( + allowlisted_native_model_provider(&json!({ + "config": {"model_provider": "orgii_compatible"} + })), + "openai" + ); + assert_eq!( + allowlisted_native_model_provider(&json!({"config": {}})), + "openai" + ); + } + + #[test] + fn suffix_inspection_distinguishes_missing_applied_and_mixed() { + let temp = tempfile::tempdir().expect("temp Codex rollout root"); + let path = temp.path().join("rollout.jsonl"); + let expected = vec![ + json!({"type": "message", "id": "message-1"}), + json!({"type": "function_call", "call_id": "call-1"}), + ]; + let rollout = |items: &[Value]| { + items + .iter() + .map(|payload| json!({"type": "response_item", "payload": payload}).to_string()) + .collect::>() + .join("\n") + }; + + std::fs::write( + &path, + rollout(&[json!({"type": "message", "id": "unrelated"})]), + ) + .expect("write missing suffix fixture"); + assert_eq!( + inspect_suffix_application(&path, &expected).expect("inspect missing suffix"), + SuffixApplication::Missing + ); + + std::fs::write(&path, rollout(&expected[..1])).expect("write mixed suffix fixture"); + assert!(inspect_suffix_application(&path, &expected).is_err()); + + std::fs::write(&path, rollout(&expected)).expect("write applied suffix fixture"); + assert_eq!( + inspect_suffix_application(&path, &expected).expect("inspect applied suffix"), + SuffixApplication::AlreadyApplied + ); + } + + #[cfg(unix)] + #[test] + fn accepts_filesystem_equivalent_catalog_cwd() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp native catalog root"); + let canonical = temp.path().join("canonical-workspace"); + let alias = temp.path().join("workspace-alias"); + std::fs::create_dir(&canonical).expect("canonical workspace"); + symlink(&canonical, &alias).expect("workspace alias"); + let entry = CodexCatalogEntry { + id: "thread-1".to_string(), + path: temp.path().join("rollout-thread-1.jsonl"), + title: "Native title".to_string(), + cwd: alias, + model_provider: "openai".to_string(), + }; + + validate_target_profile(entry, "thread-1", &canonical, "Native title", "openai") + .expect("filesystem-equivalent cwd must preserve native identity"); + } +} diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs index dcdf9fece8..8fc76c624a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs @@ -5,7 +5,10 @@ use serde_json::{json, Value}; -use super::{approval_auto_accept, thread_permission_params, CodexAppServerEventParser}; +use super::{ + approval_auto_accept, build_thread_launch_request, build_turn_input, thread_permission_params, + CodexAppServerEventParser, CodexAppServerTurn, +}; use crate::agent_sessions::cli::session_runner::launch_profiles::CliPermissionMode; const SESSION_ID: &str = "test-session"; @@ -49,6 +52,20 @@ fn thread_response_captures_id_and_emits_session_start_once() { assert!(dup.is_empty()); } +#[test] +fn native_thread_rebind_emits_fresh_id_after_initial_session_start() { + let mut p = parser(); + let _ = p.on_thread_response(&json!({"thread": {"id": "source-thread"}})); + + let chunks = p.on_thread_rebound(&json!({"thread": {"id": "forked-thread"}})); + + assert_eq!(p.thread_id(), Some("forked-thread")); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].action_type, "session_start"); + assert_eq!(chunks[0].thread_id.as_deref(), Some("forked-thread")); + assert_eq!(chunks[0].result["native_rollover"], true); +} + #[test] fn turn_started_captures_turn_id_without_chunks() { let mut p = parser(); @@ -381,6 +398,124 @@ fn failed_turn_emits_unsuccessful_session_end_with_error() { assert_eq!(p.turn_error(), Some("stream disconnected")); } +#[test] +fn context_overflow_is_recoverable_only_before_output_or_tools() { + let overflow = json!({"threadId": "t", "turn": { + "id": "u", "items": [], "status": "failed", + "error": {"message": "Codex ran out of room in the model's context window."}, + }}); + + let clean = parser(); + assert!(clean.should_recover_context_exhaustion(&overflow)); + assert!(!clean.should_recover_context_exhaustion(&json!({ + "turn": { + "status": "completed", + "error": {"message": "Codex ran out of room in the model's context window."} + } + }))); + assert!(!clean.should_recover_context_exhaustion(&json!({ + "turn": { + "status": "failed", + "error": {"message": "connection refused"} + } + }))); + + let mut with_output = parser(); + let chunks = notif( + &mut with_output, + "item/agentMessage/delta", + json!({"delta": "partial", "itemId": "msg_1"}), + ); + assert_eq!(chunks.len(), 1); + assert!(!with_output.should_recover_context_exhaustion(&overflow)); + + let mut with_tool = parser(); + let _ = notif( + &mut with_tool, + "item/started", + json!({"item": { + "type": "commandExecution", "id": "call_1", + "command": "touch changed", "cwd": "/repo", "status": "inProgress", + }}), + ); + assert!(!with_tool.should_recover_context_exhaustion(&overflow)); +} + +#[test] +fn turn_reset_preserves_thread_identity_and_clears_failed_attempt_state() { + let mut p = parser(); + let _ = p.on_thread_response(&json!({"thread": {"id": "thread-1"}})); + let _ = notif( + &mut p, + "turn/started", + json!({"turn": {"id": "turn-1", "status": "inProgress"}}), + ); + let _ = notif( + &mut p, + "error", + json!({"error": {"message": "Prompt is too long"}, "willRetry": false}), + ); + let _ = notif( + &mut p, + "turn/completed", + json!({"turn": {"id": "turn-1", "status": "failed"}}), + ); + assert_eq!(p.turn_status(), Some("failed")); + + p.reset_turn_state(); + + assert_eq!(p.thread_id(), Some("thread-1")); + assert_eq!(p.turn_id(), None); + assert_eq!(p.turn_status(), None); + assert_eq!(p.turn_error(), None); + assert!(p.usage().is_none()); +} + +#[test] +fn failed_context_recovery_restores_error_from_preceding_notification() { + let mut p = parser(); + let _ = notif( + &mut p, + "error", + json!({ + "error": {"message": "Codex ran out of room in the model's context window."}, + "willRetry": false + }), + ); + let completion = json!({"turn": {"id": "turn-1", "status": "failed"}}); + let original_error = p.completed_turn_error(&completion).map(str::to_string); + + // Native recovery drives maintenance turns and resets this transient + // parser state before it can report a failure of its own. + p.reset_turn_state(); + p.pending_error_message = original_error; + let chunks = notif(&mut p, "turn/completed", completion); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].result["success"], false); + assert_eq!( + chunks[0].result["error_message"], + "Codex ran out of room in the model's context window." + ); +} + +#[test] +fn native_compaction_notifications_emit_one_deduplicated_marker() { + let mut p = parser(); + let item = notif( + &mut p, + "item/completed", + json!({"item": {"type": "contextCompaction", "id": "compact-1"}}), + ); + assert_eq!(item.len(), 1); + assert_eq!(item[0].action_type, "context_compacted"); + assert_eq!(item[0].result["native"], true); + assert_eq!(item[0].result["provider"], "codex"); + + let legacy = notif(&mut p, "thread/compacted", json!({"threadId": "t"})); + assert!(legacy.is_empty()); +} + #[test] fn interrupted_turn_records_status() { let mut p = parser(); @@ -547,6 +682,80 @@ fn only_full_permission_auto_accepts_approvals() { assert!(!approval_auto_accept(CliPermissionMode::Plan)); } +fn native_turn( + user_input: &str, + developer_instructions: &str, + resume_thread_id: Option<&str>, +) -> CodexAppServerTurn { + CodexAppServerTurn { + session_id: SESSION_ID.to_string(), + user_input: user_input.to_string(), + developer_instructions: Some(developer_instructions.to_string()), + working_dir: "/workspace".to_string(), + resume_thread_id: resume_thread_id.map(str::to_string), + model: Some("gpt-5.6-sol".to_string()), + permission_mode: CliPermissionMode::Manual, + config: Some(json!({"mcp_servers": {"orgii": {"enabled": true}}})), + image_paths: vec!["/tmp/native-image.png".to_string()], + allow_native_context_recovery: false, + } +} + +#[test] +fn fresh_thread_keeps_agent_context_out_of_native_user_input() { + let developer_context = concat!( + "build\n\n", + "focused file" + ); + let turn = native_turn("Literal visible user text", developer_context, None); + + let (method, params) = build_thread_launch_request(&turn); + assert_eq!(method, "thread/start"); + assert_eq!(params["developerInstructions"], developer_context); + assert!(params.get("baseInstructions").is_none()); + + let input = build_turn_input(&turn); + assert_eq!( + input[0], + json!({"type": "text", "text": "Literal visible user text"}) + ); + assert_eq!( + input[1], + json!({"type": "localImage", "path": "/tmp/native-image.png"}) + ); + let visible_payload = serde_json::to_string(&input).expect("serialize turn input"); + assert!(!visible_payload.contains("")); +} + +#[test] +fn resumed_thread_receives_the_updated_developer_context() { + let first = native_turn("first", "WORKSPACE_CONTEXT_V1", None); + let (_, first_params) = build_thread_launch_request(&first); + assert_eq!( + first_params["developerInstructions"], + "WORKSPACE_CONTEXT_V1" + ); + + let resumed = native_turn( + "second literal user turn", + "WORKSPACE_CONTEXT_V2\nlatest", + Some("native-codex-thread"), + ); + let (method, params) = build_thread_launch_request(&resumed); + assert_eq!(method, "thread/resume"); + assert_eq!(params["threadId"], "native-codex-thread"); + assert_eq!( + params["developerInstructions"], + "WORKSPACE_CONTEXT_V2\nlatest" + ); + assert!(params.get("baseInstructions").is_none()); + assert_eq!( + build_turn_input(&resumed)[0], + json!({"type": "text", "text": "second literal user turn"}) + ); +} + // ─── live smoke (opt-in) ─── /// End-to-end smoke against a real `codex app-server` process. Requires the @@ -556,7 +765,7 @@ fn only_full_permission_auto_accepts_approvals() { #[tokio::test] #[ignore = "spawns real codex app-server; needs codex auth + network"] async fn live_smoke_trivial_turn() { - use super::{run_app_server_turn, CodexAppServerTurn}; + use super::run_app_server_turn; use std::process::Stdio; let mut child = match tokio::process::Command::new("codex") @@ -578,12 +787,15 @@ async fn live_smoke_trivial_turn() { let turn = CodexAppServerTurn { session_id: SESSION_ID.to_string(), - task: "Reply with exactly: pong".to_string(), + user_input: "Reply with exactly: pong".to_string(), + developer_instructions: None, working_dir: std::env::temp_dir().to_string_lossy().to_string(), resume_thread_id: None, model: None, permission_mode: CliPermissionMode::Plan, + config: None, image_paths: vec![], + allow_native_context_recovery: false, }; let protocol = diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs index de92565b5d..b63282a316 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs @@ -387,3 +387,68 @@ mod tests { assert_eq!(chunks[0].result["stop_reason"], "end_turn"); } } + +#[cfg(test)] +mod claude_terminal_reason_tests { + use crate::agent_sessions::cli::parsers::claude_code::ClaudeCodeParser; + use crate::agent_sessions::cli::parsers::CliAgentParser; + + #[test] + fn prompt_too_long_false_success_is_demoted_to_a_failed_session_end() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"prompt_too_long","result":"","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!(terminal.result["terminal_reason"], "prompt_too_long"); + let message = terminal.result["error_message"] + .as_str() + .expect("overflow carries a classifiable message"); + assert!( + app_utils::runtime_errors::is_context_exhausted_message(message), + "{message}" + ); + } + + #[test] + fn errored_result_without_error_field_falls_back_to_result_text() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Prompt is too long and cannot be compacted further.","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!( + terminal.result["error_message"], + "Prompt is too long and cannot be compacted further." + ); + } + + #[test] + fn gateway_blocking_limit_keeps_the_classifiable_prompt_error() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"terminal_reason":"blocking_limit","result":"Prompt is too long","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!(terminal.result["terminal_reason"], "blocking_limit"); + let message = terminal.result["error_message"] + .as_str() + .expect("gateway overflow keeps its provider message"); + assert_eq!(message, "Prompt is too long"); + assert!(app_utils::runtime_errors::is_context_exhausted_message( + message + )); + } +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs b/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs index 8945c6b673..8ff29e30a0 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs @@ -41,6 +41,47 @@ fn create_test_session(session_id: &str, account_id: &str) { .expect("create test CLI session"); } +#[test] +fn model_account_switch_waits_for_a_concurrent_writer() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-model-switch-contention"; + create_test_session(session_id, "account-a"); + let conn = database::db::get_connection().expect("sandbox database"); + let writer = database::db::begin_immediate(&conn).expect("hold another writer"); + writer + .execute( + "UPDATE code_sessions SET cli_session_id = 'native-latest' WHERE session_id = ?1", + [session_id], + ) + .expect("stage native identity update"); + + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = + update_model_and_account(session_id, Some("claude-opus-4-7"), Some("account-a")); + done_tx.send(result).unwrap(); + }); + started_rx.recv().unwrap(); + // A deferred transaction reads the old snapshot and fails its write + // upgrade immediately. An immediate transaction waits before reading. + let early = done_rx.recv_timeout(std::time::Duration::from_millis(200)); + writer.commit().expect("release writer"); + let result = match early { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("model switch finishes after writer commits"), + Err(error) => panic!("model switch worker disconnected: {error}"), + }; + worker.join().unwrap(); + assert!(result.expect("concurrent model switch must not fail with SQLITE_BUSY")); + let session = get_session(session_id).unwrap().unwrap(); + assert_eq!(session.model.as_deref(), Some("claude-opus-4-7")); + assert_eq!(session.cli_session_id.as_deref(), Some("native-latest")); +} + #[test] fn status_snapshots_return_only_requested_existing_sessions() { let _sandbox = test_env::sandbox(); @@ -269,6 +310,83 @@ fn old_process_resume_id_does_not_overwrite_current_account_column() { ); } +#[test] +fn staged_native_binding_is_recoverable_but_not_yet_published() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-resume-staged-binding"; + create_test_session(session_id, "account-a"); + + assert!( + stage_cli_session_id_for_account(session_id, Some("account-a"), "native-a-staged") + .expect("stage native materialization") + ); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-a")) + .expect("load staged binding") + .as_deref(), + Some("native-a-staged") + ); + assert!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load unpublished ledger") + .is_empty(), + "an unpublished materialization must not become durable transcript history" + ); + + assert!( + update_cli_session_id_for_account(session_id, Some("account-a"), "native-a-staged") + .expect("publish staged materialization") + ); + assert_eq!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load published ledger"), + vec!["native-a-staged"] + ); +} + +#[test] +fn abandoning_one_staged_binding_preserves_other_account_resume_state() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-resume-targeted-stage-abort"; + create_test_session(session_id, "account-a"); + update_cli_session_id_for_account(session_id, Some("account-a"), "native-a-published") + .expect("publish account A binding"); + update_model_and_account(session_id, Some("claude-sonnet-4-6"), Some("account-b")) + .expect("switch to account B"); + stage_cli_session_id_for_account(session_id, Some("account-b"), "native-b-staged") + .expect("stage account B binding"); + + assert!(clear_staged_cli_session_id_for_account( + session_id, + Some("account-b"), + "native-b-staged" + ) + .expect("abort account B stage")); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-b")) + .expect("load account B binding"), + None + ); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-a")) + .expect("load account A binding") + .as_deref(), + Some("native-a-published") + ); + assert_eq!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load published ledger"), + vec!["native-a-published"] + ); + assert_eq!( + get_session(session_id) + .expect("load session") + .expect("session exists") + .cli_session_id, + None + ); +} + #[test] fn clearing_cli_resume_state_removes_all_account_scoped_resume_state() { let _sandbox = test_env::sandbox(); @@ -446,3 +564,78 @@ fn late_resume_id_write_after_delete_does_not_create_orphan_state() { None ); } + +#[test] +fn native_catalog_receipt_uses_revision_cas_and_pending_only_reads() { + let _sandbox = test_env::sandbox(); + let dirty_session_id = "cli-native-catalog-dirty"; + let clean_session_id = "cli-native-catalog-clean"; + create_test_session(dirty_session_id, "account-a"); + create_test_session(clean_session_id, "account-a"); + update_cli_session_id_for_account(dirty_session_id, Some("account-a"), "native-dirty") + .expect("publish dirty binding"); + update_cli_session_id_for_account(clean_session_id, Some("account-a"), "native-clean") + .expect("publish clean binding"); + + let first = request_native_catalog_refresh(dirty_session_id, Some("account-a"), "native-dirty") + .expect("request first catalog revision") + .expect("binding still exists"); + let second = + request_native_catalog_refresh(dirty_session_id, Some("account-a"), "native-dirty") + .expect("request second catalog revision") + .expect("binding still exists"); + assert_eq!(first.requested_revision, 1); + assert_eq!(second.requested_revision, 2); + + assert!( + !acknowledge_native_catalog_refresh(&first).expect("reject stale catalog receipt"), + "an older worker must not clear a newer terminal request" + ); + let pending = pending_native_catalog_refreshes(8).expect("load dirty receipts"); + assert_eq!( + pending.len(), + 1, + "clean bindings must not enter startup repair" + ); + assert_eq!(pending[0].receipt, second); + assert_eq!(pending[0].source, "claude_code"); + + assert!(acknowledge_native_catalog_refresh(&second).expect("ack current revision")); + assert!(pending_native_catalog_refreshes(8) + .expect("reload dirty receipts") + .is_empty()); + assert!( + !acknowledge_native_catalog_refresh(&second).expect("repeat acknowledgement"), + "acknowledgement is idempotent" + ); +} + +#[test] +fn replacing_native_binding_resets_catalog_revisions() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-native-catalog-binding-replaced"; + create_test_session(session_id, "account-a"); + update_cli_session_id_for_account(session_id, Some("account-a"), "native-old") + .expect("publish old binding"); + request_native_catalog_refresh(session_id, Some("account-a"), "native-old") + .expect("request old binding refresh") + .expect("old binding exists"); + + update_cli_session_id_for_account(session_id, Some("account-a"), "native-new") + .expect("replace native binding"); + assert!(pending_native_catalog_refreshes(8) + .expect("load pending after binding replacement") + .is_empty()); + assert!( + request_native_catalog_refresh(session_id, Some("account-a"), "native-old") + .expect("request stale native id") + .is_none() + ); + assert_eq!( + request_native_catalog_refresh(session_id, Some("account-a"), "native-new") + .expect("request new native id") + .expect("new binding exists") + .requested_revision, + 1 + ); +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs index 39817e3e15..1cadeeb29b 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs @@ -29,8 +29,11 @@ pub use read::{ status_snapshots, }; pub use resume_state::{ - clear_cli_resume_state, get_cli_session_id_for_account, get_history_mutation, - update_cli_session_id, update_cli_session_id_for_account, + acknowledge_native_catalog_refresh, clear_cli_resume_state, + clear_staged_cli_session_id_for_account, get_cli_session_id_for_account, get_history_mutation, + pending_native_catalog_refreshes, request_native_catalog_refresh, + stage_cli_session_id_for_account, update_cli_session_id, update_cli_session_id_for_account, + NativeCatalogRefreshReceipt, PendingNativeCatalogRefresh, }; pub use transcript_source::{ latest_native_transcript_id, native_transcript_ids_newest_first, session_persists_chunks, diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs index dfa454bd33..851a8c42a7 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs @@ -1,10 +1,12 @@ //! Insert path for new CLI code-session rows, including the wire-typo //! guards and the frozen transcript-source decision. -use rusqlite::{params, Result as SqliteResult}; +use std::time::Duration; + +use rusqlite::{params, ErrorCode, Result as SqliteResult}; use agent_core::session::AgentExecMode; -use database::db::get_connection; +use database::db::{get_connection, with_sessions_writer}; use crate::agent_sessions::cli::native_transcript; use crate::agent_sessions::cli::persistence::types::{CodeSession, CreateCodeSessionParams}; @@ -16,12 +18,58 @@ use crate::agent_sessions::cli::types::{ use super::read::get_session; use super::shared::{now_iso, sync_orgtrack_mirror}; +const CREATE_SESSION_WRITE_MAX_ATTEMPTS: u32 = 3; +const CREATE_SESSION_WRITE_RETRY_BASE_MS: u64 = 50; + +fn is_transient_sqlite_writer_contention(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(inner, _) + if matches!(inner.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) + ) +} + +/// Serialize session creation with the existing sessions.db write owner and +/// retain a small cross-process fallback for SQLITE_BUSY/SQLITE_LOCKED. +/// +/// `get_connection` belongs inside the attempt: its schema/PRAGMA setup can +/// itself encounter a writer held by another ORG2 process. Sleep happens after +/// releasing the in-process mutex so a stale external lock cannot block this +/// process's healthy writers between attempts. +fn with_create_session_write_retry( + mut operation: impl FnMut() -> SqliteResult, + mut sleep: impl FnMut(Duration), +) -> SqliteResult { + for attempt in 0..CREATE_SESSION_WRITE_MAX_ATTEMPTS { + match with_sessions_writer(&mut operation) { + Ok(value) => return Ok(value), + Err(error) + if is_transient_sqlite_writer_contention(&error) + && attempt + 1 < CREATE_SESSION_WRITE_MAX_ATTEMPTS => + { + let delay = Duration::from_millis( + CREATE_SESSION_WRITE_RETRY_BASE_MS.saturating_mul(1_u64 << attempt), + ); + tracing::debug!( + "[CodeSession] create write contention on attempt {}/{}: {} — retrying in {}ms", + attempt + 1, + CREATE_SESSION_WRITE_MAX_ATTEMPTS, + error, + delay.as_millis() + ); + sleep(delay); + } + Err(error) => return Err(error), + } + } + unreachable!("CREATE_SESSION_WRITE_MAX_ATTEMPTS is non-zero") +} + /// Create a new code session. Returns the session ID. pub fn create_session( session_id: &str, params: &CreateCodeSessionParams, ) -> SqliteResult { - let conn = get_connection()?; let ts = now_iso(); let name = params .name @@ -95,28 +143,90 @@ pub fn create_session( .map(|_| native_transcript::TRANSCRIPT_SOURCE_NATIVE) .unwrap_or(native_transcript::TRANSCRIPT_SOURCE_CHUNKS); - conn.execute( - "INSERT INTO code_sessions - (session_id, name, status, flow, runner, cli_agent_type, model, tier, - account_id, repo_path, branch, proxy_token, proxy_url, hosted_token, - proxy_session_id, background, key_source, additional_directories, - parent_session_id, org_member_id, org_id, project_id, project_name, - project_slug, work_item_id, agent_role, created_at, updated_at, - transcript_source, product_mode, agent_exec_mode, agent_definition_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32)", - params![ - session_id, name, SessionStatus::Pending.as_ref(), flow, runner, params.cli_agent_type, - params.model, params.tier, params.account_id, - params.repo_path, params.branch, params.proxy_token, params.proxy_url, - params.hosted_token, params.proxy_session_id, background, key_source_str, - additional_dirs_json, params.parent_session_id, params.org_member_id, - org_id, params.project_id, params.project_name, params.project_slug, - params.work_item_id, params.agent_role, ts, ts, transcript_source, - product_mode, AgentExecMode::Build.as_str(), params.agent_definition_id, - ], + with_create_session_write_retry( + || { + let conn = get_connection()?; + conn.execute( + "INSERT INTO code_sessions + (session_id, name, status, flow, runner, cli_agent_type, model, tier, + account_id, repo_path, branch, proxy_token, proxy_url, hosted_token, + proxy_session_id, background, key_source, additional_directories, + parent_session_id, org_member_id, org_id, project_id, project_name, + project_slug, work_item_id, agent_role, created_at, updated_at, + transcript_source, product_mode, agent_exec_mode, agent_definition_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32)", + params![ + session_id, name, SessionStatus::Pending.as_ref(), flow, runner, + params.cli_agent_type, params.model, params.tier, params.account_id, + params.repo_path, params.branch, params.proxy_token, params.proxy_url, + params.hosted_token, params.proxy_session_id, background, key_source_str, + additional_dirs_json, params.parent_session_id, params.org_member_id, org_id, + params.project_id, params.project_name, params.project_slug, + params.work_item_id, params.agent_role, ts, ts, transcript_source, + product_mode, AgentExecMode::Build.as_str(), params.agent_definition_id, + ], + )?; + Ok(()) + }, + std::thread::sleep, )?; let session = get_session(session_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; sync_orgtrack_mirror(session_id); Ok(session) } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use rusqlite::ffi; + + use super::*; + + fn sqlite_failure(code: i32) -> rusqlite::Error { + rusqlite::Error::SqliteFailure(ffi::Error::new(code), None) + } + + #[test] + fn create_write_retries_only_transient_sqlite_contention() { + let attempts = Cell::new(0_u32); + let mut delays = Vec::new(); + let result = with_create_session_write_retry( + || { + let attempt = attempts.get(); + attempts.set(attempt + 1); + if attempt == 0 { + Err(sqlite_failure(ffi::SQLITE_BUSY)) + } else if attempt == 1 { + Err(sqlite_failure(ffi::SQLITE_LOCKED)) + } else { + Ok("created") + } + }, + |delay| delays.push(delay), + ); + + assert_eq!( + result.expect("transient contention should recover"), + "created" + ); + assert_eq!(attempts.get(), 3); + assert_eq!( + delays, + vec![Duration::from_millis(50), Duration::from_millis(100)] + ); + + let attempts = Cell::new(0_u32); + let error = with_create_session_write_retry( + || { + attempts.set(attempts.get() + 1); + Err::<(), _>(sqlite_failure(ffi::SQLITE_CONSTRAINT)) + }, + |_| panic!("permanent errors must not back off"), + ) + .expect_err("constraint failure must remain terminal"); + assert!(!is_transient_sqlite_writer_contention(&error)); + assert_eq!(attempts.get(), 1); + } +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs index 7642126623..7bdfedbe06 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs @@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension, Result as SqliteResult}; use agent_core::session::AgentExecMode; -use database::db::get_connection; +use database::db::{begin_immediate, get_connection, sessions_writer_guard}; use super::resume_state::mapped_cli_session_id_for_account_with_conn; use super::shared::{now_iso, sync_orgtrack_mirror}; @@ -35,8 +35,11 @@ pub fn update_model_and_account( model: Option<&str>, account_id: Option<&str>, ) -> SqliteResult { + // Acquire write ownership before reading the identity. A deferred + // read transaction cannot wait when upgrading a stale WAL snapshot. + let writer_guard = sessions_writer_guard(); let conn = get_connection()?; - let tx = conn.unchecked_transaction()?; + let tx = begin_immediate(&conn)?; let current: Option<(Option, Option)> = tx .query_row( "SELECT account_id, cli_session_id FROM code_sessions WHERE session_id = ?1", @@ -82,6 +85,7 @@ pub fn update_model_and_account( (None, None) => 0, }; tx.commit()?; + drop(writer_guard); if affected > 0 { sync_orgtrack_mirror(session_id); } diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs index 29e655c1d1..03a22c2089 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs @@ -26,32 +26,35 @@ fn resume_profile_key(account_id: Option<&str>) -> String { .to_string() } -/// Store the CLI agent's own session/conversation ID for resume support. -/// Internal bookkeeping — does not bump `updated_at`. -pub fn update_cli_session_id(session_id: &str, cli_session_id: &str) -> SqliteResult { - let conn = get_connection()?; - let account_id: Option = conn - .query_row( - "SELECT account_id FROM code_sessions WHERE session_id = ?1", - params![session_id], - |row| row.get(0), - ) - .optional()?; - update_cli_session_id_for_account(session_id, account_id.as_deref(), cli_session_id) +const SESSION_PROFILE_KEY: &str = "__session__"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeCatalogRefreshReceipt { + pub session_id: String, + pub profile_key: String, + pub cli_session_id: String, + pub requested_revision: i64, } -/// Store a CLI native session ID under the account/profile that launched the -/// process, not whatever account the session row may point at when the process -/// exits. This prevents a slow old process from writing account A's native -/// conversation id into account B's resume slot after a mid-turn switch. -pub fn update_cli_session_id_for_account( +impl NativeCatalogRefreshReceipt { + pub fn account_id(&self) -> Option<&str> { + (self.profile_key != SESSION_PROFILE_KEY).then_some(self.profile_key.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingNativeCatalogRefresh { + pub receipt: NativeCatalogRefreshReceipt, + pub source: String, +} + +fn stage_cli_session_id_for_account_with_tx( + tx: &rusqlite::Transaction<'_>, session_id: &str, account_id: Option<&str>, cli_session_id: &str, ) -> SqliteResult { - let conn = get_connection()?; let profile_key = resume_profile_key(account_id); - let tx = conn.unchecked_transaction()?; let affected = tx.execute( "UPDATE code_sessions SET cli_session_id = CASE @@ -62,7 +65,6 @@ pub fn update_cli_session_id_for_account( params![session_id, cli_session_id, account_id], )?; if affected == 0 { - tx.commit()?; return Ok(false); } tx.execute( @@ -71,12 +73,95 @@ pub fn update_cli_session_id_for_account( VALUES (?1, ?2, ?3, ?4) ON CONFLICT(session_id, profile_key) DO UPDATE SET cli_session_id = excluded.cli_session_id, + native_catalog_requested_revision = CASE + WHEN code_session_cli_resume_state.cli_session_id = excluded.cli_session_id + THEN code_session_cli_resume_state.native_catalog_requested_revision + ELSE 0 + END, + native_catalog_applied_revision = CASE + WHEN code_session_cli_resume_state.cli_session_id = excluded.cli_session_id + THEN code_session_cli_resume_state.native_catalog_applied_revision + ELSE 0 + END, updated_at = excluded.updated_at", params![session_id, profile_key, cli_session_id, now_iso()], )?; + Ok(true) +} + +/// Record a recoverable materialization intent in the existing resume binding +/// owner. Unlike publication, staging deliberately does not add the UUID to +/// the append-only native-transcript ledger until its artifact is durable. +pub fn stage_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + let staged = + stage_cli_session_id_for_account_with_tx(&tx, session_id, account_id, cli_session_id)?; + tx.commit()?; + Ok(staged) +} + +/// Remove one unpublished materialization intent without invalidating resume +/// bindings for other accounts/providers attached to the canonical session. +pub fn clear_staged_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + expected_cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + let profile_key = resume_profile_key(account_id); + let removed = tx.execute( + "DELETE FROM code_session_cli_resume_state + WHERE session_id = ?1 AND profile_key = ?2 AND cli_session_id = ?3", + params![session_id, profile_key, expected_cli_session_id], + )?; + tx.execute( + "UPDATE code_sessions + SET cli_session_id = NULL + WHERE session_id = ?1 AND account_id IS ?2 AND cli_session_id = ?3", + params![session_id, account_id, expected_cli_session_id], + )?; + tx.commit()?; + Ok(removed > 0) +} + +/// Store the CLI agent's own session/conversation ID for resume support. +/// Internal bookkeeping — does not bump `updated_at`. +pub fn update_cli_session_id(session_id: &str, cli_session_id: &str) -> SqliteResult { + let conn = get_connection()?; + let account_id: Option = conn + .query_row( + "SELECT account_id FROM code_sessions WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + update_cli_session_id_for_account(session_id, account_id.as_deref(), cli_session_id) +} + +/// Store a CLI native session ID under the account/profile that launched the +/// process, not whatever account the session row may point at when the process +/// exits. This prevents a slow old process from writing account A's native +/// conversation id into account B's resume slot after a mid-turn switch. +pub fn update_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + if !stage_cli_session_id_for_account_with_tx(&tx, session_id, account_id, cli_session_id)? { + tx.commit()?; + return Ok(false); + } // Append-only binding ledger (native-transcript replay + sidebar dedup // keep recognizing superseded forks after account switch / message edit). - let binding = conn + let binding = tx .query_row( "SELECT COALESCE(cli_agent_type, platform) FROM code_sessions WHERE session_id = ?1", params![session_id], @@ -129,6 +214,96 @@ pub fn get_cli_session_id_for_account( .optional() } +/// Mark the exact provider-native binding as needing a catalog/index refresh. +/// The returned generation is acknowledged only after the native App update +/// succeeds; a newer terminal convergence makes an older worker's receipt +/// stale instead of allowing it to clear the newer request. +pub fn request_native_catalog_refresh( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + let profile_key = resume_profile_key(account_id); + conn.query_row( + "UPDATE code_session_cli_resume_state + SET native_catalog_requested_revision = native_catalog_requested_revision + 1 + WHERE session_id = ?1 AND profile_key = ?2 AND cli_session_id = ?3 + RETURNING session_id, profile_key, cli_session_id, + native_catalog_requested_revision", + params![session_id, profile_key, cli_session_id], + |row| { + Ok(NativeCatalogRefreshReceipt { + session_id: row.get(0)?, + profile_key: row.get(1)?, + cli_session_id: row.get(2)?, + requested_revision: row.get(3)?, + }) + }, + ) + .optional() +} + +/// Compare-and-set acknowledgement for one completed catalog refresh. +/// Returning false means the binding changed, a newer generation was +/// requested, or this receipt was already applied; in every case the caller +/// must not overwrite the current binding's durability state. +pub fn acknowledge_native_catalog_refresh( + receipt: &NativeCatalogRefreshReceipt, +) -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE code_session_cli_resume_state + SET native_catalog_applied_revision = ?4 + WHERE session_id = ?1 + AND profile_key = ?2 + AND cli_session_id = ?3 + AND native_catalog_requested_revision = ?4 + AND native_catalog_applied_revision < ?4", + params![ + receipt.session_id, + receipt.profile_key, + receipt.cli_session_id, + receipt.requested_revision, + ], + )?; + Ok(affected > 0) +} + +/// Load only dirty native-App catalog receipts. The startup repair path is +/// intentionally bounded and never scans provider transcripts or all sessions. +pub fn pending_native_catalog_refreshes( + limit: usize, +) -> SqliteResult> { + let conn = get_connection()?; + let mut statement = conn.prepare( + "SELECT r.session_id, r.profile_key, r.cli_session_id, + r.native_catalog_requested_revision, + l.source + FROM code_session_cli_resume_state r + JOIN code_session_native_transcript_ids l + ON l.session_id = r.session_id + AND l.source_session_id = r.cli_session_id + AND l.source IN ('claude_code', 'codex_app') + WHERE r.native_catalog_requested_revision + > r.native_catalog_applied_revision + ORDER BY r.updated_at ASC, r.session_id ASC, r.profile_key ASC + LIMIT ?1", + )?; + let rows = statement.query_map(params![i64::try_from(limit).unwrap_or(i64::MAX)], |row| { + Ok(PendingNativeCatalogRefresh { + receipt: NativeCatalogRefreshReceipt { + session_id: row.get(0)?, + profile_key: row.get(1)?, + cli_session_id: row.get(2)?, + requested_revision: row.get(3)?, + }, + source: row.get(4)?, + }) + })?; + rows.collect() +} + pub(in crate::agent_sessions::cli::persistence) fn bump_history_mutation_with_tx( tx: &rusqlite::Transaction<'_>, session_id: &str, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/command.rs b/src-tauri/src/agent_sessions/cli/session_runner/command.rs index 18e238e69a..67d4760217 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/command.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/command.rs @@ -5,6 +5,7 @@ use crate::agent_sessions::cli::parsers::codex::CodexParser; use crate::agent_sessions::cli::parsers::cursor::CursorParser; use crate::agent_sessions::cli::parsers::plain_text::PlainTextParser; use crate::agent_sessions::cli::parsers::CliAgentParser; +use crate::agent_sessions::cli::session_runner::input_assembly::CliTurnEnvelope; use crate::agent_sessions::cli::session_runner::launch_profiles::{ defaults_for_agent, static_args_to_vec, uses_codex_app_server, ResolvedCliLaunchProfile, }; @@ -15,7 +16,7 @@ pub(super) struct CliCommandBuildRequest<'a> { pub agent: &'a ModelType, pub launch_profile: &'a ResolvedCliLaunchProfile, pub model: Option<&'a str>, - pub task: &'a str, + pub turn: &'a CliTurnEnvelope, pub resume_id: Option<&'a str>, pub api_key: Option<&'a str>, pub endpoint: Option<&'a str>, @@ -33,7 +34,7 @@ pub(super) fn build_command_with_launch_profile( agent, launch_profile, model, - task, + turn, resume_id, api_key, endpoint, @@ -59,13 +60,8 @@ pub(super) fn build_command_with_launch_profile( // travel over JSON-RPC (`thread/start` / `turn/start` params) instead. if uses_codex_app_server(agent, launch_profile) { let mut cmd = vec![launch_profile.command.clone()]; - // `app-server` does not expose `--profile` itself, but Codex's global - // option does. Keep it before the subcommand so the per-run MCP layer - // is loaded without putting its secret-bearing values in argv. - if let Some(profile) = codex_mcp_profile { - cmd.push("--profile".into()); - cmd.push(profile.into()); - } + // app-server rejects `--profile`; per-run MCP config travels in the + // thread JSON-RPC params so secrets never appear in argv. cmd.push("app-server".into()); if let Some(m) = model { let codex_model = map_codex_model_variant(m); @@ -117,7 +113,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push(ws.into()); } cmd.push("-p".into()); - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::ClaudeCode => { @@ -151,8 +147,15 @@ pub(super) fn build_command_with_launch_profile( cmd.push("--add-dir".into()); cmd.push(dir.clone()); } + if let Some(provider_context) = turn.provider_context() { + // Claude Code appends this to its native system prompt. Keep + // `-p` reserved for the literal user-authored message so the + // provider JSONL and Claude app render the correct user row. + cmd.push("--append-system-prompt".into()); + cmd.push(provider_context); + } cmd.push("-p".into()); - cmd.push(task.into()); + cmd.push(turn.user_text().into()); cmd } ModelType::Codex => { @@ -186,7 +189,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push("--add-dir".into()); cmd.push(dir.clone()); } - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::Copilot => { @@ -201,8 +204,6 @@ pub(super) fn build_command_with_launch_profile( } cmd } - // ACP agents: the task, cwd, and resume id all travel over JSON-RPC - // (`session/new` / `session/prompt`), never on the argv. ModelType::Kiro | ModelType::OpenCode | ModelType::DeepseekHarness => cmd, ModelType::Antigravity => { if let Some(rid) = resume_id { @@ -221,7 +222,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push(dir.clone()); } cmd.push("--print".into()); - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::KimiCli @@ -247,8 +248,9 @@ pub(super) fn build_command_with_launch_profile( | ModelType::Pi | ModelType::QoderCli | ModelType::TraeCli => { - if !task.is_empty() { - cmd.push(task.into()); + let merged_task = turn.merged_for_legacy(); + if !merged_task.is_empty() { + cmd.push(merged_task); } cmd } @@ -419,17 +421,18 @@ fn strip_cli_date_suffix(model: &str) -> &str { /// Create the appropriate parser for a CLI agent type. /// -/// ACP agents (Copilot, Kiro, OpenCode, DeepSeek Harness) use bidirectional -/// JSON-RPC instead of CliAgentParser. API key providers are not CLI agents -/// and should never reach this function. +/// Copilot uses ACP (bidirectional JSON-RPC) instead of CliAgentParser. +/// API key providers are not CLI agents and should never reach this function. pub(super) fn create_parser(agent: &ModelType, session_id: &str) -> Box { match agent { ModelType::CursorCli => Box::new(CursorParser::new(session_id)), ModelType::ClaudeCode => Box::new(ClaudeCodeParser::new(session_id)), ModelType::Codex => Box::new(CodexParser::new(session_id)), - ModelType::Antigravity => Box::new(PlainTextParser::new(session_id)), + ModelType::Antigravity | ModelType::DeepseekHarness => { + Box::new(PlainTextParser::new(session_id)) + } other => panic!( - "ModelType::{:?} does not use CliAgentParser (Copilot/Kiro/OpenCode/DeepseekHarness use ACP; API providers are not CLI agents)", + "ModelType::{:?} does not use CliAgentParser (Copilot/Kiro/OpenCode use ACP; API providers are not CLI agents)", other ), } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index 452e42d7c1..1b06304de8 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -19,7 +19,7 @@ const OPENCODE_ZENMUX_PROVIDER_ID: &str = "zenmux"; const OPENCODE_ZENMUX_BASE_URL: &str = "https://zenmux.ai/api/v1"; const OPENCODE_DEFAULT_ZENMUX_MODEL: &str = "deepseek/deepseek-chat"; const ATLASCLOUD_PROVIDER_ID: &str = "atlascloud"; -const CODEX_COMPATIBLE_PROVIDER_ID: &str = "orgii_compatible"; +pub(crate) const CODEX_COMPATIBLE_PROVIDER_ID: &str = "orgii_compatible"; const ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; const ATLASCLOUD_DEFAULT_MODEL: &str = "zai-org/glm-5.1"; const OPENCODE_ZENMUX_MODEL_IDS: &[&str] = &[ @@ -248,7 +248,7 @@ fn codex_compatible_base_url(selected_key: &ModelKey) -> Result /// auth, WebSocket support and Codex's own retry defaults. Routing them through /// the synthetic compatible-provider table downgrades all four for no benefit. /// A custom endpoint override is the one case that still needs the table. -pub(super) fn codex_needs_compatible_profile(selected_key: &ModelKey) -> bool { +pub(crate) fn codex_needs_compatible_profile(selected_key: &ModelKey) -> bool { if selected_key.model_type != ModelType::OpenaiApi { return true; } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index f62a25ed66..7dffd61d5f 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -1,10 +1,11 @@ //! Post-run finalization for CLI sessions. //! //! Everything after the spawn/stdout loop returns: compute the final session -//! status, extract a user-facing error message from stderr, persist status, -//! clear live-status, requeue Agent Org member turns, broadcast the terminal -//! event, commit worktree changes, fetch Cursor usage, and tear down the MITM -//! proxy / proxy token / synced skill files. Extracted from +//! status, extract a user-facing error message from stderr, flush and publish +//! provider-native history, persist status, clear live-status, requeue Agent +//! Org member turns, broadcast the terminal event, commit worktree changes, +//! fetch Cursor usage, and tear down the MITM proxy / proxy token / synced +//! skill files. Extracted from //! `session::run_session`. use std::collections::{HashSet, VecDeque}; @@ -334,7 +335,7 @@ pub(super) async fn finalize_session_run( }) .await; - let raw_final_status = if cli_plan_approval_gate_reached { + let mut raw_final_status = if cli_plan_approval_gate_reached { SessionStatus::Completed } else if use_codex_app_server { // exit_code is meaningless here — we kill the long-lived server @@ -355,27 +356,64 @@ pub(super) async fn finalize_session_run( } else { SessionStatus::Failed }; - if raw_final_status == SessionStatus::Failed { - super::input_assembly::forget_session_context(session_id); - } - + // A CLI that exhausted its context can exit 0 while its result frame + // reports `terminal_reason: prompt_too_long`. Demote the false success + // so the run records the overflow and the next wake starts fresh. + raw_final_status = if raw_final_status == SessionStatus::Completed + && terminal_error_message + .as_deref() + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message) + { + SessionStatus::Failed + } else { + raw_final_status + }; // CLI member sessions inside an Agent Org run must land on `Idle` after each // successful turn so they remain available for the next coordinator dispatch. // `Completed` is terminal (is_terminal() == true) and would cause // `reconcile_run_finality` to prematurely end the run. let is_org_member = session.org_member_id.is_some(); - let final_status = if raw_final_status == SessionStatus::Completed && is_org_member { + let mut final_status = if raw_final_status == SessionStatus::Completed && is_org_member { SessionStatus::Idle } else { raw_final_status }; - let error_message: Option = if final_status == SessionStatus::Failed { + let mut error_message: Option = if final_status == SessionStatus::Failed { let buf = stderr_lines.lock().await; resolve_cli_failure_message(terminal_oauth_error.clone(), terminal_error_message, &buf) } else { None }; + // Native providers write the selected profile directly. Flushing final + // deltas is the only terminal persistence boundary. + flush_and_broadcast(session_id, turn_intent_id).await; + + // Converge the provider-written file before publishing the terminal + // lifecycle. Consumers may start the next runtime as soon as that durable + // status is visible, so the exact native transcript/alias must already be + // authoritative. Only the best-effort App catalog refresh is deferred. + if let Err(error) = + super::super::native_materializer::converge_bound_native_transcript_and_schedule_catalog( + session_id, + ) + .await + { + let convergence_error = + format!("Provider-native transcript could not be finalized safely: {error}"); + tracing::error!( + session_id, + error = %error, + "failing terminal lifecycle because provider-native transcript did not converge" + ); + raw_final_status = SessionStatus::Failed; + final_status = SessionStatus::Failed; + error_message = Some(convergence_error); + } + + if raw_final_status == SessionStatus::Failed { + super::input_assembly::forget_session_context(session_id); + } super::harness_hooks::finish_turn( session_id, @@ -520,9 +558,6 @@ pub(super) async fn finalize_session_run( agent_core::lifecycle::finalize_agent_org_member_turn(None, session_id, &outcome); } - // Flush any pending streaming deltas before signaling session end - flush_and_broadcast(session_id).await; - let mut status_msg = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs index 9da4f224af..418da0d11c 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use tokio::sync::Mutex; @@ -20,7 +20,7 @@ type RunningSessionsMap = HashMap>; pub static RUNNING_SESSIONS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); -type SessionControlLocksMap = HashMap>>; +type SessionControlLocksMap = HashMap>>; /// Per-session serialization of lifecycle control (cancel vs. new-turn /// dispatch). Without it, a slow `cancel_session` can interleave with a @@ -29,34 +29,33 @@ type SessionControlLocksMap = HashMap>>; static SESSION_CONTROL_LOCKS: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +// Provider identity (runtime/account/native UUID) is immutable for the whole +// runner lifetime. Unlike the short control lock, this guard travels with the +// background task through final native publication; a model picker may stage a +// next-turn choice but cannot retarget the active runner's filesystem binding. +static SESSION_IDENTITY_LOCKS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + pub async fn session_control_lock(session_id: &str) -> Arc> { let mut locks = SESSION_CONTROL_LOCKS.lock().await; - locks - .entry(session_id.to_string()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(session_id).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(session_id.to_string(), Arc::downgrade(&lock)); + lock } -/// Strip the `...` block from user input. -/// IDE context is prepended by `inject_ide_context_into_prompt` for the CLI agent, -/// but should not be stored in the DB or shown to the user in chat history. -pub(super) fn strip_ide_context(input: &str) -> String { - const OPEN: &str = ""; - const CLOSE: &str = ""; - - let Some(start) = input.find(OPEN) else { - return input.to_string(); - }; - let Some(close_start) = input.find(CLOSE) else { - return input.to_string(); - }; - let mut after = close_start + CLOSE.len(); - while after < input.len() && input.as_bytes()[after].is_ascii_whitespace() { - after += 1; +pub async fn session_identity_lock(session_id: &str) -> Arc> { + let mut locks = SESSION_IDENTITY_LOCKS.lock().await; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(session_id).and_then(Weak::upgrade) { + return lock; } - let mut result = input[..start].to_string(); - result.push_str(&input[after..]); - result + let lock = Arc::new(Mutex::new(())); + locks.insert(session_id.to_string(), Arc::downgrade(&lock)); + lock } /// Persist an ActivityChunk to the database and broadcast it via WebSocket. @@ -76,6 +75,7 @@ pub(super) async fn emit_chunk( chunk: &core_types::activity::ActivityChunk, session_id: &str, sequence: &mut i64, + turn_intent_id: Option<&str>, ) { let action_type = chunk.action_type.as_str(); let is_delta = action_type.contains("delta") @@ -100,16 +100,22 @@ pub(super) async fn emit_chunk( // on the async runner; only chunks that may touch SQLite, the event cache, // or filesystem side effects cross onto the blocking pool. if is_delta && !delta_requires_flush { - emit_chunk_blocking(chunk, session_id, sequence); + emit_chunk_blocking(chunk, session_id, sequence, turn_intent_id); return; } let owned_chunk = chunk.clone(); let owned_session_id = session_id.to_string(); + let owned_turn_intent_id = turn_intent_id.map(str::to_string); let initial_sequence = *sequence; match tokio::task::spawn_blocking(move || { let mut next_sequence = initial_sequence; - emit_chunk_blocking(&owned_chunk, &owned_session_id, &mut next_sequence); + emit_chunk_blocking( + &owned_chunk, + &owned_session_id, + &mut next_sequence, + owned_turn_intent_id.as_deref(), + ); next_sequence }) .await @@ -123,6 +129,7 @@ fn emit_chunk_blocking( chunk: &core_types::activity::ActivityChunk, session_id: &str, sequence: &mut i64, + turn_intent_id: Option<&str>, ) { let action_type = chunk.action_type.as_str(); @@ -155,7 +162,7 @@ fn emit_chunk_blocking( .filter(|v| !v.is_empty()) .is_some(); if has_tool_identity { - flush_and_broadcast_blocking(session_id); + flush_and_broadcast_blocking(session_id, turn_intent_id); } } @@ -175,12 +182,7 @@ fn emit_chunk_blocking( } // Still broadcast the raw delta for the frontend typewriter effect - let ws_msg = serde_json::json!({ - "type": "code_session.activity", - "session_id": session_id, - "chunk": chunk, - }); - websocket_handler::broadcast(ws_msg.to_string()); + broadcast_activity_chunk(session_id, chunk, turn_intent_id); return; } @@ -190,7 +192,8 @@ fn emit_chunk_blocking( // Completion chunk: flush the matching stream from the buffer and // broadcast the Rust-accumulated SessionEvent. if is_message_type { - if let Some(event) = CLI_STREAMING_BUFFER.complete_message(session_id) { + if let Some(mut event) = CLI_STREAMING_BUFFER.complete_message(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); persist_and_broadcast_streaming_complete( session_id, "message", @@ -198,7 +201,8 @@ fn emit_chunk_blocking( Some(sequence), ); } - } else if let Some(event) = CLI_STREAMING_BUFFER.complete_thinking(session_id) { + } else if let Some(mut event) = CLI_STREAMING_BUFFER.complete_thinking(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); persist_and_broadcast_streaming_complete( session_id, "thinking", @@ -209,7 +213,7 @@ fn emit_chunk_blocking( } else { // Non-streaming chunk (tool_call, user_message, etc.): flush any // pending streams before appending, same as UnifiedEventHandler. - flush_and_broadcast_blocking(session_id); + flush_and_broadcast_blocking(session_id, turn_intent_id); } // Persist non-delta chunks to DB (legacy mode). Native-transcript @@ -233,12 +237,56 @@ fn emit_chunk_blocking( // Broadcast the original chunk as well (non-delta chunks like tool_call // are still consumed by the frontend via code_session.activity) - let ws_msg = serde_json::json!({ + broadcast_activity_chunk(session_id, chunk, turn_intent_id); +} + +/// Attach the existing runner intent to the wire envelope, not the provider +/// chunk. Provider `result` payloads are intentionally opaque and may be a +/// scalar, array, or null; wrapping or replacing them would corrupt native +/// tool/message semantics. +fn broadcast_activity_chunk( + session_id: &str, + chunk: &core_types::activity::ActivityChunk, + turn_intent_id: Option<&str>, +) { + websocket_handler::broadcast( + activity_chunk_message(session_id, chunk, turn_intent_id).to_string(), + ); +} + +fn activity_chunk_message( + session_id: &str, + chunk: &core_types::activity::ActivityChunk, + turn_intent_id: Option<&str>, +) -> serde_json::Value { + let mut message = serde_json::json!({ "type": "code_session.activity", "session_id": session_id, "chunk": chunk, }); - websocket_handler::broadcast(ws_msg.to_string()); + if let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) { + message["turn_intent_id"] = serde_json::Value::String(turn_intent_id.to_string()); + } + message +} + +/// Streaming-buffer events are ORG2-owned normalized projections, so their +/// ordinary object result can carry the same intent identity durably. Refuse +/// to reshape an unexpected opaque result. +fn preserve_turn_intent( + event: &mut crate::agent_sessions::event_pipeline::types::SessionEvent, + turn_intent_id: Option<&str>, +) { + let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) else { + return; + }; + let Some(result) = event.result.as_object_mut() else { + return; + }; + result.insert( + "turnIntentId".to_string(), + serde_json::Value::String(turn_intent_id.to_string()), + ); } /// Broadcast `agent:streaming_complete` for a flushed stream. @@ -263,14 +311,15 @@ fn persist_and_broadcast_streaming_complete( event: &crate::agent_sessions::event_pipeline::types::SessionEvent, sequence: Option<&mut i64>, ) { - // Native-transcript sessions broadcast only: neither the event cache - // nor a chunk row is written, but the sequence still advances so - // later persisted artifacts can't collide with broadcast ordering. + // The provider file remains the full transcript authority, but the event + // cache must durably own Rust's finalized stream suffix. Hidden canonical + // runners have no mounted renderer/CLI adapter, and an interrupted + // provider file may stop at the preceding complete item. Persisting this + // one normalized message/thinking row lets nativeTranscriptReconcile merge + // the safe suffix without creating a second chunk or conversation plane. let persists = persistence::session_persists_chunks(session_id); - if persists { - let cached = session_event_to_cached_event(event); - let _ = save_events_retry("cli-stream-flush", session_id, &[cached], 5); - } + let cached = session_event_to_cached_event(event); + let _ = save_events_retry("cli-stream-flush", session_id, &[cached], 5); if let Some(sequence) = sequence { if persists { persist_streaming_complete_chunk(session_id, stream_type, event, sequence); @@ -317,9 +366,10 @@ fn persist_streaming_complete_chunk( } /// Flush all pending CLI streams and broadcast completion events. -fn flush_and_broadcast_blocking(session_id: &str) { +fn flush_and_broadcast_blocking(session_id: &str, turn_intent_id: Option<&str>) { let mut sequence = next_chunk_sequence(session_id); - for event in agent_core::foundation::streaming::cli_flush_session(session_id) { + for mut event in agent_core::foundation::streaming::cli_flush_session(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); let stream_type = if event.action_type == "assistant" { "message" } else { @@ -334,10 +384,11 @@ fn flush_and_broadcast_blocking(session_id: &str) { } } -pub(super) async fn flush_and_broadcast(session_id: &str) { +pub(super) async fn flush_and_broadcast(session_id: &str, turn_intent_id: Option<&str>) { let owned_session_id = session_id.to_string(); + let owned_turn_intent_id = turn_intent_id.map(str::to_string); if let Err(err) = tokio::task::spawn_blocking(move || { - flush_and_broadcast_blocking(&owned_session_id); + flush_and_broadcast_blocking(&owned_session_id, owned_turn_intent_id.as_deref()); }) .await { @@ -346,7 +397,7 @@ pub(super) async fn flush_and_broadcast(session_id: &str) { } pub async fn flush_cli_streams_for_session(session_id: &str) { - flush_and_broadcast(session_id).await; + flush_and_broadcast(session_id, None).await; } /// Drop hook-derived live status for a finished managed session. The @@ -571,6 +622,55 @@ pub(super) async fn persist_attached_images( mod tests { use super::*; + fn streaming_event() -> crate::agent_sessions::event_pipeline::types::SessionEvent { + let buffer = agent_core::foundation::streaming::StreamingBuffer::new(5_000); + buffer.append_message_delta("intent-test", "hello"); + buffer + .complete_message("intent-test") + .expect("streaming event") + } + + #[test] + fn activity_wire_identity_does_not_mutate_opaque_provider_result() { + for result in [ + serde_json::Value::Null, + serde_json::json!("opaque"), + serde_json::json!(["opaque"]), + ] { + let chunk = core_types::activity::ActivityChunk::new( + "intent-test", + "provider_event", + "provider_event", + ) + .with_result(result.clone()); + let message = activity_chunk_message("intent-test", &chunk, Some("turn-1")); + + assert_eq!(message["turn_intent_id"], "turn-1"); + assert_eq!(message["chunk"]["result"], result); + assert_eq!(chunk.result, result); + } + } + + #[test] + fn streaming_identity_preserves_non_object_result_shapes() { + for result in [ + serde_json::Value::Null, + serde_json::json!("opaque"), + serde_json::json!(["opaque"]), + ] { + let mut event = streaming_event(); + event.result = result.clone(); + + preserve_turn_intent(&mut event, Some("turn-1")); + + assert_eq!(event.result, result); + } + + let mut event = streaming_event(); + preserve_turn_intent(&mut event, Some("turn-1")); + assert_eq!(event.result["turnIntentId"], "turn-1"); + } + #[test] fn cli_file_edit_detection_covers_display_and_storage_names() { for function_name in [ diff --git a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs index ab567bbfe8..4a50e4e2c9 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs @@ -1,15 +1,14 @@ -//! Prompt assembly for CLI sessions. +//! Typed turn assembly for CLI sessions. //! -//! Builds the effective user input sent to the agent: exec-mode bridge -//! preamble, prior-conversation context bridge, attached-image references, -//! and (for ACP agents without native rules-file sync) an inline skills -//! injection. Extracted from `session::run_session` to keep the runner's -//! orchestration readable. +//! Keeps the user's visible message separate from provider-only context such +//! as exec-mode, workspace, hook, IDE and prior-conversation bridges. Native +//! transports can route those fields to their system/developer channel while +//! legacy transports retain the historical merged-prompt behavior. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use agent_core::session::AgentExecMode; +use agent_core::session::{AgentExecMode, IdeContext}; use key_vault::key_store::ModelType; use sha2::{Digest, Sha256}; @@ -26,6 +25,80 @@ type DeliveredContextDigests = HashMap> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// One CLI turn before provider-specific transport encoding. +/// +/// `provider_context_prefix` / `provider_context_suffix` preserve the legacy +/// merged prompt's ordering for transports that do not yet expose a native +/// system/developer channel. Native transports consume `user_text` and +/// `provider_context()` independently, so provider context never becomes a +/// visible user message in their native transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CliTurnEnvelope { + user_text: String, + provider_context_prefix: Vec, + provider_context_suffix: Vec, +} + +impl CliTurnEnvelope { + pub(super) fn new(user_text: impl Into) -> Self { + Self { + user_text: user_text.into(), + provider_context_prefix: Vec::new(), + provider_context_suffix: Vec::new(), + } + } + + #[cfg(test)] + pub(super) fn from_parts( + user_text: impl Into, + provider_context: impl Into, + ) -> Self { + let mut turn = Self::new(user_text); + turn.prepend_provider_context(provider_context); + turn + } + + pub(super) fn user_text(&self) -> &str { + &self.user_text + } + + pub(super) fn prepend_provider_context(&mut self, context: impl Into) { + let context = context.into(); + if !context.trim().is_empty() { + self.provider_context_prefix.insert(0, context); + } + } + + fn append_provider_context(&mut self, context: impl Into) { + let context = context.into(); + if !context.trim().is_empty() { + self.provider_context_suffix.push(context); + } + } + + pub(super) fn provider_context(&self) -> Option { + let context = self + .provider_context_prefix + .iter() + .chain(self.provider_context_suffix.iter()) + .map(String::as_str) + .collect::>() + .join("\n\n"); + (!context.is_empty()).then_some(context) + } + + /// Compatibility encoding for providers without a native context channel. + pub(super) fn merged_for_legacy(&self) -> String { + let mut sections = Vec::with_capacity( + self.provider_context_prefix.len() + self.provider_context_suffix.len() + 1, + ); + sections.extend(self.provider_context_prefix.iter().map(String::as_str)); + sections.push(self.user_text.as_str()); + sections.extend(self.provider_context_suffix.iter().map(String::as_str)); + sections.join("\n\n") + } +} + fn should_deliver_context( session_id: &str, agent: &ModelType, @@ -141,13 +214,14 @@ fn project_mode_bridge( )) } -/// Assemble the effective prompt from the raw user input plus the CLI-session -/// preambles. `is_fresh_session` is true when there is no `cli_resume_id` -/// (only a fresh conversation gets the prior-context bridge). `skills_enabled` -/// / `disabled_skills` come from the resolved SDE skills config. +/// Assemble the visible user turn and its provider-only context. +/// `is_fresh_session` is true when there is no `cli_resume_id` (only a fresh +/// conversation gets the prior-context bridge). `skills_enabled` / +/// `disabled_skills` come from the resolved SDE skills config. #[allow(clippy::too_many_arguments)] -pub(super) fn build_effective_input( +pub(super) fn build_turn_envelope( user_input: &str, + ide_context: Option<&IdeContext>, mode: Option<&str>, product_mode: Option<&str>, project_slug: Option<&str>, @@ -161,22 +235,30 @@ pub(super) fn build_effective_input( skills_enabled: bool, disabled_skills: &[String], status_catalog: Option<&str>, -) -> String { - let mut effective_input = user_input.to_string(); +) -> CliTurnEnvelope { + let mut turn = CliTurnEnvelope::new(user_input); + + if let Some(ide_context) = ide_context { + let context = + agent_core::core::session::prompt::ide_context::format_ide_context(ide_context); + if !context.is_empty() { + turn.prepend_provider_context(format!("\n{}\n", context)); + } + } if let Some(exec_mode_bridge) = cli_exec_mode_bridge(mode) { - effective_input = format!("{}\n\n{}", exec_mode_bridge, effective_input); + turn.prepend_provider_context(exec_mode_bridge); } if let Some(project_mode_bridge) = project_mode_bridge(product_mode, project_slug, work_item_id, status_catalog) { - effective_input = format!("{}\n\n{}", project_mode_bridge, effective_input); + turn.prepend_provider_context(project_mode_bridge); } if is_fresh_session { if let Some(context_bridge) = build_context_bridge(session_id) { - effective_input = format!("{}\n\n{}", context_bridge, effective_input); + turn.prepend_provider_context(context_bridge); } } @@ -186,21 +268,24 @@ pub(super) fn build_effective_input( .enumerate() .map(|(idx, path)| format!("Image {}: {}", idx + 1, path)) .collect(); - effective_input = format!( - "{}\n\nIMPORTANT: The user attached {} image(s). You MUST read each image file below before responding. Use your read_file or view_image tool on these absolute paths:\n{}", - effective_input, + turn.append_provider_context(format!( + "IMPORTANT: The user attached {} image(s). You MUST read each image file below before responding. Use your read_file or view_image tool on these absolute paths:\n{}", image_paths.len(), refs.join("\n"), - ); + )); } // Deliver one provider-neutral workspace contract to every CLI, even when // that provider also has a native rules file. Native discovery behavior // differs across versions and typically understands only one ecosystem // filename (for example CLAUDE.md *or* AGENTS.md); the shared envelope - // guarantees parity across providers. The digest gate sends unchanged - // context once per app process/provider conversation and re-sends it when - // rules or the progressive skill catalog change. + // guarantees parity across providers. Native context-channel transports + // re-send the complete current contract on every start/resume because + // their developer/system override is per launch and may replace the prior + // override. Legacy merged transports keep the digest gate to avoid paying + // for unchanged rules on every resumed turn. + let native_context_channel = matches!(agent, ModelType::ClaudeCode) + || (matches!(agent, ModelType::Codex) && use_codex_app_server); if let Some(path) = repo_path.and_then(|path| { let path = std::path::Path::new(path); path.is_dir().then_some(path) @@ -210,9 +295,11 @@ pub(super) fn build_effective_input( skills_enabled, disabled_skills, ) - .filter(|context| should_deliver_context(session_id, agent, context, is_fresh_session)) - { - effective_input = format!("{}\n\n{}", context, effective_input); + .filter(|context| { + native_context_channel + || should_deliver_context(session_id, agent, context, is_fresh_session) + }) { + turn.prepend_provider_context(context); } } @@ -229,18 +316,19 @@ pub(super) fn build_effective_input( if let Some(hook_prompt) = hook_executor .collect_prompt_hooks(agent_core::specialization::hooks::HookEvent::PrePromptBuild) { - effective_input = format!( - "\n{}\n\n\n{}", - hook_prompt, effective_input - ); + turn.prepend_provider_context(format!( + "\n{}\n", + hook_prompt + )); } - effective_input + turn } #[cfg(test)] mod tests { - use super::{build_effective_input, project_mode_bridge}; + use super::{build_turn_envelope, project_mode_bridge}; + use agent_core::session::IdeContext; use key_vault::key_store::ModelType; #[test] @@ -324,8 +412,9 @@ mod tests { for provider in providers { assert!(provider.is_cli_agent()); - let prompt = build_effective_input( + let turn = build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -340,13 +429,15 @@ mod tests { &[], None, ); + let context = turn.provider_context().expect("provider context"); + assert_eq!(turn.user_text(), "do the task"); assert!( - prompt.contains("PROVIDER_CONTEXT_SENTINEL"), + context.contains("PROVIDER_CONTEXT_SENTINEL"), "{} missed workspace context", provider.as_str() ); assert!( - !prompt.contains("orgii_project_mode"), + !context.contains("orgii_project_mode"), "{} received Project capabilities in ordinary Build", provider.as_str() ); @@ -360,8 +451,9 @@ mod tests { std::fs::write(&agents_md, "CONTEXT_V1").expect("write v1"); let build = || { - build_effective_input( + build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -377,11 +469,17 @@ mod tests { None, ) }; - assert!(build().contains("CONTEXT_V1")); - assert!(!build().contains("CONTEXT_V1")); + assert!(build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V1"))); + assert!(!build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V1"))); std::fs::write(&agents_md, "CONTEXT_V2").expect("write v2"); - assert!(build().contains("CONTEXT_V2")); + assert!(build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V2"))); } #[test] @@ -389,8 +487,9 @@ mod tests { let workspace = tempfile::tempdir().expect("workspace"); std::fs::write(workspace.path().join("AGENTS.md"), "FRESH_CONTEXT").expect("write context"); let build = |is_fresh_session| { - build_effective_input( + build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -406,8 +505,84 @@ mod tests { None, ) }; - assert!(build(true).contains("FRESH_CONTEXT")); - assert!(!build(false).contains("FRESH_CONTEXT")); - assert!(build(true).contains("FRESH_CONTEXT")); + assert!(build(true) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + assert!(!build(false) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + assert!(build(true) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + } + + #[test] + fn visible_user_text_is_never_polluted_by_agent_context() { + let ide_context = IdeContext { + active_file: Some("src/main.rs".to_string()), + git_branch: Some("feature/native-context".to_string()), + ..IdeContext::default() + }; + let turn = build_turn_envelope( + "Please inspect this exact message.", + Some(&ide_context), + Some("build"), + Some("build"), + None, + None, + "typed-envelope-session", + false, + &ModelType::ClaudeCode, + &[], + false, + None, + false, + &[], + None, + ); + + assert_eq!(turn.user_text(), "Please inspect this exact message."); + let context = turn.provider_context().expect("provider context"); + assert!(context.contains("")); + assert!(context.contains("")); + assert!(!turn.user_text().contains("")); + assert!(turn + .merged_for_legacy() + .ends_with("Please inspect this exact message.")); + } + + #[test] + fn native_context_channels_resend_current_workspace_context_on_resume() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("AGENTS.md"), "NATIVE_CONTEXT") + .expect("write context"); + + for (agent, use_codex_app_server) in + [(ModelType::ClaudeCode, false), (ModelType::Codex, true)] + { + for _ in 0..2 { + let turn = build_turn_envelope( + "resume", + None, + Some("build"), + Some("build"), + None, + None, + &format!("native-resume-{}", agent.as_str()), + false, + &agent, + &[], + use_codex_app_server, + workspace.path().to_str(), + false, + &[], + None, + ); + assert!(turn + .provider_context() + .is_some_and(|context| context.contains("NATIVE_CONTEXT"))); + } + } } } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs index 65df576b3b..cb579428d6 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs @@ -1,6 +1,6 @@ //! Session lifecycle management — kill, cancel, cleanup. -use super::super::persistence; +use super::super::persistence::{self, CodeSession}; use super::super::types::SessionStatus; use super::helpers::{flush_cli_streams_for_session, RUNNING_SESSIONS}; use agent_core::state::control_flow::CancelReason; @@ -76,6 +76,19 @@ pub async fn kill_running_agent(session_id: &str) -> bool { // start/stop operations would serialize behind it. flush_cli_streams_for_session(session_id).await; handle.abort(); + // `abort()` only requests cancellation. Await the handle so the + // runner future has actually dropped its provider-identity guard + // before a follow-up publishes the interrupted snapshot or launches + // another turn against the same native UUID. + if let Err(error) = handle.await { + if !error.is_cancelled() { + tracing::warn!( + session_id, + error = %error, + "CLI runner failed while waiting for cancellation" + ); + } + } } let process_session_id = session_id.to_string(); @@ -92,98 +105,96 @@ pub async fn kill_running_agent(session_id: &str) -> bool { had_running_task } -/// Cancel a running session by killing the CLI subprocess. -/// -/// Does NOT release the proxy token — follow-up messages via -/// `cli_agent_message` always re-allocate a fresh token anyway. -/// The old token expires via the agent-proxy inactivity timeout or -/// is released on session deletion. -pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result { - // Serialize against `cli_agent_message` / `cli_agent_run` for this - // session: a cancel whose DB lookup lands after a follow-up turn was - // accepted would otherwise cancel the NEW intent and kill the new - // process ("stop then send loses both messages"). - let control_lock = super::helpers::session_control_lock(session_id).await; - let _control_guard = control_lock.lock().await; - - // The previous `.ok().flatten()` collapsed a DB error and a - // legitimate "session not found" into the same `None`. The - // status_changed broadcast below would then ship without - // `background` / `session_name` populated, and the UI would - // silently render an "unknown session cancelled" toast. Warn - // on the DB-error branch so the cause is visible while still - // proceeding with the cancel (we don't want to fail the cancel - // just because we couldn't decorate the broadcast). +async fn terminal_context( + session_id: &str, +) -> Result<(Option, Option), String> { let lookup_session_id = session_id.to_string(); - let (session, active_turn_intent_id) = match tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { let session = persistence::get_session(&lookup_session_id).map_err(|err| err.to_string())?; - let latest = session_persistence::turn_intents::latest_for_sessions(std::slice::from_ref( - &lookup_session_id, - )) + let active_turn_intent_id = session_persistence::turn_intents::latest_for_sessions( + std::slice::from_ref(&lookup_session_id), + ) .map_err(|err| err.to_string())? .remove(&lookup_session_id) .filter(|intent| { intent.status == session_persistence::turn_intents::TurnIntentStatus::Running }) .map(|intent| intent.turn_intent_id); - Ok::<_, String>((session, latest)) + Ok::<_, String>((session, active_turn_intent_id)) }) .await - { - Ok(Ok(result)) => result, - Ok(Err(err)) => { - tracing::warn!( - session_id = %session_id, - error = %err, - "cli::cancel_session: get_session DB error; broadcast will lack session metadata" - ); - (None, None) - } - Err(err) => { - tracing::warn!( - session_id = %session_id, - error = %err, - "cli::cancel_session: status lookup task failed" - ); - (None, None) - } - }; + .map_err(|err| format!("Task error: {err}"))? +} - // Codex app-server transport: ask the running turn to interrupt - // gracefully (bounded wait) so codex finalizes the rollout before we - // kill the process tree. No-op for every other transport/agent. - crate::agent_sessions::cli::parsers::codex_app_server::interrupt_session_gracefully(session_id) - .await; +/// One terminal owner for runner interruption paths. Callers must already have +/// stopped the runner and hold its identity boundary before invoking this. +struct InterruptedTerminal<'a> { + status: SessionStatus, + intent_status: session_persistence::turn_intents::TurnIntentStatus, + error: Option<&'a str>, + reason: Option<&'a str>, +} - let had_running = kill_running_agent(session_id).await; +async fn finalize_interrupted_runner( + session_id: &str, + session: Option<&CodeSession>, + active_turn_intent_id: Option<&str>, + terminal: InterruptedTerminal<'_>, +) -> Result<(), String> { + // Preserve every provider-durable partial row before publishing the + // terminal intent. A runtime switch may begin as soon as that intent is + // visible, so transcript/alias convergence belongs to the durable + // terminal boundary. Catalog refresh remains deferred and idempotent. + let convergence_error = if let Err(error) = + super::super::native_materializer::converge_bound_native_transcript_and_schedule_catalog( + session_id, + ) + .await + { + tracing::error!( + session_id, + error = %error, + "failing interrupted terminal because provider-native transcript did not converge" + ); + Some(format!( + "Provider-native transcript could not be finalized safely: {error}" + )) + } else { + None + }; let persist_session_id = session_id.to_string(); - let persist_turn_intent_id = active_turn_intent_id.clone(); + let persist_turn_intent_id = active_turn_intent_id.map(str::to_string); + let persist_error = convergence_error + .clone() + .or_else(|| terminal.error.map(str::to_string)); + let broadcast_error = persist_error.clone(); + let terminal_status = if convergence_error.is_some() { + SessionStatus::Failed + } else { + terminal.status + }; + let terminal_intent_status = if convergence_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + terminal.intent_status + }; tokio::task::spawn_blocking(move || { persistence::update_cli_turn_lifecycle( &persist_session_id, - SessionStatus::Cancelled, - None, - persist_turn_intent_id.as_deref().map(|turn_intent_id| { - ( - turn_intent_id, - session_persistence::turn_intents::TurnIntentStatus::Cancelled, - ) - }), + terminal_status, + persist_error.as_deref(), + persist_turn_intent_id + .as_deref() + .map(|turn_intent_id| (turn_intent_id, terminal_intent_status)), ) }) .await .map_err(|err| format!("Task error: {err}"))??; - // Cancelling also wakes any parked PermissionRequest hook long-poll - // (no-decision) — covered again by clear_live_status below when the - // agent type is known, but the else branches skip it. super::super::hook_approvals::unregister_session(session_id); - - // Cancelled is terminal: drop any hook-derived live status so the - // sidebar doesn't keep a ghost working/waiting entry for this session. - if let Some(ref session) = session { + if let Some(session) = session { if let Some(agent) = session .cli_agent_type .as_deref() @@ -204,16 +215,176 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result<(), String> { + let (session, active_turn_intent_id) = terminal_context(session_id).await?; + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: SessionStatus::Failed, + intent_status: session_persistence::turn_intents::TurnIntentStatus::Failed, + error: Some(error), + reason: None, + }, + ) + .await +} + +/// Persist the old turn boundary before a force-follow-up rebinds runtime, +/// account, or model. The caller has already stopped the process and owns the +/// session identity lock, exactly like the user-cancel path. +pub(crate) async fn finalize_interrupted_follow_up( + session_id: &str, + interrupt_error: Option<&str>, +) -> Result<(), String> { + let (session, active_turn_intent_id) = terminal_context(session_id).await?; + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: if interrupt_error.is_some() { + SessionStatus::Failed + } else { + SessionStatus::Cancelled + }, + intent_status: if interrupt_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + session_persistence::turn_intents::TurnIntentStatus::Cancelled + }, + error: interrupt_error, + reason: Some("replaced_by_follow_up"), + }, + ) + .await +} + +/// Cancel a running session by killing the CLI subprocess. +/// +/// Does NOT release the proxy token — follow-up messages via +/// `cli_agent_message` always re-allocate a fresh token anyway. +/// The old token expires via the agent-proxy inactivity timeout or +/// is released on session deletion. +pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result { + // Serialize against `cli_agent_message` / `cli_agent_run` for this + // session: a cancel whose DB lookup lands after a follow-up turn was + // accepted would otherwise cancel the NEW intent and kill the new + // process ("stop then send loses both messages"). + let control_lock = super::helpers::session_control_lock(session_id).await; + let control_guard = control_lock.lock().await; + + // The previous `.ok().flatten()` collapsed a DB error and a + // legitimate "session not found" into the same `None`. The + // status_changed broadcast below would then ship without + // `background` / `session_name` populated, and the UI would + // silently render an "unknown session cancelled" toast. Warn + // on the DB-error branch so the cause is visible while still + // proceeding with the cancel (we don't want to fail the cancel + // just because we couldn't decorate the broadcast). + let (session, active_turn_intent_id) = match terminal_context(session_id).await { + Ok(result) => result, + Err(err) => { + tracing::warn!( + session_id = %session_id, + error = %err, + "cli::cancel_session: terminal context unavailable; broadcast will lack session metadata" + ); + (None, None) + } + }; + + // Codex app-server transport: ask the running turn to interrupt + // gracefully (bounded wait) so codex finalizes the rollout before we + // kill the process tree. No-op for every other transport/agent. + let interrupt_outcome = + crate::agent_sessions::cli::parsers::codex_app_server::interrupt_session_gracefully( + session_id, + ) + .await; + + let had_running = kill_running_agent(session_id).await; + // `kill_running_agent` awaits the aborted runner, so its lifetime identity + // guard is gone. Reacquire identity while the control guard is still held + // before persisting the terminal turn boundary. + let _identity_guard = super::session_identity_lock(session_id) + .await + .lock_owned() + .await; + + // A timed-out Codex app-server interrupt cannot advertise a clean native + // resume boundary. Ordinary native runtimes already write their one + // authoritative profile directly, so no second copy step exists. + let interrupt_error = if matches!( + interrupt_outcome, + crate::agent_sessions::cli::parsers::codex_app_server::GracefulInterruptOutcome::TimedOut + ) { + Some("Codex did not finish its native interrupted turn".to_string()) + } else { + None + }; + let terminal_status = if interrupt_error.is_some() { + SessionStatus::Failed + } else { + SessionStatus::Cancelled + }; + let terminal_intent_status = if interrupt_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + session_persistence::turn_intents::TurnIntentStatus::Cancelled + }; + + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: terminal_status, + intent_status: terminal_intent_status, + error: interrupt_error.as_deref(), + reason: Some(reason.as_str()), + }, + ) + .await?; + drop(control_guard); + + if let Some(error) = interrupt_error { + tracing::error!( + "[CodeSession] Session {} cancellation failed closed (reason={}, had_running={}): {}", + session_id, + reason.as_str(), + had_running, + error + ); + return Err(error); + } + tracing::info!( "[CodeSession] Session {} cancelled (reason={}, had_running={})", session_id, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs index 0a97dc4874..4d87b142c0 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs @@ -7,7 +7,7 @@ //! - `helpers` — shared state, emit_chunk, image persistence //! - `command` — CLI command building and parser factory //! - `session` — core run_session function -//! - `input_assembly` — effective-prompt assembly (bridges, images, skills) +//! - `input_assembly` — typed user/context turn assembly (bridges, images, skills) //! - `env_setup` — child-process env / profile-dir / proxy preparation //! - `finalize` — post-run status, error surfacing, resource teardown //! - `lifecycle` — kill, cancel, cleanup @@ -21,7 +21,7 @@ pub(crate) mod command; mod context_bridge; mod cursor_usage; -mod env_setup; +pub(crate) mod env_setup; mod finalize; mod harness_hooks; mod helpers; @@ -35,17 +35,17 @@ mod session; mod token_sync; pub(crate) use harness_hooks::stop_session as stop_session_hooks; -pub use helpers::{flush_cli_streams_for_session, session_control_lock, RUNNING_SESSIONS}; +pub use helpers::{ + flush_cli_streams_for_session, session_control_lock, session_identity_lock, RUNNING_SESSIONS, +}; pub(crate) use input_assembly::forget_session_context; pub use lifecycle::{ cancel_session, cleanup_cursor_config_dir, kill_running_agent, terminate_process_tree, }; +pub(crate) use lifecycle::{fail_interrupted_turn, finalize_interrupted_follow_up}; pub use proxy_release::release_proxy_token_for_session_pub; pub use session::run_session; - -#[cfg(test)] -#[path = "../tests/runner_tests.rs"] -mod tests; +pub(crate) use session::run_session_with_ide_context; #[cfg(test)] #[path = "../tests/runner_command_tests.rs"] diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index 7ed7d527bd..07a1f57ba5 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -8,7 +8,7 @@ //! - `spawn_retry` — transient subprocess-spawn retry helpers //! - `skills_resolve` — built-in SDE agent skills-config resolution -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::process::Stdio; use std::sync::Arc; @@ -17,7 +17,7 @@ use tokio::process::Command; use tokio::sync::Mutex; use crate::api::websocket_handler; -use agent_core::session::AgentExecMode; +use agent_core::session::{AgentExecMode, IdeContext}; use key_vault::key_store::{KeyService, ModelType, KEY_SERVICE}; use super::super::launch_profile_store::resolve_cli_launch_profile; @@ -26,7 +26,7 @@ use super::super::types::KeySource; use super::command::{ build_command_with_launch_profile, launch_profile_env, CliCommandBuildRequest, }; -use super::helpers::{emit_chunk, persist_attached_images, strip_ide_context}; +use super::helpers::{emit_chunk, persist_attached_images}; use super::oauth_setup::{ is_cli_oauth_retry_eligible, refresh_cli_oauth_for_retry, sanitize_cli_oauth_env_for_child, }; @@ -46,6 +46,44 @@ const OVERLOAD_RETRY_BASE_DELAY_SECS: u64 = 2; const MAX_STDERR_LINES: usize = 20; +/// Routing/auth variables owned by an explicit Claude account selection. +/// `tokio::process::Command` inherits the desktop process environment, so a +/// variable that is absent from the newly selected account must be explicitly +/// removed or a prior shell/launcher setting can silently reroute the child. +const CLAUDE_ACCOUNT_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + "DISABLE_INTERLEAVED_THINKING", + "CLAUDE_CONFIG_DIR", +]; + +fn merge_launch_profile_environment( + agent: &ModelType, + has_explicit_account: bool, + selected_environment: &mut HashMap, + profile_environment: HashMap, +) { + for (key, value) in profile_environment { + // A stored launch profile is a runtime default, never a credential or + // routing authority. In particular, after selecting a Claude account, + // absent account-owned keys must remain absent so apply_child_environment + // can remove stale ambient Atlas/Anthropic routing. + if has_explicit_account + && matches!(agent, ModelType::ClaudeCode) + && CLAUDE_ACCOUNT_ENV_KEYS.contains(&key.as_str()) + { + continue; + } + selected_environment.entry(key).or_insert(value); + } +} + /// How long to keep waiting for the stderr reader once the child is gone. A /// CLI that hands its stderr to a surviving grandchild keeps the pipe open /// forever, and no diagnostic is worth hanging the turn on. @@ -80,6 +118,25 @@ fn environment_key_is_sensitive(key: &str) -> bool { .any(|marker| key.contains(marker)) } +fn apply_child_environment( + command: &mut Command, + agent: &ModelType, + has_explicit_account: bool, + env_vars: &HashMap, +) { + // An ambient Claude launch intentionally inherits the user's shell/CLI + // profile. Once the composer selects an ORGII account, however, that + // account is the complete routing source and absent keys must stay absent. + if has_explicit_account && matches!(agent, ModelType::ClaudeCode) { + for key in CLAUDE_ACCOUNT_ENV_KEYS { + if !env_vars.contains_key(*key) { + command.env_remove(key); + } + } + } + command.envs(env_vars); +} + fn redacted_command_parts(cmd_parts: &[String]) -> Vec { cmd_parts .iter() @@ -278,6 +335,39 @@ fn resolve_session_model( } } +/// Claude Code accepts provider-specific model ids (for example Atlas Cloud's +/// `zai-org/glm-5.1`) through its Anthropic-compatible environment, not the +/// CLI's `--model` validator. `KeyService::get_env_for_agent` supplies a safe +/// account-level fallback, but the session's explicit model selection must win +/// whenever one is present. +fn apply_claude_cross_type_session_model( + agent: &ModelType, + key_model_type: Option<&ModelType>, + session_model: Option<&str>, + env_vars: &mut HashMap, +) { + let is_cross_type_key = key_model_type.is_some_and(|key_type| key_type != agent); + if !matches!(agent, ModelType::ClaudeCode) || !is_cross_type_key { + return; + } + + let Some(model) = session_model + .map(str::trim) + .filter(|model| !model.is_empty()) + else { + return; + }; + + for key in [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] { + env_vars.insert(key.to_string(), model.to_string()); + } +} + fn resolve_cli_effective_mode( product_mode: Option<&str>, requested_mode: Option<&str>, @@ -293,6 +383,17 @@ fn resolve_cli_effective_mode( } } +fn scope_codex_transport_to_turn( + agent: &ModelType, + launch_profile: &mut super::launch_profiles::ResolvedCliLaunchProfile, + native_continuation_episode: bool, +) { + if matches!(agent, ModelType::Codex) { + launch_profile.transport = native_continuation_episode + .then(|| super::launch_profiles::CLI_TRANSPORT_APP_SERVER.to_string()); + } +} + /// Run a code session: spawn CLI, parse stdout, broadcast events. /// /// This is spawned as a background Tokio task. @@ -305,6 +406,36 @@ pub async fn run_session( mode: Option<&str>, images: Option>, turn_intent_id: Option<&str>, + allow_native_context_recovery: bool, +) -> Result<(), String> { + run_session_with_ide_context( + session_id, + user_input, + None, + cli_resume_id, + mode, + images, + turn_intent_id, + allow_native_context_recovery, + ) + .await +} + +/// Run a CLI turn while preserving the IDE snapshot as provider-only context. +/// +/// The public `run_session` wrapper remains for non-UI callers that do not +/// carry IDE state. UI run/message commands use this path so the snapshot can +/// be encoded in a native system/developer channel instead of the user row. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_session_with_ide_context( + session_id: String, + user_input: String, + ide_context: Option, + cli_resume_id: Option, + mode: Option<&str>, + images: Option>, + turn_intent_id: Option<&str>, + allow_native_context_recovery: bool, ) -> Result<(), String> { let session = persistence::get_session(&session_id) .map_err(|e| format!("DB error: {}", e))? @@ -448,10 +579,14 @@ pub async fn run_session( let run_started_at = chrono::Utc::now(); - // Resolved early: the experimental codex app-server transport gate + // Resolved early: the codex app-server transport gate // changes prompt assembly (images travel as native localImage inputs) // as well as argv and the stdout-processing branch below. - let launch_profile = resolve_cli_launch_profile(&agent)?; + let mut launch_profile = resolve_cli_launch_profile(&agent)?; + // Ordinary Codex sessions keep the established `codex exec --json` + // transport. A canonical/native continuation episode opts into app-server + // for this turn only, without mutating the user's saved launch profile. + scope_codex_transport_to_turn(&agent, &mut launch_profile, allow_native_context_recovery); let use_codex_app_server = super::launch_profiles::uses_codex_app_server(&agent, &launch_profile); @@ -472,8 +607,9 @@ pub async fn run_session( } else { None }; - let mut effective_input = super::input_assembly::build_effective_input( + let mut turn = super::input_assembly::build_turn_envelope( &user_input, + ide_context.as_ref(), Some(effective_mode_str), session.product_mode.as_deref(), session.project_slug.as_deref(), @@ -489,10 +625,10 @@ pub async fn run_session( status_catalog.as_deref(), ); if let Some(context) = lifecycle_hook_context { - effective_input = format!( - "\n{}\n\n\n{}", - context, effective_input - ); + turn.prepend_provider_context(format!( + "\n{}\n", + context + )); } // Build CLI command @@ -533,7 +669,7 @@ pub async fn run_session( // random owner-only profile layer and pass only the non-secret profile // name in argv. The guard stays alive through every transport retry and // finalization, then removes the profile on return/cancellation. - let codex_mcp_profile = if matches!(agent, ModelType::Codex) { + let codex_mcp_profile = if matches!(agent, ModelType::Codex) && !use_codex_app_server { let codex_home = super::env_setup::codex_home_for_session(&session, account_id, &session_id)?; session_mcp @@ -544,6 +680,11 @@ pub async fn run_session( } else { None }; + let codex_app_server_config = if matches!(agent, ModelType::Codex) && use_codex_app_server { + session_mcp.codex_app_server_config() + } else { + None + }; let acp_mcp_servers = session_mcp.acp_servers(); let stderr_mcp_servers = Arc::new(session_mcp); @@ -551,7 +692,7 @@ pub async fn run_session( agent: &agent, launch_profile: &launch_profile, model: model.as_deref(), - task: &effective_input, + turn: &turn, resume_id: cli_resume_id.as_deref(), api_key: api_key_for_cli, endpoint: endpoint_for_cli, @@ -607,7 +748,19 @@ pub async fn run_session( KEY_SERVICE.get_env_for_agent(&agent, account_id) }; - env_vars.extend(launch_profile_env(&launch_profile)); + apply_claude_cross_type_session_model( + &agent, + key_model_type.as_ref(), + session.model.as_deref(), + &mut env_vars, + ); + + merge_launch_profile_environment( + &agent, + account_id.is_some(), + &mut env_vars, + launch_profile_env(&launch_profile), + ); // Inherited by the CLI child and, transitively, by its hook subprocesses: // lets live-status hook posts attribute directly to this managed session @@ -628,8 +781,9 @@ pub async fn run_session( env_vars.insert("CURSOR_CLI_COMPAT".to_string(), "1".to_string()); } - // Store user input (without IDE context) - let display_input = strip_ide_context(&user_input); + // Store only the literal user-authored input. IDE and other provider + // context live in the typed turn envelope and never enter this row. + let display_input = user_input.clone(); { let conn = session_persistence::get_connection().map_err(|e| format!("DB: {}", e))?; conn.execute( @@ -784,9 +938,14 @@ pub async fn run_session( let mut attempt_stderr = CliStderrCollector::new(); stderr_lines = attempt_stderr.lines(); let mut spawn_cmd = Command::new(program); + spawn_cmd.args(args); + apply_child_environment( + &mut spawn_cmd, + &agent, + session.key_source == KeySource::HostedKey || account_id.is_some(), + &env_vars, + ); spawn_cmd - .args(args) - .envs(&env_vars) .current_dir(working_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -855,11 +1014,13 @@ pub async fn run_session( session_id.clone(), account_id, oauth_retry_eligible, - effective_input.clone(), + turn.user_text().to_string(), + turn.provider_context(), working_dir, cli_resume_id.clone(), model.as_deref(), &launch_profile, + codex_app_server_config.clone(), image_paths.clone(), session_timeout, pre_message_snapshot_id.clone(), @@ -868,6 +1029,8 @@ pub async fn run_session( &mut sequence, codex_app_server_turn_ok, &mut attempt_stderr, + allow_native_context_recovery, + turn_intent_id, ) .await?; exit_code = outcome.exit_code; @@ -881,7 +1044,7 @@ pub async fn run_session( let outcome = transport_acp::run_acp_branch( child, session_id.clone(), - effective_input.clone(), + turn.merged_for_legacy(), working_dir, cli_resume_id.clone(), agent.clone(), @@ -894,6 +1057,7 @@ pub async fn run_session( cli_session_id_out, &mut sequence, &env_vars, + turn_intent_id, ) .await?; exit_code = outcome.exit_code; @@ -917,6 +1081,7 @@ pub async fn run_session( cli_session_id_out, &mut sequence, &mut attempt_stderr, + turn_intent_id, ) .await; exit_code = outcome.exit_code; @@ -984,7 +1149,7 @@ pub async fn run_session( terminal_message, ); terminal_error_message = Some(terminal_message); - emit_chunk(&chunk, &session_id, &mut sequence).await; + emit_chunk(&chunk, &session_id, &mut sequence, turn_intent_id).await; break; } let delay_secs = OVERLOAD_RETRY_BASE_DELAY_SECS * (1u64 << overload_retry_count); diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs index ba83e0667a..405476389a 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs @@ -299,6 +299,45 @@ impl SessionMcpServers { entries } + /// In-memory config overrides for Codex app-server `thread/start` and + /// `thread/resume`. Unlike `-c` argv overrides, this JSON-RPC payload does + /// not expose MCP environment values or HTTP headers to process listings. + pub(super) fn codex_app_server_config(&self) -> Option { + let mut servers = serde_json::Map::new(); + for (name, server) in &self.servers { + let mut entry = serde_json::Map::new(); + match server.transport_type { + McpTransportType::Stdio => { + let Some(command) = trimmed(server.command.as_deref()) else { + continue; + }; + entry.insert("command".into(), serde_json::json!(command)); + if let Some(args) = server.args.as_ref().filter(|args| !args.is_empty()) { + entry.insert("args".into(), serde_json::json!(args)); + } + if let Some(cwd) = trimmed(server.cwd.as_deref()) { + entry.insert("cwd".into(), serde_json::json!(cwd)); + } + if let Some(env) = sorted_map(server.env.as_ref()) { + entry.insert("env".into(), serde_json::json!(env)); + } + } + McpTransportType::StreamableHttp => { + let Some(url) = trimmed(server.url.as_deref()) else { + continue; + }; + entry.insert("url".into(), serde_json::json!(url)); + if let Some(headers) = sorted_map(server.headers.as_ref()) { + entry.insert("http_headers".into(), serde_json::json!(headers)); + } + } + McpTransportType::Sse => continue, + } + servers.insert(name.clone(), serde_json::Value::Object(entry)); + } + (!servers.is_empty()).then(|| serde_json::json!({ "mcp_servers": servers })) + } + /// Write a per-run `$CODEX_HOME/.config.toml` layer and return the /// guard that owns cleanup. Only the random profile name is passed on the /// command line; the MCP values remain in this owner-only file. @@ -886,6 +925,17 @@ mod tests { &HashSet::new(), &HashSet::new(), ); + let app_server_config = resolved + .codex_app_server_config() + .expect("non-empty app-server MCP config"); + assert_eq!( + app_server_config["mcp_servers"]["docs"]["env"]["API_TOKEN"], + "stdio-secret" + ); + assert_eq!( + app_server_config["mcp_servers"]["remote"]["http_headers"]["Authorization"], + "Bearer url-secret" + ); let temp_dir = tempfile::tempdir().expect("Codex profile root"); let guard = resolved .write_codex_mcp_profile(temp_dir.path()) diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs index b4a3ead543..8f9fce2ede 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs @@ -18,6 +18,39 @@ use serde_json::Value; use std::collections::{HashMap, VecDeque}; use std::path::Path; +#[test] +fn codex_app_server_is_scoped_to_native_continuation_episode() { + let profile = || super::super::launch_profiles::ResolvedCliLaunchProfile { + permission_mode: super::super::launch_profiles::CliPermissionMode::Manual, + command: "codex".to_string(), + args: vec!["exec".to_string()], + env: HashMap::new(), + // Even a stale persisted opt-in must not change an ordinary turn. + transport: Some(super::super::launch_profiles::CLI_TRANSPORT_APP_SERVER.to_string()), + }; + + let mut ordinary = profile(); + scope_codex_transport_to_turn(&ModelType::Codex, &mut ordinary, false); + assert!(!super::super::launch_profiles::uses_codex_app_server( + &ModelType::Codex, + &ordinary + )); + + let mut continuation = profile(); + scope_codex_transport_to_turn(&ModelType::Codex, &mut continuation, true); + assert!(super::super::launch_profiles::uses_codex_app_server( + &ModelType::Codex, + &continuation + )); + + let mut claude = profile(); + scope_codex_transport_to_turn(&ModelType::ClaudeCode, &mut claude, true); + assert_eq!( + claude.transport.as_deref(), + Some(super::super::launch_profiles::CLI_TRANSPORT_APP_SERVER) + ); +} + #[test] fn command_logging_redacts_mcp_config_values() { let raw = vec![ @@ -719,6 +752,61 @@ fn atlas_model_string_is_preserved_before_the_codex_provider_gate_rejects_it() { ); } +#[test] +fn claude_cross_type_session_model_overrides_the_account_fallback() { + let mut env = HashMap::from([ + ("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.1".to_string()), + ( + "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ( + "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ]); + + apply_claude_cross_type_session_model( + &ModelType::ClaudeCode, + Some(&ModelType::AtlascloudApi), + Some("deepseek-ai/deepseek-v3.2"), + &mut env, + ); + + for key in [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] { + assert_eq!( + env.get(key).map(String::as_str), + Some("deepseek-ai/deepseek-v3.2"), + ); + } +} + +#[test] +fn claude_native_session_keeps_its_cli_model_path() { + let mut env = HashMap::from([("ANTHROPIC_MODEL".to_string(), "account-default".to_string())]); + + apply_claude_cross_type_session_model( + &ModelType::ClaudeCode, + Some(&ModelType::ClaudeCode), + Some("claude-opus-4-8"), + &mut env, + ); + + assert_eq!( + env.get("ANTHROPIC_MODEL").map(String::as_str), + Some("account-default"), + ); +} + #[test] fn codex_rejects_chat_only_providers_and_zenmux_preserves_aggregator_namespace() { for provider in [ModelType::ZhipuApi, ModelType::AtlascloudApi] { @@ -833,6 +921,95 @@ fn child_env_sanitization_keeps_runtime_tokens_out_of_subprocess_env() { assert!(!codex_env.contains_key(CODEX_ID_TOKEN_ENV_KEY)); } +#[test] +fn explicit_claude_account_clears_inherited_routing_not_owned_by_source() { + let selected = HashMap::from([ + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "selected-oauth".to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".to_string(), + "/selected/profile".to_string(), + ), + ]); + let mut command = Command::new("claude"); + apply_child_environment(&mut command, &ModelType::ClaudeCode, true, &selected); + + let explicit = command + .as_std() + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect::>(); + assert_eq!( + explicit.get("ANTHROPIC_AUTH_TOKEN"), + Some(&Some("selected-oauth".to_string())) + ); + assert_eq!(explicit.get("ANTHROPIC_API_KEY"), Some(&None)); + assert_eq!(explicit.get("ANTHROPIC_BASE_URL"), Some(&None)); + assert_eq!(explicit.get("ANTHROPIC_MODEL"), Some(&None)); + assert_eq!( + explicit.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"), + Some(&None) + ); +} + +#[test] +fn explicit_claude_account_wins_over_stale_launch_profile_routing() { + let mut selected = HashMap::from([ + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "anthropic-1-oauth".to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".to_string(), + "/accounts/anthropic-1".to_string(), + ), + ]); + let stale_profile = HashMap::from([ + ( + "ANTHROPIC_BASE_URL".to_string(), + "https://api.atlascloud.ai".to_string(), + ), + ("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()), + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "stale-atlas-token".to_string(), + ), + ("PATH".to_string(), "/custom/bin".to_string()), + ]); + + merge_launch_profile_environment(&ModelType::ClaudeCode, true, &mut selected, stale_profile); + + assert_eq!( + selected.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), + Some("anthropic-1-oauth") + ); + assert_eq!( + selected.get("CLAUDE_CONFIG_DIR").map(String::as_str), + Some("/accounts/anthropic-1") + ); + assert!(!selected.contains_key("ANTHROPIC_BASE_URL")); + assert!(!selected.contains_key("ANTHROPIC_MODEL")); + assert_eq!( + selected.get("PATH").map(String::as_str), + Some("/custom/bin") + ); +} + +#[test] +fn ambient_claude_profile_keeps_shell_environment_available() { + let mut command = Command::new("claude"); + apply_child_environment(&mut command, &ModelType::ClaudeCode, false, &HashMap::new()); + + assert!(command.as_std().get_envs().next().is_none()); +} + #[test] fn overloaded_error_detection() { assert!(is_api_overloaded_message("overloaded_error")); diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs index 04631c0797..3a71a55abe 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs @@ -35,6 +35,7 @@ pub(super) async fn run_acp_branch( mut cli_session_id_out: Option, sequence: &mut i64, env_vars: &HashMap, + turn_intent_id: Option<&str>, ) -> Result { // ── ACP agents (Copilot, Kiro, OpenCode, DeepSeek Harness): // bidirectional JSON-RPC ── @@ -119,7 +120,7 @@ pub(super) async fn run_acp_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir).await; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; } }) .await; diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs index f0461580e6..91706edd71 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs @@ -1,7 +1,7 @@ //! Codex app-server transport: long-lived JSON-RPC turn over stdio. //! -//! Experimental; gated by the launch-profile transport="app-server" setting -//! (see `super::super::launch_profiles::uses_codex_app_server`). +//! Native continuation episodes opt into this transport explicitly. Ordinary +//! Codex sessions retain the established per-turn `codex exec --json` path. use tokio::process::Child; @@ -23,17 +23,23 @@ pub(super) struct AppServerOutcome { pub(super) terminal_error_message: Option, } +fn is_successful_turn_status(status: &str) -> bool { + status == "completed" +} + #[allow(clippy::too_many_arguments)] pub(super) async fn run_codex_app_server_branch( mut child: Child, session_id: String, account_id: Option<&str>, oauth_retry_eligible: bool, - effective_input: String, + user_input: String, + developer_instructions: Option, working_dir: &str, cli_resume_id: Option, model: Option<&str>, launch_profile: &ResolvedCliLaunchProfile, + config: Option, image_paths: Vec, session_timeout: tokio::time::Duration, pre_message_snapshot_id: Option, @@ -42,9 +48,11 @@ pub(super) async fn run_codex_app_server_branch( sequence: &mut i64, mut codex_app_server_turn_ok: bool, attempt_stderr: &mut super::CliStderrCollector, + allow_native_context_recovery: bool, + turn_intent_id: Option<&str>, ) -> Result { // ── Codex app-server: long-lived JSON-RPC over stdio ── - // (experimental; gate = launch-profile transport="app-server"). + // The resolved launch profile may explicitly select the legacy exec path. // Same CODEX_HOME / auth env as the exec shell-out — the spawn // above already carries env_vars. use crate::agent_sessions::cli::parsers::codex_app_server; @@ -56,14 +64,17 @@ pub(super) async fn run_codex_app_server_branch( let turn = codex_app_server::CodexAppServerTurn { session_id: session_id.clone(), - task: effective_input.clone(), + user_input, + developer_instructions, working_dir: working_dir.to_string(), resume_thread_id: cli_resume_id.clone(), model: super::super::command::codex_app_server_thread_model(model), permission_mode: launch_profile.permission_mode, + config, image_paths: image_paths.clone(), + allow_native_context_recovery, }; - let app_server_handle = tokio::spawn(async move { + let mut app_server_handle = tokio::spawn(async move { codex_app_server::run_app_server_turn(stdin, stdout, turn, chunk_tx).await }); @@ -85,14 +96,14 @@ pub(super) async fn run_codex_app_server_branch( if is_cli_chunk_replay_unsafe(&chunk) { replay_unsafe_output_seen = true; } - // Bind the rollout-compatible thread id as soon as the - // session_start chunk carries it (mirrors the parser - // early-binding in the exec branch below): native - // transcript replay, managed-mirror dedup, and - // live-status attribution all key on it, and a crash - // mid-turn must not orphan the rollout. - if cli_session_id_out.is_none() { - if let Some(ref tid) = chunk.thread_id { + // Bind the rollout-compatible thread id as soon as a lifecycle + // chunk carries it (mirrors the parser early-binding in the exec + // branch below). Context recovery may natively fork the thread + // inside this same transport turn, so a DIFFERENT id must replace + // the initial binding immediately; otherwise an instant follow-up + // can resume the overflowing source UUID and compact again. + if let Some(ref tid) = chunk.thread_id { + if cli_session_id_out.as_deref() != Some(tid.as_str()) { cli_session_id_out = Some(tid.clone()); if let Err(err) = persistence::update_cli_session_id_for_account(&session_id, account_id, tid) @@ -115,16 +126,32 @@ pub(super) async fn run_codex_app_server_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir).await; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; } }) .await; - let timed_out = timeout_result.is_err(); + let mut timed_out = timeout_result.is_err(); + if timed_out { + app_server_handle.abort(); + terminal_error_message = Some("Codex app-server turn timed out".to_string()); + } + + // The chunk channel normally closes only after the protocol task exits, + // but a leaked sender or stuck cleanup must not turn the four-hour turn + // deadline into an unbounded JoinHandle wait. + let join_result = + tokio::time::timeout(tokio::time::Duration::from_secs(5), &mut app_server_handle).await; + if join_result.is_err() { + timed_out = true; + codex_app_server_turn_ok = false; + terminal_error_message = Some("Codex app-server shutdown timed out".to_string()); + app_server_handle.abort(); + } - match app_server_handle.await { - Ok(Ok(result)) => { + match join_result { + Ok(Ok(Ok(result))) if !timed_out => { cli_session_id_out = Some(result.thread_id); - codex_app_server_turn_ok = result.turn_status != "failed"; + codex_app_server_turn_ok = is_successful_turn_status(&result.turn_status); if let Some(ref usage) = result.usage { let round_model = usage.model.as_deref().or(model); if let Err(err) = session_persistence::token_usage::insert_token_usage_record( @@ -147,7 +174,7 @@ pub(super) async fn run_codex_app_server_branch( } } } - Ok(Err(err)) if !timed_out => { + Ok(Ok(Err(err))) if !timed_out => { if oauth_retry_eligible && !replay_unsafe_output_seen && is_cli_oauth_failure_message(&err) @@ -159,7 +186,7 @@ pub(super) async fn run_codex_app_server_branch( Some(super::super::super::parsers::canonicalize_cli_error_message(&err)); } } - Err(join_err) => { + Ok(Err(join_err)) if !timed_out => { tracing::error!("[CodeSession] app-server task panicked: {}", join_err); terminal_error_message = Some(format!("Codex app-server task failed: {join_err}")); } @@ -206,3 +233,16 @@ pub(super) async fn run_codex_app_server_branch( terminal_error_message, }) } + +#[cfg(test)] +mod tests { + use super::is_successful_turn_status; + + #[test] + fn only_completed_app_server_turns_succeed() { + assert!(is_successful_turn_status("completed")); + assert!(!is_successful_turn_status("failed")); + assert!(!is_successful_turn_status("interrupted")); + assert!(!is_successful_turn_status("cancelled")); + } +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs index 6da30b38bb..ef8c7cc073 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs @@ -53,6 +53,7 @@ pub(super) async fn run_standard_branch( mut cli_session_id_out: Option, sequence: &mut i64, attempt_stderr: &mut super::CliStderrCollector, + turn_intent_id: Option<&str>, ) -> StandardOutcome { let mut retryable_oauth_message: Option = None; let mut retryable_overload_message: Option = None; @@ -198,7 +199,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -227,7 +234,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -252,7 +265,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -273,7 +292,7 @@ pub(super) async fn run_standard_branch( } cli_plan_active = false; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; if cli_plan_approval_gate_triggered && !cli_plan_gate_announced { cli_plan_gate_announced = true; tracing::info!( @@ -285,7 +304,7 @@ pub(super) async fn run_standard_branch( // instead of holding Stop for up to the 45s drain window // while the child process winds down. The final // status_changed after child exit is idempotent. - flush_and_broadcast(&session_id).await; + flush_and_broadcast(&session_id, turn_intent_id).await; // The plan card supersedes any hook-derived // waiting/working entry for this turn. clear_live_status( @@ -435,7 +454,7 @@ pub(super) async fn run_standard_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, chunk, &snapshot_working_dir).await; } - emit_chunk(chunk, &session_id, sequence).await; + emit_chunk(chunk, &session_id, sequence, turn_intent_id).await; } } diff --git a/src-tauri/src/agent_sessions/cli/skill_sync.rs b/src-tauri/src/agent_sessions/cli/skill_sync.rs index b6b74bae6e..7320bdf3a7 100644 --- a/src-tauri/src/agent_sessions/cli/skill_sync.rs +++ b/src-tauri/src/agent_sessions/cli/skill_sync.rs @@ -401,6 +401,9 @@ mod tests { #[test] fn provider_skill_catalog_is_stable_bounded_and_keeps_load_paths() { + // The production loader also reads global/user roots. Keep those + // roots fixed while sibling tests switch the process-wide home. + let _sandbox = crate::test_utils::test_env::sandbox(); let workspace = tempfile::tempdir().expect("workspace"); for index in 0..80 { let skill_dir = workspace diff --git a/src-tauri/src/agent_sessions/cli/tests/mod.rs b/src-tauri/src/agent_sessions/cli/tests/mod.rs index f80bfd166f..047552587e 100644 --- a/src-tauri/src/agent_sessions/cli/tests/mod.rs +++ b/src-tauri/src/agent_sessions/cli/tests/mod.rs @@ -1,6 +1,5 @@ // Test modules for cli_session pub mod runner_command_tests; -pub mod runner_tests; pub mod stages_tests; pub mod types_tests; diff --git a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs index 4817d9bc6c..9c9c1b7227 100644 --- a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs +++ b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs @@ -2,6 +2,7 @@ use super::command::{ build_command_with_launch_profile, codex_app_server_thread_model, map_claude_model, map_claude_model_variant, CliCommandBuildRequest, }; +use super::input_assembly::CliTurnEnvelope; use super::launch_profiles::{ bare_command_for_agent, default_args_for_mode, default_env_for_mode, defaults_for_agent, CliPermissionMode, ResolvedCliLaunchProfile, @@ -13,6 +14,7 @@ struct TestCommandBuildOptions<'a> { agent: &'a ModelType, model: Option<&'a str>, task: &'a str, + provider_context: Option<&'a str>, resume_id: Option<&'a str>, api_key: Option<&'a str>, endpoint: Option<&'a str>, @@ -29,6 +31,7 @@ impl<'a> TestCommandBuildOptions<'a> { agent, model: None, task, + provider_context: None, resume_id: None, api_key: None, endpoint: None, @@ -77,12 +80,16 @@ fn build_command_from_options(options: TestCommandBuildOptions<'_>) -> Vecbuild\n\n", + "focused file" + ); + let cmd = build_command!( + ModelType::ClaudeCode, + task = user_text, + provider_context = Some(provider_context), + resume_id = Some("native-claude-uuid"), + ); + + let prompt_index = cmd.iter().position(|part| part == "-p").expect("-p"); + assert_eq!(cmd[prompt_index + 1], user_text); + assert!(!cmd[prompt_index + 1].contains("")); + + let system_index = cmd + .iter() + .position(|part| part == "--append-system-prompt") + .expect("native Claude system context flag"); + assert_eq!(cmd[system_index + 1], provider_context); + assert!(cmd[system_index + 1].contains("")); + assert!(cmd[system_index + 1].contains("")); +} + #[test] fn build_codex_with_mcp_profile_before_task() { let cmd = build_command!( @@ -547,7 +582,7 @@ fn app_server_profile(agent: &ModelType, transport: Option<&str>) -> ResolvedCli fn uses_codex_app_server_requires_codex_and_explicit_flag() { use super::launch_profiles::uses_codex_app_server; - // Default (no flag) stays on the shell-out path. + // Ordinary Codex turns retain the per-turn shell-out path. let default_profile = app_server_profile(&ModelType::Codex, None); assert!(!uses_codex_app_server(&ModelType::Codex, &default_profile)); @@ -555,7 +590,7 @@ fn uses_codex_app_server_requires_codex_and_explicit_flag() { let opted_in = app_server_profile(&ModelType::Codex, Some("app-server")); assert!(uses_codex_app_server(&ModelType::Codex, &opted_in)); - // Unknown transport values are ignored. + // Unknown transport values stay off app-server. let unknown = app_server_profile(&ModelType::Codex, Some("websocket")); assert!(!uses_codex_app_server(&ModelType::Codex, &unknown)); @@ -567,11 +602,12 @@ fn uses_codex_app_server_requires_codex_and_explicit_flag() { #[test] fn build_codex_app_server_argv_is_bare_subcommand() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("fix the bug"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: None, - task: "fix the bug", + turn: &turn, resume_id: Some("thread-123"), api_key: None, endpoint: None, @@ -587,14 +623,41 @@ fn build_codex_app_server_argv_is_bare_subcommand() { assert_eq!(cmd[1..], ["app-server".to_string()]); } +#[test] +fn build_codex_default_profile_uses_exec_argv() { + let profile = app_server_profile(&ModelType::Codex, None); + let turn = CliTurnEnvelope::new("native task travels over JSON-RPC"); + let cmd = build_command_with_launch_profile(CliCommandBuildRequest { + agent: &ModelType::Codex, + launch_profile: &profile, + model: Some("gpt-5.5-high"), + turn: &turn, + resume_id: Some("thread-123"), + api_key: None, + endpoint: None, + mode: None, + repo_path: Some("/workspace"), + additional_dirs: &[], + mcp_config_path: None, + codex_mcp_profile: None, + }); + + assert_eq!(command_name(&cmd[0]), "codex"); + assert_eq!(cmd[1], "exec"); + assert!(cmd.contains(&"model_reasoning_effort=\"high\"".to_string())); + assert!(cmd.iter().any(|part| part.contains("native task"))); + assert!(cmd.contains(&"thread-123".to_string())); +} + #[test] fn build_codex_app_server_argv_keeps_gpt_5_6_max_overrides() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("write tests"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: Some("gpt-5.6-sol-max-fast"), - task: "write tests", + turn: &turn, resume_id: None, api_key: None, endpoint: None, @@ -617,13 +680,14 @@ fn build_codex_app_server_argv_keeps_gpt_5_6_max_overrides() { } #[test] -fn build_codex_app_server_argv_keeps_mcp_profile_before_subcommand() { +fn build_codex_app_server_argv_never_exposes_mcp_profile() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("write tests"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: None, - task: "write tests", + turn: &turn, resume_id: Some("thread-123"), api_key: None, endpoint: None, @@ -634,5 +698,6 @@ fn build_codex_app_server_argv_keeps_mcp_profile_before_subcommand() { codex_mcp_profile: Some("orgii-mcp-random"), }); - assert_eq!(cmd[1..], ["--profile", "orgii-mcp-random", "app-server"]); + assert_eq!(cmd[1..], ["app-server"]); + assert!(!cmd.contains(&"orgii-mcp-random".to_string())); } diff --git a/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs b/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs deleted file mode 100644 index b4f7ad0960..0000000000 --- a/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs +++ /dev/null @@ -1,52 +0,0 @@ -use super::helpers::strip_ide_context; - -// ============================================ -// strip_ide_context -// ============================================ - -#[test] -fn strip_ide_context_no_tag() { - assert_eq!(strip_ide_context("Hello world"), "Hello world"); -} - -#[test] -fn strip_ide_context_with_tag() { - let input = "some dataActual message"; - assert_eq!(strip_ide_context(input), "Actual message"); -} - -#[test] -fn strip_ide_context_in_middle() { - let input = "Before data After"; - assert_eq!(strip_ide_context(input), "Before After"); -} - -#[test] -fn strip_ide_context_trailing_whitespace_newlines() { - let input = "data\n\nHello"; - assert_eq!(strip_ide_context(input), "Hello"); -} - -#[test] -fn strip_ide_context_missing_close_tag() { - let input = "data without close"; - assert_eq!(strip_ide_context(input), "data without close"); -} - -#[test] -fn strip_ide_context_missing_open_tag() { - let input = "just text"; - assert_eq!(strip_ide_context(input), "just text"); -} - -#[test] -fn strip_ide_context_empty() { - let input = "Content"; - assert_eq!(strip_ide_context(input), "Content"); -} - -#[test] -fn strip_ide_context_only_tag() { - let input = "data"; - assert_eq!(strip_ide_context(input), ""); -} diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs index cccc56d0a7..c5671e82d6 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs @@ -53,7 +53,28 @@ pub async fn es_remove_by_id_prefix( prefix: String, ) -> Result { let sid = state.resolve_session_id(session_id)?; - let removed = state.with_store_mut(&sid, |store| store.remove_by_id_prefix(&prefix)); + let removed_ids = state + .with_store_opt(&sid, |store| { + store + .events() + .iter() + .filter(|event| event.id.starts_with(&prefix)) + .map(|event| event.id.clone()) + .collect::>() + }) + .unwrap_or_default(); + if !removed_ids.is_empty() { + let persist_sid = sid.clone(); + let persisted_ids = removed_ids.clone(); + tokio::task::spawn_blocking(move || { + session_persistence::delete_events_by_ids(&persist_sid, &persisted_ids) + .map(|_| ()) + .map_err(|err| err.to_string()) + }) + .await + .map_err(|err| format!("es_remove_by_id_prefix worker failed: {err}"))??; + } + let removed = state.with_store_mut(&sid, |store| store.remove_by_ids(&removed_ids)); if removed > 0 { schedule_notify(&app, &state, &sid); } @@ -61,24 +82,30 @@ pub async fn es_remove_by_id_prefix( } /// Remove frontend-injected user placeholders after the backend user turn arrives. -/// `matching_contents` + `older_than` scope removal to placeholders that are -/// echoed by one of those messages or predate the newest real user turn; -/// omitted, every placeholder in the session is removed. +/// Intent-bearing placeholders are removed only by their matching durable +/// turn id. Legacy placeholders use `matching_contents` + `older_than`. +/// Omit the whole scope to remove every placeholder in the session. #[tauri::command] pub async fn es_remove_synthetic_user_inputs( app: AppHandle, state: State<'_, EventStoreState>, session_id: Option, matching_contents: Option>, + matching_turn_intent_ids: Option>, older_than: Option, ) -> Result { let sid = state.resolve_session_id(session_id)?; let removed = state.with_store_mut(&sid, |store| { - store.remove_synthetic_user_inputs( - matching_contents - .as_deref() - .map(|contents| (contents, older_than.as_deref())), - ) + let is_scoped = matching_contents.is_some() + || matching_turn_intent_ids.is_some() + || older_than.is_some(); + store.remove_synthetic_user_inputs(is_scoped.then(|| { + ( + matching_contents.as_deref().unwrap_or_default(), + matching_turn_intent_ids.as_deref().unwrap_or_default(), + older_than.as_deref(), + ) + })) }); if removed > 0 { schedule_notify(&app, &state, &sid); diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs index e63a46e24f..b25923f1a4 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs @@ -100,6 +100,46 @@ fn cached_event_normalizes_legacy_string_args() { ); } +#[test] +fn cached_event_repairs_legacy_image_only_raw_user_metadata() { + let images = ["data:image/png;base64,QUJD", "data:image/webp;base64,REVG"]; + let cached = session_persistence::CachedEvent { + id: "claudecode-user-107".to_string(), + session_id: "imported-session-legacy-image-user".to_string(), + event_type: "raw".to_string(), + function_name: Some("user".to_string()), + thread_id: None, + args_json: "{}".to_string(), + result_json: serde_json::json!({ + "images": images, + "message": { "content": "", "role": "user" }, + "type": "user" + }) + .to_string(), + content: "Activity".to_string(), + created_at: "2026-08-21T00:00:00.000Z".to_string(), + meta_json: Some( + serde_json::json!({ + "source": "assistant", + "displayText": "Activity", + "displayStatus": "completed", + "displayVariant": "tool_call", + "activityStatus": "agent", + "uiCanonical": "user" + }) + .to_string(), + ), + history_sequence: Some(107), + }; + + let event = cached_event_to_session_event(&cached); + + assert_eq!(event.source, EventSource::User); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_text, ""); + assert_eq!(event.result["images"], serde_json::json!(images)); +} + #[test] fn rust_authoritative_ids_do_not_match() { assert!(!is_ts_placeholder_id( diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs index 55b8a8dac7..19f70d400b 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs @@ -5,6 +5,9 @@ use std::collections::{HashMap, HashSet}; use crate::agent_sessions::event_pipeline::extractors::extract_event_data_with_bounded_shell_output; use crate::agent_sessions::event_pipeline::ingestion::function_map::resolve_ui_canonical; +use crate::agent_sessions::event_pipeline::ingestion::normalizer::{ + is_raw_user_message, raw_message_text, +}; use crate::agent_sessions::event_pipeline::payload_compaction::is_compacted_event; use crate::agent_sessions::event_pipeline::types::{ ActivityStatus, EventDisplayStatus, EventDisplayVariant, EventSource, SessionEvent, @@ -750,21 +753,37 @@ pub(crate) fn cached_event_to_session_event(cached: &sqlite_cache::CachedEvent) } }; - let source_str = meta_obj - .and_then(|m| m.get("source")) - .and_then(|v| v.as_str()) - .unwrap_or("system"); - let source = match source_str { - "user" => EventSource::User, - "assistant" => EventSource::Assistant, - _ => EventSource::System, + // Old replay snapshots could persist image-only raw user messages with + // assistant renderer metadata because the original normalizer used text + // presence as its role signal. The durable payload is unambiguous + // (`type=user` / `message.role=user`) and is the canonical source of + // truth. Repair that contradiction on read through the same predicate as + // live ingestion so historical Team Sessions remain losslessly portable. + let is_semantic_raw_user = + matches!(cached.event_type.as_str(), "raw" | "raw_event") && is_raw_user_message(&result); + let source = if is_semantic_raw_user { + EventSource::User + } else { + match meta_obj + .and_then(|m| m.get("source")) + .and_then(|v| v.as_str()) + .unwrap_or("system") + { + "user" => EventSource::User, + "assistant" => EventSource::Assistant, + _ => EventSource::System, + } }; - let display_text = meta_obj - .and_then(|m| m.get("displayText")) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| cached.function_name.as_deref().unwrap_or("unknown")) - .to_string(); + let display_text = if is_semantic_raw_user { + raw_message_text(&result).unwrap_or_default() + } else { + meta_obj + .and_then(|m| m.get("displayText")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| cached.function_name.as_deref().unwrap_or("unknown")) + .to_string() + }; let display_status_str = meta_obj .and_then(|m| m.get("displayStatus")) @@ -773,12 +792,16 @@ pub(crate) fn cached_event_to_session_event(cached: &sqlite_cache::CachedEvent) let display_status = serde_json::from_value(serde_json::json!(display_status_str)) .unwrap_or(EventDisplayStatus::Running); - let display_variant_str = meta_obj - .and_then(|m| m.get("displayVariant")) - .and_then(|v| v.as_str()) - .unwrap_or("tool_call"); - let display_variant = serde_json::from_value(serde_json::json!(display_variant_str)) - .unwrap_or(EventDisplayVariant::ToolCall); + let display_variant = if is_semantic_raw_user { + EventDisplayVariant::Message + } else { + let display_variant_str = meta_obj + .and_then(|m| m.get("displayVariant")) + .and_then(|v| v.as_str()) + .unwrap_or("tool_call"); + serde_json::from_value(serde_json::json!(display_variant_str)) + .unwrap_or(EventDisplayVariant::ToolCall) + }; let activity_status_str = meta_obj .and_then(|m| m.get("activityStatus")) diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs index 55c72d81c4..eab9ceffc2 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs @@ -39,6 +39,25 @@ fn is_synthetic_user_input(event: &SessionEvent) -> bool { .unwrap_or(false) } +/// A provider-rejected frontend turn has no native transcript row to reload. +/// Keep that terminal delivery projection in the existing event cache so the +/// failed bubble (and its retry/edit payload) survives a renderer/app restart. +/// Pending/accepted placeholders remain transient: their durable owners are +/// the message-delivery registry and provider transcript respectively. +fn is_persisted_failed_user_delivery(event: &SessionEvent) -> bool { + is_synthetic_user_input(event) + && event + .result + .get("deliveryStatus") + .and_then(|value| value.as_str()) + == Some("failed") + && event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()) +} + /// Set the active repository context on a session's store. #[tauri::command] pub async fn es_set_repo_context( @@ -112,19 +131,17 @@ pub async fn es_append( // only resurface as duplicate user bubbles on the next replay merge. // Their edit path (`cli_agent_truncate_after_chunk`) truncates chunks by // timestamp and does not consult the `events` table. - let user_event_ids: Vec<_> = if session_providers::skips_event_cache_save(&sid) { - Vec::new() - } else { - events - .iter() - .filter(|event| { - event.source == EventSource::User - && !is_ts_placeholder_id(&event.id) - && !is_synthetic_user_input(event) - }) - .map(|event| event.id.clone()) - .collect() - }; + let skips_event_cache_save = session_providers::skips_event_cache_save(&sid); + let user_event_ids: Vec<_> = events + .iter() + .filter(|event| { + event.source == EventSource::User + && !is_ts_placeholder_id(&event.id) + && (!skips_event_cache_save && !is_synthetic_user_input(event) + || is_persisted_failed_user_delivery(event)) + }) + .map(|event| event.id.clone()) + .collect(); let user_events = state.with_store_mut(&sid, |store| { store.append(events); @@ -197,14 +214,98 @@ pub async fn es_update_by_id( id: String, patch: SessionEventPatch, ) -> Result { + use super::event_conversion::session_event_to_cached_event; + use super::{save_events_retry, BULK_WRITE_MAX_RETRIES}; + let sid = state.resolve_session_id(session_id)?; - let found = state.with_store_mut(&sid, |store| store.update_by_id(&id, &patch)); + let (found, failed_delivery) = state.with_store_mut(&sid, |store| { + let found = store.update_by_id(&id, &patch); + let failed_delivery = found + .then(|| store.get_by_id(&id)) + .flatten() + .filter(|event| is_persisted_failed_user_delivery(event)) + .map(session_event_to_cached_event); + (found, failed_delivery) + }); if found { schedule_notify(&app, &state, &sid); } + if let Some(failed_delivery) = failed_delivery { + let persist_sid = sid.clone(); + let persist_result = tokio::task::spawn_blocking(move || { + save_events_retry( + "es_update_failed_user_delivery", + &persist_sid, + &[failed_delivery], + BULK_WRITE_MAX_RETRIES, + ) + }) + .await + .map_err(|err| format!("es_update_by_id spawn_blocking join failed: {err}"))?; + // This update is the ownership-transfer barrier for a rejected send: + // the durable queue may retire its recovery row only after the failed + // transcript projection is queryable from SQLite. Returning success + // after a write failure leaves the bubble only in renderer memory and + // makes it disappear on restart. + persist_result?; + } Ok(found) } +#[cfg(test)] +mod failed_user_delivery_tests { + use super::*; + + fn synthetic_delivery(status: &str, turn_intent_id: Option<&str>) -> SessionEvent { + let display_status = match status { + "pending" => "pending", + "failed" => "failed", + _ => "completed", + }; + serde_json::from_value(serde_json::json!({ + "id": "queued-user:q1:", + "chunk_id": null, + "sessionId": "cliagent-test", + "createdAt": "2026-09-05T00:00:00Z", + "functionName": "user_message", + "uiCanonical": "", + "actionType": "raw", + "args": {}, + "result": { + "syntheticUserInput": true, + "deliveryStatus": status, + "turnIntentId": turn_intent_id, + "message": { "role": "user", "content": "retry me" } + }, + "source": "user", + "displayText": "retry me", + "displayStatus": display_status, + "displayVariant": "message", + "activityStatus": "agent" + })) + .expect("valid delivery event") + } + + #[test] + fn only_terminal_identified_failed_user_delivery_is_persisted() { + assert!(is_persisted_failed_user_delivery(&synthetic_delivery( + "failed", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "pending", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "sent", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "failed", None + ))); + } +} + /// Merge tool_result events into their matching tool_call events (pure transform). /// /// Uses O(1) HashMap lookup instead of the TS-side O(n) `findIndex`. diff --git a/src-tauri/src/agent_sessions/event_pipeline/derived.rs b/src-tauri/src/agent_sessions/event_pipeline/derived.rs index ba04de2772..a573f81a05 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/derived.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/derived.rs @@ -85,11 +85,19 @@ pub fn is_visible_in_chat(event: &SessionEvent) -> bool { return false; } - // Hide user messages from failed turns. When an `agent:error` arrives the - // frontend marks the preceding user message as `Failed`; the original text - // stays in the store for audit / replay but should not appear in chat so - // retries don't produce a wall of duplicate inputs. - if event.source == EventSource::User && event.display_status == EventDisplayStatus::Failed { + // Legacy runtime failures mark the accepted user turn `Failed`; keep those + // hidden to avoid duplicating the provider's error card. A frontend + // delivery failure is different: the provider never accepted it, and the + // failed bubble is the user's only retry/edit surface. + let is_delivery_failure = event + .result + .get("deliveryStatus") + .and_then(|value| value.as_str()) + == Some("failed"); + if event.source == EventSource::User + && event.display_status == EventDisplayStatus::Failed + && !is_delivery_failure + { return false; } diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs index 4bc8b948e7..db34db4b4f 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs @@ -6,8 +6,6 @@ //! Uses dual accumulators so interleaved thinking + message streams //! (e.g. from Copilot ACP) are handled correctly. -use std::collections::HashSet; - use chrono::DateTime; use crate::agent_sessions::event_pipeline::ingestion::types::RawActivityChunk; @@ -337,25 +335,37 @@ fn extract_message_content(chunk: &RawActivityChunk) -> String { String::new() } +/// Collapse adjacent replays of the same identified assistant row only. +/// Distinct complete messages may legitimately have identical bodies. Delta +/// accumulation belongs to the streaming groups above; text equality alone +/// cannot prove that a complete native message is a replay of those deltas. fn dedup_assistant_messages(chunks: Vec) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut result = Vec::with_capacity(chunks.len()); + let mut result: Vec = Vec::with_capacity(chunks.len()); for chunk in chunks { let at = chunk.action_type.as_deref().unwrap_or(""); let func = chunk.function.as_deref().unwrap_or(""); let is_assistant = at == "assistant" || func == "message"; - if is_assistant { - let text = extract_message_content(&chunk).trim().to_string(); - if !text.is_empty() && seen.contains(&text) { - continue; - } - if !text.is_empty() { - seen.insert(text); - } + // Equal text is not message identity, even within one assistant run. + // Only collapse an exact replay of an explicitly identified row. + if is_assistant + && result.last().is_some_and(|previous| { + chunk.chunk_id.as_deref().is_some_and(|id| !id.is_empty()) + && previous.chunk_id == chunk.chunk_id + && previous.session_id == chunk.session_id + && previous.thread_id == chunk.thread_id + && previous.process_id == chunk.process_id + && previous.call_id == chunk.call_id + && previous.created_at == chunk.created_at + && previous.action_type == chunk.action_type + && previous.function == chunk.function + && previous.args == chunk.args + && previous.result == chunk.result + }) + { + continue; } - result.push(chunk); } diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs index 741c0649b3..7420c2807e 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs @@ -144,6 +144,10 @@ fn infer_display_variant( function_name: &str, result: &serde_json::Value, ) -> EventDisplayVariant { + if action_type == "context_compacted" || function_name == "context_compacted" { + return EventDisplayVariant::Message; + } + let is_failed_session_end = (action_type == "session_end" || function_name == "session_end") && result.get("success").and_then(|value| value.as_bool()) == Some(false) && ["error", "error_message", "observation"] @@ -159,7 +163,7 @@ fn infer_display_variant( } // User messages - if (action_type == "raw" || action_type == "raw_event") && raw_message_text(result).is_some() { + if (action_type == "raw" || action_type == "raw_event") && is_raw_user_message(result) { return EventDisplayVariant::Message; } @@ -420,7 +424,10 @@ fn infer_activity_status(action_type: &str, result: &serde_json::Value) -> Activ // ============================================================================ fn infer_source(action_type: &str, result: &serde_json::Value) -> EventSource { - if (action_type == "raw" || action_type == "raw_event") && raw_message_text(result).is_some() { + if action_type == "context_compacted" { + return EventSource::System; + } + if (action_type == "raw" || action_type == "raw_event") && is_raw_user_message(result) { return EventSource::User; } EventSource::Assistant @@ -440,7 +447,14 @@ fn infer_display_text( let result_obj = result.as_object(); match action_type { - "raw" | "raw_event" => raw_message_text(result).unwrap_or_else(|| "Activity".to_string()), + "raw" | "raw_event" if is_raw_user_message(result) => { + // Image-only user turns deliberately have no display text. Their + // attachment list renders the bubble; fabricating "Activity" + // would alter the native conversation when it is materialized. + raw_message_text(result).unwrap_or_default() + } + + "raw" | "raw_event" => "Activity".to_string(), "assistant" | "assistant_delta" | "message" | "message_delta" => result_obj .and_then(|o| str_field(o, "observation").or_else(|| str_field(o, "content"))) @@ -533,11 +547,32 @@ fn infer_display_text( } } -fn raw_message_text(result: &serde_json::Value) -> Option { - let obj = result.as_object()?; - if obj.get("type").and_then(|v| v.as_str()) != Some("user") && !obj.contains_key("message") { +pub(crate) fn is_raw_user_message(result: &serde_json::Value) -> bool { + let Some(obj) = result.as_object() else { + return false; + }; + let result_type = obj.get("type").and_then(|value| value.as_str()); + if result_type == Some("user") { + return true; + } + if result_type.is_some() { + return false; + } + let Some(message) = obj.get("message") else { + return false; + }; + message + .as_object() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()) + .is_none_or(|role| role == "user") +} + +pub(crate) fn raw_message_text(result: &serde_json::Value) -> Option { + if !is_raw_user_message(result) { return None; } + let obj = result.as_object()?; let text = obj .get("message") diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs index 31a8b4b433..e81f449fa5 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs @@ -181,7 +181,7 @@ fn test_empty_thinking_filtered() { } #[test] -fn test_dedup_assistant_messages() { +fn test_distinct_adjacent_assistant_messages_with_equal_text_are_preserved() { let chunks = vec![ RawActivityChunk { chunk_id: Some("msg1".to_string()), @@ -216,7 +216,90 @@ fn test_dedup_assistant_messages() { ]; let result = consolidate_activity_chunks(&chunks); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 2); + let replay = vec![chunks[0].clone(), chunks[0].clone()]; + assert_eq!(consolidate_activity_chunks(&replay).len(), 1); + + let mut different_scope = chunks[0].clone(); + different_scope.session_id = Some("another-session".to_string()); + assert_eq!( + consolidate_activity_chunks(&[chunks[0].clone(), different_scope]).len(), + 2 + ); + + let mut updated = chunks[0].clone(); + updated.result = Some(serde_json::json!({"content": "Hello! ", "is_delta": false})); + assert_eq!( + consolidate_activity_chunks(&[chunks[0].clone(), updated]).len(), + 2 + ); +} + +#[test] +fn test_repeated_assistant_answer_in_a_later_turn_is_kept() { + // The model may legitimately give the same answer to a repeated question. + // Only a consecutive streaming duplicate collapses; a user turn between + // two equal answers makes the second one a real assistant message. + let assistant = |id: &str, at: &str| RawActivityChunk { + chunk_id: Some(id.to_string()), + action_type: Some("assistant".to_string()), + function: Some("message".to_string()), + result: Some(serde_json::json!({ + "content": "The interrupted review did not reach a final answer.", + "is_delta": false + })), + created_at: Some(at.to_string()), + session_id: Some("sess-1".to_string()), + args: None, + thread_id: None, + process_id: None, + call_id: None, + }; + let chunks = vec![ + assistant("asst-1", "2025-01-15T10:00:01.000Z"), + RawActivityChunk { + chunk_id: Some("user-2".to_string()), + action_type: Some("raw".to_string()), + function: Some("user_message".to_string()), + result: Some(serde_json::json!({ "content": "ask it again" })), + created_at: Some("2025-01-15T10:00:02.000Z".to_string()), + session_id: Some("sess-1".to_string()), + args: None, + thread_id: None, + process_id: None, + call_id: None, + }, + assistant("asst-3", "2025-01-15T10:00:03.000Z"), + ]; + + let result = consolidate_activity_chunks(&chunks); + let ids: Vec<&str> = result + .iter() + .filter_map(|c| c.chunk_id.as_deref()) + .collect(); + assert_eq!(ids, vec!["asst-1", "user-2", "asst-3"]); +} + +#[test] +fn test_distinct_assistant_messages_a_b_a_are_preserved() { + let chunks: Vec = ["A", "B", "A"] + .iter() + .enumerate() + .map(|(index, text)| RawActivityChunk { + chunk_id: Some(format!("distinct-{index}")), + session_id: Some("same-turn".to_string()), + action_type: Some("assistant".to_string()), + function: Some("message".to_string()), + result: Some(serde_json::json!({"content": text, "is_delta": false})), + ..Default::default() + }) + .collect(); + let result = consolidate_activity_chunks(&chunks); + let ids: Vec<&str> = result + .iter() + .filter_map(|row| row.chunk_id.as_deref()) + .collect(); + assert_eq!(ids, vec!["distinct-0", "distinct-1", "distinct-2"]); } #[test] diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs index da8efb2383..216a72e15f 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs @@ -139,6 +139,47 @@ fn test_normalize_user_message() { ); } +#[test] +fn image_only_raw_user_message_keeps_user_role_without_fabricated_text() { + let chunk = RawActivityChunk { + chunk_id: Some("chunk-image-only".to_string()), + action_type: Some("raw".to_string()), + result: Some(serde_json::json!({ + "type": "user", + "message": {"content": "", "role": "user"}, + "images": ["data:image/png;base64,aGVsbG8="] + })), + created_at: Some("2025-01-15T10:30:03.000Z".to_string()), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.source, EventSource::User); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_text, ""); + assert_eq!( + event.result["images"], + serde_json::json!(["data:image/png;base64,aGVsbG8="]) + ); +} + +#[test] +fn raw_assistant_envelope_with_text_does_not_become_a_user_message() { + let chunk = RawActivityChunk { + chunk_id: Some("chunk-raw-assistant".to_string()), + action_type: Some("raw".to_string()), + result: Some(serde_json::json!({ + "type": "assistant", + "message": {"content": "provider plumbing", "role": "assistant"} + })), + created_at: Some("2025-01-15T10:30:03.000Z".to_string()), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.source, EventSource::Assistant); +} + #[test] fn test_raw_tool_use_message_is_not_user_message() { let chunk = RawActivityChunk { @@ -444,6 +485,27 @@ fn test_ui_canonical_precomputed() { assert_eq!(event_thinking.ui_canonical, "thinking"); } +#[test] +fn native_context_compaction_is_a_system_message() { + let chunk = RawActivityChunk { + action_type: Some("context_compacted".to_string()), + function: Some("context_compacted".to_string()), + result: Some(serde_json::json!({ + "success": true, + "native": true, + "provider": "codex", + })), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.function_name, "context_compacted"); + assert_eq!(event.ui_canonical, "context_compacted"); + assert_eq!(event.source, EventSource::System); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_status, EventDisplayStatus::Completed); +} + #[test] fn ingest_backfills_opencode_subagent_prompt_from_child_session() { let chunk = RawActivityChunk { diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs b/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs index 7da09e877c..f99d7155ba 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs @@ -7,8 +7,10 @@ use std::collections::HashSet; use super::helpers::{ is_authoritative_transcript_message, is_completed_authoritative_stream_transcript, - is_synthetic_transcript_placeholder, normalize_user_text, normalized_event_text, + is_synthetic_transcript_placeholder, logical_user_turn_key, normalize_user_text, + normalized_event_text, preserve_synthetic_turn_intent, stream_placeholder_prefix_for_authoritative, transcript_message_key, transcript_text, + user_turn_projection_authority, }; use super::{ active_shell_replays_for_session, bound_shell_replay_state, capture_shell_replay_bookmarks, @@ -55,6 +57,12 @@ impl EventStore { self.version += 1; return; } + if let Some(changed) = self.reconcile_duplicate_user_turn(&mut event) { + if changed { + self.version += 1; + } + return; + } if let Some(&idx) = self.id_index.get(&event.id) { if Self::would_downgrade_terminal_tool_call(&self.events[idx], &event) { @@ -72,7 +80,7 @@ impl EventStore { self.mark_changed(event_id); } else { if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); @@ -220,30 +228,38 @@ impl EventStore { } /// With no scope, removes every synthetic placeholder (legacy behavior). - /// A scope removes only placeholders that are echoed by one of the given - /// user-message contents, or that predate `older_than` (a placeholder - /// older than the newest real user turn can no longer receive an echo, - /// e.g. skill-pill messages whose wire content differs from the pill) — - /// a NEWER unmatched placeholder is a message whose echo has not arrived - /// yet and must survive history merges carrying older real user turns. + /// Intent-bearing placeholders are removed only by the same durable turn + /// id. Native history replay may re-stamp an older turn with a timestamp + /// later than a new optimistic row, so `older_than` is not valid evidence + /// for modern rows. Legacy placeholders retain content/time reconciliation. pub fn remove_synthetic_user_inputs( &mut self, - scope: Option<(&[String], Option<&str>)>, + scope: Option<(&[String], &[String], Option<&str>)>, ) -> usize { - let scope = scope.map(|(contents, older_than)| { + let scope = scope.map(|(contents, turn_intent_ids, older_than)| { let targets: std::collections::HashSet = contents .iter() .map(|content| normalize_user_text(content)) .collect(); - (targets, older_than.map(str::to_string)) + let intent_targets: std::collections::HashSet = + turn_intent_ids.iter().cloned().collect(); + (targets, intent_targets, older_than.map(str::to_string)) }); let should_remove = |event: &SessionEvent| -> bool { if event.source != EventSource::User || !is_synthetic_transcript_placeholder(event) { return false; } - let Some((targets, older_than)) = &scope else { + let Some((targets, intent_targets, older_than)) = &scope else { return true; }; + if let Some(turn_intent_id) = event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + return intent_targets.contains(turn_intent_id); + } let content_matched = transcript_text(event) .map(|text| targets.contains(&normalize_user_text(&text))) .unwrap_or(false); @@ -449,30 +465,76 @@ impl EventStore { /// marker, while backend parser/runtime events do not. Matching is scoped to /// transcript source and normalized message text so legitimate repeated /// authoritative messages are preserved. - pub(super) fn remove_matching_synthetic_transcript_placeholders( + pub(super) fn remove_matching_synthetic_transcript_placeholder( &mut self, - authoritative: &SessionEvent, + authoritative: &mut SessionEvent, ) -> usize { let Some(authoritative_key) = transcript_message_key(authoritative) else { return 0; }; - let removed_ids = self.matching_synthetic_transcript_placeholder_ids(&authoritative_key); - self.remove_events_by_ids(removed_ids) + let Some((removed_id, turn_intent_id)) = + self.matching_synthetic_transcript_placeholder(&authoritative_key) + else { + return 0; + }; + preserve_synthetic_turn_intent(authoritative, turn_intent_id.as_deref()); + self.remove_events_by_ids(vec![removed_id]) } - fn matching_synthetic_transcript_placeholder_ids( + /// Reconcile two transport projections of the same accepted user turn. + /// + /// SDE first emits a low-level `user_input` activity and then persists the + /// canonical `user_message`. Both are useful producer-side signals, but + /// EventStore is the transcript boundary and must expose exactly one row. + /// Return `Some(changed)` when the incoming event was consumed here. + pub(super) fn reconcile_duplicate_user_turn( + &mut self, + incoming: &mut SessionEvent, + ) -> Option { + let incoming_key = logical_user_turn_key(incoming)?; + let existing_idx = self.events.iter().position(|existing| { + existing.id != incoming.id + && logical_user_turn_key(existing).as_deref() == Some(incoming_key.as_str()) + })?; + + if user_turn_projection_authority(incoming) + <= user_turn_projection_authority(&self.events[existing_idx]) + { + return Some(false); + } + + incoming.created_at = self.events[existing_idx].created_at.clone(); + preserve_first_insert_replay(&self.events[existing_idx], incoming); + let removed_id = self.events[existing_idx].id.clone(); + let incoming_id = incoming.id.clone(); + self.events[existing_idx] = incoming.clone(); + self.mark_removed(removed_id); + self.mark_changed(incoming_id); + self.rebuild_indexes(); + Some(true) + } + + fn matching_synthetic_transcript_placeholder( &self, authoritative_key: &(EventSource, String), - ) -> Vec { + ) -> Option<(String, Option)> { self.events .iter() - .filter(|event| { + .find(|event| { is_synthetic_transcript_placeholder(event) && transcript_message_key(event).as_ref() == Some(authoritative_key) }) - .map(|event| event.id.clone()) - .collect() + .map(|event| { + ( + event.id.clone(), + event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .map(str::to_string), + ) + }) } pub(super) fn remove_events_by_ids(&mut self, removed_ids: Vec) -> usize { diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs b/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs index 7eefc5d053..b7e258ffae 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs @@ -3,7 +3,7 @@ //! These helpers operate on `SessionEvent` slices and values but hold no //! store state themselves, making them easy to test in isolation. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::agent_sessions::event_pipeline::types::{ EventDisplayStatus, EventDisplayVariant, EventSource, SessionEvent, @@ -89,6 +89,88 @@ pub(super) fn is_authoritative_transcript_message(event: &SessionEvent) -> bool transcript_message_key(event).is_some() && !is_synthetic_transcript_placeholder(event) } +/// Stable identity of one accepted user turn across the frontend placeholder, +/// the Rust runtime's low-level `user_input` row, and the persisted +/// `user_message` row. +/// +/// Modern submissions carry `turnIntentId`. Older Agent rows still expose the +/// same relationship through `user_message.result.messageId == user_input.id`. +/// Text is deliberately not part of this key: two consecutive turns may have +/// identical words and must remain distinct. +pub(super) fn logical_user_turn_key(event: &SessionEvent) -> Option { + if event.source != EventSource::User { + return None; + } + if let Some(turn_intent_id) = event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + return Some(format!("intent:{turn_intent_id}")); + } + let message_id = event + .result + .get("messageId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .or_else(|| { + (event.function_name == "user_input" && !event.id.is_empty()) + .then_some(event.id.as_str()) + })?; + Some(format!("message:{message_id}")) +} + +/// Prefer the single durable projection when several transport layers report +/// the same logical user turn. +pub(super) fn user_turn_projection_authority(event: &SessionEvent) -> u8 { + if is_synthetic_transcript_placeholder(event) { + return 0; + } + if event + .result + .get("backendPersisted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return 3; + } + if event.function_name == "user_message" { + return 2; + } + 1 +} + +/// Collapse duplicate user-turn projections during full hydration while +/// retaining the first slot in timeline order and the strongest event body. +pub(super) fn reconcile_loaded_duplicate_user_turns(events: &mut Vec) -> usize { + let mut owner_by_key = HashMap::::new(); + let mut reconciled = Vec::with_capacity(events.len()); + let mut removed = 0usize; + + for mut event in events.drain(..) { + let Some(key) = logical_user_turn_key(&event) else { + reconciled.push(event); + continue; + }; + let Some(&existing_idx) = owner_by_key.get(&key) else { + owner_by_key.insert(key, reconciled.len()); + reconciled.push(event); + continue; + }; + removed += 1; + if user_turn_projection_authority(&event) + > user_turn_projection_authority(&reconciled[existing_idx]) + { + event.created_at = reconciled[existing_idx].created_at.clone(); + reconciled[existing_idx] = event; + } + } + + *events = reconciled; + removed +} + // --------------------------------------------------------------------------- // Placeholder / turn helpers // --------------------------------------------------------------------------- @@ -96,23 +178,45 @@ pub(super) fn is_authoritative_transcript_message(event: &SessionEvent) -> bool pub(super) fn reconcile_loaded_synthetic_transcript_placeholders( events: &mut Vec, ) -> usize { - let authoritative_keys: Vec<(EventSource, String)> = events + let synthetic_candidates: Vec<((EventSource, String), String, Option)> = events .iter() - .filter(|event| is_authoritative_transcript_message(event)) - .filter_map(transcript_message_key) - .collect(); - - let removed_ids: HashSet = events - .iter() - .filter(|event| { - is_synthetic_transcript_placeholder(event) - && transcript_message_key(event) - .as_ref() - .is_some_and(|key| authoritative_keys.iter().any(|candidate| candidate == key)) + .filter(|event| is_synthetic_transcript_placeholder(event)) + .filter_map(|event| { + transcript_message_key(event).map(|key| { + ( + key, + event.id.clone(), + event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .map(str::to_string), + ) + }) }) - .map(|event| event.id.clone()) .collect(); + let mut removed_ids = HashSet::new(); + for authoritative in events + .iter_mut() + .filter(|event| is_authoritative_transcript_message(event)) + { + let Some(authoritative_key) = transcript_message_key(authoritative) else { + continue; + }; + let Some((_, candidate_id, turn_intent_id)) = + synthetic_candidates + .iter() + .find(|(candidate_key, candidate_id, _)| { + candidate_key == &authoritative_key && !removed_ids.contains(candidate_id) + }) + else { + continue; + }; + removed_ids.insert(candidate_id.clone()); + preserve_synthetic_turn_intent(authoritative, turn_intent_id.as_deref()); + } + let removed = removed_ids.len(); if removed > 0 { events.retain(|event| !removed_ids.contains(&event.id)); @@ -120,6 +224,35 @@ pub(super) fn reconcile_loaded_synthetic_transcript_placeholders( removed } +/// Preserve ORGII's durable user-intent identity when a provider transcript +/// row replaces the optimistic frontend placeholder. Provider JSONL rows do +/// not carry this id, but turn indexing and conversation publishing require it. +pub(super) fn preserve_synthetic_turn_intent( + authoritative: &mut SessionEvent, + turn_intent_id: Option<&str>, +) { + let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) else { + return; + }; + if authoritative + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()) + { + return; + } + if !authoritative.result.is_object() { + authoritative.result = serde_json::json!({}); + } + if let Some(result) = authoritative.result.as_object_mut() { + result.insert( + "turnIntentId".to_string(), + serde_json::Value::String(turn_intent_id.to_string()), + ); + } +} + pub(super) fn is_turn_placeholder(event: &SessionEvent) -> bool { event.function_name == TURN_PLACEHOLDER_FUNCTION_NAME || event.id.starts_with(TURN_PLACEHOLDER_ID_PREFIX) diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs b/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs index c985c1f52a..ad6bdf0392 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs @@ -7,7 +7,8 @@ use std::collections::HashSet; use super::helpers::{ is_authoritative_transcript_message, is_turn_placeholder, loaded_turn_ids_from_events, - placeholder_turn_id, reconcile_loaded_synthetic_transcript_placeholders, timeline_source_order, + placeholder_turn_id, reconcile_loaded_duplicate_user_turns, + reconcile_loaded_synthetic_transcript_placeholders, timeline_source_order, }; use super::{ active_shell_replays_for_session, capture_shell_replay_bookmarks, hydrate_shell_event_bounded, @@ -43,6 +44,7 @@ impl EventStore { hydration_mode: HydrationMode, ) { reconcile_loaded_synthetic_transcript_placeholders(&mut events); + reconcile_loaded_duplicate_user_turns(&mut events); for event in &mut events { hydrate_shell_event_bounded(event); } @@ -74,8 +76,12 @@ impl EventStore { continue; } self.stamp_repo(&mut event); + if let Some(replaced) = self.reconcile_duplicate_user_turn(&mut event) { + changed |= replaced; + continue; + } if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); @@ -137,6 +143,10 @@ impl EventStore { } else { hydrate_shell_event_bounded(&mut event); } + if let Some(replaced) = self.reconcile_duplicate_user_turn(&mut event) { + changed |= replaced; + continue; + } if event.action_type == "tool_result" { if let Some(ref call_id) = event.call_id { if let Some(&call_idx) = self.call_id_index.get(call_id) { @@ -248,7 +258,7 @@ impl EventStore { changed = true; } else { if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); diff --git a/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs index 74e5c57010..a866ac2dff 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs @@ -149,6 +149,17 @@ fn test_chat_hides_failed_user_message() { assert!(!is_visible_in_chat(&event)); } +#[test] +fn test_chat_shows_failed_user_delivery_for_retry() { + let mut event = make_user_message("u_delivery_failed"); + event.display_status = EventDisplayStatus::Failed; + event.result = serde_json::json!({ + "deliveryStatus": "failed", + "deliveryError": "backend unavailable", + }); + assert!(is_visible_in_chat(&event)); +} + #[test] fn test_chat_shows_completed_user_message() { let event = make_user_message("u_ok"); diff --git a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs index 7f20cb26fb..8b56cf789e 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs @@ -576,6 +576,7 @@ fn test_scoped_synthetic_removal_keeps_unechoed_newer_placeholder() { // the fresh follow-up whose echo has not arrived yet. let removed = store.remove_synthetic_user_inputs(Some(( &["first message".to_string()], + &[], Some("2026-08-14T10:00:00Z"), ))); @@ -598,6 +599,7 @@ fn test_scoped_synthetic_removal_drops_placeholder_predating_newest_real_turn() // newest real user turn instead. let removed = store.remove_synthetic_user_inputs(Some(( &["expanded yaml payload".to_string()], + &[], Some("2026-08-14T09:30:00Z"), ))); @@ -605,6 +607,39 @@ fn test_scoped_synthetic_removal_drops_placeholder_predating_newest_real_turn() assert!(store.get_by_id("user-input-stale-pill").is_none()); } +#[test] +fn test_scoped_synthetic_removal_does_not_timestamp_evict_new_intent() { + let mut store = EventStore::new(); + let mut pending = make_synthetic_user_event( + "user-input-next", + "continue exploring", + "2026-08-14T10:00:00Z", + ); + pending.result["turnIntentId"] = serde_json::json!("turn-next"); + store.set(vec![pending]); + + // A replayed OLD turn may be materialized later and therefore carry a + // misleadingly newer timestamp. It cannot settle the current intent. + let old_contents = vec!["old request".to_string()]; + let old_intents = vec!["turn-old".to_string()]; + let removed = store.remove_synthetic_user_inputs(Some(( + &old_contents, + &old_intents, + Some("2026-08-14T11:00:00Z"), + ))); + assert_eq!(removed, 0); + assert!(store.get_by_id("user-input-next").is_some()); + + let matching_intents = vec!["turn-next".to_string()]; + let removed = store.remove_synthetic_user_inputs(Some(( + &[], + &matching_intents, + Some("2026-08-14T11:00:00Z"), + ))); + assert_eq!(removed, 1); + assert!(store.get_by_id("user-input-next").is_none()); +} + #[test] fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() { let mut store = EventStore::new(); @@ -612,7 +647,10 @@ fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() synthetic.source = EventSource::User; synthetic.function_name = "user_message".to_string(); synthetic.ui_canonical = "user_message".to_string(); - synthetic.result = serde_json::json!({ "syntheticUserInput": true }); + synthetic.result = serde_json::json!({ + "syntheticUserInput": true, + "turnIntentId": "turn-live-1", + }); synthetic.chunk_id = None; synthetic.display_text = "hello from user".to_string(); @@ -628,7 +666,13 @@ fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() store.merge_events(vec![backend]); assert!(store.get_by_id("user-input-synthetic").is_none()); - assert!(store.get_by_id("user-input-cliagent-real").is_some()); + assert_eq!( + store + .get_by_id("user-input-cliagent-real") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-live-1") + ); } #[test] @@ -637,7 +681,10 @@ fn test_set_reconciles_persisted_matching_synthetic_placeholder() { let mut synthetic = make_event("user-input-synthetic", "raw"); synthetic.source = EventSource::User; synthetic.function_name = "user_message".to_string(); - synthetic.result = serde_json::json!({ "syntheticUserInput": true }); + synthetic.result = serde_json::json!({ + "syntheticUserInput": true, + "turnIntentId": "turn-reload-1", + }); synthetic.display_text = "persisted duplicate".to_string(); let mut backend = make_event("user-input-real", "raw"); @@ -648,7 +695,46 @@ fn test_set_reconciles_persisted_matching_synthetic_placeholder() { store.set(vec![synthetic, backend]); assert!(store.get_by_id("user-input-synthetic").is_none()); - assert!(store.get_by_id("user-input-real").is_some()); + assert_eq!( + store + .get_by_id("user-input-real") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-reload-1") + ); +} + +#[test] +fn test_repeated_user_text_reconciles_one_intent_per_authoritative_row() { + let mut store = EventStore::new(); + let mut first = make_synthetic_user_event( + "user-input-synthetic-1", + "repeat me", + "2026-08-29T00:00:00Z", + ); + first.result["turnIntentId"] = serde_json::json!("turn-repeat-1"); + let mut second = make_synthetic_user_event( + "user-input-synthetic-2", + "repeat me", + "2026-08-29T00:00:01Z", + ); + second.result["turnIntentId"] = serde_json::json!("turn-repeat-2"); + store.append(vec![first, second]); + + let mut authoritative = make_event("user-input-real-1", "raw"); + authoritative.source = EventSource::User; + authoritative.display_text = "repeat me".to_string(); + store.merge_events(vec![authoritative]); + + assert!(store.get_by_id("user-input-synthetic-1").is_none()); + assert!(store.get_by_id("user-input-synthetic-2").is_some()); + assert_eq!( + store + .get_by_id("user-input-real-1") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-repeat-1") + ); } #[test] @@ -673,6 +759,90 @@ fn test_merge_authoritative_message_keeps_legitimate_repeated_user_text() { assert!(store.get_by_id("user-input-second").is_some()); } +fn make_runtime_user_projection( + id: &str, + function_name: &str, + turn_intent_id: &str, + backend_persisted: bool, +) -> SessionEvent { + let mut event = make_event(id, "raw"); + event.source = EventSource::User; + event.function_name = function_name.to_string(); + event.ui_canonical = function_name.to_string(); + event.display_text = "one logical user turn".to_string(); + event.result = serde_json::json!({ + "type": "user", + "message": { "content": "one logical user turn", "role": "user" }, + "turnIntentId": turn_intent_id, + "backendPersisted": backend_persisted, + }); + event +} + +#[test] +fn test_merge_user_turn_prefers_persisted_projection_by_turn_intent() { + let mut store = EventStore::new(); + let mut live = + make_runtime_user_projection("message-42", "user_input", "turn-intent-42", false); + live.created_at = "2026-08-30T10:00:00.000Z".to_string(); + let mut persisted = make_runtime_user_projection( + "user-message-message-42", + "user_message", + "turn-intent-42", + true, + ); + persisted.result["messageId"] = serde_json::json!("message-42"); + persisted.created_at = "2026-08-30T10:00:00.001Z".to_string(); + + store.append(vec![live]); + store.merge_events(vec![persisted]); + + assert_eq!(store.event_count(), 1); + assert!(store.get_by_id("message-42").is_none()); + let canonical = store + .get_by_id("user-message-message-42") + .expect("persisted projection survives"); + assert_eq!(canonical.created_at, "2026-08-30T10:00:00.000Z"); + assert_eq!(canonical.result["backendPersisted"], true); +} + +#[test] +fn test_late_low_level_user_projection_cannot_duplicate_persisted_turn() { + let mut store = EventStore::new(); + let mut persisted = make_runtime_user_projection( + "user-message-message-43", + "user_message", + "turn-intent-43", + true, + ); + persisted.result["messageId"] = serde_json::json!("message-43"); + let live = make_runtime_user_projection("message-43", "user_input", "turn-intent-43", false); + + store.append(vec![persisted]); + store.merge_events(vec![live]); + + assert_eq!(store.event_count(), 1); + assert!(store.get_by_id("message-43").is_none()); + assert!(store.get_by_id("user-message-message-43").is_some()); +} + +#[test] +fn test_hydration_collapses_legacy_message_id_pair_without_text_dedup() { + let mut store = EventStore::new(); + let live = make_runtime_user_projection("message-44", "user_input", "", false); + let mut persisted = + make_runtime_user_projection("user-message-message-44", "user_message", "", true); + persisted.result["messageId"] = serde_json::json!("message-44"); + let repeated = make_runtime_user_projection("message-45", "user_input", "", false); + + store.set(vec![live, persisted, repeated]); + + assert_eq!(store.event_count(), 2); + assert!(store.get_by_id("message-44").is_none()); + assert!(store.get_by_id("user-message-message-44").is_some()); + assert!(store.get_by_id("message-45").is_some()); +} + #[test] fn test_merge_authoritative_message_keeps_non_matching_synthetic_text() { let mut store = EventStore::new(); diff --git a/src-tauri/src/agent_sessions/mod.rs b/src-tauri/src/agent_sessions/mod.rs index caa033c241..5122d427b0 100644 --- a/src-tauri/src/agent_sessions/mod.rs +++ b/src-tauri/src/agent_sessions/mod.rs @@ -20,3 +20,4 @@ pub mod external_cli_adapter; pub mod follow_up_suggestions; pub mod human; pub mod session_directory; +pub mod turn_intents; diff --git a/src-tauri/src/agent_sessions/session_directory/patch.rs b/src-tauri/src/agent_sessions/session_directory/patch.rs index a9fa118a49..c7612afeec 100644 --- a/src-tauri/src/agent_sessions/session_directory/patch.rs +++ b/src-tauri/src/agent_sessions/session_directory/patch.rs @@ -466,7 +466,22 @@ pub async fn session_patch( session_id: String, patch: SessionPatch, ) -> Result<(), String> { - let identity_changed = patch.model.is_some(); + let identity_changed = patch.model.is_some() || patch.account_id.is_some(); + // Model/account identity participates in provider-native publication. + // Serialize that patch with interrupt/finalize/follow-up so an in-flight + // runner that started as account A can never be published through a newly + // patched account B binding. The UI remains responsive; the selection is + // committed for the next turn once the current provider boundary settles. + let _identity_guard = if identity_changed { + Some( + crate::agent_sessions::cli::session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await, + ) + } else { + None + }; let switched_to_project = patch.product_mode.as_deref() == Some("project"); let renamed = patch .name diff --git a/src-tauri/src/agent_sessions/turn_intents.rs b/src-tauri/src/agent_sessions/turn_intents.rs new file mode 100644 index 0000000000..4b1624ea0a --- /dev/null +++ b/src-tauri/src/agent_sessions/turn_intents.rs @@ -0,0 +1,99 @@ +//! Provider-neutral durable turn-intent reads. +//! +//! Canonical conversation recovery uses the same `session_turn_intents` rows +//! already written by Agent and CLI runtimes. Keeping this query above either +//! adapter avoids a second frontend receipt/claim database. + +use serde::Serialize; + +// One IPC call is deliberately bounded so renderer shutdown/update can tear +// it down promptly. The frontend chains these windows while the exact durable +// intent remains queued/running; a legitimate long provider turn therefore +// has no arbitrary wall-clock deadline. +const MAX_TURN_WAIT_MS: u64 = 60_000; +const TURN_WAIT_INITIAL_POLL_MS: u64 = 100; +const TURN_WAIT_MAX_POLL_MS: u64 = 1_000; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnIntentStatus { + pub session_id: String, + pub turn_intent_id: String, + pub status: String, + pub updated_at: String, +} + +fn read_status( + session_id: &str, + turn_intent_id: &str, +) -> Result, String> { + session_persistence::turn_intents::read_intent(session_id, turn_intent_id) + .map(|row| { + row.map(|intent| SessionTurnIntentStatus { + session_id: intent.session_id, + turn_intent_id: intent.turn_intent_id, + status: intent.status.as_str().to_string(), + updated_at: intent.updated_at, + }) + }) + .map_err(|err| format!("DB error: {err}")) +} + +#[tauri::command] +pub async fn session_turn_intent_status( + session_id: String, + turn_intent_id: String, +) -> Result, String> { + if session_id.is_empty() || turn_intent_id.is_empty() { + return Err("session_id and turn_intent_id are required".to_string()); + } + tokio::task::spawn_blocking(move || read_status(&session_id, &turn_intent_id)) + .await + .map_err(|err| format!("Task error: {err}"))? +} + +#[tauri::command] +pub async fn session_wait_for_turn_terminal( + session_id: String, + turn_intent_id: String, + timeout_ms: u64, +) -> Result { + if session_id.is_empty() || turn_intent_id.is_empty() { + return Err("session_id and turn_intent_id are required".to_string()); + } + let timeout_ms = timeout_ms.clamp(1, MAX_TURN_WAIT_MS); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let mut poll_ms = TURN_WAIT_INITIAL_POLL_MS; + + loop { + let read_session_id = session_id.clone(); + let read_turn_intent_id = turn_intent_id.clone(); + let intent = tokio::task::spawn_blocking(move || { + read_status(&read_session_id, &read_turn_intent_id) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + + if let Some(intent) = intent.filter(|row| { + matches!( + row.status.as_str(), + "completed" | "failed" | "cancelled" | "stale" | "coalesced" | "rejected" + ) + }) { + return Ok(intent); + } + + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(format!( + "turn {turn_intent_id} for session {session_id} timed out" + )); + } + tokio::time::sleep(std::cmp::min( + tokio::time::Duration::from_millis(poll_ms), + deadline - now, + )) + .await; + poll_ms = poll_ms.saturating_mul(2).min(TURN_WAIT_MAX_POLL_MS); + } +} diff --git a/src-tauri/src/app/setup_hook/services.rs b/src-tauri/src/app/setup_hook/services.rs index a41f7440dd..e66b183e55 100644 --- a/src-tauri/src/app/setup_hook/services.rs +++ b/src-tauri/src/app/setup_hook/services.rs @@ -23,38 +23,45 @@ pub(crate) fn start_backend_services( tracing::info!("[Transport] Transport layer initialized"); } } - match agent_sessions::cli::persistence::sweep_stale_sessions() { - Ok(orphans) if !orphans.is_empty() => { - tracing::info!( - count = orphans.len(), - "[CLI Sessions] swept stale sessions to failed" - ); - // Kill the orphaned CLI process trees. After a backend - // restart (crash, quit, or dev-mode Rust recompile) the - // old CLI agents keep running unsupervised — the new - // backend has no RUNNING_SESSIONS handle, so the user's - // cancel button can't reach them. Resume previously did - // this lazily per-session; do it eagerly for all. - tauri::async_runtime::spawn(async move { - for (session_id, pid) in orphans { - tracing::info!( - "[CLI Sessions] terminating orphaned process tree pid={} (session {})", - pid, - session_id - ); - agent_sessions::cli::session_runner::terminate_process_tree( - pid, - &session_id, - ) - .await; - } - }); + let stale_cli_processes = match agent_sessions::cli::persistence::sweep_stale_sessions() { + Ok(orphans) => { + if !orphans.is_empty() { + tracing::info!( + count = orphans.len(), + "[CLI Sessions] swept stale sessions to failed" + ); + } + orphans } - Ok(_) => {} Err(err) => { tracing::warn!(error = %err, "[CLI Sessions] Failed to sweep stale sessions"); + Vec::new() } - } + }; + // Reuse the existing startup lifecycle: first terminate provider processes + // left behind by the previous backend, then make one bounded pass over + // durable native-App catalog receipts. There is no timer or parallel + // coordinator, and clean sessions are never visited. + tauri::async_runtime::spawn(async move { + for (session_id, pid) in stale_cli_processes { + tracing::info!( + "[CLI Sessions] terminating orphaned process tree pid={} (session {})", + pid, + session_id + ); + agent_sessions::cli::session_runner::terminate_process_tree(pid, &session_id).await; + } + let (repaired, failed) = agent_sessions::cli::native_materializer:: + reconcile_pending_native_catalog_refreshes_on_startup() + .await; + if repaired > 0 || failed > 0 { + tracing::info!( + repaired, + failed, + "[CLI Sessions] reconciled pending native App catalog refreshes" + ); + } + }); system_services::app_menu::setup_menu_events(app.handle()); tracing::info!("[AppMenu] Menu event handlers registered"); diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 2e1ac025f9..1760cf9dbf 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -404,6 +404,9 @@ api::mobile_bridge::commands::mobile_remote_notify_cloud_auth_changed, api::mobile_bridge::commands::mobile_remote_sync_sidebar_sessions, // Code session commands (spawn CLI agents, manage sessions) agent_sessions::cli::commands::cli_agent_create, +agent_sessions::cli::native_materializer::materialize_native_conversation, +agent_sessions::cli::native_materializer::synchronize_native_conversation, +agent_sessions::cli::native_materializer::discard_native_conversation_materialization, agent_sessions::cli::commands::cli_agent_message, agent_sessions::cli::commands::cli_agent_approval_response, agent_sessions::cli::commands::cli_agent_status, @@ -412,6 +415,7 @@ agent_sessions::cli::commands::cli_agent_history_mutation, agent_sessions::cli::commands::cli_agent_cancel, agent_sessions::cli::commands::cli_agent_tui_release, agent_sessions::cli::commands::cli_agent_chunks, +agent_sessions::cli::commands::cli_agent_transcript_revision, agent_sessions::cli::commands::cli_agent_transcript_path, agent_sessions::cli::commands::cli_agent_truncate_after_chunk, agent_sessions::cli::commands::cli_agent_delete, @@ -1058,6 +1062,8 @@ agent_sessions::session_directory::commands::session_aggregate_list, agent_sessions::session_directory::commands::session_native_sidebar_page, agent_sessions::session_directory::commands::session_external_history_sidebar_list, agent_sessions::session_directory::patch::session_patch, +agent_sessions::turn_intents::session_turn_intent_status, +agent_sessions::turn_intents::session_wait_for_turn_terminal, // Flow Awareness commands (user activity tracking for intent inference) agent_core::flow_awareness::commands::flow_record_activity, agent_core::flow_awareness::commands::flow_record_activities, diff --git a/src-tauri/src/infrastructure/dev_bundled_auth.rs b/src-tauri/src/infrastructure/dev_bundled_auth.rs index f9a207e150..5c7066e83c 100644 --- a/src-tauri/src/infrastructure/dev_bundled_auth.rs +++ b/src-tauri/src/infrastructure/dev_bundled_auth.rs @@ -179,8 +179,10 @@ fn decode_webkit_string(value: ValueRef<'_>) -> Option { ValueRef::Text(bytes) => std::str::from_utf8(bytes).ok().map(str::to_owned), ValueRef::Blob(bytes) if bytes.len() % 2 == 0 => { let units = bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])); + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)); char::decode_utf16(units) .collect::>() .ok() diff --git a/src-tauri/src/orgtrack/history_commands/scan.rs b/src-tauri/src/orgtrack/history_commands/scan.rs index 3d988b1d2a..ca60236fb9 100644 --- a/src-tauri/src/orgtrack/history_commands/scan.rs +++ b/src-tauri/src/orgtrack/history_commands/scan.rs @@ -311,38 +311,74 @@ pub struct ExternalHistoryAppOpenPlanWire { pub source_available: bool, } -/// Plan how to reopen an imported external session in the app that owns it. -/// `Ok(None)` when the session is unknown, a subagent child, or its source -/// has no verified per-session deep link (everything but Claude Code and -/// Codex today). +fn external_history_app_open_plan_from_conn( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + if let Some((plan, session)) = + orgtrack_core::sources::app_open::app_open_plan_for_cached_session(conn, session_id)? + { + let source_available = + !session.source_path.is_empty() && Path::new(&session.source_path).exists(); + return Ok(Some(ExternalHistoryAppOpenPlanWire { + plan, + source_available, + })); + } + + // Managed native sessions use their current account/profile binding, not + // the append-only discovery ledger: after A -> B -> A, this must open the + // same native conversation the next CLI turn will resume. + let Some(session) = crate::agent_sessions::cli::persistence::get_session(session_id) + .map_err(|error| format!("Failed to read managed session {session_id}: {error}"))? + else { + return Ok(None); + }; + let Some((binding, native_id)) = + crate::agent_sessions::cli::native_transcript::current_native_store_key_for_session( + &session, + )? + else { + return Ok(None); + }; + let Some(plan) = orgtrack_core::sources::app_open::app_open_plan(binding.source, &native_id) + else { + return Ok(None); + }; + let source_available = + crate::agent_sessions::cli::native_materializer::native_app_transcript_path( + &session, &native_id, + )? + .is_some(); + Ok(Some(ExternalHistoryAppOpenPlanWire { + plan, + source_available, + })) +} + +/// Plan how to reopen an imported or managed native session in the app that +/// owns it. `Ok(None)` when the session is unknown, has no current native +/// binding, is an imported subagent child, or its source has no verified +/// per-session deep link (everything but Claude Code and Codex today). #[tauri::command] pub async fn external_history_app_open_plan( session_id: String, ) -> Result, String> { tokio::task::spawn_blocking(move || { let conn = open_cache_conn()?; - let Some((plan, session)) = - orgtrack_core::sources::app_open::app_open_plan_for_cached_session(&conn, &session_id)? - else { - return Ok(None); - }; - let source_available = - !session.source_path.is_empty() && Path::new(&session.source_path).exists(); - Ok(Some(ExternalHistoryAppOpenPlanWire { - plan, - source_available, - })) + external_history_app_open_plan_from_conn(&conn, &session_id) }) .await .map_err(|err| format!("Task join error: {err}"))? } -/// Open an imported external session in the app that owns it. +/// Open an imported or managed native session in the app that owns it. /// -/// The deep link is rebuilt from the cache row here instead of being -/// accepted from the frontend, so the webview never names a URL the host -/// hands to the OS: the only links this can fire are the uuid-validated -/// vendor routes [`orgtrack_core::sources::app_open`] knows how to spell. +/// The deep link is rebuilt from the authoritative imported cache row or +/// managed native binding here instead of being accepted from the frontend, +/// so the webview never names a URL the host hands to the OS: the only links +/// this can fire are the uuid-validated vendor routes +/// [`orgtrack_core::sources::app_open`] knows how to spell. /// That also keeps the `opener:allow-open-url` capability scope limited to /// `http(s)`, since no custom-scheme URL ever crosses the IPC boundary. /// @@ -359,14 +395,10 @@ pub async fn external_history_open_in_app( let deep_link = tokio::task::spawn_blocking(move || { let conn = open_cache_conn()?; - let Some((plan, _)) = - orgtrack_core::sources::app_open::app_open_plan_for_cached_session(&conn, &session_id)? - else { - return Err(format!( - "No native app deep link for imported session {session_id}" - )); + let Some(plan) = external_history_app_open_plan_from_conn(&conn, &session_id)? else { + return Err(format!("No native app deep link for session {session_id}")); }; - Ok(plan.deep_link) + Ok(plan.plan.deep_link) }) .await .map_err(|err| format!("Task join error: {err}"))??; @@ -375,3 +407,209 @@ pub async fn external_history_open_in_app( .open_url(deep_link.clone(), None::<&str>) .map_err(|err| format!("Failed to open {deep_link}: {err}")) } + +#[cfg(test)] +mod managed_app_open_plan_tests { + use super::*; + use std::fs; + + use crate::agent_sessions::cli::persistence::{self, CreateCodeSessionParams}; + use crate::test_utils::test_env; + + const CLAUDE_A_UUID: &str = "11111111-1111-4111-8111-111111111111"; + const CLAUDE_B_UUID: &str = "22222222-2222-4222-8222-222222222222"; + const CODEX_UUID: &str = "33333333-3333-4333-8333-333333333333"; + + fn create_managed_session( + session_id: &str, + cli_agent_type: &str, + account_id: &str, + repo_path: &Path, + ) { + persistence::create_session( + session_id, + &CreateCodeSessionParams { + name: Some("managed app-open fixture".to_string()), + flow: None, + runner: None, + cli_agent_type: cli_agent_type.to_string(), + model: Some("test-model".to_string()), + tier: None, + account_id: Some(account_id.to_string()), + repo_path: Some(repo_path.to_string_lossy().into_owned()), + branch: None, + worktree_path: None, + worktree_base_ref: None, + proxy_token: None, + proxy_url: None, + hosted_token: None, + proxy_session_id: None, + isolate: None, + background: Some(false), + key_source: Some("own_key".to_string()), + additional_directories: None, + parent_session_id: None, + org_member_id: None, + agent_definition_id: None, + org_id: None, + project_id: None, + project_name: None, + project_slug: None, + work_item_id: None, + agent_role: None, + product_mode: None, + }, + ) + .expect("create managed native session"); + } + + fn plan_for(session_id: &str) -> Option { + let conn = open_cache_conn().expect("open imported-history cache"); + external_history_app_open_plan_from_conn(&conn, session_id) + .expect("resolve native app-open plan") + } + + fn claude_transcript_path(cwd: &Path, native_id: &str) -> std::path::PathBuf { + let project_slug: String = cwd + .to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect(); + app_paths::native_transcript_home_dir() + .join(".claude/projects") + .join(project_slug) + .join(format!("{native_id}.jsonl")) + } + + fn claude_runner_transcript_path( + account_id: &str, + cwd: &Path, + native_id: &str, + ) -> std::path::PathBuf { + let native = claude_transcript_path(cwd, native_id); + let relative = native + .strip_prefix(app_paths::native_transcript_home_dir().join(".claude")) + .expect("Claude native transcript relative path"); + app_paths::claude_code_cli_profile_dir(account_id).join(relative) + } + + fn write_file(path: &Path) { + fs::create_dir_all(path.parent().expect("fixture parent")) + .expect("create fixture directory"); + fs::write(path, b"{}\n").expect("write provider transcript fixture"); + } + + #[test] + fn managed_claude_plan_uses_the_current_account_binding() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-claude"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + CLAUDE_A_UUID, + ) + .expect("bind account A"); + + let conn = database::db::get_connection().expect("open sessions database"); + conn.execute( + "UPDATE code_sessions SET account_id = 'account-b' WHERE session_id = ?1", + [session_id], + ) + .expect("switch to account B"); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-b"), + CLAUDE_B_UUID, + ) + .expect("bind account B"); + conn.execute( + "UPDATE code_sessions SET account_id = 'account-a' WHERE session_id = ?1", + [session_id], + ) + .expect("switch back to account A"); + let cwd = fs::canonicalize(sandbox.path()).expect("canonical fixture workspace"); + write_file(&claude_transcript_path(&cwd, CLAUDE_A_UUID)); + + let plan = plan_for(session_id).expect("managed Claude plan"); + assert_eq!(plan.plan.source, "claude_code"); + assert_eq!(plan.plan.native_session_id, CLAUDE_A_UUID); + assert_eq!( + plan.plan.deep_link, + format!("claude://resume?session={CLAUDE_A_UUID}") + ); + assert!(plan.source_available); + } + + #[test] + fn managed_codex_plan_addresses_the_bound_thread_uuid() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-codex"; + create_managed_session(session_id, "codex", "openai-1", sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some("openai-1"), CODEX_UUID) + .expect("bind Codex thread"); + write_file( + &app_paths::native_transcript_home_dir() + .join(".codex/sessions/2026/09/06") + .join(format!("rollout-2026-09-06T00-00-00-{CODEX_UUID}.jsonl")), + ); + + let plan = plan_for(session_id).expect("managed Codex plan"); + assert_eq!(plan.plan.source, "codex_app"); + assert_eq!(plan.plan.native_session_id, CODEX_UUID); + assert_eq!(plan.plan.deep_link, format!("codex://threads/{CODEX_UUID}")); + assert!(plan.source_available); + } + + #[test] + fn managed_session_without_a_native_binding_has_no_plan() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-unbound"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + + assert!(plan_for(session_id).is_none()); + } + + #[test] + fn managed_plan_does_not_treat_a_runner_only_alias_as_native_app_available() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-runner-only"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + CLAUDE_A_UUID, + ) + .expect("bind account A"); + let cwd = fs::canonicalize(sandbox.path()).expect("canonical fixture workspace"); + write_file(&claude_runner_transcript_path( + "account-a", + &cwd, + CLAUDE_A_UUID, + )); + + let plan = plan_for(session_id).expect("managed Claude plan"); + assert!(!plan.source_available); + } + + #[test] + fn managed_session_with_an_unsafe_native_id_has_no_plan() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-unsafe"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + "not-a-uuid?launch=anything", + ) + .expect("bind malformed provider id fixture"); + + assert!(plan_for(session_id).is_none()); + } +} diff --git a/src/api/tauri/externalHistory/appOpen.ts b/src/api/tauri/externalHistory/appOpen.ts index e11c4102a7..6b5bc4009d 100644 --- a/src/api/tauri/externalHistory/appOpen.ts +++ b/src/api/tauri/externalHistory/appOpen.ts @@ -1,8 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; /** - * Backend plan for reopening an imported external session in the vendor's - * own app via a per-session deep link (`claude://resume?session=…`, + * Backend plan for reopening an imported or managed native session in the + * vendor's own app via a per-session deep link (`claude://resume?session=…`, * `codex://threads/…`). Mirrors `ExternalHistoryAppOpenPlanWire` in * `src-tauri/src/orgtrack/history_commands/scan.rs` (camelCase JSON). * @@ -11,7 +11,7 @@ import { invoke } from "@tauri-apps/api/core"; * frontend never gets to name what the OS opens. */ export interface ExternalHistoryAppOpenPlan { - /** Imported-history source id (`claude_code` / `codex_app`). */ + /** Native transcript source id (`claude_code` / `codex_app`). */ source: string; /** Name of the app the deep link opens, for labels and tooltips. */ appDisplayName: string; @@ -28,9 +28,9 @@ export interface ExternalHistoryAppOpenPlan { } /** - * `null` when the session is unknown to the imported-history cache, is a - * subagent child, or its source has no verified per-session app deep link - * (everything but Claude Code and Codex today). + * `null` when the session is unknown, has no current native binding, is an + * imported subagent child, or its source has no verified per-session app + * deep link (everything but Claude Code and Codex today). */ export async function externalHistoryAppOpenPlan( sessionId: string @@ -42,7 +42,7 @@ export async function externalHistoryAppOpenPlan( } /** - * Open the imported session in the app that owns it. Rejects when the + * Open the imported or managed native session in the app that owns it. * session has no deep link or the OS refuses the URL; a link that routes * nowhere cannot be detected, so callers must not treat resolution as proof * the app surfaced the conversation. diff --git a/src/api/tauri/rpc/procedures/cli.ts b/src/api/tauri/rpc/procedures/cli.ts index 2135df3dec..03c6c8406b 100644 --- a/src/api/tauri/rpc/procedures/cli.ts +++ b/src/api/tauri/rpc/procedures/cli.ts @@ -20,6 +20,10 @@ export const cli = { .input(schemas.cli.CliSessionIdInputSchema) .output(schemas.cli.CliChunksSchema) .build(), + transcriptRevision: defineProcedure("cli_agent_transcript_revision") + .input(schemas.cli.CliSessionIdInputSchema) + .output(schemas.cli.CliTranscriptRevisionSchema) + .build(), cancel: defineProcedure("cli_agent_cancel") .input(schemas.cli.CliCancelInputSchema) .output(z.boolean()) diff --git a/src/api/tauri/rpc/procedures/sessionCore.ts b/src/api/tauri/rpc/procedures/sessionCore.ts index 2b133a7a7e..42b334d118 100644 --- a/src/api/tauri/rpc/procedures/sessionCore.ts +++ b/src/api/tauri/rpc/procedures/sessionCore.ts @@ -261,8 +261,20 @@ const shellReplay = { .build(), } as const; +const turnIntents = { + status: defineProcedure("session_turn_intent_status") + .input(schemas.sessionCore.SessionTurnIntentInput) + .output(schemas.sessionCore.SessionTurnIntentStatusSchema.nullable()) + .build(), + waitForTerminal: defineProcedure("session_wait_for_turn_terminal") + .input(schemas.sessionCore.SessionTurnIntentWaitInput) + .output(schemas.sessionCore.SessionTurnIntentStatusSchema) + .build(), +} as const; + export const sessionCore = { cache, eventStore, shellReplay, + turnIntents, } as const; diff --git a/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts b/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts new file mode 100644 index 0000000000..7c5453daa8 --- /dev/null +++ b/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { SessionMessageSchema } from "../agentSession"; + +describe("SessionMessageSchema", () => { + it("normalizes nullable Rust tool fields on ordinary messages", () => { + expect( + SessionMessageSchema.parse({ + id: "message-1", + role: "assistant", + content: "done", + toolName: null, + toolInput: null, + createdAt: "2026-08-26T00:00:00.000Z", + }) + ).toMatchObject({ + id: "message-1", + role: "assistant", + content: "done", + toolName: undefined, + toolInput: undefined, + }); + }); + + it("preserves native tool metadata", () => { + expect( + SessionMessageSchema.parse({ + id: "message-2", + role: "tool_call", + content: "Tool call: read_file", + toolName: "read_file", + toolInput: '{"file_path":"README.md"}', + createdAt: "2026-08-26T00:00:00.000Z", + }) + ).toMatchObject({ + toolName: "read_file", + toolInput: '{"file_path":"README.md"}', + }); + }); +}); diff --git a/src/api/tauri/rpc/schemas/agentSession.ts b/src/api/tauri/rpc/schemas/agentSession.ts index 16f8b7cbee..dd49ed2209 100644 --- a/src/api/tauri/rpc/schemas/agentSession.ts +++ b/src/api/tauri/rpc/schemas/agentSession.ts @@ -185,8 +185,17 @@ export const SessionMessageSchema = z id: z.string(), role: z.string(), content: z.string(), - toolName: z.string().optional(), - toolInput: z.string().optional(), + // Rust serializes absent Option fields as null. Normalize those + // values at the RPC boundary so callers keep the established optional + // string contract without rejecting ordinary non-tool messages. + toolName: z.preprocess( + (value) => value ?? undefined, + z.string().optional() + ), + toolInput: z.preprocess( + (value) => value ?? undefined, + z.string().optional() + ), createdAt: z.string(), compactFromSequence: z.number().nullable().optional(), }) diff --git a/src/api/tauri/rpc/schemas/cli.ts b/src/api/tauri/rpc/schemas/cli.ts index 44866bd7fa..9fe5e67ef8 100644 --- a/src/api/tauri/rpc/schemas/cli.ts +++ b/src/api/tauri/rpc/schemas/cli.ts @@ -12,6 +12,7 @@ export const CliMessageRequestSchema = z.object({ ideContext: z.unknown().optional(), mode: z.string().optional(), images: z.array(z.string()).optional(), + allowNativeContextRecovery: z.boolean().optional(), }); /** `cli_agent_message` takes a single `request` struct, like the other @@ -57,3 +58,8 @@ export const CliStatusBatchItemSchema = z.object({ }); export const CliChunksSchema = z.array(ActivityChunkSchema); + +export const CliTranscriptRevisionSchema = z.object({ + native: z.boolean(), + revision: z.string().nullable().optional(), +}); diff --git a/src/api/tauri/rpc/schemas/sessionCore.ts b/src/api/tauri/rpc/schemas/sessionCore.ts index 5c269c0234..9a153649e2 100644 --- a/src/api/tauri/rpc/schemas/sessionCore.ts +++ b/src/api/tauri/rpc/schemas/sessionCore.ts @@ -35,6 +35,32 @@ export const EventDisplayStatusSchema = z.enum([ "awaiting_user", ]); +export const SessionTurnIntentInput = z.object({ + sessionId: z.string().min(1), + turnIntentId: z.string().min(1), +}); + +export const SessionTurnIntentWaitInput = SessionTurnIntentInput.extend({ + timeoutMs: z.number().int().positive().max(60_000), +}); + +export const SessionTurnIntentStatusSchema = z.object({ + sessionId: z.string().min(1), + turnIntentId: z.string().min(1), + status: z.enum([ + "optimistic", + "queued", + "running", + "completed", + "failed", + "cancelled", + "stale", + "coalesced", + "rejected", + ]), + updatedAt: z.string(), +}); + export const EventDisplayVariantSchema = z.enum([ "tool_call", "message", @@ -263,6 +289,7 @@ export const NullableSessionIdInput = z.object({ export const RemoveSyntheticUserInputsInput = z.object({ sessionId: z.string().nullable(), matchingContents: z.array(z.string()).optional(), + matchingTurnIntentIds: z.array(z.string()).optional(), olderThan: z.string().optional(), }); diff --git a/src/api/tauri/session/__tests__/session.test.ts b/src/api/tauri/session/__tests__/session.test.ts index 16be5a995f..d9f1d96a43 100644 --- a/src/api/tauri/session/__tests__/session.test.ts +++ b/src/api/tauri/session/__tests__/session.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; + import { type SessionAggregateRecord, toFrontendSession, @@ -39,6 +41,18 @@ function makeAggregateRecord( // ============================================================================ describe("toFrontendSession", () => { + it("preserves native mirror provenance through exact-ID hydration", () => { + const result = toFrontendSession( + makeAggregateRecord({ + sessionId: "codexapp-rollout-native-mirror", + clientOrigin: "org2", + clientOriginRaw: "orgii", + }) + ); + expect(result.clientOrigin).toBe("org2"); + expect(result.clientOriginRaw).toBe("orgii"); + expect(isPrimarySessionListSession(result)).toBe(false); + }); it("converts basic fields correctly", () => { const record = makeAggregateRecord({ sessionId: "session-abc", diff --git a/src/api/tauri/session/index.ts b/src/api/tauri/session/index.ts index d10b363df8..4d91f60ae3 100644 --- a/src/api/tauri/session/index.ts +++ b/src/api/tauri/session/index.ts @@ -147,6 +147,8 @@ export function toFrontendSession(record: SessionAggregateRecord): Session { repoRootPath: record.repoRootPath, repoRemoteUrls: record.repoRemoteUrls, storagePath: record.storagePath, + clientOrigin: record.clientOrigin, + clientOriginRaw: record.clientOriginRaw, worktreePath: record.worktreePath, worktreeBranch: record.worktreeBranch, baseBranch: record.baseBranch, diff --git a/src/app/root/e2e/helpers/cloud.ts b/src/app/root/e2e/helpers/cloud.ts index 8dc72fe5a2..f91c777f9b 100644 --- a/src/app/root/e2e/helpers/cloud.ts +++ b/src/app/root/e2e/helpers/cloud.ts @@ -528,6 +528,8 @@ export function createCloudHelpers({ store }: CloudHelperDeps) { body: comment.body, editedAt: comment.editedAt ?? null, deletedAt: comment.deletedAt ?? null, + clientDeliveryStatus: comment.clientDeliveryStatus ?? null, + clientDeliveryError: comment.clientDeliveryError ?? null, })), addressableHeadIds: addressableThreads.map( (thread) => thread.headId diff --git a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts index 67727c09e4..a4798ca007 100644 --- a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts +++ b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts @@ -35,7 +35,6 @@ import { type QueuedMessage, messageQueueAtom, queueEditingAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { todosAtom } from "@src/store/ui/todoAtom"; @@ -65,7 +64,6 @@ export function createInspectChatStateHelper(store: E2EStore) { userInitiatedCancel: boolean; turnPhase: string; turnGeneration: number; - queueFlushRequest: number; queuedMessages: Array<{ id: string; sessionId: string; @@ -223,7 +221,6 @@ export function createInspectChatStateHelper(store: E2EStore) { turnGeneration: activeSessionId ? getTurnGeneration(activeSessionId) : 0, - queueFlushRequest: store.get(queueFlushRequestAtom), queuedMessages, forceSendPendingMessages, fileReviewCount: store.get(fileReviewMapAtom).size, diff --git a/src/app/root/e2e/helpers/sessions.ts b/src/app/root/e2e/helpers/sessions.ts index 7ae60cdc9b..9228e8a023 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -67,7 +67,6 @@ import { chatWidthAtom } from "@src/store/ui/chatPanel/widthAtoms"; import { messageQueueAtom, queueEditTargetAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { @@ -292,7 +291,6 @@ export function createSessionHelpers(store: E2EStore) { store.set(sessionIdAtom, null); store.set(messageQueueAtom, []); store.set(queueEditTargetAtom, null); - store.set(queueFlushRequestAtom, 0); resetTurnLifecycleForTests(); store.set(chatImageAttachmentsAtom, []); store.set(isPendingCancelAtom, false); diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 4b44756b4b..aaa223adc1 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -663,7 +663,6 @@ export interface E2EHelpers { isPendingCancel: boolean; isQueueEditing: boolean; userInitiatedCancel: boolean; - queueFlushRequest: number; queuedMessages: Array<{ id: string; sessionId: string; content: string }>; runtimeError: string | null; rawEvents: Array<{ diff --git a/src/app/root/services/GlobalSessionSync/index.tsx b/src/app/root/services/GlobalSessionSync/index.tsx index 7c6bdd3b3c..1b28e4ef54 100644 --- a/src/app/root/services/GlobalSessionSync/index.tsx +++ b/src/app/root/services/GlobalSessionSync/index.tsx @@ -14,6 +14,7 @@ import React from "react"; import { useEventStoreBridge } from "@src/engines/SessionCore/core/store/useEventStoreBridge"; import GlobalPlanningIndicatorBridgeSync from "@src/engines/SessionCore/hooks/replay/GlobalPlanningIndicatorBridgeSync"; import { useQueueDispatch } from "@src/engines/SessionCore/hooks/session/useQueueDispatch"; +import { dispatchQueuedCanonicalConversation } from "@src/features/ConversationContinuation/canonicalConversationDispatcher"; import { useBackgroundSessionMonitor } from "@src/hooks/cliSession/useBackgroundSessionMonitor"; import { useNotificationApprovalBridge } from "@src/hooks/notifications/useNotificationApprovalBridge"; import { useNativeSessionStatusMonitor } from "@src/hooks/session/useNativeSessionStatusMonitor"; @@ -25,7 +26,7 @@ const GlobalSessionSync: React.FC = () => { useNotificationApprovalBridge(); useNativeSessionStatusMonitor(); useTeamInboxNotifications(); - useQueueDispatch(); + useQueueDispatch(dispatchQueuedCanonicalConversation); return ; }; diff --git a/src/components/ModelSelectorPill/index.tsx b/src/components/ModelSelectorPill/index.tsx index 2a4289e196..208f082374 100644 --- a/src/components/ModelSelectorPill/index.tsx +++ b/src/components/ModelSelectorPill/index.tsx @@ -63,6 +63,8 @@ interface ModelSelectorPillProps { settingsMenuDefaultAdvanced?: boolean; /** Mobile uses the combined settings menu whenever variant rows exist. */ preferCombinedSettingsMenu?: boolean; + /** Prevent opening a picker while its execution inventory is unresolved. */ + disabled?: boolean; } const ModelSelectorPill = forwardRef( @@ -84,6 +86,7 @@ const ModelSelectorPill = forwardRef( effortSegmentOverride, settingsMenuDefaultAdvanced = false, preferCombinedSettingsMenu = false, + disabled = false, }, ref ) => { @@ -182,7 +185,8 @@ const ModelSelectorPill = forwardRef( tooltipFramedWide: true, ariaLabel: ariaLabel ?? defaultLabel, active, - danger: !hasModelSelection, + danger: !disabled && !hasModelSelection, + disabled, onClick, dataTestId: dataTestId, buttonRef: modelSegmentRef, @@ -190,7 +194,7 @@ const ModelSelectorPill = forwardRef( leadingFlush: triggerLeadingFlush, }; - if (!effortEditable || !effortModelId) { + if (disabled || !effortEditable || !effortModelId) { return [modelSegment]; } @@ -248,6 +252,7 @@ const ModelSelectorPill = forwardRef( ariaLabel, dataTestId, defaultLabel, + disabled, displayParts.label, displayParts.rawValue, displayParts.thinking, @@ -279,14 +284,18 @@ const ModelSelectorPill = forwardRef( variantOptions.fastAvailableAnywhere || variantOptions.thinkingToggleable; const useCombinedSettingsMenu = - preferCombinedSettingsMenu && effortModelId && canEditVariants; + !disabled && + preferCombinedSettingsMenu && + Boolean(effortModelId) && + canEditVariants; const useSliderSettingsMenu = + !disabled && !preferCombinedSettingsMenu && effortEditable && - effortModelId && - variant && + Boolean(effortModelId) && + Boolean(variant) && variantOptions.availableLevels.length > 1; - if (useCombinedSettingsMenu || useSliderSettingsMenu) { + if ((useCombinedSettingsMenu || useSliderSettingsMenu) && effortModelId) { return ( [0]["surfaceState"]; @@ -124,6 +125,7 @@ const ChatFloatingComposer: React.FC = memo( chatPanelPosition, sessionId, inputAreaSessionId, + controlSessionId, currentPlanApproval, shouldShowCurrentPlanSurface, currentPlanSurfaceState, @@ -334,6 +336,7 @@ const ChatFloatingComposer: React.FC = memo( omitChatHeader chatPanelPosition={chatPanelPosition} sessionId={inputAreaSessionId} + controlSessionId={controlSessionId} onSubmitOverride={onSubmitOverride} customMentionOptions={customMentionOptions} topRowPills={ diff --git a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts index 5c14eb3972..cb9776412e 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import type { AgentOrgRunMemberView } from "@src/api/tauri/agent"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; export interface FollowAgentNavState { @@ -91,6 +92,18 @@ export interface ChatHistoryProps { groupChatViewActive?: boolean; onGroupChatViewToggle?: (active: boolean) => void; mutationActionsDisabled?: boolean; + /** Re-admit a failed canonical Agent intent through its canonical queue. */ + onFailedUserIntentRetry?: (input: { + displayText: string; + agentContent?: string; + imageDataUrls?: string[]; + turnIntentId?: string; + }) => Promise; + /** + * The canonical dispatch a retry of a held Agent row should carry: the + * current root and the runtime the picker shows now. + */ + resolveFailedUserIntentDispatch?: () => QueuedConversationDispatch | null; /** * Session-scoped source for the planning footer. Session-scoped surfaces * should set `isLive` to false while showing a replay slice. diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts index 9feb4dda34..65de20107f 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts @@ -7,6 +7,49 @@ import { import { buildDedupMaps, isAssistantMessageEvent } from "../dedup"; +describe("delivery failure presentation ownership", () => { + it("keeps one local Retry presentation without hiding remote or provider failures", () => { + const user = makeSessionEvent({ + source: "user", + function: "user_message", + action_type: "raw", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + turnIntentId: "turn-1", + }, + }); + const failure = makeSessionEvent({ + id: "convturn-error-turn-1", + source: "system", + action_type: "error", + result: { turnIntentId: "turn-1", error: "launch failed" }, + }); + const provider = { ...failure, id: "provider-error-1" }; + const other = { + ...failure, + id: "convturn-error-turn-2", + result: { ...failure.result, turnIntentId: "turn-2" }, + }; + expect( + buildDedupMaps([user, failure, provider, other]) + .duplicateDeliveryFailureIds + ).toEqual(new Set([failure.id])); + expect(buildDedupMaps([failure]).duplicateDeliveryFailureIds.size).toBe(0); + expect( + buildDedupMaps([ + { + ...user, + displayStatus: "completed", + result: { ...user.result, deliveryStatus: "sent" }, + }, + failure, + ]).duplicateDeliveryFailureIds.size + ).toBe(0); + }); +}); + function makeRunningToolCall( functionName: string, overrides: Record = {} @@ -354,6 +397,27 @@ describe("buildDedupMaps — user message dedup", () => { expect(duplicateUserIds.has(persisted.id)).toBe(false); }); + it("never collapses a failed optimistic row into the provider's copy of its prompt", () => { + const failed = makeUserMessage("Reply with the marker", { + id: "queued-user:queue-rejected:", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "model not supported", + message: { content: "Reply with the marker" }, + }, + }); + const landedCopy = makeUserMessage("Reply with the marker", { + id: "runlanded-user-rejected", + result: { message: { content: "Reply with the marker" } }, + }); + + const { duplicateUserIds } = buildDedupMaps([failed, landedCopy]); + + expect(duplicateUserIds.has(failed.id)).toBe(false); + expect(duplicateUserIds.has(landedCopy.id)).toBe(false); + }); + it("does not treat user-input-prefixed backend events as optimistic echoes", () => { const first = makeUserMessage("Repeat this", { id: "user-input-backend-1", diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts index ad1e6eb07a..d3aff7a03b 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts @@ -28,6 +28,8 @@ export interface DedupResult { duplicateAssistantIds: Set; /** Chunk IDs of duplicate optimistic/persisted user messages that should be skipped */ duplicateUserIds: Set; + /** Shared dispatch failures already represented by a local Retry message. */ + duplicateDeliveryFailureIds: Set; } function getEventCallId(event: SessionEvent): string | undefined { @@ -115,9 +117,41 @@ export function buildDedupMaps(events: SessionEvent[]): DedupResult { runningArgsMap, duplicateAssistantIds, duplicateUserIds, + duplicateDeliveryFailureIds: buildDeliveryFailureDedupSet(events), }; } +function buildDeliveryFailureDedupSet(events: SessionEvent[]): Set { + const failedIntents = new Set(); + for (const event of events) { + const intent = event.result?.turnIntentId; + if ( + isSyntheticUserInputEvent(event) && + event.displayStatus === "failed" && + event.result?.deliveryStatus === "failed" && + typeof intent === "string" && + intent.length > 0 + ) + failedIntents.add(intent); + } + const duplicates = new Set(); + for (const event of events) { + const intent = event.result?.turnIntentId; + // Only the canonical dispatch-failure event has this exact identity. + // Keep provider errors and remote-only failures; no text matching, and + // no mutation of the shared terminal record required by other members. + if ( + typeof intent === "string" && + failedIntents.has(intent) && + event.id === `convturn-error-${intent}` && + event.source === "system" && + event.actionType === "error" + ) + duplicates.add(event.id); + } + return duplicates; +} + /** * Public predicate: true when the event is a user-visible assistant/agent * message (as opposed to a tool call, system message, or raw event). @@ -152,7 +186,12 @@ function buildUserDedupSet(events: SessionEvent[]): Set { if (!text) continue; if (isOptimisticUserEvent(event)) { - pendingOptimisticByText.set(text, event); + // A failed optimistic row is the visible retry owner. The provider's + // copy of that prompt (recorded before it rejected the turn) must not + // collapse the failure and its Retry into a plain duplicate bubble. + if (event.result?.["deliveryStatus"] !== "failed") { + pendingOptimisticByText.set(text, event); + } continue; } diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts index 3401710d08..f877d6577d 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts @@ -416,6 +416,7 @@ export function processChatItems( runningArgsMap, duplicateAssistantIds, duplicateUserIds, + duplicateDeliveryFailureIds, } = buildDedupMaps(events); // ------------------------------------------ @@ -429,7 +430,8 @@ export function processChatItems( if ( runningChunksToSkip.has(event.id) || duplicateAssistantIds.has(event.id) || - duplicateUserIds.has(event.id) + duplicateUserIds.has(event.id) || + duplicateDeliveryFailureIds.has(event.id) ) { continue; } diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts index d9d2feb646..e13ffea54f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts @@ -57,6 +57,13 @@ const RESULT_RENDER_KEYS = [ "linesAdded", "linesRemoved", "status", + // These fields also determine the payload captured by Retry/Edit handlers. + "queueMessageId", + "deliveryOwnerRetired", + "deliveryStatus", + "deliveryError", + "turnIntentId", + "syntheticUserInput", ] as const; const ARG_RENDER_KEYS = [ diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index f71ccf60f3..9c975ed458 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -19,7 +19,10 @@ import type { UseChatHistoryStateReturn } from "../hooks/useChatHistoryState"; import type { useChatNavigationController } from "../hooks/useChatNavigationController"; import type { UseChatSearchReturn } from "../hooks/useChatSearch"; import type { useChatViewportController } from "../hooks/useChatViewportController"; -import { useGroupHeaderRenderer } from "../hooks/useGroupHeaderRenderer"; +import { + isRetryableFailedUserIntentHeader, + useGroupHeaderRenderer, +} from "../hooks/useGroupHeaderRenderer"; import type { useReloadSession } from "../hooks/useReloadSession"; import ChatHistoryEmptyState from "./ChatHistoryEmptyState"; import ChatPinnedHeaderLayer from "./ChatPinnedHeaderLayer"; @@ -233,6 +236,7 @@ const ChatHistoryView: React.FC = ({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit: mutationActionsDisabled ? undefined : handleEditUserMessage, + onFailedUserIntentEdit: handleEditUserMessage, onRestoreCheckpoint: mutationActionsDisabled ? undefined : handleHeaderRestoreCheckpoint, @@ -314,7 +318,10 @@ const ChatHistoryView: React.FC = ({ defaultTurnCollapsed={defaultTurnCollapsed} turnCollapseInteractionAtRef={turnCollapseInteractionAtRef} onEditSubmit={ - mutationActionsDisabled ? undefined : handlePinnedEditSubmit + mutationActionsDisabled && + !isRetryableFailedUserIntentHeader(activePinnedHeader) + ? undefined + : handlePinnedEditSubmit } onRestoreCheckpoint={ mutationActionsDisabled ? undefined : handleHeaderRestoreCheckpoint diff --git a/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx b/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx index 2bd2bedc79..252d5c64fa 100644 --- a/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx @@ -1,6 +1,6 @@ -import { useAtomValue } from "jotai"; +import { type PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import type { ComponentProps, FC } from "react"; -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import { useAgentStatusTrail } from "@src/engines/ChatPanel/hooks/useAgentStatusTrail"; import { manualCompactInFlightSessionAtom } from "@src/engines/ChatPanel/hooks/useManualCompact"; @@ -12,7 +12,6 @@ import { type PlanningIndicatorState, usePlanningIndicator, } from "@src/engines/SessionCore/hooks/replay/usePlanningIndicator"; -import { useConversationRunnerScope } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; import ChatHistoryList from "./ChatHistoryList"; @@ -101,31 +100,20 @@ function PlanningIndicatorBridgeContent({ ); } -function ScopedPlanningIndicatorBridge({ +function ScopedPlanningIndicatorSync({ effectiveScope, - ...props -}: PlanningIndicatorBridgeProps & { + outputAtom, +}: { effectiveScope: PlanningIndicatorScope; + outputAtom: PrimitiveAtom; }) { const planningState = usePlanningIndicator(effectiveScope); - return ( - - ); -} - -function GlobalPlanningIndicatorBridge(props: PlanningIndicatorBridgeProps) { - const planningState = useAtomValue(globalPlanningIndicatorBridgeOutputAtom); - return ( - - ); + const publish = useSetAtom(outputAtom); + const { count, variantIndex } = planningState; + useLayoutEffect(() => { + publish({ count, variantIndex }); + }, [count, variantIndex, publish]); + return null; } /** @@ -139,26 +127,32 @@ const PlanningIndicatorBridge: FC = ({ planningIndicatorScope, ...props }) => { - const runnerScope = useConversationRunnerScope(); - const effectiveScope = runnerScope - ? { sessionId: runnerScope, isLive: true } - : planningIndicatorScope; + const [scopedOutputAtom] = useState(() => + atom({ count: 0, variantIndex: 0 }) + ); + const planningState = useAtomValue( + planningIndicatorScope + ? scopedOutputAtom + : globalPlanningIndicatorBridgeOutputAtom + ); - if (effectiveScope) { - return ( - + {planningIndicatorScope && ( + + )} + - ); - } - - return ( - + ); }; diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts index 734bac341c..e21245d22f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts @@ -23,6 +23,7 @@ import { HIDDEN_AGENT_STATUS_TRAIL_STATE } from "@src/engines/ChatPanel/hooks/ag import type { OptimizedChatItem } from "../../chatItemPipeline/types"; import type { GroupHeaderRenderPart } from "../../renderers/GroupHeaderRenderer"; import ChatHistoryList from "../ChatHistoryList"; +import { sameChatHistoryListProps } from "../ChatHistoryListEquality"; import { buildChatGroupRenderKeys } from "../ChatHistoryListLayout"; import type { ChatHistoryListHandle, @@ -117,6 +118,36 @@ describe("ChatHistoryList turn identity", () => { }); } + it.each([ + ["queueMessageId", "old-owner", undefined], + ["deliveryOwnerRetired", undefined, true], + ["deliveryStatus", "failed", "pending"], + ["deliveryError", "old failure", "new failure"], + ["turnIntentId", "old-intent", "retry-intent"], + ])("invalidates cached actions when %s changes", (key, before, after) => { + const item = bodyItem(0); + const previous = listProps( + [ + { + ...item, + event: { ...item.event!, result: { [key]: before } }, + }, + ], + "same-session" + ); + const next = { + ...previous, + flatItems: [ + { + ...item, + event: { ...item.event!, result: { [key]: after } }, + }, + ], + }; + expect(sameChatHistoryListProps(previous, next)).toBe(false); + expect(sameChatHistoryListProps(previous, previous)).toBe(true); + }); + function listProps( flatItems: OptimizedChatItem[], virtualListDataKey: string diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts new file mode 100644 index 0000000000..4a8c6ed72d --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +import PlanningIndicatorBridge from "../PlanningIndicatorBridge"; + +const lifecycle = vi.hoisted(() => ({ mounts: 0, unmounts: 0 })); +vi.mock("@src/engines/SessionCore", () => ({ + useStreamingDeltaForSession: () => null, +})); +vi.mock("@src/engines/ChatPanel/hooks/useAgentStatusTrail", () => ({ + useAgentStatusTrail: () => ({ phase: "hidden" }), +})); +vi.mock("@src/engines/ChatPanel/hooks/useManualCompact", async () => { + const { atom } = await import("jotai"); + return { manualCompactInFlightSessionAtom: atom(null) }; +}); +vi.mock("@src/engines/SessionCore/hooks/replay/usePlanningIndicator", () => ({ + usePlanningIndicator: () => ({ count: 1, variantIndex: 7 }), +})); +vi.mock("../ChatHistoryList", () => ({ + default: function MockHistoryList({ + planningIndicatorCount, + }: { + planningIndicatorCount: number; + }) { + useEffect(() => { + lifecycle.mounts++; + return () => { + lifecycle.unmounts++; + }; + }, []); + return createElement( + "div", + { "data-testid": "scroll-root" }, + planningIndicatorCount + ); + }, +})); + +it("preserves the history scroll root across runner start, Stop and runtime changes", () => { + const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + environment.IS_REACT_ACT_ENVIRONMENT = true; + lifecycle.mounts = 0; + lifecycle.unmounts = 0; + const container = document.createElement("div"); + const root = createRoot(container); + // List props are deliberately stubbed: this tests the production bridge's + // ownership of the list, not the virtualizer's geometry implementation. + const noop = () => undefined; + const props: Omit< + Parameters[0], + "planningIndicatorScope" + > = { + flatItems: [], + groupCounts: [], + turnIds: [], + totalFlatItems: 0, + codeBlockContainerWidth: 800, + footerSpacerHeight: 0, + bottomInset: 0, + topPaddingPx: 0, + virtualListRef: { current: null }, + virtualListDataKey: "same-conversation", + getIsWpGeneWorking: () => false, + getIsExploring: () => false, + renderGroupHeader: () => null, + onAtBottomStateChange: noop, + onRangeChanged: noop, + onEndReached: noop, + onSubmit: noop, + onSkip: noop, + virtualScrollerRef: { current: null }, + planningIndicatorEnabled: true, + onPlanningIndicatorCount: vi.fn(), + tailTurnStartedAtMs: null, + tailTurnLastActivityAtMs: null, + }; + try { + act(() => + root.render( + createElement(PlanningIndicatorBridge, { + ...props, + planningIndicatorScope: null, + }) + ) + ); + const scrollRoot = container.firstElementChild as HTMLElement; + scrollRoot.scrollTop = 3200; + for (const scope of [ + { sessionId: "codex-child", isLive: true }, + null, + { sessionId: "claude-child", isLive: true }, + null, + ]) { + act(() => + root.render( + createElement(PlanningIndicatorBridge, { + ...props, + planningIndicatorScope: scope, + }) + ) + ); + expect(container.querySelector('[data-testid="scroll-root"]')).toBe( + scrollRoot + ); + expect(scrollRoot.scrollTop).toBe(3200); + expect(scrollRoot.textContent).toBe(scope ? "1" : "0"); + } + expect(lifecycle.mounts).toBe(1); + expect(lifecycle.unmounts).toBe(0); + } finally { + act(() => root.unmount()); + delete environment.IS_REACT_ACT_ENVIRONMENT; + } +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts index d57c1629f1..1944566341 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts @@ -25,6 +25,86 @@ import { let counter = 0; +it.each([false, true])( + "uses native timing without rendering lifecycle rows (collapsed=%s)", + (collapsed) => { + const user = userItem("retry this request"); + user.event!.createdAt = "2026-09-08T04:59:09.964Z"; + const failure = cliErrorItem("database is locked"); + failure.event!.createdAt = "2026-09-08T04:59:12.170Z"; + const start = item( + makeEvent({ + actionType: "task_start", + functionName: "task_start", + createdAt: "2026-09-08T06:09:11.351Z", + }) + ); + const end = item( + makeEvent({ + actionType: "task_completed", + functionName: "task_completed", + createdAt: "2026-09-08T06:09:14.496Z", + }) + ); + const answer = assistantItem("recovered"); + answer.event!.createdAt = "2026-09-08T06:09:14.475Z"; + const projected = projectChatGroups([user, failure, start, answer, end], { + tailTurnPhase: "complete", + allTurnsCollapsed: collapsed, + }); + expect(projected.groupMeta[0]).toMatchObject({ + startMs: Date.parse(start.event!.createdAt), + endMs: Date.parse(end.event!.createdAt), + durationMs: 3145, + }); + expect(user.event!.createdAt).toBe("2026-09-08T04:59:09.964Z"); + expect(projected.flatItems.map((entry) => entry.event?.id)).not.toContain( + start.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).not.toContain( + end.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toContain( + failure.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toContain( + answer.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toEqual([ + failure.event!.id, + answer.event!.id, + ]); + expect(projected.originalToFlatIndex.size).toBe(5); + for (const index of projected.originalToFlatIndex.values()) { + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(projected.totalFlatItems); + } + } +); + +it.each([undefined, "invalid", "2026-09-08T03:00:00Z"])( + "keeps legacy timing without a valid execution start (%s)", + (timestamp) => { + const user = userItem("request"); + user.event!.createdAt = "2026-09-08T04:00:00Z"; + const answer = assistantItem("answer"); + answer.event!.createdAt = "2026-09-08T04:00:05Z"; + const events = [user]; + if (timestamp !== undefined) { + events.push( + item( + makeEvent({ + actionType: "task_start", + createdAt: timestamp, + }) + ) + ); + } + events.push(answer); + expect(projectChatGroups(events).groupMeta[0].durationMs).toBe(5000); + } +); + function makeEvent(overrides: Partial): SessionEvent { counter++; return { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts new file mode 100644 index 0000000000..d280716412 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts @@ -0,0 +1,328 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useChatScroll } from "../useChatScroll"; +import { useChatScrollPin } from "../useChatScrollPin"; + +describe("useChatScroll tail-follow intent", () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + afterEach(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("recovers from layout jumps but preserves an explicit manual pause", () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollTo = vi.fn(); + const scrollRoot = document.createElement("div"); + Object.defineProperties(scrollRoot, { + clientHeight: { value: 600 }, + scrollHeight: { value: 4_000 }, + scrollTo: { value: scrollTo }, + }); + const scrollerRef = { current: scrollRoot }; + const manualScrollAtRef = { current: 0 }; + + vi.spyOn(performance, "now").mockReturnValue(1_000); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = vi.fn(); + disconnect = vi.fn(); + } + ); + + function Harness({ + reportNotAtBottom, + tailFollowKey, + }: { + reportNotAtBottom: boolean; + tailFollowKey: string; + }) { + const visibleRangeEndRef = useRef(50); + const pinLastGroupRef = useRef(false); + const programmaticScrollAtRef = useRef(0); + const turnCollapseInteractionAtRef = useRef(0); + const contentOverflowingRef = useRef(true); + const pendingCancelRef = useRef(false); + const { handleAtBottomStateChange } = useChatScroll({ + optimizedChatHistoryLength: 50, + virtuosoScrollerRef: scrollerRef, + atBottom: true, + setAtBottom: vi.fn(), + setIsChatScrolledToBottom: vi.fn(), + isPendingCancelRef: pendingCancelRef, + visibleRangeEndRef, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + turnCollapseInteractionAtRef, + isContentOverflowingRef: contentOverflowingRef, + activeSessionId: "session-1", + footerSpacerHeight: 0, + bottomInset: 0, + tailFollowKey, + }); + useEffect(() => { + if (reportNotAtBottom) handleAtBottomStateChange(false); + }, [handleAtBottomStateChange, reportNotAtBottom]); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-1", + }) + ) + ); + scrollTo.mockClear(); + + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-2", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + + scrollTo.mockClear(); + manualScrollAtRef.current = 500; + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-3", + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); + + it.each([false, true])( + "pauses on explicit input with delayed mount=%s", + (delayedMount) => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollRoot = document.createElement("div"); + const textarea = document.createElement("textarea"); + scrollRoot.appendChild(textarea); + const scrollerRef: { current: HTMLDivElement | null } = { + current: delayedMount ? null : scrollRoot, + }; + const manualScrollAtRef = { current: 0 }; + const onPinToTopChange = vi.fn(); + + vi.spyOn(performance, "now").mockReturnValue(1_000); + + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + function Harness({ ready = false }: { ready?: boolean }) { + const pinLastGroupRef = useRef(true); + const programmaticScrollAtRef = useRef(0); + const pendingCancelRef = useRef(false); + const contentOverflowingRef = useRef(true); + useChatScrollPin({ + activeId: "session-1", + groupCounts: [], + totalFlatItems: 0, + footerSpacerHeight: 0, + bottomInset: 0, + sessionLoadStatus: "loaded", + virtuosoScrollerRef: scrollerRef, + atBottom: true, + isPendingCancelRef: pendingCancelRef, + isContentOverflowingRef: contentOverflowingRef, + optimizedChatHistoryLength: ready ? 10 : 0, + latestLocalSubmitId: null, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + onPinToTopChange, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(createElement(Harness))); + if (delayedMount) { + scrollerRef.current = scrollRoot; + act(() => root.render(createElement(Harness, { ready: true }))); + } + + // A virtualizer remeasure emits the same native scroll event as movement, + // but without an input gesture it must not cancel tail follow. + act(() => scrollRoot.dispatchEvent(new Event("scroll"))); + expect(manualScrollAtRef.current).toBe(0); + + act(() => scrollRoot.dispatchEvent(new WheelEvent("wheel"))); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => scrollRoot.dispatchEvent(new Event("touchmove"))); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => + scrollRoot.dispatchEvent( + new MouseEvent("pointerdown", { button: 0, clientX: 0 }) + ) + ); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => { + scrollRoot.dispatchEvent( + new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }) + ); + }); + expect(manualScrollAtRef.current).toBe(1_000); + expect(onPinToTopChange).toHaveBeenCalledWith(false); + + manualScrollAtRef.current = 0; + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) + ); + }); + expect(manualScrollAtRef.current).toBe(0); + + act(() => root.unmount()); + container.remove(); + } + ); + + it("keeps a manual pause for remote groups but re-arms for a local submit", () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollTo = vi.fn(); + const scrollRoot = document.createElement("div"); + Object.defineProperties(scrollRoot, { + clientHeight: { value: 600 }, + scrollHeight: { value: 4_000 }, + scrollTo: { value: scrollTo }, + }); + const scrollerRef = { current: scrollRoot }; + const manualScrollAtRef = { current: 0 }; + + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + function Harness({ + groupCount, + latestLocalSubmitId, + }: { + groupCount: number; + latestLocalSubmitId: string | null; + }) { + const pinLastGroupRef = useRef(false); + const programmaticScrollAtRef = useRef(0); + const pendingCancelRef = useRef(false); + const contentOverflowingRef = useRef(true); + useChatScrollPin({ + activeId: "session-1", + groupCounts: Array.from({ length: groupCount }, () => 1), + totalFlatItems: groupCount, + footerSpacerHeight: 0, + bottomInset: 0, + sessionLoadStatus: "loaded", + virtuosoScrollerRef: scrollerRef, + atBottom: false, + isPendingCancelRef: pendingCancelRef, + isContentOverflowingRef: contentOverflowingRef, + optimizedChatHistoryLength: groupCount, + latestLocalSubmitId, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => + root.render( + createElement(Harness, { + groupCount: 1, + latestLocalSubmitId: null, + }) + ) + ); + scrollTo.mockClear(); + + manualScrollAtRef.current = 1_000; + act(() => + root.render( + createElement(Harness, { + groupCount: 2, + latestLocalSubmitId: null, + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(1_000); + + act(() => + root.render( + createElement(Harness, { + groupCount: 3, + latestLocalSubmitId: "submit-1", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(0); + + scrollTo.mockClear(); + manualScrollAtRef.current = 2_000; + act(() => + root.render( + createElement(Harness, { + groupCount: 4, + latestLocalSubmitId: "submit-1", + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(2_000); + + act(() => + root.render( + createElement(Harness, { + groupCount: 5, + latestLocalSubmitId: "submit-2", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(0); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts index 2d10781257..acec81297d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts @@ -12,18 +12,55 @@ import { vi, } from "vitest"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; + import type { OptimizedChatItem } from "../../chatItemPipeline/types"; import { useEditUserMessage } from "../useEditUserMessage"; -const { submitUserIntentSpy, storeSessionId } = vi.hoisted(() => ({ +const { + checkSnapshotChangesSpy, + durableHydrationRows, + flushMessageQueueSpy, + hydrateMessageQueueSpy, + messageQueueHydrated, + queuedDeliveries, + removeByIdPrefixSpy, + updateByIdSpy, + storeSetSpy, + surfaceSessionId, + submitUserIntentSpy, + refreshMessageDeliveriesSpy, + storeSessionId, + truncateBeforeIdSpy, +} = vi.hoisted(() => ({ + checkSnapshotChangesSpy: vi.fn(async () => false), + durableHydrationRows: { current: [] as Array> }, + flushMessageQueueSpy: vi.fn(async () => undefined), + hydrateMessageQueueSpy: vi.fn(async () => undefined), + messageQueueHydrated: { current: true }, + queuedDeliveries: { current: [] as Array> }, + removeByIdPrefixSpy: vi.fn(async () => 1), + updateByIdSpy: vi.fn(async () => true), + storeSetSpy: vi.fn((_atom: unknown, _update: unknown) => true), + surfaceSessionId: { current: undefined as string | undefined }, submitUserIntentSpy: vi.fn(async (..._args: unknown[]) => undefined), + refreshMessageDeliveriesSpy: vi.fn(async () => undefined), storeSessionId: { current: "osagent-session-1" }, + truncateBeforeIdSpy: vi.fn(async () => undefined), })); vi.mock("jotai", async (importOriginal) => ({ ...(await importOriginal()), useSetAtom: () => vi.fn(), - useStore: () => ({ get: () => storeSessionId.current }), + useStore: () => ({ + get: (atom: { debugLabel?: string }) => + atom.debugLabel === "messageQueueAtom" + ? queuedDeliveries.current + : atom.debugLabel === "messageQueueHydratedAtom" + ? messageQueueHydrated.current + : storeSessionId.current, + set: storeSetSpy, + }), })); vi.mock("react-i18next", () => ({ @@ -33,7 +70,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@src/api/tauri/agent", () => ({ - checkSnapshotChanges: vi.fn(async () => false), + checkSnapshotChanges: checkSnapshotChangesSpy, truncateAfterMessage: vi.fn(async () => undefined), })); @@ -41,6 +78,10 @@ vi.mock("@src/components/Message", () => ({ default: { warning: vi.fn(), error: vi.fn(), info: vi.fn() }, })); +vi.mock("@src/engines/ChatPanel/ChatSessionContext", () => ({ + useChatSessionId: () => surfaceSessionId.current, +})); + vi.mock( "@src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit", () => ({ @@ -67,11 +108,22 @@ vi.mock("@src/engines/SessionCore/core/atoms", () => ({ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { - truncateBeforeId: vi.fn(async () => undefined), + removeByIdPrefix: removeByIdPrefixSpy, + updateById: updateByIdSpy, + truncateBeforeId: truncateBeforeIdSpy, evictSession: vi.fn(async () => undefined), }, })); +vi.mock( + "@src/engines/SessionCore/hooks/session/messageQueuePersistence", + () => ({ + flushMessageQueuePersistence: flushMessageQueueSpy, + hydrateMessageQueue: hydrateMessageQueueSpy, + refreshMessageDeliveries: refreshMessageDeliveriesSpy, + }) +); + vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ deleteSession: vi.fn(async () => undefined), })); @@ -122,8 +174,16 @@ type EditUserMessageFn = ( imageDataUrls?: string[] ) => Promise; +let resolveDispatchForTest: + | (() => QueuedConversationDispatch | null) + | undefined; + +function resolveDispatchViaTest(): QueuedConversationDispatch | null { + return resolveDispatchForTest?.() ?? null; +} + function Harness({ onReady }: { onReady: (fn: EditUserMessageFn) => void }) { - const editUserMessage = useEditUserMessage(); + const editUserMessage = useEditUserMessage(undefined, resolveDispatchViaTest); useEffect(() => { onReady(editUserMessage); }, [editUserMessage, onReady]); @@ -143,8 +203,24 @@ describe("useEditUserMessage resend projection", () => { }); beforeEach(() => { + checkSnapshotChangesSpy.mockClear(); + durableHydrationRows.current = []; + flushMessageQueueSpy.mockClear(); + hydrateMessageQueueSpy.mockClear(); + hydrateMessageQueueSpy.mockImplementation(async () => { + queuedDeliveries.current = [...durableHydrationRows.current]; + messageQueueHydrated.current = true; + }); + messageQueueHydrated.current = true; + queuedDeliveries.current = []; + removeByIdPrefixSpy.mockClear(); + updateByIdSpy.mockClear(); + storeSetSpy.mockClear(); submitUserIntentSpy.mockClear(); + refreshMessageDeliveriesSpy.mockClear(); + truncateBeforeIdSpy.mockClear(); storeSessionId.current = "osagent-session-1"; + surfaceSessionId.current = undefined; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -215,4 +291,448 @@ describe("useEditUserMessage resend projection", () => { expect(call.displayContent).toBe("/canvas build a timer"); expect(call.agentContent).toBeUndefined(); }); + + it("retries a failed delivery without truncating later history", async () => { + const failed = { + event: { + id: "user-input-failed", + createdAt: "2026-01-01T00:00:00.000Z", + source: "user", + functionName: "user_message", + uiCanonical: "", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "user-input-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry this exact request"); + }); + + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + displayContent: "retry this exact request", + turnIntentId: "turn-intent-failed", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "user-input-failed", + "osagent-session-1" + ); + expect(checkSnapshotChangesSpy).not.toHaveBeenCalled(); + expect(truncateBeforeIdSpy).not.toHaveBeenCalled(); + }); + + it("retries a reconciled orphan through the current submit path", async () => { + const failed = { + event: { + id: "queued-user:legacy-orphan:", + createdAt: "2026-01-01T00:00:00.000Z", + source: "user", + functionName: "user_message", + uiCanonical: "", + displayText: "@VantaNode inspect this", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: + "This message was not sent because its pending delivery could not be recovered. Retry to send it again.", + turnIntentId: "turn-intent-orphan", + message: { + role: "user", + content: "@VantaNode inspect this", + }, + images: ["data:image/png;base64,keep"], + mentions: [{ id: "vanta", label: "VantaNode" }], + }, + }, + chunk_id: "queued-user:legacy-orphan:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "@VantaNode inspect this", [ + "data:image/png;base64,keep", + ]); + }); + + expect(refreshMessageDeliveriesSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "osagent-session-1", + displayContent: "@VantaNode inspect this", + imageDataUrls: ["data:image/png;base64,keep"], + turnIntentId: "turn-intent-orphan", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "queued-user:legacy-orphan:", + "osagent-session-1" + ); + expect(storeSetSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + expect.anything() + ); + }); + + it("retries a hydrated failed queue row in place without losing attachments", async () => { + queuedDeliveries.current = [ + { + id: "queue-failed", + turnIntentId: "turn-intent-failed", + sessionId: "osagent-session-1", + content: "retry this exact request", + displayContent: "retry this exact request", + imageDataUrls: ["data:image/png;base64,keep"], + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-failed", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "queued-user-turn-intent-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry this exact request"); + }); + + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-failed:", + expect.objectContaining({ + displayText: "retry this exact request", + displayStatus: "pending", + result: expect.objectContaining({ + images: ["data:image/png;base64,keep"], + turnIntentId: expect.not.stringMatching("turn-intent-failed"), + deliveryStatus: "pending", + queueMessageId: "queue-failed", + }), + }), + "osagent-session-1" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-failed" + ); + }); + + it("retries a held canonical row with the runtime the picker shows now", async () => { + const admittedDispatch: QueuedConversationDispatch = { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "osagent-session-1", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.3-codex-medium", + }, + }; + const currentDispatch: QueuedConversationDispatch = { + ...admittedDispatch, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }; + resolveDispatchForTest = () => currentDispatch; + queuedDeliveries.current = [ + { + id: "queue-rejected", + turnIntentId: "turn-intent-rejected", + sessionId: "osagent-session-1", + content: "reply with the marker", + displayContent: "reply with the marker", + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "model not supported", + createdAt: "2026-01-01T00:00:00.000Z", + conversationDispatch: admittedDispatch, + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-rejected", + displayText: "reply with the marker", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-rejected", + turnIntentId: "turn-intent-rejected", + }, + }, + chunk_id: "queued-user-turn-intent-rejected", + } as unknown as OptimizedChatItem; + + try { + await act(async () => { + await editUserMessage?.(failed, "reply with the marker"); + }); + } finally { + resolveDispatchForTest = undefined; + } + + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "editMessageAtom" }), + expect.objectContaining({ + messageId: "queue-rejected", + conversationDispatch: currentDispatch, + }) + ); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-rejected" + ); + }); + + it("hydrates a cold failed owner and patches its runner row from the root surface", async () => { + messageQueueHydrated.current = false; + durableHydrationRows.current = [ + { + id: "queue-cold", + turnIntentId: "turn-intent-cold", + // The canonical root is mounted, but the queue projection belongs to + // the concrete local execution Session selected at admission. + sessionId: "cliagent-runner-child", + content: "cold retry", + displayContent: "cold retry", + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "database is locked", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user:queue-cold:", + sessionId: "cliagent-runner-child", + displayText: "cold retry", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-cold", + turnIntentId: "turn-intent-cold", + }, + }, + chunk_id: "queued-user:queue-cold:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "cold retry"); + }); + + expect(hydrateMessageQueueSpy).toHaveBeenCalledOnce(); + expect(refreshMessageDeliveriesSpy).not.toHaveBeenCalled(); + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-cold:", + expect.objectContaining({ + displayText: "cold retry", + displayStatus: "pending", + }), + "cliagent-runner-child" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + }); + + it("never deletes a queue-owned bubble while its cold owner is unavailable", async () => { + messageQueueHydrated.current = false; + const failed = { + event: { + id: "queued-user:queue-missing:", + displayText: "keep this failed row", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-missing", + turnIntentId: "turn-intent-missing", + }, + }, + chunk_id: "queued-user:queue-missing:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "keep this failed row"); + }); + + expect(hydrateMessageQueueSpy).toHaveBeenCalledOnce(); + expect(refreshMessageDeliveriesSpy).toHaveBeenCalledOnce(); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + expect(updateByIdSpy).not.toHaveBeenCalled(); + }); + + it("retries a retired failed delivery as a fresh intent", async () => { + messageQueueHydrated.current = true; + const failed = { + event: { + id: "queued-user:queue-retired:", + source: "user", + displayText: "retry after the owner retired", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryOwnerRetired: true, + queueMessageId: "queue-retired", + turnIntentId: "turn-intent-retired", + }, + }, + chunk_id: "queued-user:queue-retired:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry after the owner retired"); + }); + + expect(refreshMessageDeliveriesSpy).toHaveBeenCalledOnce(); + expect(submitUserIntentSpy).toHaveBeenCalledOnce(); + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + displayContent: "retry after the owner retired", + turnIntentId: "turn-intent-retired", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "queued-user:queue-retired:", + expect.any(String) + ); + }); + + it("edits a hydrated failed queue row and patches its existing bubble", async () => { + queuedDeliveries.current = [ + { + id: "queue-failed", + turnIntentId: "turn-intent-failed", + sessionId: "osagent-session-1", + content: "retry this exact request", + displayContent: "retry this exact request", + imageDataUrls: ["data:image/png;base64,old"], + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-failed", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "queued-user-turn-intent-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "@VantaNode inspect the retry", [ + "data:image/png;base64,new", + ]); + }); + + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "editMessageAtom" }), + expect.objectContaining({ + messageId: "queue-failed", + content: "@VantaNode inspect the retry", + imageDataUrls: ["data:image/png;base64,new"], + turnIntentId: expect.any(String), + }) + ); + expect(flushMessageQueueSpy).toHaveBeenCalledOnce(); + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-failed:", + expect.objectContaining({ + displayText: "@VantaNode inspect the retry", + displayStatus: "pending", + result: expect.objectContaining({ + message: { + role: "user", + content: "@VantaNode inspect the retry", + }, + images: ["data:image/png;base64,new"], + turnIntentId: expect.any(String), + deliveryStatus: "pending", + queueMessageId: "queue-failed", + }), + }), + "osagent-session-1" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-failed" + ); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + }); + + it("retries against the mounted SideChat session instead of global active", async () => { + surfaceSessionId.current = "osagent-side-chat"; + storeSessionId.current = "osagent-main-chat"; + act(() => + root.render( + createElement(Harness, { + onReady: (fn: EditUserMessageFn) => { + editUserMessage = fn; + }, + }) + ) + ); + const failed = { + event: { + id: "side-chat-failed", + displayText: "retry in side chat", + displayStatus: "failed", + result: { syntheticUserInput: true, deliveryStatus: "failed" }, + }, + chunk_id: "side-chat-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry in side chat"); + }); + + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "osagent-side-chat" }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "side-chat-failed", + "osagent-side-chat" + ); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts index 5405d7449d..38827af52c 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts @@ -25,11 +25,19 @@ const message = makeChatItem( const headers = [message]; const interactionRef = { current: 0 }; -function Header({ paginated }: { paginated: boolean }) { +function Header({ + paginated, + groupHeaders = headers, + onFailedUserIntentEdit, +}: { + paginated: boolean; + groupHeaders?: typeof headers; + onFailedUserIntentEdit?: () => void; +}) { const renderHeader = useGroupHeaderRenderer({ displaySourceGroupIndices: [0], sourceGroupCount: 1, - displayGroupHeaders: headers, + displayGroupHeaders: groupHeaders, displayGroupMeta: [], displayGroupCount: 1, turnPaginationEnabled: paginated, @@ -38,6 +46,7 @@ function Header({ paginated }: { paginated: boolean }) { defaultTurnCollapsed: false, turnCollapseInteractionAtRef: interactionRef, onEditSubmit: undefined, + onFailedUserIntentEdit, onRestoreCheckpoint: undefined, }); return renderHeader(0); @@ -153,4 +162,69 @@ describe("continuous chat user-message previews", () => { act(() => root.render(null)); expect(disconnect).toHaveBeenCalledOnce(); }); + + it("keeps Retry and edit actions on a rehydrated failed user turn", () => { + const retry = vi.fn(); + const failed = makeChatItem( + makeSessionEvent({ + id: "queued-user:restart:", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "retry after restart", + displayVariant: "message", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "provider unavailable", + turnIntentId: "turn-restart", + message: { role: "user", content: "retry after restart" }, + }, + }) + ); + + act(() => + root.render( + createElement(Header, { + paginated: false, + groupHeaders: [failed], + onFailedUserIntentEdit: retry, + }) + ) + ); + + const retryButton = container.querySelector( + '[data-testid="chat-message-delivery-retry"]' + ); + const editButton = container.querySelector( + '[data-testid="chat-message-user-edit-button"]' + ); + expect(retryButton).not.toBeNull(); + expect(editButton).not.toBeNull(); + act(() => retryButton!.click()); + expect(retry).toHaveBeenCalledWith( + failed, + "retry after restart", + undefined + ); + }); + + it("does not enable mutation actions for accepted read-only history", () => { + act(() => + root.render( + createElement(Header, { + paginated: false, + onFailedUserIntentEdit: vi.fn(), + }) + ) + ); + + expect( + container.querySelector('[data-testid="chat-message-delivery-retry"]') + ).toBeNull(); + expect( + container.querySelector('[data-testid="chat-message-user-edit-button"]') + ).toBeNull(); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts index b41ad12290..446788cf8d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts @@ -1,3 +1,5 @@ +import { isInternalLifecycleEvent } from "@src/engines/SessionCore/ingestion/visibilityFilters"; + import { isAgentOrgGroupChatUserMessage, isAgentOrgInboxTranscriptEvent, @@ -118,6 +120,10 @@ function isUnloadedTurnItem(item: OptimizedChatItem | undefined): boolean { return getUnloadedTurnMeta(item) !== null; } +function isLifecycleItem(item: OptimizedChatItem): boolean { + return Boolean(item.event && isInternalLifecycleEvent(item.event)); +} + export function isTurnPreviewItem( item: OptimizedChatItem | undefined ): boolean { @@ -327,7 +333,22 @@ export function projectChatGroups( const groupMeta: ChatGroupMeta[] = groups.map((group) => { const headerEvent = group.header?.event; const turnId = headerEvent?.id ?? null; - const startMs = parseEpochMs(headerEvent?.createdAt); + const messageMs = parseEpochMs(headerEvent?.createdAt); + // Native runtimes can accept a queued/retried message long after it was + // written. Their execution boundary, when available, owns worked-for + // timing; the user-message timestamp remains unchanged. + let executionStartMs: number | null = null; + for (const item of group.items) { + if (item.event?.actionType !== "task_start") continue; + const candidate = parseEpochMs(item.event.createdAt); + if ( + candidate !== null && + (messageMs === null || candidate >= messageMs) + ) { + executionStartMs = candidate; + } + } + const startMs = executionStartMs ?? messageMs; let endMs: number | null = null; for (let i = group.items.length - 1; i >= 0; i--) { const itemMs = parseEpochMs(group.items[i].event?.createdAt); @@ -396,14 +417,13 @@ export function projectChatGroups( if (!isCollapsed) { const keepStructuralPlaceholder = meta.unloadedTurn !== null; - const surviving = keepStructuralPlaceholder - ? group.items - : group.items.filter((item) => !isUnloadedTurnItem(item)); + const shouldKeep = (item: OptimizedChatItem) => + !isLifecycleItem(item) && + (keepStructuralPlaceholder || !isUnloadedTurnItem(item)); + const surviving = group.items.filter(shouldKeep); survivingPerGroup[groupIndex] = surviving; droppedItemTargetByGroup[groupIndex] = group.items.map((item) => - !keepStructuralPlaceholder && isUnloadedTurnItem(item) - ? runningFlatIdx - : null + shouldKeep(item) ? null : runningFlatIdx ); groupCounts[groupIndex] = surviving.length; runningFlatIdx += surviving.length; @@ -424,10 +444,13 @@ export function projectChatGroups( groupCounts[groupIndex] = previews.length; runningFlatIdx += previews.length; } else { - survivingPerGroup[groupIndex] = group.items; - droppedItemTargetByGroup[groupIndex] = group.items.map(() => null); - groupCounts[groupIndex] = group.items.length; - runningFlatIdx += group.items.length; + const surviving = group.items.filter((item) => !isLifecycleItem(item)); + survivingPerGroup[groupIndex] = surviving; + droppedItemTargetByGroup[groupIndex] = group.items.map((item) => + isLifecycleItem(item) ? runningFlatIdx : null + ); + groupCounts[groupIndex] = surviving.length; + runningFlatIdx += surviving.length; } continue; } @@ -465,7 +488,7 @@ export function projectChatGroups( if (keepIndex === -1) { const structuralSourceIndex = group.items.findIndex( - (item) => !isUnloadedTurnItem(item) + (item) => !isUnloadedTurnItem(item) && !isLifecycleItem(item) ); const structuralSource = group.items[structuralSourceIndex]; if (!structuralSource) { @@ -488,7 +511,9 @@ export function projectChatGroups( continue; } - const keptIndices = [keepIndex, ...pinnedIndices]; + // Collapse changes visibility, not chronology: a failed attempt before + // a successful retry must not become the apparent final result. + const keptIndices = [keepIndex, ...pinnedIndices].sort((a, b) => a - b); const keptIndexSet = new Set(keptIndices); const kept = keptIndices.map((index) => group.items[index]); survivingPerGroup[groupIndex] = kept; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts index fdf4f1697d..f39566fdf5 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; +import type { ChatHistoryProps } from "../ChatHistory.types"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import type { UseChatHistoryStateReturn } from "./useChatHistoryState"; import { useEditUserMessage } from "./useEditUserMessage"; @@ -10,6 +11,8 @@ interface UseChatHistoryItemActionsOptions { groupHeaders: (OptimizedChatItem | null)[]; handleIgnoreQuestionRef: UseChatHistoryStateReturn["handleIgnoreQuestionRef"]; handleReplyQuestionRef: UseChatHistoryStateReturn["handleReplyQuestionRef"]; + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"]; + resolveFailedUserIntentDispatch?: ChatHistoryProps["resolveFailedUserIntentDispatch"]; } /** Stabilizes history mutation callbacks passed into virtualized row renderers. */ @@ -18,8 +21,13 @@ export function useChatHistoryItemActions({ groupHeaders, handleIgnoreQuestionRef, handleReplyQuestionRef, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, }: UseChatHistoryItemActionsOptions) { - const handleEditUserMessage = useEditUserMessage(); + const handleEditUserMessage = useEditUserMessage( + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch + ); const handleRestoreCheckpoint = useRestoreCheckpoint(); const pinnedEditSubmitRef = useRef(handleEditUserMessage); useEffect(() => { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts index 2020cfbcea..a774e3d85b 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts @@ -94,6 +94,19 @@ const MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS = 450; const TURN_COLLAPSE_AUTO_FOLLOW_SUPPRESS_MS = 700; const FOLLOW_SETTLE_FRAME_COUNT = 4; +/** + * `atBottom` is geometry, not user intent. A virtualizer remeasure or an + * async projection swap can temporarily move a tail-following viewport away + * from the bottom without any user input. Only an explicit manual scroll + * should suspend streaming follow in that state. + */ +function isTailFollowPaused( + atBottom: boolean, + manualScrollAt: number +): boolean { + return !atBottom && manualScrollAt > 0; +} + export function useChatScroll({ optimizedChatHistoryLength, virtuosoScrollerRef, @@ -142,11 +155,17 @@ export function useChatScroll({ const handleAtBottomStateChange = useCallback( (bottom: boolean) => { + if (bottom) { + // Reaching the tail explicitly re-arms follow after the user has read + // older content and scrolled back down. + // eslint-disable-next-line react-hooks/immutability -- This caller-owned ref is the scroll hooks' existing non-rendering intent channel. + effectiveManualScrollAtRef.current = 0; + } if (atBottomRef.current === bottom) return; atBottomRef.current = bottom; debouncedSetAtBottom(bottom); }, - [debouncedSetAtBottom] + [debouncedSetAtBottom, effectiveManualScrollAtRef] ); const scrollElementToBottom = useCallback( @@ -234,7 +253,15 @@ export function useChatScroll({ ) { return; } - if (!alwaysFollowTail && !atBottomRef.current) return; + if ( + !alwaysFollowTail && + isTailFollowPaused( + atBottomRef.current, + effectiveManualScrollAtRef.current + ) + ) { + return; + } scheduleSettledFollow(); }); }; @@ -262,7 +289,15 @@ export function useChatScroll({ useEffect(() => { if (!tailFollowKey) return; if (pinLastGroupRef.current) return; - if (!alwaysFollowTail && !atBottomRef.current) return; + if ( + !alwaysFollowTail && + isTailFollowPaused( + atBottomRef.current, + effectiveManualScrollAtRef.current + ) + ) { + return; + } if ( performance.now() - effectiveManualScrollAtRef.current < MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts index 8b40666257..b5f80bbe2d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts @@ -21,6 +21,8 @@ export interface UseChatScrollPinOptions { isPendingCancelRef: MutableRefObject; isContentOverflowingRef: MutableRefObject; optimizedChatHistoryLength: number; + /** The newest group is this surface's optimistic, still-pending submit. */ + latestLocalSubmitId: string | null; /** * Shared ref owned by the parent. Both useChatScroll and useChatScrollPin * read/write this ref so they coordinate pin intent without re-renders. @@ -42,6 +44,51 @@ export interface UseChatScrollPinReturn { programmaticScrollAtRef: MutableRefObject; } +function isScrollbarPointerDown( + event: PointerEvent, + element: HTMLElement +): boolean { + if (event.button !== 0) return false; + const rect = element.getBoundingClientRect(); + const nativeScrollbarWidth = Math.max( + 0, + element.offsetWidth - element.clientWidth + ); + const hitWidth = Math.max(12, nativeScrollbarWidth); + return event.clientX >= rect.right - hitWidth; +} + +const KEYBOARD_SCROLL_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + " ", + "Spacebar", +]); + +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest( + "input, textarea, select, [contenteditable='true'], [role='textbox']" + ) !== null + ); +} + +function isKeyboardScrollIntent(event: KeyboardEvent): boolean { + return ( + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isEditableKeyboardTarget(event.target) && + KEYBOARD_SCROLL_KEYS.has(event.key) + ); +} + /** * Manages three scroll-pin behaviours for ChatHistory: * @@ -50,10 +97,10 @@ export interface UseChatScrollPinReturn { * 2. Pin the latest user-message group to the viewport top when a new * group is added. Re-fires as the temporary footer * reserve grows so the first scroll lands at the correct offset. - * 3. Breaks pin intent on user-initiated scroll. Distinguishes programmatic - * scrolls (ignored) from user scrolls (releases pin) via a time window. - * The listener is mounted once and reads live state via refs — it is NOT - * re-registered on every new message, eliminating monitoring gaps. + * 3. Breaks pin intent on explicit user scroll input. A plain `scroll` event + * is deliberately insufficient: virtualizer remeasurement and projection + * replacement also emit trusted scroll events, and treating those as user + * intent strands a streaming conversation at the top. */ export function useChatScrollPin({ activeId, @@ -67,6 +114,7 @@ export function useChatScrollPin({ isPendingCancelRef: _isPendingCancelRef, isContentOverflowingRef: _isContentOverflowingRef, optimizedChatHistoryLength, + latestLocalSubmitId, pinLastGroupRef, manualScrollAtRef, programmaticScrollAtRef, @@ -148,8 +196,11 @@ export function useChatScrollPin({ programmaticScrollAtRef, ]); - // Effect 2: scroll to bottom only when a new user-message group is added. + // Effect 2: a local optimistic submit explicitly re-arms bottom follow. + // Remote groups continue following only while the viewer has not paused by + // scrolling up; their arrival must not steal the viewport from older text. const prevGroupLenRef = useRef(groupCounts.length); + const lastFollowedSubmitIdRef = useRef(latestLocalSubmitId); useEffect(() => { const prevGroupLen = prevGroupLenRef.current; @@ -157,45 +208,65 @@ export function useChatScrollPin({ const newGroupAdded = groupCounts.length > prevGroupLen; if (!newGroupAdded) return; + const newLocalSubmit = + latestLocalSubmitId !== null && + latestLocalSubmitId !== lastFollowedSubmitIdRef.current; + if (newLocalSubmit) lastFollowedSubmitIdRef.current = latestLocalSubmitId; + if (effectiveManualScrollAtRef.current > 0 && !newLocalSubmit) { + return; + } pinLastGroupRef.current = false; onPinToTopChange?.(false); return scheduleFollowToEnd(); }, [ groupCounts.length, + latestLocalSubmitId, + effectiveManualScrollAtRef, onPinToTopChange, pinLastGroupRef, scheduleFollowToEnd, ]); - // Effect 3: break pin intent on user-initiated scroll. - // - // Mounted once per scroller element change — intentionally does NOT - // depend on totalFlatItems or sessionLoadStatus. Those changes used to - // force a remove+re-add cycle that created a short window where user - // scrolls went undetected. Live values are accessed via refs instead. + // Effect 3: break follow/pin intent only on explicit user input. Layout and + // TanStack Virtual corrections use the same native `scroll` event as a + // wheel gesture, so listening to `scroll` itself cannot distinguish intent. + // The empty/loading surface has no scroller. Refs alone do not wake an + // effect when the history mounts, so bind again at that boundary (and on + // session changes), without rebinding on every streamed item. + const hasHistory = optimizedChatHistoryLength > 0; useEffect(() => { - const el = virtuosoScrollerRef.current; + const el = virtuosoScrollerRef.current ?? staticScrollerRef?.current; if (!el) return; - const PROGRAMMATIC_WINDOW_MS = 250; - const handleScroll = (): void => { - const now = performance.now(); - const elapsed = now - programmaticScrollAtRef.current; - if (elapsed < PROGRAMMATIC_WINDOW_MS) return; - effectiveManualScrollAtRef.current = now; + const markManualScroll = (): void => { + effectiveManualScrollAtRef.current = performance.now(); if (!pinLastGroupRef.current) return; pinLastGroupRef.current = false; onPinToTopChangeRef.current?.(false); }; - el.addEventListener("scroll", handleScroll, { passive: true }); + const handlePointerDown = (event: PointerEvent): void => { + if (isScrollbarPointerDown(event, el)) markManualScroll(); + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (isKeyboardScrollIntent(event)) markManualScroll(); + }; + el.addEventListener("wheel", markManualScroll, { passive: true }); + el.addEventListener("touchmove", markManualScroll, { passive: true }); + el.addEventListener("pointerdown", handlePointerDown); + el.addEventListener("keydown", handleKeyDown); return () => { - el.removeEventListener("scroll", handleScroll); + el.removeEventListener("wheel", markManualScroll); + el.removeEventListener("touchmove", markManualScroll); + el.removeEventListener("pointerdown", handlePointerDown); + el.removeEventListener("keydown", handleKeyDown); }; }, [ + activeId, + hasHistory, virtuosoScrollerRef, pinLastGroupRef, - programmaticScrollAtRef, effectiveManualScrollAtRef, + staticScrollerRef, ]); return { scrollToEnd, programmaticScrollAtRef }; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts index 7df8a972f8..dbb42fd200 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts @@ -29,6 +29,7 @@ interface UseChatViewportControllerOptions { displayTotalFlatItems: number; followAgentNav: FollowAgentNavState; isPendingCancelRef: UseChatEmptyStateReturn["isPendingCancelRef"]; + latestLocalSubmitId: string | null; onScrollNavChange?: (state: ScrollNavState) => void; planningIndicatorCount: 0 | 1; sessionLoadStatus: UseChatHistoryStateReturn["sessionLoadStatus"]; @@ -57,6 +58,7 @@ export function useChatViewportController({ displayTotalFlatItems, followAgentNav, isPendingCancelRef, + latestLocalSubmitId, onScrollNavChange, planningIndicatorCount, sessionLoadStatus, @@ -227,6 +229,7 @@ export function useChatViewportController({ isPendingCancelRef, isContentOverflowingRef, optimizedChatHistoryLength: activeProjectionHistoryLength, + latestLocalSubmitId, pinLastGroupRef, manualScrollAtRef, programmaticScrollAtRef, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts index 75f846f9b8..384c3f8a06 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts @@ -20,6 +20,7 @@ import { truncateAfterMessage, } from "@src/api/tauri/agent"; import Message from "@src/components/Message"; +import { useChatSessionId } from "@src/engines/ChatPanel/ChatSessionContext"; import { projectOutgoingUserMessage } from "@src/engines/ChatPanel/hooks/useInputArea/projectOutgoingUserMessage"; import { useUserIntentSubmit } from "@src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit"; import { editTruncationTimestampAtom } from "@src/engines/SessionCore"; @@ -30,13 +31,30 @@ import { import { cancelTurnForTimelineBoundary } from "@src/engines/SessionCore/control/sessionTimelineBoundary"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { + flushMessageQueuePersistence, + hydrateMessageQueue, + refreshMessageDeliveries, +} from "@src/engines/SessionCore/hooks/session/messageQueuePersistence"; +import { + isUserIntentSendError, + setOptimisticQueueUserDelivery, +} from "@src/engines/SessionCore/services/userIntentDispatch"; import { deleteSession as deleteCachedSession } from "@src/engines/SessionCore/storage/cacheAdapter"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; import { clearPendingPlanApproval, pendingPlanApprovalsAtom, } from "@src/store/session/planApprovalAtom"; import { activeSessionIdAtom } from "@src/store/session/viewAtom"; +import { + editMessageAtom, + forceSendMessageAtom, + messageQueueAtom, + messageQueueHydratedAtom, +} from "@src/store/ui/messageQueueAtom"; import { clearTodosForSessionAtom } from "@src/store/ui/todoAtom"; import { invokeTauri } from "@src/util/platform/tauri/init"; import { @@ -44,6 +62,7 @@ import { isCliSession, } from "@src/util/session/sessionDispatch"; +import type { ChatHistoryProps } from "../ChatHistory.types"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import { showRevertConfirm } from "../components/RevertConfirmDialog"; @@ -58,7 +77,10 @@ function agentMessageIdFromUserEventId(eventId: string): string | undefined { : undefined; } -export function useEditUserMessage(): ( +export function useEditUserMessage( + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"], + resolveFailedUserIntentDispatch?: ChatHistoryProps["resolveFailedUserIntentDispatch"] +): ( chatItem: OptimizedChatItem, newText: string, imageDataUrls?: string[] @@ -67,9 +89,13 @@ export function useEditUserMessage(): ( const setPendingPlanApprovals = useSetAtom(pendingPlanApprovalsAtom); const clearTodosForSession = useSetAtom(clearTodosForSessionAtom); const store = useStore(); + const surfaceSessionId = useChatSessionId(); const resolveCurrentSessionId = useCallback( - () => store.get(activeSessionIdAtom) ?? store.get(sessionIdAtom), - [store] + () => + surfaceSessionId ?? + store.get(activeSessionIdAtom) ?? + store.get(sessionIdAtom), + [store, surfaceSessionId] ); const submitUserIntent = useUserIntentSubmit({ getSessionId: resolveCurrentSessionId, @@ -96,6 +122,7 @@ export function useEditUserMessage(): ( const initiatedSessionId = resolveCurrentSessionId(); const isStillOnInitiatingSession = (): boolean => { if (!initiatedSessionId) return false; + if (surfaceSessionId) return surfaceSessionId === initiatedSessionId; const activeSessionId = store.get(activeSessionIdAtom); if (activeSessionId) return activeSessionId === initiatedSessionId; return store.get(sessionIdAtom) === initiatedSessionId; @@ -106,6 +133,171 @@ export function useEditUserMessage(): ( if (!eventId) return; const createdAt = chatItem.event?.createdAt; + const failedSyntheticIntent = Boolean( + initiatedSessionId && + chatItem.event?.displayStatus === "failed" && + chatItem.event.result?.syntheticUserInput === true + ); + + // A delivery failure happened before the provider accepted this turn, + // so it is not a history-edit boundary. Retry through the ordinary + // submit/queue path and remove only the superseded failed placeholder; + // never truncate later turns or offer a file rewind for this case. + if (failedSyntheticIntent && initiatedSessionId && chatItem.event) { + const originalText = chatItem.event.displayText ?? ""; + const originalTurnIntentId = turnIntentIdOf(chatItem.event); + const queueMessageId = + typeof chatItem.event.result?.queueMessageId === "string" + ? chatItem.event.result.queueMessageId + : null; + const resendImages = + imageDataUrls && imageDataUrls.length > 0 ? imageDataUrls : undefined; + const projection = projectOutgoingUserMessage({ + displayText: newText, + allowCanvasInterception: + !resendImages && !isCliSession(initiatedSessionId), + }); + try { + if (queueMessageId && !store.get(messageQueueHydratedAtom)) { + // The transcript can hydrate before the durable delivery registry + // after an app restart. Never interpret that temporary absence as + // permission to create a second owner and delete the only bubble. + await hydrateMessageQueue(store); + } + const findDurableFailedQueueRow = () => + queueMessageId + ? store + .get(messageQueueAtom) + .find( + (message) => + message.id === queueMessageId && + Boolean(message.deliveryError) + ) + : undefined; + let durableFailedQueueRow = findDurableFailedQueueRow(); + if (queueMessageId && !durableFailedQueueRow) { + // Cover a cross-window mutation that landed after initial hydrate. + await refreshMessageDeliveries(store); + durableFailedQueueRow = findDurableFailedQueueRow(); + } + if (queueMessageId && !durableFailedQueueRow) { + if (chatItem.event.result?.deliveryOwnerRetired !== true) { + // queueMessageId is an ownership claim, not a hint. Falling + // back to a new submit here would delete the only visible root + // row and create a second delivery on whichever Session is + // currently mounted. Preserve the failed bubble until its owner + // is readable. + throw new Error("failed delivery owner is not available yet"); + } + // The dispatcher retired this owner after a terminal provider/ + // Cloud verdict and stamped the row. The failed bubble is the only + // remaining owner, so retry it as a fresh intent below. + log.warn( + "[useEditUserMessage] failed delivery owner was retired; retrying as a new intent" + ); + } + if (durableFailedQueueRow) { + const retryTurnIntentId = mintTurnIntentId(); + // A held canonical row keeps the runtime it was admitted with. + // The user changes the picker precisely because that runtime + // failed, so the retry must carry the runtime shown now. + const retryDispatch = + durableFailedQueueRow.conversationDispatch?.kind === + "canonical_conversation" + ? (resolveFailedUserIntentDispatch?.() ?? undefined) + : undefined; + const updated = store.set(editMessageAtom, { + messageId: durableFailedQueueRow.id, + content: projection.displayContent, + imageDataUrls: resendImages, + turnIntentId: retryTurnIntentId, + ...(retryDispatch ? { conversationDispatch: retryDispatch } : {}), + }); + if (!updated) { + throw new Error("failed delivery is no longer retryable"); + } + // Commit the edited payload while the row is still explicitly + // held. Then patch the SAME queue-owned transcript row back to + // pending before releasing the existing owner. The provider sees + // a fresh terminal intent, while the user never gets a duplicate + // bubble or loses serialized mention/image payloads. + try { + await flushMessageQueuePersistence(store); + const pendingUpdated = await setOptimisticQueueUserDelivery( + { + // Queue admission owns the concrete EventStore projection + // session. The mounted surface can be the canonical root + // while this row lives on a local execution child. + sessionId: durableFailedQueueRow.sessionId, + visibleText: projection.displayContent, + imageDataUrls: + resendImages ?? durableFailedQueueRow.imageDataUrls, + turnIntentId: retryTurnIntentId, + queueMessageId: durableFailedQueueRow.id, + createdAt: durableFailedQueueRow.createdAt, + }, + "pending" + ); + if (!pendingUpdated) { + throw new Error( + "failed delivery projection is no longer available" + ); + } + } catch (retryPreparationError) { + // The old failed bubble is still authoritative. Restore its + // matching held queue owner instead of leaving a new pending + // owner whose EventStore projection never moved with it. + store.set(messageQueueAtom, (queue) => + queue.map((message) => + message.id === durableFailedQueueRow.id + ? durableFailedQueueRow + : message + ) + ); + await flushMessageQueuePersistence(store).catch(() => undefined); + throw retryPreparationError; + } + store.set(forceSendMessageAtom, durableFailedQueueRow.id); + return; + } + const turnIntentId = + newText === originalText + ? (originalTurnIntentId ?? undefined) + : undefined; + const handled = await onFailedUserIntentRetry?.({ + displayText: projection.displayContent, + agentContent: projection.agentContent, + imageDataUrls: resendImages, + turnIntentId, + }); + if (!handled) { + await submitUserIntent({ + sessionId: initiatedSessionId, + displayContent: projection.displayContent, + agentContent: projection.agentContent, + imageDataUrls: resendImages, + source: "dispatch", + turnIntentId, + }); + } + await eventStoreProxy.removeByIdPrefix(eventId, initiatedSessionId); + } catch (error) { + // A send-stage error already produced the replacement failed row. + // A preparation/storage error did not, so retain the original row. + if (isUserIntentSendError(error)) { + await eventStoreProxy + .removeByIdPrefix(eventId, initiatedSessionId) + .catch(() => 0); + } + log.error( + "[useEditUserMessage] failed delivery retry failed:", + error + ); + Message.error(t("errors.errorOccurred")); + } + return; + } + let revertFiles = true; if ( @@ -246,9 +438,12 @@ export function useEditUserMessage(): ( setPendingPlanApprovals, clearTodosForSession, resolveCurrentSessionId, + surfaceSessionId, submitUserIntent, t, store, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, ] ); } diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx b/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx index 4a790bf63b..c32ddd77bf 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx @@ -28,9 +28,25 @@ interface UseGroupHeaderRendererOptions { defaultTurnCollapsed: boolean; turnCollapseInteractionAtRef: React.MutableRefObject; onEditSubmit: GroupHeaderRendererProps["onEditSubmit"]; + /** Retry/edit stays valid for a rejected synthetic turn on read-only source history. */ + onFailedUserIntentEdit: GroupHeaderRendererProps["onEditSubmit"]; onRestoreCheckpoint: GroupHeaderRendererProps["onRestoreCheckpoint"]; } +export function isRetryableFailedUserIntentHeader( + header: OptimizedChatItem | null | undefined +): boolean { + const result = header?.event?.result; + return Boolean( + header?.event?.source === "user" && + header.event.displayStatus === "failed" && + result?.syntheticUserInput === true && + result.deliveryStatus === "failed" && + typeof result.turnIntentId === "string" && + result.turnIntentId.length > 0 + ); +} + export function useGroupHeaderRenderer({ displaySourceGroupIndices, sourceGroupCount, @@ -44,6 +60,7 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit, + onFailedUserIntentEdit, onRestoreCheckpoint, }: UseGroupHeaderRendererOptions) { return useCallback( @@ -73,7 +90,12 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed={defaultTurnCollapsed} renderPart={renderPart} turnCollapseInteractionAtRef={turnCollapseInteractionAtRef} - onEditSubmit={onEditSubmit} + onEditSubmit={ + onEditSubmit ?? + (isRetryableFailedUserIntentHeader(header) + ? onFailedUserIntentEdit + : undefined) + } onRestoreCheckpoint={onRestoreCheckpoint} /> ); @@ -91,6 +113,7 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit, + onFailedUserIntentEdit, onRestoreCheckpoint, ] ); diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index e8460e3b4a..eaee5fa808 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -7,17 +7,12 @@ import { useAtomValue } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { loadEventComponent } from "@src/engines/SessionCore/rendering/registry/events"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; -import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; -import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { isSessionActiveAtom } from "@src/store/session/cliSessionStatusAtom"; import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; -import { type Session, sessionByIdAtom } from "@src/store/session/sessionAtom"; +import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; import { ParentAgentSenderProvider } from "../ChatItems/ParentAgentSenderContext"; -import { SharedConversationSenderProvider } from "../ChatItems/SharedConversationSenderContext"; import { resolveParentAgentSenderSessionId } from "../ChatItems/parentAgentSender"; import { useChatSessionId } from "../ChatSessionContext"; import { @@ -43,45 +38,6 @@ export type { ScrollNavState } from "./ChatHistory.types"; const EMPTY_ORG_MEMBERS: ChatHistoryProps["agentOrgMembers"] = []; -function resolveSharedConversationSender( - session: Session | undefined, - remoteEntries: Record< - string, - { rows?: readonly RemoteTeammateSessionMetadata[] } | undefined - > -) { - // Pre-lineage imports recorded no owner name; the live listing row still - // knows it, so resolve through the cloud rows before giving up on the - // "Shared user" placeholder. - const rowOwnerName = (orgId: string, sourceSessionId: string) => - remoteEntries[orgId]?.rows - ?.find((row) => row.sourceSessionId === sourceSessionId) - ?.ownerDisplayName?.trim(); - if (session?.importedFrom) { - const lineage = session.importedFrom; - return { - displayName: - lineage.ownerDisplayName?.trim() || - rowOwnerName(lineage.orgId, lineage.sourceSessionId) || - "Shared user", - avatarUrl: lineage.ownerAvatarUrl, - }; - } - // Row-field lineage is stripped on some reload paths; the registry - // fallback keeps the SOURCE owner's name resolvable so inherited rows - // never regress to the "Shared user" placeholder. - const forkedFrom = session ? getSessionForkedFrom(session) : undefined; - if (forkedFrom) { - return { - displayName: - forkedFrom.ownerDisplayName?.trim() || - rowOwnerName(forkedFrom.orgId, forkedFrom.sourceSessionId) || - "Shared user", - }; - } - return null; -} - const ChatHistory: React.FC = ({ surfaceBgClass = "bg-chat-pane", chatPanelPosition = "right", @@ -108,25 +64,21 @@ const ChatHistory: React.FC = ({ groupChatViewActive = false, onGroupChatViewToggle, mutationActionsDisabled = false, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, planningIndicatorScope = null, }) => { const activeId = useChatSessionId() ?? null; const rawCursorIdeTurnSummaries = useAtomValue( cursorIdeTurnSummariesAtomFamily(activeId ?? "") ); - const activeSession = usePinnedSession(activeId ?? ""); + const activeSession = useAtomValue(sessionByIdAtom(activeId ?? "")); const isCursorIde = activeId ? isCursorIdeSession(activeId) : false; const cursorIdeTurnSummaries = isCursorIde ? rawCursorIdeTurnSummaries : []; const handleReloadSession = useReloadSession(activeId); const historyState = useChatHistoryState(); const isAgentWorking = useAtomValue(isSessionActiveAtom); const groupChat = useGroupChatContext(); - const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); - const sharedConversationSender = useMemo( - () => resolveSharedConversationSender(activeSession, remoteEntries), - [activeSession, remoteEntries] - ); - useEffect(() => { // Canvas payloads can reach the WorkStation as soon as the tool call is // stored. Warm the chat renderer while the user is still waiting for the @@ -209,6 +161,12 @@ const ChatHistory: React.FC = ({ displayTotalFlatItems: projection.displayTotalFlatItems, followAgentNav, isPendingCancelRef: emptyState.isPendingCancelRef, + latestLocalSubmitId: (() => { + const event = projection.displayGroupHeaders.at(-1)?.event; + return event?.source === "user" && event.displayStatus === "pending" + ? event.id + : null; + })(), onScrollNavChange, planningIndicatorCount, sessionLoadStatus: historyState.sessionLoadStatus, @@ -255,47 +213,47 @@ const ChatHistory: React.FC = ({ groupHeaders: projection.groupHeaders, handleIgnoreQuestionRef: historyState.handleIgnoreQuestionRef, handleReplyQuestionRef: historyState.handleReplyQuestionRef, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, }); return ( - - - - - + + + ); }; diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx index 155eb1f7d9..14e08fb339 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx @@ -83,6 +83,13 @@ const RESULT_RENDER_KEYS = [ "linesAdded", "linesRemoved", "status", + // Keep retry actions current even when the visible message body is unchanged. + "queueMessageId", + "deliveryOwnerRetired", + "deliveryStatus", + "deliveryError", + "turnIntentId", + "syntheticUserInput", ] as const; const ARG_RENDER_KEYS = [ diff --git a/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx new file mode 100644 index 0000000000..13c85f3d16 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext } from "react"; + +import { + CONVERSATION_VIEWER_SIGNED_OUT, + type ConversationSenderIdentity, + type ConversationSenderRelationship, + type ConversationSenderStamp, + type ConversationViewerState, + conversationSenderStampOf, + resolveConversationSenderRelationship, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +interface ConversationSenderMetadataContextValue { + viewer: ConversationViewerState; + /** + * Enrich a validated event stamp or provide source-owner presentation for + * inherited unstamped rows. Returning an identity never changes row side; + * only a stable event stamp can establish viewer/other ownership. + */ + resolveSender: ( + event: SessionEvent, + stampedSender: ConversationSenderStamp | null + ) => ConversationSenderIdentity | null; +} + +const ConversationSenderMetadataContext = + createContext(null); + +export const ConversationSenderMetadataProvider = + ConversationSenderMetadataContext.Provider; + +export interface ConversationSenderResolution { + identity: ConversationSenderIdentity | null; + relationship: ConversationSenderRelationship; +} + +/** Resolve one row without importing any transport/account implementation. */ +export function useConversationSenderResolution( + event: SessionEvent | undefined +): ConversationSenderResolution { + const context = useContext(ConversationSenderMetadataContext); + const stampedSender = conversationSenderStampOf(event); + const identity = event + ? context + ? context.resolveSender(event, stampedSender) + : stampedSender + : null; + const relationship = resolveConversationSenderRelationship( + stampedSender, + context?.viewer ?? CONVERSATION_VIEWER_SIGNED_OUT + ); + return { identity, relationship }; +} diff --git a/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx b/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx index 0ebae1e5f1..63c68533bd 100644 --- a/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx +++ b/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx @@ -16,8 +16,8 @@ export interface ParentAgentSender { * Resolved once per chat rather than per message: every user row in a session * shares one answer, and reading it from the session store per row would * subscribe hundreds of memoized rows to a session object that churns on every - * status update. `SharedConversationSenderContext` carries teammate identity - * the same way and for the same reason. + * status update. `ConversationSenderMetadataContext` carries human account + * identity through the same one-provider-per-surface boundary. */ const ParentAgentSenderContext = createContext(null); diff --git a/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx b/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx deleted file mode 100644 index b51af018a1..0000000000 --- a/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext, useContext } from "react"; - -export interface SharedConversationSender { - displayName: string; - avatarUrl?: string; -} - -const SharedConversationSenderContext = - createContext(null); - -export const SharedConversationSenderProvider = - SharedConversationSenderContext.Provider; - -export function useSharedConversationSender(): SharedConversationSender | null { - return useContext(SharedConversationSenderContext); -} diff --git a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx index dc7178fdda..ba3695bb32 100644 --- a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx +++ b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from "jotai"; import React, { type FC, type MouseEvent, @@ -13,24 +12,21 @@ import { useTranslation } from "react-i18next"; import { CHAT_BUBBLE_TOOLBAR_BUTTON_CLASS } from "@src/components/ChatBubble"; import ClampedContent from "@src/components/ClampedContent"; +import type { ComposerSnapshot } from "@src/components/ComposerInput"; import ExpandOverlay from "@src/components/ExpandOverlay"; +import Message from "@src/components/Message"; import PersonAvatar from "@src/components/PersonAvatar"; import { REPO_SETUP_PROMPT_MARKER } from "@src/config/repoSetupMarker"; import type { OptimizedChatItem } from "@src/engines/ChatPanel/ChatHistory/chatItemPipeline/types"; +import { conversationSenderStampOf } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; -import type { ConversationSenderStamp } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; -import { CONVERSATION_SENDER_ARG } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; import { discussionPayloadOf } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; -import { resolveTeamChatMentions } from "@src/features/Org2Cloud/SessionConversation/teamChatMentions"; -import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; import { ClipboardCheckIcon, - Edit03Icon, File01Icon, HugeiconsIcon, Image01Icon, PencilEdit01Icon, - RotateLeft01Icon, SparklesIcon, Undo02Icon, } from "@src/icons"; @@ -43,27 +39,16 @@ import UserMessageContent, { import InputArea from "../InputArea"; import { stripExpandedPillContent } from "../InputArea/utils/pillContentParser"; import SessionIdentityIcon from "../components/SessionIdentityIcon"; +import { useConversationSenderResolution } from "./ConversationSenderMetadataContext"; import { useParentAgentSender } from "./ParentAgentSenderContext"; import RawPromptToggle from "./RawPromptToggle"; -import { useSharedConversationSender } from "./SharedConversationSenderContext"; import { normalizeUserMessageText } from "./normalizeUserMessageText"; import { wasSubmittedByViewer } from "./parentAgentSender"; import { describeModelLabel } from "./rawPromptModelLabel"; import { resolveRawUserPrompt } from "./rawUserPrompt"; +import { useUserMessageDeliveryActions } from "./useUserMessageDeliveryActions"; import { resolveUserMessageSide } from "./userMessageSide"; -function readConversationSenderStamp( - event: { args?: Record } | undefined -): ConversationSenderStamp | null { - const raw = event?.args?.[CONVERSATION_SENDER_ARG]; - if (!raw || typeof raw !== "object") return null; - const stamp = raw as Partial; - return typeof stamp.userId === "string" && - typeof stamp.displayName === "string" - ? (stamp as ConversationSenderStamp) - : null; -} - const USER_MSG_MAX_LINES = 3; const USER_MSG_MAX_CHARS = 120; // Continuous chat leaves roughly ten rendered lines visible before folding. @@ -204,8 +189,6 @@ const UserChatItem = ({ onRestoreCheckpoint, }: UserChatItemProps) => { const { t } = useTranslation("sessions"); - const sharedConversationSender = useSharedConversationSender(); - const viewerCloudUserId = useAtomValue(org2CloudAuthAtom)?.userId ?? null; const [isEditing, setIsEditing] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -225,7 +208,7 @@ const UserChatItem = ({ [messageTimestamp] ); const modelLabel = useMemo(() => describeModelLabel(modelId), [modelId]); - const discussion = event ? discussionPayloadOf(event) : null; + const senderResolution = useConversationSenderResolution(event); // Who wrote this turn. In a session an agent started, a `user` turn is the // parent's dispatch rather than the reader's own message, so the row is // attributed to the parent session — same identity icon the header shows. @@ -235,8 +218,9 @@ const UserChatItem = ({ // the org roster so the `@name` text renders as a member pill. const comments = useSessionCommentsContext(); const mentionableMembers = comments?.mentionableMembers; - const mentionedUserIds = discussion?.mentionedUserIds; - const mentions = useMemo((): UserMessageMention[] | undefined => { + const discussionPayload = event ? discussionPayloadOf(event) : null; + const mentionedUserIds = discussionPayload?.mentionedUserIds; + const mentions: UserMessageMention[] | undefined = (() => { if (!mentionedUserIds?.length) return undefined; const resolved: UserMessageMention[] = []; for (const userId of mentionedUserIds) { @@ -247,7 +231,7 @@ const UserChatItem = ({ if (displayName) resolved.push({ userId, displayName }); } return resolved.length > 0 ? resolved : undefined; - }, [mentionedUserIds, mentionableMembers]); + })(); const editedText = event?.displayText ? stripExpandedPillContent(String(event.displayText)) : ""; @@ -267,6 +251,23 @@ const UserChatItem = ({ if (!Array.isArray(images) || images.length === 0) return undefined; return images.filter((image): image is string => typeof image === "string"); }, [activityResult]); + const deliveryStatus = (() => { + const raw = activityResult?.result?.deliveryStatus; + if (raw === "pending" || raw === "sent" || raw === "failed") { + return raw; + } + if (event?.displayStatus === "pending") return "pending"; + if (event?.displayStatus === "failed") return "failed"; + return null; + })(); + const deliveryError = + typeof activityResult?.result?.deliveryError === "string" + ? activityResult.result.deliveryError + : null; + const deliveryActions = useUserMessageDeliveryActions({ + event, + deliveryStatus, + }); const fullContent = useMemo(() => { // When display_text is present on the event it is the pill-format string @@ -299,6 +300,12 @@ const UserChatItem = ({ // Extract images from activity result for display in chat history. const messageImages = isAgentOrgInboxTranscript ? undefined : activityImages; + const retryDelivery = discussionPayload + ? deliveryActions.retry + : (deliveryActions.retry ?? + (onEditSubmit + ? () => onEditSubmit(editedText || fullContent, messageImages) + : null)); const needsTruncation = useMemo(() => { if (!compactPreview) return false; @@ -343,15 +350,6 @@ const UserChatItem = ({ setIsEditing(true); }, [messageImages]); - const failedLocalDiscussion = Boolean( - discussion?.deliveryStatus === "failed" && - discussion.authorUserId === viewerCloudUserId - ); - const retryFailedDiscussion = useCallback(() => { - if (!comments || !discussion || !failedLocalDiscussion) return; - void comments.retryComment(discussion.commentId).catch(() => undefined); - }, [comments, discussion, failedLocalDiscussion]); - const handleEditCancel = useCallback(() => { setIsEditing(false); }, []); @@ -361,18 +359,23 @@ const UserChatItem = ({ }, []); const handleEditSubmitInternal = useCallback( - (newText: string, addedImageDataUrls?: string[]) => { - setIsEditing(false); - if (failedLocalDiscussion && discussion && comments) { - const mentionedUserIds = resolveTeamChatMentions( - newText, - comments.mentionableMembers - ); - void comments - .retryComment(discussion.commentId, newText, mentionedUserIds) - .catch(() => undefined); + ( + newText: string, + addedImageDataUrls?: string[], + composerSnapshot?: ComposerSnapshot + ) => { + const retryEdit = deliveryActions.editAndRetry; + if (retryEdit) { + void retryEdit(newText, composerSnapshot) + .then((accepted) => { + if (accepted) setIsEditing(false); + }) + .catch((error: unknown) => { + Message.error(String(error)); + }); return; } + setIsEditing(false); const rustImages = [ ...((editImageList && editImageList.length > 0 ? editImageList.map(imageRefToRustPath) @@ -381,7 +384,7 @@ const UserChatItem = ({ ]; onEditSubmit?.(newText, rustImages.length > 0 ? rustImages : undefined); }, - [comments, discussion, editImageList, failedLocalDiscussion, onEditSubmit] + [deliveryActions, editImageList, onEditSubmit] ); // Edit mode @@ -406,12 +409,13 @@ const UserChatItem = ({ const planApprovedEdited = isPlanApproved && fullContent.startsWith("[Plan approved (edited)"); const isEditableDisplay = Boolean( - (onEditSubmit || failedLocalDiscussion) && + (onEditSubmit || deliveryActions.canEditFailed) && + deliveryStatus !== "pending" && !isRepoSetup && !isAgentOrgInboxTranscript && !isPlanApproved && - (!event?.args?.["sessionDiscussion"] || failedLocalDiscussion) && - !readConversationSenderStamp(event) + (!event?.args?.["sessionDiscussion"] || deliveryStatus === "failed") && + (!conversationSenderStampOf(event) || deliveryActions.canEditFailed) ); const hasDisplayContent = Boolean( fullContent.trim() || @@ -423,15 +427,12 @@ const UserChatItem = ({ if (!hasDisplayContent) return null; const displayNeedsTruncation = needsTruncation; - const senderStamp = readConversationSenderStamp(event); - const stampIsViewer = Boolean( - senderStamp && viewerCloudUserId && senderStamp.userId === viewerCloudUserId - ); - const ownerSide = senderStamp - ? stampIsViewer + const ownerSide = + senderResolution.relationship === "viewer" ? "right" - : "left" - : resolveUserMessageSide(event); + : senderResolution.relationship === "other" + ? "left" + : resolveUserMessageSide(event); // Only turns that would otherwise read as the viewer's own are reattributed // — a teammate's shared message already names its own sender and keeps it — // and only those the viewer did not actually submit. Someone can open a @@ -446,9 +447,7 @@ const UserChatItem = ({ const senderName = isParentAgentMessage ? parentAgentSender?.parentSession?.name?.trim() || t("chat.parentAgentSender") - : senderStamp?.displayName.trim() || - sharedConversationSender?.displayName.trim() || - "Shared user"; + : senderResolution.identity?.displayName?.trim() || null; const containerClass = `${DISPLAY_CONTAINER_BASE} ${isEditableDisplay ? "cursor-pointer outline-none" : ""}`; const messageContent = ( @@ -460,35 +459,6 @@ const UserChatItem = ({ ); // Display mode - const discussionDeliveryActions = failedLocalDiscussion ? ( - <> - - - - ) : null; - const effectiveToolbarActions = toolbarActions ?? discussionDeliveryActions; const display = ( <>
)} - {discussion?.deliveryStatus === "pending" && ( - - {t("common:status.sending")} - - )} - {discussion?.deliveryStatus === "failed" && ( - - {discussion.deliveryError || - t("chat.failedToSendMessage", "Failed to send message")} - - )}
- {(rawPrompt.trim() || isEditableDisplay || effectiveToolbarActions) && ( + {(rawPrompt.trim() || + isEditableDisplay || + toolbarActions || + deliveryStatus === "pending" || + deliveryStatus === "failed") && (
-
- {(timestampLabel || modelLabel) && ( - - {timestampLabel && messageTimestamp && ( - - )} - {timestampLabel && modelLabel && ( - - )} - {modelLabel && ( + {(rawPrompt.trim() || + isEditableDisplay || + toolbarActions || + timestampLabel || + modelLabel) && ( +
+ {(timestampLabel || modelLabel) && ( + + {timestampLabel && messageTimestamp && ( + + )} + {timestampLabel && modelLabel && ( + + )} + {modelLabel && ( + + {modelLabel.name} + {modelLabel.variant ? ` · ${modelLabel.variant}` : ""} + + )} + + )} + {rawPrompt.trim() && event?.sessionId && ( + + )} + {isEditableDisplay && onRestoreCheckpoint && ( + + )} + {isEditableDisplay && ( + + )} + {toolbarActions} +
+ )} + {(deliveryStatus === "pending" || deliveryStatus === "failed") && ( + + {deliveryStatus === "pending" && ( + + {t("common:status.sending")} + + )} + {deliveryStatus === "failed" && ( + <> - {modelLabel.name} - {modelLabel.variant ? ` · ${modelLabel.variant}` : ""} + {t("chat.failedToSendMessage")} + {deliveryError && + deliveryError !== t("chat.failedToSendMessage") + ? `: ${deliveryError}` + : null} - )} - - )} - {rawPrompt.trim() && event?.sessionId && ( - - )} - {isEditableDisplay && onRestoreCheckpoint && ( - - )} - {isEditableDisplay && ( - - )} - {effectiveToolbarActions} -
+ {retryDelivery && ( + + )} + + )} + + )}
)} @@ -696,7 +702,7 @@ const UserChatItem = ({ }`} data-message-side={messageSide} > - {isRemoteSharedMessage ? ( + {isRemoteSharedMessage && senderName ? (
)} diff --git a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts index b63863c1f6..ebbe59913f 100644 --- a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts +++ b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts @@ -2,6 +2,10 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { + CONVERSATION_SENDER_ARG, + type ConversationViewerState, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; import { makeChatItem, makeSessionEvent, @@ -9,8 +13,8 @@ import { import { namespaceCopyEventId } from "@src/features/TeamCollaboration/copyEventId"; import type { Session } from "@src/store/session"; +import { ConversationSenderMetadataProvider } from "../ConversationSenderMetadataContext"; import { ParentAgentSenderProvider } from "../ParentAgentSenderContext"; -import { SharedConversationSenderProvider } from "../SharedConversationSenderContext"; import UserChatItem from "../UserChatItem"; function renderMessage(id: string): string { @@ -27,11 +31,15 @@ function renderMessage(id: string): string { return renderToStaticMarkup( createElement( - SharedConversationSenderProvider, + ConversationSenderMetadataProvider, { value: { - displayName: "Ada Lovelace", - avatarUrl: "https://example.com/ada.png", + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: () => ({ + userId: "ada-user", + displayName: "Ada Lovelace", + avatarUrl: "https://example.com/ada.png", + }), }, }, createElement(UserChatItem, { chatItem: makeChatItem(event) }) @@ -89,13 +97,190 @@ describe("UserChatItem shared sender presentation", () => { expect(markup).toContain('data-message-side="right"'); expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Ada Lovelace"); + }); + + it("keeps the viewer's stamped plane row on the right without an alias", () => { + const event = makeSessionEvent({ + id: "convplane-self", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Optimistic self message", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "viewer-user", + displayName: "Viewer Name", + }, + }, + }); + const markup = renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + expect(markup).toContain('data-message-side="right"'); + expect(markup).not.toContain("Viewer Name"); + expect(markup).not.toContain("shared-message-sender-avatar"); }); + it("resolves a known remote account without inventing a fallback label", () => { + const event = makeSessionEvent({ + id: "convplane-remote", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Remote account message", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { userId: "remote-user" }, + }, + }); + const markup = renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: (_event, stamp) => + stamp?.userId === "remote-user" + ? { + userId: stamp.userId, + displayName: "Grace Hopper", + avatarUrl: "https://example.com/grace.png", + } + : stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Grace Hopper"); + expect(markup).toContain('src="https://example.com/grace.png"'); + expect(markup).not.toContain("Shared user"); + }); it("does not render a message-level copy control", () => { const markup = renderMessage("user-message-without-footer"); expect(markup).not.toContain('data-icon="copy"'); }); + + it("keeps a stamped local self twin on the right before and after auth hydration", () => { + const event = makeSessionEvent({ + id: "user-message-local-self", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Local self while auth hydrates", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "viewer-user", + displayName: "Viewer Name", + }, + }, + }); + const renderWithViewer = (viewer: ConversationViewerState) => + renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + const loading = renderWithViewer({ status: "loading" }); + const hydrated = renderWithViewer({ + status: "known", + userId: "viewer-user", + }); + for (const markup of [loading, hydrated]) { + expect(markup).toContain('data-message-side="right"'); + expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Shared user"); + } + }); + + it("keeps stamped remote provenance left while auth hydrates without inventing a name", () => { + const sessionId = "agentsession-local"; + const event = makeSessionEvent({ + id: namespaceCopyEventId(sessionId, "user-message-remote-stamped"), + sessionId, + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Remote while auth hydrates", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { userId: "remote-user" }, + }, + }); + const renderWithViewer = (viewer: ConversationViewerState) => + renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + const loading = renderWithViewer({ status: "loading" }); + const hydrated = renderWithViewer({ + status: "known", + userId: "viewer-user", + }); + for (const markup of [loading, hydrated]) { + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Remote while auth hydrates"); + expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Shared user"); + } + }); + + it("does not invent a Shared user while remote provenance hydrates", () => { + const sessionId = "agentsession-local"; + const event = makeSessionEvent({ + id: namespaceCopyEventId(sessionId, "user-message-remote"), + sessionId, + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Loading provenance", + displayVariant: "message", + }); + const markup = renderToStaticMarkup( + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ); + + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Loading provenance"); + expect(markup).not.toContain("Shared user"); + expect(markup).not.toContain("shared-message-sender-avatar"); + }); }); describe("UserChatItem raw prompt affordance", () => { @@ -151,6 +336,35 @@ describe("UserChatItem raw prompt affordance", () => { }); }); +describe("UserChatItem delivery failure", () => { + it("renders the underlying provider error beside the failed message", () => { + const event = makeSessionEvent({ + id: "user-message-failed", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Continue this conversation", + displayVariant: "message", + displayStatus: "failed", + result: { + deliveryStatus: "failed", + deliveryError: + "provider-native transcript is not a semantic prefix of the canonical conversation", + }, + }); + + const markup = renderToStaticMarkup( + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ); + + expect(markup).toContain('data-testid="chat-message-delivery-failed"'); + expect(markup).toContain( + "provider-native transcript is not a semantic prefix of the canonical conversation" + ); + }); +}); + describe("UserChatItem parent-agent attribution", () => { const parentSession = { session_id: "agentsession-root", diff --git a/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts new file mode 100644 index 0000000000..2e580fdc89 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; + +import type { ComposerSnapshot } from "@src/components/ComposerInput"; +import { Message } from "@src/components/Message"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; +import { discussionPayloadOf } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; + +import { useGroupChatContext } from "../ChatHistory/GroupChatView/GroupChatContext"; + +interface UserMessageDeliveryActions { + /** The current viewer may edit this failed transport row. */ + canEditFailed: boolean; + retry: (() => void) | null; + /** Returns true when a transport accepted responsibility for the edit. */ + editAndRetry: + | ((text: string, composerSnapshot?: ComposerSnapshot) => Promise) + | null; +} + +/** + * Transport adapter for failed user rows. + * + * `UserChatItem` renders only these neutral actions. Cloud comments and + * Agent-team chat retain ownership of retry validation, idempotency and wire + * delivery in their existing contexts. + */ +export function useUserMessageDeliveryActions(params: { + event: SessionEvent | undefined; + deliveryStatus: "pending" | "sent" | "failed" | null; +}): UserMessageDeliveryActions { + const comments = useSessionCommentsContext(); + const groupChat = useGroupChatContext(); + const discussion = params.event ? discussionPayloadOf(params.event) : null; + const groupChatInboxId = + typeof params.event?.args?.groupChatInboxId === "number" + ? params.event.args.groupChatInboxId + : null; + const canEditFailed = Boolean( + params.deliveryStatus === "failed" && + comments?.viewerUserId && + discussion?.authorUserId === comments.viewerUserId + ); + + const reportFailure = useCallback((error: unknown) => { + Message.error(error instanceof Error ? error.message : String(error)); + }, []); + + if ( + params.deliveryStatus === "failed" && + canEditFailed && + comments && + discussion?.commentId + ) { + return { + canEditFailed: true, + retry: () => { + void comments.retryComment(discussion.commentId).catch(reportFailure); + }, + editAndRetry: async ( + text: string, + composerSnapshot?: ComposerSnapshot + ) => { + try { + await comments.retryComment( + discussion.commentId, + text, + composerSnapshot + ); + return true; + } catch (error) { + reportFailure(error); + return false; + } + }, + }; + } + if ( + params.deliveryStatus === "failed" && + groupChat && + groupChatInboxId !== null + ) { + return { + canEditFailed: true, + retry: () => groupChat.retryFailedMessage(groupChatInboxId), + editAndRetry: async (text: string) => { + try { + groupChat.retryFailedMessage(groupChatInboxId, text); + return true; + } catch (error) { + reportFailure(error); + return false; + } + }, + }; + } + return { + canEditFailed: false, + retry: null, + editAndRetry: null, + }; +} diff --git a/src/engines/ChatPanel/ChatPanelContent.test.ts b/src/engines/ChatPanel/ChatPanelContent.test.ts index e1eb08bb9c..2b439ea3a1 100644 --- a/src/engines/ChatPanel/ChatPanelContent.test.ts +++ b/src/engines/ChatPanel/ChatPanelContent.test.ts @@ -17,7 +17,6 @@ function render(sessionViewMode: SessionViewMode): string { currentSessionId: "s-1", displayMode: "full" as const, emptyChatContent: createElement("div", { "data-empty": "true" }), - onSessionContinuation: () => undefined, paginationEnabled: false, position: "right" as const, showPanelContent: true, diff --git a/src/engines/ChatPanel/ChatPanelContent.tsx b/src/engines/ChatPanel/ChatPanelContent.tsx index 9c6728773b..5d1f0f5102 100644 --- a/src/engines/ChatPanel/ChatPanelContent.tsx +++ b/src/engines/ChatPanel/ChatPanelContent.tsx @@ -1,15 +1,15 @@ import React from "react"; -import type { SessionContinuation } from "@src/store/session/sessionTabPlacementAtom"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; import SessionContentView from "./SessionContentView"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; import type { SessionViewMode } from "./hooks/useSessionViewMode"; interface ChatPanelContentProps { currentSessionId: string | null; + conversationTargetBinding?: ConversationTargetBinding | null; emptyChatContent: React.ReactNode; - onSessionContinuation: (continuation: SessionContinuation) => void; displayMode: ChatHistoryDisplayMode; paginationEnabled: boolean; position: "left" | "right"; @@ -32,8 +32,8 @@ interface ChatPanelContentProps { */ export function ChatPanelContent({ currentSessionId, + conversationTargetBinding, emptyChatContent, - onSessionContinuation, displayMode, paginationEnabled, position, @@ -58,11 +58,11 @@ export function ChatPanelContent({ >
{/* Mounted only while active. Each alternate view windows its own diff --git a/src/engines/ChatPanel/ChatPanelHeader.tsx b/src/engines/ChatPanel/ChatPanelHeader.tsx index d14dd80117..933185f5e1 100644 --- a/src/engines/ChatPanel/ChatPanelHeader.tsx +++ b/src/engines/ChatPanel/ChatPanelHeader.tsx @@ -53,6 +53,7 @@ interface ChatPanelHeaderProps { chatPanelPosition: ChatPanelPosition; copyEventJsonLabel: "idle" | "copied" | "failed"; currentSessionId: string | null; + appOpenSessionId?: string | null; displayMode: ChatHistoryDisplayMode; eventsLength: number; handleChatFocusToggle: () => void; @@ -112,6 +113,7 @@ export function ChatPanelHeader({ chatPanelPosition, copyEventJsonLabel, currentSessionId, + appOpenSessionId, displayMode, eventsLength, handleChatFocusToggle, @@ -242,6 +244,7 @@ export function ChatPanelHeader({ activeSessionExists={activeSessionExists} copyEventJsonLabel={copyEventJsonLabel} currentSessionId={currentSessionId} + appOpenSessionId={appOpenSessionId} displayMode={displayMode} eventsLength={eventsLength} handleCompactDisplayModeToggle={handleCompactDisplayModeToggle} diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index f9fda1e07f..1e7f914bcd 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -22,7 +22,7 @@ * - Session tab bar / header * - Session creator (shown when no session) */ -import { useAtomValue, useStore } from "jotai"; +import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import React, { memo, @@ -32,27 +32,18 @@ import React, { useRef, useState, } from "react"; -import { useTranslation } from "react-i18next"; -import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; -import Message from "@src/components/Message"; import { useShowInteractArea } from "@src/contexts/workspace/ChatContext"; -import { forkExternalHistoryIntoOrgiiSession } from "@src/engines/ChatPanel/externalHistoryFork"; import { derivePlanApprovalViewState } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; import { useCloudSessionHasDownloadSurface } from "@src/features/Org2Cloud/useCloudSessionDownloadSurface"; -import { ForkCancelledError } from "@src/features/TeamCollaboration/forkSession"; import { useFileReviewSync } from "@src/hooks/fileReview"; -import { createLogger } from "@src/hooks/logger"; import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApproval"; import { useSessionWorkspaceSync } from "@src/hooks/session/useSessionWorkspaceSync"; -import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; import { loadSessions, sessionByIdAtom } from "@src/store/session"; import type { Session } from "@src/store/session"; import { - restoreToInputAtom, sessionRuntimeStatusAtom, streamRetryStatusAtom, } from "@src/store/session/cliSessionStatusAtom"; @@ -71,12 +62,16 @@ import { ChatViewHistorySurface } from "./ChatViewHistorySurface"; import { ChatViewLiveRegion } from "./ChatViewLiveRegion"; import { ChatViewPostHistoryOverlays } from "./ChatViewPostHistoryOverlays"; import type { ChatViewProps } from "./ChatViewTypes"; +import { ConversationExecutionBindingContext } from "./ConversationExecutionBindingContext"; +import { resolveConversationRunnerBindings } from "./ConversationStreamProvider"; import { useComposerSections } from "./InputArea/hooks/useComposerSections"; import { - shouldShowExternalHistoryForkComposer, + shouldShowExternalHistoryContinuationComposer, shouldShowMainChatComposer, } from "./chatViewComposerVisibility"; import { resolveInitialFileChanges } from "./chatViewFileChanges"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; +import { useConversationSubmitRouter } from "./hooks/conversationSubmit/useConversationSubmitRouter"; import { useBrowserAddToConversationAction } from "./hooks/useBrowserAddToConversationAction"; import { useChatViewAgentOrgSurface } from "./hooks/useChatViewAgentOrgSurface"; import { useChatViewAgentStationDiff } from "./hooks/useChatViewAgentStationDiff"; @@ -86,20 +81,26 @@ import { useChatViewOrgtrackSummary } from "./hooks/useChatViewOrgtrackSummary"; import { useChatViewPipelineClaim } from "./hooks/useChatViewPipelineClaim"; import { useChatViewPlanPillState } from "./hooks/useChatViewPlanPillState"; import { useChatViewScrollToBottom } from "./hooks/useChatViewScrollToBottom"; +import { useConversationTargetBinding } from "./hooks/useConversationTargetBinding"; import { useFollowAgent } from "./hooks/useFollowAgent"; -import type { SubmitOverrideInput } from "./hooks/useInputArea/types"; import { latestCompletedAssistantFingerprint, useWorkItemFollowUpSuggestions, } from "./hooks/useWorkItemFollowUpSuggestions"; -const logger = createLogger("ChatView"); - export type { ChatViewProps } from "./ChatViewTypes"; -const ChatView: React.FC = memo( +type ResolvedChatViewProps = Omit< + ChatViewProps, + "conversationTargetBinding" +> & { + conversationTargetBinding: ConversationTargetBinding | null; +}; + +const ResolvedChatView: React.FC = memo( ({ sessionId, + conversationTargetBinding, displayMode = "full", turnPaginationEnabled = true, position = "right", @@ -107,11 +108,7 @@ const ChatView: React.FC = memo( readOnly = false, secondary = false, chromeTopInset = 0, - onSessionContinuation, }) => { - const { t: tNavigation } = useTranslation("navigation"); - const store = useStore(); - const { openSession } = useSessionView(); const rootRef = useRef(null); const inputBoxRef = useRef(null); const [pinnedHeaderHost, setPinnedHeaderHost] = @@ -133,7 +130,6 @@ const ChatView: React.FC = memo( useTodoSync(isReadOnlySurface ? undefined : sessionId); useFileReviewSync(sessionId, !isReadOnlySurface && !secondary); const currentSession = useAtomValue(sessionByIdAtom(sessionId)); - const pinnedCommentsSession = usePinnedSession(sessionId) ?? null; const hydratedSessionIdsRef = useRef(new Set()); useEffect(() => { if ( @@ -185,75 +181,9 @@ const ChatView: React.FC = memo( enabled: !isReadOnlySurface && !secondary && !isCursorIde && isLiveStatus, }); - // Every imported third-party history is immutable at its source. The - // composer below is still interactive, but submitting it creates an - // ORGII-owned continuation after the shared workspace/account/model - // picker — it never writes back into Codex/Claude/Cursor/etc. - const showInteractArea = useShowInteractArea(); const hasCloudDownloadSurface = useCloudSessionHasDownloadSurface(sessionId); - // Sources whose CLI cannot reopen a session (Cursor IDE, Windsurf, - // Trae, …) are pure read-only replays: no composer, no continuation - // affordance. Only CLI-continuable histories offer the fork composer. - const importedCliResume = getImportedHistoryCliResume(sessionId); - const handleExternalHistoryForkSubmit = useCallback( - async (input: SubmitOverrideInput) => { - if (!isImportedHistory) return false; - try { - // Carry BOTH projection fields (mirrors - // useImportedSessionSubmitOverride): displayText stays the user's - // visible words, agentContent is the dispatched agent input. The - // old `agentContent ?? displayText` collapse persisted the internal - // contract as the user's message. - const newSessionId = await forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: sessionId, - sourceSession: currentSession, - userMessage: input.displayText, - agentMessage: input.agentContent, - imageDataUrls: input.imageDataUrls, - }); - await loadSessions({ forceRefresh: true }); - const continuationSession = store.get(sessionByIdAtom(newSessionId)); - const continuation = { - sessionId: newSessionId, - sessionName: continuationSession?.name, - repoPath: continuationSession?.repoPath, - }; - if (onSessionContinuation) { - onSessionContinuation(continuation); - } else { - openSession( - continuation.sessionId, - continuation.sessionName, - continuation.repoPath - ); - } - } catch (error) { - // InputArea clears a handled override. Restore the exact draft on - // cancel/failure so choosing credentials is never destructive. - store.set(restoreToInputAtom, { - sessionId, - displayContent: input.displayText, - imageDataUrls: input.imageDataUrls, - }); - if (!(error instanceof ForkCancelledError)) { - logger.error("failed to continue imported history", error); - Message.error(tNavigation("collaboration.forkImported.error")); - } - } - return true; - }, - [ - currentSession, - isImportedHistory, - onSessionContinuation, - openSession, - sessionId, - store, - tNavigation, - ] - ); const { showFollowAgent, followAgentLabel, @@ -326,22 +256,20 @@ const ChatView: React.FC = memo( const showCurrentPlanSurface = useAtomValue(showCurrentPlanSurfaceAtom); const hasBlockingDownloadSurface = hasCloudDownloadSurface && transcriptEmpty; - const showExternalHistoryForkComposer = - shouldShowExternalHistoryForkComposer({ + const showExternalHistoryContinuationComposer = + shouldShowExternalHistoryContinuationComposer({ hasBlockingDownloadSurface, isImportedHistory, readOnly, - canResume: Boolean(importedCliResume), }); - const showMainComposer = shouldShowMainChatComposer({ - showInteractArea, - isReadOnlySurface, - hasBlockingDownloadSurface, - }); - const showFloatingComposer = - showMainComposer || showExternalHistoryForkComposer; + const showMainComposer = + shouldShowMainChatComposer({ + showInteractArea, + isReadOnlySurface, + hasBlockingDownloadSurface, + }) || showExternalHistoryContinuationComposer; const { setMeasuredFloatingComposerRef, historyBottomInset } = - useChatViewFloatingComposerInset(showFloatingComposer); + useChatViewFloatingComposerInset(showMainComposer); const gitArtifactStats = useMemo( () => ({ @@ -374,7 +302,7 @@ const ChatView: React.FC = memo( handleAgentOrgMemberSessionJump, handleMainComposerSubmitOverride, cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, @@ -386,9 +314,19 @@ const ChatView: React.FC = memo( groupChatHistoryAction, } = useChatViewAgentOrgSurface({ sessionId, - currentSession, - onSessionContinuation, showCurrentPlanSurface, + conversationRoot: conversationTargetBinding?.root ?? null, + }); + const { + submit: handleConversationSubmit, + retry: handleCanonicalConversationRetry, + resolveDispatch: resolveCanonicalRetryDispatch, + } = useConversationSubmitRouter({ + sessionId, + currentSession, + root: conversationTargetBinding?.root ?? null, + selectedTarget: conversationTargetBinding?.target ?? null, + onSurfaceSubmit: handleMainComposerSubmitOverride, }); // Primary card active-data state (reported up by each card) @@ -425,7 +363,7 @@ const ChatView: React.FC = memo( } = useComposerSections({ sessionId, queueCount: sessionMessageQueue.length, - enqueueCount, + queueTailKey, hasQuestion, hasPermission, hasModeSwitch, @@ -454,8 +392,9 @@ const ChatView: React.FC = memo( // The visible ChatView's session is the authoritative composer target. // Agent-org member views may override it with queueSessionId, but ordinary // imported teammate sessions have no agent-org queue target. Passing null - // there made useMessageDispatch fail before onSubmitOverride could run - // ("no active sessionId"), bypassing the fork-before-send flow entirely. + // there made useMessageDispatch fail before onSubmitOverride could admit + // the turn to the canonical queue ("no active sessionId"), so no writable + // native execution episode could be prepared. const inputAreaSessionId = queueSessionId ?? sessionId; const { suggestions: followUpSuggestions, @@ -508,7 +447,7 @@ const ChatView: React.FC = memo( agentOrgIntervention: agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - onSubmitOverride: handleMainComposerSubmitOverride, + onSubmitOverride: handleConversationSubmit, customMentionOptions: groupChatMentionOptions, queueEditProps, disableStopWhenEmpty: groupChatViewActive, @@ -551,7 +490,7 @@ const ChatView: React.FC = memo( agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - handleMainComposerSubmitOverride, + handleConversationSubmit, groupChatMentionOptions, queueEditProps, followUpSuggestions, @@ -563,92 +502,143 @@ const ChatView: React.FC = memo( // sessionsAtom, but their org tags and push markers are keyed by bare // session id — a session_id-only stub keeps the discussion surface alive // on their local view. Scope-only shares still need the full row and - // stay uncovered here. Rows that WERE resident stay pinned so a sidebar - // roster refresh cannot strip the open conversation's identity fields. + // stay uncovered here. Imported replay rows are retained centrally by the + // session loader, so this surface does not keep a second Session cache. const commentsSession = - pinnedCommentsSession ?? + currentSession ?? (isExternalHistorySession(sessionId) ? ({ session_id: sessionId } as Session) : null); + const commentsTargetOverride = + conversationTargetBinding?.cloudTarget ?? null; return ( -
0 - ? turnPaginationEnabled || groupChatViewActive - ? { paddingTop: chromeTopInset } - : { top: chromeTopInset } - : undefined - } - data-chat-pinned-header-portal-host - /> -
- + {(activeRunnerSessionId) => { + const runnerBindings = resolveConversationRunnerBindings( + sessionId, + activeRunnerSessionId + ); + return ( + <> +
0 + ? turnPaginationEnabled || groupChatViewActive + ? { paddingTop: chromeTopInset } + : { top: chromeTopInset } + : undefined + } + data-chat-pinned-header-portal-host /> -
- - - } - composer={} - /> +
+ +
+ + + + + + ); + }} + ); } ); +ResolvedChatView.displayName = "ResolvedChatView"; + +const ChatViewWithLoadedBinding: React.FC< + Omit +> = memo((props) => { + const conversationTargetBinding = useConversationTargetBinding( + props.sessionId + ); + return ( + + ); +}); + +ChatViewWithLoadedBinding.displayName = "ChatViewWithLoadedBinding"; + +const ChatView: React.FC = memo( + ({ conversationTargetBinding, ...props }) => + conversationTargetBinding === undefined ? ( + + ) : ( + + ) +); + ChatView.displayName = "ChatView"; export default ChatView; diff --git a/src/engines/ChatPanel/ChatViewComposerSection.types.ts b/src/engines/ChatPanel/ChatViewComposerSection.types.ts index 11e7134e8d..65c4654ccd 100644 --- a/src/engines/ChatPanel/ChatViewComposerSection.types.ts +++ b/src/engines/ChatPanel/ChatViewComposerSection.types.ts @@ -34,6 +34,8 @@ interface GroupChatPendingMessageView { export interface ChatViewComposerSectionProps { sessionId: string; inputAreaSessionId: string; + /** Native execution episode controlled by Stop while the source stays visible. */ + controlSessionId?: string | null; showMainComposer: boolean; composerRef: React.Ref; inputBoxRef?: React.Ref; diff --git a/src/engines/ChatPanel/ChatViewHistorySurface.tsx b/src/engines/ChatPanel/ChatViewHistorySurface.tsx index 49f63d9639..0dea593028 100644 --- a/src/engines/ChatPanel/ChatViewHistorySurface.tsx +++ b/src/engines/ChatPanel/ChatViewHistorySurface.tsx @@ -13,13 +13,11 @@ import ChatHistory from "./ChatHistory"; import type { ChatHistoryProps } from "./ChatHistory/ChatHistory.types"; import { GroupChatProvider } from "./ChatHistory/GroupChatView/GroupChatContext"; import { AgentEventsTap } from "./ChatHistory/GroupChatView/useGroupChatMergedEvents"; -import { ConversationStreamProvider } from "./ConversationStreamProvider"; import AgentOrgOverviewPanel from "./InputArea/components/AgentOrgOverviewPanel"; interface ChatViewHistorySurfaceProps { sessionId: string; groupChatViewActive: boolean; - groupChatMergedEvents: SessionEvent[]; groupChatAgents: ReadonlyArray<{ sessionId: string }>; pipelineSessionId: string | null; handleGroupChatTapEvents: (sessionId: string, events: SessionEvent[]) => void; @@ -49,12 +47,18 @@ interface ChatViewHistorySurfaceProps { ChatHistoryProps["onGroupChatViewToggle"] >; isReadOnlySurface: boolean; + onFailedUserIntentRetry: NonNullable< + ChatHistoryProps["onFailedUserIntentRetry"] + >; + resolveFailedUserIntentDispatch: NonNullable< + ChatHistoryProps["resolveFailedUserIntentDispatch"] + >; + planningIndicatorScope: ChatHistoryProps["planningIndicatorScope"]; } export function ChatViewHistorySurface({ sessionId, groupChatViewActive, - groupChatMergedEvents, groupChatAgents, pipelineSessionId, handleGroupChatTapEvents, @@ -79,77 +83,77 @@ export function ChatViewHistorySurface({ groupChatViewAvailable, handleGroupChatViewToggle, isReadOnlySurface, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, + planningIndicatorScope, }: ChatViewHistorySurfaceProps) { return ( - { + void retryFailedGroupChatMessage(rowId, editedDisplayText).catch( + (error: unknown) => Message.error(String(error)) + ); + }} > - { - void retryFailedGroupChatMessage(rowId, editedDisplayText).catch( - (error: unknown) => Message.error(String(error)) - ); - }} - > - {groupChatViewActive && ( - - )} - {groupChatViewActive && - groupChatAgents - .filter( - (agent) => - !agent.sessionId.startsWith("agent-org-member-pending:") - ) - .map((agent) => ( - + )} + {groupChatViewActive && + groupChatAgents + .filter( + (agent) => !agent.sessionId.startsWith("agent-org-member-pending:") + ) + .map((agent) => ( + + ))} + + - ))} - - - ) : null - } - onAgentOrgMemberSelect={handleAgentOrgMemberSessionJump} - onAgentOrgRunViewRefresh={refreshAgentOrgRunView} - onScrollNavChange={handleScrollNavChange} - followAgentNav={followAgentNav} - browserAddToConversationNav={browserAddToConversationNav} - displayMode={displayMode} - turnPaginationEnabled={turnPaginationEnabled} - paginationTrailingSlot={paginationTrailingSlot} - pinnedHeaderPortalHost={pinnedHeaderHost} - chromeTopInset={chromeTopInset} - bottomInset={historyBottomInset} - groupChatViewAvailable={groupChatViewAvailable} - groupChatViewActive={groupChatViewActive} - onGroupChatViewToggle={handleGroupChatViewToggle} - /> - - - + ) : null + } + onAgentOrgMemberSelect={handleAgentOrgMemberSessionJump} + onAgentOrgRunViewRefresh={refreshAgentOrgRunView} + onScrollNavChange={handleScrollNavChange} + followAgentNav={followAgentNav} + browserAddToConversationNav={browserAddToConversationNav} + displayMode={displayMode} + turnPaginationEnabled={turnPaginationEnabled} + paginationTrailingSlot={paginationTrailingSlot} + pinnedHeaderPortalHost={pinnedHeaderHost} + chromeTopInset={chromeTopInset} + bottomInset={historyBottomInset} + groupChatViewAvailable={groupChatViewAvailable} + groupChatViewActive={groupChatViewActive} + onGroupChatViewToggle={handleGroupChatViewToggle} + onFailedUserIntentRetry={onFailedUserIntentRetry} + resolveFailedUserIntentDispatch={resolveFailedUserIntentDispatch} + planningIndicatorScope={planningIndicatorScope} + /> + + ); } diff --git a/src/engines/ChatPanel/ChatViewLiveRegion.tsx b/src/engines/ChatPanel/ChatViewLiveRegion.tsx index 9b959ed94f..c7ab40dc20 100644 --- a/src/engines/ChatPanel/ChatViewLiveRegion.tsx +++ b/src/engines/ChatPanel/ChatViewLiveRegion.tsx @@ -1,17 +1,23 @@ import { type ReactNode, memo } from "react"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { SessionCommentsProvider } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; +import { Org2ConversationSenderMetadataProvider } from "@src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider"; +import type { SessionCommentTarget } from "@src/features/Org2Cloud/sessionCommentTarget"; import type { Session } from "@src/store/session"; +import { ConversationStreamProvider } from "./ConversationStreamProvider"; import { usePipelineChatEvents } from "./hooks/usePipelineChatEvents"; interface ChatViewLiveRegionProps { commentsSession: Session | null; + commentsTargetOverride: SessionCommentTarget | null; turnAnchorsVisible: boolean; rootRef: React.RefObject; dataSessionId: string; - transcript: ReactNode; - composer: ReactNode; + conversationSessionId: string; + conversationOverrideEvents: SessionEvent[] | undefined; + children: (activeRunnerSessionId: string | null) => ReactNode; } /** @@ -21,29 +27,43 @@ interface ChatViewLiveRegionProps { */ export const ChatViewLiveRegion = memo(function ChatViewLiveRegion({ commentsSession, + commentsTargetOverride, turnAnchorsVisible, rootRef, dataSessionId, - transcript, - composer, + conversationSessionId, + conversationOverrideEvents, + children, }: ChatViewLiveRegionProps) { const { commentAnchors, transcriptReady } = usePipelineChatEvents(); return ( -
- {transcript} - {composer} -
+ + {(activeRunnerSessionId) => ( +
+ {children(activeRunnerSessionId)} +
+ )} +
+
); }); diff --git a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx index 7493c0851a..3ed0aec955 100644 --- a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx +++ b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx @@ -1,93 +1,32 @@ -/** - * ChatViewPostHistoryOverlays — bottom-of-history overlays stacked above the - * primary chat history surface: the "continue as ORGII session" composer - * shown for imported/external history, and (when that composer isn't - * showing) a standalone scroll-to-bottom affordance for imported history - * views. - */ +/** Standalone history affordances used only when no composer is visible. */ import React from "react"; -import { useTranslation } from "react-i18next"; -import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; -import { COMPOSER_BOTTOM_DOCK_PADDING_CLASS } from "@src/config/composerStackTokens"; import { CHAT_PANEL_WIDTH_TOKENS } from "@src/config/detailPanelTokens"; -import { - CHAT_SESSION_CONTEXT_NONE, - ChatSessionContext, -} from "./ChatSessionContext"; -import InputArea from "./InputArea"; -import type { SubmitOverrideInput } from "./hooks/useInputArea/types"; - interface ChatViewPostHistoryOverlaysProps { - showExternalHistoryForkComposer: boolean; - composerRef: (node: HTMLDivElement | null) => void; - position: "left" | "right"; - onSubmitOverride: (input: SubmitOverrideInput) => Promise; + composerVisible: boolean; externalScrollToBottomButton: React.ReactNode; isImportedHistory: boolean; - /** The viewed history session — Address Comments targets its threads - * even though this composer dispatches into a fork. */ - sessionId?: string; } export function ChatViewPostHistoryOverlays({ - showExternalHistoryForkComposer, - composerRef, - position, - onSubmitOverride, + composerVisible, externalScrollToBottomButton, isImportedHistory, - sessionId, }: ChatViewPostHistoryOverlaysProps) { - const { t: tNavigation } = useTranslation("navigation"); - // The composer only renders for CLI-continuable sources (ChatView gates - // `showExternalHistoryForkComposer` on the same `getImportedHistoryCliResume` - // check), so `cliResume` is always defined whenever this placeholder runs. - const cliResume = getImportedHistoryCliResume(sessionId); - const composerPlaceholder = tNavigation( - "collaboration.continueCli.composerPlaceholder", - { agent: cliResume?.displayName ?? "" } - ); - return ( - <> - {showExternalHistoryForkComposer && ( + isImportedHistory && + !composerVisible && + externalScrollToBottomButton && ( +
-
-
- - - -
+ + {externalScrollToBottomButton} +
- )} - {isImportedHistory && - !showExternalHistoryForkComposer && - externalScrollToBottomButton && ( -
-
- - {externalScrollToBottomButton} - -
-
- )} - +
+ ) ); } diff --git a/src/engines/ChatPanel/ChatViewTypes.ts b/src/engines/ChatPanel/ChatViewTypes.ts index 1f18f7e445..67be003f57 100644 --- a/src/engines/ChatPanel/ChatViewTypes.ts +++ b/src/engines/ChatPanel/ChatViewTypes.ts @@ -3,12 +3,18 @@ * sibling hooks/sub-components can reference them without importing the * full `ChatView` component. */ -import type { SessionContinuation } from "@src/store/session/sessionTabPlacementAtom"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; + export interface ChatViewProps { /** Session ID to display. Sync bridges and events load for this session. */ sessionId: string; + /** + * Resolved once by a surface that also owns session header actions. When + * omitted, ChatView remains self-contained and resolves the binding itself. + */ + conversationTargetBinding?: ConversationTargetBinding | null; displayMode?: ChatHistoryDisplayMode; turnPaginationEnabled?: boolean; /** Dock side for the containing chat panel, used to place side previews inward. */ @@ -40,10 +46,4 @@ export interface ChatViewProps { * the IDE's current folders. */ secondary?: boolean; - /** - * Retarget the owning tab after an immutable imported history is forked - * into a writable ORGII session. The callback must also claim/navigate the - * new session pipeline for its surface. - */ - onSessionContinuation?: (continuation: SessionContinuation) => void; } diff --git a/src/engines/ChatPanel/ConversationExecutionBindingContext.ts b/src/engines/ChatPanel/ConversationExecutionBindingContext.ts new file mode 100644 index 0000000000..2509455c2f --- /dev/null +++ b/src/engines/ChatPanel/ConversationExecutionBindingContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +import type { ConversationTargetBinding } from "./conversationTargetSelection"; + +/** + * One canonical conversation binding per ChatView surface. + * + * Runtime/model controls are deep composer children, but resolving a binding + * can probe the local workspace and subscribe to durable target memory. Keep + * that work at the ChatView boundary and share the result instead of mounting + * an independent resolver in every consumer. + */ +export const ConversationExecutionBindingContext = + createContext(null); + +export function useConversationExecutionBinding(): ConversationTargetBinding | null { + return useContext(ConversationExecutionBindingContext); +} diff --git a/src/engines/ChatPanel/ConversationStreamProvider.test.ts b/src/engines/ChatPanel/ConversationStreamProvider.test.ts new file mode 100644 index 0000000000..8546a1b703 --- /dev/null +++ b/src/engines/ChatPanel/ConversationStreamProvider.test.ts @@ -0,0 +1,399 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + NATIVE_SOURCE_EVENT_ID_ARG, + nativeSourceEventId, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { buildConversationRunnerOverlay } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; +import { + type ActiveMessageDelivery, + messageDeliveryRecordsAtom, +} from "@src/store/ui/messageQueueAtom"; + +import { + conversationActiveDeliveriesAtom, + createLocalExecutionHydrationCoordinator, + projectVisibleLocalExecutionTail, + resolveConversationRunnerBindings, + selectConversationActiveRunners, + shouldHydrateLocalExecutionSnapshot, + shouldIngestConversationRunnerLiveEvents, +} from "./ConversationStreamProvider"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +function messageEvent( + id: string, + source: "user" | "assistant", + displayText: string, + createdAt: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "root", + createdAt, + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user" : "assistant_message", + actionType: source === "user" ? "raw" : "assistant", + args: {}, + result: { content: displayText }, + source, + displayText, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function root(conversationId: string): ConversationRootLocator { + return { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId, + }; +} + +function activeDelivery( + id: string, + conversationId: string, + dispatchIdentityKey = "identity-a" +): ActiveMessageDelivery { + return { + id, + turnIntentId: `turn-${id}`, + sessionId: conversationId, + content: id, + displayContent: id, + conversationDispatch: { + kind: "canonical_conversation", + root: root(conversationId), + target: { cliAgentType: "claude_code" }, + dispatchIdentityKey, + }, + status: "preparing", + priority: "next", + createdAt: "2026-09-04T00:00:00.000Z", + }; +} + +describe("resolveConversationRunnerBindings", () => { + it("keeps the visible transcript on its canonical source while footer and Stop follow the runner", () => { + expect( + resolveConversationRunnerBindings( + "codexapp-canonical-source", + "cliagent-native-runner" + ) + ).toEqual({ + sourceSessionId: "codexapp-canonical-source", + controlSessionId: "cliagent-native-runner", + planningIndicatorScope: { + sessionId: "cliagent-native-runner", + isLive: true, + }, + }); + }); + + it("returns footer and Stop to their ordinary source owners without a runner", () => { + expect( + resolveConversationRunnerBindings("cliagent-ordinary", null) + ).toEqual({ + sourceSessionId: "cliagent-ordinary", + controlSessionId: null, + planningIndicatorScope: null, + }); + }); +}); + +describe("shouldIngestConversationRunnerLiveEvents", () => { + it("mounts the hidden runner ingestion edge for a canonical root", () => { + expect( + shouldIngestConversationRunnerLiveEvents( + "cliagent-hidden", + "imported-session-root" + ) + ).toBe(true); + }); + + it("leaves a visible runner to the primary SessionSync owner", () => { + expect( + shouldIngestConversationRunnerLiveEvents( + "cliagent-visible", + "cliagent-visible" + ) + ).toBe(false); + }); +}); + +describe("conversation delivery lifecycle scoping", () => { + it("does not notify one conversation when an unrelated delivery changes", () => { + const store = createStore(); + const matching = activeDelivery("matching", "root-a"); + const unrelated = activeDelivery("unrelated", "root-b"); + const scopedAtom = conversationActiveDeliveriesAtom({ + cloudRootKey: conversationRootKey(root("root-a")), + cloudIdentityKey: "identity-a", + localRootKey: null, + }); + const listener = vi.fn(); + const unsubscribe = store.sub(scopedAtom, listener); + + store.set(messageDeliveryRecordsAtom, [matching]); + expect(listener).toHaveBeenCalledTimes(1); + expect(store.get(scopedAtom)).toEqual([matching]); + + store.set(messageDeliveryRecordsAtom, [matching, unrelated]); + expect(listener).toHaveBeenCalledTimes(1); + + store.set(messageDeliveryRecordsAtom, [matching, { ...unrelated }]); + expect(listener).toHaveBeenCalledTimes(1); + + store.set(messageDeliveryRecordsAtom, [{ ...matching }, unrelated]); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("keeps Cloud delivery identity isolation while local roots use their root owner", () => { + const matching = activeDelivery("matching", "root-a", "identity-a"); + const wrongIdentity = activeDelivery( + "wrong-identity", + "root-a", + "identity-b" + ); + const local = activeDelivery("local", "local-root", "identity-b"); + const store = createStore(); + const scopedAtom = conversationActiveDeliveriesAtom({ + cloudRootKey: conversationRootKey(root("root-a")), + cloudIdentityKey: "identity-a", + localRootKey: conversationRootKey(root("local-root")), + }); + store.set(messageDeliveryRecordsAtom, [matching, wrongIdentity, local]); + + expect(store.get(scopedAtom)).toEqual([matching, local]); + }); +}); + +describe("local execution-child hydration lifecycle", () => { + it("hydrates only on first/root-change/settled-delivery boundaries", () => { + expect( + shouldHydrateLocalExecutionSnapshot(null, { + rootKey: "root-a", + activeDeliveryCount: 0, + }) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: "root-a", activeDeliveryCount: 1 } + ) + ).toBe(false); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 1 }, + { rootKey: "root-a", activeDeliveryCount: 0 } + ) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: "root-b", activeDeliveryCount: 0 } + ) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: null, activeDeliveryCount: 0 } + ) + ).toBe(false); + }); + + it("single-flights hydration bursts and commits only the latest generation", async () => { + const first = deferred(); + const second = deferred(); + const loads: string[] = []; + const commits: Array<[string, string]> = []; + const coordinator = createLocalExecutionHydrationCoordinator( + async (request: string) => { + loads.push(request); + return loads.length === 1 ? first.promise : second.promise; + }, + (result, request) => commits.push([result, request]), + () => undefined + ); + + coordinator.request("old-root"); + coordinator.request("latest-root"); + expect(loads).toEqual(["old-root"]); + + first.resolve("stale-result"); + await first.promise; + await vi.waitFor(() => { + expect(loads).toEqual(["old-root", "latest-root"]); + }); + expect(commits).toEqual([]); + + second.resolve("latest-result"); + await second.promise; + await vi.waitFor(() => { + expect(commits).toEqual([["latest-result", "latest-root"]]); + }); + }); + + it("releases hydration ownership if an error callback throws", async () => { + const hydrate = vi.fn(async (request: string) => { + if (request === "failed-root") throw new Error("read failed"); + return request; + }); + const commit = vi.fn(); + const coordinator = createLocalExecutionHydrationCoordinator( + hydrate, + commit, + () => { + coordinator.request("next-root"); + throw new Error("error subscriber failed"); + } + ); + + coordinator.request("failed-root"); + await vi.waitFor(() => { + expect(commit).toHaveBeenCalledWith("next-root", "next-root"); + }); + coordinator.request("later-root"); + await vi.waitFor(() => { + expect(commit).toHaveBeenCalledWith("later-root", "later-root"); + }); + expect(hydrate).toHaveBeenCalledTimes(3); + }); + + it("routes a local active turn through the same runner overlay as Cloud", () => { + const delivery = { + ...activeDelivery("local-turn", "local-root"), + runnerSessionId: "claude-child", + // Raw provider history contains many rows hidden from chat projection. + runnerEventStartIndex: 36, + }; + const [runner] = selectConversationActiveRunners([delivery], { + cloudRootKey: null, + cloudIdentityKey: null, + localRootKey: conversationRootKey(root("local-root")), + landedTurnIds: new Set(), + }); + const historical = messageEvent( + "historical", + "assistant", + "old answer", + "2026-09-05T00:00:00Z" + ); + const currentUser = messageEvent( + "current-user", + "user", + "continue", + "2026-09-05T00:01:00Z" + ); + const currentAssistant = messageEvent( + "current-assistant", + "assistant", + "working", + "2026-09-05T00:01:01Z" + ); + + expect(runner).toEqual({ + runnerSessionId: "claude-child", + turnId: "turn-local-turn", + eventStartIndex: 36, + }); + if (!runner) throw new Error("expected local active runner"); + expect( + buildConversationRunnerOverlay( + runner, + [ + historical, + { + ...currentUser, + result: { + ...currentUser.result, + turnIntentId: "turn-local-turn", + }, + }, + { + ...historical, + id: "materialized-historical", + chunk_id: "materialized-historical", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(historical), + }, + }, + { + ...currentAssistant, + result: { + ...currentAssistant.result, + turnIntentId: "turn-local-turn", + }, + }, + ], + "local-root" + ).map((event) => [event.id, event.displayText]) + ).toEqual([["runlive-current-assistant", "working"]]); + }); + + it("verifies a child against raw native history before applying chat visibility", () => { + const rawRoot = [ + messageEvent("root-user", "user", "inspect", "2026-09-05T00:00:00Z"), + // Normal chat projection hides this structural native message, but it + // still participates in the provider transcript prefix. + messageEvent("root-hidden", "assistant", " ", "2026-09-05T00:00:01Z"), + messageEvent("root-answer", "assistant", "done", "2026-09-05T00:00:02Z"), + ]; + const suffix = [ + messageEvent("child-user", "user", "continue", "2026-09-05T00:01:00Z"), + messageEvent( + "child-answer", + "assistant", + "continued", + "2026-09-05T00:01:01Z" + ), + ]; + const childEvents = [ + ...rawRoot.map((event) => ({ + ...event, + id: `child-${event.id}`, + chunk_id: `child-${event.id}`, + })), + ...suffix, + ]; + const child = { + session_id: "claude-child", + created_at: "2026-09-05T00:01:00Z", + }; + + expect(rawRoot.filter(isVisibleInChat)).toHaveLength(2); + expect( + projectVisibleLocalExecutionTail( + rawRoot.filter(isVisibleInChat), + [{ child, events: childEvents }], + "root" + ) + ).toEqual([]); + expect( + projectVisibleLocalExecutionTail( + rawRoot, + [{ child, events: childEvents }], + "root" + ).map((event) => event.displayText) + ).toEqual(["continue", "continued"]); + }); +}); diff --git a/src/engines/ChatPanel/ConversationStreamProvider.tsx b/src/engines/ChatPanel/ConversationStreamProvider.tsx index c22d9a81b6..e51d75f46c 100644 --- a/src/engines/ChatPanel/ConversationStreamProvider.tsx +++ b/src/engines/ChatPanel/ConversationStreamProvider.tsx @@ -1,70 +1,368 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "jotai"; +import { selectAtom } from "jotai/utils"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { resolveConversationViewerState } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + type LocalCanonicalConversationSnapshot, + type LocalExecutionSegment, + loadLocalCanonicalConversationSnapshot, + projectVerifiedLocalExecutionTail, + suppressLandedQueuedUserRows, + suppressLandedRowsOfFailedQueuedTurns, +} from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { derivePlanDisplayEvents } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { useSessionEventIngestion } from "@src/engines/SessionCore/sync/useSessionEventIngestion"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; import { - activeConversationRunnersAtom, - collectLandedTurnIds, - selectActiveRunners, -} from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; + assembleCanonicalConversationTimeline, + legacyConversationFamilyForTimeline, +} from "@src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline"; import { type ConversationFamilyMember, resolveConversationFamily, - stitchConversationSegments, } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; import { useConversationPlaneEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; -import { ConversationRunnerScopeProvider } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; -import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; import { - buildDiscussionEvents, - mergeConversationEvents, -} from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; + buildConversationRunnerOverlay, + collectLandedTurnIds, + conversationRunnerOverlaysEqual, +} from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; +import { mergeConversationEvents } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; import { useEnsureFamilyLoaded } from "@src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded"; import { useMarkDiscussionSeen } from "@src/features/Org2Cloud/SessionConversation/useMarkDiscussionSeen"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; -import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; -import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { findImportedSession } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudRemoteSessionsAtom, + remoteSessionsEntryForIdentity, +} from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import { + findImportedSession, + normalizeSourceEndpointUrl, +} from "@src/features/TeamCollaboration/engine/collabImportIdentity"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; +import { createLogger } from "@src/hooks/logger"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { sessionsAtom } from "@src/store/session"; +import { sessionByIdAtom, sessionsAtom } from "@src/store/session"; +import { + type ActiveMessageDelivery, + activeMessageDeliveriesAtom, +} from "@src/store/ui/messageQueueAtom"; import { ChatHistoryOverrideContext } from "./ChatHistoryOverrideContext"; +import { + conversationRootForSession, + conversationSourceFromImportedHistory, +} from "./hooks/useConversationTargetBinding"; + +const EMPTY_DISCUSSION_COMMENTS = [] as const; +const EMPTY_LOCAL_EXECUTION_SEGMENTS: readonly LocalExecutionSegment[] = []; +const log = createLogger("ConversationStreamProvider"); + +interface ConversationDeliveryScope { + cloudRootKey: string | null; + cloudIdentityKey: string | null; + localRootKey: string | null; +} + +function activeDeliveriesEqual( + left: readonly ActiveMessageDelivery[], + right: readonly ActiveMessageDelivery[] +): boolean { + return ( + left.length === right.length && + left.every((delivery, index) => delivery === right[index]) + ); +} + +export function selectConversationActiveDeliveries( + deliveries: readonly ActiveMessageDelivery[], + scope: ConversationDeliveryScope +): ActiveMessageDelivery[] { + return deliveries.filter((delivery) => { + const descriptor = delivery.conversationDispatch; + const rootKey = conversationRootKey(descriptor.root); + if (scope.localRootKey && rootKey === scope.localRootKey) return true; + return Boolean( + scope.cloudRootKey && + scope.cloudIdentityKey && + rootKey === scope.cloudRootKey && + descriptor.dispatchIdentityKey === scope.cloudIdentityKey + ); + }); +} + +export function conversationActiveDeliveriesAtom( + scope: ConversationDeliveryScope +) { + return selectAtom( + activeMessageDeliveriesAtom, + (deliveries) => selectConversationActiveDeliveries(deliveries, scope), + activeDeliveriesEqual + ); +} + +interface LatestHydrationRequest { + generation: number; + value: T; +} + +interface LocalExecutionHydrationCoordinator { + request: (request: TRequest) => void; + invalidate: () => void; + activate: () => void; + deactivate: () => void; +} + +/** + * Runs at most one native-history hydration at a time and coalesces bursts to + * the newest request. Generation checks prevent an old root from committing. + */ +export function createLocalExecutionHydrationCoordinator( + hydrate: (request: TRequest) => Promise, + onCurrent: (result: TResult, request: TRequest) => void, + onError: (error: unknown, request: TRequest) => void +): LocalExecutionHydrationCoordinator { + let generation = 0; + let active = true; + let running = false; + let pending: LatestHydrationRequest | null = null; + + const drain = async () => { + while (active && pending) { + const current = pending; + pending = null; + try { + const result = await hydrate(current.value); + if (active && current.generation === generation) { + onCurrent(result, current.value); + } + } catch (error) { + if (active && current.generation === generation) { + onError(error, current.value); + } + } + } + running = false; + // No await occurs between the loop condition and this assignment, but keep + // the restart guard explicit so future scheduling changes cannot lose work. + if (active && pending) start(); + }; + const start = () => { + if (!active || running || !pending) return; + running = true; + void drain().catch((error: unknown) => { + // A projection/error subscriber can throw too. Do not leave the + // single-flight owner permanently held or lose a newer pending root. + running = false; + log.error("local execution hydration callback failed", error); + if (active && pending) start(); + }); + }; + + return { + request(request) { + generation += 1; + pending = { generation, value: request }; + start(); + }, + invalidate() { + generation += 1; + pending = null; + }, + activate() { + active = true; + start(); + }, + deactivate() { + active = false; + generation += 1; + pending = null; + }, + }; +} + +interface LocalExecutionHydrationRequest { + root: ConversationRootLocator; + rootKey: string; +} + +interface LocalExecutionHydrationSnapshot { + rootKey: string; + snapshot: LocalCanonicalConversationSnapshot; +} + +interface LocalExecutionHydrationTrigger { + rootKey: string | null; + activeDeliveryCount: number; +} + +/** + * Native history is immutable during a queued turn from this projection's + * perspective: the live runner overlay owns in-flight output. Rehydrate when + * the root changes or a delivery leaves (its native suffix has settled), not + * when a delivery starts or the root Session object streams metadata updates. + */ +export function shouldHydrateLocalExecutionSnapshot( + previous: LocalExecutionHydrationTrigger | null, + next: LocalExecutionHydrationTrigger +): boolean { + if (!next.rootKey) return false; + return ( + previous === null || + previous.rootKey !== next.rootKey || + next.activeDeliveryCount < previous.activeDeliveryCount + ); +} + +async function hydrateLocalExecutionSnapshot( + request: LocalExecutionHydrationRequest +): Promise { + return { + rootKey: request.rootKey, + snapshot: await loadLocalCanonicalConversationSnapshot(request.root), + }; +} + +interface ConversationActiveRunner { + runnerSessionId: string; + turnId: string; + eventStartIndex: number; +} + +export function selectConversationActiveRunners( + deliveries: readonly ActiveMessageDelivery[], + scope: ConversationDeliveryScope & { landedTurnIds: ReadonlySet } +): ConversationActiveRunner[] { + return deliveries.flatMap((delivery) => { + const descriptor = delivery.conversationDispatch; + const rootKey = conversationRootKey(descriptor.root); + const isLocal = Boolean( + scope.localRootKey && rootKey === scope.localRootKey + ); + const isCloud = Boolean( + scope.cloudRootKey && + scope.cloudIdentityKey && + rootKey === scope.cloudRootKey && + descriptor.dispatchIdentityKey === scope.cloudIdentityKey + ); + if ( + (!isLocal && !isCloud) || + (isCloud && scope.landedTurnIds.has(delivery.turnIntentId)) || + !delivery.runnerSessionId || + delivery.runnerEventStartIndex === undefined + ) { + return []; + } + return [ + { + runnerSessionId: delivery.runnerSessionId, + turnId: delivery.turnIntentId, + eventStartIndex: delivery.runnerEventStartIndex, + }, + ]; + }); +} + +/** Verify against raw native history, then run the ordinary chat projection. */ +export function projectVisibleLocalExecutionTail( + authoritativeRootEvents: readonly SessionEvent[], + segments: readonly LocalExecutionSegment[], + canonicalSessionId: string +): SessionEvent[] { + return derivePlanDisplayEvents( + projectVerifiedLocalExecutionTail( + authoritativeRootEvents, + segments, + canonicalSessionId + ).filter(isVisibleInChat) + ); +} interface ConversationStreamProviderProps { sessionId: string; /** Pre-merged group-chat stream; takes precedence over conversation merging. */ overrideEvents: SessionEvent[] | undefined; - children: React.ReactNode; + children: (activeRunnerSessionId: string | null) => React.ReactNode; +} + +export function resolveConversationRunnerBindings( + sourceSessionId: string, + activeRunnerSessionId: string | null +): { + sourceSessionId: string; + controlSessionId: string | null; + planningIndicatorScope: { sessionId: string; isLive: true } | null; +} { + return { + sourceSessionId, + controlSessionId: activeRunnerSessionId, + planningIndicatorScope: activeRunnerSessionId + ? { sessionId: activeRunnerSessionId, isLive: true } + : null, + }; +} + +/** The primary SessionSync surface already ingests its own live channel. */ +export function shouldIngestConversationRunnerLiveEvents( + runnerSessionId: string, + pipelineSessionId: string | null +): boolean { + return runnerSessionId !== pipelineSessionId; } interface MemberEventsTapProps { bareSessionId: string; localSessionId: string; + ingestLive?: boolean; onEvents: (bareSessionId: string, events: SessionEvent[]) => void; + onUnmount?: (bareSessionId: string) => void; } /** Invisible per-family-member subscription; the atom self-hydrates on mount. */ function MemberEventsTap({ bareSessionId, localSessionId, + ingestLive = false, onEvents, + onUnmount, }: MemberEventsTapProps): null { + useSessionEventIngestion(ingestLive ? localSessionId : null); const events = useAtomValue(chatEventsForSessionAtomFamily(localSessionId)); React.useEffect(() => { onEvents(bareSessionId, events); }, [bareSessionId, events, onEvents]); + React.useEffect( + () => () => { + onUnmount?.(bareSessionId); + }, + [bareSessionId, onUnmount] + ); return null; } /** - * Feeds ChatHistory the conversation stream: the fork family stitched into - * one transcript (root first, continuations introduced by divider rows), - * with the session's discussion rows (cloud comments) interleaved by - * timestamp. Must render inside `SessionCommentsProvider`. + * Feeds ChatHistory the conversation stream: pre-plane compatibility + * segments plus the canonical Cloud plane and discussion rows. Post-plane + * execution episodes are not subscribed as additional transcript owners. + * Must render inside `SessionCommentsProvider`. */ export function ConversationStreamProvider({ sessionId, @@ -76,20 +374,24 @@ export function ConversationStreamProvider({ chatEventsForSessionAtomFamily(pipelineSessionId ?? sessionId) ); const comments = useSessionCommentsContext(); - const currentSession = usePinnedSession(sessionId); + const currentSession = useAtomValue(sessionByIdAtom(sessionId)); const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); const sessions = useAtomValue(sessionsAtom); const auth = useAtomValue(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; const target = comments?.target ?? null; - const grouped = comments?.grouped ?? null; + const discussionComments = comments?.comments ?? EMPTY_DISCUSSION_COMMENTS; const toSourceEventId = comments?.toSourceEventId ?? null; const anchorBareSessionId = currentSession?.importedFrom?.sourceSessionId ?? sessionId; const family = useMemo(() => { if (!target || overrideEvents) return null; - const rows = remoteEntries[target.orgId]?.rows; + const rows = remoteSessionsEntryForIdentity( + remoteEntries[target.orgId], + authIdentityKey + )?.rows; if (!rows?.length) return null; const resolved = resolveConversationFamily(rows, anchorBareSessionId); if (resolved) return resolved; @@ -142,18 +444,30 @@ export function ConversationStreamProvider({ target, overrideEvents, remoteEntries, + authIdentityKey, anchorBareSessionId, currentSession, auth?.userId, auth?.profile?.displayName, ]); + const plane = useConversationPlaneEvents(target); + const timelineFamily = useMemo( + () => + legacyConversationFamilyForTimeline( + family, + anchorBareSessionId, + plane.events, + plane.historyStartedAt + ), + [anchorBareSessionId, family, plane.events, plane.historyStartedAt] + ); useMarkDiscussionSeen(sessionId, comments, family); const memberTaps = useMemo(() => { - if (!family || !target) return []; + if (!timelineFamily || !target) return []; const taps: { bareSessionId: string; localSessionId: string }[] = []; - for (const member of family) { + for (const member of timelineFamily) { if (member.bareSessionId === anchorBareSessionId) continue; const local = sessions.find( @@ -173,13 +487,23 @@ export function ConversationStreamProvider({ } } return taps; - }, [family, target, sessions, auth?.supabaseUrl, anchorBareSessionId]); + }, [ + timelineFamily, + target, + sessions, + auth?.supabaseUrl, + anchorBareSessionId, + ]); const loadedBareSessionIds = useMemo( () => new Set(memberTaps.map((tap) => tap.bareSessionId)), [memberTaps] ); - useEnsureFamilyLoaded(family, loadedBareSessionIds, anchorBareSessionId); + useEnsureFamilyLoaded( + timelineFamily, + loadedBareSessionIds, + anchorBareSessionId + ); const [eventsByBareId, setEventsByBareId] = useState< ReadonlyMap @@ -195,134 +519,274 @@ export function ConversationStreamProvider({ }, [] ); - - const plane = useConversationPlaneEvents(target); - const viewerUserId = auth?.userId ?? null; + const handleMemberUnmount = useCallback((bareSessionId: string) => { + setEventsByBareId((previous) => { + if (!previous.has(bareSessionId)) return previous; + const next = new Map(previous); + next.delete(bareSessionId); + return next; + }); + }, []); + const viewer = resolveConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null, + true + ); // Live overlay for THIS device's in-flight member turns: the runner is a // local session, so its thinking / tool / worked-for events stream in real // time — tap and merge them until the plane carries the turn's terminal // tail, so the sender sees the agent working instead of a dead wait. - const runnerRegistry = useAtomValue(activeConversationRunnersAtom); - const setRunnerRegistry = useSetAtom(activeConversationRunnersAtom); const planeRootId = target?.sessionId ?? null; + const runnerRegistryKey = useMemo(() => { + if (!auth || !authIdentityKey || !target || !planeRootId) return null; + return conversationRootKey({ + authority: "org2-cloud", + authorityScope: [ + normalizeSourceEndpointUrl(auth.supabaseUrl), + target.orgId, + ], + conversationId: planeRootId, + }); + }, [auth, authIdentityKey, planeRootId, target]); + const localRoot = useMemo(() => { + if (target || overrideEvents) return null; + const imported = conversationSourceFromImportedHistory({ + sessionId, + session: currentSession, + })?.root; + const root = + imported ?? + (currentSession ? conversationRootForSession(currentSession) : null); + return root && root.conversationId === sessionId ? root : null; + }, [currentSession, overrideEvents, sessionId, target]); + const localRootKey = localRoot ? conversationRootKey(localRoot) : null; + const localRootRef = useRef(localRoot); + useEffect(() => { + localRootRef.current = localRoot; + }, [localRoot]); + const scopedActiveDeliveriesAtom = useMemo( + () => + conversationActiveDeliveriesAtom({ + cloudRootKey: runnerRegistryKey, + cloudIdentityKey: authIdentityKey, + localRootKey, + }), + [authIdentityKey, localRootKey, runnerRegistryKey] + ); + const activeDeliveries = useAtomValue(scopedActiveDeliveriesAtom); const landedTurnIds = useMemo( () => collectLandedTurnIds(plane.events), [plane.events] ); const activeRunners = useMemo(() => { - if (!planeRootId) return []; - // Drop a runner as soon as its agent tail is on the plane — the - // authoritative rows take over with no double-render. - return selectActiveRunners( - runnerRegistry[planeRootId] ?? [], - landedTurnIds - ); - }, [runnerRegistry, planeRootId, landedTurnIds]); + return selectConversationActiveRunners(activeDeliveries, { + cloudRootKey: runnerRegistryKey, + cloudIdentityKey: authIdentityKey, + localRootKey, + landedTurnIds, + }); + }, [ + activeDeliveries, + authIdentityKey, + landedTurnIds, + localRootKey, + runnerRegistryKey, + ]); + const activeRunnerIds = useMemo( + () => new Set(activeRunners.map((runner) => runner.runnerSessionId)), + [activeRunners] + ); // The in-flight runner drives the chat footer's running/typing indicator // so a member's long turn shows "Thinking…" instead of a frozen screen. - const activeRunnerScope = + const activeRunnerSessionId = activeRunners.length > 0 ? activeRunners[activeRunners.length - 1].runnerSessionId : null; + const localRootDeliveryCount = useMemo( + () => + localRootKey + ? activeDeliveries.filter( + (delivery) => + conversationRootKey(delivery.conversationDispatch.root) === + localRootKey + ).length + : 0, + [activeDeliveries, localRootKey] + ); + const [loadedLocalExecution, setLoadedLocalExecution] = + useState(null); + const localHydrationCoordinatorRef = + useRef | null>( + null + ); + const localHydrationTriggerRef = + useRef(null); useEffect(() => { - if (!planeRootId) return; - const list = runnerRegistry[planeRootId]; - if (!list?.length) return; - const kept = selectActiveRunners(list, landedTurnIds); - if (kept.length === list.length) return; - setRunnerRegistry((current) => { - const next = { ...current }; - if (kept.length === 0) delete next[planeRootId]; - else next[planeRootId] = kept; - return next; + const coordinator = createLocalExecutionHydrationCoordinator< + LocalExecutionHydrationRequest, + LocalExecutionHydrationSnapshot + >( + hydrateLocalExecutionSnapshot, + (next) => { + setLoadedLocalExecution(next); + }, + (error, request) => { + log.warn("local execution hydration could not load children", { + sessionId: request.root.conversationId, + localRootKey: request.rootKey, + error, + }); + } + ); + localHydrationCoordinatorRef.current = coordinator; + coordinator.activate(); + return () => { + coordinator.deactivate(); + if (localHydrationCoordinatorRef.current === coordinator) { + localHydrationCoordinatorRef.current = null; + } + }; + }, []); + useEffect(() => { + const nextTrigger = { + rootKey: localRootKey, + activeDeliveryCount: localRootDeliveryCount, + }; + const shouldHydrate = shouldHydrateLocalExecutionSnapshot( + localHydrationTriggerRef.current, + nextTrigger + ); + localHydrationTriggerRef.current = nextTrigger; + const coordinator = localHydrationCoordinatorRef.current; + if (!coordinator) return; + const currentRoot = localRootRef.current; + if (!currentRoot || !localRootKey) { + coordinator.invalidate(); + return; + } + if (!shouldHydrate) return; + coordinator.request({ + root: currentRoot, + rootKey: localRootKey, }); - }, [planeRootId, runnerRegistry, landedTurnIds, setRunnerRegistry]); - const [runnerEventsById, setRunnerEventsById] = useState< + }, [localRootDeliveryCount, localRootKey]); + const localSnapshot = + localRootKey && loadedLocalExecution?.rootKey === localRootKey + ? loadedLocalExecution.snapshot + : null; + const authoritativeLocalRootEvents = localSnapshot?.rootEvents ?? null; + const localExecutionSegments: readonly LocalExecutionSegment[] = + localSnapshot?.segments ?? EMPTY_LOCAL_EXECUTION_SEGMENTS; + const localTails = useMemo(() => { + if (!localRootKey || !authoritativeLocalRootEvents) return []; + return projectVisibleLocalExecutionTail( + authoritativeLocalRootEvents, + localExecutionSegments, + sessionId + ); + }, [ + authoritativeLocalRootEvents, + localExecutionSegments, + localRootKey, + sessionId, + ]); + const [runnerOverlayById, setRunnerOverlayById] = useState< ReadonlyMap >(() => new Map()); const handleRunnerEvents = useCallback( (runnerSessionId: string, events: SessionEvent[]) => { - setRunnerEventsById((previous) => { - if (previous.get(runnerSessionId) === events) return previous; - const next = new Map(previous); - next.set(runnerSessionId, events); + const runner = activeRunners.find( + (candidate) => candidate.runnerSessionId === runnerSessionId + ); + if (!runner) return; + const overlay = buildConversationRunnerOverlay(runner, events, sessionId); + setRunnerOverlayById((previous) => { + if ( + conversationRunnerOverlaysEqual( + previous.get(runnerSessionId), + overlay + ) + ) { + return previous; + } + const next = new Map( + [...previous].filter(([id]) => activeRunnerIds.has(id)) + ); + // Keep only the current-turn projection. Holding the full native + // transcript here would pin a large imported/reused Session after the + // EventStore subscription is gone. + next.set(runnerSessionId, overlay); return next; }); }, - [] + [activeRunnerIds, activeRunners, sessionId] ); + const handleRunnerUnmount = useCallback((runnerSessionId: string) => { + setRunnerOverlayById((previous) => { + if (!previous.has(runnerSessionId)) return previous; + const next = new Map(previous); + next.delete(runnerSessionId); + return next; + }); + }, []); const value = useMemo((): SessionEvent[] | undefined => { if (overrideEvents) return overrideEvents; - const base = family - ? stitchConversationSegments( - family, - anchorBareSessionId, - chatEvents, - eventsByBareId - ) - : chatEvents; - // 0024 conversation-plane turns (every member's AND the owner's) fold - // onto the transcript by server seq — local twins keep their identity. - const timeline = - plane.events.length > 0 - ? mergePlaneIntoTranscript(base, plane.events, sessionId, viewerUserId) - : base; - // Synthetic rows merged by timestamp: the sender's live runner overlay - // and Team chat discussion. + const timeline = assembleCanonicalConversationTimeline({ + family: timelineFamily, + anchorBareSessionId, + anchorEvents: chatEvents, + eventsByBareSessionId: eventsByBareId, + planeEvents: plane.events, + planeHistoryStartedAt: plane.historyStartedAt, + comments: discussionComments, + streamSessionId: sessionId, + viewer, + ...(toSourceEventId ? { toSourceEventId } : {}), + }); + // The only UI-only addition is the sender's live runner overlay. const synthetic: SessionEvent[] = []; // Live runner overlay (sender-local, pre-tail): show the agent working. - // The runner's own user event carries the injected context prefix, so - // only its non-user tail is overlaid; ids are namespaced so they never - // collide with plane rows, and the whole overlay vanishes once the - // turnId lands on the plane above. + // The canonical optimistic row already owns the visible user message, so + // the overlay contributes only provider output. Its ids are namespaced and + // the whole overlay vanishes once the turnId lands on the plane above. for (const runner of activeRunners) { - const live = runnerEventsById.get(runner.runnerSessionId); - if (!live?.length) continue; - for (const event of live) { - if (event.source === "user") continue; - synthetic.push({ - ...event, - id: `runlive-${event.id}`, - chunk_id: `runlive-${event.id}`, - sessionId, - }); - } + const overlay = runnerOverlayById.get(runner.runnerSessionId); + if (overlay?.length) synthetic.push(...overlay); } - if ( - grouped && - toSourceEventId && - (grouped.byEventId.size > 0 || - grouped.sessionLevel.length > 0 || - grouped.orphaned.length > 0) - ) { - const bySourceId = new Map(); - for (const event of chatEvents) { - const sourceId = toSourceEventId(event.id); - if (!bySourceId.has(sourceId)) bySourceId.set(sourceId, event); - } - synthetic.push(...buildDiscussionEvents(grouped, sessionId, bySourceId)); + if (localTails.length > 0) { + return mergeConversationEvents( + suppressLandedQueuedUserRows(timeline, localTails), + [ + ...synthetic, + ...suppressLandedRowsOfFailedQueuedTurns(timeline, localTails), + ] + ); } if (synthetic.length === 0) { - return family || timeline !== base ? timeline : undefined; + return timelineFamily || + plane.events.length > 0 || + discussionComments.length > 0 + ? timeline + : undefined; } return mergeConversationEvents(timeline, synthetic); }, [ + localTails, overrideEvents, - family, + timelineFamily, anchorBareSessionId, chatEvents, eventsByBareId, sessionId, - viewerUserId, - grouped, + viewer, + discussionComments, toSourceEventId, plane.events, + plane.historyStartedAt, activeRunners, - runnerEventsById, + runnerOverlayById, ]); - return ( <> {memberTaps.map((tap) => ( @@ -331,6 +795,7 @@ export function ConversationStreamProvider({ bareSessionId={tap.bareSessionId} localSessionId={tap.localSessionId} onEvents={handleMemberEvents} + onUnmount={handleMemberUnmount} /> ))} {activeRunners.map((runner) => ( @@ -338,14 +803,17 @@ export function ConversationStreamProvider({ key={`runner-${runner.runnerSessionId}`} bareSessionId={runner.runnerSessionId} localSessionId={runner.runnerSessionId} + ingestLive={shouldIngestConversationRunnerLiveEvents( + runner.runnerSessionId, + pipelineSessionId + )} onEvents={handleRunnerEvents} + onUnmount={handleRunnerUnmount} /> ))} - - - {children} - - + + {children(activeRunnerSessionId)} + ); } diff --git a/src/engines/ChatPanel/InputArea/components/ModelPill.test.ts b/src/engines/ChatPanel/InputArea/components/ModelPill.test.ts new file mode 100644 index 0000000000..d3325d70f9 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/ModelPill.test.ts @@ -0,0 +1,327 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { modelSelectorAtom } from "@src/store/ui/modelSelectorAtom"; + +import ModelPill from "./ModelPill"; + +const fixture = vi.hoisted(() => ({ + sessionId: "session-1", + binding: { + root: { + authority: "local-session", + authorityScope: [], + conversationId: "session-1", + }, + cloudTarget: null, + selection: { + keySource: "own", + cliAgentType: "codex", + model: "gpt-5.5", + selectedAccountId: "openai-1", + }, + runtimeSelection: { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "codex", + agentName: "Codex", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.5", + workspaceRepoPath: "/tmp/repo", + }, + readiness: "ready", + nativeCliTargets: ["codex", "claude_code"], + applyRuntimePick: vi.fn(() => true), + applyModelPick: vi.fn(() => true), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@src/engines/ChatPanel/ConversationExecutionBindingContext", () => ({ + useConversationExecutionBinding: () => fixture.binding, +})); +vi.mock("@src/engines/SessionCore/hooks/session", () => ({ + useSessionId: () => ({ sessionId: fixture.sessionId }), +})); +vi.mock("@src/hooks/models/useValidatedLastPair", () => ({ + useValidatedLastPair: () => null, +})); +vi.mock("@src/hooks/session/useSessionPatch", () => ({ + useSessionModelField: () => ({ setModel: vi.fn() }), +})); +vi.mock("@src/store/session", async () => { + const { atom } = await import("jotai"); + const emptySessionAtom = atom(undefined); + return { sessionByIdAtom: () => emptySessionAtom }; +}); +vi.mock("@src/store/session/cliSessionStatusAtom", async () => { + const { atom } = await import("jotai"); + return { sessionRuntimeStatusAtom: atom("idle") }; +}); +vi.mock("@src/store/ui/chatPanelAtom", async () => { + const { atom } = await import("jotai"); + return { modelPickerStyleAtom: atom("spotlight") }; +}); +vi.mock("@src/components/AnyIcon", () => ({ default: () => null })); +vi.mock("@src/components/ModelIcon", () => ({ default: () => null })); +vi.mock("@src/components/Message", () => ({ + Message: { info: vi.fn(), warning: vi.fn() }, +})); +vi.mock("@src/components/SelectorPill", async () => { + const { createElement, forwardRef } = await import("react"); + return { + default: forwardRef< + HTMLButtonElement, + { onClick: () => void; dataTestId: string } + >(({ onClick, dataTestId }, ref) => + createElement("button", { + ref, + "data-testid": dataTestId, + onClick, + }) + ), + }; +}); +vi.mock("@src/components/ModelSelectorPill", async () => { + const { createElement, forwardRef } = await import("react"); + return { + default: forwardRef< + HTMLButtonElement, + { onClick: () => void; dataTestId: string } + >(({ onClick, dataTestId }, ref) => + createElement("button", { + ref, + "data-testid": dataTestId, + onClick, + }) + ), + }; +}); +vi.mock( + "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker", + async () => { + const React = await import("react"); + return { + DispatchCategoryPicker: ({ + isOpen, + onSelect, + }: { + isOpen: boolean; + onSelect: (selection: Record) => void; + }) => + isOpen + ? React.createElement( + "div", + { "data-testid": "runtime-palette" }, + React.createElement("button", { + "data-testid": "runtime-choice-claude", + onClick: () => + onSelect({ + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "claude_code", + agentName: "Claude Code", + }), + }) + ) + : null, + }; + } +); +vi.mock( + "@src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette", + async () => { + const React = await import("react"); + return { + UnifiedModelPalette: ({ + isOpen, + cliAgentTypeOverride, + }: { + isOpen: boolean; + cliAgentTypeOverride?: string; + }) => + isOpen + ? React.createElement("div", { + "data-testid": "model-palette", + "data-cli-agent-type": cliAgentTypeOverride, + }) + : null, + }; + } +); +vi.mock( + "@src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/UnifiedModelDropdown", + async () => { + const React = await import("react"); + return { + UnifiedModelDropdown: ({ isOpen }: { isOpen: boolean }) => + isOpen + ? React.createElement("div", { "data-testid": "model-dropdown" }) + : null, + }; + } +); + +describe("ModelPill disclosure ownership", () => { + let container: HTMLDivElement; + let root: Root; + let store: ReturnType; + + function showBoundCodex(): void { + Object.assign(fixture.binding.selection, { + cliAgentType: "codex", + model: "gpt-5.5", + selectedAccountId: "openai-1", + }); + Object.assign(fixture.binding.runtimeSelection, { + cliAgentType: "codex", + agentName: "Codex", + }); + Object.assign(fixture.binding.target, { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.5", + }); + } + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + fixture.sessionId = "session-1"; + showBoundCodex(); + fixture.binding.applyRuntimePick.mockReset(); + fixture.binding.applyRuntimePick.mockReturnValue(true); + store = createStore(); + store.set(modelSelectorAtom, { isOpen: false }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root.render( + React.createElement(Provider, { store }, React.createElement(ModelPill)) + ) + ); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function click(testId: string): void { + const button = container.querySelector( + `[data-testid="${testId}"]` + ); + if (!button) throw new Error(`missing ${testId}`); + act(() => button.click()); + } + + function renderCurrentSession(): void { + root.render( + React.createElement(Provider, { store }, React.createElement(ModelPill)) + ); + } + + function showBoundAmbientClaude(): void { + Object.assign(fixture.binding.selection, { + cliAgentType: "claude_code", + model: "default", + selectedAccountId: undefined, + }); + Object.assign(fixture.binding.runtimeSelection, { + cliAgentType: "claude_code", + agentName: "Claude Code", + }); + Object.assign(fixture.binding.target, { + cliAgentType: "claude_code", + accountId: undefined, + model: undefined, + }); + } + + it("keeps the runtime and model palettes mutually exclusive", () => { + click("chat-runtime-pill"); + expect( + container.querySelector('[data-testid="runtime-palette"]') + ).not.toBeNull(); + expect(container.querySelector('[data-testid="model-palette"]')).toBeNull(); + + click("chat-model-pill-model"); + expect( + container.querySelector('[data-testid="runtime-palette"]') + ).toBeNull(); + expect( + container.querySelector('[data-testid="model-palette"]') + ).not.toBeNull(); + + click("chat-runtime-pill"); + expect(container.querySelector('[data-testid="model-palette"]')).toBeNull(); + expect( + container.querySelector('[data-testid="runtime-palette"]') + ).not.toBeNull(); + }); + + it("discards an unfinished runtime pick when the conversation changes", () => { + fixture.binding.applyRuntimePick.mockReturnValue(false); + click("chat-runtime-pill"); + click("runtime-choice-claude"); + expect( + container + .querySelector('[data-testid="model-palette"]') + ?.getAttribute("data-cli-agent-type") + ).toBe("claude_code"); + + act(() => { + fixture.sessionId = "session-2"; + store.set(modelSelectorAtom, { isOpen: true }); + renderCurrentSession(); + }); + expect( + container + .querySelector('[data-testid="model-palette"]') + ?.getAttribute("data-cli-agent-type") + ).toBe("codex"); + + act(() => { + fixture.sessionId = "session-1"; + store.set(modelSelectorAtom, { isOpen: true }); + renderCurrentSession(); + }); + expect( + container + .querySelector('[data-testid="model-palette"]') + ?.getAttribute("data-cli-agent-type") + ).toBe("codex"); + }); + + it("stops masking an ambient Claude target after the binding resolves", () => { + fixture.binding.applyRuntimePick.mockReturnValue(false); + click("chat-runtime-pill"); + click("runtime-choice-claude"); + expect( + container + .querySelector('[data-testid="chat-model-target"]') + ?.getAttribute("data-model-id") + ).toBeNull(); + + act(() => { + showBoundAmbientClaude(); + store.set(modelSelectorAtom, { isOpen: false }); + renderCurrentSession(); + }); + + expect( + container + .querySelector('[data-testid="chat-model-target"]') + ?.getAttribute("data-model-id") + ).toBe("default"); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/ModelPill.tsx b/src/engines/ChatPanel/InputArea/components/ModelPill.tsx index 53d22c8cd2..4eacb8a6a2 100644 --- a/src/engines/ChatPanel/InputArea/components/ModelPill.tsx +++ b/src/engines/ChatPanel/InputArea/components/ModelPill.tsx @@ -6,7 +6,7 @@ * * Two operating modes: * - In-session (a sessionId is in scope, the typical InputArea case) - * — display values come from `sessionByIdAtom(sessionId)` for the + * — display values come from the canonical Session row for the * fields the row carries (`model`, `accountId`, `keySource`, * `cliAgentType`, `tier`); display-only labels are derived from * KeyVault by accountId in `resolveModelDisplaySelection`. @@ -17,7 +17,7 @@ * default atom only. Used by the SessionCreator preview. */ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import React, { memo, useCallback, useMemo, useRef } from "react"; +import React, { memo, useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; @@ -26,22 +26,28 @@ import { KEY_SOURCE, isHostedKey, } from "@src/api/tauri/session"; +import AnyIcon from "@src/components/AnyIcon"; import { Message } from "@src/components/Message"; +import ModelIcon from "@src/components/ModelIcon"; import ModelSelectorPill from "@src/components/ModelSelectorPill"; +import SelectorPill from "@src/components/SelectorPill"; +import { resolveAgentIcon } from "@src/config/agentIcons"; +import { useConversationExecutionBinding } from "@src/engines/ChatPanel/ConversationExecutionBindingContext"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; -import { useConversationSetupPillBinding } from "@src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding"; import type { AdvancedConfig } from "@src/features/SessionCreator/types"; import { useValidatedLastPair } from "@src/hooks/models/useValidatedLastPair"; import { useSessionModelField } from "@src/hooks/session/useSessionPatch"; +import type { AgentSelection } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; +import { DispatchCategoryPicker } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker"; import { UnifiedModelPalette } from "@src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette"; import { UnifiedModelDropdown } from "@src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/UnifiedModelDropdown"; +import { sessionByIdAtom } from "@src/store/session"; import { sessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; import { type LastModelSelection, creatorDefaultModelSelectionAtom, extractModelPair, } from "@src/store/session/creatorDefaultModelAtom"; -import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { modelPickerStyleAtom } from "@src/store/ui/chatPanel/displayPrefsAtoms"; import { modelSelectorAtom } from "@src/store/ui/modelSelectorAtom"; import { isActiveStatus } from "@src/types/session/session"; @@ -51,10 +57,36 @@ import { getDispatchCategory } from "@src/util/session/sessionDispatch"; // Component // ============================================ -const ModelPill: React.FC = memo(() => { +function isSameRuntimeSelection( + left: AgentSelection, + right: AgentSelection | null +): boolean { + if (!right || left.category !== right.category) return false; + if (left.category === "cli_agent") { + return ( + right.category === "cli_agent" && + Boolean(left.cliAgentType) && + left.cliAgentType === right.cliAgentType + ); + } + return ( + left.category === "rust_agent" && + right.category === "rust_agent" && + Boolean(left.agentDefinitionId) && + left.agentDefinitionId === right.agentDefinitionId + ); +} + +const ModelPillComponent: React.FC = () => { const { t } = useTranslation(); const modelPickerStyle = useAtomValue(modelPickerStyleAtom); const modelSegmentRef = useRef(null); + const runtimeSegmentRef = useRef(null); + const [isRuntimeOpen, setIsRuntimeOpen] = useState(false); + const [pendingRuntimePick, setPendingRuntimePick] = useState<{ + sessionId: string | null | undefined; + selection: AgentSelection; + } | null>(null); const [selectorState, setSelectorState] = useAtom(modelSelectorAtom); const isModelOpen = selectorState.isOpen; // Creator-default selection — also used as the display-only-fields @@ -64,6 +96,14 @@ const ModelPill: React.FC = memo(() => { const setCreatorDefaultModel = useSetAtom(creatorDefaultModelSelectionAtom); const { sessionId } = useSessionId(); + const [pendingRuntimeOwner, setPendingRuntimeOwner] = useState(sessionId); + if (pendingRuntimeOwner !== sessionId) { + // React supports guarded state adjustment while rendering. This clears an + // unfinished runtime -> model pick before the new conversation commits, + // without an effect flash or a second cross-session state owner. + setPendingRuntimeOwner(sessionId); + setPendingRuntimePick(null); + } const isInSession = Boolean(sessionId); const session = useAtomValue(sessionByIdAtom(sessionId ?? "")); const runtimeStatus = useAtomValue(sessionRuntimeStatusAtom); @@ -72,7 +112,31 @@ const ModelPill: React.FC = memo(() => { // the remembered runner setup — the pill mirrors and edits THAT record // instead of the imported row, whose model field is deliberately empty // and whose patches the next family refresh would wipe anyway. - const conversationBinding = useConversationSetupPillBinding(sessionId); + const conversationBinding = useConversationExecutionBinding(); + const pendingRuntimeCandidate = + pendingRuntimePick && pendingRuntimePick.sessionId === sessionId + ? pendingRuntimePick.selection + : null; + // Runtime resolution can complete after the picker initially reported that + // it still needed a model. Once the authoritative conversation binding has + // committed that runtime, its complete target/presentation owns the footer; + // do not let the picker-local draft keep masking ambient Claude as + // "Select model" while turns already execute against Claude. + const pendingRuntimeResolved = Boolean( + pendingRuntimeCandidate && + conversationBinding?.target && + isSameRuntimeSelection( + pendingRuntimeCandidate, + conversationBinding.runtimeSelection + ) + ); + const pendingRuntimeSelection = pendingRuntimeResolved + ? null + : pendingRuntimeCandidate; + const clearPendingRuntimeSelection = useCallback( + () => setPendingRuntimePick(null), + [] + ); // When inside an active session, pass the session's own dispatchCategory and // cliAgentType to the palette so account filtering uses the correct agent @@ -83,10 +147,16 @@ const ModelPill: React.FC = memo(() => { ? getDispatchCategory(sessionId) : undefined; const paletteCategoryOverride: DispatchCategory | undefined = isInSession - ? (session?.category ?? sessionIdCategory) + ? conversationBinding + ? (pendingRuntimeSelection?.category ?? + conversationBinding.runtimeSelection?.category) + : (session?.category ?? sessionIdCategory) : undefined; const paletteCliAgentTypeOverride: CliAgentType | undefined = isInSession - ? (session?.cliAgentType ?? undefined) + ? conversationBinding + ? (pendingRuntimeSelection?.cliAgentType ?? + conversationBinding.runtimeSelection?.cliAgentType) + : (session?.cliAgentType ?? undefined) : undefined; // The display value `lastModel` is built from the session row when @@ -134,6 +204,7 @@ const ModelPill: React.FC = memo(() => { provider: lastModel.provider, model: lastModel.model, selectedAccountId: lastModel.selectedAccountId, + cliAgentType: lastModel.cliAgentType, selectedSourceLabel: lastModel.selectedSourceLabel, selectedSourceModelType: lastModel.selectedSourceModelType, }; @@ -144,11 +215,14 @@ const ModelPill: React.FC = memo(() => { // Team-conversation composer: the pick belongs to the remembered // runner setup, never to the imported row (whose model field is // deliberately empty and whose patches a family refresh wipes). - // Before the first send confirms a setup there is no record to - // edit — the setup dialog remains the authoritative entry. + // Runtime has its own standard New Session picker. This picker only + // changes the model/account source for that selected runtime. if (conversationBinding) { - conversationBinding.applyModelPick(config); - setCreatorDefaultModel(extractModelPair(config)); + if ( + conversationBinding.applyModelPick(config, pendingRuntimeSelection) + ) { + clearPendingRuntimeSelection(); + } return; } // In-session: keySource / cliAgentType / tier are session-create @@ -221,17 +295,26 @@ const ModelPill: React.FC = memo(() => { setSessionModel, runtimeStatus, conversationBinding, + clearPendingRuntimeSelection, + pendingRuntimeSelection, t, ] ); const handleOpenModelSelector = useCallback(() => { + setIsRuntimeOpen(false); setSelectorState({ isOpen: true }); }, [setSelectorState]); + const handleToggleRuntimeSelector = useCallback(() => { + if (!isRuntimeOpen) setSelectorState({ isOpen: false }); + setIsRuntimeOpen((open) => !open); + }, [isRuntimeOpen, setSelectorState]); + const handleCloseSelector = useCallback(() => { setSelectorState({ isOpen: false }); - }, [setSelectorState]); + clearPendingRuntimeSelection(); + }, [clearPendingRuntimeSelection, setSelectorState]); const handleVariantApply = useCallback( (nextModelId: string) => { @@ -249,31 +332,144 @@ const ModelPill: React.FC = memo(() => { [advancedConfig, handleConfigChange, lastModel] ); - const modelPill = ( - { + if (conversationBinding?.applyRuntimePick(selection)) { + clearPendingRuntimeSelection(); + setIsRuntimeOpen(false); + return; + } + // Mirror New Session: retain the uncommitted runtime choice only in + // this picker, then immediately hand off to the existing source/model + // palette. No incomplete execution target reaches persisted state. + setPendingRuntimePick({ sessionId, selection }); + setIsRuntimeOpen(false); + setSelectorState({ isOpen: true }); + }, + [ + clearPendingRuntimeSelection, + conversationBinding, + sessionId, + setSelectorState, + ] + ); + + const pillSelection = useMemo( + () => + pendingRuntimeSelection + ? null + : conversationBinding && lastModel + ? { ...lastModel, cliAgentLabel: undefined } + : lastModel, + [conversationBinding, lastModel, pendingRuntimeSelection] + ); + + const conversationTargetReady = + !conversationBinding || + (conversationBinding.readiness === "ready" && + (Boolean(conversationBinding.target) || + Boolean(pendingRuntimeSelection))); + const modelDefaultLabel = + conversationBinding?.readiness === "loading" + ? t("common:actions.loading") + : t("sessions:creator.model"); + const visiblePillSelection = conversationTargetReady ? pillSelection : null; + const effectiveModelOpen = isModelOpen && conversationTargetReady; + const runtimeSelection = + pendingRuntimeSelection ?? conversationBinding?.runtimeSelection ?? null; + const runtimeReady = conversationBinding?.readiness === "ready"; + const effectiveRuntimeOpen = isRuntimeOpen && runtimeReady; + const runtimeLabel = + conversationBinding?.readiness === "loading" + ? t("common:actions.loading") + : (runtimeSelection?.agentName ?? t("sessions:creator.selectAgent")); + const runtimeIcon = runtimeSelection?.cliAgentType ? ( + + ) : ( + ); + const paletteAdvancedConfig = pendingRuntimeSelection + ? { + keySource: KEY_SOURCE.OWN, + cliAgentType: pendingRuntimeSelection.cliAgentType, + } + : advancedConfig; + + const modelPill = ( +
+ +
+ ); + + // A Chat Pane can open synchronously before a cloud replay's local + // Session row exists. Never paint the unrelated New Session defaults in + // that gap; the loading-source binding normally resolves in the same + // frame, and an unavailable source renders no false selection at all. + if (isInSession && !session && !conversationBinding) return null; return ( <> + {conversationBinding && ( + <> + + setIsRuntimeOpen(false)} + onSelect={handleRuntimeSelect} + currentCategory={runtimeSelection?.category} + currentAgentDefinitionId={runtimeSelection?.agentDefinitionId} + currentCliAgentType={runtimeSelection?.cliAgentType} + hideOrgs + allowedCliAgentTypes={conversationBinding.nativeCliTargets} + anchorRef={runtimeSegmentRef} + placement="top" + /> + + )} {modelPill} - {isModelOpen && + {effectiveModelOpen && (modelPickerStyle === "dropdown" ? ( { ))} ); -}); +}; + +const ModelPill = memo(ModelPillComponent); ModelPill.displayName = "ModelPill"; diff --git a/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx b/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx index ac8ba5f905..555f444d69 100644 --- a/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx +++ b/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx @@ -35,6 +35,7 @@ interface QueuedMessageItemProps { draggable: boolean; isDragging: boolean; isEditing: boolean; + isHandoff: boolean; onStartEdit: (msg: QueuedMessage) => void; onSendNow: (messageId: string) => void; onCancel: (messageId: string) => void; @@ -46,6 +47,7 @@ const QueuedMessageItem: React.FC = memo( draggable, isDragging, isEditing, + isHandoff, onStartEdit, onSendNow, onCancel, @@ -54,11 +56,12 @@ const QueuedMessageItem: React.FC = memo( // "now" priority = Send Now clicked; the dispatcher delivers the moment // the interrupted turn's terminal lands. Render as "sending now…" so the // user sees their click took effect during the interrupt window. - const isSendingNow = msg.priority === "now"; + const isSending = + isHandoff || msg.status !== "queued" || msg.priority === "now"; const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: msg.id, - disabled: isEditing || isSendingNow || !draggable, + disabled: isEditing || isSending || !draggable, }); const style: React.CSSProperties = { @@ -78,19 +81,19 @@ const QueuedMessageItem: React.FC = memo( style={style} className={`${COMPOSER_STACK_ROW_BASE} ${ isEditing ? "bg-primary-1" : COMPOSER_STACK_ROW_HOVER - } ${draggable && !isEditing && !isSendingNow ? "cursor-grab active:cursor-grabbing" : ""}`} + } ${draggable && !isEditing && !isSending ? "cursor-grab active:cursor-grabbing" : ""}`} data-testid="queued-message-item" data-queued-message-id={msg.id} data-queued-message-content={msg.displayContent} - data-queued-message-sending={isSendingNow || undefined} + data-queued-message-sending={isSending || undefined} title={msg.displayContent} aria-label={msg.displayContent} - {...(draggable && !isEditing && !isSendingNow + {...(draggable && !isEditing && !isSending ? { ...attributes, ...listeners } : {})} >
- {isSendingNow ? ( + {isSending ? ( = memo( > {preview} - {isSendingNow && ( + {isSending && ( {t("common:labels.sendingNow")} )} - {!isEditing && !isSendingNow && ( + {!isEditing && !isSending && ( } @@ -180,6 +186,7 @@ const QueuedMessages: React.FC = memo( draggable={draggable} isDragging={draggingId === msg.id} isEditing={editTarget?.messageId === msg.id} + isHandoff={Boolean(handoffIds?.has(msg.id))} onStartEdit={startEdit} onSendNow={onSendNow} onCancel={onCancel} diff --git a/src/engines/ChatPanel/InputArea/hooks/__tests__/useEditMode.test.ts b/src/engines/ChatPanel/InputArea/hooks/__tests__/useEditMode.test.ts index 9b110269dc..eb90795b76 100644 --- a/src/engines/ChatPanel/InputArea/hooks/__tests__/useEditMode.test.ts +++ b/src/engines/ChatPanel/InputArea/hooks/__tests__/useEditMode.test.ts @@ -23,6 +23,7 @@ describe("useEditMode initial message text", () => { getEditor: () => ({}), getText: () => "", getTextWithPills: () => "", + getSnapshot: () => ({ parts: [] }), setContent, focus: vi.fn(), }; diff --git a/src/engines/ChatPanel/InputArea/hooks/useComposerSections.ts b/src/engines/ChatPanel/InputArea/hooks/useComposerSections.ts index c947f38cbe..8429a6a297 100644 --- a/src/engines/ChatPanel/InputArea/hooks/useComposerSections.ts +++ b/src/engines/ChatPanel/InputArea/hooks/useComposerSections.ts @@ -45,7 +45,8 @@ export interface GitArtifactStats { export interface UseComposerSectionsOptions { sessionId?: string | null; queueCount: number; - enqueueCount?: number; + /** Current session's newest durable queue identity. */ + queueTailKey?: string | null; /** Whether the AskQuestionCard currently has pending data (controls pill visibility). */ hasQuestion?: boolean; /** Whether the PermissionCard currently has pending data. */ @@ -129,7 +130,7 @@ export function createFileInlineSection({ export function useComposerSections({ sessionId, queueCount, - enqueueCount = 0, + queueTailKey = null, hasQuestion = false, hasPermission = false, hasModeSwitch = false, @@ -197,20 +198,18 @@ export function useComposerSections({ setFileChangeStats({ count: 0, additions: 0, deletions: 0 }); } - // Auto-expand queue when messages arrive. Prefer the monotonic enqueue - // counter when it is available, but also react to count growth so the queue - // stays visible if the counter update and queue filter land in different - // render passes or a session switch restores a non-empty queue. - const [prevEnqueueCount, setPrevEnqueueCount] = useState(enqueueCount); + // Auto-expand only for a new durable row in this session. A global enqueue + // counter made traffic in session B open the queue card in session A. + const [prevQueueTailKey, setPrevQueueTailKey] = useState(queueTailKey); const [prevQueueCount, setPrevQueueCount] = useState(queueCount); const [queueAutoOpenedForCount, setQueueAutoOpenedForCount] = useState( queueCount > 0 ? queueCount : 0 ); - if (prevEnqueueCount !== enqueueCount || prevQueueCount !== queueCount) { + if (prevQueueTailKey !== queueTailKey || prevQueueCount !== queueCount) { const hasNewQueueWork = queueCount > 0 && - (enqueueCount > prevEnqueueCount || queueCount > prevQueueCount); - setPrevEnqueueCount(enqueueCount); + (queueTailKey !== prevQueueTailKey || queueCount > prevQueueCount); + setPrevQueueTailKey(queueTailKey); setPrevQueueCount(queueCount); setQueueAutoOpenedForCount(hasNewQueueWork ? queueCount : 0); if (hasNewQueueWork) { diff --git a/src/engines/ChatPanel/InputArea/hooks/useEditMode.ts b/src/engines/ChatPanel/InputArea/hooks/useEditMode.ts index b128911e2f..254457ff99 100644 --- a/src/engines/ChatPanel/InputArea/hooks/useEditMode.ts +++ b/src/engines/ChatPanel/InputArea/hooks/useEditMode.ts @@ -27,7 +27,11 @@ interface UseEditModeOptions { /** Initial text to pre-fill */ initialContent?: string; /** Callback when edit is submitted */ - onEditSubmit?: (text: string, imageDataUrls?: string[]) => void; + onEditSubmit?: ( + text: string, + imageDataUrls?: string[], + composerSnapshot?: ComposerSnapshot + ) => void; /** Images newly attached while editing */ attachedImageDataUrls?: string[]; /** @@ -46,6 +50,7 @@ interface UseEditModeOptions { setContent: (content: string | ComposerSnapshot) => void; getText: () => string; getTextWithPills: () => string; + getSnapshot: () => ComposerSnapshot; focus: () => void; } | null>; } @@ -134,7 +139,8 @@ export function useEditMode({ if (text) { onEditSubmit( text, - attachedImageDataUrls.length > 0 ? attachedImageDataUrls : undefined + attachedImageDataUrls.length > 0 ? attachedImageDataUrls : undefined, + composerInputRef.current.getSnapshot() ); // The images are now part of the edited message — drop them from // the composer attachment atom so they aren't shown (or re-folded) diff --git a/src/engines/ChatPanel/InputArea/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index f9c2eb2a29..dd53d95079 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -3,9 +3,13 @@ import React, { memo, useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { SessionFollowUpSuggestion } from "@src/api/services/sessionFollowUpSuggestions"; -import type { ComposerInputRef } from "@src/components/ComposerInput"; +import type { + ComposerInputRef, + ComposerSnapshot, +} from "@src/components/ComposerInput"; import ComposerShell from "@src/components/ComposerShell"; import Message from "@src/components/Message"; +import { useConversationExecutionBinding } from "@src/engines/ChatPanel/ConversationExecutionBindingContext"; import { useInputArea } from "@src/engines/ChatPanel/hooks/useInputArea"; import type { CustomMentionOption, @@ -58,7 +62,11 @@ interface InputAreaProps { placeholder?: string; isEditMode?: boolean; initialContent?: string; - onEditSubmit?: (text: string, imageDataUrls?: string[]) => void; + onEditSubmit?: ( + text: string, + imageDataUrls?: string[], + composerSnapshot?: ComposerSnapshot + ) => void; onEditSendNow?: (text: string, imageDataUrls?: string[]) => void; onEditCancel?: () => void; editLabel?: string; @@ -71,6 +79,8 @@ interface InputAreaProps { omitChatHeader?: boolean; chatPanelPosition?: "left" | "right"; sessionId?: string; + /** Optional native execution episode for Stop/status; messages stay on sessionId. */ + controlSessionId?: string | null; onSubmitOverride?: (input: SubmitOverrideInput) => Promise; customMentionOptions?: ReadonlyArray; topRowPills?: React.ReactNode; @@ -147,6 +157,7 @@ const InputAreaInteractive: React.FC = memo( surfaceBg = false, omitChatHeader = false, sessionId: propSessionId, + controlSessionId, onSubmitOverride, customMentionOptions, topRowPills, @@ -167,6 +178,7 @@ const InputAreaInteractive: React.FC = memo( slashItemCategories, presentation = "default", }) => { + const conversationExecutionBinding = useConversationExecutionBinding(); const { t } = useTranslation("sessions"); const { sessionId } = useSessionId({ propSessionId }); @@ -196,10 +208,18 @@ const InputAreaInteractive: React.FC = memo( const mergedCustomMentionOptions = useMemo( () => [ ...openedTabMentionOptions, - ...(customMentionOptions ?? []), + // Agent/Agent Org audience pills are a different address space from + // Cloud members. They must not enter a Team Chat snapshot where an + // identically-shaped id could be persisted as a human recipient. + ...(teamChatActive ? [] : (customMentionOptions ?? [])), ...teamChatMentionOptions, ], - [openedTabMentionOptions, customMentionOptions, teamChatMentionOptions] + [ + openedTabMentionOptions, + customMentionOptions, + teamChatActive, + teamChatMentionOptions, + ] ); const { @@ -264,15 +284,25 @@ const InputAreaInteractive: React.FC = memo( } = useInputArea({ placeholder, sessionId: propSessionId, + controlSessionId, sessionScope, submitDisabled, onSubmitOverride: conversationSubmitOverride, customMentionOptions: mergedCustomMentionOptions, - enableAgentInterceptors, + // Team Chat is a human comment surface. It keeps shared composer + // validation/attachments, but Agent-only slash commands, pending + // questions, MCP prompts, and skill expansion must not mutate or consume + // the backing Agent transcript before the comment router sees the text. + enableAgentInterceptors: enableAgentInterceptors && !teamChatActive, + executionControlsEnabled: !teamChatActive, }); const currentTextEmpty = isInputEmpty(); const currentInputEmpty = currentTextEmpty && !hasImages; + // Canonical conversations own resume/retry through the canonical queue; + // the generic CLI Resume action would target the hidden runner directly. + const genericResumeAvailable = + canResume && !teamChatActive && conversationExecutionBinding === null; const stopSuppressedForEmptyInput = disableStopWhenEmpty && currentInputEmpty && !isWpGeneWorking; const voiceFeatureEnabled = useAtomValue(voiceInputEnabledAtom); @@ -545,7 +575,7 @@ const InputAreaInteractive: React.FC = memo( hasImages={hasImages} isHosted={isHosted} canStopAgent={canStopAgent} - canResume={canResume} + canResume={genericResumeAvailable} onInterrupt={interruptSession} onResume={resumeSession} isCursorIde={isCursorIde} @@ -582,7 +612,7 @@ const InputAreaInteractive: React.FC = memo( modelPill={modelPill} isHosted={isHosted} canStopAgent={canStopAgent} - canResume={canResume} + canResume={genericResumeAvailable} onInterrupt={interruptSession} onResume={resumeSession} isCursorIde={isCursorIde} diff --git a/src/engines/ChatPanel/SideChat/index.tsx b/src/engines/ChatPanel/SideChat/index.tsx index b259291fb3..c8ebc9df47 100644 --- a/src/engines/ChatPanel/SideChat/index.tsx +++ b/src/engines/ChatPanel/SideChat/index.tsx @@ -19,9 +19,10 @@ * `ChatSessionContext.Provider` + `ChatProvider` route `ChatHistory` to * `chatEventsForSessionAtomFamily(sessionId)` — a per-session snapshot * subscription that streams live without touching the global pipeline. - * Sending goes through `SessionService.sendMessage`, which is adapter- - * routed per session id, via the composer's `onSubmitOverride` (the - * `ChannelComposer` call shape). + * Sending still goes through the ordinary user-intent submit boundary via the + * composer's `onSubmitOverride` (the `ChannelComposer` call shape), so queue + * admission and optimistic pending/sent/failed rows cannot diverge from the + * main chat pane. * * Two body modes, driven by `sideChatSessionIdAtom`: * - session id → that session's live chat + composer; @@ -41,7 +42,7 @@ import { HEADER_ICON_SIZE, } from "@src/config/workstation/tokens"; import { ChatProvider } from "@src/contexts/workspace/ChatContext"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { isUserIntentSendError } from "@src/engines/SessionCore/services/userIntentDispatch"; import { createLogger } from "@src/hooks/logger"; import { BubbleChatIcon, @@ -51,7 +52,7 @@ import { } from "@src/icons"; import { openOrFocusSessionInChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { activeChatPanelTabTypeAtom } from "@src/store/chatPanel/chatPanelTabsState"; -import { sessionMapAtom } from "@src/store/session"; +import { type Session, sessionMapAtom } from "@src/store/session"; import { chatTurnPaginationEnabledAtom } from "@src/store/ui/chatPanel/displayPrefsAtoms"; import { chatVisibleAtom, @@ -68,8 +69,12 @@ import { stripPillReferences } from "@src/util/session/stripPillReferences"; import ChatHistory from "../ChatHistory"; import { ChatSessionContext } from "../ChatSessionContext"; +import { ConversationExecutionBindingContext } from "../ConversationExecutionBindingContext"; import InputArea from "../InputArea"; +import { useConversationSubmitRouter } from "../hooks/conversationSubmit/useConversationSubmitRouter"; +import { useConversationTargetBinding } from "../hooks/useConversationTargetBinding"; import type { SubmitOverrideInput } from "../hooks/useInputArea/types"; +import { useUserIntentSubmit } from "../hooks/useWorkspaceChat/useUserIntentSubmit"; import type { ChatPanelProps } from "../types"; import { shouldShowSideChatLauncher } from "./sideChatLauncherVisibility"; @@ -253,6 +258,7 @@ const SideChatWindow: React.FC = ({ ) : SessionCreatorSlot ? ( @@ -280,40 +286,60 @@ const SideChatWindow: React.FC = ({ interface SideChatSessionBodyProps { sessionId: string; + session?: Session; isLive: boolean; } const SideChatSessionBody: React.FC = ({ sessionId, + session, isLive, }) => { const turnPaginationEnabled = useAtomValue(chatTurnPaginationEnabledAtom); + const conversationTargetBinding = useConversationTargetBinding(sessionId); + const getSessionId = useCallback(() => sessionId, [sessionId]); + const submitUserIntent = useUserIntentSubmit({ getSessionId }); - const handleSubmit = useCallback( + const handleSurfaceSubmit = useCallback( async ({ displayText, agentContent, imageDataUrls, }: SubmitOverrideInput): Promise => { + if (conversationTargetBinding?.root) return false; const content = agentContent ?? displayText; if (!content.trim()) return false; try { - await SessionService.sendMessage({ + await submitUserIntent({ sessionId, - content, - displayText, + displayContent: displayText, + agentContent: content, imageDataUrls, - turnIntentSource: "user_submit", - directUserIntent: true, + source: "dispatch", }); return true; } catch (error) { log.error(`Failed to send side-chat message to ${sessionId}:`, error); + // The ordinary dispatch boundary already persisted a visible failed + // row. Treat that submit as handled so InputArea does not restore a + // duplicate draft; only pre-admission failures keep the composer. + if (isUserIntentSendError(error)) return true; return false; } }, - [sessionId] + [conversationTargetBinding?.root, sessionId, submitUserIntent] ); + const { + submit: handleSubmit, + retry: handleCanonicalConversationRetry, + resolveDispatch: resolveCanonicalRetryDispatch, + } = useConversationSubmitRouter({ + sessionId, + currentSession: session, + root: conversationTargetBinding?.root ?? null, + selectedTarget: conversationTargetBinding?.target ?? null, + onSurfaceSubmit: handleSurfaceSubmit, + }); return ( @@ -324,20 +350,35 @@ const SideChatSessionBody: React.FC = ({ surfaceBgClass="bg-bg-2" turnPaginationEnabled={turnPaginationEnabled} planningIndicatorScope={{ sessionId, isLive }} + onFailedUserIntentRetry={ + conversationTargetBinding + ? handleCanonicalConversationRetry + : undefined + } + resolveFailedUserIntentDispatch={ + conversationTargetBinding + ? resolveCanonicalRetryDispatch + : undefined + } />
- + + +
diff --git a/src/engines/ChatPanel/chatViewComposerVisibility.test.ts b/src/engines/ChatPanel/chatViewComposerVisibility.test.ts index 26a052ef9f..c9dd4db229 100644 --- a/src/engines/ChatPanel/chatViewComposerVisibility.test.ts +++ b/src/engines/ChatPanel/chatViewComposerVisibility.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - shouldShowExternalHistoryForkComposer, + shouldShowExternalHistoryContinuationComposer, shouldShowMainChatComposer, } from "./chatViewComposerVisibility"; @@ -25,18 +25,16 @@ describe("chat view composer visibility", () => { it("hides the continuation composer only while the first download blocks", () => { expect( - shouldShowExternalHistoryForkComposer({ + shouldShowExternalHistoryContinuationComposer({ isImportedHistory: true, readOnly: false, - canResume: true, hasBlockingDownloadSurface: true, }) ).toBe(false); expect( - shouldShowExternalHistoryForkComposer({ + shouldShowExternalHistoryContinuationComposer({ isImportedHistory: true, readOnly: false, - canResume: true, hasBlockingDownloadSurface: false, }) ).toBe(true); diff --git a/src/engines/ChatPanel/chatViewComposerVisibility.ts b/src/engines/ChatPanel/chatViewComposerVisibility.ts index 44095e3e61..d0146f09ce 100644 --- a/src/engines/ChatPanel/chatViewComposerVisibility.ts +++ b/src/engines/ChatPanel/chatViewComposerVisibility.ts @@ -15,18 +15,14 @@ export function shouldShowMainChatComposer({ return showInteractArea && !isReadOnlySurface && !hasBlockingDownloadSurface; } -export function shouldShowExternalHistoryForkComposer({ +export function shouldShowExternalHistoryContinuationComposer({ isImportedHistory, readOnly, - canResume, hasBlockingDownloadSurface, }: { isImportedHistory: boolean; readOnly: boolean; - canResume: boolean; hasBlockingDownloadSurface: boolean; }): boolean { - return ( - !hasBlockingDownloadSurface && isImportedHistory && !readOnly && canResume - ); + return !hasBlockingDownloadSurface && isImportedHistory && !readOnly; } diff --git a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.test.ts b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.test.ts index bc65ee26cf..f4d533de0e 100644 --- a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.test.ts +++ b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.test.ts @@ -634,9 +634,54 @@ function deferred() { } describe("SessionHeaderActionsMenu native app action", () => { + it("opens the newest executed provider episode from a canonical viewer", async () => { + mocks.appOpenPlan.mockResolvedValue(appPlan("Claude")); + await act(async () => + render({ + currentSessionId: "imported-session-canonical", + appOpenSessionId: "cliagent-current-episode", + }) + ); + + expect(mocks.appOpenPlan).toHaveBeenCalledWith("cliagent-current-episode"); + await act(async () => element("session-open-in-app-menu-item").click()); + expect(mocks.openInApp).toHaveBeenCalledWith("cliagent-current-episode"); + }); + + it("does not fall back to an older imported provider when the newest episode has no native binding", async () => { + mocks.appOpenPlan.mockResolvedValue(null); + await act(async () => + render({ + currentSessionId: "claudecodeapp-older-provider", + appOpenSessionId: "agent-current-episode", + }) + ); + + expect(mocks.appOpenPlan).toHaveBeenCalledWith("agent-current-episode"); + expect( + document.querySelector('[data-testid="session-open-in-app-menu-item"]') + ).toBeNull(); + }); + + it("does not fall back to an imported source before a canonical root has executed", async () => { + await act(async () => + render({ + currentSessionId: "claudecodeapp-source", + appOpenSessionId: null, + }) + ); + + expect(mocks.appOpenPlan).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-testid="session-open-in-app-menu-item"]') + ).toBeNull(); + }); + it.each([ ["claudecodeapp-session-a", "Claude", "claude", "claude"], ["codexapp-session-a", "Codex", "codex", "openai"], + ["cliagent-managed-claude", "Claude", "claude", "claude"], + ["cliagent-managed-codex", "Codex", "codex", "openai"], ])( "shows %s as a direct row between separators with its brand and up-right arrow", async (sessionId, app, iconId, brand) => { @@ -700,17 +745,12 @@ describe("SessionHeaderActionsMenu native app action", () => { } ); - it.each([ - null, - "session-a", - "cursoride-a", - "cursorcliapp-a", - "opencodeapp-a", - ])( - "does no native-app work for unsupported session %s", + it.each(["session-a", "cursoride-a", "cursorcliapp-a", "opencodeapp-a"])( + "keeps unsupported session %s hidden when the backend returns no plan", async (sessionId) => { await act(async () => render({ currentSessionId: sessionId })); - expect(mocks.appOpenPlan).not.toHaveBeenCalled(); + expect(mocks.appOpenPlan).toHaveBeenCalledOnce(); + expect(mocks.appOpenPlan).toHaveBeenCalledWith(sessionId); expect( document.querySelector('[data-testid="session-open-in-app-menu-item"]') ).toBeNull(); @@ -718,12 +758,21 @@ describe("SessionHeaderActionsMenu native app action", () => { } ); + it("does no native-app work without a selected session", async () => { + await act(async () => render({ currentSessionId: null })); + expect(mocks.appOpenPlan).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-testid="session-open-in-app-menu-item"]') + ).toBeNull(); + expect(document.querySelectorAll('[role="separator"]')).toHaveLength(0); + }); + it("loads only when the menu opens, and keeps the row absent while the plan is pending", async () => { const pending = deferred(); mocks.appOpenPlan.mockReturnValueOnce(pending.promise); await act(async () => render({ - currentSessionId: "claudecodeapp-a", + currentSessionId: "cliagent-managed-claude", isHeaderActionsOpen: false, }) ); @@ -731,7 +780,7 @@ describe("SessionHeaderActionsMenu native app action", () => { await act(async () => render({ isHeaderActionsOpen: true })); expect(mocks.appOpenPlan).toHaveBeenCalledOnce(); - expect(mocks.appOpenPlan).toHaveBeenCalledWith("claudecodeapp-a"); + expect(mocks.appOpenPlan).toHaveBeenCalledWith("cliagent-managed-claude"); expect( document.querySelector('[data-testid="session-open-in-app-menu-item"]') ).toBeNull(); diff --git a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx index a28b6202a8..0b20d4d2e7 100644 --- a/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx +++ b/src/engines/ChatPanel/components/SessionHeaderActionsMenu.tsx @@ -52,6 +52,8 @@ export interface SessionHeaderActionsMenuProps { activeSessionExists: boolean; copyEventJsonLabel: "idle" | "copied" | "failed"; currentSessionId: string | null; + /** Existing binding owner selected by the canonical conversation resolver. */ + appOpenSessionId?: string | null; displayMode: ChatHistoryDisplayMode; eventsLength: number; handleCompactDisplayModeToggle: (checked: boolean) => void; @@ -91,6 +93,7 @@ export const SessionHeaderActionsMenu: React.FC< activeSessionExists, copyEventJsonLabel, currentSessionId, + appOpenSessionId, displayMode, eventsLength, handleCompactDisplayModeToggle, @@ -488,8 +491,9 @@ export const SessionHeaderActionsMenu: React.FC< {showTranscriptActions && ( diff --git a/src/engines/ChatPanel/components/SessionOpenInAppMenuItem.tsx b/src/engines/ChatPanel/components/SessionOpenInAppMenuItem.tsx index 0fac519d89..dc988a4175 100644 --- a/src/engines/ChatPanel/components/SessionOpenInAppMenuItem.tsx +++ b/src/engines/ChatPanel/components/SessionOpenInAppMenuItem.tsx @@ -9,9 +9,9 @@ import { useTranslation } from "react-i18next"; import { type ExternalHistoryAppOpenPlan, + IMPORTED_HISTORY_SOURCE_DESCRIPTORS, externalHistoryAppOpenPlan, externalHistoryOpenInApp, - getImportedHistoryAppOpen, } from "@src/api/tauri/externalHistory"; import AnyIcon from "@src/components/AnyIcon"; import DropdownItem from "@src/components/Dropdown/DropdownItem"; @@ -29,11 +29,13 @@ const log = createLogger("ChatPanel"); interface SessionOpenInAppMenuItemProps { sessionId: string | null; + /** Existing binding owner selected by the canonical conversation resolver. */ + appOpenSessionId?: string | null; onCloseMenu: () => void; } /** - * "Open in " menu action for imported external sessions. + * "Open in " menu action for imported and managed native sessions. * * Where `SessionContinueCliHeaderExtras` hands the session to its CLI inside * an ORGII terminal, this hands it to the vendor's own app through a @@ -49,21 +51,27 @@ interface SessionOpenInAppMenuItemProps { */ export const SessionOpenInAppMenuItem: React.FC< SessionOpenInAppMenuItemProps -> = ({ sessionId, onCloseMenu }) => { +> = ({ + sessionId, + appOpenSessionId: resolvedAppOpenSessionId, + onCloseMenu, +}) => { const { t } = useTranslation("navigation"); const [plan, setPlan] = useState(null); const opening = useRef(false); - - // Sync capability gate: sources without an app deep link never render the - // row and never pay the backend round-trip. The backend stays - // authoritative for per-session cases (subagents, odd ids). - const descriptorAppOpen = getImportedHistoryAppOpen(sessionId); + // `undefined` is a legacy/direct imported surface. An explicit `null` + // means the canonical binding owner found no executed native episode, so + // do not silently open an older imported source conversation. + const appOpenSessionId = + resolvedAppOpenSessionId === undefined + ? sessionId + : resolvedAppOpenSessionId; useEffect(() => { setPlan(null); - if (!sessionId || !descriptorAppOpen) return undefined; + if (!appOpenSessionId) return undefined; let cancelled = false; - externalHistoryAppOpenPlan(sessionId) + externalHistoryAppOpenPlan(appOpenSessionId) .then((result) => { if (!cancelled) setPlan(result); }) @@ -73,7 +81,17 @@ export const SessionOpenInAppMenuItem: React.FC< return () => { cancelled = true; }; - }, [sessionId, descriptorAppOpen]); + }, [appOpenSessionId]); + + const descriptorAppOpen = useMemo( + () => + plan + ? IMPORTED_HISTORY_SOURCE_DESCRIPTORS.find( + (source) => source.sourceId === plan.source + )?.appOpen + : undefined, + [plan] + ); const appDisplayName = plan?.appDisplayName ?? descriptorAppOpen?.displayName ?? ""; @@ -92,11 +110,11 @@ export const SessionOpenInAppMenuItem: React.FC< }, [appDisplayName, plan, t]); const handleOpen = useCallback(async (): Promise => { - if (!sessionId || !plan?.sourceAvailable || opening.current) return; + if (!appOpenSessionId || !plan?.sourceAvailable || opening.current) return; opening.current = true; onCloseMenu(); try { - await externalHistoryOpenInApp(sessionId); + await externalHistoryOpenInApp(appOpenSessionId); } catch (error) { log.error("failed to open imported session in its app", error); Message.error( @@ -105,7 +123,7 @@ export const SessionOpenInAppMenuItem: React.FC< } finally { opening.current = false; } - }, [appDisplayName, onCloseMenu, plan, sessionId, t]); + }, [appDisplayName, appOpenSessionId, onCloseMenu, plan, t]); if (!descriptorAppOpen || !plan) return null; diff --git a/src/engines/ChatPanel/conversationTargetSelection.test.ts b/src/engines/ChatPanel/conversationTargetSelection.test.ts new file mode 100644 index 0000000000..d21ec8c1e4 --- /dev/null +++ b/src/engines/ChatPanel/conversationTargetSelection.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from "vitest"; + +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; + +import { + resolveConversationRuntimeSelection, + resolveConversationRuntimeTarget, + resolveConversationTargetPillPresentation, + resolveConversationTargetReadiness, + resolveDefaultConversationTarget, + resolvePickedConversationRuntimeTarget, +} from "./conversationTargetSelection"; + +function account( + id: string, + modelType: "claude_code" | "codex", + model: string +): KeyVaultAccount { + return { + id, + hasLocalKey: true, + isListed: false, + modelType, + name: id, + status: "ready", + hasKey: true, + hasApiKey: false, + hasSessionToken: true, + canLaunchCli: true, + enabled: true, + availableModels: [model], + enabledModels: [model], + }; +} + +const registry = { + agents: [ + { + name: "claude_code", + compatibleApiProviders: ["anthropic_api"], + supportsRustAgents: false, + }, + { + name: "codex", + compatibleApiProviders: ["openai_compatible"], + supportsRustAgents: true, + }, + ], + apiProviders: [], +} as unknown as AgentRegistry; + +describe("canonical conversation target selection", () => { + it("uses the selected Rust agent's existing preferred account and model", () => { + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "rust_agent", + targetKind: "agent", + agentDefinitionId: "builtin:sde", + agentName: "SDE Agent", + }, + current: null, + workspaceRepoPath: "/repo", + preferredAccountId: "rust-account", + preferredModel: "gpt-5.6-sol", + accounts: [account("rust-account", "codex", "gpt-5.6-sol")], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + agentDefinitionId: "builtin:sde", + accountId: "rust-account", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }); + }); + + it("keeps an explicit ORG2 runtime above a native CLI source", () => { + const target = { + agentDefinitionId: "builtin:sde", + accountId: "rust-account", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }; + + expect( + resolveConversationRuntimeSelection({ + target, + source: { + root: { + authority: "local-session", + authorityScope: [], + conversationId: "native-source", + }, + cliAgentType: "claude_code", + model: "claude-opus-5", + initialTarget: null, + workspaceRepoPath: "/repo", + }, + definitions: [ + { + id: "builtin:sde", + name: "SDE Agent", + } as never, + ], + }) + ).toMatchObject({ + category: "rust_agent", + agentDefinitionId: "builtin:sde", + agentName: "SDE Agent", + }); + + expect( + resolveConversationTargetPillPresentation({ + target, + accounts: [account("rust-account", "codex", "gpt-5.6-sol")], + }) + ).toMatchObject({ + selection: { + cliAgentType: undefined, + model: "gpt-5.6-sol", + selectedAccountId: "rust-account", + }, + }); + }); + + it("does not infer a Codex account from source model provenance", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: null, + initialTarget: null, + sourceCliAgentType: "codex", + sourceModel: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + accounts: [ + account("codex-local", "codex", "gpt-5.6-sol"), + account("claude-local", "claude_code", "claude-opus-5"), + ], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toBeNull(); + }); + + it("commits a pending Codex pick only after source and model are complete", () => { + const selection = { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "codex", + agentName: "Codex", + } as const; + const input = { + selection, + workspaceRepoPath: "/repo", + accounts: [account("openai-1", "codex", "gpt-5.6-sol")], + registry, + nativeCliTargets: ["claude_code", "codex"] as const, + }; + expect( + resolvePickedConversationRuntimeTarget({ + ...input, + config: { keySource: "own_key", cliAgentType: "codex" }, + }) + ).toBeNull(); + expect( + resolvePickedConversationRuntimeTarget({ + ...input, + config: { + keySource: "own_key", + cliAgentType: "codex", + selectedAccountId: "openai-1", + model: "gpt-5.6-sol", + }, + }) + ).toEqual({ + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }); + }); + + it("uses the signed-in local Claude CLI when no managed account exists", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: null, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: undefined, + workspaceRepoPath: "/repo", + accounts: [], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "claude_code", + model: undefined, + workspaceRepoPath: "/repo", + }); + }); + + it("reuses the newest compatible Claude account and model used by the conversation", () => { + const selection = { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "claude_code", + agentName: "Claude Code", + } as const; + + expect( + resolveConversationRuntimeTarget({ + selection, + current: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + previousTargets: [ + { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + ], + workspaceRepoPath: "/repo", + accounts: [ + account("openai-1", "codex", "gpt-5.6-sol"), + account("anthropic-1", "claude_code", "claude-opus-5"), + ], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }); + }); + + it("reuses the conversation's explicit Codex pair after a Claude turn", () => { + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "codex", + agentName: "Codex", + }, + current: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + previousTargets: [ + { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + ], + workspaceRepoPath: "/repo", + accounts: [ + account("openai-1", "codex", "gpt-5.6-sol"), + account("anthropic-1", "claude_code", "claude-opus-5"), + ], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }); + }); + + it("uses Claude ambient only when the conversation has no valid explicit pair", () => { + const selection = { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "claude_code", + agentName: "Claude Code", + } as const; + const disabledClaude = { + ...account("anthropic-disabled", "claude_code", "claude-opus-5"), + enabled: false, + }; + expect( + resolveConversationRuntimeTarget({ + selection, + current: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + previousTargets: [ + { + cliAgentType: "claude_code", + accountId: "anthropic-disabled", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + ], + workspaceRepoPath: "/repo", + accounts: [account("openai-1", "codex", "gpt-5.6-sol"), disabledClaude], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "claude_code", + workspaceRepoPath: "/repo", + }); + }); + + it("keeps an explicit composer provider switch", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: { + cliAgentType: "codex", + accountId: "codex-local", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: "claude-opus-5", + workspaceRepoPath: "/repo", + accounts: [account("codex-local", "codex", "gpt-5.6-sol")], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toMatchObject({ + cliAgentType: "codex", + model: "gpt-5.6-sol", + }); + }); + + it("retains the verified workspace while cold-start resolution is pending", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: { + cliAgentType: "claude_code", + accountId: "claude-local", + model: "claude-opus-5", + workspaceRepoPath: "/local/checkout", + }, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: "claude-opus-5", + workspaceRepoPath: undefined, + accounts: [account("claude-local", "claude_code", "claude-opus-5")], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "claude_code", + accountId: "claude-local", + model: "claude-opus-5", + workspaceRepoPath: "/local/checkout", + }); + }); + + it("does not present source provenance as a selected execution target", () => { + expect( + resolveConversationTargetPillPresentation({ + target: null, + }) + ).toEqual({ selection: null }); + + expect( + resolveConversationRuntimeSelection({ + target: null, + source: { + root: { + authority: "local-session", + authorityScope: [], + conversationId: "codex-source", + }, + cliAgentType: "codex", + model: "gpt-5.6-sol", + initialTarget: null, + workspaceRepoPath: "/repo", + }, + definitions: [], + }) + ).toBeNull(); + }); + + it("keeps runtime controls neutral until both inventories settle", () => { + expect( + resolveConversationTargetReadiness({ + accountsLoaded: false, + agentDiscoverySettled: true, + hasAvailableRuntime: true, + }) + ).toBe("loading"); + expect( + resolveConversationTargetReadiness({ + accountsLoaded: true, + agentDiscoverySettled: false, + hasAvailableRuntime: true, + }) + ).toBe("loading"); + expect( + resolveConversationTargetReadiness({ + accountsLoaded: true, + agentDiscoverySettled: true, + hasAvailableRuntime: false, + }) + ).toBe("unavailable"); + expect( + resolveConversationTargetReadiness({ + accountsLoaded: true, + agentDiscoverySettled: true, + hasAvailableRuntime: true, + }) + ).toBe("ready"); + }); + + it("shows Claude's native Default model for an ambient runtime switch", () => { + expect( + resolveConversationTargetPillPresentation({ + target: { + cliAgentType: "claude_code", + workspaceRepoPath: "/repo", + }, + }) + ).toEqual({ + selection: { + keySource: "own_key", + model: "default", + selectedAccountId: undefined, + cliAgentType: "claude_code", + selectedSourceLabel: undefined, + selectedSourceModelType: "claude_code", + }, + }); + }); +}); diff --git a/src/engines/ChatPanel/conversationTargetSelection.ts b/src/engines/ChatPanel/conversationTargetSelection.ts new file mode 100644 index 0000000000..ee4590bd16 --- /dev/null +++ b/src/engines/ChatPanel/conversationTargetSelection.ts @@ -0,0 +1,356 @@ +import { + type CliAgentType, + CliAgentTypeSchema, +} from "@src/api/tauri/rpc/schemas/validation"; +import { KEY_SOURCE, isHostedKey } from "@src/api/tauri/session"; +import { formatAgentType } from "@src/assets/providers"; +import type { + ConversationRootLocator, + ConversationSource, + LocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { SessionCommentTarget } from "@src/features/Org2Cloud/sessionCommentTarget"; +import { + type AgentRuntimeSelection, + resolveAgentRuntimeSelection, +} from "@src/features/SessionCreator/agentRuntimeConfig"; +import type { AdvancedConfig } from "@src/features/SessionCreator/types"; +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import type { AgentDefinition } from "@src/modules/MainApp/AgentOrgs/types"; +import type { AgentSelection } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; +import type { LastModelSelection } from "@src/store/session/creatorDefaultModelAtom"; +import { SESSION_TARGET_KIND } from "@src/store/session/creatorStateAtom"; + +export interface ConversationTargetBinding { + root: ConversationRootLocator; + /** Session whose current native binding the Open-in-App action should use. */ + appOpenSessionId: string | null; + cloudTarget: SessionCommentTarget | null; + selection: LastModelSelection | null; + runtimeSelection: AgentSelection | null; + target: LocalConversationTarget | null; + readiness: ConversationTargetReadiness; + nativeCliTargets: readonly CliAgentType[]; + applyRuntimePick: (selection: AgentSelection) => boolean; + applyModelPick: ( + config: AdvancedConfig, + pendingRuntime?: AgentSelection | null + ) => boolean; +} + +type ConversationTargetReadiness = "loading" | "ready" | "unavailable"; + +export function resolveConversationTargetReadiness(params: { + accountsLoaded: boolean; + agentDiscoverySettled: boolean; + hasAvailableRuntime: boolean; +}): ConversationTargetReadiness { + if (!params.accountsLoaded || !params.agentDiscoverySettled) return "loading"; + return params.hasAvailableRuntime ? "ready" : "unavailable"; +} + +interface DefaultConversationTargetInput { + preferredTarget: LocalConversationTarget | null; + initialTarget: LocalConversationTarget | null; + sourceCliAgentType?: string; + sourceModel?: string; + workspaceRepoPath: string | null | undefined; + accounts?: readonly KeyVaultAccount[]; + registry?: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +} + +interface RuntimeConversationTargetInput { + selection: AgentSelection; + current: LocalConversationTarget | null; + /** Newest-first targets already used by this canonical conversation. */ + previousTargets?: readonly LocalConversationTarget[]; + workspaceRepoPath: string | null; + preferredAccountId?: string; + preferredModel?: string; + accounts: readonly KeyVaultAccount[]; + registry?: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +} + +interface PickedConversationRuntimeTargetInput { + selection: AgentSelection; + config: AdvancedConfig; + workspaceRepoPath: string | null; + accounts: readonly KeyVaultAccount[]; + registry?: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +} + +const EMPTY_AGENT_REGISTRY: AgentRegistry = { agents: [], apiProviders: [] }; + +function selectionForTarget( + target: LocalConversationTarget +): AgentRuntimeSelection | null { + if (target.cliAgentType) { + const parsed = CliAgentTypeSchema.safeParse(target.cliAgentType); + return parsed.success + ? { category: "cli_agent", cliAgentType: parsed.data } + : null; + } + return target.agentDefinitionId ? { category: "rust_agent" } : null; +} + +function configForTarget( + target: LocalConversationTarget, + accounts: readonly KeyVaultAccount[] +): AdvancedConfig { + const account = target.accountId + ? accounts.find((candidate) => candidate.id === target.accountId) + : undefined; + return { + keySource: KEY_SOURCE.OWN, + cliAgentType: target.cliAgentType as CliAgentType | undefined, + selectedAccountId: target.accountId, + model: target.model, + agent: account?.modelType, + provider: account?.modelType, + nativeHarnessType: account?.nativeHarnessType, + selectedSourceLabel: account?.name, + selectedSourceModelType: + account?.modelType ?? + (target.cliAgentType === "claude_code" ? "claude_code" : undefined), + }; +} + +function targetForResolvedConfig( + selection: AgentSelection | AgentRuntimeSelection, + config: AdvancedConfig, + workspaceRepoPath: string | null +): LocalConversationTarget | null { + if (selection.category === "cli_agent" && selection.cliAgentType) { + const accountId = config.selectedAccountId?.trim(); + const model = config.model?.trim(); + if (!accountId) { + return selection.cliAgentType === "claude_code" + ? { + cliAgentType: "claude_code", + model: model || undefined, + workspaceRepoPath, + } + : null; + } + if (!model) return null; + return { + cliAgentType: selection.cliAgentType, + accountId, + model, + workspaceRepoPath, + }; + } + if (selection.category !== "rust_agent") return null; + const agentDefinitionId = + "agentDefinitionId" in selection ? selection.agentDefinitionId : undefined; + const accountId = config.selectedAccountId?.trim(); + const model = config.model?.trim(); + return agentDefinitionId && accountId && model + ? { agentDefinitionId, accountId, model, workspaceRepoPath } + : null; +} + +function resolveTargetForSelection(params: { + selection: AgentSelection; + candidates: readonly AdvancedConfig[]; + workspaceRepoPath: string | null; + accounts: readonly KeyVaultAccount[]; + registry: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +}): LocalConversationTarget | null { + const resolution = resolveAgentRuntimeSelection({ + selection: params.selection, + candidates: params.candidates, + accounts: params.accounts, + registry: params.registry, + allowedCliAgentTypes: params.nativeCliTargets, + allowHosted: false, + allowAmbientClaude: true, + }); + return resolution.status === "ready" + ? targetForResolvedConfig( + params.selection, + resolution.config, + params.workspaceRepoPath + ) + : null; +} + +export function resolveDefaultConversationTarget({ + preferredTarget, + initialTarget, + sourceCliAgentType, + sourceModel, + workspaceRepoPath, + accounts = [], + registry = EMPTY_AGENT_REGISTRY, + nativeCliTargets, +}: DefaultConversationTargetInput): LocalConversationTarget | null { + const resolvedWorkspaceRepoPath = + workspaceRepoPath === undefined + ? (preferredTarget?.workspaceRepoPath ?? + initialTarget?.workspaceRepoPath ?? + null) + : workspaceRepoPath; + + for (const candidate of [preferredTarget, initialTarget]) { + if (!candidate) continue; + const runtime = selectionForTarget(candidate); + if (!runtime) continue; + const selection: AgentSelection = candidate.cliAgentType + ? { + category: "cli_agent", + targetKind: SESSION_TARGET_KIND.CLI_AGENT, + cliAgentType: runtime.cliAgentType!, + agentName: formatAgentType(candidate.cliAgentType), + } + : { + category: "rust_agent", + targetKind: SESSION_TARGET_KIND.AGENT, + agentDefinitionId: candidate.agentDefinitionId!, + agentName: candidate.agentDefinitionId!, + }; + const resolved = resolveTargetForSelection({ + selection, + candidates: [configForTarget(candidate, accounts)], + workspaceRepoPath: resolvedWorkspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); + if (resolved) return resolved; + } + + const parsedSource = CliAgentTypeSchema.safeParse(sourceCliAgentType); + if (parsedSource.success) { + return resolveTargetForSelection({ + selection: { + category: "cli_agent", + targetKind: SESSION_TARGET_KIND.CLI_AGENT, + cliAgentType: parsedSource.data, + agentName: formatAgentType(parsedSource.data), + }, + candidates: [ + { + keySource: KEY_SOURCE.OWN, + cliAgentType: parsedSource.data, + model: sourceModel, + }, + ], + workspaceRepoPath: resolvedWorkspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); + } + return null; +} + +export function resolveConversationRuntimeTarget({ + selection, + current, + previousTargets = [], + workspaceRepoPath, + preferredAccountId, + preferredModel, + accounts, + registry = EMPTY_AGENT_REGISTRY, + nativeCliTargets, +}: RuntimeConversationTargetInput): LocalConversationTarget | null { + const candidates: AdvancedConfig[] = []; + if (current) candidates.push(configForTarget(current, accounts)); + candidates.push( + ...previousTargets.map((target) => configForTarget(target, accounts)) + ); + if (preferredAccountId && preferredModel) { + candidates.push({ + keySource: KEY_SOURCE.OWN, + selectedAccountId: preferredAccountId, + model: preferredModel, + }); + } + return resolveTargetForSelection({ + selection, + candidates, + workspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); +} + +export function resolvePickedConversationRuntimeTarget({ + selection, + config, + workspaceRepoPath, + accounts, + registry = EMPTY_AGENT_REGISTRY, + nativeCliTargets, +}: PickedConversationRuntimeTargetInput): LocalConversationTarget | null { + if (isHostedKey(config.keySource)) return null; + return resolveTargetForSelection({ + selection, + candidates: [config], + workspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); +} + +export function resolveConversationTargetPillPresentation(params: { + target: LocalConversationTarget | null; + accounts?: readonly KeyVaultAccount[]; +}): Pick { + if (!params.target) return { selection: null }; + const config = configForTarget(params.target, params.accounts ?? []); + return { + selection: { + keySource: KEY_SOURCE.OWN, + model: + config.model ?? + (params.target.cliAgentType === "claude_code" && + !params.target.accountId + ? "default" + : undefined), + selectedAccountId: config.selectedAccountId, + cliAgentType: config.cliAgentType, + selectedSourceLabel: config.selectedSourceLabel, + selectedSourceModelType: config.selectedSourceModelType, + }, + }; +} + +export function resolveConversationRuntimeSelection(params: { + target: LocalConversationTarget | null; + source: ConversationSource; + definitions: readonly AgentDefinition[]; +}): AgentSelection | null { + if (!params.target) return null; + const parsed = CliAgentTypeSchema.safeParse(params.target.cliAgentType); + if (parsed.success) { + return { + category: "cli_agent", + targetKind: SESSION_TARGET_KIND.CLI_AGENT, + cliAgentType: parsed.data, + agentName: formatAgentType(parsed.data), + }; + } + const agentDefinitionId = params.target.agentDefinitionId; + if (!agentDefinitionId) return null; + const definition = params.definitions.find( + (candidate) => candidate.id === agentDefinitionId + ); + return { + category: "rust_agent", + targetKind: SESSION_TARGET_KIND.AGENT, + agentDefinitionId, + agentName: + definition?.name ?? params.source.agentDisplayName ?? agentDefinitionId, + agentIconId: definition?.iconId, + }; +} diff --git a/src/engines/ChatPanel/externalHistoryFork.test.ts b/src/engines/ChatPanel/externalHistoryFork.test.ts deleted file mode 100644 index 1e3f8be6c3..0000000000 --- a/src/engines/ChatPanel/externalHistoryFork.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { - type ImportedHistorySource, - getImportedHistorySourceBySessionId, -} from "@src/api/tauri/externalHistory"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; -import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; -import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; -import type { ActivityChunk } from "@src/types/session/session"; - -import { - buildExternalHistoryHandoffPrompt, - forkExternalHistoryIntoOrgiiSession, -} from "./externalHistoryFork"; - -vi.mock("@src/api/tauri/externalHistory", () => ({ - getImportedHistorySourceBySessionId: vi.fn(), -})); -vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ - SessionService: { create: vi.fn() }, -})); -vi.mock("@src/features/TeamCollaboration/forkSession", () => ({ - requestForkSessionSetup: vi.fn(), -})); -vi.mock("@src/features/TeamCollaboration/repoScopeResolver", () => ({ - resolveShareableScopeKeys: vi.fn(), -})); - -function chunk( - id: string, - actionType: string, - functionName: string, - result: Record -): ActivityChunk { - return { - chunk_id: id, - action_type: actionType, - function: functionName, - args: {}, - result, - created_at: "2026-07-13T00:00:00.000Z", - }; -} - -describe("buildExternalHistoryHandoffPrompt", () => { - it("works for every registered source label and excludes private reasoning", () => { - const prompt = buildExternalHistoryHandoffPrompt( - [ - chunk("u1", "raw", "user_message", { message: "fix the sync" }), - chunk("r1", "reasoning", "thinking", { - content: "private chain of thought", - }), - { - ...chunk("t1", "tool_call", "read_file", { output: "old file" }), - args: { path: "src/sync.ts" }, - }, - chunk("a1", "assistant_message", "assistant_message", { - content: "I found the issue", - }), - ], - "continue and verify it", - "Claude App" - ); - - expect(prompt).toContain("imported Claude App history"); - expect(prompt).toContain("User: fix the sync"); - expect(prompt).toContain("[Imported Claude App action]"); - expect(prompt).toContain("Tool: read_file"); - expect(prompt).toContain("Assistant: I found the issue"); - expect(prompt).toContain("continue and verify it"); - expect(prompt).not.toContain("private chain of thought"); - }); -}); - -describe("forkExternalHistoryIntoOrgiiSession", () => { - const loadFullTranscriptChunks = vi.fn(); - const source: ImportedHistorySource = { - sourceId: "codex_app", - listCategory: "external_history:codex_app", - prefix: "codexapp-", - iconId: "codex", - displayName: "Codex App", - groupLabel: "Codex App", - listable: true, - replayable: true, - supportsWindowedReplay: false, - dispatchCategory: "external_history", - loadPreviewChunks: vi.fn(), - loadFullTranscriptChunks, - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getImportedHistorySourceBySessionId).mockReturnValue(source); - vi.mocked(resolveShareableScopeKeys).mockResolvedValue([ - "github.com/org/repo", - ]); - vi.mocked(requestForkSessionSetup).mockResolvedValue({ - workspaceRepoPath: "/local/repo", - execution: { - agentDefinitionId: "custom:security-auditor", - accountId: "openai", - model: "gpt-test", - }, - }); - loadFullTranscriptChunks.mockResolvedValue([ - chunk("u1", "user_message", "user_message", { message: "old ask" }), - ]); - vi.mocked(SessionService.create).mockResolvedValue({ - sessionId: "agentsession-forked", - }); - }); - - it("uses the shared setup before loading history, then creates one writable ORGII continuation", async () => { - const callOrder: string[] = []; - vi.mocked(requestForkSessionSetup).mockImplementation(async () => { - callOrder.push("setup"); - return { - workspaceRepoPath: "/local/repo", - execution: { - agentDefinitionId: "custom:security-auditor", - accountId: "openai", - model: "gpt-test", - }, - }; - }); - loadFullTranscriptChunks.mockImplementation(async () => { - callOrder.push("transcript"); - return [ - chunk("u1", "user_message", "user_message", { - message: "old ask", - }), - ]; - }); - - const sessionId = await forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: "codexapp-source-1", - sourceSession: { - session_id: "codexapp-source-1", - status: "completed", - created_at: "2026-07-13T00:00:00Z", - updated_at: "2026-07-13T00:00:00Z", - name: "Imported review", - repoPath: "/source/repo", - model: "gpt-source", - }, - userMessage: "continue and run tests", - imageDataUrls: ["data:image/png;base64,abc"], - }); - - expect(sessionId).toBe("agentsession-forked"); - expect(callOrder).toEqual(["setup", "transcript"]); - expect(resolveShareableScopeKeys).toHaveBeenCalledWith("/source/repo"); - expect(requestForkSessionSetup).toHaveBeenCalledWith({ - sourceTitle: "Imported review", - sourceScopeKey: "github.com/org/repo", - sourceModel: "gpt-source", - }); - expect(SessionService.create).toHaveBeenCalledTimes(1); - expect(SessionService.create).toHaveBeenCalledWith( - expect.objectContaining({ - imageDataUrls: ["data:image/png;base64,abc"], - name: "Continue Imported review", - repoPath: "/local/repo", - model: "gpt-test", - accountId: "openai", - keySource: "own_key", - agentDefinitionId: "custom:security-auditor", - mode: "build", - task: expect.stringContaining("continue and run tests"), - }) - ); - expect( - vi.mocked(SessionService.create).mock.calls[0]?.[0] - ).not.toHaveProperty("parentSessionId"); - }); - - it("dispatches the agent projection while userMessage stays the display copy", async () => { - const contract = - "[Canvas Creation Request]\nCreate a new interactive inline Canvas for the user request below. Call render_inline_canvas exactly once for the finished Canvas.\n\n[User Request]\nbuild a coffee order UI"; - - await forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: "codexapp-source-1", - userMessage: "canvas [skill:/canvas] build a coffee order UI", - agentMessage: contract, - }); - - const task = vi.mocked(SessionService.create).mock.calls[0]?.[0]?.task; - // The handoff prompt embeds the AGENT copy as the continuation request — - // never the raw pill serialization the display copy carries. - expect(task).toContain("render_inline_canvas exactly once"); - expect(task).not.toContain("[skill:/canvas]"); - }); - - it("falls back to the display copy when no agent projection exists", async () => { - await forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: "codexapp-source-1", - userMessage: "continue and run tests", - }); - - expect(SessionService.create).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.stringContaining("continue and run tests"), - }) - ); - }); - - it("does not load or create anything when the shared setup is cancelled", async () => { - vi.mocked(requestForkSessionSetup).mockRejectedValueOnce( - new Error("cancelled") - ); - - await expect( - forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: "codexapp-source-1", - userMessage: "continue", - }) - ).rejects.toThrow("cancelled"); - expect(loadFullTranscriptChunks).not.toHaveBeenCalled(); - expect(SessionService.create).not.toHaveBeenCalled(); - }); -}); diff --git a/src/engines/ChatPanel/externalHistoryFork.ts b/src/engines/ChatPanel/externalHistoryFork.ts deleted file mode 100644 index 202c3fc333..0000000000 --- a/src/engines/ChatPanel/externalHistoryFork.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; -import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; -import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; -import type { Session } from "@src/store/session"; -import type { ActivityChunk } from "@src/types/session/session"; - -const MAX_HISTORY_ITEMS = 80; -const MAX_TEXT_LENGTH = 1200; - -function textValue(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (Array.isArray(value)) { - const parts = value.map(textValue).filter(Boolean); - return parts.length > 0 ? parts.join("\n") : undefined; - } - if (value && typeof value === "object") { - const object = value as Record; - return ( - textValue(object.text) ?? - textValue(object.content) ?? - textValue(object.message) ?? - textValue(object.output) ?? - textValue(object.summary) - ); - } - return undefined; -} - -function truncateText(text: string): string { - return text.length > MAX_TEXT_LENGTH - ? `${text.slice(0, MAX_TEXT_LENGTH)}…` - : text; -} - -function summarizeToolChunk( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const functionName = chunk.function || "unknown_tool"; - const argsText = textValue(chunk.args); - const resultText = textValue(chunk.result); - const lines = [`[Imported ${sourceName} action]`, `Tool: ${functionName}`]; - if (argsText) lines.push(`Input: ${truncateText(argsText)}`); - if (resultText) - lines.push(`Result at that time: ${truncateText(resultText)}`); - return lines.join("\n"); -} - -function chunkToHandoffItem( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const actionType = chunk.action_type; - if (actionType.includes("thinking") || actionType.includes("reasoning")) { - return undefined; - } - - const resultText = textValue(chunk.result); - const argsText = textValue(chunk.args); - const content = resultText ?? argsText; - - if (actionType === "user_message" || chunk.function === "user_message") { - return content ? `User: ${truncateText(content)}` : undefined; - } - if ( - actionType === "assistant_message" || - actionType === "llm_response" || - chunk.function === "assistant_message" - ) { - return content ? `Assistant: ${truncateText(content)}` : undefined; - } - if (actionType === "tool_call" || actionType.includes("tool")) { - return summarizeToolChunk(chunk, sourceName); - } - - return content ? `Assistant context: ${truncateText(content)}` : undefined; -} - -export function buildExternalHistoryHandoffPrompt( - chunks: ActivityChunk[], - userMessage: string, - sourceName: string -): string { - const items = chunks - .map((chunk) => chunkToHandoffItem(chunk, sourceName)) - .filter((item): item is string => Boolean(item)) - .slice(-MAX_HISTORY_ITEMS); - - return [ - `You are continuing work from an imported ${sourceName} history inside a new ORGII-owned session.`, - `The imported ${sourceName} history is read-only historical context. Do not treat its tool calls as ORGII-executed tools or current workspace state.`, - "Imported tool results may be stale; verify files, commands, and failures against the selected workspace before relying on them.", - "Reasoning/thinking chunks were intentionally skipped.", - "", - `## Imported ${sourceName} handoff context`, - items.length > 0 - ? items.join("\n\n") - : "No usable transcript items were found.", - "", - "## User request to continue in ORGII", - userMessage, - ].join("\n"); -} - -export async function forkExternalHistoryIntoOrgiiSession(params: { - sourceSessionId: string; - sourceSession?: Session; - /** The user's visible words (display projection of the composer text). */ - userMessage: string; - /** - * Agent-facing projection of `userMessage` (skill pills expanded, canvas - * contract, base64-free). When present it is what the model must receive - * as the continuation request; `userMessage` remains the display copy. - * `session_launch` only carries a single content field, so the handoff - * prompt embeds the agent projection — a fully split visible message would - * need backend support. - */ - agentMessage?: string; - imageDataUrls?: string[]; -}): Promise { - const source = getImportedHistorySourceBySessionId(params.sourceSessionId); - if (!source) { - throw new Error( - `No imported-history source is registered for ${params.sourceSessionId}` - ); - } - const sourceRepoPath = - params.sourceSession?.repoPath || params.sourceSession?.worktreePath; - const sourceScopeKeys = sourceRepoPath - ? await resolveShareableScopeKeys(sourceRepoPath) - : null; - // Prompt before loading the potentially large source transcript. The user - // chooses this machine's real checkout and credentials; an imported model - // label is only a preference hint, never an execution fallback. - const setup = await requestForkSessionSetup({ - sourceTitle: params.sourceSession?.name || `${source.displayName} history`, - sourceScopeKey: sourceScopeKeys?.[0], - sourceModel: params.sourceSession?.model, - }); - const chunks = await source.loadFullTranscriptChunks(params.sourceSessionId); - const content = buildExternalHistoryHandoffPrompt( - chunks, - params.agentMessage ?? params.userMessage, - source.displayName - ); - // This continuation is a normal top-level ORGII session. `parentSessionId` - // is reserved for real subagents and would hide the continuation from the - // primary session list after a reload. The handoff prompt carries the - // external source context without changing the new session's hierarchy. - const result = await SessionService.create({ - task: content, - imageDataUrls: params.imageDataUrls, - name: `Continue ${params.sourceSession?.name || `${source.displayName} history`}`, - repoPath: setup.workspaceRepoPath ?? undefined, - model: setup.execution.model, - accountId: setup.execution.accountId, - keySource: "own_key", - agentDefinitionId: setup.execution.agentDefinitionId, - mode: "build", - }); - return result.sessionId; -} diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts new file mode 100644 index 0000000000..c55aa732ed --- /dev/null +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; + +import { SubmitValidationError } from "../useInputArea/types"; +import { + buildCanonicalConversationDispatch, + canonicalConversationTargetOrThrow, +} from "./useConversationSubmitRouter"; + +const root: ConversationRootLocator = { + authority: "imported-history", + authorityScope: ["codex_app"], + conversationId: "codexapp-session-1", +}; + +describe("canonicalConversationTargetOrThrow", () => { + it("allows an ordinary session to use its existing direct dispatcher", () => { + expect(canonicalConversationTargetOrThrow(null, null)).toBeNull(); + }); + + it("never routes a canonical source through the legacy direct dispatcher", () => { + expect(() => canonicalConversationTargetOrThrow(root, null)).toThrow( + SubmitValidationError + ); + }); + + it("returns the selected canonical runtime", () => { + const target = { + cliAgentType: "codex", + accountId: "openai", + model: "gpt-test", + workspaceRepoPath: "/repo", + } as const; + expect(canonicalConversationTargetOrThrow(root, target)).toBe(target); + }); +}); + +describe("buildCanonicalConversationDispatch", () => { + const target = { + cliAgentType: "codex" as const, + accountId: "openai-1", + model: "gpt-5.6-sol", + }; + + it("carries the current root and runtime for a local canonical retry", () => { + expect( + buildCanonicalConversationDispatch({ + root: { + authority: "local-session", + authorityScope: [], + conversationId: "s-1", + }, + selectedTarget: target, + auth: null, + }) + ).toEqual({ + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "s-1", + }, + target, + }); + }); + + it("binds a Cloud retry to the signed-in identity and returns null without it", () => { + const cloudRoot: ConversationRootLocator = { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "s-1", + }; + expect( + buildCanonicalConversationDispatch({ + root: cloudRoot, + selectedTarget: target, + auth: null, + }) + ).toBeNull(); + expect( + buildCanonicalConversationDispatch({ + root: cloudRoot, + selectedTarget: target, + auth: { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "token", + refreshToken: "refresh", + expiresAt: 0, + } as never, + })?.dispatchIdentityKey + ).toBe("https://cloud.example|user-1"); + }); + + it("returns null while no canonical runtime is selected", () => { + expect( + buildCanonicalConversationDispatch({ + root: { + authority: "local-session", + authorityScope: [], + conversationId: "s-1", + }, + selectedTarget: null, + auth: null, + }) + ).toBeNull(); + expect( + buildCanonicalConversationDispatch({ + root: null, + selectedTarget: target, + auth: null, + }) + ).toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts new file mode 100644 index 0000000000..8ed037ab6f --- /dev/null +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts @@ -0,0 +1,201 @@ +import { useStore } from "jotai"; +import { useCallback } from "react"; + +import { useUserIntentSubmit } from "@src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit"; +import type { + ConversationRootLocator, + LocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { + type Org2CloudAuthState, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { useCloudSessionDownloadProgressEntry } from "@src/features/Org2Cloud/useCloudSessionDownloadSurface"; +import type { Session } from "@src/store/session"; + +import { isImportedSessionSubmitBlocked } from "../importedSessionSubmitReadiness"; +import { + type SubmitOverrideInput, + SubmitValidationError, +} from "../useInputArea/types"; + +interface UseConversationSubmitRouterOptions { + sessionId: string; + currentSession: Session | undefined; + root: ConversationRootLocator | null; + selectedTarget: LocalConversationTarget | null; + /** Existing human/team-chat routing always gets first refusal. */ + onSurfaceSubmit: (input: SubmitOverrideInput) => Promise; +} + +interface CanonicalConversationRetryInput extends SubmitOverrideInput { + turnIntentId?: string; +} + +interface ConversationSubmitRouter { + submit: (input: SubmitOverrideInput) => Promise; + /** Retry a failed Agent turn without routing it through Team Chat. */ + retry: (input: CanonicalConversationRetryInput) => Promise; + /** + * The canonical dispatch a retry of a held row should carry now: the + * current root and the runtime the picker shows, not the pair the row was + * admitted with. Null while no canonical runtime is selected. + */ + resolveDispatch: () => QueuedConversationDispatch | null; +} + +export function buildCanonicalConversationDispatch(params: { + root: ConversationRootLocator | null; + selectedTarget: Parameters[1]; + auth: Org2CloudAuthState | null; +}): QueuedConversationDispatch | null { + const { root, selectedTarget, auth } = params; + if (!root) return null; + let target: ReturnType; + try { + target = canonicalConversationTargetOrThrow(root, selectedTarget); + } catch { + return null; + } + if (!target) return null; + if (root.authority === "org2-cloud") { + if (!auth) return null; + return { + kind: "canonical_conversation", + root, + target, + dispatchIdentityKey: org2CloudAuthIdentityKey(auth), + }; + } + return { kind: "canonical_conversation", root, target }; +} + +/** + * Distinguish an ordinary Session (no canonical root) from a canonical + * conversation whose runtime inventory is still loading or unavailable. + * Only the former may fall through to the legacy direct-session dispatcher. + */ +export function canonicalConversationTargetOrThrow( + root: ConversationRootLocator | null, + target: LocalConversationTarget | null +): LocalConversationTarget | null { + if (!root) return null; + if (!target) { + throw new SubmitValidationError( + "Select an available runtime before continuing this conversation" + ); + } + if ( + target.cliAgentType && + (target.cliAgentType !== "claude_code" || target.accountId) && + (!target.accountId || !target.model) + ) { + throw new SubmitValidationError( + "Select a model and source before continuing this conversation" + ); + } + return target; +} + +/** + * Thin admission edge for canonical conversations. + * + * It does not execute providers, fork sessions, restore drafts, or maintain a + * second queue. Human/team-chat routing remains the existing surface concern; + * every Agent continuation is admitted into SessionCore's durable queue. + */ +export function useConversationSubmitRouter({ + sessionId, + currentSession, + root, + selectedTarget, + onSurfaceSubmit, +}: UseConversationSubmitRouterOptions): ConversationSubmitRouter { + const store = useStore(); + const downloadProgress = useCloudSessionDownloadProgressEntry(sessionId); + const submitUserIntent = useUserIntentSubmit({ + getSessionId: () => sessionId, + }); + + const enqueueCanonical = useCallback( + async (input: CanonicalConversationRetryInput) => { + if ( + isImportedSessionSubmitBlocked({ + sessionId, + session: currentSession, + progress: downloadProgress, + }) + ) { + throw new SubmitValidationError( + "Wait for the shared session to finish loading before continuing" + ); + } + + const target = canonicalConversationTargetOrThrow(root, selectedTarget); + if (!root || !target) return false; + + let dispatchIdentityKey: string | undefined; + if (root.authority === "org2-cloud") { + const auth = store.get(org2CloudAuthAtom); + if (!auth) { + throw new SubmitValidationError( + "Cloud sign-in is required before queuing this turn" + ); + } + dispatchIdentityKey = org2CloudAuthIdentityKey(auth); + } + const conversationDispatch: QueuedConversationDispatch = { + kind: "canonical_conversation", + root, + target, + ...(dispatchIdentityKey ? { dispatchIdentityKey } : {}), + }; + try { + await submitUserIntent({ + sessionId, + displayContent: input.displayText, + agentContent: input.agentContent, + imageDataUrls: input.imageDataUrls, + source: "dispatch", + turnIntentId: input.turnIntentId, + conversationDispatch, + }); + return true; + } catch (error) { + throw new SubmitValidationError( + error instanceof Error ? error.message : String(error) + ); + } + }, + [ + currentSession, + downloadProgress, + root, + selectedTarget, + sessionId, + store, + submitUserIntent, + ] + ); + + const submit = useCallback( + async (input: SubmitOverrideInput) => { + if (await onSurfaceSubmit(input)) return true; + return enqueueCanonical(input); + }, + [enqueueCanonical, onSurfaceSubmit] + ); + const resolveDispatch = useCallback( + () => + buildCanonicalConversationDispatch({ + root, + selectedTarget, + auth: store.get(org2CloudAuthAtom), + }), + [root, selectedTarget, store] + ); + + return { submit, retry: enqueueCanonical, resolveDispatch }; +} diff --git a/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.test.ts b/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.test.ts new file mode 100644 index 0000000000..e574252f6d --- /dev/null +++ b/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import type { CloudSessionDownloadProgress } from "@src/features/Org2Cloud/cloudSessionDownloadProgressAtom"; +import type { Session } from "@src/store/session"; + +import { isImportedSessionSubmitBlocked } from "./importedSessionSubmitReadiness"; + +const session = { + session_id: "imported-session-abc", + importedFrom: { + orgId: "org-1", + sourceSessionId: "source-1", + sourceEndpointUrl: "https://cloud.example.test", + }, +} as Session; + +function progress( + loadedEvents: number, + phase: CloudSessionDownloadProgress["phase"] = "downloading" +): CloudSessionDownloadProgress { + return { + authIdentityKey: "https://cloud.example.test|user-1", + rowId: "org-1:owner:source-1", + orgId: "org-1", + loadedEvents, + totalEvents: 100, + startedAtMs: 0, + updatedAtMs: 1, + phase, + }; +} + +describe("isImportedSessionSubmitBlocked", () => { + it.each([0, 29, 67, 99])( + "blocks imported replay submit at %i%%", + (loadedEvents) => { + expect( + isImportedSessionSubmitBlocked({ + sessionId: session.session_id, + session, + progress: progress(loadedEvents), + }) + ).toBe(true); + } + ); + + it("blocks finalizing, paused, and not-yet-hydrated imported sessions", () => { + for (const phase of ["finalizing", "paused"] as const) { + expect( + isImportedSessionSubmitBlocked({ + sessionId: session.session_id, + session, + progress: progress(99, phase), + }) + ).toBe(true); + } + expect( + isImportedSessionSubmitBlocked({ + sessionId: session.session_id, + session: undefined, + progress: progress(100, "completed"), + }) + ).toBe(true); + }); + + it("unblocks only a completed, provenance-hydrated replay", () => { + expect( + isImportedSessionSubmitBlocked({ + sessionId: session.session_id, + session, + progress: progress(100, "completed"), + }) + ).toBe(false); + expect( + isImportedSessionSubmitBlocked({ + sessionId: session.session_id, + session, + progress: undefined, + }) + ).toBe(false); + }); + + it("does not gate ordinary native sessions", () => { + expect( + isImportedSessionSubmitBlocked({ + sessionId: "agentsession-native", + session: undefined, + progress: progress(67), + }) + ).toBe(false); + }); +}); diff --git a/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.ts b/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.ts new file mode 100644 index 0000000000..ec79b0a11b --- /dev/null +++ b/src/engines/ChatPanel/hooks/importedSessionSubmitReadiness.ts @@ -0,0 +1,18 @@ +import type { CloudSessionDownloadProgress } from "@src/features/Org2Cloud/cloudSessionDownloadProgressAtom"; +import { isImportedSessionId } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import type { Session } from "@src/store/session"; + +/** + * An imported replay is writable only after both transfer and Session + * provenance hydration finish. This one predicate gates the rendered button + * and the submit override so mouse, keyboard, and stale-render races agree. + */ +export function isImportedSessionSubmitBlocked(params: { + sessionId: string; + session: Session | undefined; + progress: CloudSessionDownloadProgress | undefined; +}): boolean { + if (!isImportedSessionId(params.sessionId)) return false; + if (params.progress && params.progress.phase !== "completed") return true; + return !params.session?.importedFrom; +} diff --git a/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx b/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx index 55692519e1..dcff39bfdb 100644 --- a/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx +++ b/src/engines/ChatPanel/hooks/useAgentOrgGroupChatLiveSessions.tsx @@ -1,10 +1,7 @@ -import { memo, useEffect, useMemo } from "react"; +import { memo, useMemo } from "react"; import type { AgentOrgRunMemberView } from "@src/api/tauri/agent"; -import { parseRawSessionEvent } from "@src/engines/SessionCore/core/schemas"; -import "@src/engines/SessionCore/sync/adapters"; -import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; -import { useSessionChannel } from "@src/engines/SessionCore/sync/useSessionChannel"; +import { useSessionEventIngestion } from "@src/engines/SessionCore/sync/useSessionEventIngestion"; import { isActiveStatus } from "@src/types/session/session"; const PENDING_MEMBER_SESSION_PREFIX = "agent-org-member-pending:"; @@ -20,21 +17,7 @@ interface LiveSessionTapProps { } function LiveSessionTap({ sessionId }: LiveSessionTapProps) { - const handler = useMemo(() => { - const adapter = getAdapterForSession(sessionId); - if (!adapter) return null; - return adapter.createEventHandler(sessionId, {}); - }, [sessionId]); - - useEffect(() => { - return () => handler?.dispose(); - }, [handler]); - - useSessionChannel(handler ? sessionId : null, (raw) => { - if (!handler) return; - handler.handleEvent(parseRawSessionEvent(raw)); - }); - + useSessionEventIngestion(sessionId); return null; } diff --git a/src/engines/ChatPanel/hooks/useChatViewAgentOrgSurface.tsx b/src/engines/ChatPanel/hooks/useChatViewAgentOrgSurface.tsx index 26de90654f..b2055ffa60 100644 --- a/src/engines/ChatPanel/hooks/useChatViewAgentOrgSurface.tsx +++ b/src/engines/ChatPanel/hooks/useChatViewAgentOrgSurface.tsx @@ -14,28 +14,24 @@ import { useAtomValue } from "jotai"; import { useMemo } from "react"; import { GroupChatPausedBanner } from "@src/engines/ChatPanel/components/ChatStatusBanners"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; import { activeSessionIdAtom } from "@src/store/session"; -import type { Session } from "@src/store/session"; import { ChatViewGroupChatHistoryAction } from "../ChatViewGroupChatHistoryAction"; -import type { ChatViewProps } from "../ChatViewTypes"; import { useAgentOrgIntervention } from "../InputArea/components/useAgentOrgIntervention"; import { useAgentOrgMemberSessionJump } from "../InputArea/components/useAgentOrgMemberSessionJump"; import { useAgentOrgRunView } from "../InputArea/components/useAgentOrgRunView"; import { useAgentOrgGroupChatController } from "./useAgentOrgGroupChatController"; import { useChatViewMessageQueue } from "./useChatViewMessageQueue"; -import { useImportedSessionSubmitOverride } from "./useImportedSessionSubmitOverride"; export function useChatViewAgentOrgSurface({ sessionId, - currentSession, - onSessionContinuation, showCurrentPlanSurface, + conversationRoot, }: { sessionId: string; - currentSession: Session | undefined; - onSessionContinuation: ChatViewProps["onSessionContinuation"]; showCurrentPlanSurface: boolean; + conversationRoot: ConversationRootLocator | null; }) { const { view: agentOrgRunView, @@ -100,16 +96,9 @@ export function useChatViewAgentOrgSurface({ const handleAgentOrgMemberSessionJump = useAgentOrgMemberSessionJump(sessionId); - const handleMainComposerSubmitOverride = useImportedSessionSubmitOverride({ - sessionId, - currentSession, - onFallbackSubmit: handleGroupChatSubmitOverride, - onSessionContinuation, - }); - const { cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, @@ -118,6 +107,7 @@ export function useChatViewAgentOrgSurface({ } = useChatViewMessageQueue({ pipelineSessionId, queueSessionId, + conversationRoot, }); const groupChatPausedBottomContent = groupChatRunPaused ? ( @@ -182,10 +172,10 @@ export function useChatViewAgentOrgSurface({ groupChatPendingMessage, handleGroupChatViewToggle, handleAgentOrgMemberSessionJump, - handleMainComposerSubmitOverride, + handleMainComposerSubmitOverride: handleGroupChatSubmitOverride, retryFailedGroupChatMessage, cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, diff --git a/src/engines/ChatPanel/hooks/useChatViewMessageQueue.test.ts b/src/engines/ChatPanel/hooks/useChatViewMessageQueue.test.ts new file mode 100644 index 0000000000..e4e93a470c --- /dev/null +++ b/src/engines/ChatPanel/hooks/useChatViewMessageQueue.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { QueuedMessage } from "@src/store/ui/messageQueueAtom"; + +import { queuedMessageBelongsToConversationView } from "./useChatViewMessageQueue"; + +const root: ConversationRootLocator = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "root-1", +}; + +function message(overrides: Partial = {}): QueuedMessage { + return { + id: "message-1", + turnIntentId: "turn-1", + sessionId: "source-session", + content: "hello", + displayContent: "hello", + priority: "next", + status: "queued", + createdAt: "2026-09-01T00:00:00.000Z", + ...overrides, + }; +} + +describe("queuedMessageBelongsToConversationView", () => { + it("keeps a canonical queued row visible after the view retargets to a native episode", () => { + expect( + queuedMessageBelongsToConversationView( + message({ + conversationDispatch: { + kind: "canonical_conversation", + root, + target: { + cliAgentType: "codex", + accountId: "openai-1", + workspaceRepoPath: "/repo", + }, + }, + }), + { + pipelineSessionId: "codex-native-episode", + queueSessionId: "codex-native-episode", + conversationRoot: root, + } + ) + ).toBe(true); + }); + + it("does not leak another conversation's canonical queue rows", () => { + expect( + queuedMessageBelongsToConversationView( + message({ + conversationDispatch: { + kind: "canonical_conversation", + root: { ...root, conversationId: "root-2" }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + workspaceRepoPath: "/repo", + }, + }, + }), + { + pipelineSessionId: "codex-native-episode", + queueSessionId: "codex-native-episode", + conversationRoot: root, + } + ) + ).toBe(false); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts b/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts index 2c27a8596e..ca2c7d7ef4 100644 --- a/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts +++ b/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts @@ -1,53 +1,100 @@ -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom, useStore } from "jotai"; import { useCallback, useMemo } from "react"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { cancelQueuedMessageDeliveries } from "@src/engines/SessionCore/hooks/session/messageQueuePersistence"; +import { createLogger } from "@src/hooks/logger"; import { - clearQueuedMessagesAtom, - dequeueMessageAtom, + type QueuedMessage, editMessageAtom, - enqueueCountAtom, forceSendMessageAtom, messageQueueAtom, - queueFlushRequestAtom, reorderQueueAtom, } from "@src/store/ui/messageQueueAtom"; import { useQueueEditMode } from "../InputArea/hooks/useQueueEditMode"; +const log = createLogger("ChatViewMessageQueue"); + /** Keeps queue filtering and global-index reordering consistent for ChatView. */ +export function queuedMessageBelongsToConversationView( + message: QueuedMessage, + params: { + pipelineSessionId: string | null; + queueSessionId: string | null; + conversationRoot: ConversationRootLocator | null; + } +): boolean { + if ( + message.sessionId === params.queueSessionId || + message.sessionId === params.pipelineSessionId + ) { + return true; + } + return Boolean( + params.conversationRoot && + message.conversationDispatch && + conversationRootKey(message.conversationDispatch.root) === + conversationRootKey(params.conversationRoot) + ); +} + export function useChatViewMessageQueue({ pipelineSessionId, queueSessionId, + conversationRoot, }: { pipelineSessionId: string | null; queueSessionId: string | null; + conversationRoot: ConversationRootLocator | null; }) { + const store = useStore(); const messageQueue = useAtomValue(messageQueueAtom); const sessionMessageQueue = useMemo( () => messageQueue.filter( (message) => - message.sessionId === queueSessionId || - message.sessionId === pipelineSessionId + // preparing/accepted are crash-recovery records, not composer queue + // cards. Their user row and ordinary planning/working footer already + // render in the transcript once dispatch begins. + message.status === "queued" && + // A pre-acceptance failure whose EventStore commit could not finish + // remains in the durable registry as the retry owner. It is rendered + // as a failed transcript bubble, not as a second queued footer card. + !message.deliveryError && + queuedMessageBelongsToConversationView(message, { + pipelineSessionId, + queueSessionId, + conversationRoot, + }) ), - [messageQueue, pipelineSessionId, queueSessionId] + [conversationRoot, messageQueue, pipelineSessionId, queueSessionId] ); - const enqueueCount = useAtomValue(enqueueCountAtom); - const cancelQueuedMessage = useSetAtom(dequeueMessageAtom); - const clearQueuedMessages = useSetAtom(clearQueuedMessagesAtom); const editQueuedMessage = useSetAtom(editMessageAtom); const reorderQueue = useSetAtom(reorderQueueAtom); const forceSendQueuedMessage = useSetAtom(forceSendMessageAtom); - const setQueueFlushRequest = useSetAtom(queueFlushRequestAtom); + const queueTailKey = sessionMessageQueue.at(-1)?.turnIntentId ?? null; + + const cancelQueuedMessage = useCallback( + (messageId: string) => { + void cancelQueuedMessageDeliveries(store, [messageId]).catch((error) => + log.error( + "[useChatViewMessageQueue] failed to cancel queued message", + error + ) + ); + }, + [store] + ); const handleSendNow = useCallback( (messageId: string) => { const message = messageQueue.find((item) => item.id === messageId); if (!message) return; forceSendQueuedMessage(messageId); - setQueueFlushRequest((requestId) => requestId + 1); }, - [messageQueue, forceSendQueuedMessage, setQueueFlushRequest] + [messageQueue, forceSendQueuedMessage] ); const handleCommitQueueEdit = useCallback( @@ -74,8 +121,16 @@ export function useChatViewMessageQueue({ ); const handleClearSessionQueue = useCallback(() => { - clearQueuedMessages(sessionMessageQueue.map((message) => message.id)); - }, [clearQueuedMessages, sessionMessageQueue]); + void cancelQueuedMessageDeliveries( + store, + sessionMessageQueue.map((message) => message.id) + ).catch((error) => + log.error( + "[useChatViewMessageQueue] failed to clear queued messages", + error + ) + ); + }, [sessionMessageQueue, store]); const queueEditProps = useQueueEditMode({ onCommit: handleCommitQueueEdit, @@ -84,7 +139,7 @@ export function useChatViewMessageQueue({ return { cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, diff --git a/src/engines/ChatPanel/hooks/useConversationTargetBinding.test.ts b/src/engines/ChatPanel/hooks/useConversationTargetBinding.test.ts new file mode 100644 index 0000000000..6d5c439e66 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from "vitest"; + +import { + conversationExecutions, + conversationRootForSession, + conversationSourceFromImportedHistory, + latestConversationExecution, + mergeConversationExecutionTargets, + resolveConversationAppOpenSessionId, + resolveConversationExecutionTargetHydration, + resolveNativeConversationCliTargets, + writableConversationWorkspacePath, +} from "./useConversationTargetBinding"; + +describe("conversation target binding source", () => { + it("keeps an installed shell-out Claude runtime without GUI launch support", () => { + expect( + resolveNativeConversationCliTargets( + [ + { name: "claude_code", installed: true, supportsGui: false }, + { name: "codex", installed: true, supportsGui: true }, + ] as never, + true + ) + ).toEqual(["claude_code", "codex"]); + }); + + it("projects a native imported history onto the canonical runtime picker", () => { + expect( + conversationSourceFromImportedHistory({ + sessionId: "claudecodeapp-session-1", + session: { + name: "Native Claude history", + model: "claude-opus-5", + repoPath: "/repo", + } as never, + }) + ).toMatchObject({ + cliAgentType: "claude_code", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + initialTarget: null, + }); + }); + + it("keeps an execution child's encoded Cloud root authoritative", () => { + const root = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "root-1", + } as const; + const parentSessionId = JSON.stringify([ + "org2-conversation", + 1, + root.authority, + root.authorityScope, + root.conversationId, + ]); + + expect( + conversationRootForSession({ + session_id: "native-child", + parentSessionId, + cliAgentType: "codex", + } as never) + ).toEqual(root); + }); + + it("prefers the discovered local git root over a stale source worktree", () => { + expect( + conversationSourceFromImportedHistory({ + sessionId: "claudecodeapp-session-1", + session: { + name: "Native Claude history", + repoPath: "/deleted/source-worktree", + repoRootPath: "/local/repo-root", + } as never, + }) + ).toMatchObject({ + workspaceRepoPath: "/local/repo-root", + }); + }); + + it("keeps every imported provider eligible without native source resume", () => { + expect( + conversationSourceFromImportedHistory({ + sessionId: "windsurfapp-session-1", + }) + ).toMatchObject({ + cliAgentType: undefined, + workspaceRepoPath: null, + initialTarget: null, + }); + }); + + it("keeps the writable episode checkout on later turns", () => { + expect( + writableConversationWorkspacePath( + { + repoPath: "/local/writable-episode", + } as never, + { + repoPath: "/deleted/imported-worktree", + repoRootPath: "/local/root-fallback", + } as never + ) + ).toBe("/local/writable-episode"); + }); + + it("derives the remembered runtime from the newest persisted episode", () => { + const root = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "root-1", + }; + const parentSessionId = JSON.stringify([ + "org2-conversation", + 1, + root.authority, + root.authorityScope, + root.conversationId, + ]); + expect( + latestConversationExecution( + [ + { + session_id: "older-codex", + parentSessionId, + updated_at: "2026-08-29T10:00:00Z", + }, + { + session_id: "newer-claude", + parentSessionId, + updated_at: "2026-08-29T11:00:00Z", + }, + { + session_id: "other-root", + parentSessionId: "other", + updated_at: "2026-08-29T12:00:00Z", + }, + ] as never, + root + )?.session_id + ).toBe("newer-claude"); + + expect( + conversationExecutions( + [ + { + session_id: "older-codex", + parentSessionId, + updated_at: "2026-08-29T10:00:00Z", + }, + { + session_id: "newer-claude", + parentSessionId, + updated_at: "2026-08-29T11:00:00Z", + }, + ] as never, + root + ).map((session) => session.session_id) + ).toEqual(["newer-claude", "older-codex"]); + }); + + it("uses durable hidden executions as the restart authority", () => { + const durable = [ + { + sessionId: "hidden-codex", + updatedAt: "2026-09-05T12:00:00.000Z", + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + ] as const; + + expect( + resolveConversationExecutionTargetHydration( + "root-a", + { rootKey: "root-a", status: "ready", targets: durable }, + [] + ) + ).toEqual({ loading: false, failed: false, targets: durable }); + }); + + it("opens the newest executed episode and preserves a settled imported source with no child", () => { + expect( + resolveConversationAppOpenSessionId({ + viewerSessionId: "claudecodeapp-source", + executionTargets: [], + loading: false, + failed: false, + }) + ).toBe("claudecodeapp-source"); + + expect( + resolveConversationAppOpenSessionId({ + viewerSessionId: "claudecodeapp-source", + executionTargets: [ + { + sessionId: "agent-newest", + updatedAt: "2026-09-07T00:00:00.000Z", + target: { + agentDefinitionId: "sde", + accountId: "account-sde", + model: "model-sde", + }, + }, + ], + loading: false, + failed: false, + }) + ).toBe("agent-newest"); + }); + + it("does not guess an app-open owner while execution hydration is pending or failed", () => { + for (const state of [ + { loading: true, failed: false }, + { loading: false, failed: true }, + ]) { + expect( + resolveConversationAppOpenSessionId({ + viewerSessionId: "claudecodeapp-source", + executionTargets: [], + ...state, + }) + ).toBeNull(); + } + }); + + it("stays loading instead of falling back while a new root hydrates", () => { + const staleTarget = { + sessionId: "old-claude", + updatedAt: "2026-09-05T12:00:00.000Z", + target: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "sonnet", + workspaceRepoPath: "/repo", + }, + } as const; + + expect( + resolveConversationExecutionTargetHydration( + "new-root", + { rootKey: "old-root", status: "ready", targets: [staleTarget] }, + [] + ) + ).toEqual({ loading: true, failed: false, targets: [] }); + }); + + it("uses a live execution immediately while durable history hydrates", () => { + const liveTarget = { + sessionId: "live-codex", + updatedAt: "2026-09-05T12:00:00.000Z", + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + } as const; + + expect( + resolveConversationExecutionTargetHydration("root-a", null, [liveTarget]) + ).toEqual({ loading: false, failed: false, targets: [liveTarget] }); + }); + + it("retains historical runtime pairs while applying a newer live overlay", () => { + const durable = [ + { + sessionId: "codex", + updatedAt: "2026-09-05T10:00:00.000Z", + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + { + sessionId: "claude", + updatedAt: "2026-09-05T09:00:00.000Z", + target: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "sonnet", + }, + }, + ] as const; + const live = [ + { + sessionId: "claude", + updatedAt: "2026-09-05T11:00:00.000Z", + target: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "opus", + }, + }, + ] as const; + + expect( + mergeConversationExecutionTargets(durable, live).map( + ({ sessionId, target }) => [sessionId, target.model] + ) + ).toEqual([ + ["claude", "opus"], + ["codex", "gpt-5.6-sol"], + ]); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts new file mode 100644 index 0000000000..0bbd3ab910 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts @@ -0,0 +1,711 @@ +/** React binding from a canonical conversation to the standard creator controls. */ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; +import { isHostedKey } from "@src/api/tauri/session"; +import { + type ConversationTargetBinding, + resolveConversationRuntimeSelection, + resolveConversationRuntimeTarget, + resolveConversationTargetPillPresentation, + resolveConversationTargetReadiness, + resolveDefaultConversationTarget, + resolvePickedConversationRuntimeTarget, +} from "@src/engines/ChatPanel/conversationTargetSelection"; +import { + type ConversationRootLocator, + type ConversationSource, + type LocalConversationTarget, + NATIVE_CONVERSATION_CLI_TARGETS, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + type LocalConversationExecutionTargetSnapshot, + conversationExecutionParentId, + loadLocalConversationExecutionTargets, + localConversationRootForSession, + parseConversationExecutionParentId, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { useCloudConversationSource } from "@src/features/Org2Cloud/SessionConversation/useCloudConversationSource"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudOrgsLoadedAtom, + sidebarActiveCloudOrgIdAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + org2CloudPushCursorsAtom, + org2CloudPushedMetadataAtom, +} from "@src/features/Org2Cloud/org2CloudSyncAtoms"; +import { + pushedCloudOrgIdsForSession, + resolvePendingCloudConversationTarget, + sessionCommentTargetForConversationRoot, + useSessionCommentTarget, +} from "@src/features/Org2Cloud/sessionCommentTarget"; +import type { AdvancedConfig } from "@src/features/SessionCreator/types"; +import { sessionOrgTagsAtom } from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import { createLogger } from "@src/hooks/logger"; +import { + getRustCompatibleAccounts, + useAgentCompatibility, +} from "@src/hooks/models/useAgentCompatibility"; +import { useModelAccountLookup } from "@src/hooks/models/useModelAccountLookup"; +import { useAgentDefinitions } from "@src/modules/MainApp/AgentOrgs/hooks/useAgentDefinitions"; +import type { AgentSelection } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; +import { reposAtom } from "@src/store/repo"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; +import type { Session } from "@src/store/session/sessionAtom"; +import { + sessionByIdAtom, + sessionsAtom, +} from "@src/store/session/sessionAtom/atoms"; +import { + conversationTargetOverridesAtom, + reconcileConversationTargetOverrideAtom, + setConversationTargetOverrideAtom, +} from "@src/store/ui/conversationTargetAtom"; + +const log = createLogger("useConversationTargetBinding"); + +export interface ExecutionTargetHydration { + rootKey: string; + status: "loading" | "ready" | "error"; + targets: readonly LocalConversationExecutionTargetSnapshot[]; +} + +/** + * Project any imported provider history onto the same canonical conversation + * picker used by local and Team Sessions. + * + * The source does not need to expose a provider-native `resume` command. Its + * authoritative transcript is already readable through the imported-history + * adapter, so the user can still materialize it into any supported target + * runtime. A compatible source runtime is only used as the initial selection; + * unsupported sources start at the ordinary "Select agent" state. + */ +export function conversationSourceFromImportedHistory(params: { + sessionId: string | null | undefined; + session?: Session; +}): ConversationSource | undefined { + const externalSource = getImportedHistorySourceBySessionId(params.sessionId); + if (!externalSource || !params.sessionId) return undefined; + + const sourceCliAgentType = externalSource.cliResume?.agentType; + const compatibleSourceCliAgentType = + sourceCliAgentType && + NATIVE_CONVERSATION_CLI_TARGETS.includes( + sourceCliAgentType as (typeof NATIVE_CONVERSATION_CLI_TARGETS)[number] + ) + ? sourceCliAgentType + : undefined; + const root = { + authority: "imported-history", + authorityScope: [externalSource.sourceId], + conversationId: params.sessionId, + } as const; + + return { + root, + cliAgentType: compatibleSourceCliAgentType, + model: params.session?.model, + initialTarget: null, + workspaceRepoPath: + params.session?.repoRootPath ?? + params.session?.worktreePath ?? + params.session?.repoPath ?? + null, + }; +} + +export function resolveNativeConversationCliTargets( + agents: AgentRegistry["agents"], + discoverySettled: boolean +): CliAgentType[] { + if (!discoverySettled) return []; + const supported = [...NATIVE_CONVERSATION_CLI_TARGETS] as CliAgentType[]; + // Continuations launch through the native shell-out adapters. GUI launch + // capability is unrelated and would incorrectly hide a working ambient + // Claude installation whose discovery row reports supportsGui=false. + return supported.filter((runtime) => + agents.some((agent) => agent.name === runtime && agent.installed) + ); +} + +/** Recover the target persisted by the newest native execution episode. */ +export function latestConversationExecution( + sessions: readonly Session[], + root: ConversationRootLocator +): Session | undefined { + return conversationExecutions(sessions, root)[0]; +} + +/** Native execution episodes for a canonical conversation, newest first. */ +export function conversationExecutions( + sessions: readonly Session[], + root: ConversationRootLocator +): Session[] { + const parentId = conversationExecutionParentId(root); + return sessions + .filter((candidate) => candidate.parentSessionId === parentId) + .sort((left, right) => + (right.updated_at ?? "").localeCompare(left.updated_at ?? "") + ); +} + +/** Merge the durable restart snapshot with newer in-memory Session updates. */ +export function mergeConversationExecutionTargets( + durable: readonly LocalConversationExecutionTargetSnapshot[], + live: readonly LocalConversationExecutionTargetSnapshot[] +): LocalConversationExecutionTargetSnapshot[] { + const bySessionId = new Map( + durable.map((execution) => [execution.sessionId, execution] as const) + ); + for (const execution of live) { + const persisted = bySessionId.get(execution.sessionId); + if (!persisted || execution.updatedAt > persisted.updatedAt) { + bySessionId.set(execution.sessionId, execution); + } + } + return [...bySessionId.values()].sort((left, right) => + right.updatedAt.localeCompare(left.updatedAt) + ); +} + +/** Ignore stale roots and block fallback selection until hydration settles. */ +export function resolveConversationExecutionTargetHydration( + rootKey: string | null, + hydration: ExecutionTargetHydration | null, + live: readonly LocalConversationExecutionTargetSnapshot[] +): { + loading: boolean; + failed: boolean; + targets: LocalConversationExecutionTargetSnapshot[]; +} { + if (!rootKey) { + return { loading: false, failed: false, targets: [...live] }; + } + const current = hydration?.rootKey === rootKey ? hydration : null; + // A live Session row is already newer than (or equal to) the pending disk + // snapshot, so it can render immediately while the restart authority fills + // in older provider pairs in the background. + const loading = + live.length === 0 && (!current || current.status === "loading"); + const failed = current?.status === "error" && live.length === 0; + return { + loading, + failed, + targets: mergeConversationExecutionTargets( + current?.status === "ready" ? current.targets : [], + live + ), + }; +} + +/** Select the current native-session owner only after target hydration settles. */ +export function resolveConversationAppOpenSessionId(params: { + viewerSessionId: string | null | undefined; + executionTargets: readonly LocalConversationExecutionTargetSnapshot[]; + loading: boolean; + failed: boolean; +}): string | null { + if (params.loading || params.failed) return null; + return ( + params.executionTargets[0]?.sessionId ?? params.viewerSessionId ?? null + ); +} + +/** Recover the provider/runtime target recorded by an existing native Session. */ +function localConversationTargetFromSession( + session: Pick< + Session, + | "cliAgentType" + | "agentDefinitionId" + | "accountId" + | "model" + | "repoPath" + | "worktreePath" + > +): LocalConversationTarget | null { + const workspaceRepoPath = session.worktreePath ?? session.repoPath ?? null; + if ( + session.cliAgentType && + (session.accountId || session.cliAgentType === "claude_code") + ) { + return { + cliAgentType: session.cliAgentType, + accountId: session.accountId, + model: session.model, + workspaceRepoPath, + }; + } + if (session.agentDefinitionId && session.accountId && session.model) { + return { + agentDefinitionId: session.agentDefinitionId, + accountId: session.accountId, + model: session.model, + workspaceRepoPath, + }; + } + return null; +} + +/** + * A writable episode owns its execution checkout. Its canonical root may be + * an immutable imported row whose absolute source cwd is stale or belongs to + * another machine, so it must never overwrite the episode on later turns. + */ +export function writableConversationWorkspacePath( + episode: Session, + root: Session +): string | null { + return ( + episode.worktreePath ?? + episode.repoPath ?? + episode.repoRootPath ?? + root.repoRootPath ?? + root.worktreePath ?? + root.repoPath ?? + null + ); +} + +/** A continuation child never becomes a new conversation authority. */ +export function conversationRootForSession( + session: Pick< + Session, + "session_id" | "parentSessionId" | "cliAgentType" | "agentDefinitionId" + > +): ConversationRootLocator | null { + return ( + parseConversationExecutionParentId(session.parentSessionId) ?? + localConversationRootForSession( + session.session_id, + session.cliAgentType, + session.agentDefinitionId + ) + ); +} + +export function useConversationTargetBinding( + sessionId: string | null | undefined +): ConversationTargetBinding | null { + // The remote transcript/progress surface can mount before its canonical + // Session row commits. That is a hydration state, not a second source of + // execution identity; roster loaders retain imported replay rows centrally. + const session = useAtomValue(sessionByIdAtom(sessionId ?? "")); + const sessions = useAtomValue(sessionsAtom); + const repos = useAtomValue(reposAtom); + const cloudAuth = useAtomValue(org2CloudAuthAtom); + const cloudOrgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); + const sessionOrgTags = useAtomValue(sessionOrgTagsAtom); + const selectedCloudOrg = useAtomValue(sidebarActiveCloudOrgIdAtom); + const pushCursors = useAtomValue(org2CloudPushCursorsAtom); + const pushedMetadata = useAtomValue(org2CloudPushedMetadataAtom); + const { accounts, hasLoaded: accountsLoaded } = useModelAccountLookup(); + const { registry, discoveryState } = useAgentCompatibility(); + const { builtInAgents, agents: customAgents } = useAgentDefinitions(); + const definitions = useMemo( + () => [...builtInAgents, ...customAgents], + [builtInAgents, customAgents] + ); + const externalSource = useMemo( + () => conversationSourceFromImportedHistory({ sessionId, session }), + [session, sessionId] + ); + const commentTargetSession = useMemo( + () => + session ?? + (externalSource && sessionId + ? ({ session_id: sessionId } as Session) + : null), + [externalSource, session, sessionId] + ); + const encodedCloudTarget = useMemo( + () => + commentTargetSession + ? sessionCommentTargetForConversationRoot( + conversationRootForSession(commentTargetSession) + ) + : null, + [commentTargetSession] + ); + const cloudTarget = useSessionCommentTarget( + commentTargetSession, + encodedCloudTarget + ); + const pendingCloudTarget = useMemo(() => { + if (cloudTarget || !cloudAuth || cloudOrgsLoaded || !commentTargetSession) { + return null; + } + return resolvePendingCloudConversationTarget({ + session: commentTargetSession, + tags: sessionOrgTags, + preferredOrgId: selectedCloudOrg, + pushedOrgIds: pushedCloudOrgIdsForSession( + commentTargetSession.session_id, + pushCursors, + pushedMetadata + ), + }); + }, [ + cloudAuth, + cloudOrgsLoaded, + cloudTarget, + commentTargetSession, + pushCursors, + pushedMetadata, + selectedCloudOrg, + sessionOrgTags, + ]); + const executionCloudTarget = cloudTarget ?? pendingCloudTarget; + const cloudSource = useCloudConversationSource({ + sessionId, + session, + target: executionCloudTarget, + sessions, + repos, + }); + const pickerOverrides = useAtomValue(conversationTargetOverridesAtom); + const setPickerOverride = useSetAtom(setConversationTargetOverrideAtom); + const reconcilePickerOverride = useSetAtom( + reconcileConversationTargetOverrideAtom + ); + + const source = useMemo(() => { + // Cloud sharing is the conversation authority from every viewpoint. An + // owner row, an imported replay, and a native child must therefore choose + // the same root before considering provider-local history provenance. + if (cloudSource.source) { + return cloudSource.source; + } + + if (externalSource) return externalSource; + + if (!session) return undefined; + + const root = conversationRootForSession(session); + if (!root) return undefined; + const rootSession = + sessions.find( + (candidate) => candidate.session_id === root.conversationId + ) ?? session; + return { + root, + cliAgentType: session.cliAgentType ?? rootSession.cliAgentType, + agentDefinitionId: + session.agentDefinitionId ?? rootSession.agentDefinitionId, + agentDisplayName: + session.agentDisplayName ?? rootSession.agentDisplayName, + model: session.model ?? rootSession.model, + initialTarget: localConversationTargetFromSession(session), + workspaceRepoPath: writableConversationWorkspacePath( + session, + rootSession + ), + }; + }, [cloudSource.source, externalSource, session, sessions]); + + const sourceRootKey = source ? conversationRootKey(source.root) : null; + const [executionTargetHydration, setExecutionTargetHydration] = + useState(null); + useEffect(() => { + const root = source?.root ?? null; + if (!root || !sourceRootKey) { + setExecutionTargetHydration(null); + return; + } + + let current = true; + setExecutionTargetHydration({ + rootKey: sourceRootKey, + status: "loading", + targets: [], + }); + void loadLocalConversationExecutionTargets(root) + .then((targets) => { + if (!current) return; + setExecutionTargetHydration({ + rootKey: sourceRootKey, + status: "ready", + targets, + }); + }) + .catch((error: unknown) => { + if (!current) return; + log.warn("durable execution target hydration failed", { + rootKey: sourceRootKey, + error, + }); + setExecutionTargetHydration({ + rootKey: sourceRootKey, + status: "error", + targets: [], + }); + }); + + return () => { + current = false; + }; + // `sourceRootKey` encodes every locator field. Reloading on presentation + // metadata or sessionsAtom changes would repeatedly hide a ready picker. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceRootKey]); + const persistedExecutions = useMemo( + () => (source ? conversationExecutions(sessions, source.root) : []), + [sessions, source] + ); + const liveExecutionTargets = useMemo( + () => + persistedExecutions.flatMap((execution) => { + const target = localConversationTargetFromSession(execution); + return target + ? [ + { + sessionId: execution.session_id, + updatedAt: execution.updated_at ?? "", + target, + }, + ] + : []; + }), + [persistedExecutions] + ); + const executionTargetResolution = useMemo( + () => + resolveConversationExecutionTargetHydration( + sourceRootKey, + executionTargetHydration, + liveExecutionTargets + ), + [executionTargetHydration, liveExecutionTargets, sourceRootKey] + ); + const executionTargetHydrationLoading = executionTargetResolution.loading; + const executionTargetHydrationFailed = executionTargetResolution.failed; + const executionTargets = executionTargetResolution.targets; + const previousTargets = useMemo(() => { + const targets = executionTargets.map((execution) => execution.target); + if (source?.initialTarget) targets.push(source.initialTarget); + return targets; + }, [executionTargets, source]); + const persistedTarget = executionTargets[0]?.target ?? null; + useEffect(() => { + if (!sourceRootKey) return; + reconcilePickerOverride({ + rootKey: sourceRootKey, + persistedTarget, + }); + }, [persistedTarget, reconcilePickerOverride, sourceRootKey]); + const preferredTarget = + (sourceRootKey ? pickerOverrides.get(sourceRootKey) : undefined) ?? + persistedTarget; + + const agentDiscoverySettled = + discoveryState === "ready" || + discoveryState === "error" || + registry.agents.length > 0; + // Background refreshes keep the last settled inventory usable. Only the + // first hydration blocks target resolution. + const inventoryLoading = !accountsLoaded || !agentDiscoverySettled; + + const nativeCliTargets = useMemo(() => { + return resolveNativeConversationCliTargets( + registry.agents, + agentDiscoverySettled + ); + }, [agentDiscoverySettled, registry.agents]); + + const target = useMemo(() => { + if ( + !source || + inventoryLoading || + executionTargetHydrationLoading || + executionTargetHydrationFailed + ) { + return null; + } + return resolveDefaultConversationTarget({ + preferredTarget, + initialTarget: source.initialTarget, + sourceCliAgentType: source.cliAgentType, + sourceModel: source.model, + workspaceRepoPath: cloudSource.workspacePending + ? undefined + : source.workspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); + }, [ + accounts, + cloudSource.workspacePending, + executionTargetHydrationFailed, + executionTargetHydrationLoading, + inventoryLoading, + nativeCliTargets, + preferredTarget, + registry, + source, + ]); + + const hasAvailableRuntime = useMemo( + () => + nativeCliTargets.length > 0 || + (definitions.length > 0 && + getRustCompatibleAccounts(registry, [...accounts]).some( + (account) => account.enabled + )), + [accounts, definitions.length, nativeCliTargets.length, registry] + ); + const resolvedReadiness = resolveConversationTargetReadiness({ + accountsLoaded, + agentDiscoverySettled, + hasAvailableRuntime, + }); + const readiness = + pendingCloudTarget || executionTargetHydrationLoading + ? "loading" + : executionTargetHydrationFailed + ? "unavailable" + : resolvedReadiness; + + const presentation = useMemo(() => { + if (!source || readiness !== "ready" || !target) return null; + return resolveConversationTargetPillPresentation({ + target, + accounts, + }); + }, [accounts, readiness, source, target]); + + const runtimeSelection = useMemo( + () => + source && readiness === "ready" && target + ? resolveConversationRuntimeSelection({ + target, + source, + definitions, + }) + : null, + [definitions, readiness, source, target] + ); + + const applyModelPick = useCallback( + ( + config: AdvancedConfig, + pendingRuntime?: AgentSelection | null + ): boolean => { + if (readiness !== "ready" || isHostedKey(config.keySource) || !source) { + return false; + } + const selectedRuntime = pendingRuntime ?? runtimeSelection; + if (!selectedRuntime) return false; + const nextTarget = resolvePickedConversationRuntimeTarget({ + selection: selectedRuntime, + config: { + ...config, + cliAgentType: selectedRuntime.cliAgentType, + }, + workspaceRepoPath: + target?.workspaceRepoPath ?? source.workspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); + if (!nextTarget) return false; + setPickerOverride({ + rootKey: conversationRootKey(source.root), + target: nextTarget, + }); + return true; + }, + [ + accounts, + nativeCliTargets, + readiness, + registry, + runtimeSelection, + setPickerOverride, + source, + target, + ] + ); + + const applyRuntimePick = useCallback( + (selection: AgentSelection): boolean => { + if (readiness !== "ready" || !source) return false; + const definition = selection.agentDefinitionId + ? definitions.find( + (candidate) => candidate.id === selection.agentDefinitionId + ) + : undefined; + const next = resolveConversationRuntimeTarget({ + selection, + current: target, + previousTargets, + workspaceRepoPath: + target?.workspaceRepoPath ?? source.workspaceRepoPath, + preferredAccountId: definition?.selectedAccountId, + preferredModel: definition?.selectedModelId, + accounts, + registry, + nativeCliTargets, + }); + if (!next) return false; + setPickerOverride({ + rootKey: conversationRootKey(source.root), + target: next, + }); + return true; + }, + [ + definitions, + accounts, + nativeCliTargets, + previousTargets, + readiness, + registry, + setPickerOverride, + source, + target, + ] + ); + + return useMemo( + () => + source + ? { + root: source.root, + appOpenSessionId: resolveConversationAppOpenSessionId({ + viewerSessionId: sessionId, + executionTargets, + loading: executionTargetHydrationLoading, + failed: executionTargetHydrationFailed, + }), + cloudTarget, + selection: presentation?.selection ?? null, + runtimeSelection, + target, + readiness, + nativeCliTargets, + applyRuntimePick, + applyModelPick, + } + : null, + [ + applyModelPick, + applyRuntimePick, + cloudTarget, + executionTargetHydrationFailed, + executionTargetHydrationLoading, + executionTargets, + nativeCliTargets, + presentation, + readiness, + runtimeSelection, + sessionId, + source, + target, + ] + ); +} diff --git a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts b/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts deleted file mode 100644 index ec9bb3d14b..0000000000 --- a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; - -import Message from "@src/components/Message"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; -import { waitForSessionChannelReady } from "@src/engines/SessionCore/sync/useSessionChannel"; -import { activeConversationRunnersAtom } from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; -import { - type ConversationFamilyMember, - resolveConversationFamily, -} from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; -import { publishOwnerTurn } from "@src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher"; -import { - bumpConversationPlaneSignal, - conversationPlaneAtom, - conversationPlaneKey, - conversationPlaneSignalAtom, -} from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; -import { buildConversationPlaneStreamEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneEvents"; -import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; -import { - buildRunnerPrompt, - renderConversationContext, - runConversationTurn, -} from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; -import { - org2CloudAccessSettingsAtom, - withCloudSessionMode, -} from "@src/features/Org2Cloud/org2CloudAccessSettings"; -import { - commitRefreshedAuth, - org2CloudAuthAtom, -} from "@src/features/Org2Cloud/org2CloudAuthAtom"; -import { ensureFreshSession } from "@src/features/Org2Cloud/org2CloudClient"; -import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { findImportedSession } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; -import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; -import type { ForkImportedErrorKind } from "@src/features/TeamCollaboration/useForkImportedSession"; -import { useForkImportedSession } from "@src/features/TeamCollaboration/useForkImportedSession"; -import { createLogger } from "@src/hooks/logger"; -import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; -import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; -import type { Session } from "@src/store/session"; -import { sessionsAtom } from "@src/store/session"; -import { restoreToInputAtom } from "@src/store/session/cliSessionStatusAtom"; -import type { SessionContinuation } from "@src/store/session/sessionTabPlacementAtom"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; - -import type { SubmitOverrideInput } from "./useInputArea/types"; -import { useUserIntentSubmit } from "./useWorkspaceChat/useUserIntentSubmit"; - -const logger = createLogger("ChatView"); - -const IMPORTED_FORK_ERROR_KEYS: Record< - Exclude, - string -> = { - retention: "collaboration.forkImported.retentionError", - gone: "collaboration.forkImported.goneError", - replay: "collaboration.forkImported.replayError", - snapshot: "collaboration.forkImported.snapshotError", - agent: "collaboration.forkImported.agentError", - backend: "collaboration.forkImported.backendError", - generic: "collaboration.forkImported.error", -}; - -interface UseImportedSessionSubmitOverrideOptions { - sessionId: string; - currentSession: Session | undefined; - onFallbackSubmit: (input: SubmitOverrideInput) => Promise; - onSessionContinuation?: (continuation: SessionContinuation) => void; -} - -/** - * Intercepts the first send from an imported teammate session and routes it - * through the fork flow. Ordinary sessions continue through the supplied - * Agent-Org/group-chat submit handler unchanged. - */ -function memberActivity(member: ConversationFamilyMember): number { - const parsed = Date.parse(member.row.lastActivityAt ?? ""); - return Number.isNaN(parsed) ? 0 : parsed; -} - -export function useImportedSessionSubmitOverride({ - sessionId, - currentSession, - onFallbackSubmit, - onSessionContinuation, -}: UseImportedSessionSubmitOverrideOptions): ( - input: SubmitOverrideInput -) => Promise { - const { t } = useTranslation("navigation"); - const { openSession } = useSessionView(); - const setRestoreToInput = useSetAtom(restoreToInputAtom); - const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); - const sessions = useAtomValue(sessionsAtom); - const auth = useAtomValue(org2CloudAuthAtom); - - // TIP-FOLLOW: a conversation continues at its NEWEST family member no - // matter which member's surface the send comes from. Without this, a send - // from an older member forks a SIBLING branch — the reply would ignore - // everything said since, which is never what "keep chatting" means. - const lineage = currentSession - ? getSessionForkedFrom(currentSession) - : undefined; - const familyOrgId = - currentSession?.importedFrom?.orgId ?? lineage?.orgId ?? null; - const anchorBareSessionId = - currentSession?.importedFrom?.sourceSessionId ?? sessionId; - const familyTip = useMemo(() => { - if (!familyOrgId) return null; - const rows = remoteEntries[familyOrgId]?.rows; - if (!rows?.length) return null; - const family = resolveConversationFamily(rows, anchorBareSessionId); - if (!family) return null; - const live = family.filter( - (member) => - !member.row.deletedAt && - member.row.eventsEpoch !== undefined && - (member.row.eventsCount ?? 0) > 0 - ); - if (live.length === 0) return null; - const tip = live.reduce((best, member) => - memberActivity(member) > memberActivity(best) ? member : best - ); - return tip.bareSessionId === anchorBareSessionId ? null : tip; - }, [familyOrgId, remoteEntries, anchorBareSessionId]); - /** The tip session when it lives on THIS device as a writable session. */ - const ownLocalTip = useMemo(() => { - if (!familyTip) return null; - return ( - sessions.find( - (candidate) => candidate.session_id === familyTip.bareSessionId - ) ?? null - ); - }, [familyTip, sessions]); - /** The tip's imported replay copy — fork source when the tip is remote. */ - const tipImportedCopy = useMemo(() => { - if (!familyTip || ownLocalTip || !familyOrgId) return null; - const copy = findImportedSession( - sessions, - familyOrgId, - familyTip.bareSessionId, - auth?.supabaseUrl - ); - return copy?.importedFrom ? copy : null; - }, [familyTip, ownLocalTip, familyOrgId, sessions, auth?.supabaseUrl]); - - const { fork: forkImportedSession } = useForkImportedSession( - tipImportedCopy ?? currentSession ?? null - ); - - // CONVERSATION PLANE (0024): once the backend supports the multi-writer - // turn plane, implicit sends stop forking entirely — a member's turn runs - // in an invisible one-shot local session and publishes to the plane; the - // owner's sends keep their own session but inject the plane delta as - // context. The fork/tip paths below remain ONLY as the pre-0024 fallback. - const setAuth = useSetAtom(org2CloudAuthAtom); - const planeEntries = useAtomValue(conversationPlaneAtom); - const setPlaneSignal = useSetAtom(conversationPlaneSignalAtom); - const setAccessSettings = useSetAtom(org2CloudAccessSettingsAtom); - const setActiveRunners = useSetAtom(activeConversationRunnersAtom); - const conversationRootId = useMemo(() => { - if (lineage) return lineage.rootSessionId ?? lineage.sourceSessionId; - if (currentSession?.importedFrom) { - const rows = familyOrgId ? remoteEntries[familyOrgId]?.rows : undefined; - const source = currentSession.importedFrom.sourceSessionId; - const row = rows?.find( - (candidate) => candidate.sourceSessionId === source - ); - return row?.forkedFrom?.rootSessionId ?? source; - } - return sessionId; - }, [ - lineage, - currentSession?.importedFrom, - familyOrgId, - remoteEntries, - sessionId, - ]); - const planeInfo = useMemo(() => { - if (!conversationRootId) return null; - if (familyOrgId) { - const entry = - planeEntries[conversationPlaneKey(familyOrgId, conversationRootId)]; - return entry - ? { orgId: familyOrgId, rootId: conversationRootId, entry } - : null; - } - // Own sessions carry no lineage org — recover it from whichever plane - // entry the open conversation surface already fetched. - const suffix = `:${conversationRootId}`; - for (const [key, entry] of Object.entries(planeEntries)) { - if (key.endsWith(suffix)) { - return { - orgId: key.slice(0, -suffix.length), - rootId: conversationRootId, - entry, - }; - } - } - return null; - }, [planeEntries, familyOrgId, conversationRootId]); - const viewerOwnsRoot = useMemo( - () => - sessions.some((candidate) => candidate.session_id === conversationRootId), - [sessions, conversationRootId] - ); - const forkSubmitInFlightRef = useRef(false); - // useUserIntentSubmit reads this target so the synthetic user event and - // dispatch both land in the fork, not the still-mounted imported session. - const forkDispatchSessionIdRef = useRef(null); - const submitIntoForkedSession = useUserIntentSubmit({ - getSessionId: () => forkDispatchSessionIdRef.current, - }); - - // A turn can outlive the access token valid at dispatch (a 10-minute - // member turn did, live — its tail push failed with "JWT expired"), so - // every plane push resolves a fresh token from the CURRENT auth state. - const getAccessToken = useCallback(async (): Promise => { - const current = getInstrumentedStore().get(org2CloudAuthAtom); - if (!current) throw new Error("cloud sign-in required"); - const fresh = await ensureFreshSession(current); - if (!fresh) throw new Error("cloud auth refresh failed"); - commitRefreshedAuth(setAuth, current, fresh); - return fresh.accessToken; - }, [setAuth]); - - const restorePendingDraft = useCallback( - (pending: SubmitOverrideInput, targetSessionId: string) => { - setRestoreToInput({ - sessionId: targetSessionId, - displayContent: pending.displayText, - imageDataUrls: pending.imageDataUrls, - }); - }, - [setRestoreToInput] - ); - - return useCallback( - async (input: SubmitOverrideInput): Promise => { - const planeReady = planeInfo?.entry.state === "ready"; - // (a) Member send on a plane-capable backend: publish the message to - // the conversation immediately, run the turn in an invisible one-shot - // local session, stream the agent tail back to the plane. No fork. - if (planeReady && planeInfo && !viewerOwnsRoot) { - if (forkSubmitInFlightRef.current) { - restorePendingDraft(input, sessionId); - return true; - } - forkSubmitInFlightRef.current = true; - try { - if (!auth) throw new Error("cloud sign-in required"); - const freshAuth = await ensureFreshSession(auth); - if (!freshAuth) throw new Error("cloud auth refresh failed"); - commitRefreshedAuth(setAuth, auth, freshAuth); - const rootLocal = - sessions.find( - (candidate) => candidate.session_id === planeInfo.rootId - ) ?? - findImportedSession( - sessions, - planeInfo.orgId, - planeInfo.rootId, - auth.supabaseUrl - ); - const rootEvents = rootLocal - ? await eventStoreProxy - .getPersistedEvents(rootLocal.session_id) - .catch(() => [] as SessionEvent[]) - : []; - const timeline = mergePlaneIntoTranscript( - rootEvents, - planeInfo.entry.events, - sessionId, - auth.userId - ); - // The root row's repo scope keys the setup memory AND resolves the - // runner's local checkout — without it the dialog reappears and a - // workspace-requiring agent cannot launch at all. - const rootRow = familyOrgId - ? remoteEntries[familyOrgId]?.rows?.find( - (candidate) => candidate.sourceSessionId === planeInfo.rootId - ) - : undefined; - let publishResolve!: () => void; - const userPublished = new Promise((resolve) => { - publishResolve = resolve; - }); - let liveRunnerSessionId: string | null = null; - const dropLiveRunner = () => { - const runnerSessionId = liveRunnerSessionId; - if (!runnerSessionId) return; - liveRunnerSessionId = null; - setActiveRunners((current) => { - const list = current[planeInfo.rootId]; - if (!list) return current; - const kept = list.filter( - (runner) => runner.runnerSessionId !== runnerSessionId - ); - if (kept.length === list.length) return current; - const next = { ...current }; - if (kept.length === 0) delete next[planeInfo.rootId]; - else next[planeInfo.rootId] = kept; - return next; - }); - }; - const turnPromise = runConversationTurn({ - getAccessToken, - orgId: planeInfo.orgId, - rootSessionId: planeInfo.rootId, - conversationTitle: - currentSession?.name ?? rootLocal?.name ?? "Conversation", - displayText: input.displayText, - agentContent: input.agentContent, - imageDataUrls: input.imageDataUrls, - timeline, - sourceScopeKey: rootRow?.repoScopeKey, - sourceModel: currentSession?.model ?? rootRow?.model, - onRunnerReady: (runnerSessionId, turnId) => { - // Plumbing session: never sync it to the cloud as a session. - setAccessSettings((current) => - withCloudSessionMode( - current, - planeInfo.orgId, - runnerSessionId, - COLLAB_SESSION_ACCESS_MODE.OFF - ) - ); - // Overlay the runner's LIVE events (thinking / tools / worked-for) - // into the conversation until the plane carries this turn's - // agent tail — or the turn settles without one. - liveRunnerSessionId = runnerSessionId; - setActiveRunners((current) => { - const list = current[planeInfo.rootId] ?? []; - return { - ...current, - [planeInfo.rootId]: [...list, { runnerSessionId, turnId }], - }; - }); - }, - onUserMessagePublished: publishResolve, - onPushed: () => - bumpConversationPlaneSignal(setPlaneSignal, planeInfo.orgId), - }); - // The composer unblocks as soon as the user's words are on the - // plane; the agent tail continues in the background. - const settled = turnPromise.then( - () => dropLiveRunner(), - (error) => { - dropLiveRunner(); - logger.error("conversation turn failed", error); - Message.error(t("collaboration.forkImported.sendFailed")); - } - ); - await Promise.race([userPublished, settled]); - void settled; - return true; - } catch (error) { - logger.error("conversation plane send failed", error); - restorePendingDraft(input, sessionId); - Message.error(t("collaboration.forkImported.sendFailed")); - return true; - } finally { - forkSubmitInFlightRef.current = false; - } - } - // (b) Owner send on a plane-capable backend: the owner's own session - // stays the execution surface, the agent SEES the members' turns (the - // plane rows of other authors ride the agent copy as a read-only - // context prefix — the owner's own turns are already its history), - // and the turn is PUBLISHED to the plane under a turnId exactly like - // a member turn, so every turn of the conversation has a seq. - if (planeReady && planeInfo && viewerOwnsRoot) { - // Group-chat routing owns its own sends. - if (await onFallbackSubmit(input)) return true; - if (!auth) return false; - const freshAuth = await ensureFreshSession(auth); - if (!freshAuth) return false; - commitRefreshedAuth(setAuth, auth, freshAuth); - const othersRows = planeInfo.entry.events.filter( - (row) => row.authorUserId !== auth.userId - ); - const agentContent = - othersRows.length > 0 - ? buildRunnerPrompt( - renderConversationContext( - buildConversationPlaneStreamEvents(othersRows, sessionId) - ), - input.agentContent ?? input.displayText - ) - : input.agentContent; - const turnIntentId = mintTurnIntentId(); - try { - await submitIntoForkedSession({ - sessionId, - displayContent: input.displayText, - agentContent, - imageDataUrls: input.imageDataUrls, - turnIntentId, - applyStopSubmitGuards: true, - dedupeDirectSubmit: true, - clearUserInitiatedCancelOnQueue: true, - }); - } catch (error) { - logger.error("owner conversation send failed", error); - restorePendingDraft(input, sessionId); - Message.error(t("collaboration.forkImported.sendFailed")); - return true; - } - void publishOwnerTurn({ - getAccessToken, - orgId: planeInfo.orgId, - rootSessionId: planeInfo.rootId, - sessionId, - turnIntentId, - displayText: input.displayText, - onPushed: () => - bumpConversationPlaneSignal(setPlaneSignal, planeInfo.orgId), - }).catch((error: unknown) => { - logger.warn("owner turn publish failed", error); - }); - return true; - } - // The tip already lives here as a writable session (typically the - // viewer's own earlier continuation): no new fork — the send goes - // straight into it, and the surface follows. This is what keeps a - // back-and-forth conversation ONE conversation instead of a fork - // per round. - if (ownLocalTip) { - if (forkSubmitInFlightRef.current) { - restorePendingDraft(input, sessionId); - return true; - } - forkSubmitInFlightRef.current = true; - try { - forkDispatchSessionIdRef.current = ownLocalTip.session_id; - const continuation = { - sessionId: ownLocalTip.session_id, - sessionName: ownLocalTip.name, - repoPath: ownLocalTip.repoPath, - }; - if (onSessionContinuation) { - onSessionContinuation(continuation); - } else { - openSession( - continuation.sessionId, - continuation.sessionName, - continuation.repoPath - ); - } - try { - await waitForSessionChannelReady(ownLocalTip.session_id); - await submitIntoForkedSession({ - sessionId: ownLocalTip.session_id, - displayContent: input.displayText, - agentContent: input.agentContent, - imageDataUrls: input.imageDataUrls, - }); - } catch (error) { - logger.error("failed to send into the conversation tip", error); - restorePendingDraft(input, ownLocalTip.session_id); - Message.error(t("collaboration.forkImported.sendFailed")); - } finally { - forkDispatchSessionIdRef.current = null; - } - return true; - } finally { - forkSubmitInFlightRef.current = false; - } - } - // Remote tip (or no family): fork before send. `forkImportedSession` - // is bound to the tip's imported copy when the family has moved past - // this surface, so the continuation inherits the WHOLE conversation. - if (!currentSession?.importedFrom && !tipImportedCopy) { - return onFallbackSubmit(input); - } - if (forkSubmitInFlightRef.current) { - // A picker/fork is already in flight. Keep a second submission as - // the imported draft rather than replacing the captured first send. - restorePendingDraft(input, sessionId); - return true; - } - - forkSubmitInFlightRef.current = true; - try { - const outcome = await forkImportedSession(); - if (!outcome.ok) { - restorePendingDraft(input, sessionId); - if (outcome.errorKind !== "cancelled") { - Message.error(t(IMPORTED_FORK_ERROR_KEYS[outcome.errorKind])); - } - return true; - } - - forkDispatchSessionIdRef.current = outcome.localSessionId; - if (onSessionContinuation) { - onSessionContinuation({ - sessionId: outcome.localSessionId, - sessionName: outcome.name, - repoPath: outcome.repoPath, - }); - } else { - openSession(outcome.localSessionId, outcome.name, outcome.repoPath); - } - try { - // The first turn can finish before the new IPC channel is mounted. - // Wait for readiness so agent:complete cannot be lost. - await waitForSessionChannelReady(outcome.localSessionId); - await submitIntoForkedSession({ - sessionId: outcome.localSessionId, - displayContent: input.displayText, - agentContent: input.agentContent, - imageDataUrls: input.imageDataUrls, - }); - } catch (error) { - logger.error("failed to send captured message into fork", error); - restorePendingDraft(input, outcome.localSessionId); - Message.error(t("collaboration.forkImported.sendFailed")); - } finally { - forkDispatchSessionIdRef.current = null; - } - } finally { - forkSubmitInFlightRef.current = false; - } - return true; - }, - [ - auth, - currentSession?.importedFrom, - currentSession?.name, - currentSession?.model, - familyOrgId, - forkImportedSession, - getAccessToken, - onFallbackSubmit, - onSessionContinuation, - openSession, - ownLocalTip, - planeInfo, - remoteEntries, - restorePendingDraft, - sessionId, - sessions, - setAccessSettings, - setActiveRunners, - setAuth, - setPlaneSignal, - submitIntoForkedSession, - t, - tipImportedCopy, - viewerOwnsRoot, - ] - ); -} diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/inputAreaEventSelectors.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/inputAreaEventSelectors.test.ts index 1d48df16f9..d35d4030bc 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/__tests__/inputAreaEventSelectors.test.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/inputAreaEventSelectors.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { extractPlanMentionSource } from "../inputAreaEventSelectors"; +import { + extractPlanMentionSource, + resolveInputAreaWorkingState, +} from "../inputAreaEventSelectors"; function createPlanEvent( planPath: string, @@ -57,3 +60,57 @@ describe("extractPlanMentionSource", () => { ]); }); }); + +describe("resolveInputAreaWorkingState", () => { + it("shows Stop for a hidden native runner even when the source session is idle", () => { + expect( + resolveInputAreaWorkingState({ + runnerSessionId: "claude-runner-1", + runnerTurnActive: true, + sourceSessionActive: false, + hasComposerStopBlockingWork: false, + pendingCancel: false, + executionControlsEnabled: true, + }) + ).toBe(true); + }); + + it("keeps the existing pending-cancel gate for a hidden runner", () => { + expect( + resolveInputAreaWorkingState({ + runnerSessionId: "codex-runner-1", + runnerTurnActive: true, + sourceSessionActive: false, + hasComposerStopBlockingWork: false, + pendingCancel: true, + executionControlsEnabled: true, + }) + ).toBe(false); + }); + + it("drops stale Stop as soon as the hidden runner reaches terminal", () => { + expect( + resolveInputAreaWorkingState({ + runnerSessionId: "codex-runner-1", + runnerTurnActive: false, + sourceSessionActive: true, + hasComposerStopBlockingWork: true, + pendingCancel: false, + executionControlsEnabled: true, + }) + ).toBe(false); + }); + + it("does not expose Agent controls in a human Team Chat composer", () => { + expect( + resolveInputAreaWorkingState({ + runnerSessionId: "claude-runner-1", + runnerTurnActive: true, + sourceSessionActive: true, + hasComposerStopBlockingWork: true, + pendingCancel: false, + executionControlsEnabled: false, + }) + ).toBe(false); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts b/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts index d7e3cbe92d..6c7a5aa250 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts @@ -262,6 +262,7 @@ describe("useSubmitMessage composer boundary", () => { displayText: expected, agentContent: undefined, imageDataUrls: undefined, + composerSnapshot: editorHarness.editor.getSnapshot(), }); expect(handleSessChatSubmit).not.toHaveBeenCalled(); } else { @@ -378,7 +379,7 @@ describe("useSubmitMessage composer boundary", () => { expect(editorHarness.readText()).toBe(""); }); - it("lets a read-only imported replay delegate to its fork-before-send override", async () => { + it("lets a read-only imported replay delegate to its continuation override", async () => { const editorHarness = createEditor("continue from this replay"); const onSubmitOverride = vi.fn().mockResolvedValue(true); const handleSessChatSubmit = vi.fn().mockResolvedValue(undefined); @@ -401,6 +402,7 @@ describe("useSubmitMessage composer boundary", () => { displayText: "continue from this replay", agentContent: "agent:continue from this replay", imageDataUrls: undefined, + composerSnapshot: editorHarness.editor.getSnapshot(), }); expect(handleSessChatSubmit).not.toHaveBeenCalled(); expect(editorHarness.readText()).toBe(""); diff --git a/src/engines/ChatPanel/hooks/useInputArea/index.ts b/src/engines/ChatPanel/hooks/useInputArea/index.ts index 9568cb745b..de509e55a7 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/index.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/index.ts @@ -66,9 +66,11 @@ import { canvasSlashCommandNeedsInstruction } from "./canvasSlashCommand"; import { resolveDraftRestoreAction } from "./draftRestore"; import { type PlanMentionSourceItem, + resolveInputAreaWorkingState, useInputAreaChatRoundCount, useInputAreaComposerStopBlockingWork, useInputAreaPlanMentionSource, + useInputAreaRunnerTurnActive, } from "./inputAreaEventSelectors"; import type { CustomMentionOption, @@ -163,9 +165,11 @@ export function useInputArea( customMentionOptions, onSubmitOverride, sessionId: propSessionId, + controlSessionId, sessionScope = "active", submitDisabled = false, enableAgentInterceptors = true, + executionControlsEnabled = true, } = options; // ============================================ @@ -186,6 +190,10 @@ export function useInputArea( // Workspace Chat // ============================================ + const conversationRunnerTurnActive = useInputAreaRunnerTurnActive( + controlSessionId ?? null + ); + const { handleSessInputChange, handleSessChatSubmit, @@ -194,7 +202,11 @@ export function useInputArea( isHosted, canStopAgent, canResume, - } = useWorkspaceChat({ sessionId: propSessionId, sessionScope }); + } = useWorkspaceChat({ + sessionId: propSessionId, + sessionScope, + controlSessionId, + }); // ============================================ // Atoms (Global State) @@ -264,8 +276,14 @@ export function useInputArea( // This uses the composer-specific gate: foreground tools remain stoppable, // while background processes and hidden status sentinels stay in footer/replay // surfaces without keeping the main button stuck in Stop. - const isWpGeneWorking = - (isSessionActive || hasComposerStopBlockingWork) && !isPendingCancel; + const isWpGeneWorking = resolveInputAreaWorkingState({ + runnerSessionId: controlSessionId ?? null, + runnerTurnActive: conversationRunnerTurnActive, + sourceSessionActive: isSessionActive, + hasComposerStopBlockingWork, + pendingCancel: isPendingCancel, + executionControlsEnabled, + }); const sessionFileReloadKey = buildCompactFilesReloadKey( activeSessionId ?? null, diff --git a/src/engines/ChatPanel/hooks/useInputArea/inputAreaEventSelectors.ts b/src/engines/ChatPanel/hooks/useInputArea/inputAreaEventSelectors.ts index 4436573d55..1b5b83763c 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/inputAreaEventSelectors.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/inputAreaEventSelectors.ts @@ -3,6 +3,10 @@ import { selectAtom } from "jotai/utils"; import { useMemo } from "react"; import { countChatRounds } from "@src/engines/ChatPanel/InputArea/components/compactFileChangesHelpers"; +import { + isTurnActive, + turnLifecycleSignalAtom, +} from "@src/engines/SessionCore/control/turnLifecycle"; import { sortedEventsAtom } from "@src/engines/SessionCore/core/atoms/events"; import { sessionHasComposerStopBlockingWork } from "@src/engines/SessionCore/core/runningEventGate"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; @@ -22,6 +26,27 @@ function booleanEqual(left: boolean, right: boolean): boolean { return left === right; } +export function resolveInputAreaWorkingState(options: { + runnerSessionId: string | null; + runnerTurnActive: boolean; + sourceSessionActive: boolean; + hasComposerStopBlockingWork: boolean; + pendingCancel: boolean; + executionControlsEnabled: boolean; +}): boolean { + if (!options.executionControlsEnabled || options.pendingCancel) return false; + return options.runnerSessionId !== null + ? options.runnerTurnActive + : options.sourceSessionActive || options.hasComposerStopBlockingWork; +} + +export function useInputAreaRunnerTurnActive( + runnerSessionId: string | null +): boolean { + useAtomValue(turnLifecycleSignalAtom); + return runnerSessionId !== null && isTurnActive(runnerSessionId); +} + function planMentionSourceEqual( left: readonly PlanMentionSourceItem[], right: readonly PlanMentionSourceItem[] diff --git a/src/engines/ChatPanel/hooks/useInputArea/types.ts b/src/engines/ChatPanel/hooks/useInputArea/types.ts index 632a3ba93a..3a9fb28f37 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/types.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/types.ts @@ -9,8 +9,12 @@ import type { RefObject, } from "react"; -import type { ComposerInputRef } from "@src/components/ComposerInput"; +import type { + ComposerInputRef, + ComposerSnapshot, +} from "@src/components/ComposerInput"; import type { ComposerModeEntry } from "@src/config/sessionCreatorConfig"; +import type { MessageAudienceTarget } from "@src/features/TeamCollaboration/messageAudienceRouting"; import type { MenuItemId } from "@src/scaffold/ContextMenu/config"; import type { ChatImageAttachment } from "@src/store/ui/chatImageAtom"; import type { SlashItem } from "@src/types/extensions/types"; @@ -23,6 +27,12 @@ export interface SubmitOverrideInput { displayText: string; agentContent?: string; imageDataUrls?: string[]; + /** + * The exact editor document captured when Submit was pressed. Team Chat + * reads stable member ids from its mention pills instead of reparsing a + * mutable display name after asynchronous preprocessing. + */ + composerSnapshot?: ComposerSnapshot; } /** Rejected before any network/provider delivery was attempted. */ @@ -56,6 +66,8 @@ export interface CustomMentionOption { selectType?: MenuItemId; selectValue?: string; selectDisplayName?: string; + /** Identity-stable collaboration target carried by the inserted pill. */ + audienceTarget?: MessageAudienceTarget; } export interface UseInputAreaOptions { @@ -63,11 +75,15 @@ export interface UseInputAreaOptions { placeholder?: string; /** Explicit session ID for the chat surface using this composer. */ sessionId?: string; + /** Native execution episode controlled by Stop without retargeting messages. */ + controlSessionId?: string | null; /** Session whose comment threads Address Comments targets when the * composer dispatches elsewhere (external-history fork composer). */ sessionScope?: "active" | "none"; submitDisabled?: boolean; enableAgentInterceptors?: boolean; + /** False for human discussion composers, which must not expose Agent Stop. */ + executionControlsEnabled?: boolean; onSubmitOverride?: (input: SubmitOverrideInput) => Promise; customMentionOptions?: ReadonlyArray; } diff --git a/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts b/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts index a23188229b..b75e00d1da 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useAtMention.ts @@ -246,8 +246,14 @@ export function useAtMention(options: UseAtMentionOptions): AtMentionHandlers { return; } + const audiencePath = (() => { + const target = option.audienceTarget; + if (!target) return `member://${encodeURIComponent(option.id)}`; + if (target.kind === "all") return "audience://all"; + return `${target.kind}://${encodeURIComponent(target.id)}`; + })(); composerInputRef.current.insertFilePill( - `member://${option.id}`, + audiencePath, false, "member", option.label diff --git a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts index b3d1b9ce03..a72d9cad78 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/useSubmitMessage.ts @@ -142,10 +142,10 @@ export function useSubmitMessage({ const submitMessage = useCallback( async (options: SubmitMessageOptions = {}) => { // Imported teammate replays are intentionally read-only in the event - // store, but their composer owns an onSubmitOverride that performs - // fork-before-send. Let that coordinator inspect the submission before - // applying the ordinary read-only guard; otherwise the generic - // "No active session" toast makes the fork flow unreachable. + // store, but their composer owns an onSubmitOverride that admits the + // turn to the canonical conversation queue. Let that coordinator inspect + // the submission before applying the ordinary read-only guard; otherwise + // the generic "No active session" toast makes continuation unreachable. if (wpReadOnly && !onSubmitOverride) { Message.warning(t("chat.noActiveSession")); return; @@ -172,6 +172,13 @@ export function useSubmitMessage({ imageAttachment.hasImages ); const { isExplicitAction } = resolvedInput; + // Capture typed mention identities before any async secret scan, MCP + // expansion, or pending-pill load. Display text is not an identity + // source: a roster rename while those awaits run must not retarget the + // Team Chat message. + const submitComposerSnapshot = isExplicitAction + ? undefined + : refs.composerInputRef.current.getSnapshot(); let { displayText } = resolvedInput; const hasText = displayText.trim().length > 0; const { hasAttachedImages } = resolvedInput; @@ -368,6 +375,7 @@ export function useSubmitMessage({ displayText, agentContent, imageDataUrls, + composerSnapshot: submitComposerSnapshot, }); if (submitInFlightKeyRef.current === submitKey) return; submitInFlightKeyRef.current = submitKey; @@ -378,9 +386,7 @@ export function useSubmitMessage({ // Captured only so a true pre-send validation failure can leave the // composer untouched. Transport/provider failures remain visible on // the failed transcript row and never repopulate this editor. - const editorSnapshot = isExplicitAction - ? null - : refs.composerInputRef.current.getSnapshot(); + const editorSnapshot = submitComposerSnapshot ?? null; const imagesSnapshot: ChatImageAttachment[] = isExplicitAction ? [] : imageAttachment.images.slice(); @@ -422,6 +428,7 @@ export function useSubmitMessage({ displayText: displayText || "(image)", agentContent, imageDataUrls: dispatchImages, + composerSnapshot: submitComposerSnapshot, }) : false; if (!overrideHandled) { diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts index 8090cc0ae7..49a5849127 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts @@ -6,28 +6,15 @@ * has its own dispatcher; this hook gathers React dependencies and * delegates to the correct one. */ -import { useSetAtom } from "jotai"; import { useCallback } from "react"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { resolveSessionAgentExecMode } from "@src/config/sessionCreatorConfig"; import { - beginOptimisticTurn, - failOptimisticTurn, -} from "@src/engines/SessionCore/control/optimisticTurnStatus"; -import { - beginTurnDispatch, - confirmTurnRunning, - markTurnTerminal, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; -import { createSyntheticUserEvent } from "@src/engines/SessionCore/sync/adapters/shared"; -import { markSessionActive } from "@src/store/session"; -import { - lastUserMessageAtom, - setSessionRuntimeStatusAtom, -} from "@src/store/session/cliSessionStatusAtom"; + type DispatchUserIntentResult, + dispatchUserIntent, +} from "@src/engines/SessionCore/services/userIntentDispatch"; +import type { SessionRuntimeStatusSource } from "@src/store/session/cliSessionStatusAtom"; import { type LastModelSelection, creatorDefaultModelSelectionAtom, @@ -36,49 +23,34 @@ import { sessionMapAtom } from "@src/store/session/sessionAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; - -export function useMessageDispatch() { - const setSessionRuntimeStatus = useSetAtom(setSessionRuntimeStatusAtom); - const setLastUserMessage = useSetAtom(lastUserMessageAtom); - - const addUserMessage = useCallback( - async ( - sessionId: string, - content: string, - imageDataUrls?: string[], - turnIntentId?: string - ): Promise => { - const userEvent = createSyntheticUserEvent(sessionId, content, { - imageDataUrls, - turnIntentId, - }); - await eventStoreProxy.append([userEvent], sessionId); - // Capture the exact text/images the user sent so the cancel-restore - // path (Scenario A: cancel before any assistant output) can put it - // back into the input box. - setLastUserMessage({ - sessionId, - displayContent: content, - imageDataUrls, - }); - return userEvent.id; - }, - [setLastUserMessage] - ); +export interface MessageDispatchInput { + sessionId: string; + content: string; + visibleText: string; + imageDataUrls?: string[]; + modelSelectionOverride?: LastModelSelection; + displayText?: string; + clientMessageId?: string; + turnIntentId: string; + runtimeStatusSource?: SessionRuntimeStatusSource; + beforeAppend?: () => void | Promise; +} +export function useMessageDispatch() { const dispatchMessageBySessionType = useCallback( - async ( - sessionId: string, - content: string, - imageDataUrls?: string[], - modelSelectionOverride?: LastModelSelection, - displayText?: string, - clientMessageId?: string, - turnIntentId?: string, - reservedDispatchGeneration?: number - ): Promise => { + async ({ + sessionId, + content, + visibleText, + imageDataUrls, + modelSelectionOverride, + displayText, + clientMessageId, + turnIntentId, + runtimeStatusSource = "dispatch", + beforeAppend, + }: MessageDispatchInput): Promise => { // Read directly from the store at call time to avoid stale-closure // race: if the user changes the mode pill and immediately sends a // message in the same React render batch, useAtomValue subscriptions @@ -99,64 +71,27 @@ export function useMessageDispatch() { ); const { model, accountId } = resolveModelForMessage(lastModelSelection); - // Synchronous turn reserve: every dispatch funnels through here, so the - // FSM observes the session as busy before the first await. A concurrent - // submit therefore queues instead of double-dispatching. - const dispatchGeneration = - reservedDispatchGeneration ?? beginTurnDispatch(sessionId); - - beginOptimisticTurn(sessionId); - - try { - await SessionService.sendMessage({ - sessionId, + return dispatchUserIntent({ + sessionId, + visibleText, + imageDataUrls, + runtimeStatusSource, + beforeAppend, + send: { content, displayText, model, accountId, mode: agentExecMode, - imageDataUrls, clientMessageId, turnIntentId, turnIntentSource: "user_submit", directUserIntent: true, - }); - // Backend accepted the message — the turn is running even if the - // provider's running ack has not been observed yet. - confirmTurnRunning(sessionId); - // Bump the row's `updated_at` to "now" so the sidebar / - // Kanban "recent activity" views float this session to the - // top immediately. The backend's authoritative timestamp - // lands on the next session list refresh and overwrites - // this — see `markSessionActive` doc for the policy. - markSessionActive(sessionId); - if (isCursorIdeSession(sessionId)) { - // Cursor IDE sessions have no turn lifecycle (the CDP stream has no - // terminal event) — close the turn right after a successful handoff. - setSessionRuntimeStatus({ - sessionId, - status: "idle", - source: "dispatch", - }); - markTurnTerminal(sessionId, "completed", { - generation: dispatchGeneration, - }); - } - } catch (err) { - // IPC failed before Rust even received the message — reset so the UI - // does not stay stuck in the optimistic "running" state. - failOptimisticTurn(sessionId); - markTurnTerminal(sessionId, "failed", { - generation: dispatchGeneration, - }); - throw err; - } + }, + }); }, - [setSessionRuntimeStatus] + [] ); - return { - addUserMessage, - dispatchMessageBySessionType, - }; + return { dispatchMessageBySessionType }; } diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts index 5b5bbfca99..2a5660d988 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useSessionActions.ts @@ -122,11 +122,14 @@ export function shouldRestoreStoppedUserMessage(options: { } interface UseSessionActionsOptions { - getSessionId: () => string | null; + getControlSessionId: () => string | null; + getQueueSessionId: () => string | null; + restoreStoppedMessage: boolean; } export function useSessionActions(options: UseSessionActionsOptions) { - const { getSessionId } = options; + const { getControlSessionId, getQueueSessionId, restoreStoppedMessage } = + options; const { t } = useTranslation("sessions"); const store = useStore(); const setPendingCancel = useSetAtom(isPendingCancelAtom); @@ -138,7 +141,7 @@ export function useSessionActions(options: UseSessionActionsOptions) { }, []); const resumeSession = useCallback(async () => { - const sessionId = getSessionId(); + const sessionId = getControlSessionId(); if (!sessionId) { Message.error(t("errors.noSessionIdFound")); return; @@ -160,7 +163,7 @@ export function useSessionActions(options: UseSessionActionsOptions) { failOptimisticTurn(sessionId); Message.error(t("errors.failedToResume")); } - }, [getSessionId, t]); + }, [getControlSessionId, t]); /** * Interrupt the current turn (user Stop). @@ -168,29 +171,34 @@ export function useSessionActions(options: UseSessionActionsOptions) { * Send Now interrupts are NOT routed here — the queue dispatcher issues its * own "force-send" timeline boundary. * - * Stop is an O(1) timeline boundary: it updates local runtime state, restores - * the click-time prompt to the composer, and signals Rust cancellation. It - * must not read/repair DB history or scan/mutate the EventStore. + * Stop is an O(1) timeline boundary: it updates local runtime state and + * signals Rust cancellation. Ordinary sessions may restore an unrendered + * click-time prompt; canonical conversations already own a durable user row, + * so their hidden execution episode must never restore a duplicate prompt. + * The boundary must not read/repair DB history or scan/mutate the EventStore. */ const interruptSession = useCallback(async () => { - const sessionId = getSessionId(); + const sessionId = getControlSessionId(); if (!sessionId) { log.error("[useSessionActions] No session ID found for interrupt"); return; } - beginStopBoundary(sessionId); + const queueSessionId = getQueueSessionId() ?? sessionId; + beginStopBoundary(sessionId, { queueSessionId }); setSessionRolledBack(false); const pendingSyntheticEvent = store.get(pendingSyntheticEventAtom); - const currentUserMessage = resolveRestorableUserMessage({ - lastUserMessage: store.get(lastUserMessageAtom), - pendingDisplayText: - pendingSyntheticEvent?.source === "user" - ? pendingSyntheticEvent.displayText - : undefined, - pendingImages: pendingSyntheticEvent?.result?.images, - }); + const currentUserMessage = restoreStoppedMessage + ? resolveRestorableUserMessage({ + lastUserMessage: store.get(lastUserMessageAtom), + pendingDisplayText: + pendingSyntheticEvent?.source === "user" + ? pendingSyntheticEvent.displayText + : undefined, + pendingImages: pendingSyntheticEvent?.result?.images, + }) + : null; const restorableMessage = currentUserMessage; if ( @@ -237,6 +245,7 @@ export function useSessionActions(options: UseSessionActionsOptions) { }, 10_000); await cancelTurnForTimelineBoundary(sessionId, "stop", { + queueSessionId, onError: (msg: string) => { Message.error(t(msg)); setPendingCancel(false); @@ -250,9 +259,15 @@ export function useSessionActions(options: UseSessionActionsOptions) { forceTurnIdle(sessionId); }, }); - })(); + })().catch((error: unknown) => { + log.error("Stop request failed", error); + setPendingCancel(false); + Message.error(String(error)); + }); }, [ - getSessionId, + getControlSessionId, + getQueueSessionId, + restoreStoppedMessage, setPendingCancel, setRestoreToInput, setSessionRolledBack, diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts index c0011c15d5..cb13266a72 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts @@ -14,30 +14,31 @@ import { const SESSION_ID = "agent-builtin:sde-worker-intervention"; const mocks = vi.hoisted(() => ({ - addUserMessage: vi.fn(), + appendProjection: vi.fn(), beginOptimisticTurn: vi.fn(), - beginTurnDispatch: vi.fn(), dispatchMessageBySessionType: vi.fn(), - failOptimisticTurn: vi.fn(), + flushQueue: vi.fn(), getTurnPhase: vi.fn(), - markTurnTerminal: vi.fn(), mintTurnIntentId: vi.fn(), - removeByIdPrefix: vi.fn(), + removeProjection: vi.fn(), })); vi.mock("@src/engines/SessionCore/control/optimisticTurnStatus", () => ({ beginOptimisticTurn: mocks.beginOptimisticTurn, - failOptimisticTurn: mocks.failOptimisticTurn, })); vi.mock("@src/engines/SessionCore/control/turnLifecycle", () => ({ - beginTurnDispatch: mocks.beginTurnDispatch, getTurnPhase: mocks.getTurnPhase, - markTurnTerminal: mocks.markTurnTerminal, })); -vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ - eventStoreProxy: { removeByIdPrefix: mocks.removeByIdPrefix }, +vi.mock( + "@src/engines/SessionCore/hooks/session/messageQueuePersistence", + () => ({ flushMessageQueuePersistence: mocks.flushQueue }) +); + +vi.mock("@src/engines/SessionCore/services/userIntentDispatch", () => ({ + appendOptimisticQueueUserDelivery: mocks.appendProjection, + removeOptimisticQueueUserDelivery: mocks.removeProjection, })); vi.mock("@src/engines/SessionCore/sync/adapters/shared/eventFactories", () => ({ @@ -55,7 +56,6 @@ vi.mock("@src/hooks/logger", () => ({ vi.mock("./useMessageDispatch", () => ({ useMessageDispatch: () => ({ - addUserMessage: mocks.addUserMessage, dispatchMessageBySessionType: mocks.dispatchMessageBySessionType, }), })); @@ -78,31 +78,27 @@ function renderSubmitHook(store: ReturnType) { describe("useUserIntentSubmit Agent Org intervention", () => { beforeEach(() => { - mocks.addUserMessage.mockReset().mockResolvedValue("synthetic-user-1"); + mocks.appendProjection.mockReset().mockResolvedValue(undefined); mocks.beginOptimisticTurn.mockReset(); - mocks.beginTurnDispatch.mockReset().mockReturnValue(7); mocks.dispatchMessageBySessionType.mockReset().mockResolvedValue(undefined); - mocks.failOptimisticTurn.mockReset(); + mocks.flushQueue.mockReset().mockResolvedValue(undefined); mocks.getTurnPhase.mockReset().mockReturnValue("idle"); - mocks.markTurnTerminal.mockReset(); mocks.mintTurnIntentId.mockReset().mockReturnValue("turn-intent-1"); - mocks.removeByIdPrefix.mockReset().mockResolvedValue(1); + mocks.removeProjection.mockReset().mockResolvedValue(undefined); }); - it("appends the direct user event before dispatching the same intent", async () => { + it("routes the direct turn through the shared user-intent dispatcher", async () => { const submit = renderSubmitHook(createStore()); await submit({ sessionId: SESSION_ID, displayContent: "hello worker" }); - expect(mocks.addUserMessage).toHaveBeenCalledWith( - SESSION_ID, - "hello worker", - undefined, - "turn-intent-1" - ); - expect(mocks.dispatchMessageBySessionType).toHaveBeenCalledOnce(); - expect(mocks.addUserMessage.mock.invocationCallOrder[0]).toBeLessThan( - mocks.dispatchMessageBySessionType.mock.invocationCallOrder[0] + expect(mocks.dispatchMessageBySessionType).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION_ID, + content: "hello worker", + visibleText: "hello worker", + turnIntentId: "turn-intent-1", + }) ); }); @@ -143,7 +139,185 @@ describe("useUserIntentSubmit Agent Org intervention", () => { expect(mocks.dispatchMessageBySessionType).not.toHaveBeenCalled(); }); - it("removes the optimistic user event and rejects when backend dispatch fails", async () => { + it("admits canonical continuation through the same queue owner and durability barrier", async () => { + const store = createStore(); + const submit = renderSubmitHook(store); + const conversationDispatch = { + kind: "canonical_conversation" as const, + root: { + authority: "local-session" as const, + authorityScope: [], + conversationId: "canonical-root", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }; + mocks.flushQueue.mockImplementationOnce(async () => { + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + conversationDispatch, + requiresExplicitDispatch: true, + priority: "next", + }), + ]); + expect(mocks.appendProjection).not.toHaveBeenCalled(); + }); + + await submit({ + sessionId: SESSION_ID, + displayContent: "continue natively", + conversationDispatch, + }); + + const [queued] = store.get(messageQueueAtom); + expect(queued).toMatchObject({ + conversationDispatch, + content: "continue natively", + displayContent: "continue natively", + }); + expect(queued?.requiresExplicitDispatch).toBeUndefined(); + expect(mocks.appendProjection).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + visibleText: "continue natively", + imageDataUrls: undefined, + turnIntentId: "turn-intent-1", + queueMessageId: queued?.id, + createdAt: queued?.createdAt, + }); + expect(mocks.dispatchMessageBySessionType).not.toHaveBeenCalled(); + }); + + it("rolls canonical admission back when its durable owner cannot commit", async () => { + const store = createStore(); + const submit = renderSubmitHook(store); + mocks.flushQueue + .mockRejectedValueOnce(new Error("delivery store unavailable")) + .mockResolvedValueOnce(undefined); + + await expect( + submit({ + sessionId: SESSION_ID, + displayContent: "keep my draft", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "canonical-root", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + }) + ).rejects.toThrow("delivery store unavailable"); + + expect(store.get(messageQueueAtom)).toEqual([]); + expect(mocks.appendProjection).not.toHaveBeenCalled(); + expect(mocks.flushQueue).toHaveBeenCalledTimes(2); + }); + + it("removes the durable canonical owner after a provable projection rollback", async () => { + const store = createStore(); + const submit = renderSubmitHook(store); + mocks.appendProjection.mockRejectedValueOnce( + new Error("event store unavailable") + ); + + await expect( + submit({ + sessionId: SESSION_ID, + displayContent: "keep this draft too", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "canonical-root", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + }) + ).rejects.toThrow("event store unavailable"); + + expect(mocks.removeProjection).toHaveBeenCalledOnce(); + expect(store.get(messageQueueAtom)).toEqual([]); + expect(mocks.flushQueue).toHaveBeenCalledTimes(2); + }); + + it("retains the canonical hold when projection rollback is not provable", async () => { + const store = createStore(); + const submit = renderSubmitHook(store); + mocks.appendProjection.mockRejectedValueOnce(new Error("append uncertain")); + mocks.removeProjection.mockRejectedValueOnce(new Error("store offline")); + + await expect( + submit({ + sessionId: SESSION_ID, + displayContent: "never orphan this", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "canonical-root", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + }) + ).rejects.toThrow("append uncertain"); + + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + requiresExplicitDispatch: true, + priority: "next", + }), + ]); + expect(mocks.flushQueue).toHaveBeenCalledOnce(); + }); + + it("releases a post-Stop canonical turn with the existing now priority", async () => { + const store = createStore(); + store.set(postStopDispatchSessionsAtom, { [SESSION_ID]: true }); + const submit = renderSubmitHook(store); + + await submit({ + sessionId: SESSION_ID, + displayContent: "continue after stop", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "canonical-root", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + }); + + const [queued] = store.get(messageQueueAtom); + expect(queued).toEqual(expect.objectContaining({ priority: "now" })); + expect(queued?.requiresExplicitDispatch).toBeUndefined(); + }); + + it("does not run a second optimistic-row cleanup when dispatch fails", async () => { const submit = renderSubmitHook(createStore()); mocks.dispatchMessageBySessionType.mockRejectedValue( new Error("backend send unavailable") @@ -153,10 +327,6 @@ describe("useUserIntentSubmit Agent Org intervention", () => { submit({ sessionId: SESSION_ID, displayContent: "retry me" }) ).rejects.toThrow("backend send unavailable"); - expect(mocks.addUserMessage).toHaveBeenCalledOnce(); - expect(mocks.removeByIdPrefix).toHaveBeenCalledWith( - "synthetic-user-1", - SESSION_ID - ); + expect(mocks.dispatchMessageBySessionType).toHaveBeenCalledOnce(); }); }); diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts index be54c25282..8eb212a39d 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts @@ -11,21 +11,16 @@ import { useCallback, useEffect } from "react"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { resolveSessionAgentExecMode } from "@src/config/sessionCreatorConfig"; +import { getTurnPhase } from "@src/engines/SessionCore/control/turnLifecycle"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { flushMessageQueuePersistence } from "@src/engines/SessionCore/hooks/session/messageQueuePersistence"; import { - beginOptimisticTurn, - failOptimisticTurn, -} from "@src/engines/SessionCore/control/optimisticTurnStatus"; -import { publishTurnIntentDispatch } from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; -import { - beginTurnDispatch, - getTurnPhase, - markTurnTerminal, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; + appendOptimisticQueueUserDelivery, + removeOptimisticQueueUserDelivery, +} from "@src/engines/SessionCore/services/userIntentDispatch"; import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { type SessionRuntimeStatusSource, - closePostStopDispatchEpisodeAtom, isSessionActiveAtom, lastUserMessageAtom, postStopDispatchSessionsAtom, @@ -33,9 +28,9 @@ import { import { creatorDefaultModelSelectionAtom } from "@src/store/session/creatorDefaultModelAtom"; import { sessionMapAtom } from "@src/store/session/sessionAtom"; import { + clearQueuedMessagesAtom, enqueueMessageAtom, messageQueueAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; @@ -79,11 +74,12 @@ export interface SubmitUserIntentOptions { source?: SessionRuntimeStatusSource; applyStopSubmitGuards?: boolean; dedupeDirectSubmit?: boolean; - clearUserInitiatedCancelOnQueue?: boolean; onQueued?: () => void; onBeforeDirectDispatch?: () => void; /** Stable caller-owned identity for observing a queued/direct dispatch. */ turnIntentId?: string; + /** Route this intent through the existing canonical queue dispatcher. */ + conversationDispatch?: QueuedConversationDispatch; } interface UseUserIntentSubmitOptions { @@ -95,13 +91,8 @@ export function useUserIntentSubmit({ }: UseUserIntentSubmitOptions) { const store = useStore(); const isSessionActive = useAtomValue(isSessionActiveAtom); - const enqueueMessage = useSetAtom(enqueueMessageAtom); - const setQueueFlushRequest = useSetAtom(queueFlushRequestAtom); const setLastUserMessage = useSetAtom(lastUserMessageAtom); - const closePostStopDispatchEpisode = useSetAtom( - closePostStopDispatchEpisodeAtom - ); - const { addUserMessage, dispatchMessageBySessionType } = useMessageDispatch(); + const { dispatchMessageBySessionType } = useMessageDispatch(); useEffect(() => { if (!isSessionActive) { @@ -119,10 +110,10 @@ export function useUserIntentSubmit({ source = "dispatch", applyStopSubmitGuards = false, dedupeDirectSubmit = false, - clearUserInitiatedCancelOnQueue = false, onQueued, onBeforeDirectDispatch, turnIntentId: providedTurnIntentId, + conversationDispatch, }: SubmitUserIntentOptions): Promise => { const sessionId = explicitSessionId ?? getSessionId(); if (!sessionId) { @@ -177,6 +168,7 @@ export function useUserIntentSubmit({ message.sessionId === sessionId && !message.requiresExplicitDispatch ); const shouldEnqueue = + conversationDispatch !== undefined || explicitPostStopSubmit || getTurnPhase(sessionId) !== "idle" || hasQueuedNaturalSibling; @@ -194,8 +186,10 @@ export function useUserIntentSubmit({ session?.agentExecMode ); - const queueResult = enqueueMessage({ - id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const createdAt = new Date().toISOString(); + const message = { + id, turnIntentId, sessionId, content: contentForAgent, @@ -203,10 +197,24 @@ export function useUserIntentSubmit({ imageDataUrls, modelSelection: snapshotSelection ?? undefined, agentExecMode: snapshotMode, + conversationDispatch, priority: explicitPostStopSubmit ? "now" : "next", status: "queued", - createdAt: new Date().toISOString(), - }); + createdAt, + } as const; + // Canonical continuation can spend seconds validating/materializing a + // native transcript. Stage that row behind the existing explicit hold + // until both the durable queue owner and its optimistic EventStore + // projection exist. Ordinary Session queue admission remains the same + // single enqueue below without this additional durability barrier. + const admittedMessage = conversationDispatch + ? { + ...message, + priority: "next" as const, + requiresExplicitDispatch: true, + } + : message; + const queueResult = store.set(enqueueMessageAtom, admittedMessage); if (queueResult !== "enqueued" && queueResult !== "duplicate") { throw new Error( queueResult === "message_too_large" @@ -214,14 +222,52 @@ export function useUserIntentSubmit({ : "Message queue is full; send or remove a queued message first" ); } - if (clearUserInitiatedCancelOnQueue && explicitPostStopSubmit) { - closePostStopDispatchEpisode(sessionId); + if (queueResult === "duplicate") { + onQueued?.(); + return; } - if (explicitPostStopSubmit) { - setQueueFlushRequest((requestId) => requestId + 1); - } - if (!explicitPostStopSubmit) { - beginOptimisticTurn(sessionId, "queue"); + if (conversationDispatch) { + let durableOwnerCommitted = false; + try { + await flushMessageQueuePersistence(store); + durableOwnerCommitted = true; + await appendOptimisticQueueUserDelivery({ + sessionId, + visibleText: displayContent, + imageDataUrls, + turnIntentId, + queueMessageId: id, + createdAt, + }); + } catch (error) { + if (!durableOwnerCommitted) { + store.set(clearQueuedMessagesAtom, [id]); + await flushMessageQueuePersistence(store).catch(() => undefined); + } else { + // Roll back in inverse order. If EventStore cannot prove the + // projection absent, retain the held durable owner; hydration + // can reconcile it without ever manufacturing another send. + const projectionRemoved = await removeOptimisticQueueUserDelivery( + { sessionId, queueMessageId: id } + ) + .then(() => true) + .catch(() => false); + if (projectionRemoved) { + store.set(clearQueuedMessagesAtom, [id]); + await flushMessageQueuePersistence(store).catch( + () => undefined + ); + } + } + throw error; + } + // Release the same row to the existing queue coordinator only after + // its recovery owner and visible user projection are durable. + store.set(messageQueueAtom, (queue) => + queue.map((candidate) => + candidate.id === id ? message : candidate + ) + ); } onQueued?.(); return; @@ -232,71 +278,33 @@ export function useUserIntentSubmit({ displayContent, imageDataUrls: restoreImageDataUrls, }); - const dispatchGeneration = beginTurnDispatch(sessionId); - publishTurnIntentDispatch(turnIntentId, { - sessionId, - generation: dispatchGeneration, - }); - beginOptimisticTurn(sessionId, source); if (dedupeDirectSubmit) { sharedSubmitGuard.current = true; sharedSubmitPayload.current = submitPayloadKey; } - let userEventId: string | null = null; - let dispatchStarted = false; try { - onBeforeDirectDispatch?.(); - userEventId = await addUserMessage( - sessionId, - displayContent, - imageDataUrls, - turnIntentId - ); const displayTextForDispatch = contentForAgent !== displayContent ? displayContent : undefined; - dispatchStarted = true; - await dispatchMessageBySessionType( + await dispatchMessageBySessionType({ sessionId, - contentForAgent, + content: contentForAgent, + visibleText: displayContent, imageDataUrls, - undefined, - displayTextForDispatch, - `direct:${sessionId}:${stableSubmitHash(submitPayloadKey)}`, + displayText: displayTextForDispatch, + clientMessageId: `direct:${sessionId}:${stableSubmitHash(submitPayloadKey)}`, turnIntentId, - dispatchGeneration - ); + runtimeStatusSource: source, + beforeAppend: onBeforeDirectDispatch, + }); } catch (error) { if (dedupeDirectSubmit) { sharedSubmitGuard.current = false; sharedSubmitPayload.current = null; } - if (!dispatchStarted) { - failOptimisticTurn(sessionId, source); - markTurnTerminal(sessionId, "failed", { - generation: dispatchGeneration, - }); - } - if (userEventId) { - try { - await eventStoreProxy.removeByIdPrefix(userEventId, sessionId); - } catch { - // Preserve the original dispatch error. A failed cleanup must not - // turn an already-failed submit into a misleading success. - } - } throw error; } }, - [ - addUserMessage, - closePostStopDispatchEpisode, - dispatchMessageBySessionType, - enqueueMessage, - getSessionId, - setLastUserMessage, - setQueueFlushRequest, - store, - ] + [dispatchMessageBySessionType, getSessionId, setLastUserMessage, store] ); } diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.test.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.test.ts new file mode 100644 index 0000000000..4723edfd58 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveWorkspaceChatControlSessionId, + resolveWorkspaceChatEffectiveSessionId, + shouldRestoreWorkspaceStoppedMessage, +} from "./useWorkspaceChat"; + +describe("resolveWorkspaceChatControlSessionId", () => { + it("targets the hidden native runner without changing the canonical message session", () => { + const canonicalSessionId = "codexapp-source"; + + expect( + resolveWorkspaceChatControlSessionId( + "cliagent-native-runner", + canonicalSessionId + ) + ).toBe("cliagent-native-runner"); + expect(canonicalSessionId).toBe("codexapp-source"); + }); + + it("falls back to the ordinary session when there is no runner", () => { + expect( + resolveWorkspaceChatControlSessionId(null, "cliagent-ordinary") + ).toBe("cliagent-ordinary"); + }); +}); + +describe("resolveWorkspaceChatEffectiveSessionId", () => { + it("preserves SideChat's explicit Stop scope without creating an implicit message target", () => { + expect( + resolveWorkspaceChatEffectiveSessionId( + "cliagent-side-chat", + true, + "cliagent-unrelated-active", + "cliagent-unrelated-pipeline" + ) + ).toBe("cliagent-side-chat"); + expect( + resolveWorkspaceChatEffectiveSessionId( + null, + true, + "cliagent-unrelated-active", + "cliagent-unrelated-pipeline" + ) + ).toBeNull(); + }); +}); + +describe("shouldRestoreWorkspaceStoppedMessage", () => { + it("keeps ordinary Stop restore but skips a canonical hidden runner", () => { + expect(shouldRestoreWorkspaceStoppedMessage(null)).toBe(true); + expect(shouldRestoreWorkspaceStoppedMessage("cliagent-native-runner")).toBe( + false + ); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts index 89b0d0da16..721438c7de 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts @@ -34,10 +34,41 @@ const log = createLogger("useWorkspaceChat"); interface UseWorkspaceChatOptions { sessionId?: string; sessionScope?: "active" | "none"; + /** Native execution episode controlled by Stop/Resume; submits stay canonical. */ + controlSessionId?: string | null; +} + +export function resolveWorkspaceChatControlSessionId( + controlSessionId: string | null | undefined, + messageSessionId: string | null +): string | null { + return controlSessionId ?? messageSessionId; +} + +export function shouldRestoreWorkspaceStoppedMessage( + controlSessionId: string | null | undefined +): boolean { + return controlSessionId == null; +} + +export function resolveWorkspaceChatEffectiveSessionId( + controlSessionId: string | null | undefined, + isSessionless: boolean, + resolvedSessionId: string | null | undefined, + coreSessionId: string | null | undefined +): string | null { + return ( + controlSessionId ?? + (isSessionless ? null : resolvedSessionId || coreSessionId || null) + ); } const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { - const { sessionId: propSessionId, sessionScope = "active" } = options; + const { + sessionId: propSessionId, + sessionScope = "active", + controlSessionId, + } = options; const { t } = useTranslation("sessions"); const [searchParams] = useSearchParams(); @@ -92,6 +123,11 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { activeSessionId, workstationActiveSessionId, ]); + const getControlSessionId = useCallback( + (): string | null => + resolveWorkspaceChatControlSessionId(controlSessionId, getSessionId()), + [controlSessionId, getSessionId] + ); // ============================================ // Sub-hooks @@ -99,7 +135,12 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { const submitUserIntent = useUserIntentSubmit({ getSessionId }); const { resumeSession, interruptSession, stopSession } = useSessionActions({ - getSessionId, + getControlSessionId, + getQueueSessionId: getSessionId, + // A canonical user row is already durable in the conversation plane. + // Restoring it into the hidden native episode would create a duplicate. + restoreStoppedMessage: + shouldRestoreWorkspaceStoppedMessage(controlSessionId), }); // ============================================ @@ -139,7 +180,6 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { source: "dispatch", applyStopSubmitGuards: true, dedupeDirectSubmit: true, - clearUserInitiatedCancelOnQueue: true, onQueued: () => setSessChatInput(""), onBeforeDirectDispatch: () => setSessChatInput(""), }); @@ -185,9 +225,15 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { // ============================================ // Derived State // ============================================ - const effectiveSessionId = isSessionless - ? null - : resolvedSessionId || coreSessionId; + // Sessionless composers intentionally have no implicit message target, but + // an explicitly mounted control target (for example SideChat) still owns + // Stop/Resume. Do not discard that explicit scope with the message scope. + const effectiveSessionId = resolveWorkspaceChatEffectiveSessionId( + controlSessionId, + isSessionless, + resolvedSessionId, + coreSessionId + ); const canStopAgent = useMemo( () => diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index ce3b8fe5c8..d4c7aee182 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -80,6 +80,7 @@ import { useChatPanelNavigationActions } from "./hooks/useChatPanelNavigationAct import { useChatPanelResize } from "./hooks/useChatPanelResize"; import { useChatPanelSessionModals } from "./hooks/useChatPanelSessionModals"; import { useChatPanelTabsController } from "./hooks/useChatPanelTabsController"; +import { useConversationTargetBinding } from "./hooks/useConversationTargetBinding"; import { usePanelTitle } from "./hooks/usePanelTitle"; import { useSessionSwipeNavigation } from "./hooks/useSessionSwipeNavigation"; import { useSessionViewMode } from "./hooks/useSessionViewMode"; @@ -106,6 +107,9 @@ const ChatPanel: React.FC = memo( const shouldOffsetHeaderForCollapsedSidebar = useShouldOffsetChatPanelHeader({ position, useExternalWidth }); const { currentSessionId, currentSession, panelTitle } = usePanelTitle(); + const conversationTargetBinding = useConversationTargetBinding( + currentSessionId ?? null + ); const activeSession = currentSession ?? undefined; const humanSessionActive = currentSession?.category === "human_session" || @@ -359,6 +363,7 @@ const ChatPanel: React.FC = memo( chatPanelPosition={position} copyEventJsonLabel={copyEventJsonLabel} currentSessionId={currentSessionId ?? null} + appOpenSessionId={conversationTargetBinding?.appOpenSessionId ?? null} displayMode={displayMode} eventsLength={eventCount} handleChatFocusToggle={handleChatFocusToggle} @@ -449,9 +454,9 @@ const ChatPanel: React.FC = memo( const chatColumn = ( { expect(interruptSpy).not.toHaveBeenCalled(); }); + it("parks the canonical queue while interrupting a hidden runner", () => { + beginStopBoundary("cliagent-runner", { + queueSessionId: "codexapp-source", + }); + + expect( + storeSetSpy.mock.calls.some( + ([target, value]) => + target.debugLabel === "openPostStopDispatchEpisode" && + value === "codexapp-source" + ) + ).toBe(true); + expect( + storeSetSpy.mock.calls.some( + ([target, value]) => + target.debugLabel === "parkSessionQueuedMessagesAfterStopAtom" && + value === "codexapp-source" + ) + ).toBe(true); + expect(markStoppedSpy).toHaveBeenCalledWith("cliagent-runner"); + }); + it("deduplicates concurrent Stop interrupts for the same session", async () => { let resolveInterrupt!: () => void; interruptSpy.mockImplementationOnce( diff --git a/src/engines/SessionCore/control/__tests__/turnLifecycle.test.ts b/src/engines/SessionCore/control/__tests__/turnLifecycle.test.ts index 016dd4564f..db6723a445 100644 --- a/src/engines/SessionCore/control/__tests__/turnLifecycle.test.ts +++ b/src/engines/SessionCore/control/__tests__/turnLifecycle.test.ts @@ -12,6 +12,7 @@ import { markTurnRunning, markTurnTerminal, resetTurnLifecycleForTests, + restoreTurnWorkingAfterInterruptFailure, } from "../turnLifecycle"; const SESSION = "session-1"; @@ -167,6 +168,33 @@ describe("turnLifecycle", () => { expect(getTurnPhase(SESSION)).toBe("idle"); }); + it("restores the same running generation when the interrupt transport fails", () => { + beginTurnDispatch(SESSION); + markTurnRunning(SESSION); + const generation = getTurnGeneration(SESSION); + beginTurnStopping(SESSION); + + restoreTurnWorkingAfterInterruptFailure(SESSION, { generation }); + + expect(getTurnPhase(SESSION)).toBe("working"); + vi.advanceTimersByTime(10_000); + expect(getTurnPhase(SESSION)).toBe("working"); + }); + + it("does not revive an idle or newer turn after a stale interrupt failure", () => { + beginTurnDispatch(SESSION); + markTurnRunning(SESSION); + const staleGeneration = getTurnGeneration(SESSION); + beginTurnStopping(SESSION); + forceTurnIdle(SESSION); + + restoreTurnWorkingAfterInterruptFailure(SESSION, { + generation: staleGeneration, + }); + + expect(getTurnPhase(SESSION)).toBe("idle"); + }); + it("dead-man does not fire after the phase already resolved", () => { beginTurnDispatch(SESSION); markTurnRunning(SESSION); diff --git a/src/engines/SessionCore/control/sessionTimelineBoundary.ts b/src/engines/SessionCore/control/sessionTimelineBoundary.ts index 2d2279557b..c66e5c0b96 100644 --- a/src/engines/SessionCore/control/sessionTimelineBoundary.ts +++ b/src/engines/SessionCore/control/sessionTimelineBoundary.ts @@ -26,7 +26,7 @@ import { hasLiveSubagentJobs, subagentJobMapAtom, } from "@src/store/session/subagentJobAtom"; -import { holdSessionQueueForStopAtom } from "@src/store/ui/messageQueueAtom"; +import { parkSessionQueuedMessagesAfterStopAtom } from "@src/store/ui/messageQueueAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { streamingDeltaContentAtom } from "../core/atoms"; @@ -55,6 +55,11 @@ interface TimelineBoundaryEffect { shellKill: ShellKillScope; } +interface TimelineBoundaryScopeOptions { + /** Canonical queue owner when execution is running in a hidden episode. */ + queueSessionId?: string; +} + /** * Single source of truth for every boundary's side-effects. Mirrors the * backend's `CancelReason::boundary_effect()` struct: a new @@ -172,7 +177,8 @@ function shouldInterruptForTimelineBoundary( export function beginTimelineBoundary( sessionId: string, - reason: TimelineBoundaryReason + reason: TimelineBoundaryReason, + options: TimelineBoundaryScopeOptions = {} ): void { const store = getInstrumentedStore(); const effect = BOUNDARY_EFFECTS[reason]; @@ -190,14 +196,15 @@ export function beginTimelineBoundary( beginTurnStopping(sessionId); } + const queueSessionId = options.queueSessionId ?? sessionId; if (effect.isUserStop) { - store.set(openPostStopDispatchEpisodeAtom, sessionId); + store.set(openPostStopDispatchEpisodeAtom, queueSessionId); store.set(isPendingCancelAtom, true); // Stop parks every queued follow-up of this session: the natural drain // skips them permanently; only an explicit Send Now dispatches them. - store.set(holdSessionQueueForStopAtom, sessionId); + store.set(parkSessionQueuedMessagesAfterStopAtom, queueSessionId); } else { - store.set(closePostStopDispatchEpisodeAtom, sessionId); + store.set(closePostStopDispatchEpisodeAtom, queueSessionId); store.set(isPendingCancelAtom, false); } @@ -244,8 +251,11 @@ export function beginTimelineBoundary( }); } -export function beginStopBoundary(sessionId: string): void { - beginTimelineBoundary(sessionId, "stop"); +export function beginStopBoundary( + sessionId: string, + options: TimelineBoundaryScopeOptions = {} +): void { + beginTimelineBoundary(sessionId, "stop", options); } export function isTimelineInterruptInFlight( @@ -258,9 +268,18 @@ export function isTimelineInterruptInFlight( export async function cancelTurnForTimelineBoundary( sessionId: string, reason: TimelineBoundaryReason, - options: { onError?: (message: string) => void } = {} + options: { + onError?: (message: string) => void; + queueSessionId?: string; + } = {} ): Promise { - beginTimelineBoundary(sessionId, reason); + beginTimelineBoundary( + sessionId, + reason, + options.queueSessionId + ? { queueSessionId: options.queueSessionId } + : undefined + ); if (!shouldInterruptForTimelineBoundary(sessionId, reason)) return; const key = boundaryKey(sessionId, reason); if (interruptInFlightByBoundary.has(key)) return; diff --git a/src/engines/SessionCore/control/turnLifecycle.ts b/src/engines/SessionCore/control/turnLifecycle.ts index c9bb30c909..57516d1d80 100644 --- a/src/engines/SessionCore/control/turnLifecycle.ts +++ b/src/engines/SessionCore/control/turnLifecycle.ts @@ -56,7 +56,14 @@ export type TurnTerminalStatus = "completed" | "failed" | "cancelled"; * distinction only matters for diagnostics. */ export function toTurnTerminalStatus(status: string): TurnTerminalStatus { - if (status === "failed" || status === "error" || status === "timeout") { + if ( + status === "failed" || + status === "error" || + status === "timeout" || + status === "stale" || + status === "coalesced" || + status === "rejected" + ) { return "failed"; } if (status === "cancelled" || status === "abandoned") { @@ -187,19 +194,21 @@ export function beginTurnDispatch(sessionId: string): number { export function markTurnRunning( sessionId: string, options: { generation?: number } = {} -): void { +): boolean { const state = getState(sessionId); if ( options.generation !== undefined && options.generation !== state.generation ) { - return; + return false; } - if (state.phase === "working" || state.phase === "stopping") return; + if (state.phase === "working") return true; + if (state.phase === "stopping") return false; if (state.phase === "idle") { state.generation += 1; } transition(sessionId, state, "working"); + return true; } /** @@ -224,6 +233,28 @@ export function beginTurnStopping(sessionId: string): void { transition(sessionId, state, "stopping"); } +/** + * The interrupt transport rejected before the provider accepted a Stop. + * Restore the same generation to provider-owned work instead of waiting for + * a terminal that cannot be caused by that failed interrupt. This is the + * inverse of `beginTurnStopping`; it never opens an idle turn and cannot + * revive a newer generation. + */ +export function restoreTurnWorkingAfterInterruptFailure( + sessionId: string, + options: { generation?: number } = {} +): void { + const state = getState(sessionId); + if ( + state.phase !== "stopping" || + (options.generation !== undefined && + options.generation !== state.generation) + ) { + return; + } + transition(sessionId, state, "working"); +} + /** * Provider delivered a turn-final terminal. This is the ONLY natural way a * turn ends. @@ -236,20 +267,20 @@ export function markTurnTerminal( sessionId: string, status: TurnTerminalStatus, options: { generation?: number } = {} -): void { +): boolean { const state = getState(sessionId); if ( options.generation !== undefined && options.generation !== state.generation ) { - return; + return false; } if (state.phase === "dispatching" && options.generation === undefined) { log.warn( `[turnLifecycle] discarding unattributed "${status}" terminal for ` + `session ${sessionId} while dispatching (generation ${state.generation})` ); - return; + return false; } state.lastTerminal = { generation: state.generation, @@ -261,6 +292,7 @@ export function markTurnTerminal( } else { bumpSignal(); } + return true; } /** diff --git a/src/engines/SessionCore/conversations/canonicalConversationEvents.test.ts b/src/engines/SessionCore/conversations/canonicalConversationEvents.test.ts new file mode 100644 index 0000000000..baeed8a356 --- /dev/null +++ b/src/engines/SessionCore/conversations/canonicalConversationEvents.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { loadCanonicalConversationEvents } from "./canonicalConversationEvents"; + +const mocks = vi.hoisted(() => ({ + cliStatus: vi.fn(), + loadAuthoritative: vi.fn(), + getPersistedEvents: vi.fn(), + mergeInterrupted: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { status: mocks.cliStatus } }, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadAuthoritative, +})); +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { getPersistedEvents: mocks.getPersistedEvents }, +})); +vi.mock("./nativeConversationMaterializer", () => ({ + mergeInterruptedConversationProjection: mocks.mergeInterrupted, +})); + +function event(id: string): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "cliagent-test", + createdAt: "2026-08-30T00:00:00.000Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: { content: id }, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + }; +} + +describe("loadCanonicalConversationEvents", () => { + const native = [event("native")]; + const projected = [...native, event("partial")]; + const merged = [...native, event("merged-partial")]; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadAuthoritative.mockResolvedValue({ + events: native, + source: "cli_history", + }); + mocks.getPersistedEvents.mockResolvedValue(projected); + mocks.mergeInterrupted.mockReturnValue(merged); + }); + + it.each(["failed", "error", "timeout", "cancelled", "abandoned"])( + "preserves the durable partial suffix for %s terminal sessions", + async (status) => { + mocks.cliStatus.mockResolvedValue({ status }); + + await expect( + loadCanonicalConversationEvents("cliagent-test") + ).resolves.toEqual({ events: merged, source: "cli_history" }); + expect(mocks.getPersistedEvents).toHaveBeenCalledWith("cliagent-test"); + expect(mocks.mergeInterrupted).toHaveBeenCalledWith(native, projected); + } + ); + + it.each(["completed", "archived", "running", "unknown"])( + "keeps the native-only path for %s sessions", + async (status) => { + mocks.cliStatus.mockResolvedValue({ status }); + + await expect( + loadCanonicalConversationEvents("cliagent-test") + ).resolves.toEqual({ events: native, source: "cli_history" }); + expect(mocks.getPersistedEvents).not.toHaveBeenCalled(); + expect(mocks.mergeInterrupted).not.toHaveBeenCalled(); + } + ); + + it.each(["failed", "error", "timeout", "cancelled", "abandoned"])( + "recovers the durable partial projection when native history is unavailable for %s sessions", + async (status) => { + const nativeError = new Error("native transcript unavailable"); + mocks.loadAuthoritative.mockRejectedValue(nativeError); + mocks.cliStatus.mockResolvedValue({ status }); + + await expect( + loadCanonicalConversationEvents("cliagent-test") + ).resolves.toEqual({ events: merged, source: "cli_history" }); + expect(mocks.getPersistedEvents).toHaveBeenCalledWith("cliagent-test"); + expect(mocks.mergeInterrupted).toHaveBeenCalledWith([], projected); + } + ); + + it.each(["completed", "archived", "running", "unknown"])( + "fails closed when native history is unavailable for %s sessions", + async (status) => { + const nativeError = new Error("native transcript unavailable"); + mocks.loadAuthoritative.mockRejectedValue(nativeError); + mocks.cliStatus.mockResolvedValue({ status }); + + await expect( + loadCanonicalConversationEvents("cliagent-test") + ).rejects.toBe(nativeError); + expect(mocks.getPersistedEvents).not.toHaveBeenCalled(); + expect(mocks.mergeInterrupted).not.toHaveBeenCalled(); + } + ); + + it("fails closed when the EventStore fallback is unavailable", async () => { + const nativeError = new Error("native transcript unavailable"); + const eventStoreError = new Error("EventStore unavailable"); + mocks.loadAuthoritative.mockRejectedValue(nativeError); + mocks.cliStatus.mockResolvedValue({ status: "failed" }); + mocks.getPersistedEvents.mockRejectedValue(eventStoreError); + + await expect(loadCanonicalConversationEvents("cliagent-test")).rejects.toBe( + eventStoreError + ); + expect(mocks.mergeInterrupted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/conversations/canonicalConversationEvents.ts b/src/engines/SessionCore/conversations/canonicalConversationEvents.ts new file mode 100644 index 0000000000..62a7daf833 --- /dev/null +++ b/src/engines/SessionCore/conversations/canonicalConversationEvents.ts @@ -0,0 +1,63 @@ +/** + * Canonical conversation read for runtime transfer. + * + * Provider-native history remains the round-trip verification authority. A + * CLI can nevertheless be killed before its newest fork flushes; EventStore + * then owns the already-accepted user row and durable partial output. Merge + * only that provider-portable semantic suffix for continuation purposes. + */ +import { rpc } from "@src/api/tauri/rpc"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isInterruptedCliTerminalStatus } from "@src/engines/SessionCore/sync/adapters/cli/cliLifecycle"; +import { + type AuthoritativeSessionEvents, + loadAuthoritativeSessionEvents, +} from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import { mergeInterruptedConversationProjection } from "./nativeConversationMaterializer"; + +export async function loadCanonicalConversationEvents( + sessionId: string, + signal: AbortSignal = new AbortController().signal +): Promise { + let authoritative: AuthoritativeSessionEvents; + try { + authoritative = await loadAuthoritativeSessionEvents(sessionId, signal); + } catch (error) { + if (!isCliSession(sessionId) || signal.aborted) throw error; + + const status = await rpc.cli.status({ sessionId }).catch(() => null); + if (!isInterruptedCliTerminalStatus(status?.status)) throw error; + + // A killed CLI can leave its accepted user message and completed tool + // output in EventStore without ever flushing a readable native file. + // Recover only that existing portable projection; live/completed Sessions + // still require the provider-native transcript above. + const projected = await eventStoreProxy.getPersistedEvents(sessionId); + return { + events: mergeInterruptedConversationProjection([], projected), + source: "cli_history", + }; + } + if (!isCliSession(sessionId) || signal.aborted) return authoritative; + // Completed native turns have already flushed their provider transcript and + // should stay on the cheap native-only path, especially for large Sessions. + // Only a killed/failed turn can own a durable EventStore suffix that is not + // yet present in the provider file. + const status = await rpc.cli.status({ sessionId }).catch(() => null); + if (!isInterruptedCliTerminalStatus(status?.status)) { + return authoritative; + } + const projected = await eventStoreProxy + .getPersistedEvents(sessionId) + .catch(() => [] as SessionEvent[]); + return { + ...authoritative, + events: mergeInterruptedConversationProjection( + authoritative.events, + projected + ), + }; +} diff --git a/src/engines/SessionCore/conversations/conversationSenderMetadata.test.ts b/src/engines/SessionCore/conversations/conversationSenderMetadata.test.ts new file mode 100644 index 0000000000..27ef4d3442 --- /dev/null +++ b/src/engines/SessionCore/conversations/conversationSenderMetadata.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + CONVERSATION_SENDER_ARG, + conversationSenderStampOf, + resolveConversationSenderRelationship, + resolveConversationViewerState, +} from "./conversationSenderMetadata"; + +describe("conversationSenderStampOf", () => { + it("normalizes a valid provider-neutral sender stamp", () => { + expect( + conversationSenderStampOf({ + args: { + [CONVERSATION_SENDER_ARG]: { + userId: " user-1 ", + displayName: " Ada Lovelace ", + avatarUrl: " https://example.com/ada.png ", + }, + }, + }) + ).toEqual({ + userId: "user-1", + displayName: "Ada Lovelace", + avatarUrl: "https://example.com/ada.png", + }); + }); + + it("keeps a stable account id while omitting blank presentation fields", () => { + expect( + conversationSenderStampOf({ + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "user-2", + displayName: " ", + avatarUrl: "", + }, + }, + }) + ).toEqual({ userId: "user-2" }); + }); + + it("rejects unstamped and malformed metadata", () => { + expect(conversationSenderStampOf({ args: {} })).toBeNull(); + expect( + conversationSenderStampOf({ + args: { + [CONVERSATION_SENDER_ARG]: { + userId: " ", + displayName: "Invented user", + }, + }, + }) + ).toBeNull(); + }); +}); + +describe("conversation viewer ownership", () => { + it("keeps pre-hydration ownership unresolved instead of treating null as logout", () => { + const viewer = resolveConversationViewerState(null, false); + + expect(viewer).toEqual({ status: "loading" }); + expect( + resolveConversationSenderRelationship({ userId: "viewer" }, viewer) + ).toBe("unresolved"); + expect( + resolveConversationSenderRelationship({ userId: "remote" }, viewer) + ).toBe("unresolved"); + }); + + it("compares stamps only after the viewer identity hydrates", () => { + const viewer = resolveConversationViewerState(" viewer ", false); + + expect(viewer).toEqual({ status: "known", userId: "viewer" }); + expect( + resolveConversationSenderRelationship({ userId: "viewer" }, viewer) + ).toBe("viewer"); + expect( + resolveConversationSenderRelationship({ userId: "remote" }, viewer) + ).toBe("other"); + }); + + it("distinguishes a completed signed-out state from loading", () => { + const viewer = resolveConversationViewerState(null, true); + + expect(viewer).toEqual({ status: "signed_out" }); + expect( + resolveConversationSenderRelationship({ userId: "remote" }, viewer) + ).toBe("other"); + expect(resolveConversationSenderRelationship(null, viewer)).toBe( + "unstamped" + ); + }); +}); diff --git a/src/engines/SessionCore/conversations/conversationSenderMetadata.ts b/src/engines/SessionCore/conversations/conversationSenderMetadata.ts new file mode 100644 index 0000000000..88fa85cd9d --- /dev/null +++ b/src/engines/SessionCore/conversations/conversationSenderMetadata.ts @@ -0,0 +1,102 @@ +import { z } from "zod/v4"; + +import type { SessionEvent } from "../core/types"; + +/** + * Provider-neutral event metadata for a human-authored conversation row. + * + * The string is intentionally kept wire-compatible with conversation events + * already persisted by ORG2 Cloud. Providers may stamp this key, while the + * generic transcript only knows how to validate and render its contents. + */ +export const CONVERSATION_SENDER_ARG = "conversationSender"; + +export const ConversationSenderStampSchema = z + .object({ + userId: z.string().trim().min(1), + displayName: z.string().optional(), + avatarUrl: z.string().optional(), + }) + .transform(({ userId, displayName, avatarUrl }) => { + const normalizedDisplayName = displayName?.trim(); + const normalizedAvatarUrl = avatarUrl?.trim(); + return { + userId, + ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}), + ...(normalizedAvatarUrl ? { avatarUrl: normalizedAvatarUrl } : {}), + }; + }); + +/** Stable event stamp. `userId` is required so viewer ownership is exact. */ +export type ConversationSenderStamp = z.output< + typeof ConversationSenderStampSchema +>; + +/** + * Display identity after a composition layer enriches a stamp. Imported + * pre-lineage history may know only a name/avatar, so `userId` is optional + * here even though it is mandatory on newly stamped events. + */ +export interface ConversationSenderIdentity { + userId?: string; + displayName?: string; + avatarUrl?: string; +} + +/** + * Provider-neutral viewer identity lifecycle. + * + * `loading` is deliberately distinct from `signed_out`: while persisted auth + * is hydrating, a stamped local twin must keep its existing local/remote side + * instead of being reclassified as somebody else's message. + */ +export type ConversationViewerState = + | { status: "loading" } + | { status: "known"; userId: string } + | { status: "signed_out" }; + +export const CONVERSATION_VIEWER_LOADING: ConversationViewerState = { + status: "loading", +}; +export const CONVERSATION_VIEWER_SIGNED_OUT: ConversationViewerState = { + status: "signed_out", +}; + +export type ConversationSenderRelationship = + | "viewer" + | "other" + | "unresolved" + | "unstamped"; + +/** Build the viewer state without conflating a pre-hydration null with logout. */ +export function resolveConversationViewerState( + viewerUserId: string | null | undefined, + hydrationComplete: boolean +): ConversationViewerState { + const userId = viewerUserId?.trim(); + if (userId) return { status: "known", userId }; + return hydrationComplete + ? CONVERSATION_VIEWER_SIGNED_OUT + : CONVERSATION_VIEWER_LOADING; +} + +/** Compare a durable sender stamp only when viewer ownership is knowable. */ +export function resolveConversationSenderRelationship( + stampedSender: ConversationSenderStamp | null, + viewer: ConversationViewerState +): ConversationSenderRelationship { + if (!stampedSender) return "unstamped"; + if (viewer.status === "loading") return "unresolved"; + if (viewer.status === "signed_out") return "other"; + return stampedSender.userId === viewer.userId ? "viewer" : "other"; +} + +/** Read a sender stamp without trusting provider or persisted event payloads. */ +export function conversationSenderStampOf( + event: Pick | undefined +): ConversationSenderStamp | null { + const parsed = ConversationSenderStampSchema.safeParse( + event?.args?.[CONVERSATION_SENDER_ARG] + ); + return parsed.success ? parsed.data : null; +} diff --git a/src/engines/SessionCore/conversations/conversationTypes.test.ts b/src/engines/SessionCore/conversations/conversationTypes.test.ts new file mode 100644 index 0000000000..8d95685765 --- /dev/null +++ b/src/engines/SessionCore/conversations/conversationTypes.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + isConversationRootLocator, + isLocalConversationTarget, +} from "./conversationTypes"; + +describe("isLocalConversationTarget", () => { + it("accepts native CLI and ORG2 agent targets", () => { + expect( + isLocalConversationTarget({ + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "opus", + workspaceRepoPath: "/repo", + }) + ).toBe(true); + expect( + isLocalConversationTarget({ + agentDefinitionId: "agent-1", + accountId: "account-1", + model: "model-1", + }) + ).toBe(true); + }); + + it("rejects malformed durable queue targets", () => { + expect(isLocalConversationTarget({})).toBe(false); + expect(isLocalConversationTarget({ cliAgentType: "" })).toBe(false); + expect( + isLocalConversationTarget({ + cliAgentType: "codex", + agentDefinitionId: "agent-1", + }) + ).toBe(false); + expect( + isLocalConversationTarget({ + agentDefinitionId: "agent-1", + accountId: "account-1", + }) + ).toBe(false); + expect( + isLocalConversationTarget({ + cliAgentType: "codex", + workspaceRepoPath: 42, + }) + ).toBe(false); + expect( + isLocalConversationTarget({ + cliAgentType: "claude_code", + accountId: "", + }) + ).toBe(false); + expect( + isLocalConversationTarget({ + cliAgentType: "codex", + model: "", + }) + ).toBe(false); + }); +}); + +describe("isConversationRootLocator", () => { + it("rejects identities whose serialized form aliases another root", () => { + expect( + isConversationRootLocator({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).toBe(true); + expect( + isConversationRootLocator({ + authority: " local-session ", + authorityScope: [], + conversationId: "root-1", + }) + ).toBe(false); + expect( + isConversationRootLocator({ + authority: "org2-cloud", + authorityScope: [" org-1"], + conversationId: "root-1", + }) + ).toBe(false); + }); +}); diff --git a/src/engines/SessionCore/conversations/conversationTypes.ts b/src/engines/SessionCore/conversations/conversationTypes.ts new file mode 100644 index 0000000000..aa1904a182 --- /dev/null +++ b/src/engines/SessionCore/conversations/conversationTypes.ts @@ -0,0 +1,117 @@ +/** Provider/runtime selection for one writable canonical-conversation episode. */ +export interface ConversationRootLocator { + /** Adapter-owned namespace: local-session, imported-history, or org2-cloud. */ + authority: string; + /** Stable non-secret partition components. */ + authorityScope: readonly string[]; + conversationId: string; +} + +export const NATIVE_CONVERSATION_CLI_TARGETS = [ + "claude_code", + "codex", +] as const; + +export type NativeConversationCliTarget = + (typeof NATIVE_CONVERSATION_CLI_TARGETS)[number]; + +export type LocalConversationTarget = + | { + agentDefinitionId: string; + cliAgentType?: never; + accountId: string; + model: string; + workspaceRepoPath?: string | null; + } + | { + /** The external provider owns identity for provider-native execution. */ + agentDefinitionId?: never; + cliAgentType: string; + /** Undefined means the provider's ambient local CLI profile. */ + accountId?: string; + model?: string; + workspaceRepoPath?: string | null; + }; + +/** Fail closed when restoring a durable queue row from disk. */ +export function isLocalConversationTarget( + value: unknown +): value is LocalConversationTarget { + if (!value || typeof value !== "object") return false; + const target = value as Record; + const workspaceValid = + target.workspaceRepoPath === undefined || + target.workspaceRepoPath === null || + typeof target.workspaceRepoPath === "string"; + if (!workspaceValid) return false; + if (typeof target.agentDefinitionId === "string") { + return ( + target.agentDefinitionId.length > 0 && + target.cliAgentType === undefined && + typeof target.accountId === "string" && + target.accountId.length > 0 && + typeof target.model === "string" && + target.model.length > 0 + ); + } + const cliAgentType = target.cliAgentType as NativeConversationCliTarget; + const hasAccount = + typeof target.accountId === "string" && target.accountId.trim().length > 0; + const hasModel = + typeof target.model === "string" && target.model.trim().length > 0; + return ( + target.agentDefinitionId === undefined && + typeof target.cliAgentType === "string" && + NATIVE_CONVERSATION_CLI_TARGETS.includes(cliAgentType) && + ((hasAccount && hasModel) || + (cliAgentType === "claude_code" && + target.accountId === undefined && + (target.model === undefined || hasModel))) + ); +} + +export function isConversationRootLocator( + value: unknown +): value is ConversationRootLocator { + if (!value || typeof value !== "object") return false; + const root = value as Record; + return ( + typeof root.authority === "string" && + root.authority === root.authority.trim() && + root.authority.length > 0 && + root.authority.length <= 2_048 && + Array.isArray(root.authorityScope) && + root.authorityScope.length <= 16 && + root.authorityScope.every( + (part) => + typeof part === "string" && + part === part.trim() && + part.length > 0 && + part.length <= 2_048 + ) && + typeof root.conversationId === "string" && + root.conversationId === root.conversationId.trim() && + root.conversationId.length > 0 && + root.conversationId.length <= 2_048 + ); +} + +/** Stable key for queue scoping and target-memory lookup. */ +export function conversationRootKey(root: ConversationRootLocator): string { + return JSON.stringify([ + root.authority, + [...root.authorityScope], + root.conversationId, + ]); +} + +/** Provider-neutral source metadata for one canonical conversation. */ +export interface ConversationSource { + root: ConversationRootLocator; + cliAgentType?: string; + agentDefinitionId?: string; + agentDisplayName?: string; + model?: string; + initialTarget: LocalConversationTarget | null; + workspaceRepoPath: string | null; +} diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts new file mode 100644 index 0000000000..4276f64598 --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -0,0 +1,2950 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CONVERSATION_TURN_ID_ARG, + continueLocalConversation, + continueLocalConversationAfterTimelineLoad, + conversationExecutionParentId, + loadLocalConversationExecutionTargets, + localConversationRootForSession, + parseConversationExecutionParentId, + recoverLocalConversationTurn, +} from "./localConversationContinuation"; + +const mocks = vi.hoisted(() => ({ + getAgentSession: vi.fn(), + cliStatus: vi.fn(), + cliWaitForTurnTerminal: vi.fn(), + turnIntentStatus: vi.fn(), + invokeTauri: vi.fn(), + create: vi.fn(), + sendMessage: vi.fn(), + appendEvents: vi.fn(), + updateEvent: vi.fn(), + setEvents: vi.fn(), + mergeEvents: vi.fn(), + setStreaming: vi.fn(), + removeEvents: vi.fn(), + removeSyntheticUserInputs: vi.fn(), + getStoredEvents: vi.fn(), + getLatestSnapshot: vi.fn(), + subscribeSession: vi.fn(), + loadEvents: vi.fn(), + reconcileNative: vi.fn(), + recoverNativeAfterMismatch: vi.fn(), + materialize: vi.fn(), + synchronize: vi.fn(), + publishTurnIntentDispatch: vi.fn(), + getTerminal: vi.fn(), + markTerminal: vi.fn(), + beginOptimistic: vi.fn(), + failOptimistic: vi.fn(), + storeGet: vi.fn(), + storeSet: vi.fn(), +})); + +vi.mock("@src/api/tauri/agent", () => ({ getSession: mocks.getAgentSession })); +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { + cli: { + status: mocks.cliStatus, + }, + sessionCore: { + turnIntents: { + waitForTerminal: mocks.cliWaitForTurnTerminal, + status: mocks.turnIntentStatus, + }, + }, + }, +})); +vi.mock("@src/util/platform/tauri/init", () => ({ + invokeTauri: mocks.invokeTauri, +})); +vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ + SessionService: { create: mocks.create, sendMessage: mocks.sendMessage }, +})); +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + append: mocks.appendEvents, + updateById: mocks.updateEvent, + set: mocks.setEvents, + mergeEvents: mocks.mergeEvents, + setStreaming: mocks.setStreaming, + removeByIdPrefix: mocks.removeEvents, + removeSyntheticUserInputEvents: mocks.removeSyntheticUserInputs, + getEvents: mocks.getStoredEvents, + getLatestSessionSnapshot: mocks.getLatestSnapshot, + subscribeSession: mocks.subscribeSession, + }, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadEvents, +})); +vi.mock("@src/engines/SessionCore/sync/nativeTranscriptReconcile", () => ({ + reconcileNativeTranscript: mocks.reconcileNative, + recoverNativeTranscriptAfterMismatch: mocks.recoverNativeAfterMismatch, +})); +vi.mock("./nativeConversationMaterializer", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("./nativeConversationMaterializer") + >()), + materializeNativeConversation: mocks.materialize, + synchronizeNativeConversation: mocks.synchronize, +})); +vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { + const { atom } = await import("jotai"); + return { + beginTurnDispatch: vi.fn(() => 3), + getTurnGeneration: vi.fn(() => 3), + getTurnPhase: vi.fn(() => "dispatching"), + confirmTurnRunning: vi.fn(), + getLastTurnTerminal: mocks.getTerminal, + markTurnTerminal: mocks.markTerminal, + toTurnTerminalStatus: (status: string) => + status === "failed" || status === "error" || status === "timeout" + ? "failed" + : status === "cancelled" || status === "abandoned" + ? "cancelled" + : "completed", + turnLifecycleSignalAtom: atom(0), + }; +}); +vi.mock("@src/engines/SessionCore/control/optimisticTurnStatus", () => ({ + beginOptimisticTurn: mocks.beginOptimistic, + failOptimisticTurn: mocks.failOptimistic, +})); +vi.mock("@src/engines/SessionCore/control/turnIntentDispatchLifecycle", () => ({ + publishTurnIntentDispatch: mocks.publishTurnIntentDispatch, +})); +vi.mock("@src/util/core/state/instrumentedStore", () => ({ + getInstrumentedStore: () => ({ + get: mocks.storeGet, + set: mocks.storeSet, + sub: vi.fn(() => () => undefined), + }), +})); + +function event( + id: string, + source: SessionEvent["source"], + text: string, + options: { turnId?: string; sessionId?: string; createdAt?: string } = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: options.sessionId ?? "root", + createdAt: options.createdAt ?? "2026-08-26T00:00:00.000Z", + functionName: source === "user" ? "user_message" : "assistant", + uiCanonical: source === "user" ? "user_message" : "agent_message", + actionType: source === "user" ? "raw" : "assistant", + args: options.turnId ? { [CONVERSATION_TURN_ID_ARG]: options.turnId } : {}, + result: { message: { content: text, role: source }, content: text }, + source, + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function attemptTailEvent( + kind: "assistant" | "thinking" | "tool" | "plan" | "failure", + sessionId: string, + turnId: string +): SessionEvent { + const base = event(`${kind}-${turnId}`, "assistant", `${kind} side effect`, { + sessionId, + turnId, + }); + switch (kind) { + case "assistant": + return base; + case "thinking": + return { + ...base, + functionName: "llm_thinking", + uiCanonical: "thinking", + actionType: "llm_thinking_delta", + displayVariant: "thinking", + }; + case "tool": + return { + ...base, + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + callId: `call-${turnId}`, + args: { path: "/repo/README.md" }, + result: { status: "running", call_id: `call-${turnId}` }, + }; + case "plan": + return { + ...base, + functionName: "plan_update", + uiCanonical: "plan_update", + actionType: "plan_update", + displayVariant: "plan", + }; + case "failure": + return { + ...base, + functionName: "error", + uiCanonical: "error", + actionType: "error", + displayStatus: "failed", + displayVariant: "error", + result: { error: "network connection failed", success: false }, + }; + } +} + +const root = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "root-1", +}; +const target = { + agentDefinitionId: "builtin:sde", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", +}; +const codexTarget = { + cliAgentType: "codex", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", +}; + +let childEvents: SessionEvent[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + // A blocked preparation never consumes queued one-shot transport mocks. + // Do not let those implementations leak into the next test's send. + mocks.sendMessage.mockReset(); + childEvents = []; + mocks.invokeTauri.mockResolvedValue([]); + mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "native_store", + })); + mocks.reconcileNative.mockImplementation(async (sessionId: string) => { + await new Promise((resolve) => setTimeout(resolve, 25)); + return mocks + .loadEvents(sessionId) + .then((result: { events: SessionEvent[] }) => result.events); + }); + mocks.recoverNativeAfterMismatch.mockImplementation( + async ( + sessionId: string, + initialEvents: SessionEvent[], + isRecovered: (events: readonly SessionEvent[]) => boolean + ) => { + let events = initialEvents; + for (let attempt = 0; attempt < 2 && !isRecovered(events); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + events = await mocks + .loadEvents(sessionId) + .then((result: { events: SessionEvent[] }) => result.events); + } + return events; + } + ); + mocks.materialize.mockImplementation(async ({ sessionId, timeline }) => { + childEvents = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + return { + events: childEvents, + receipt: { nativeSessionId: sessionId, itemCount: childEvents.length }, + }; + }); + mocks.synchronize.mockImplementation(async ({ sessionId, timeline }) => { + childEvents = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + return { + events: childEvents, + receipt: { nativeSessionId: sessionId, itemCount: childEvents.length }, + }; + }); + mocks.appendEvents.mockResolvedValue(undefined); + mocks.updateEvent.mockResolvedValue(true); + mocks.setEvents.mockResolvedValue(undefined); + mocks.mergeEvents.mockResolvedValue(undefined); + mocks.setStreaming.mockResolvedValue(undefined); + mocks.removeEvents.mockResolvedValue(1); + mocks.removeSyntheticUserInputs.mockResolvedValue(1); + mocks.getStoredEvents.mockImplementation(async () => childEvents); + mocks.getLatestSnapshot.mockReturnValue(null); + mocks.subscribeSession.mockReturnValue(() => undefined); + mocks.storeGet.mockReturnValue(null); + mocks.sendMessage.mockImplementation( + async ({ sessionId, content, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", content, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "completed", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockImplementation( + async ({ sessionId, turnIntentId }) => ({ + sessionId, + turnIntentId, + status: "completed", + updatedAt: "2026-08-29T00:01:00.000Z", + }) + ); + mocks.turnIntentStatus.mockResolvedValue(null); +}); + +function mockCompatibleCliEpisode( + sessionId: string, + cliAgentType: "codex" | "claude_code", + timeline: SessionEvent[] +): void { + childEvents = timeline; + mocks.invokeTauri.mockResolvedValue([ + { sessionId, updatedAt: "2026-08-26T01:00:00Z" }, + ]); + mocks.cliStatus.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00Z", + repoPath: "/repo", + accountId: "account-1", + model: "model-1", + cliAgentType, + }); +} + +describe("durable execution target hydration", () => { + it("restores the newest hidden continuation child without an in-memory roster", async () => { + const localRoot = { + authority: "local-session", + authorityScope: [], + conversationId: "sdeagent-canonical-root", + } as const; + const parentSessionId = conversationExecutionParentId(localRoot); + mocks.invokeTauri.mockImplementation(async (command, args) => { + expect(command).toBe("es_get_child_sessions"); + expect(args).toEqual({ parentSessionId }); + return [ + { + sessionId: "cliagent-latest-codex", + updatedAt: "2026-09-05T09:00:00.000Z", + }, + ]; + }); + mocks.getAgentSession.mockResolvedValue({ + agentDefinitionId: "builtin:sde", + accountId: "sde-account", + model: "sde-model", + workspacePath: "/repo", + updatedAt: "2026-09-05T08:00:00.000Z", + }); + mocks.cliStatus.mockResolvedValue({ + cliAgentType: "codex", + accountId: "codex-account", + model: "gpt-5.6-sol", + repoPath: "/repo", + updatedAt: "2026-09-05T09:00:00.000Z", + }); + + await expect( + loadLocalConversationExecutionTargets(localRoot) + ).resolves.toEqual([ + { + sessionId: "cliagent-latest-codex", + updatedAt: "2026-09-05T09:00:00.000Z", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + { + sessionId: "sdeagent-canonical-root", + updatedAt: "2026-09-05T08:00:00.000Z", + target: { + agentDefinitionId: "builtin:sde", + accountId: "sde-account", + model: "sde-model", + workspaceRepoPath: "/repo", + }, + }, + ]); + }); + + it("keeps earlier runtime pairs available after multiple provider switches", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-codex-return", + updatedAt: "2026-09-05T11:00:00.000Z", + }, + { + sessionId: "cliagent-claude", + updatedAt: "2026-09-05T10:00:00.000Z", + }, + { + sessionId: "cliagent-codex-first", + updatedAt: "2026-09-05T09:00:00.000Z", + }, + ]); + mocks.cliStatus.mockImplementation(async ({ sessionId }) => ({ + cliAgentType: sessionId.includes("claude") ? "claude_code" : "codex", + accountId: sessionId.includes("claude") ? "anthropic-1" : "openai-1", + model: sessionId.includes("claude") ? "sonnet" : "gpt-5.6-sol", + repoPath: "/repo", + updatedAt: + sessionId === "cliagent-codex-return" + ? "2026-09-05T11:00:00.000Z" + : sessionId === "cliagent-claude" + ? "2026-09-05T10:00:00.000Z" + : "2026-09-05T09:00:00.000Z", + })); + + const executions = await loadLocalConversationExecutionTargets(root); + + expect(executions.map(({ sessionId }) => sessionId)).toEqual([ + "cliagent-codex-return", + "cliagent-claude", + "cliagent-codex-first", + ]); + expect(executions.map(({ target }) => target.cliAgentType)).toEqual([ + "codex", + "claude_code", + "codex", + ]); + }); +}); + +describe("local native conversation continuation", () => { + it("uses a provider-neutral, non-secret durable parent identity", () => { + expect(conversationExecutionParentId(root)).toBe( + '["org2-conversation",1,"org2-cloud",["org-1"],"root-1"]' + ); + }); + + it("round-trips the durable parent id and promotes runnable local roots", () => { + const localRoot = localConversationRootForSession( + "cliagent-local-claude", + "claude_code" + ); + expect(localRoot).toEqual({ + authority: "local-session", + authorityScope: [], + conversationId: "cliagent-local-claude", + }); + expect( + parseConversationExecutionParentId( + conversationExecutionParentId(localRoot!) + ) + ).toEqual(localRoot); + expect( + localConversationRootForSession("cliagent-cursor", "cursor_cli") + ).toEqual({ + authority: "local-session", + authorityScope: [], + conversationId: "cliagent-cursor", + }); + expect( + localConversationRootForSession( + "cliagent-lightweight-row", + undefined, + undefined + ) + ).toEqual({ + authority: "local-session", + authorityScope: [], + conversationId: "cliagent-lightweight-row", + }); + expect( + localConversationRootForSession( + "sdeagent-local-native", + undefined, + "builtin:sde" + ) + ).toEqual({ + authority: "local-session", + authorityScope: [], + conversationId: "sdeagent-local-native", + }); + expect( + localConversationRootForSession( + "sdeagent-read-only", + undefined, + undefined + ) + ).toBeNull(); + expect(parseConversationExecutionParentId("not-json")).toBeNull(); + }); + + it("keeps a failed user row when a fresh episode cannot load its timeline", async () => { + const error = new Error("canonical timeline unavailable"); + + await expect( + continueLocalConversationAfterTimelineLoad({ + root, + title: "Shared", + loadTimeline: async () => { + throw error; + }, + displayText: "switch runtime now", + target, + turnIntentId: "turn-load-failure", + }) + ).rejects.toThrow(error.message); + + expect(mocks.removeEvents).not.toHaveBeenCalled(); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.updateEvent).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-child" + ); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "failed", + expect.any(Object) + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("reveals the first imported execution before loading a large timeline", async () => { + const order: string[] = []; + mocks.create.mockImplementationOnce(async () => { + order.push("created"); + return { sessionId: "agentsession-child" }; + }); + + await continueLocalConversationAfterTimelineLoad({ + root: { + authority: "imported-history", + authorityScope: ["codex_app"], + conversationId: "codexapp-source-1", + }, + title: "Imported continuation", + loadTimeline: async () => { + order.push("timeline"); + return [event("u1", "user", "original question")]; + }, + displayText: "new request", + target, + turnIntentId: "turn-eager", + onSessionPreparing: () => { + order.push("visible"); + }, + }); + + expect(order.slice(0, 3)).toEqual(["created", "visible", "timeline"]); + expect(mocks.create).toHaveBeenCalledTimes(1); + }); + + it("keeps a materialized child recoverable when its runner receipt cannot persist", async () => { + const timeline = [event("u1", "user", "original question")]; + const receiptFailure = new QueuedConversationRecoveryPendingError( + "runner receipt unavailable" + ); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue after receipt recovery", + target, + turnIntentId: "turn-runner-receipt", + onSessionReady: () => { + throw receiptFailure; + }, + }) + ).rejects.toBe(receiptFailure); + + expect(mocks.materialize).toHaveBeenCalledTimes(1); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.updateEvent).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-child" + ); + + // A restarted queue discovers the already-materialized native child by + // canonical parent and resumes the same turn instead of creating another. + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-child", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue after receipt recovery", + target, + turnIntentId: "turn-runner-receipt", + }) + ).resolves.toEqual( + expect.objectContaining({ sessionId: "agentsession-child" }) + ); + + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.synchronize).toHaveBeenCalledTimes(1); + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + }); + + it("surfaces accepted-turn receipt failures as recovery-pending", async () => { + const persistError = new Error("queue store locked"); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "run exactly once", + target, + turnIntentId: "turn-accepted-receipt", + onTurnAccepted: () => { + throw persistError; + }, + }) + ).rejects.toMatchObject({ + name: "QueuedConversationRecoveryPendingError", + message: expect.stringContaining("queue store locked"), + }); + + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + expect(mocks.cliWaitForTurnTerminal).not.toHaveBeenCalled(); + expect(mocks.updateEvent).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-child" + ); + }); + + it("adopts a durable accepted runner through the shared turn-intent lifecycle", async () => { + const history = [event("u1", "user", "original question")]; + const currentUser = event("canonical-current", "user", "continue", { + turnId: "turn-recover-adopted", + }); + childEvents = [ + ...history.map((item) => ({ ...item, sessionId: "agentsession-child" })), + event("native-current", "user", "continue", { + sessionId: "agentsession-child", + turnId: "turn-recover-adopted", + }), + event("native-answer", "assistant", "recovered answer", { + sessionId: "agentsession-child", + turnId: "turn-recover-adopted", + }), + ]; + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-child", + updatedAt: "2026-08-30T00:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-30T00:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + mocks.turnIntentStatus.mockResolvedValue({ + sessionId: "agentsession-child", + turnIntentId: "turn-recover-adopted", + status: "completed", + updatedAt: "2026-08-30T00:00:01.000Z", + }); + + const result = await recoverLocalConversationTurn({ + root, + title: "Shared", + timeline: [...history, currentUser], + displayText: "continue", + target, + turnIntentId: "turn-recover-adopted", + runnerSessionId: "agentsession-child", + }); + + expect(result).toMatchObject({ + sessionId: "agentsession-child", + terminalStatus: "completed", + agentTail: [expect.objectContaining({ id: "native-answer" })], + }); + expect(mocks.publishTurnIntentDispatch).toHaveBeenCalledWith( + "turn-recover-adopted", + { sessionId: "agentsession-child", generation: 3 } + ); + expect(mocks.beginOptimistic).toHaveBeenCalledWith( + "agentsession-child", + "dispatch" + ); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "completed", + { generation: 3 } + ); + expect(mocks.reconcileNative).toHaveBeenCalledWith("agentsession-child", { + preserveInterruptedSuffix: false, + }); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("restores provider acceptance before validating a restarted runner", async () => { + mocks.turnIntentStatus.mockResolvedValue({ + sessionId: "agentsession-missing", + turnIntentId: "turn-recover-receipt", + status: "running", + updatedAt: "2026-08-30T00:00:01.000Z", + }); + // Candidate discovery no longer contains the accepted runner. Recovery is + // blocked for inspection, but the durable backend receipt must still move + // the frontend owner across the irreversible acceptance boundary first. + mocks.invokeTauri.mockResolvedValue([]); + const onBeforeTurnDispatch = vi.fn(); + const onTurnAccepted = vi.fn(); + + await expect( + recoverLocalConversationTurn({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "continue", + target, + turnIntentId: "turn-recover-receipt", + runnerSessionId: "agentsession-missing", + onBeforeTurnDispatch, + onTurnAccepted, + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryBlockedError); + + expect(onTurnAccepted).toHaveBeenCalledOnce(); + expect(onTurnAccepted).toHaveBeenCalledWith("agentsession-missing"); + expect(onBeforeTurnDispatch).toHaveBeenCalledWith("agentsession-missing"); + expect(onBeforeTurnDispatch.mock.invocationCallOrder[0]).toBeLessThan( + onTurnAccepted.mock.invocationCallOrder[0]! + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("does not open a second source lifecycle when target launch fails", async () => { + mocks.create.mockRejectedValueOnce(new Error("OAuth refresh rejected")); + + await expect( + continueLocalConversationAfterTimelineLoad({ + root, + title: "Shared", + loadTimeline: async () => [ + event("u1", "user", "previous question"), + event("a1", "assistant", "previous answer"), + ], + displayText: "switch to Claude", + target, + turnIntentId: "turn-launch-failure", + }) + ).rejects.toThrow("OAuth refresh rejected"); + + expect(mocks.removeEvents).not.toHaveBeenCalled(); + expect(mocks.updateEvent).not.toHaveBeenCalled(); + expect(mocks.markTerminal).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("materializes native history, then sends only the new request", async () => { + mocks.storeGet.mockReturnValue("agentsession-child"); + const history = [ + event("u1", "user", "original question"), + event("a1", "assistant", "original answer"), + ]; + const queuedUser = event("queued-u2", "user", "new request", { + turnId: "turn-1", + }); + queuedUser.displayStatus = "pending"; + queuedUser.result = { + ...queuedUser.result, + turnIntentId: "turn-1", + deliveryStatus: "pending", + }; + const timeline = [...history, queuedUser]; + const agentContent = + "internal\n\nnew request"; + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "new request", + agentContent, + target, + turnIntentId: "turn-1", + }); + + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + task: "", + parentSessionId: conversationExecutionParentId(root), + }) + ); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline: history, + }); + expect(mocks.setEvents).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: agentContent, + displayText: "new request", + }) + ); + expect(mocks.appendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + sessionId: "agentsession-child", + source: "user", + displayText: "new request", + result: expect.objectContaining({ + syntheticUserInput: true, + turnIntentId: "turn-1", + }), + }), + ], + "agentsession-child" + ); + expect(result).toMatchObject({ + sessionId: "agentsession-child", + agentTail: [expect.objectContaining({ displayText: "native answer" })], + }); + }); + + it("crosses the caller acceptance boundary immediately before provider dispatch", async () => { + const order: string[] = []; + mocks.sendMessage.mockImplementationOnce(async () => { + order.push("provider"); + childEvents = [ + ...childEvents, + event("user-boundary", "user", "continue", { + sessionId: "agentsession-child", + turnId: "turn-boundary", + }), + event("answer-boundary", "assistant", "done", { + sessionId: "agentsession-child", + turnId: "turn-boundary", + }), + ]; + }); + + await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "continue", + target, + turnIntentId: "turn-boundary", + onBeforeTurnDispatch: () => { + order.push("accepted"); + }, + }); + + expect(order).toEqual(["accepted", "provider"]); + }); + + it("binds a created episode to the planning footer before materialization", async () => { + const order: string[] = []; + mocks.setEvents.mockImplementationOnce(async () => { + order.push("projection"); + }); + mocks.beginOptimistic.mockImplementation(() => { + order.push("optimistic"); + }); + mocks.appendEvents.mockImplementationOnce(async () => { + order.push("user"); + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + order.push("send"); + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-reveal-before-send", + onSessionPreparing: () => { + order.push("preparing"); + }, + onSessionReady: () => { + order.push("ready"); + }, + }); + + expect(order).toEqual([ + "optimistic", + "user", + "preparing", + "optimistic", + "projection", + "ready", + "send", + ]); + }); + + it("leaves context recovery to the native runtime and waits for its accepted anchor", async () => { + const timeline = [ + event("u1", "user", "canonical question"), + event("a1", "assistant", "canonical answer"), + ]; + const parentSessionId = conversationExecutionParentId(root); + const children = [ + { + sessionId: "cliagent-exhausted", + updatedAt: "2026-08-29T00:00:00.000Z", + }, + ]; + mocks.invokeTauri.mockImplementation(async (command, args) => { + if (command === "es_get_child_sessions") { + expect(args).toEqual({ parentSessionId }); + return children; + } + return true; + }); + mocks.cliStatus.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-29T00:00:00.000Z", + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + repoPath: "/repo", + }); + mocks.loadEvents.mockResolvedValue({ + events: timeline, + source: "native_store", + }); + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "failed", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockImplementation( + async ({ sessionId, turnIntentId }) => ({ + sessionId, + turnIntentId, + status: "failed", + updatedAt: "2026-08-29T00:03:00.000Z", + }) + ); + mocks.sendMessage.mockResolvedValue(undefined); + + await expect( + continueLocalConversation({ + root, + title: "Canonical rollover", + timeline, + displayText: "retry me once", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-context-rollover", + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "cliagent-exhausted", + allowNativeContextRecovery: true, + }) + ); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + }); + + it.each(["assistant", "thinking", "tool", "plan", "failure"] as const)( + "does not rebuild a failed attempt after $kind output", + async (kind) => { + const timeline = [ + event("u1", "user", "canonical question"), + event("a1", "assistant", "canonical answer"), + ]; + mocks.invokeTauri.mockImplementation(async (command) => + command === "es_get_child_sessions" + ? [ + { + sessionId: "cliagent-partial", + updatedAt: "2026-08-29T00:00:00.000Z", + }, + ] + : true + ); + let statusReads = 0; + mocks.cliStatus.mockImplementation(async () => { + statusReads += 1; + return statusReads === 1 + ? { + status: "completed", + updatedAt: "2026-08-29T00:00:00.000Z", + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + repoPath: "/repo", + } + : { + status: "failed", + updatedAt: "2026-08-29T00:01:00.000Z", + }; + }); + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents.length > 0 ? childEvents : timeline, + source: "native_store", + })); + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "failed", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockImplementation( + async ({ sessionId, turnIntentId }) => ({ + sessionId, + turnIntentId, + status: "failed", + updatedAt: "2026-08-29T00:02:00.000Z", + }) + ); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...timeline.map((item) => ({ ...item, sessionId })), + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + attemptTailEvent(kind, sessionId, turnIntentId), + ]; + } + ); + + const result = await continueLocalConversation({ + root, + title: "Unsafe rollover", + timeline, + displayText: "do not replay this", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: `turn-partial-${kind}`, + }); + + expect(result).toMatchObject({ + sessionId: "cliagent-partial", + terminalStatus: "failed", + agentTail: [ + expect.objectContaining({ + displayText: `${kind} side effect`, + }), + ], + }); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledTimes(1); + } + ); + + it("anchors on EventStore identity when the native transcript cannot carry it", async () => { + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + const providerUser = event( + `provider-user-${turnIntentId}`, + "user", + displayText, + { sessionId } + ); + childEvents = [ + ...childEvents, + providerUser, + event( + `provider-answer-${turnIntentId}`, + "assistant", + "native answer", + { + sessionId, + } + ), + ]; + } + ); + mocks.getStoredEvents.mockImplementationOnce(async () => + childEvents.map((item) => + item.id === "provider-user-turn-result-anchor" + ? { + ...item, + result: { + ...item.result, + turnIntentId: "turn-result-anchor", + }, + } + : item + ) + ); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + target, + turnIntentId: "turn-result-anchor", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ + id: "convturn-turn-result-anchor-native-start", + actionType: "task_start", + createdAt: "2026-08-26T00:00:00.000Z", + }), + expect.objectContaining({ + id: "provider-answer-turn-result-anchor", + displayText: "native answer", + }), + ]); + }); + + it("anchors a provider-native suffix on the exact normalized agent payload", async () => { + let nativeEvents: SessionEvent[] = []; + const agentContent = "runtime bridge\r\n\r\nnew request"; + mocks.getLatestSnapshot.mockImplementation(() => ({ + // The rendered EventStore can remain on the pre-turn projection while + // Codex/Claude have already flushed the completed native transcript. + chatEvents: childEvents, + })); + mocks.sendMessage.mockImplementationOnce(async ({ sessionId, content }) => { + nativeEvents = [ + ...childEvents, + event("native-user", "user", content.replace(/\r\n?/g, "\n"), { + sessionId, + createdAt: "2026-09-08T07:31:49.130Z", + }), + event("native-answer", "assistant", "native suffix answer", { + sessionId, + createdAt: "2026-09-08T07:31:57.662Z", + }), + ]; + }); + mocks.loadEvents.mockImplementation(async () => ({ + events: nativeEvents.length > 0 ? nativeEvents : childEvents, + source: "native_store", + })); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + agentContent, + target, + turnIntentId: "turn-provider-native-suffix", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ + id: "convturn-turn-provider-native-suffix-native-start", + actionType: "task_start", + createdAt: "2026-09-08T07:31:49.130Z", + }), + expect.objectContaining({ + id: "native-answer", + displayText: "native suffix answer", + }), + ]); + expect(mocks.reconcileNative).toHaveBeenCalledWith("agentsession-child", { + preserveInterruptedSuffix: false, + }); + expect(mocks.recoverNativeAfterMismatch).not.toHaveBeenCalled(); + expect(mocks.loadEvents).toHaveBeenCalledTimes(1); + }); + + it("never treats a provider-added text prefix as the current user anchor", async () => { + vi.useFakeTimers(); + try { + let nativeEvents: SessionEvent[] = []; + mocks.getLatestSnapshot.mockImplementation(() => ({ + chatEvents: childEvents, + })); + mocks.sendMessage.mockImplementationOnce(async ({ sessionId }) => { + nativeEvents = [ + ...childEvents, + event("wrong-native-user", "user", "provider prefix\n\nnew request", { + sessionId, + }), + event("wrong-native-answer", "assistant", "must not be captured", { + sessionId, + }), + ]; + }); + mocks.loadEvents.mockImplementation(async () => ({ + events: nativeEvents.length > 0 ? nativeEvents : childEvents, + source: "native_store", + })); + + const continuation = continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + target, + turnIntentId: "turn-exact-native-anchor", + }); + const rejected = expect(continuation).rejects.toThrow( + "missing its native transcript anchor" + ); + // Let create/materialize/send reach the transcript-settle loop before + // advancing its backoff timers. + for (let attempt = 0; attempt < 20; attempt += 1) { + if (mocks.sendMessage.mock.calls.length > 0) break; + await Promise.resolve(); + } + expect(mocks.sendMessage).toHaveBeenCalledOnce(); + await vi.runAllTimersAsync(); + await rejected; + expect(mocks.recoverNativeAfterMismatch).toHaveBeenCalledOnce(); + expect(mocks.mergeEvents).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: "wrong-native-answer" }), + ]), + "agentsession-child" + ); + } finally { + vi.useRealTimers(); + } + }); + + it("uses image identity rather than empty text as an image-only fallback", async () => { + let nativeEvents: SessionEvent[] = []; + const image = "data:image/png;base64,AAAA"; + mocks.getLatestSnapshot.mockImplementation(() => ({ + chatEvents: childEvents, + })); + mocks.sendMessage.mockImplementationOnce(async ({ sessionId }) => { + const nativeUser = event("native-image-user", "user", "", { sessionId }); + nativeUser.result = { ...nativeUser.result, images: [image] }; + nativeEvents = [ + ...childEvents, + nativeUser, + event("native-image-answer", "assistant", "image answer", { + sessionId, + }), + ]; + }); + mocks.loadEvents.mockImplementation(async () => ({ + events: nativeEvents.length > 0 ? nativeEvents : childEvents, + source: "native_store", + })); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "", + imageDataUrls: [image], + target, + turnIntentId: "turn-image-only-anchor", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ + id: "convturn-turn-image-only-anchor-native-start", + actionType: "task_start", + createdAt: "2026-08-26T00:00:00.000Z", + }), + expect.objectContaining({ id: "native-image-answer" }), + ]); + }); + + it("leaves fresh terminal ownership with the lifecycle coordinator", async () => { + mocks.getTerminal.mockReturnValue(null); + mocks.getAgentSession.mockResolvedValue({ status: "completed" }); + + const result = await continueLocalConversation({ + root, + title: "Durable terminal", + timeline: [event("u1", "user", "original question")], + displayText: "continue", + target, + turnIntentId: "turn-durable-terminal", + }); + + expect(result.terminalStatus).toBe("completed"); + expect(mocks.markTerminal).not.toHaveBeenCalled(); + }); + + it("waits for an exact CLI turn in Rust when background timers are throttled", async () => { + mocks.getTerminal.mockReturnValue(null); + mocks.create.mockResolvedValue({ sessionId: "cliagent-hidden-child" }); + mocks.cliStatus.mockResolvedValue(null); + + const result = await continueLocalConversation({ + root, + title: "Hidden CLI continuation", + timeline: [event("u1", "user", "original question")], + displayText: "continue in background", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-hidden-cli", + }); + + expect(result.terminalStatus).toBe("completed"); + expect(mocks.cliWaitForTurnTerminal).toHaveBeenCalledWith({ + sessionId: "cliagent-hidden-child", + turnIntentId: "turn-hidden-cli", + timeoutMs: expect.any(Number), + }); + }); + + it("reopens the exact durable long poll while the turn intent is still running", async () => { + mocks.getTerminal.mockReturnValue(null); + mocks.cliWaitForTurnTerminal + .mockRejectedValueOnce(new Error("bounded wait elapsed")) + .mockResolvedValueOnce({ + sessionId: "agentsession-child", + turnIntentId: "turn-native-backoff", + status: "completed", + updatedAt: "2026-08-29T00:02:00.000Z", + }); + mocks.turnIntentStatus.mockResolvedValueOnce({ + sessionId: "agentsession-child", + turnIntentId: "turn-native-backoff", + status: "running", + updatedAt: "2026-08-29T00:01:00.000Z", + }); + + await expect( + continueLocalConversation({ + root, + title: "Native agent continuation", + timeline: [event("u1", "user", "original question")], + displayText: "continue without hot polling", + target, + turnIntentId: "turn-native-backoff", + }) + ).resolves.toMatchObject({ terminalStatus: "completed" }); + + expect(mocks.cliWaitForTurnTerminal).toHaveBeenCalledTimes(2); + expect(mocks.turnIntentStatus).toHaveBeenCalledOnce(); + expect(mocks.getAgentSession).not.toHaveBeenCalled(); + }); + + it("does not let a replayed CLI terminal finish the next exact turn", async () => { + mocks.create.mockResolvedValue({ sessionId: "cliagent-reused-terminal" }); + mocks.cliStatus.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-29T00:02:00.000Z", + }); + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "completed", + at: Date.now() + 10_000, + }); + let resolveExactTurn!: (value: { + sessionId: string; + turnIntentId: string; + status: string; + updatedAt: string; + }) => void; + mocks.cliWaitForTurnTerminal.mockReturnValue( + new Promise((resolve) => { + resolveExactTurn = resolve; + }) + ); + + let settled = false; + const pending = continueLocalConversation({ + root, + title: "Ignore stale CLI terminal", + timeline: [event("u1", "user", "original question")], + displayText: "continue after the old terminal", + target: { + cliAgentType: "claude_code", + accountId: "claude-account", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-after-replayed-terminal", + }); + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + } + ); + + await vi.waitFor(() => + expect(mocks.cliWaitForTurnTerminal).toHaveBeenCalled() + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(settled).toBe(false); + + resolveExactTurn({ + sessionId: "cliagent-reused-terminal", + turnIntentId: "turn-after-replayed-terminal", + status: "completed", + updatedAt: "2026-08-29T00:03:00.000Z", + }); + await expect(pending).resolves.toMatchObject({ + terminalStatus: "completed", + }); + }); + + it("joins the single native transcript reconciler", async () => { + mocks.getLatestSnapshot.mockImplementation(() => ({ + chatEvents: childEvents, + })); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + target, + turnIntentId: "turn-window-anchor", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ displayText: "native answer" }), + ]); + expect(mocks.reconcileNative).toHaveBeenCalledWith("agentsession-child", { + preserveInterruptedSuffix: false, + }); + expect(mocks.getLatestSnapshot).not.toHaveBeenCalled(); + expect(mocks.loadEvents).toHaveBeenCalledTimes(1); + }); + + it("waits for the terminal assistant instead of publishing an empty tail", async () => { + mocks.getLatestSnapshot.mockImplementation(() => ({ + chatEvents: childEvents, + })); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + ]; + setTimeout(() => { + childEvents = [ + ...childEvents, + event(`answer-${turnIntentId}`, "assistant", "late native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + }, 20); + } + ); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "original question")], + displayText: "new request", + target, + turnIntentId: "turn-late-tail", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ displayText: "late native answer" }), + ]); + }); + + it("backs off full reads while a hidden native transcript settles", async () => { + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + ]; + setTimeout(() => { + childEvents = [ + ...childEvents, + event(`answer-${turnIntentId}`, "assistant", "late hidden answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + }, 20); + } + ); + + const result = await continueLocalConversation({ + root, + title: "Hidden shared", + timeline: [event("u1", "user", "original question")], + displayText: "new hidden request", + target, + turnIntentId: "turn-hidden-late-tail", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ displayText: "late hidden answer" }), + ]); + expect(mocks.getStoredEvents.mock.calls.length).toBeLessThanOrEqual(3); + expect(mocks.loadEvents.mock.calls.length).toBeLessThanOrEqual(3); + }); + + it("treats an explicitly cancelled user-only turn as a durable empty-tail boundary", async () => { + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "cancelled", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockResolvedValueOnce({ + sessionId: "agentsession-child", + turnIntentId: "turn-user-only-cancelled", + status: "cancelled", + updatedAt: "2026-08-29T00:01:00.000Z", + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + const result = await continueLocalConversation({ + root, + title: "Interrupted", + timeline: [event("u1", "user", "original question")], + displayText: "start a long task", + target, + turnIntentId: "turn-user-only-cancelled", + }); + + expect(result).toMatchObject({ + terminalStatus: "cancelled", + agentTail: [], + }); + expect(mocks.markTerminal).not.toHaveBeenCalled(); + }); + + it("keeps an interrupted turn recovery-pending until its native user anchor converges", async () => { + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "cancelled", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockResolvedValueOnce({ + sessionId: "agentsession-child", + turnIntentId: "turn-cancelled-before-anchor", + status: "cancelled", + updatedAt: "2026-08-29T00:01:00.000Z", + }); + // The provider accepted the send but its native transcript has not exposed + // even the current user item yet. This is not the same as a user-only + // interrupted turn, whose matching anchor makes resolveSettledTail return + // a durable empty array. + mocks.sendMessage.mockResolvedValueOnce(undefined); + + await expect( + continueLocalConversation({ + root, + title: "Interrupted before transcript flush", + timeline: [event("u1", "user", "original question")], + displayText: "start a long task", + target, + turnIntentId: "turn-cancelled-before-anchor", + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.markTerminal).not.toHaveBeenCalled(); + }); + + it("settles an interrupted turn the provider closed before recording its prompt", async () => { + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "cancelled", + at: Date.now() + 1_000, + }); + mocks.cliWaitForTurnTerminal.mockResolvedValueOnce({ + sessionId: "agentsession-child", + turnIntentId: "turn-cancelled-before-prompt", + status: "cancelled", + updatedAt: "2026-08-29T00:01:00.000Z", + }); + // Stop reached Codex two seconds after task start: the rollout gained + // `task_started` and `turn_aborted` but never the user message. No user + // anchor can converge later, so this must not stay recovery-pending. + mocks.sendMessage.mockImplementationOnce(async ({ sessionId }) => { + const lifecycle = (id: string, actionType: string): SessionEvent => + ({ + ...event(id, "assistant", "", { sessionId }), + functionName: actionType, + uiCanonical: actionType, + actionType, + result: {}, + displayText: actionType, + }) as SessionEvent; + childEvents = [ + ...childEvents, + lifecycle("lifecycle-start", "task_start"), + lifecycle("lifecycle-aborted", "task_failed"), + ]; + }); + + const result = await continueLocalConversation({ + root, + title: "Interrupted before prompt flush", + timeline: [event("u1", "user", "original question")], + displayText: "start a long task", + target, + turnIntentId: "turn-cancelled-before-prompt", + }); + + expect(result).toMatchObject({ + terminalStatus: "cancelled", + agentTail: [], + }); + expect(mocks.markTerminal).not.toHaveBeenCalled(); + }); + + it("rebuilds a same-provider import from canonical events", async () => { + const timeline = [event("u1", "user", "provider-owned history")]; + await continueLocalConversation({ + root: { + authority: "imported-history", + authorityScope: ["claude_code"], + conversationId: "claudecodeapp-source", + }, + title: "Claude source", + timeline, + displayText: "continue", + target: { + cliAgentType: "claude_code", + accountId: "claude-local", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-adopt", + }); + + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline, + }); + }); + + it("rebuilds canonical events through the ambient local Claude CLI", async () => { + const timeline = [event("u1", "user", "provider-owned history")]; + await continueLocalConversation({ + root: { + authority: "imported-history", + authorityScope: ["claude_code"], + conversationId: "claudecodeapp-ambient", + }, + title: "Claude source", + timeline, + displayText: "continue locally", + target: { + cliAgentType: "claude_code", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-ambient", + }); + + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + cliAgentType: "claude_code", + accountId: undefined, + model: undefined, + }) + ); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline, + }); + // A hidden/background continuation must not replace the visible Session's + // pending optimistic row. Only a foreground preparation may bridge the + // rescue slot across a Session switch. + expect( + mocks.storeSet.mock.calls.some( + ([, value]) => + value && + typeof value === "object" && + "displayText" in (value as Record) + ) + ).toBe(false); + }); + + it.each([true, false])( + "shares the queue row only when the root executes (same owner: %s)", + async (sameOwner) => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + await continueLocalConversation({ + root: sameOwner + ? { + authority: "local-session", + authorityScope: [], + conversationId: "agentsession-existing", + } + : root, + title: "Root return", + timeline, + displayText: "resume the root", + target, + turnIntentId: "turn-root-return", + queueMessageId: "queue-root-return", + }); + const prepared = mocks.appendEvents.mock.calls[0][0][0]; + if (sameOwner) { + expect(prepared.id).toBe("queued-user:queue-root-return:"); + expect(prepared.result.queueMessageId).toBe("queue-root-return"); + } else { + expect(prepared.id).not.toBe("queued-user:queue-root-return:"); + expect(prepared.result.queueMessageId).toBeUndefined(); + } + expect(mocks.updateEvent).toHaveBeenCalledWith( + prepared.id, + expect.objectContaining({ displayStatus: "completed" }), + "agentsession-existing" + ); + } + ); + + it("resumes an exact native transcript without rematerializing it", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + + await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "resume natively", + target, + turnIntentId: "turn-2", + }); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: "resume natively" }) + ); + // Preparation appends immediately; dispatch idempotently restores the + // exact same event id in case native synchronization replaced projection. + expect(mocks.appendEvents).toHaveBeenCalledTimes(2); + expect(mocks.appendEvents).toHaveBeenNthCalledWith( + 1, + [expect.objectContaining({ sessionId: "agentsession-existing" })], + "agentsession-existing" + ); + expect(mocks.appendEvents).toHaveBeenNthCalledWith( + 2, + [expect.objectContaining({ sessionId: "agentsession-existing" })], + "agentsession-existing" + ); + }); + + it("shows the ordinary optimistic turn before synchronizing a reused episode", async () => { + const order: string[] = []; + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + mocks.beginOptimistic.mockImplementation(() => { + order.push("optimistic"); + }); + mocks.appendEvents.mockImplementationOnce(async () => { + order.push("user"); + }); + mocks.synchronize.mockImplementationOnce(async () => { + order.push("synchronize"); + return { + events: childEvents, + receipt: { + nativeSessionId: "agentsession-existing", + itemCount: childEvents.length, + }, + }; + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, displayText, turnIntentId }) => { + order.push("send"); + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "resume after a large delta", + target, + turnIntentId: "turn-visible-before-sync", + onSessionPreparing: () => { + order.push("preparing"); + }, + onSessionReady: () => { + order.push("ready"); + }, + }); + + expect(order).toEqual([ + "optimistic", + "user", + "preparing", + "optimistic", + "synchronize", + "ready", + "send", + ]); + }); + + it("keeps one failed user row when reused-episode synchronization fails", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + mocks.synchronize.mockRejectedValueOnce( + new Error("native transcript synchronization failed") + ); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "resume after a large delta", + target, + turnIntentId: "turn-sync-failed", + }) + ).rejects.toThrow("native transcript synchronization failed"); + + const optimisticUserEvent = mocks.appendEvents.mock.calls[0]?.[0]?.[0]; + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.failOptimistic).toHaveBeenCalledOnce(); + expect(mocks.markTerminal).toHaveBeenCalledOnce(); + expect(mocks.updateEvent).toHaveBeenCalledWith( + optimisticUserEvent.id, + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-existing" + ); + }); + + it("keeps one native episode when only the per-turn model changes", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-before-switch", + agentDefinitionId: "builtin:sde", + }); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue with another model", + target: { ...target, model: "model-after-switch" }, + turnIntentId: "turn-model-switch", + }); + + expect(result).toMatchObject({ + sessionId: "agentsession-existing", + }); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "agentsession-existing", + model: "model-after-switch", + }) + ); + }); + + it("reuses one episode across the macOS /tmp filesystem alias", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/private/tmp/orgii-e2e-workspace-repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue in the same workspace", + target: { + ...target, + workspaceRepoPath: "/tmp/orgii-e2e-workspace-repo", + }, + turnIntentId: "turn-path-alias", + }); + + expect(result).toMatchObject({ + sessionId: "agentsession-existing", + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("inherits an existing episode workspace while automatic resolution is pending", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-existing", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/local/checkout", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue before workspace hydration finishes", + target: { ...target, workspaceRepoPath: null }, + turnIntentId: "turn-auto-workspace", + }); + + expect(result).toMatchObject({ + sessionId: "agentsession-existing", + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("does not replace unique native history when shared history diverged", async () => { + childEvents = [ + event("old", "user", "old", { sessionId: "agentsession-old" }), + ]; + mocks.invokeTauri.mockResolvedValue([ + { sessionId: "agentsession-old", updatedAt: "2026-08-26T01:00:00Z" }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + const timeline = [event("new", "user", "teammate added context")]; + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue", + target, + turnIntentId: "turn-3", + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryBlockedError); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("appends canonical role history natively before resuming one episode", async () => { + const existing = event("u1", "user", "existing", { + sessionId: "cliagent-existing", + }); + childEvents = [existing]; + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-existing", + updatedAt: "2026-08-26T01:00:00Z", + }, + ]); + mocks.cliStatus.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00Z", + repoPath: "/repo", + accountId: "account-1", + model: "model-1", + cliAgentType: "codex", + }); + const timeline = [existing, event("a1", "assistant", "remote answer")]; + + await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue after remote turn", + target: { + cliAgentType: "codex", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-native-delta", + }); + + expect(mocks.synchronize).toHaveBeenCalledWith({ + sessionId: "cliagent-existing", + timeline, + }); + expect(mocks.mergeEvents).toHaveBeenCalledWith( + [expect.objectContaining({ displayText: "remote answer" })], + "cliagent-existing" + ); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("fails closed when native resume fails", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline; + mocks.invokeTauri.mockResolvedValue([ + { sessionId: "agentsession-existing", updatedAt: "2026-08-26T01:00:00Z" }, + ]); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-08-26T01:00:00Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + }); + mocks.sendMessage.mockRejectedValueOnce(new Error("native id vanished")); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue", + target, + turnIntentId: "turn-4", + }) + ).rejects.toThrow("native id vanished"); + expect(mocks.create).not.toHaveBeenCalled(); + const failedUserEvent = mocks.appendEvents.mock.calls.at(-1)?.[0]?.[0]; + expect(mocks.removeEvents).not.toHaveBeenCalled(); + expect(mocks.updateEvent).toHaveBeenCalledWith( + failedUserEvent.id, + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-existing" + ); + }); + + it("rebuilds a busy Codex native episode and retries the same turn once", async () => { + const existingSessionId = "cliagent-codex-owned-by-app"; + const initialTimeline = [ + event("u1", "user", "inspect the repository", { + sessionId: existingSessionId, + }), + ]; + const refreshedTimeline = [ + ...initialTimeline, + event("a1", "assistant", "I inspected it", { + sessionId: existingSessionId, + }), + ]; + mockCompatibleCliEpisode(existingSessionId, "codex", initialTimeline); + const loadTimeline = vi + .fn() + .mockResolvedValueOnce(initialTimeline) + .mockResolvedValueOnce(refreshedTimeline); + mocks.sendMessage.mockRejectedValueOnce( + new Error( + "JSON-RPC -32600 thread/resume failed: thread already has an active writer" + ) + ); + const preparing: string[] = []; + const ready: string[] = []; + const beforeDispatch: string[] = []; + + const result = await continueLocalConversationAfterTimelineLoad({ + root, + title: "Shared", + loadTimeline, + displayText: "continue without duplicating my message", + target: codexTarget, + turnIntentId: "turn-active-writer", + onSessionPreparing: (sessionId) => { + preparing.push(sessionId); + }, + onSessionReady: (sessionId) => { + ready.push(sessionId); + }, + onBeforeTurnDispatch: (sessionId) => { + beforeDispatch.push(sessionId); + }, + }); + + expect(result).toMatchObject({ sessionId: "agentsession-child" }); + expect(mocks.sendMessage).toHaveBeenCalledTimes(2); + expect(mocks.sendMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sessionId: existingSessionId, + content: "continue without duplicating my message", + turnIntentId: "turn-active-writer", + clientMessageId: "conversation-turn:turn-active-writer", + }) + ); + expect(mocks.sendMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sessionId: "agentsession-child", + content: "continue without duplicating my message", + turnIntentId: "turn-active-writer", + clientMessageId: "conversation-turn:turn-active-writer", + }) + ); + expect(mocks.removeSyntheticUserInputs).toHaveBeenCalledOnce(); + expect(mocks.removeSyntheticUserInputs).toHaveBeenCalledWith( + existingSessionId, + { + matchingContents: [], + matchingTurnIntentIds: ["turn-active-writer"], + } + ); + expect(mocks.materialize).toHaveBeenCalledOnce(); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline: refreshedTimeline, + }); + expect(loadTimeline).toHaveBeenCalledTimes(2); + expect(preparing).toEqual([existingSessionId, "agentsession-child"]); + expect(ready).toEqual([existingSessionId, "agentsession-child"]); + expect(beforeDispatch).toEqual([existingSessionId, "agentsession-child"]); + }); + + it.each([ + "native=293 canonical=292 (provider transcript is longer than the canonical conversation)", + "native=292 canonical=293 (assistant output differs)", + ])( + "retains a failed intent instead of silently rebuilding divergent history: %s", + async (mismatch) => { + const existingSessionId = "cliagent-codex-diverged"; + const timeline = [ + event("u1", "user", "inspect the repository", { + sessionId: existingSessionId, + }), + ]; + mockCompatibleCliEpisode(existingSessionId, "codex", timeline); + mocks.synchronize.mockRejectedValueOnce( + new Error( + `provider-native transcript is not a semantic prefix of the canonical conversation: ${mismatch}` + ) + ); + + await expect( + continueLocalConversationAfterTimelineLoad({ + root, + title: "Shared", + loadTimeline: async () => timeline, + displayText: "continue after the plane lost a row", + target: codexTarget, + turnIntentId: "turn-diverged", + }) + ).rejects.toThrow("is not a semantic prefix"); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.removeSyntheticUserInputs).not.toHaveBeenCalled(); + expect(mocks.failOptimistic).toHaveBeenCalled(); + } + ); + + it("does not retry a rebuilt Codex episode more than once", async () => { + const existingSessionId = "cliagent-codex-owned-by-app"; + const timeline = [ + event("u1", "user", "same native history", { + sessionId: existingSessionId, + }), + ]; + mockCompatibleCliEpisode(existingSessionId, "codex", timeline); + mocks.sendMessage.mockRejectedValue( + new Error( + "JSON-RPC -32600 thread/resume failed: thread already has an active writer" + ) + ); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "retry exactly once", + target: codexTarget, + turnIntentId: "turn-active-writer-bounded", + }) + ).rejects.toThrow("already has an active writer"); + + expect(mocks.sendMessage).toHaveBeenCalledTimes(2); + expect(mocks.create).toHaveBeenCalledOnce(); + expect(mocks.materialize).toHaveBeenCalledOnce(); + expect(mocks.removeSyntheticUserInputs).toHaveBeenCalledOnce(); + }); + + it("does not treat another runtime's active-writer text as a Codex lock", async () => { + const existingSessionId = "cliagent-claude-existing"; + const timeline = [ + event("u1", "user", "same native history", { + sessionId: existingSessionId, + }), + ]; + mockCompatibleCliEpisode(existingSessionId, "claude_code", timeline); + mocks.sendMessage.mockRejectedValueOnce( + new Error( + "JSON-RPC -32600 thread/resume failed: thread already has an active writer" + ) + ); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "do not rebuild Claude", + target: { + cliAgentType: "claude_code", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-claude-active-writer-text", + }) + ).rejects.toThrow("already has an active writer"); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.removeSyntheticUserInputs).not.toHaveBeenCalled(); + }); + + it("reuses an earlier direct Codex child after a direct Claude child becomes the frontier", async () => { + const localRoot = { + authority: "local-session", + authorityScope: [], + conversationId: "sdeagent-canonical-root", + } as const; + const parentSessionId = conversationExecutionParentId(localRoot); + const codexSessionId = "cliagent-earlier-codex-child"; + const claudeSessionId = "cliagent-latest-claude-child"; + const canonical = [ + event("codex-u1", "user", "start in Codex", { + sessionId: codexSessionId, + }), + event("codex-a1", "assistant", "Codex answer", { + sessionId: codexSessionId, + }), + event("claude-u2", "user", "continue in Claude", { + sessionId: claudeSessionId, + }), + event("claude-a2", "assistant", "Claude answer", { + sessionId: claudeSessionId, + }), + ]; + mocks.invokeTauri.mockImplementation(async (command, args) => { + expect(command).toBe("es_get_child_sessions"); + expect(args).toEqual({ parentSessionId }); + return [ + { + sessionId: claudeSessionId, + updatedAt: "2026-09-05T08:40:00.000Z", + }, + { + sessionId: codexSessionId, + updatedAt: "2026-09-05T08:00:00.000Z", + }, + ]; + }); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-09-05T07:00:00.000Z", + agentDefinitionId: "builtin:sde", + accountId: "codex-account", + model: "codex-model", + workspacePath: "/repo", + }); + mocks.cliStatus.mockImplementation(async ({ sessionId }) => ({ + status: "completed", + updatedAt: + sessionId === claudeSessionId + ? "2026-09-05T08:40:00.000Z" + : "2026-09-05T08:00:00.000Z", + cliAgentType: sessionId === claudeSessionId ? "claude_code" : "codex", + accountId: + sessionId === claudeSessionId ? "claude-account" : "codex-account", + model: sessionId === claudeSessionId ? "claude-model" : "codex-model", + repoPath: "/repo", + })); + let codexEvents = canonical.slice(0, 2); + mocks.loadEvents.mockImplementation(async (sessionId) => ({ + events: sessionId === codexSessionId ? codexEvents : [], + source: "native_store", + })); + mocks.synchronize.mockImplementation(async ({ sessionId, timeline }) => { + codexEvents = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + return { + events: codexEvents, + receipt: { nativeSessionId: sessionId, itemCount: codexEvents.length }, + }; + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, content, turnIntentId }) => { + codexEvents = [ + ...codexEvents, + event(`user-${turnIntentId}`, "user", content, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + const result = await continueLocalConversationAfterTimelineLoad({ + root: localRoot, + title: "Runtime round trip", + loadTimeline: async () => canonical, + displayText: "return to Codex", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "codex-return", + }); + + expect(result).toMatchObject({ sessionId: codexSessionId }); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.synchronize).toHaveBeenCalledWith({ + sessionId: codexSessionId, + timeline: canonical, + }); + expect(mocks.cliStatus).toHaveBeenCalledWith({ + sessionId: claudeSessionId, + }); + expect(mocks.cliStatus).toHaveBeenCalledWith({ sessionId: codexSessionId }); + expect(mocks.loadEvents).toHaveBeenCalledWith(codexSessionId); + expect(mocks.loadEvents).not.toHaveBeenCalledWith(claudeSessionId); + }); + + it("reuses an earlier Codex child whose native tool call ids were rewritten", async () => { + const localRoot = { + authority: "local-session", + authorityScope: [], + conversationId: "sdeagent-canonical-root", + } as const; + const parentSessionId = conversationExecutionParentId(localRoot); + const codexSessionId = "cliagent-earlier-codex-child"; + const claudeSessionId = "cliagent-latest-claude-child"; + const toolEvent = (callId: string, sessionId: string): SessionEvent => + ({ + ...event("codex-t1", "assistant", "read README", { sessionId }), + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + callId, + args: { path: "/repo/README.md" }, + result: { status: "completed", call_id: callId, output: "# ORG2" }, + }) as SessionEvent; + const canonical = [ + event("codex-u1", "user", "start in Codex", { + sessionId: codexSessionId, + }), + toolEvent("sdeagent-canonical-root:tool:1", codexSessionId), + event("codex-a1", "assistant", "Codex answer", { + sessionId: codexSessionId, + }), + event("claude-u2", "user", "continue in Claude", { + sessionId: claudeSessionId, + }), + event("claude-a2", "assistant", "Claude answer", { + sessionId: claudeSessionId, + }), + ]; + let codexEvents: SessionEvent[] = [ + canonical[0], + toolEvent("call_9d1f4e0c6b7a4c0e8a3f2b1d5e6f7a80", codexSessionId), + canonical[2], + ]; + mocks.invokeTauri.mockImplementation(async (command, args) => { + expect(command).toBe("es_get_child_sessions"); + expect(args).toEqual({ parentSessionId }); + return [ + { + sessionId: claudeSessionId, + updatedAt: "2026-09-05T08:40:00.000Z", + }, + { + sessionId: codexSessionId, + updatedAt: "2026-09-05T08:00:00.000Z", + }, + ]; + }); + mocks.getAgentSession.mockResolvedValue({ + status: "completed", + updatedAt: "2026-09-05T07:00:00.000Z", + agentDefinitionId: "builtin:sde", + accountId: "codex-account", + model: "codex-model", + workspacePath: "/repo", + }); + mocks.cliStatus.mockImplementation(async ({ sessionId }) => ({ + status: "completed", + updatedAt: + sessionId === claudeSessionId + ? "2026-09-05T08:40:00.000Z" + : "2026-09-05T08:00:00.000Z", + cliAgentType: sessionId === claudeSessionId ? "claude_code" : "codex", + accountId: + sessionId === claudeSessionId ? "claude-account" : "codex-account", + model: sessionId === claudeSessionId ? "claude-model" : "codex-model", + repoPath: "/repo", + })); + mocks.loadEvents.mockImplementation(async (sessionId) => ({ + events: sessionId === codexSessionId ? codexEvents : [], + source: "native_store", + })); + mocks.synchronize.mockImplementation(async ({ sessionId, timeline }) => { + codexEvents = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + return { + events: codexEvents, + receipt: { nativeSessionId: sessionId, itemCount: codexEvents.length }, + }; + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, content, turnIntentId }) => { + codexEvents = [ + ...codexEvents, + event(`user-${turnIntentId}`, "user", content, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + const result = await continueLocalConversationAfterTimelineLoad({ + root: localRoot, + title: "Runtime round trip with rewritten call ids", + loadTimeline: async () => canonical, + displayText: "return to Codex", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "codex-return-rewritten", + }); + + expect(result).toMatchObject({ sessionId: codexSessionId }); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + }); + + it("does not bypass unpublished native history by selecting an older UUID on Retry", async () => { + const canonical = [ + event("u1", "user", "first question"), + event("a1", "assistant", "first answer"), + event("u2", "user", "second question"), + event("a2", "assistant", "second answer"), + ]; + const eventsBySession = new Map([ + [ + "agentsession-future", + [ + ...canonical, + event("future", "assistant", "not in canonical history", { + sessionId: "agentsession-future", + }), + ], + ], + ["agentsession-prefix", canonical.slice(0, 2)], + ]); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-future", + updatedAt: "2026-09-05T09:00:00.000Z", + }, + { + sessionId: "agentsession-prefix", + updatedAt: "2026-09-05T08:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockImplementation(async (sessionId) => ({ + status: "completed", + updatedAt: + sessionId === "agentsession-future" + ? "2026-09-05T09:00:00.000Z" + : "2026-09-05T08:00:00.000Z", + workspacePath: "/repo", + accountId: "account-1", + model: "model-1", + agentDefinitionId: "builtin:sde", + })); + mocks.loadEvents.mockImplementation(async (sessionId) => ({ + events: eventsBySession.get(sessionId) ?? [], + source: "native_store", + })); + mocks.synchronize.mockImplementation(async ({ sessionId, timeline }) => { + const synchronized = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + eventsBySession.set(sessionId, synchronized); + return { + events: synchronized, + receipt: { + nativeSessionId: sessionId, + itemCount: synchronized.length, + }, + }; + }); + mocks.sendMessage.mockImplementationOnce( + async ({ sessionId, content, turnIntentId }) => { + const current = eventsBySession.get(sessionId) ?? []; + eventsBySession.set(sessionId, [ + ...current, + event(`user-${turnIntentId}`, "user", content, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]); + } + ); + + const retry = () => + continueLocalConversation({ + root, + title: "Shared", + timeline: canonical, + displayText: "continue safely", + target, + turnIntentId: "turn-prefix-selection", + }); + + await expect(retry()).rejects.toBeInstanceOf( + QueuedConversationRecoveryBlockedError + ); + await expect(retry()).rejects.toBeInstanceOf( + QueuedConversationRecoveryBlockedError + ); + expect(mocks.synchronize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("marks a fresh native episode failed when its first resume send is rejected", async () => { + mocks.sendMessage.mockRejectedValueOnce( + new Error("provider rejected native id") + ); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "native history")], + displayText: "continue", + target, + turnIntentId: "turn-fresh-failure", + }) + ).rejects.toThrow("provider rejected native id"); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "failed", + { generation: 3 } + ); + const failedUserEvent = mocks.appendEvents.mock.calls.at(-1)?.[0]?.[0]; + expect(mocks.removeEvents).not.toHaveBeenCalled(); + expect(mocks.updateEvent).toHaveBeenCalledWith( + failedUserEvent.id, + expect.objectContaining({ displayStatus: "failed" }), + "agentsession-child" + ); + }); + + it("rejects a CLI target without a native writer contract", async () => { + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [], + displayText: "continue", + target: { + cliAgentType: "kiro", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-5", + }) + ).rejects.toThrow("cannot materialize"); + }); + + it("reuses the original Claude UUID after a Claude to Codex to Claude round trip", async () => { + const localRoot = { + authority: "local-session", + authorityScope: [], + conversationId: "cliagent-claude-root", + } as const; + const parentSessionId = conversationExecutionParentId(localRoot); + const eventsBySession = new Map([ + [ + localRoot.conversationId, + [ + event("root-u1", "user", "remember this native history", { + sessionId: localRoot.conversationId, + }), + event("root-a1", "assistant", "remembered", { + sessionId: localRoot.conversationId, + }), + ], + ], + ]); + const children: Array<{ sessionId: string; updatedAt: string }> = []; + mocks.invokeTauri.mockImplementation(async (command, args) => { + expect(args).toEqual({ parentSessionId }); + return children; + }); + mocks.cliStatus.mockImplementation(async ({ sessionId }) => { + if (sessionId === localRoot.conversationId) { + return { + status: "completed", + updatedAt: "2026-08-28T03:00:00.000Z", + cliAgentType: "claude_code", + accountId: "claude-account", + model: "claude-model", + repoPath: "/repo", + }; + } + return { + status: "completed", + updatedAt: "2026-08-28T04:00:00.000Z", + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + repoPath: "/repo", + }; + }); + mocks.loadEvents.mockImplementation(async (sessionId) => ({ + events: eventsBySession.get(sessionId) ?? [], + source: "native_store", + })); + let creationCount = 0; + mocks.create.mockImplementation(async () => { + creationCount += 1; + const sessionId = + creationCount === 1 ? "cliagent-codex-child" : "cliagent-claude-return"; + children.push({ + sessionId, + updatedAt: + creationCount === 1 + ? "2026-08-28T04:00:00.000Z" + : "2026-08-28T05:00:00.000Z", + }); + return { sessionId }; + }); + mocks.materialize.mockImplementation(async ({ sessionId, timeline }) => { + const materialized = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + eventsBySession.set(sessionId, materialized); + return { + events: materialized, + receipt: { nativeSessionId: sessionId, itemCount: materialized.length }, + }; + }); + mocks.synchronize.mockImplementation(async ({ sessionId, timeline }) => { + const synchronized = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + eventsBySession.set(sessionId, synchronized); + return { + events: synchronized, + receipt: { + nativeSessionId: sessionId, + itemCount: synchronized.length, + }, + }; + }); + const sentInto: string[] = []; + mocks.sendMessage.mockImplementation( + async ({ sessionId, displayText, turnIntentId }) => { + sentInto.push(sessionId); + const current = eventsBySession.get(sessionId) ?? []; + eventsBySession.set(sessionId, [ + ...current, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]); + } + ); + + const claudeTarget = { + cliAgentType: "claude_code", + accountId: "claude-account", + model: "claude-model", + workspaceRepoPath: "/repo", + }; + const first = await continueLocalConversation({ + root: localRoot, + title: "Round trip", + timeline: eventsBySession.get(localRoot.conversationId)!, + displayText: "first Claude turn", + target: claudeTarget, + turnIntentId: "cc-first", + }); + expect(first).toMatchObject({ + sessionId: localRoot.conversationId, + }); + + const middle = await continueLocalConversation({ + root: localRoot, + title: "Round trip", + timeline: eventsBySession.get(localRoot.conversationId)!, + displayText: "Codex middle turn", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "codex-middle", + }); + expect(middle).toMatchObject({ + sessionId: "cliagent-codex-child", + }); + + const last = await continueLocalConversation({ + root: localRoot, + title: "Round trip", + timeline: eventsBySession.get("cliagent-codex-child")!, + displayText: "return to Claude", + target: claudeTarget, + turnIntentId: "cc-return", + }); + expect(last).toMatchObject({ + sessionId: localRoot.conversationId, + }); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(sentInto).toEqual([ + localRoot.conversationId, + "cliagent-codex-child", + localRoot.conversationId, + ]); + expect(eventsBySession.get(localRoot.conversationId)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayText: "Codex middle turn" }), + ]) + ); + }); +}); diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.ts b/src/engines/SessionCore/conversations/localConversationContinuation.ts new file mode 100644 index 0000000000..2211c69036 --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -0,0 +1,1462 @@ +/** + * Provider-neutral local continuation for one canonical conversation. + * + * The canonical transcript can come from Cloud, an imported session, or a + * normal local Session. Execution always happens on this device with the + * caller's selected local runtime/account/workspace. A normal persisted + * Session is the continuation record: `parentSessionId` groups its hidden + * execution episodes under a deterministic conversation parent, so no + * localStorage runner registry or parallel continuation database is needed. + */ +import { getSession as getAgentSession } from "@src/api/tauri/agent"; +import { rpc } from "@src/api/tauri/rpc"; +import { + type TurnTerminalStatus, + toTurnTerminalStatus, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { + type UserIntentPreparation, + UserIntentSendError, + activateUserIntentPreparation, + adoptAcceptedUserIntent, + confirmUserIntentPreparation, + dispatchUserIntent, + failUserIntentPreparation, + isUserIntentSendError, + optimisticQueueUserEventId, + prepareUserIntent, + settleUserIntentLifecycle, +} from "@src/engines/SessionCore/services/userIntentDispatch"; +import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { + reconcileNativeTranscript, + recoverNativeTranscriptAfterMismatch, +} from "@src/engines/SessionCore/sync/nativeTranscriptReconcile"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; +import { createLogger } from "@src/hooks/logger"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import type { + ConversationRootLocator, + LocalConversationTarget, +} from "./conversationTypes"; +import { + materializeNativeConversation, + nativeConversationItemsArePrefix, + nativeConversationItemsAreProviderPortablePrefix, + nativeSourceEventId, + projectNativeConversationItems, + removeKnownNativeConversationEchoes, + sourceEventIdOfNativeItem, + supportsNativeConversationTarget, + synchronizeNativeConversation, +} from "./nativeConversationMaterializer"; +import { + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, +} from "./queuedConversationContract"; + +export type { + ConversationRootLocator, + LocalConversationTarget, +} from "./conversationTypes"; + +const TURN_WAIT_WINDOW_MS = 60_000; +const log = createLogger("localConversationContinuation"); + +function isCodexNativeEpisodeAlreadyOwned( + error: unknown, + target: LocalConversationTarget +): boolean { + if (target.cliAgentType !== "codex") return false; + + const messages: string[] = []; + const seen = new Set(); + let current: unknown = error; + while (current != null && !seen.has(current)) { + seen.add(current); + if (current instanceof Error) { + messages.push(current.message); + current = (current as Error & { cause?: unknown }).cause; + continue; + } + messages.push(String(current)); + break; + } + const message = messages.join("\n").toLowerCase(); + return ( + message.includes("-32600") && + message.includes("thread/resume") && + message.includes("already has an active writer") + ); +} + +async function notifyConversationTurnAccepted( + callback: ContinueLocalConversationParams["onTurnAccepted"], + sessionId: string, + turnIntentId: string +): Promise { + if (!callback) return; + try { + await callback(sessionId); + } catch (error) { + log.error( + `[native-continuation] failed to persist acceptance receipt for ${turnIntentId}`, + error + ); + // Provider acceptance is already irreversible. The durable queue must + // retain this exact owner and reconnect by turnIntentId; continuing as if + // bookkeeping succeeded would silently strand a running native turn and + // make a later retry eligible to send twice. + throw new QueuedConversationRecoveryPendingError( + `provider accepted ${turnIntentId}, but its durable receipt could not be persisted: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +export const CONVERSATION_TURN_ID_ARG = "conversationTurnId"; + +interface ContinueLocalConversationParams { + root: ConversationRootLocator; + title: string; + /** Canonical transcript immediately before this new user turn. */ + timeline: readonly SessionEvent[]; + displayText: string; + agentContent?: string; + imageDataUrls?: string[]; + target: LocalConversationTarget; + turnIntentId: string; + /** Queue projection on the canonical root, reused when that root executes. */ + queueMessageId?: string; + onSessionReady?: ( + sessionId: string, + /** Authoritative native-event prefix that predates this turn. */ + eventStartIndex: number + ) => void | Promise; + /** + * Last reversible boundary before the selected provider receives the turn. + * Cloud authority uses it to durably mark a cross-device lease accepted; + * recovery also invokes it when an older local receipt proves the provider + * already crossed that boundary. Local conversations leave it unset. + */ + onBeforeTurnDispatch?: (sessionId: string) => void | Promise; + /** + * Fires once the selected provider has durably accepted this user turn. + * Queue ownership lives above the continuation adapter: callers use this + * boundary to remove the durable queue row while the native turn keeps + * running and reconciling in the background. + */ + onTurnAccepted?: (sessionId: string) => void | Promise; + /** + * A fresh episode now owns preparation, before its canonical transcript has + * finished materializing. Surfaces use this to bind the ordinary planning + * footer immediately without overlaying historical events. + */ + onSessionPreparing?: (sessionId: string) => void | Promise; +} + +interface ContinueLocalConversationAfterTimelineLoadParams extends Omit< + ContinueLocalConversationParams, + "timeline" +> { + /** + * Read the authoritative canonical transcript only after this conversation + * reaches the head of the singleton message queue. This prevents a submit made + * immediately after Stop from racing the previous turn's native-tail + * reconciliation and materializing a stale prefix into the next runtime. + */ + loadTimeline: () => Promise; +} + +export interface ContinueLocalConversationResult { + sessionId: string; + terminalStatus: TurnTerminalStatus; + agentTail: SessionEvent[]; +} + +interface RecoverLocalConversationParams extends ContinueLocalConversationParams { + runnerSessionId: string; + eventStartIndex?: number; +} + +type ConversationTurnPreparation = UserIntentPreparation; + +interface ChildSessionView { + sessionId: string; + updatedAt: string; +} + +interface ExecutionCandidate { + sessionId: string; + updatedAt: string; +} + +/** + * Read-only execution identity persisted by the normal Session owner. + * + * This is intentionally only a projection of existing Session rows. It is + * not another continuation registry: callers use it to hydrate picker state + * after restart, while execution and resume continue to use those same rows. + */ +export interface LocalConversationExecutionTargetSnapshot { + sessionId: string; + updatedAt: string; + target: LocalConversationTarget; +} + +function requireIdentityPart(label: string, value: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`conversation ${label} is required`); + if (normalized.length > 2_048) { + throw new Error(`conversation ${label} is too long`); + } + return normalized; +} + +/** Durable grouping id stored directly on normal native/CLI Session rows. */ +export function conversationExecutionParentId( + locator: ConversationRootLocator +): string { + if (locator.authorityScope.length > 16) { + throw new Error("conversation authority scope has too many parts"); + } + return JSON.stringify([ + "org2-conversation", + 1, + requireIdentityPart("authority", locator.authority), + locator.authorityScope.map((part, index) => + requireIdentityPart(`authority scope ${index}`, part) + ), + requireIdentityPart("id", locator.conversationId), + ]); +} + +/** Parse only parent ids emitted by `conversationExecutionParentId`. */ +export function parseConversationExecutionParentId( + value: string | null | undefined +): ConversationRootLocator | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + if ( + !Array.isArray(parsed) || + parsed.length !== 5 || + parsed[0] !== "org2-conversation" || + parsed[1] !== 1 || + typeof parsed[2] !== "string" || + !Array.isArray(parsed[3]) || + !parsed[3].every((part) => typeof part === "string") || + typeof parsed[4] !== "string" + ) { + return null; + } + return { + authority: parsed[2], + authorityScope: parsed[3] as string[], + conversationId: parsed[4], + }; + } catch { + return null; + } +} + +/** + * Promote a normal readable My Session to a canonical conversation root. + * Target support is checked separately: any native transcript may be a source, + * while only runtimes with a verified writer/reader adapter may execute it. + */ +export function localConversationRootForSession( + sessionId: string, + _cliAgentType: string | null | undefined, + agentDefinitionId?: string | null +): ConversationRootLocator | null { + // The session-id namespace already proves that this is a readable native + // CLI conversation. Sidebar/lightweight rows can hydrate before their + // `cliAgentType`; requiring that presentation metadata here made the + // continuation binding disappear and left only the unrelated global model + // picker. Target resolution remains strict and asks the user to choose a + // runtime until the missing metadata arrives. + if (!isCliSession(sessionId) && !agentDefinitionId) { + return null; + } + return { + authority: "local-session", + authorityScope: [], + conversationId: sessionId, + }; +} + +export function conversationTurnIdOf(event: SessionEvent): string | null { + const turnIntentId = turnIntentIdOf(event); + if (turnIntentId) return turnIntentId; + const value = event.args?.[CONVERSATION_TURN_ID_ARG]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +async function listExecutionChildren( + parentSessionId: string +): Promise { + const children = await invokeTauri( + "es_get_child_sessions", + { parentSessionId } + ); + return children + .filter( + (child) => + typeof child.sessionId === "string" && child.sessionId.length > 0 + ) + .map((child) => ({ + sessionId: child.sessionId, + updatedAt: child.updatedAt, + })) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +async function listExecutionCandidates( + locator: ConversationRootLocator +): Promise { + const children = await listExecutionChildren( + conversationExecutionParentId(locator) + ); + if (locator.authority !== "local-session") return children; + + // The ordinary source Session is already a fully native execution episode. + // Include it next to provider-switch children so returning to the source + // provider reuses its native UUID instead of creating a duplicate copy. + let root: ExecutionRow | null; + try { + root = await readExecutionRow(locator.conversationId); + } catch (error) { + throw new QueuedConversationRecoveryPendingError( + `source execution identity is temporarily unavailable: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!root?.updatedAt) return children; + return [ + { + sessionId: locator.conversationId, + updatedAt: root.updatedAt, + }, + ...children, + ].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +function sameOptional(left: unknown, right: string | undefined): boolean { + return ( + (typeof left === "string" && left.length > 0 ? left : undefined) === right + ); +} + +function comparableWorkspacePath(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + let normalized = value + .trim() + .replace(/^file:\/\//, "") + .replace(/\/+$/, ""); + if (!normalized) return undefined; + // macOS exposes the same temporary filesystem through both spellings. + // Agent session rows are canonicalized by Rust to /private/tmp while the + // New Session/workspace picker can retain the user-facing /tmp spelling. + // Treating that alias as a runtime identity change creates an unnecessary + // child episode and moves the live answer off the visible owner stream. + if (normalized === "/private/tmp") normalized = "/tmp"; + else if (normalized.startsWith("/private/tmp/")) { + normalized = normalized.slice("/private".length); + } + return normalized; +} + +function sameWorkspacePath(left: unknown, right: string | undefined): boolean { + const requested = comparableWorkspacePath(right); + // A missing target path is the automatic-workspace state used while a + // shared/imported Session hydrates its local repo-scope match. For an + // existing native episode, its durable repo path is already the verified + // local choice and must be inherited. A concrete different path remains an + // intentional isolation boundary and rolls to a new episode. + return requested === undefined || comparableWorkspacePath(left) === requested; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +interface ExecutionRow { + target: LocalConversationTarget; + updatedAt?: string; +} + +async function readExecutionRow( + sessionId: string +): Promise { + if (isCliSession(sessionId)) { + const row = (await rpc.cli.status({ sessionId })) as Record< + string, + unknown + > | null; + if (!row) return null; + const cliAgentType = optionalString(row.cliAgentType); + const accountId = optionalString(row.accountId); + const updatedAt = optionalString(row.updatedAt); + if (!cliAgentType || (!accountId && cliAgentType !== "claude_code")) { + return null; + } + return { + target: { + cliAgentType, + accountId, + model: optionalString(row.model), + workspaceRepoPath: + optionalString(row.worktreePath) ?? optionalString(row.repoPath), + }, + updatedAt, + }; + } + + const row = await getAgentSession(sessionId); + if (!row) return null; + const agentDefinitionId = optionalString(row.agentDefinitionId); + const accountId = optionalString(row.accountId); + const model = optionalString(row.model); + const updatedAt = optionalString(row.updatedAt); + if (!agentDefinitionId || !accountId || !model) return null; + return { + target: { + agentDefinitionId, + accountId, + model, + workspaceRepoPath: optionalString(row.workspacePath), + }, + updatedAt, + }; +} + +/** + * Load every durable execution target for a canonical conversation, newest + * first. Hidden continuation children are deliberately read through + * `es_get_child_sessions`; they are not required to be present in the UI's + * in-memory Session roster. + */ +export async function loadLocalConversationExecutionTargets( + locator: ConversationRootLocator +): Promise { + const candidates = await listExecutionCandidates(locator); + const rows = await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + row: await readExecutionRow(candidate.sessionId), + })) + ); + return rows + .flatMap(({ candidate, row }) => + row + ? [ + { + sessionId: candidate.sessionId, + updatedAt: row.updatedAt ?? candidate.updatedAt, + target: row.target, + }, + ] + : [] + ) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +async function candidateMatchesTarget( + sessionId: string, + target: LocalConversationTarget +): Promise { + const existing = (await readExecutionRow(sessionId))?.target ?? null; + if (!existing) { + log.info( + `[native-continuation] skipping ${sessionId}: execution identity is unavailable` + ); + return false; + } + // A model is a per-turn launch choice, not provider conversation identity. + // The ordinary composer can already change models while preserving one + // Session/native UUID. Treating it as an episode fingerprint caused a + // Codex -> Claude -> Codex round trip to clone the original Codex + // conversation whenever the picker selected a different compatible Codex + // model on return. Runtime/profile/workspace still define the isolation + // boundary; the selected model is passed to `sendMessage` below. + const matches = + sameOptional(existing.cliAgentType, target.cliAgentType) && + sameWorkspacePath( + existing.workspaceRepoPath, + target.workspaceRepoPath ?? undefined + ) && + sameOptional(existing.accountId, target.accountId) && + sameOptional(existing.agentDefinitionId, target.agentDefinitionId); + if (!matches) { + log.info( + `[native-continuation] skipping ${sessionId}: runtime identity does not match`, + { + existingRuntime: existing.cliAgentType ?? existing.agentDefinitionId, + requestedRuntime: target.cliAgentType ?? target.agentDefinitionId, + accountMatches: sameOptional(existing.accountId, target.accountId), + workspaceMatches: sameWorkspacePath( + existing.workspaceRepoPath, + target.workspaceRepoPath ?? undefined + ), + existingWorkspace: comparableWorkspacePath(existing.workspaceRepoPath), + requestedWorkspace: comparableWorkspacePath( + target.workspaceRepoPath ?? undefined + ), + } + ); + } + return matches; +} + +async function findCompatibleExecution( + locator: ConversationRootLocator, + target: LocalConversationTarget, + timeline: readonly SessionEvent[], + knownMatchingCandidates?: readonly ExecutionCandidate[] +): Promise<{ + sessionId: string; + events: SessionEvent[]; +} | null> { + const canonicalItems = projectNativeConversationItems(timeline); + const availableCandidates = + knownMatchingCandidates ?? (await listExecutionCandidates(locator)); + // Candidates are newest-first. A provider switch makes the global frontier + // belong to another runtime, but it does not invalidate the earlier native + // UUID for this target. Reuse the newest target-compatible episode whose + // native history is still a canonical prefix; synchronization below appends + // the intervening cross-runtime delta before the next provider send. + for (const candidate of availableCandidates) { + if ( + !knownMatchingCandidates && + !(await candidateMatchesTarget(candidate.sessionId, target)) + ) { + continue; + } + try { + const loaded = await loadAuthoritativeSessionEvents(candidate.sessionId); + const events = loaded.events; + const executionItems = projectNativeConversationItems(events); + // A newly-created child may legitimately be empty if the renderer died + // between Session creation and native materialization. Empty is the + // canonical zero-length prefix: synchronizeNativeConversation rebuilds + // the provider transcript before sending the same durable turn intent. + if ( + nativeConversationItemsAreProviderPortablePrefix( + executionItems, + canonicalItems + ) + ) { + return { + sessionId: candidate.sessionId, + events, + }; + } + // A compatible execution may contain unpublished partial/tool output. + // Selecting an older UUID (or creating a fresh one) would silently omit + // that output on Retry just as surely as ignoring the synchronizer's + // prefix error. Explicit forks have their own root; an unexplained + // branch inside this root requires reconciliation before another send. + throw new QueuedConversationRecoveryBlockedError( + `native history for ${candidate.sessionId} differs from the canonical conversation (native=${executionItems.length}, canonical=${canonicalItems.length}); reconcile the missing history before retrying` + ); + } catch (error) { + if ( + error instanceof QueuedConversationRecoveryPendingError || + error instanceof QueuedConversationRecoveryBlockedError + ) { + throw error; + } + // An unknown reader failure cannot prove that this episode is absent or + // divergent. Fail closed and retry instead of silently selecting an + // older provider-native UUID whose relative history is unknown. + throw new QueuedConversationRecoveryPendingError( + `native transcript for ${candidate.sessionId} is temporarily unavailable: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + return null; +} + +/** + * Keep EventStore's render/cache projection aligned after a target-native + * episode has been synchronized from the canonical SessionEvent log. The + * provider file is only that episode's execution format; the verified + * canonical projection remains the conversation authority. + */ +async function hydrateSynchronizedConversationProjection( + sessionId: string, + before: readonly SessionEvent[], + after: readonly SessionEvent[] +): Promise { + if (before.length === after.length && sameEventPrefix(before, after)) return; + if (sameEventPrefix(before, after)) { + await eventStoreProxy.mergeEvents(after.slice(before.length), sessionId); + return; + } + await eventStoreProxy.set([...after], sessionId); +} + +async function waitForTurnTerminal( + sessionId: string, + turnIntentId: string +): Promise { + for (;;) { + try { + const terminal = await rpc.sessionCore.turnIntents.waitForTerminal({ + sessionId, + turnIntentId, + timeoutMs: TURN_WAIT_WINDOW_MS, + }); + log.info( + `[native-continuation] durable turn intent ${turnIntentId}: ${terminal.status}` + ); + return toTurnTerminalStatus(terminal.status); + } catch (error) { + // A bounded long-poll timeout is not a turn timeout. Re-read the exact + // durable row and open another window while the provider owns it. + const current = await rpc.sessionCore.turnIntents.status({ + sessionId, + turnIntentId, + }); + if ( + current && + ["optimistic", "queued", "running"].includes(current.status) + ) { + continue; + } + if (current) { + return toTurnTerminalStatus(current.status); + } + throw error; + } + } +} + +function sameEventPrefix( + before: readonly SessionEvent[], + after: readonly SessionEvent[] +): boolean { + return ( + before.length <= after.length && + before.every((event, index) => event.id === after[index]?.id) + ); +} + +function sliceTurnTail( + before: readonly SessionEvent[], + after: readonly SessionEvent[], + turnIntentId: string +): SessionEvent[] | null { + let appended: readonly SessionEvent[]; + if (sameEventPrefix(before, after)) { + appended = after.slice(before.length); + const anchor = appended.findIndex( + (event) => + event.source === "user" && conversationTurnIdOf(event) === turnIntentId + ); + if (anchor < 0) return null; + appended = appended.slice(anchor + 1); + } else { + const anchor = after.findIndex( + (event) => + event.source === "user" && conversationTurnIdOf(event) === turnIntentId + ); + if (anchor < 0) return null; + appended = after.slice(anchor + 1); + } + return removeKnownNativeConversationEchoes( + before, + appended.filter((event) => event.source !== "user") + ); +} + +/** + * Provider-native transcripts cannot be required to persist ORG2's private + * turn-intent id. After terminal, recover the structured native suffix by + * proving that the complete pre-turn portable transcript is still an exact + * semantic prefix, then locating the newly appended user message. This is a + * role/tool transcript comparison; no history is rendered into a prompt. + */ +function sliceProviderNativeTail( + before: readonly SessionEvent[], + after: readonly SessionEvent[], + turnIntentId: string, + expectedRequest: ProviderRequestIdentity, + logMismatch = true +): SessionEvent[] | null { + const beforeItems = projectNativeConversationItems(before); + const afterItems = projectNativeConversationItems(after); + if (!nativeConversationItemsArePrefix(beforeItems, afterItems)) { + if (logMismatch) { + log.warn( + `[native-continuation] native semantic prefix mismatch: before=${beforeItems.length}, after=${afterItems.length}` + ); + } + return null; + } + + const appendedItems = afterItems.slice(beforeItems.length); + const userIndex = appendedItems.findIndex( + (item) => + item.kind === "message" && + item.role === "user" && + (item.turnId === turnIntentId || + nativeUserMessageMatchesRequest(item, expectedRequest)) + ); + if (userIndex < 0) { + if (logMismatch) { + log.warn( + `[native-continuation] native suffix has no matching user anchor: appended=${appendedItems.length}` + ); + } + return null; + } + + const tailEventIds = new Set( + appendedItems.slice(userIndex + 1).map(sourceEventIdOfNativeItem) + ); + if (tailEventIds.size === 0) return []; + const tail = after.filter( + (event) => + event.source !== "user" && tailEventIds.has(nativeSourceEventId(event)) + ); + // The canonical user timestamp may predate a queued/retried native send. + // Preserve the provider's actual user-append boundary using the existing + // lifecycle event, which is excluded from native role/tool materialization. + const nativeUser = appendedItems[userIndex]; + if (tail.length > 0 && Number.isFinite(Date.parse(nativeUser.createdAt))) { + const id = `convturn-${turnIntentId}-native-start`; + tail.unshift({ + id, + chunk_id: id, + sessionId: tail[0].sessionId, + createdAt: nativeUser.createdAt, + functionName: "task_start", + uiCanonical: "task_start", + actionType: "task_start", + source: "system", + args: { [CONVERSATION_TURN_ID_ARG]: turnIntentId }, + result: { turnIntentId }, + displayText: "", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "processed", + payloadRefs: [], + }); + } + log.info( + `[native-continuation] recovered provider-native tail: items=${tailEventIds.size}, events=${tail.length}` + ); + return tail; +} + +function resolveSettledTail( + before: readonly SessionEvent[], + events: readonly SessionEvent[], + turnIntentId: string, + expectedRequest: ProviderRequestIdentity, + logMismatch: boolean +): SessionEvent[] | null { + const identifiedTail = sliceTurnTail(before, events, turnIntentId); + if (identifiedTail && identifiedTail.length > 0) return identifiedTail; + return sliceProviderNativeTail( + before, + events, + turnIntentId, + expectedRequest, + logMismatch + ); +} + +async function loadSettledTail( + sessionId: string, + before: readonly SessionEvent[], + turnIntentId: string, + expectedRequest: ProviderRequestIdentity, + preserveInterruptedSuffix: boolean +): Promise<{ agentTail: SessionEvent[]; events: SessionEvent[] }> { + const reconcileOptions = { + preserveInterruptedSuffix, + }; + let events = await reconcileNativeTranscript(sessionId, reconcileOptions); + let agentTail = resolveSettledTail( + before, + events, + turnIntentId, + expectedRequest, + false + ); + if (agentTail) return { agentTail, events }; + if (preserveInterruptedSuffix) { + if (providerClosedTurnWithoutRecordingPrompt(before, events)) { + // Stop reached the provider before it persisted the prompt: the native + // transcript gained only the turn's closing lifecycle marker and no + // portable item. Nothing further will converge, so this is the same + // durable empty-tail boundary as a user-only interrupted turn. The + // accepted user row stays on the canonical timeline for the next send. + log.warn( + `[native-continuation] ${turnIntentId} was interrupted before the provider recorded its prompt; settling an empty tail` + ); + return { agentTail: [], events }; + } + // `resolveSettledTail` returns [] (which is truthy) when the provider + // transcript contains the accepted user anchor but no assistant/tool + // output. `null` is materially different: the accepted turn has not yet + // converged into the native transcript/EventStore projection. Keep the + // durable queue item recovery-pending rather than publishing a false + // empty-tail success and losing the user's interrupted turn on rollover. + throw new QueuedConversationRecoveryPendingError( + `conversation turn ${turnIntentId} is missing its interrupted native transcript anchor` + ); + } + + // Backend terminal publication normally makes the first read complete. + // Retry only after this concrete semantic mismatch, never as a fixed delay + // in every queued turn's critical path. + events = await recoverNativeTranscriptAfterMismatch( + sessionId, + events, + (candidate) => + resolveSettledTail( + before, + candidate, + turnIntentId, + expectedRequest, + false + ) !== null, + reconcileOptions + ); + agentTail = resolveSettledTail( + before, + events, + turnIntentId, + expectedRequest, + true + ); + if (agentTail) return { agentTail, events }; + throw new Error( + `conversation turn ${turnIntentId} is missing its native transcript anchor` + ); +} + +const TURN_CLOSING_LIFECYCLE_ACTIONS = new Set([ + "task_completed", + "task_failed", +]); + +/** + * True when the provider transcript grew past the pre-turn prefix only by a + * closing task lifecycle marker: the provider finalized (aborted) the turn + * without ever recording the prompt or any portable output. The portable item + * list is unchanged, so no user anchor can appear later. + */ +export function providerClosedTurnWithoutRecordingPrompt( + before: readonly SessionEvent[], + after: readonly SessionEvent[] +): boolean { + const beforeItems = projectNativeConversationItems(before); + const afterItems = projectNativeConversationItems(after); + if ( + afterItems.length !== beforeItems.length || + !nativeConversationItemsArePrefix(beforeItems, afterItems) + ) { + return false; + } + const knownIds = new Set(before.map((event) => event.id)); + return after.some( + (event) => + !knownIds.has(event.id) && + TURN_CLOSING_LIFECYCLE_ACTIONS.has(event.actionType) + ); +} + +interface ProviderRequestIdentity { + text: string; + images: readonly string[]; +} + +function normalizeProviderRequestText(value: string): string { + // Native stores can normalize platform line endings while preserving the + // message byte-for-byte otherwise. Do not trim or use suffix matching: + // whitespace and provider context wrappers are part of the real payload. + return value.replace(/\r\n?/g, "\n"); +} + +function sameProviderImages( + left: readonly string[], + right: readonly string[] +): boolean { + return ( + left.length === right.length && + left.every((item, index) => item === right[index]) + ); +} + +function nativeUserMessageMatchesRequest( + item: { + text: string; + images: readonly string[]; + }, + expected: ProviderRequestIdentity +): boolean { + return ( + normalizeProviderRequestText(item.text) === + normalizeProviderRequestText(expected.text) && + sameProviderImages(item.images, expected.images) + ); +} + +async function finishConversationTurn(params: { + sessionId: string; + before: readonly SessionEvent[]; + turnIntentId: string; + providerRequest: ProviderRequestIdentity; + generation: number; + /** Recovery created this frontend lifecycle after provider acceptance. */ + settleAdoptedLifecycle?: boolean; +}): Promise< + Pick +> { + const terminalStatus = await waitForTurnTerminal( + params.sessionId, + params.turnIntentId + ); + // Keep the exact turn generation active until its authoritative tail is in + // EventStore. Releasing the FSM at the durable provider terminal would let + // the ordinary Session queue inject a follow-up against a stale transcript. + const settled = await loadSettledTail( + params.sessionId, + params.before, + params.turnIntentId, + params.providerRequest, + terminalStatus === "cancelled" || terminalStatus === "failed" + ); + // Fresh sends are closed only by the CLI/Agent lifecycle coordinator. + // Crash recovery created a synthetic frontend lifecycle after the original + // terminal event, so it alone closes that adopted generation here. + if (params.settleAdoptedLifecycle) { + settleUserIntentLifecycle(params, terminalStatus); + } + return { + terminalStatus, + agentTail: settled.agentTail, + }; +} + +async function prepareConversationTurn( + sessionId: string, + params: Pick< + ContinueLocalConversationParams, + "displayText" | "imageDataUrls" | "turnIntentId" | "root" | "queueMessageId" + >, + runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"] +): Promise { + // EventStore deduplicates user rows by turn intent. When the root itself + // executes, reuse its queue row instead of preparing a second id that the + // store will discard. A separate native episode owns a separate projection. + const queueMessageId = + params.root.authority === "local-session" && + params.root.conversationId === sessionId + ? params.queueMessageId + : undefined; + return prepareUserIntent({ + sessionId, + queueMessageId, + userEventId: queueMessageId + ? optimisticQueueUserEventId(queueMessageId) + : undefined, + visibleText: params.displayText, + imageDataUrls: params.imageDataUrls, + turnIntentId: params.turnIntentId, + runtimeStatusSource, + }); +} + +async function dispatchConversationMessage( + sessionId: string, + params: Omit, + options: { + allowNativeContextRecovery: boolean; + runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"]; + preparation?: ConversationTurnPreparation; + } +): ReturnType { + return dispatchUserIntent({ + sessionId, + visibleText: params.displayText, + imageDataUrls: params.imageDataUrls, + runtimeStatusSource: options.runtimeStatusSource, + preparation: options.preparation, + send: { + content: params.agentContent ?? params.displayText, + displayText: params.displayText, + model: params.target.model, + accountId: params.target.accountId, + mode: "build", + clientMessageId: `conversation-turn:${params.turnIntentId}`, + turnIntentId: params.turnIntentId, + turnIntentSource: "user_submit", + directUserIntent: true, + allowNativeContextRecovery: options.allowNativeContextRecovery, + }, + }); +} + +async function createConversationExecution( + params: Pick +): Promise<{ sessionId: string }> { + return SessionService.create({ + task: "", + name: params.title, + repoPath: params.target.workspaceRepoPath ?? undefined, + model: params.target.model, + accountId: params.target.accountId, + cliAgentType: params.target.cliAgentType, + keySource: "own_key", + agentDefinitionId: params.target.agentDefinitionId, + parentSessionId: conversationExecutionParentId(params.root), + mode: "build", + }); +} + +async function materializeCreatedConversation( + sessionId: string, + params: Pick +) { + // SessionEvent is the sole conversation authority. Even when the imported + // source and target happen to be the same provider, a new execution episode + // is rebuilt from the canonical role/tool event list instead of adopting a + // provider file. This guarantees Team Chat and turns produced by every + // other runtime participate in exactly the same target-native transcript. + return materializeNativeConversation({ + sessionId, + timeline: params.timeline, + }); +} + +interface CreatedConversationOptions { + loadTimeline: () => Promise; + onSessionCreated?: (sessionId: string) => void | Promise; +} + +async function runCreatedConversationTurn( + params: Omit, + options: CreatedConversationOptions +): Promise { + const created = await createConversationExecution(params); + let materialized: + | Awaited> + | undefined; + // Keep ownership of the eager visible preparation while launch is still + // pending. If session_launch rejects (bad OAuth, offline CLI, etc.), close + // that exact generation immediately instead of leaving the composer to the + // dispatching dead-man. + let preparation: ConversationTurnPreparation | null = null; + try { + preparation = await prepareConversationTurn( + created.sessionId, + params, + "launch" + ); + // Native transcript conversion can take materially longer than provider + // startup. Promote preparation out of the dispatch dead-man while keeping + // the same shared direct-turn lifecycle used by ordinary composer sends. + confirmUserIntentPreparation(preparation); + await options.onSessionCreated?.(created.sessionId); + activateUserIntentPreparation(preparation); + const timeline = (await options.loadTimeline()).filter( + (event) => conversationTurnIdOf(event) !== params.turnIntentId + ); + materialized = await materializeCreatedConversation(created.sessionId, { + timeline, + }); + // CLI native files are outside EventStore, so seed their verified replay + // for an immediate first render. Rust Agent materialization already + // hydrates its own EventStore; setting the same rows here would duplicate + // each user message under the Agent history adapter's normalized id. + if (params.target.cliAgentType) { + await eventStoreProxy.set( + [...materialized.events, preparation.userEvent], + created.sessionId + ); + } + await params.onSessionReady?.( + created.sessionId, + materialized.events.length + ); + await params.onBeforeTurnDispatch?.(created.sessionId); + const dispatched = await dispatchConversationMessage( + created.sessionId, + params, + { + // A fresh episode was rebuilt from the canonical role/tool list, so + // provider-native compact/rollover may recover a target-window limit. + allowNativeContextRecovery: true, + runtimeStatusSource: "launch", + preparation, + } + ); + preparation = dispatched.preparation; + } catch (error) { + log.error( + `[localConversationContinuation] launch turn failed for ${created.sessionId}:`, + error + ); + if (preparation) { + if (error instanceof QueuedConversationRecoveryPendingError) throw error; + await failUserIntentPreparation(preparation, error).catch( + () => undefined + ); + throw isUserIntentSendError(error) + ? error + : new UserIntentSendError(error, preparation.userEvent.id); + } + throw error; + } + + await notifyConversationTurnAccepted( + params.onTurnAccepted, + created.sessionId, + params.turnIntentId + ); + + if (!materialized) { + throw new Error("conversation materialization completed without a receipt"); + } + if (!preparation) { + throw new Error("conversation dispatch completed without a preparation"); + } + + const finished = await finishConversationTurn({ + sessionId: created.sessionId, + before: materialized.events, + turnIntentId: params.turnIntentId, + providerRequest: { + text: params.agentContent ?? params.displayText, + images: params.imageDataUrls ?? [], + }, + generation: preparation.generation, + }); + return { + sessionId: created.sessionId, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; +} + +async function continueLocalConversationAtQueueHead( + params: ContinueLocalConversationParams, + knownCandidates?: readonly ExecutionCandidate[], + reloadTimelineForRollover?: () => Promise +): Promise { + // Queue admission renders the new user row immediately on the canonical + // source. Materialization must rebuild the transcript *before* that turn; + // the provider receives it exactly once through dispatchUserIntent below. + const effectiveParams = { + ...params, + timeline: params.timeline.filter( + (event) => conversationTurnIdOf(event) !== params.turnIntentId + ), + }; + // Publishing the canonical user turn is independent of local execution + // discovery. Cloud/root surfaces can render it while a native episode is + // still being verified or materialized. + const compatible = await findCompatibleExecution( + effectiveParams.root, + effectiveParams.target, + effectiveParams.timeline, + knownCandidates + ); + if (compatible) { + const preparation = await prepareConversationTurn( + compatible.sessionId, + effectiveParams, + "dispatch" + ); + // Synchronizing a large canonical delta is part of the accepted user + // intent, not a pre-submit loading screen. Use the same optimistic row, + // generation, and planning footer as an ordinary queued send before any + // provider-native I/O begins. + confirmUserIntentPreparation(preparation); + let dispatched: Awaited>; + try { + await effectiveParams.onSessionPreparing?.(compatible.sessionId); + activateUserIntentPreparation(preparation); + const beforeSynchronization = compatible.events; + const synchronized = await synchronizeNativeConversation({ + sessionId: compatible.sessionId, + timeline: effectiveParams.timeline, + }); + compatible.events = synchronized.events; + if (effectiveParams.target.cliAgentType) { + await hydrateSynchronizedConversationProjection( + compatible.sessionId, + beforeSynchronization, + synchronized.events + ); + } + // Reveal/follow the writable episode before dispatch. The ordinary + // optimistic row and planning footer are already mounted while native + // synchronization runs; this exact boundary only opens the live event + // overlay at the verified pre-turn prefix. + await effectiveParams.onSessionReady?.( + compatible.sessionId, + compatible.events.length + ); + await effectiveParams.onBeforeTurnDispatch?.(compatible.sessionId); + dispatched = await dispatchConversationMessage( + compatible.sessionId, + effectiveParams, + { + // Permission is not a trigger: the native transport still requires + // an explicit context-exhausted terminal with zero assistant/tool + // output. A compatible episode is already synchronized to the + // canonical prefix, so provider-native compact/rollover is the + // cheapest first recovery. The fresh canonical rebuild below + // remains the fallback when native recovery itself fails. + allowNativeContextRecovery: true, + runtimeStatusSource: "dispatch", + preparation, + } + ); + } catch (error) { + if (error instanceof QueuedConversationRecoveryPendingError) throw error; + const rebuildReason = isCodexNativeEpisodeAlreadyOwned( + error, + effectiveParams.target + ) + ? "its native UUID has an active writer" + : null; + if (rebuildReason) { + const rolloverTimeline = reloadTimelineForRollover + ? await reloadTimelineForRollover() + : effectiveParams.timeline; + // The native App exclusively owns this UUID, but synchronization has + // already verified its history against the canonical timeline. + // Discard only this failed optimistic echo and rebuild a fresh episode. + // A prefix mismatch is NOT a rollover signal: rebuilding from a shorter + // or divergent plane could silently omit native-only tool/output rows. + // That error follows the ordinary visible failed-intent path below. + // runCreatedConversationTurn does not recurse through candidate lookup, + // which bounds this recovery to one automatic resend of the same intent. + await eventStoreProxy.removeSyntheticUserInputEvents( + compatible.sessionId, + { + matchingContents: [], + matchingTurnIntentIds: [effectiveParams.turnIntentId], + } + ); + log.info( + `[localConversationContinuation] rebuilding episode ${compatible.sessionId} because ${rebuildReason}` + ); + return runCreatedConversationTurn(effectiveParams, { + loadTimeline: async () => rolloverTimeline, + onSessionCreated: effectiveParams.onSessionPreparing, + }); + } + log.error( + `[localConversationContinuation] resume turn failed for ${compatible.sessionId}:`, + error + ); + await failUserIntentPreparation(preparation, error).catch( + () => undefined + ); + throw isUserIntentSendError(error) + ? error + : new UserIntentSendError(error, preparation.userEvent.id); + } + await notifyConversationTurnAccepted( + effectiveParams.onTurnAccepted, + compatible.sessionId, + effectiveParams.turnIntentId + ); + const finished = await finishConversationTurn({ + sessionId: compatible.sessionId, + before: compatible.events, + turnIntentId: effectiveParams.turnIntentId, + providerRequest: { + text: effectiveParams.agentContent ?? effectiveParams.displayText, + images: effectiveParams.imageDataUrls ?? [], + }, + generation: dispatched.preparation.generation, + }); + return { + sessionId: compatible.sessionId, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; + } + + return runCreatedConversationTurn(effectiveParams, { + loadTimeline: async () => effectiveParams.timeline, + onSessionCreated: effectiveParams.onSessionPreparing, + }); +} + +function assertSupportedConversationTarget( + target: LocalConversationTarget +): void { + if (!supportsNativeConversationTarget(target)) { + throw new Error( + `target ${target.cliAgentType ?? "native"} cannot materialize a provider-native role/tool transcript` + ); + } +} + +/** + * Reconnect a durable queue row to a provider turn accepted before this + * renderer stopped. `session_turn_intents` is the acceptance authority; the + * queue contributes only the concrete runner address needed to find it. + * Returning `null` proves the backend never accepted this intent, so the + * caller may safely run the ordinary dispatch path with the same id. + */ +export async function recoverLocalConversationTurn( + params: RecoverLocalConversationParams +): Promise { + assertSupportedConversationTarget(params.target); + const durableIntent = await rpc.sessionCore.turnIntents.status({ + sessionId: params.runnerSessionId, + turnIntentId: params.turnIntentId, + }); + if (!durableIntent || durableIntent.status === "optimistic") return null; + if (["stale", "coalesced", "rejected"].includes(durableIntent.status)) { + throw new QueuedConversationRecoveryBlockedError( + `conversation turn was retired before provider execution (${durableIntent.status}); edit or retry it as a new intent` + ); + } + + // The backend turn-intent row is the provider-acceptance authority. A + // renderer can stop after that row becomes queued/running/terminal but + // before the frontend delivery receipt advances from `preparing`. Restore + // that irreversible boundary before doing any transcript/candidate reads: + // those reads may remain temporarily unavailable, but the queue must never + // present the accepted user turn as Sending or make it eligible for a fresh + // provider launch. + await params.onBeforeTurnDispatch?.(params.runnerSessionId); + await notifyConversationTurnAccepted( + params.onTurnAccepted, + params.runnerSessionId, + params.turnIntentId + ); + + const candidates = await listExecutionCandidates(params.root); + const belongsToRoot = + candidates.some( + (candidate) => candidate.sessionId === params.runnerSessionId + ) || + (params.root.authority === "local-session" && + params.root.conversationId === params.runnerSessionId); + if ( + !belongsToRoot || + !(await candidateMatchesTarget(params.runnerSessionId, params.target)) + ) { + throw new QueuedConversationRecoveryBlockedError( + "durable conversation runner no longer belongs to this root/target" + ); + } + + const timeline = params.timeline.filter( + (event) => conversationTurnIdOf(event) !== params.turnIntentId + ); + const { events } = await loadAuthoritativeSessionEvents( + params.runnerSessionId + ); + const canonicalItems = projectNativeConversationItems(timeline); + const executionItems = projectNativeConversationItems(events); + if ( + !nativeConversationItemsAreProviderPortablePrefix( + canonicalItems, + executionItems + ) + ) { + throw new QueuedConversationRecoveryBlockedError( + "accepted conversation runner diverged from the canonical transcript" + ); + } + + const adopted = adoptAcceptedUserIntent({ + sessionId: params.runnerSessionId, + turnIntentId: params.turnIntentId, + runtimeStatusSource: "dispatch", + }); + try { + await params.onSessionPreparing?.(params.runnerSessionId); + await params.onSessionReady?.( + params.runnerSessionId, + params.eventStartIndex ?? timeline.length + ); + const finished = await finishConversationTurn({ + sessionId: params.runnerSessionId, + before: timeline, + turnIntentId: params.turnIntentId, + providerRequest: { + text: params.agentContent ?? params.displayText, + images: params.imageDataUrls ?? [], + }, + generation: adopted.generation, + settleAdoptedLifecycle: true, + }); + return { + sessionId: params.runnerSessionId, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; + } catch (error) { + if (error instanceof QueuedConversationRecoveryPendingError) throw error; + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } +} + +export async function continueLocalConversation( + params: ContinueLocalConversationParams +): Promise { + assertSupportedConversationTarget(params.target); + return continueLocalConversationAtQueueHead(params); +} + +/** + * Continue a canonical conversation whose authoritative history is mutable. + * History is loaded only after the application's singleton durable queue has + * granted this root its turn. Serialization belongs to + * useQueueDispatch/turnLifecycle, not to this provider adapter. + */ +export async function continueLocalConversationAfterTimelineLoad( + params: ContinueLocalConversationAfterTimelineLoadParams +): Promise { + assertSupportedConversationTarget(params.target); + const { loadTimeline, ...continuationParams } = params; + const candidates = await listExecutionCandidates(params.root); + const matchingCandidates: ExecutionCandidate[] = []; + for (const candidate of candidates) { + if (await candidateMatchesTarget(candidate.sessionId, params.target)) { + matchingCandidates.push(candidate); + } + } + if (matchingCandidates.length === 0) { + // No native episode could possibly be reused. Create the ordinary Session + // before parsing a potentially large imported transcript so its pending + // row, footer and follow-up queue appear through the existing UI path. + return runCreatedConversationTurn(continuationParams, { + loadTimeline, + onSessionCreated: params.onSessionPreparing, + }); + } + const timeline = await loadTimeline(); + return continueLocalConversationAtQueueHead( + { ...continuationParams, timeline }, + matchingCandidates, + loadTimeline + ); +} diff --git a/src/engines/SessionCore/conversations/localConversationExecutionTail.test.ts b/src/engines/SessionCore/conversations/localConversationExecutionTail.test.ts new file mode 100644 index 0000000000..1058c780cb --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationExecutionTail.test.ts @@ -0,0 +1,1077 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { optimisticQueueUserEventId } from "@src/engines/SessionCore/services/userIntentDispatch"; + +import { + collapseRetriedPromptCopies, + loadLocalCanonicalConversationSnapshot, + loadLocalCanonicalConversationTimeline, + mergeVerifiedLocalExecutionTimeline, + projectVerifiedLocalExecutionTail, + resolveLocalExecutionChildren, + suppressLandedQueuedUserRows, + suppressLandedRowsOfFailedQueuedTurns, + verifiedNativeConversationSuffixEvents, +} from "./localConversationExecutionTail"; + +const mocks = vi.hoisted(() => ({ + invokeTauri: vi.fn(), + loadCanonical: vi.fn(), + loadCliRevision: vi.fn(), +})); + +vi.mock("@src/util/platform/tauri/init", () => ({ + invokeTauri: mocks.invokeTauri, +})); + +vi.mock("./canonicalConversationEvents", () => ({ + loadCanonicalConversationEvents: mocks.loadCanonical, +})); + +vi.mock("@src/engines/SessionCore/sync/adapters/cli/cliHistory", () => ({ + loadCliTranscriptRevision: mocks.loadCliRevision, +})); + +function event( + id: string, + createdAt: string, + source: SessionEvent["source"], + displayText: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "child-1", + createdAt, + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user" : "assistant_message", + actionType: source === "user" ? "raw" : "assistant", + args: {}, + result: {}, + source, + displayText, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +describe("local conversation execution tail", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadCliRevision.mockResolvedValue(undefined); + }); + + it("returns the complete native-App delta only after a strict canonical prefix", () => { + const canonical = [ + event("root-u1", "2026-09-04T05:00:00Z", "user", "round one"), + event("root-a1", "2026-09-04T05:00:01Z", "assistant", "one"), + ]; + const nativeAppUser = event( + "claude-u2", + "2026-09-04T05:01:00Z", + "user", + "native app prompt" + ); + const nativeAppAnswer = event( + "claude-a2", + "2026-09-04T05:01:01Z", + "assistant", + "native app answer" + ); + + expect( + verifiedNativeConversationSuffixEvents(canonical, [ + ...canonical.map((item) => ({ + ...item, + id: `copy-${item.id}`, + chunk_id: `copy-${item.id}`, + })), + nativeAppUser, + nativeAppAnswer, + ]) + ).toEqual([nativeAppUser, nativeAppAnswer]); + expect( + verifiedNativeConversationSuffixEvents(canonical, [ + event("different-u", "2026-09-04T05:00:00Z", "user", "branch"), + event("different-a", "2026-09-04T05:00:01Z", "assistant", "answer"), + ]) + ).toBeNull(); + }); + + it("resolves children with a known creation time in creation order", () => { + const children = resolveLocalExecutionChildren( + [ + { sessionId: "later" }, + { sessionId: "unknown-created" }, + { sessionId: "earlier" }, + { sessionId: "earlier" }, + ], + new Map([ + ["later", "2026-09-04T06:10:00Z"], + ["unknown-created", undefined], + ["earlier", "2026-09-04T05:58:37Z"], + ]), + new Map([ + ["later", "2026-09-04T06:12:00Z"], + ["unknown-created", undefined], + ["earlier", "2026-09-04T06:00:00Z"], + ]) + ); + expect(children).toEqual([ + { + session_id: "earlier", + created_at: "2026-09-04T05:58:37Z", + updated_at: "2026-09-04T06:00:00Z", + }, + { + session_id: "later", + created_at: "2026-09-04T06:10:00Z", + updated_at: "2026-09-04T06:12:00Z", + }, + ]); + }); + + it("returns one stable root-plus-child snapshot for the replay owner", async () => { + const children = [ + { + sessionId: "cliagent-claude-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + }, + ]; + mocks.invokeTauri.mockResolvedValue(children); + mocks.loadCliRevision.mockResolvedValue("native-v1"); + mocks.loadCanonical.mockImplementation(async (sessionId: string) => ({ + source: "native_store", + events: + sessionId === "root-1" + ? [event("root-a", "2026-09-04T05:00:00Z", "assistant", "root")] + : [ + event( + "child-copy-a", + "2026-09-04T05:00:00Z", + "assistant", + "root" + ), + event("child-a", "2026-09-04T05:02:00Z", "assistant", "child"), + ], + })); + + await expect( + loadLocalCanonicalConversationSnapshot({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toMatchObject({ + events: [ + expect.objectContaining({ displayText: "root" }), + expect.objectContaining({ displayText: "child" }), + ], + rootEvents: [expect.objectContaining({ displayText: "root" })], + segments: [ + { + child: expect.objectContaining({ + session_id: "cliagent-claude-child", + }), + events: expect.arrayContaining([ + expect.objectContaining({ displayText: "child" }), + ]), + }, + ], + childRevision: JSON.stringify([ + [ + "cliagent-claude-child", + "2026-09-04T05:01:00Z", + "2026-09-04T05:02:00Z", + "native-v1", + ], + ]), + }); + expect(mocks.invokeTauri).toHaveBeenCalledTimes(2); + expect(mocks.loadCliRevision).toHaveBeenCalledTimes(3); + }); + + it("rejects a snapshot when the native App appends during a child read", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-codex-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + status: "completed", + isTerminal: true, + }, + ]); + mocks.loadCliRevision + .mockResolvedValueOnce("native-before") + .mockResolvedValue("native-after"); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationSnapshot({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toMatchObject({ childRevision: null }); + }); + + it("rejects a snapshot when the native App appends before the final frontier check", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-claude-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + }, + ]); + mocks.loadCliRevision + .mockResolvedValueOnce("native-before") + .mockResolvedValueOnce("native-before") + .mockResolvedValueOnce("native-after"); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationSnapshot({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toMatchObject({ childRevision: null }); + }); + + it("keeps a running child with an unavailable native transcript pending", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-legacy-cursor-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + status: "running", + isTerminal: false, + }, + ]); + mocks.loadCliRevision.mockResolvedValue(null); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationSnapshot({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toMatchObject({ childRevision: null }); + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + }); + + it("blocks a settled child whose native transcript is unavailable until manual retry", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-codex-settled-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + status: "completed", + isTerminal: true, + }, + ]); + mocks.loadCliRevision + .mockResolvedValueOnce(null) + .mockResolvedValue("native-restored"); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).rejects.toBeInstanceOf(QueuedConversationBlockedError); + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toEqual([expect.objectContaining({ displayText: "body" })]); + }); + + it("blocks an idle child whose native transcript is unavailable", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-member-idle-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + status: "idle", + isTerminal: false, + }, + ]); + mocks.loadCliRevision.mockResolvedValue(null); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [], + }); + + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).rejects.toBeInstanceOf(QueuedConversationBlockedError); + }); + + it("immediately rereads an unstable canonical timeline once", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-codex-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + }, + ]); + mocks.loadCliRevision + .mockResolvedValueOnce("native-v1") + .mockResolvedValueOnce("native-v2") + .mockResolvedValue("native-v2"); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).resolves.toEqual([expect.objectContaining({ displayText: "body" })]); + expect(mocks.loadCliRevision).toHaveBeenCalledTimes(6); + }); + + it("returns typed recovery pending after two unstable canonical reads", async () => { + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-codex-child", + createdAt: "2026-09-04T05:01:00Z", + updatedAt: "2026-09-04T05:02:00Z", + }, + ]); + mocks.loadCliRevision + .mockResolvedValueOnce("native-v1") + .mockResolvedValueOnce("native-v2") + .mockResolvedValueOnce("native-v2") + .mockResolvedValueOnce("native-v2") + .mockResolvedValueOnce("native-v3") + .mockResolvedValueOnce("native-v3"); + mocks.loadCanonical.mockResolvedValue({ + source: "native_store", + events: [event("event", "2026-09-04T05:02:00Z", "assistant", "body")], + }); + + await expect( + loadLocalCanonicalConversationTimeline({ + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + expect(mocks.loadCliRevision).toHaveBeenCalledTimes(6); + }); + + it("folds successive provider episodes into one runtime-switch timeline", () => { + const rootEvents = [ + event("codex-u1", "2026-09-04T05:00:00Z", "user", "round one"), + event("codex-a1", "2026-09-04T05:00:01Z", "assistant", "one"), + ]; + // The same Claude UUID was resumed for rounds two and three, so its latest + // native transcript contains both suffixes after the materialized Codex + // prefix. Returning to Codex consumes this complete canonical timeline. + const claudeEvents = [ + event("claude-copy-u1", "2026-09-04T05:01:00Z", "user", "round one"), + event("claude-copy-a1", "2026-09-04T05:01:01Z", "assistant", "one"), + event("claude-u2", "2026-09-04T05:02:00Z", "user", "round two"), + event("claude-a2", "2026-09-04T05:02:01Z", "assistant", "two"), + event("claude-u3", "2026-09-04T05:03:00Z", "user", "round three"), + event("claude-a3", "2026-09-04T05:03:01Z", "assistant", "three"), + ]; + const segments = [ + { + child: { + session_id: "claude-child", + created_at: "2026-09-04T05:01:00Z", + }, + events: claudeEvents, + }, + ]; + expect( + mergeVerifiedLocalExecutionTimeline(rootEvents, segments).map( + (candidate) => candidate.displayText + ) + ).toEqual(["round one", "one", "round two", "two", "round three", "three"]); + expect( + projectVerifiedLocalExecutionTail(rootEvents, segments, "codex-root").map( + (candidate) => [candidate.sessionId, candidate.displayText] + ) + ).toEqual([ + ["codex-root", "round two"], + ["codex-root", "two"], + ["codex-root", "round three"], + ["codex-root", "three"], + ]); + + // Once Codex is synchronized and resumed, the root itself contains the + // Claude rounds plus the new Codex suffix. Replaying the older Claude + // child must not append those rounds a second time. + const returnedCodexEvents = [ + ...rootEvents, + ...claudeEvents.slice(rootEvents.length), + event("codex-u4", "2026-09-04T05:04:00Z", "user", "round four"), + event("codex-a4", "2026-09-04T05:04:01Z", "assistant", "four"), + ]; + expect( + mergeVerifiedLocalExecutionTimeline(returnedCodexEvents, segments).map( + (candidate) => candidate.displayText + ) + ).toEqual([ + "round one", + "one", + "round two", + "two", + "round three", + "three", + "round four", + "four", + ]); + }); + + it("orders a later child's original turn ahead of a reused child's injected copy", () => { + const rootEvents = [ + event("root-u1", "2026-09-06T03:00:00Z", "user", "who are you"), + event("root-a1", "2026-09-06T03:00:01Z", "assistant", "an agent"), + ]; + // The Codex child ran turns A and C. Turn B ran in the Claude child and + // was injected back into the Codex thread before C; Codex stamped that + // copy with the injection time, after B's real timestamps. + const codexEvents = [ + event("codex-copy-u1", "2026-09-06T03:32:18Z", "user", "who are you"), + event("codex-copy-a1", "2026-09-06T03:32:18Z", "assistant", "an agent"), + event("codex-uA", "2026-09-06T03:32:44Z", "user", "prompt A"), + event("codex-aA", "2026-09-06T03:33:11Z", "assistant", "reply A"), + event("codex-copy-uB", "2026-09-06T03:50:09Z", "user", "prompt B"), + event("codex-copy-aB", "2026-09-06T03:50:09Z", "assistant", "reply B"), + event("codex-uC", "2026-09-06T03:50:30Z", "user", "prompt C"), + event("codex-aC", "2026-09-06T03:50:36Z", "assistant", "reply C"), + ]; + const claudeEvents = [ + event("claude-copy-u1", "2026-09-06T03:00:00Z", "user", "who are you"), + event("claude-copy-a1", "2026-09-06T03:00:01Z", "assistant", "an agent"), + event("claude-copy-uA", "2026-09-06T03:32:44Z", "user", "prompt A"), + event("claude-copy-aA", "2026-09-06T03:33:11Z", "assistant", "reply A"), + event("claude-uB", "2026-09-06T03:48:32Z", "user", "prompt B"), + event("claude-aB", "2026-09-06T03:48:35Z", "assistant", "reply B"), + event("claude-copy-uC", "2026-09-06T03:50:30Z", "user", "prompt C"), + event("claude-copy-aC", "2026-09-06T03:50:36Z", "assistant", "reply C"), + event("claude-uD", "2026-09-06T03:51:53Z", "user", "prompt D"), + event("claude-aD", "2026-09-06T03:51:55Z", "assistant", "reply D"), + ]; + const segments = [ + { + child: { + session_id: "codex-child", + created_at: "2026-09-06T03:32:12Z", + }, + events: codexEvents, + }, + { + child: { + session_id: "claude-child", + created_at: "2026-09-06T03:48:30Z", + }, + events: claudeEvents, + }, + ]; + + expect( + mergeVerifiedLocalExecutionTimeline(rootEvents, segments).map( + (candidate) => candidate.id + ) + ).toEqual([ + "root-u1", + "root-a1", + "codex-uA", + "codex-aA", + "claude-uB", + "claude-aB", + "codex-uC", + "codex-aC", + "claude-uD", + "claude-aD", + ]); + }); + + it("folds a provider child when native tool call ids were rewritten", () => { + const rootTool = { + ...event( + "codex-tool", + "2026-09-04T20:15:15.000Z", + "assistant", + "file contents" + ), + functionName: "read_file", + actionType: "tool_call", + callId: "call_codex:part-0", + args: { path: "CLAUDE.md" }, + result: { status: "completed", output: "file contents" }, + } as SessionEvent; + const rootEvents = [ + event("codex-u1", "2026-09-04T20:15:14.000Z", "user", "inspect"), + rootTool, + event("codex-a1", "2026-09-04T20:15:16.000Z", "assistant", "done"), + ]; + const childEvents = [ + event("claude-u1", "2026-09-04T20:15:14.000Z", "user", "inspect"), + { + ...rootTool, + id: "claude-tool", + chunk_id: "claude-tool", + callId: "call_claude_rewritten", + }, + event("claude-a1", "2026-09-04T20:15:16.000Z", "assistant", "done"), + event( + "claude-u2", + "2026-09-04T22:06:46.000Z", + "user", + "native app prompt" + ), + event( + "claude-a2", + "2026-09-04T22:06:52.000Z", + "assistant", + "native app answer" + ), + ]; + + expect( + projectVerifiedLocalExecutionTail( + rootEvents, + [ + { + child: { + session_id: "claude-child", + created_at: "2026-09-04T21:15:47.329Z", + }, + events: childEvents, + }, + ], + "codex-root" + ).map((candidate) => candidate.displayText) + ).toEqual(["native app prompt", "native app answer"]); + }); + + it("projects a reused child when the root already shows its newest optimistic user row", () => { + const rootNative = [ + event( + "codex-u1", + "2026-09-04T20:15:14.324Z", + "user", + "Reply exactly CU_PR939_PRIMARY_CODEX_NATIVE_FIXED_20260905_OK" + ), + event( + "codex-a1", + "2026-09-04T20:15:16.936Z", + "assistant", + "CU_PR939_PRIMARY_CODEX_NATIVE_FIXED_20260905_OK" + ), + ]; + const newestOptimistic = event( + optimisticQueueUserEventId("hydration-retest"), + "2026-09-04T22:06:46.141Z", + "user", + "Reply exactly CU_CHILD_HYDRATION_RETEST_20260905_OK" + ); + newestOptimistic.result = { turnIntentId: "hydration-retest" }; + const newestLanded = event( + "claude-u3", + "2026-09-04T22:06:46.246Z", + "user", + "Reply exactly CU_CHILD_HYDRATION_RETEST_20260905_OK" + ); + newestLanded.result = { turnIntentId: "hydration-retest" }; + const childEvents = [ + ...rootNative.map((item) => ({ + ...item, + id: `claude-copy-${item.id}`, + chunk_id: `claude-copy-${item.id}`, + })), + event( + "claude-u2", + "2026-09-04T21:46:48.894Z", + "user", + "Reply exactly CU_UI_CHILD_REFRESH_FIXED_20260905_OK" + ), + event( + "claude-a2", + "2026-09-04T21:46:54.459Z", + "assistant", + "CU_UI_CHILD_REFRESH_FIXED_20260905_OK" + ), + newestLanded, + event( + "claude-a3", + "2026-09-04T22:06:52.068Z", + "assistant", + "CU_CHILD_HYDRATION_RETEST_20260905_OK" + ), + ]; + + // The optimistic root row is chronologically newest but appears before + // every projected child suffix in the input array. It must not participate + // in native-prefix verification or it hides both completed Claude turns. + const tail = projectVerifiedLocalExecutionTail( + [...rootNative, newestOptimistic], + [ + { + child: { + session_id: "claude-child", + created_at: "2026-09-04T21:15:47.329Z", + }, + events: childEvents, + }, + ], + "codex-root" + ); + expect(tail.map((candidate) => candidate.displayText)).toEqual([ + "Reply exactly CU_UI_CHILD_REFRESH_FIXED_20260905_OK", + "CU_UI_CHILD_REFRESH_FIXED_20260905_OK", + "Reply exactly CU_CHILD_HYDRATION_RETEST_20260905_OK", + "CU_CHILD_HYDRATION_RETEST_20260905_OK", + ]); + expect( + suppressLandedQueuedUserRows([...rootNative, newestOptimistic], tail).map( + (candidate) => candidate.id + ) + ).toEqual(rootNative.map((candidate) => candidate.id)); + }); + + it("folds a provider-native compact marker after its verified history", () => { + const rootEvents = [ + event("root-u1", "2026-09-04T05:00:00Z", "user", "old question"), + event("root-a1", "2026-09-04T05:00:01Z", "assistant", "old answer"), + ]; + const compact = { + ...event( + "compact-1", + "2026-09-04T05:01:00Z", + "assistant", + "summary of the old exchange" + ), + functionName: "context_compacted", + actionType: "context_compacted", + } as SessionEvent; + const childEvents = [ + event("copy-u1", "2026-09-04T05:00:00Z", "user", "old question"), + event("copy-a1", "2026-09-04T05:00:01Z", "assistant", "old answer"), + compact, + event("child-u2", "2026-09-04T05:02:00Z", "user", "new question"), + event("child-a2", "2026-09-04T05:02:01Z", "assistant", "new answer"), + ]; + const merged = mergeVerifiedLocalExecutionTimeline(rootEvents, [ + { + child: { + session_id: "compacted-child", + created_at: "2026-09-04T05:01:00Z", + }, + events: childEvents, + }, + ]); + expect(merged.map((candidate) => candidate.id)).toEqual([ + "root-u1", + "root-a1", + "compact-1", + "child-u2", + "child-a2", + ]); + }); + + it("folds a second native compact from the prior effective message list", () => { + const firstCompact = { + ...event( + "compact-1", + "2026-09-04T05:01:00Z", + "assistant", + "first summary" + ), + functionName: "context_compacted", + actionType: "context_compacted", + } as SessionEvent; + const canonical = [ + event("old-u", "2026-09-04T05:00:00Z", "user", "old question"), + event("old-a", "2026-09-04T05:00:01Z", "assistant", "old answer"), + firstCompact, + event("u2", "2026-09-04T05:02:00Z", "user", "after first"), + event("a2", "2026-09-04T05:02:01Z", "assistant", "answer two"), + ]; + const secondCompact = { + ...event( + "compact-2", + "2026-09-04T05:03:01Z", + "assistant", + "second summary" + ), + functionName: "context_compacted", + actionType: "context_compacted", + } as SessionEvent; + const childEvents = [ + { ...firstCompact, id: "copy-compact-1", chunk_id: "copy-compact-1" }, + event("copy-u2", "2026-09-04T05:02:00Z", "user", "after first"), + event("copy-a2", "2026-09-04T05:02:01Z", "assistant", "answer two"), + event("u3", "2026-09-04T05:03:00Z", "user", "trigger compact"), + secondCompact, + event("a3", "2026-09-04T05:03:02Z", "assistant", "answer three"), + ]; + expect( + mergeVerifiedLocalExecutionTimeline(canonical, [ + { + child: { + session_id: "second-compact-child", + created_at: "2026-09-04T05:03:00Z", + }, + events: childEvents, + }, + ]).map((candidate) => candidate.id) + ).toEqual([ + "old-u", + "old-a", + "compact-1", + "u2", + "a2", + "u3", + "compact-2", + "a3", + ]); + }); + + it("keeps an interrupted portable suffix but drops its unresolved tool call", () => { + const rootEvents = [ + event("root-u1", "2026-09-04T05:00:00Z", "user", "inspect"), + event("root-a1", "2026-09-04T05:00:01Z", "assistant", "starting"), + ]; + const completedTool = { + ...event( + "tool-complete", + "2026-09-04T05:01:01Z", + "assistant", + "file contents" + ), + functionName: "read_file", + actionType: "tool_call", + callId: "call_complete", + args: { path: "README.md" }, + result: { status: "completed", output: "file contents" }, + } as SessionEvent; + const unresolvedTool = { + ...completedTool, + id: "tool-open", + chunk_id: "tool-open", + callId: "call_open", + displayStatus: "running", + result: { status: "running" }, + } as SessionEvent; + const childEvents = [ + event("copy-u1", "2026-09-04T05:00:00Z", "user", "inspect"), + event("copy-a1", "2026-09-04T05:00:01Z", "assistant", "starting"), + event("child-u2", "2026-09-04T05:01:00Z", "user", "continue"), + completedTool, + event( + "child-partial", + "2026-09-04T05:01:02Z", + "assistant", + "partial result" + ), + unresolvedTool, + ]; + const merged = mergeVerifiedLocalExecutionTimeline(rootEvents, [ + { + child: { + session_id: "interrupted-child", + created_at: "2026-09-04T05:01:00Z", + }, + events: childEvents, + }, + ]); + expect(merged.map((candidate) => candidate.id)).toEqual([ + "root-u1", + "root-a1", + "child-u2", + "tool-complete", + "child-partial", + ]); + }); + + it("drops the queue-synthesized pending row once the same user turn landed", () => { + const pending = event( + optimisticQueueUserEventId("intent-1"), + "2026-09-04T05:58:37Z", + "user", + "Reply with exactly MARKER" + ); + pending.result = { turnIntentId: "intent-1" }; + const otherPending = event( + optimisticQueueUserEventId("intent-2"), + "2026-09-04T05:59:00Z", + "user", + "another queued message" + ); + otherPending.result = { turnIntentId: "intent-2" }; + const history = event("hist-1", "2026-08-31T10:34:04Z", "user", "old"); + const landedUser = event( + "runlanded-user-1", + "2026-09-04T05:58:44Z", + "user", + "Reply with exactly MARKER " + ); + landedUser.result = { turnIntentId: "intent-1" }; + expect(pending.id).toBe("queued-user:intent-1:"); + expect( + suppressLandedQueuedUserRows( + [history, pending, otherPending], + [landedUser] + ).map((candidate) => candidate.id) + ).toEqual(["hist-1", optimisticQueueUserEventId("intent-2")]); + expect(suppressLandedQueuedUserRows([history, pending], [])).toHaveLength( + 2 + ); + }); + + it("collapses repeated projections of the same identified retry only", () => { + const first = event("u-try-1", "2026-09-06T04:39:28Z", "user", "Reply now"); + const second = event( + "u-try-2", + "2026-09-06T04:53:27Z", + "user", + "Reply now" + ); + const third = event("u-try-3", "2026-09-06T05:39:00Z", "user", "Reply now"); + const reply = event("a-final", "2026-09-06T05:39:15Z", "assistant", "done"); + const later = event("u-again", "2026-09-06T05:40:00Z", "user", "Reply now"); + for (const retry of [first, second, third]) { + retry.result = { turnIntentId: "retry-intent" }; + } + later.result = { turnIntentId: "later-intent" }; + expect( + collapseRetriedPromptCopies([first, second, third, reply, later]).map( + (candidate) => candidate.id + ) + ).toEqual(["u-try-3", "a-final", "u-again"]); + }); + + it("keeps equal text from distinct intents and attachments", () => { + const first = event("u-first", "2026-09-06T05:39:00Z", "user", "same"); + first.result = { + turnIntentId: "intent-first", + images: ["data:image/png;base64,first"], + }; + const second = event("u-second", "2026-09-06T05:39:01Z", "user", "same"); + second.result = { + turnIntentId: "intent-second", + images: ["data:image/png;base64,second"], + }; + const legacy = event("u-legacy", "2026-09-06T05:39:02Z", "user", "same"); + + expect( + collapseRetriedPromptCopies([first, second, legacy]).map( + (candidate) => candidate.id + ) + ).toEqual(["u-first", "u-second", "u-legacy"]); + }); + + it("keeps a failed optimistic row and drops the child's landed copy of it", () => { + const failed = { + ...event( + optimisticQueueUserEventId("intent-failed"), + "2026-09-06T04:39:19Z", + "user", + "Reply with exactly REJECTED" + ), + displayStatus: "failed" as const, + result: { + deliveryStatus: "failed", + deliveryError: "model not supported", + turnIntentId: "turn-failed", + }, + }; + const landedUser = event( + "runlanded-user-rejected", + "2026-09-06T04:39:20Z", + "user", + "Reply with exactly REJECTED" + ); + landedUser.result = { turnIntentId: "turn-failed" }; + const landedOther = event( + "runlanded-assistant-1", + "2026-09-06T04:39:21Z", + "assistant", + "unrelated reply" + ); + const landedRetryCopy = event( + "runlanded-user-rejected-retry", + "2026-09-06T04:53:27Z", + "user", + "Reply with exactly REJECTED" + ); + landedRetryCopy.result = { turnIntentId: "turn-failed" }; + const landedEarlierSamePrompt = event( + "runlanded-user-earlier", + "2026-09-06T04:10:00Z", + "user", + "Reply with exactly REJECTED" + ); + landedEarlierSamePrompt.result = { turnIntentId: "turn-earlier" }; + expect( + suppressLandedQueuedUserRows([failed], [landedUser]).map( + (candidate) => candidate.id + ) + ).toEqual([failed.id]); + expect( + suppressLandedRowsOfFailedQueuedTurns( + [failed], + [landedEarlierSamePrompt, landedUser, landedOther, landedRetryCopy] + ).map((candidate) => candidate.id) + ).toEqual(["runlanded-user-earlier", "runlanded-assistant-1"]); + expect( + suppressLandedRowsOfFailedQueuedTurns([], [landedUser]).map( + (candidate) => candidate.id + ) + ).toEqual(["runlanded-user-rejected"]); + }); + + it("does not hide a later answered turn that repeats failed text", () => { + const failed = { + ...event( + optimisticQueueUserEventId("failed-row"), + "2026-09-06T04:39:19Z", + "user", + "same prompt" + ), + displayStatus: "failed" as const, + result: { deliveryStatus: "failed", turnIntentId: "failed-intent" }, + }; + const failedEcho = event( + "failed-echo", + "2026-09-06T04:39:20Z", + "user", + "same prompt" + ); + failedEcho.result = { turnIntentId: "failed-intent" }; + const later = event( + "later-user", + "2026-09-06T04:40:00Z", + "user", + "same prompt" + ); + later.result = { + turnIntentId: "answered-intent", + images: ["data:image/png;base64,later"], + }; + const answer = event( + "later-answer", + "2026-09-06T04:40:01Z", + "assistant", + "answered" + ); + const legacyWithoutIdentity = event( + "legacy-user", + "2026-09-06T04:40:02Z", + "user", + "same prompt" + ); + + expect( + suppressLandedRowsOfFailedQueuedTurns( + [failed], + [failedEcho, later, answer, legacyWithoutIdentity] + ).map((candidate) => candidate.id) + ).toEqual(["later-user", "later-answer", "legacy-user"]); + }); + + it("suppresses only one matching optimistic row for repeated prompt text", () => { + const first = event( + optimisticQueueUserEventId("repeat-1"), + "2026-09-04T05:58:37Z", + "user", + "same prompt" + ); + first.result = { turnIntentId: "repeat-1" }; + const second = event( + optimisticQueueUserEventId("repeat-2"), + "2026-09-04T05:59:00Z", + "user", + "same prompt" + ); + second.result = { turnIntentId: "repeat-2" }; + const landed = event( + "runlanded-repeat-2", + "2026-09-04T05:59:01Z", + "user", + "same prompt" + ); + landed.result = { turnIntentId: "repeat-2" }; + expect( + suppressLandedQueuedUserRows([first, second], [landed]).map( + (candidate) => candidate.id + ) + ).toEqual([first.id]); + }); + + it("keeps optimistic rows when equal landed text has no matching identity", () => { + const pending = event( + optimisticQueueUserEventId("pending-intent"), + "2026-09-04T05:58:37Z", + "user", + "same prompt" + ); + pending.result = { turnIntentId: "pending-intent" }; + const differentTurn = event( + "runlanded-different-turn", + "2026-09-04T05:58:38Z", + "user", + "same prompt" + ); + differentTurn.result = { turnIntentId: "different-intent" }; + const legacyWithoutIdentity = event( + "runlanded-legacy", + "2026-09-04T05:58:39Z", + "user", + "same prompt" + ); + + expect( + suppressLandedQueuedUserRows( + [pending], + [differentTurn, legacyWithoutIdentity] + ).map((candidate) => candidate.id) + ).toEqual([pending.id]); + }); +}); diff --git a/src/engines/SessionCore/conversations/localConversationExecutionTail.ts b/src/engines/SessionCore/conversations/localConversationExecutionTail.ts new file mode 100644 index 0000000000..aa6dc3ff0c --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationExecutionTail.ts @@ -0,0 +1,551 @@ +import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isOptimisticQueueUserEventId } from "@src/engines/SessionCore/services/userIntentDispatch"; +import { loadCliTranscriptRevision } from "@src/engines/SessionCore/sync/adapters/cli/cliHistory"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import type { ConversationRootLocator } from "./conversationTypes"; +import { conversationExecutionParentId } from "./localConversationContinuation"; +import { + nativeConversationEventSemanticKey, + nativeConversationItemsAreProviderPortablePrefix, + nativeSourceEventId, + projectNativeConversationItems, + sourceEventIdOfNativeItem, +} from "./nativeConversationMaterializer"; + +export const LOCAL_EXECUTION_TAIL_EVENT_PREFIX = "runlanded-"; +const STABLE_CANONICAL_SNAPSHOT_ATTEMPTS = 2; + +export interface LocalExecutionChild { + session_id: string; + created_at: string; + /** Existing session catalog revision; used to refresh a reused native child. */ + updated_at?: string; + /** Raw catalog status; `idle` is quiescent but intentionally non-terminal. */ + status?: string; + /** Authoritative `SessionStatus::is_terminal()` projection from Rust. */ + is_terminal?: boolean; +} + +export interface LocalExecutionSegment { + child: LocalExecutionChild; + events: readonly SessionEvent[]; + /** Provider-file revision that bracketed this exact child read. */ + nativeRevision?: string | null; +} + +export interface LocalCanonicalConversationSnapshot { + events: SessionEvent[]; + /** Authoritative root retained for read-side tail projection. */ + rootEvents: SessionEvent[]; + /** Raw child segments read by the same consistent snapshot. */ + segments: LocalExecutionSegment[]; + /** + * Stable catalog frontier covered by `events`. `null` means a child changed + * while the snapshot was being read, so callers must not cache it as clean. + */ + childRevision: string | null; +} + +interface LocalExecutionChildRow { + sessionId: string; + createdAt?: string; + updatedAt?: string; + status?: string; + isTerminal?: boolean; +} + +export function resolveLocalExecutionChildren( + children: readonly { sessionId: string }[], + createdAtBySessionId: ReadonlyMap, + updatedAtBySessionId: ReadonlyMap< + string, + string | undefined + > = createdAtBySessionId +): LocalExecutionChild[] { + const resolved: LocalExecutionChild[] = []; + const seen = new Set(); + for (const child of children) { + const createdAt = createdAtBySessionId.get(child.sessionId); + if (!child.sessionId || !createdAt || seen.has(child.sessionId)) continue; + seen.add(child.sessionId); + resolved.push({ + session_id: child.sessionId, + created_at: createdAt, + updated_at: updatedAtBySessionId.get(child.sessionId) ?? createdAt, + }); + } + return resolved.sort((left, right) => + left.created_at.localeCompare(right.created_at) + ); +} + +export async function loadLocalExecutionChildren( + root: ConversationRootLocator +): Promise { + // The child-session command already joins Agent, CLI, and imported episode + // catalogs into one authoritative row shape. Use its creation timestamp + // directly; probing every provider adapter here both duplicated that owner + // and could silently drop a valid child while an adapter was still waking. + const children = await invokeTauri( + "es_get_child_sessions", + { + parentSessionId: conversationExecutionParentId(root), + } + ); + const resolved = resolveLocalExecutionChildren( + children, + new Map(children.map((child) => [child.sessionId, child.createdAt])), + new Map( + children.map((child) => [ + child.sessionId, + child.updatedAt ?? child.createdAt, + ]) + ) + ); + const rowsById = new Map(children.map((child) => [child.sessionId, child])); + return resolved.map((child) => { + const row = rowsById.get(child.session_id); + return { + ...child, + ...(typeof row?.status === "string" ? { status: row.status } : {}), + ...(typeof row?.isTerminal === "boolean" + ? { is_terminal: row.isTerminal } + : {}), + }; + }); +} + +function localExecutionChildrenRevision( + children: readonly LocalExecutionChild[], + nativeRevisions: ReadonlyMap = new Map() +): string | null { + if ( + children.some((child) => nativeRevisions.get(child.session_id) === null) + ) { + return null; + } + return JSON.stringify( + children.map((child) => [ + child.session_id, + child.created_at, + child.updated_at ?? child.created_at, + nativeRevisions.get(child.session_id) ?? "", + ]) + ); +} + +async function loadLocalExecutionChildNativeRevision( + sessionId: string +): Promise { + if (!isCliSession(sessionId)) return undefined; + // `undefined` is the legitimate legacy/chunks case, whose DB timestamps are + // the durable revision. `null` means a native transcript exists in principle + // but cannot currently be read; preserve that distinction so the snapshot + // owner fails closed instead of certifying a hollow provider replay. + return loadCliTranscriptRevision(sessionId); +} + +async function loadLocalExecutionChildrenState( + root: ConversationRootLocator +): Promise<{ + children: LocalExecutionChild[]; + nativeRevisions: Map; + revision: string | null; +}> { + const children = await loadLocalExecutionChildren(root); + const revisions = await Promise.all( + children.map((child) => + loadLocalExecutionChildNativeRevision(child.session_id) + ) + ); + const nativeRevisions = new Map( + children.map( + (child, index) => [child.session_id, revisions[index]] as const + ) + ); + return { + children, + nativeRevisions, + revision: localExecutionChildrenRevision(children, nativeRevisions), + }; +} + +function isQuiescentExecutionChild(child: LocalExecutionChild): boolean { + return child.is_terminal === true || child.status === "idle"; +} + +function unavailableQuiescentNativeChild( + state: Awaited> +): LocalExecutionChild | undefined { + return state.children.find( + (child) => + isQuiescentExecutionChild(child) && + state.nativeRevisions.get(child.session_id) === null + ); +} + +export async function loadLocalExecutionChildrenRevision( + root: ConversationRootLocator +): Promise { + return (await loadLocalExecutionChildrenState(root)).revision; +} + +export function verifiedNativeConversationSuffixEvents( + canonicalEvents: readonly SessionEvent[], + childEvents: readonly SessionEvent[] +): SessionEvent[] | null { + const canonicalItems = projectNativeConversationItems(canonicalEvents); + const childItems = projectNativeConversationItems(childEvents); + if ( + !nativeConversationItemsAreProviderPortablePrefix( + canonicalItems, + childItems + ) + ) { + return null; + } + const suffixSourceIds = new Set( + childItems.slice(canonicalItems.length).map(sourceEventIdOfNativeItem) + ); + if (suffixSourceIds.size === 0) return []; + return childEvents.filter((event) => + suffixSourceIds.has(nativeSourceEventId(event)) + ); +} + +function isContextCompactEvent(event: SessionEvent): boolean { + return ( + event.actionType === "context_compacted" || + event.functionName === "context_compacted" + ); +} + +function nativeCompactedSuffixEvents( + canonicalEvents: readonly SessionEvent[], + childEvents: readonly SessionEvent[] +): SessionEvent[] | null { + const canonicalItems = projectNativeConversationItems(canonicalEvents); + for ( + let compactIndex = 0; + compactIndex < childEvents.length; + compactIndex += 1 + ) { + if (!isContextCompactEvent(childEvents[compactIndex])) continue; + const beforeCompactItems = projectNativeConversationItems( + childEvents.slice(0, compactIndex) + ); + if ( + !nativeConversationItemsAreProviderPortablePrefix( + canonicalItems, + beforeCompactItems + ) + ) { + continue; + } + const preCompactSuffixIds = new Set( + beforeCompactItems + .slice(canonicalItems.length) + .map(sourceEventIdOfNativeItem) + ); + return childEvents.filter( + (event, eventIndex) => + (eventIndex < compactIndex && + preCompactSuffixIds.has(nativeSourceEventId(event))) || + (eventIndex >= compactIndex && + nativeConversationEventSemanticKey(event) !== null) + ); + } + return null; +} + +/** + * Fold local execution episodes into one provider-portable conversation. + * + * Every child is a native materialization of the prefix accumulated before it. + * Prefer the provider's effective native-item prefix (which understands an + * existing compact marker). When a child performs another native compact, + * verify the effective message list immediately before that marker, then append + * the completed pre-compact turn, compact marker, and structured suffix. + * Divergent/branched children never enter the canonical timeline. + */ +export function mergeVerifiedLocalExecutionTimeline( + rootEvents: readonly SessionEvent[], + segments: readonly LocalExecutionSegment[] +): SessionEvent[] { + let canonical = [...rootEvents]; + // A reused child keeps materialized copies of turns that another child + // executed later, and a provider may stamp those copies with its injection + // time. Folding children in creation order would take such a copy before + // the executing child's original rows and misplace the turn. Fold from + // whichever child's next verified suffix starts earliest; a run stops as + // soon as another child's next row is due, and the copy then verifies as + // an already-folded prefix instead of appending a second time. + for (;;) { + const candidates = segments.flatMap(({ events }, index) => { + const portable = verifiedNativeConversationSuffixEvents( + canonical, + events + ); + if (portable) { + return portable.length > 0 + ? [{ index, suffix: portable, splittable: true }] + : []; + } + const compacted = nativeCompactedSuffixEvents(canonical, events); + return compacted && compacted.length > 0 + ? [{ index, suffix: compacted, splittable: false }] + : []; + }); + if (candidates.length === 0) return canonical; + const startsAt = (candidate: (typeof candidates)[number]) => + eventTimestampMs(candidate.suffix[0]); + const chosen = candidates.reduce((best, candidate) => + startsAt(candidate) < startsAt(best) ? candidate : best + ); + const cutoff = Math.min( + ...candidates.filter((candidate) => candidate !== chosen).map(startsAt) + ); + const run: SessionEvent[] = []; + for (const event of chosen.suffix) { + if ( + chosen.splittable && + run.length > 0 && + eventTimestampMs(event) > cutoff + ) { + break; + } + run.push(event); + } + canonical = [...canonical, ...run]; + } +} + +function eventTimestampMs(event: SessionEvent): number { + const parsed = Date.parse(event.createdAt ?? ""); + return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; +} + +/** One durable loader shared by local/imported execution and the visible UI. */ +export async function loadLocalCanonicalConversationSnapshot( + root: ConversationRootLocator +): Promise { + const [{ events: rootEvents }, initialState] = await Promise.all([ + loadCanonicalConversationEvents(root.conversationId), + loadLocalExecutionChildrenState(root), + ]); + const unavailableChild = unavailableQuiescentNativeChild(initialState); + if (unavailableChild) { + throw new QueuedConversationBlockedError( + `Native history for finished execution ${unavailableChild.session_id} is unavailable. Restore the provider transcript, then retry this message.` + ); + } + const segments = await Promise.all( + initialState.children.map(async (child) => { + const events = await loadLocalExecutionChildEvents(child.session_id); + const revisionAfterRead = await loadLocalExecutionChildNativeRevision( + child.session_id + ); + const revisionBeforeRead = initialState.nativeRevisions.get( + child.session_id + ); + return { + child, + events, + nativeRevision: + revisionBeforeRead === revisionAfterRead ? revisionAfterRead : null, + }; + }) + ); + const revisionAtRead = localExecutionChildrenRevision( + initialState.children, + new Map( + segments.map((segment) => [ + segment.child.session_id, + segment.nativeRevision, + ]) + ) + ); + const revisionAfterRead = await loadLocalExecutionChildrenRevision(root); + return { + events: mergeVerifiedLocalExecutionTimeline(rootEvents, segments), + rootEvents, + segments, + childRevision: + revisionAtRead !== null && revisionAtRead === revisionAfterRead + ? revisionAtRead + : null, + }; +} + +/** One durable loader shared by local/imported execution and cloud replay. */ +export async function loadLocalCanonicalConversationTimeline( + root: ConversationRootLocator +): Promise { + for ( + let attempt = 0; + attempt < STABLE_CANONICAL_SNAPSHOT_ATTEMPTS; + attempt += 1 + ) { + const snapshot = await loadLocalCanonicalConversationSnapshot(root); + if (snapshot.childRevision !== null) return snapshot.events; + } + throw new QueuedConversationRecoveryPendingError( + "provider-native conversation changed while its canonical timeline was being read" + ); +} + +/** Namespace only the verified child suffix for rendering on the root stream. */ +export function projectVerifiedLocalExecutionTail( + rootEvents: readonly SessionEvent[], + segments: readonly LocalExecutionSegment[], + canonicalSessionId: string +): SessionEvent[] { + // Queue-owned rows make a user submission visible on the root immediately, + // but they are not part of that root provider's native transcript. A reused + // child can already contain earlier execution turns before the newest + // optimistic row, so treating the row as a native-root item makes the real + // child look divergent (root + newest user vs root + prior turns + newest + // user) and rejects its entire suffix. Verify from the provider-native root; + // `suppressLandedQueuedUserRows` replaces each matching optimistic bubble + // with the landed child user row after projection. + const nativeRootEvents = rootEvents.filter( + (event) => !isOptimisticQueueUserEventId(event.id) + ); + return collapseRetriedPromptCopies( + mergeVerifiedLocalExecutionTimeline(nativeRootEvents, segments).slice( + nativeRootEvents.length + ) + ).map((event) => ({ + ...event, + id: `${LOCAL_EXECUTION_TAIL_EVENT_PREFIX}${event.id}`, + chunk_id: `${LOCAL_EXECUTION_TAIL_EVENT_PREFIX}${event.id}`, + sessionId: canonicalSessionId, + })); +} + +/** + * Collapse repeated projections only when their durable turn/message identity + * proves they are the same user submission. Equal text is not identity: users + * may intentionally send the same words in consecutive turns or attach + * different images. Legacy/provider rows without identity therefore remain + * visible rather than risking silent transcript loss. + */ +export function collapseRetriedPromptCopies( + tail: readonly SessionEvent[] +): SessionEvent[] { + const kept: SessionEvent[] = []; + for (const event of tail) { + const previous = kept[kept.length - 1]; + const previousIdentity = previous + ? logicalUserMessageIdentity(previous) + : null; + const eventIdentity = logicalUserMessageIdentity(event); + if ( + previous && + previous.source === "user" && + event.source === "user" && + previousIdentity !== null && + previousIdentity === eventIdentity + ) { + kept[kept.length - 1] = event; + continue; + } + kept.push(event); + } + return kept; +} + +/** Mirror EventStore's logical user-turn identity at the read-side boundary. */ +function logicalUserMessageIdentity(event: SessionEvent): string | null { + if (event.source !== "user") return null; + const turnIntentId = turnIntentIdOf(event); + if (turnIntentId) return `intent:${turnIntentId}`; + const messageId = (event.result as Record | undefined) + ?.messageId; + if (typeof messageId === "string" && messageId.length > 0) { + return `message:${messageId}`; + } + if (event.functionName === "user_input" && event.id) { + return `message:${event.id}`; + } + return null; +} + +function isFailedOptimisticQueueUserRow(event: SessionEvent): boolean { + return ( + event.source === "user" && + isOptimisticQueueUserEventId(event.id) && + event.result?.["deliveryStatus"] === "failed" + ); +} + +/** + * A provider records the user's prompt before it can reject the turn, so the + * child's landed copy of that prompt exists even when the turn failed. The + * failed optimistic row is the visible retry owner: keep it and drop the + * landed copy, otherwise the failure and its retry vanish behind a plain + * duplicate bubble. + */ +export function suppressLandedRowsOfFailedQueuedTurns( + anchorEvents: readonly SessionEvent[], + tails: readonly SessionEvent[] +): SessionEvent[] { + const failed = anchorEvents.filter(isFailedOptimisticQueueUserRow); + if (failed.length === 0 || tails.length === 0) return [...tails]; + const failedIdentities = new Set( + failed + .map(logicalUserMessageIdentity) + .filter((identity): identity is string => identity !== null) + ); + return tails.filter((landed) => { + if (landed.source !== "user") return true; + const identity = logicalUserMessageIdentity(landed); + return identity === null || !failedIdentities.has(identity); + }); +} + +export function suppressLandedQueuedUserRows( + anchorEvents: readonly SessionEvent[], + tails: readonly SessionEvent[] +): SessionEvent[] { + if (tails.length === 0) return [...anchorEvents]; + const optimistic = anchorEvents.filter( + (event) => + event.source === "user" && + isOptimisticQueueUserEventId(event.id) && + !isFailedOptimisticQueueUserRow(event) + ); + if (optimistic.length === 0) return [...anchorEvents]; + + // The accepted queue row and the EventStore projection of its provider echo + // share the same durable turn/message identity. Never fall back to text or + // timestamp proximity: a later intentional repeat (possibly with different + // attachments) is a distinct turn. Legacy rows without identity stay + // visible; a harmless duplicate is preferable to deleting real history. + const landedIdentities = new Set( + tails + .map(logicalUserMessageIdentity) + .filter((identity): identity is string => identity !== null) + ); + const optimisticIds = new Set(optimistic.map((event) => event.id)); + return anchorEvents.filter((event) => { + if (!optimisticIds.has(event.id)) return true; + const identity = logicalUserMessageIdentity(event); + return identity === null || !landedIdentities.has(identity); + }); +} + +export async function loadLocalExecutionChildEvents( + sessionId: string +): Promise { + const { events } = await loadCanonicalConversationEvents(sessionId); + return [...events]; +} diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts new file mode 100644 index 0000000000..0865c4fa4f --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts @@ -0,0 +1,845 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + MAX_PORTABLE_TOOL_CALL_ID_LENGTH, + NATIVE_SOURCE_EVENT_ID_ARG, + assertNativeConversationPayloadWithinBounds, + materializeNativeConversation, + mergeInterruptedConversationProjection, + nativeConversationItemsArePrefix, + nativeConversationItemsAreProviderPortablePrefix, + nativeConversationItemsEqual, + projectNativeConversation, + projectNativeConversationItems, + removeKnownNativeConversationEchoes, + supportsNativeConversationTarget, + synchronizeNativeConversation, +} from "./nativeConversationMaterializer"; + +const mocks = vi.hoisted(() => ({ + invokeTauri: vi.fn(), + loadEvents: vi.fn(), +})); + +vi.mock("@src/util/platform/tauri/init", () => ({ + invokeTauri: mocks.invokeTauri, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadEvents, +})); + +function message( + id: string, + source: "user" | "assistant", + text: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "source", + createdAt: "2026-08-26T00:00:00.000Z", + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user_message" : "agent_message", + actionType: "raw", + args: {}, + result: { message: { role: source, content: text }, content: text }, + source, + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function tool(): SessionEvent { + return { + id: "tool-1", + chunk_id: "tool-1", + sessionId: "source", + createdAt: "2026-08-26T00:00:01.000Z", + functionName: "read_file", + uiCanonical: "tool_call", + actionType: "tool_call", + callId: "call-1", + args: { + path: "/repo/README.md", + nested: { second: 2, first: 1 }, + conversationTurnId: "internal-turn", + conversationSender: { displayName: "Ada" }, + __orgiiPrivate: true, + }, + result: {}, + source: "assistant", + displayText: "", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function lifecycle(actionType: "task_start" | "task_completed"): SessionEvent { + return { + ...tool(), + id: `imported-session-c716811f02b60f8b4671537ff7f85579~codex-lifecycle-154-${actionType}`, + chunk_id: `lifecycle-${actionType}`, + actionType, + callId: undefined, + functionName: actionType, + displayVariant: "tool_call", + } as SessionEvent; +} + +function compactMarker(id = "compact-1"): SessionEvent { + return { + ...message(id, "assistant", "provider summary"), + functionName: "context_compacted", + uiCanonical: "context_compacted", + actionType: "context_compacted", + source: "system", + result: { + header: "Context compacted", + observation: "provider summary", + native: true, + }, + } as SessionEvent; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("native conversation materialization", () => { + it("carries canonical event and turn identity through a rendered projection", () => { + const user = message("convplane-row-1", "user", "continue"); + user.args = { + [NATIVE_SOURCE_EVENT_ID_ARG]: "orgii_evt_source_user_1", + conversationTurnId: "turn-1", + }; + const assistant = message("convplane-row-2", "assistant", "done"); + assistant.args = { + [NATIVE_SOURCE_EVENT_ID_ARG]: "orgii_evt_source_assistant_1", + }; + + expect(projectNativeConversationItems([user, assistant])).toEqual([ + expect.objectContaining({ + id: "orgii_evt_source_user_1", + role: "user", + turnId: "turn-1", + }), + expect.objectContaining({ + id: "orgii_evt_source_assistant_1", + role: "assistant", + }), + ]); + }); + + it("projects roles and paired tools without rendering history into a prompt", () => { + const items = projectNativeConversationItems([ + message("u1", "user", "inspect it"), + tool(), + message("a1", "assistant", "done"), + ]); + + expect(items).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "inspect it", + }), + expect.objectContaining({ + kind: "tool_call", + callId: "call-1", + name: "read_file", + }), + expect.objectContaining({ + kind: "tool_result", + callId: "call-1", + output: "", + isError: false, + interrupted: false, + }), + expect.objectContaining({ + kind: "message", + role: "assistant", + text: "done", + }), + ]); + const args = JSON.parse( + (items[1] as Extract<(typeof items)[number], { kind: "tool_call" }>) + .arguments + ); + expect(args).toEqual({ + path: "/repo/README.md", + nested: { second: 2, first: 1 }, + }); + }); + + it("keeps native user text clean and reports unsupported authorship metadata", () => { + const user = message("u1", "user", "looks good"); + user.args = { + conversationSender: { userId: "user-1", displayName: "Alice" }, + }; + + expect(projectNativeConversation([user])).toEqual({ + items: [expect.objectContaining({ text: "looks good" })], + fidelity: { + level: "lossy", + omitted: ["participant_authorship"], + }, + }); + }); + + it("rejects oversized native payloads before crossing the Tauri boundary", () => { + const items = projectNativeConversationItems([ + message("u1", "user", "a payload that exceeds a tiny test bound"), + ]); + expect(() => + assertNativeConversationPayloadWithinBounds(items, { maxBytes: 16 }) + ).toThrow("native transcript is"); + }); + + it("keeps pending and failed human messages visible without executing them", () => { + const pending = message("pending", "user", "not accepted yet"); + pending.displayStatus = "pending"; + pending.result = { ...pending.result, deliveryStatus: "pending" }; + const failed = message("failed", "user", "retry this later"); + failed.displayStatus = "failed"; + failed.result = { ...failed.result, deliveryStatus: "failed" }; + const sent = message("sent", "user", "accepted message"); + sent.result = { ...sent.result, deliveryStatus: "sent" }; + + expect(projectNativeConversationItems([pending, failed, sent])).toEqual([ + expect.objectContaining({ + id: expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + text: "accepted message", + }), + ]); + }); + + it("keeps the safe partial prefix of an interrupted turn", () => { + const completed = tool(); + completed.id = "tool-completed"; + completed.chunk_id = "tool-completed"; + completed.callId = "call-completed"; + const interrupted = tool(); + interrupted.id = "tool-interrupted"; + interrupted.chunk_id = "tool-interrupted"; + interrupted.callId = "call-interrupted"; + interrupted.displayStatus = "pending"; + + const events = [ + message("u1", "user", "inspect the repo"), + message("a-partial", "assistant", "I found the entrypoint."), + completed, + interrupted, + lifecycle("task_completed"), + ]; + const items = projectNativeConversationItems(events); + + expect(items.map((item) => item.id)).toEqual([ + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}:call$/), + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}:result$/), + ]); + }); + + it("extends an older readable native fork with a durable interrupted suffix", () => { + const native = [ + message("native-u1", "user", "first"), + message("native-a1", "assistant", "done"), + ]; + const completed = tool(); + const interrupted = tool(); + interrupted.id = "pending-tool"; + interrupted.chunk_id = "pending-tool"; + interrupted.callId = "pending-call"; + interrupted.displayStatus = "pending"; + const projected = [ + message("projected-u1", "user", "first"), + message("projected-a1", "assistant", "done"), + message("interrupted-user", "user", "second"), + message("interrupted-partial", "assistant", "partial finding"), + completed, + interrupted, + ]; + + const merged = mergeInterruptedConversationProjection(native, projected); + expect(merged.map((event) => event.id)).toEqual([ + "native-u1", + "native-a1", + "interrupted-user", + "interrupted-partial", + "tool-1", + ]); + }); + + it("preserves message ids that happen to end in a tool suffix", () => { + const native = [message("native-u1", "user", "first")]; + const projected = [ + message("projected-u1", "user", "first"), + message("human:call", "user", "second"), + message("answer:result", "assistant", "partial answer"), + ]; + + expect( + mergeInterruptedConversationProjection(native, projected).map( + (event) => event.id + ) + ).toEqual(["native-u1", "human:call", "answer:result"]); + }); + + it("fails closed when the projected history diverged from native truth", () => { + const native = [message("native-u1", "user", "first")]; + const projected = [message("projected-u1", "user", "rewritten")]; + expect(mergeInterruptedConversationProjection(native, projected)).toEqual( + native + ); + }); + + it("does not promote production-shaped lifecycle rows into provider tools", () => { + const items = projectNativeConversationItems([ + message("u1", "user", "inspect it"), + lifecycle("task_start"), + lifecycle("task_completed"), + message("a1", "assistant", "done"), + ]); + + expect(items.map((item) => item.kind)).toEqual(["message", "message"]); + }); + + it("collapses the Rust acceptance row into its persisted user message", () => { + const accepted = message("turn-message-id", "user", "one prompt"); + accepted.functionName = "user_input"; + accepted.uiCanonical = "user_input"; + const persisted = message( + "user-message-turn-message-id", + "user", + "one prompt" + ); + persisted.result = { + ...persisted.result, + messageId: "turn-message-id", + backendPersisted: true, + }; + + expect(projectNativeConversationItems([accepted, persisted])).toEqual([ + expect.objectContaining({ + id: expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + kind: "message", + role: "user", + text: "one prompt", + }), + ]); + expect(projectNativeConversationItems([accepted])).toHaveLength(1); + }); + + it("keeps tool pairing stable inside the strict provider call-id envelope", () => { + const event = tool(); + event.callId = `call-${"x".repeat(96)}`; + + const first = projectNativeConversationItems([event]); + const second = projectNativeConversationItems([structuredClone(event)]); + const call = first[0]; + const result = first[1]; + + expect(call?.kind).toBe("tool_call"); + expect(result?.kind).toBe("tool_result"); + if (call?.kind !== "tool_call" || result?.kind !== "tool_result") return; + expect(call.callId).toBe(result.callId); + expect(call.callId.length).toBeLessThanOrEqual( + MAX_PORTABLE_TOOL_CALL_ID_LENGTH + ); + expect(second).toEqual(first); + }); + + it("preserves a provider-native call id that already fits", () => { + const items = projectNativeConversationItems([tool()]); + expect(items[0]).toMatchObject({ kind: "tool_call", callId: "call-1" }); + expect(items[1]).toMatchObject({ kind: "tool_result", callId: "call-1" }); + }); + + it("rewrites provider call ids with characters rejected by Claude", () => { + const event = tool(); + event.callId = "call_native:part-0"; + + const items = projectNativeConversationItems([event]); + expect(items[0]).toMatchObject({ + kind: "tool_call", + callId: expect.stringMatching(/^call_[A-Za-z0-9_-]+$/), + }); + expect(items[1]).toMatchObject({ + kind: "tool_result", + callId: (items[0] as { callId: string }).callId, + }); + expect((items[0] as { callId: string }).callId).not.toContain(":"); + }); + + it("compares cross-provider tool ids by pairing topology", () => { + const first = tool(); + const second = { + ...tool(), + id: "tool-2", + chunk_id: "tool-2", + callId: "call-2", + args: { path: "/repo/package.json" }, + result: { status: "completed", output: "package contents" }, + } as SessionEvent; + const expected = projectNativeConversationItems([first, second]); + const rewritten = structuredClone(expected); + for (const item of rewritten) { + if (item.kind !== "tool_call" && item.kind !== "tool_result") continue; + item.callId = item.callId === "call-1" ? "claude-a" : "claude-b"; + } + + expect(nativeConversationItemsArePrefix(expected, rewritten)).toBe(false); + expect( + nativeConversationItemsAreProviderPortablePrefix(expected, rewritten) + ).toBe(true); + + const orphanResult = structuredClone(rewritten); + if (orphanResult[1]?.kind === "tool_result") { + orphanResult[1].callId = "orphan-result"; + } + expect( + nativeConversationItemsAreProviderPortablePrefix(expected, orphanResult) + ).toBe(false); + + const collapsedCalls = structuredClone(rewritten); + for (const item of collapsedCalls.slice(2)) { + if (item.kind === "tool_call" || item.kind === "tool_result") { + item.callId = "claude-a"; + } + } + expect( + nativeConversationItemsAreProviderPortablePrefix(expected, collapsedCalls) + ).toBe(false); + + const interrupted = structuredClone(expected); + const providerRoundTrip = structuredClone(expected); + if ( + interrupted[1]?.kind === "tool_result" && + providerRoundTrip[1]?.kind === "tool_result" + ) { + interrupted[1].isError = true; + interrupted[1].interrupted = true; + providerRoundTrip[1].isError = true; + providerRoundTrip[1].interrupted = false; + } + expect(nativeConversationItemsEqual(interrupted, providerRoundTrip)).toBe( + false + ); + expect( + nativeConversationItemsAreProviderPortablePrefix( + interrupted, + providerRoundTrip + ) + ).toBe(true); + }); + + it("compares JSON tool arguments semantically rather than by object key order", () => { + const left = projectNativeConversationItems([tool()]); + const right = structuredClone(left); + if (right[0]?.kind === "tool_call") { + right[0].arguments = + '{"nested":{"first":1,"second":2},"path":"/repo/README.md"}'; + } + expect(nativeConversationItemsEqual(left, right)).toBe(true); + }); + + it("does not alias malformed tool arguments to a valid tagged JSON value", () => { + const valid = projectNativeConversationItems([tool()]); + const malformed = structuredClone(valid); + if (valid[0]?.kind === "tool_call") { + valid[0].arguments = '["raw","{broken"]'; + } + if (malformed[0]?.kind === "tool_call") { + malformed[0].arguments = "{broken"; + } + + expect( + nativeConversationItemsAreProviderPortablePrefix(valid, malformed) + ).toBe(false); + }); + + it("rebuilds the provider's effective context from its latest compact boundary", () => { + const compacted = projectNativeConversationItems([ + message("u1", "user", "old question"), + tool(), + message("a1", "assistant", "old answer"), + compactMarker(), + ]); + const withDelta = projectNativeConversationItems([ + message("u1", "user", "old question"), + tool(), + message("a1", "assistant", "old answer"), + compactMarker(), + message("u2", "user", "continue"), + ]); + + expect(compacted).toEqual([ + expect.objectContaining({ + kind: "context_summary", + id: expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + summary: "provider summary", + }), + ]); + expect(nativeConversationItemsArePrefix(compacted, withDelta)).toBe(true); + expect(withDelta.at(-1)).toMatchObject({ + kind: "message", + role: "user", + }); + }); + + it("preserves failed and interrupted tool-result semantics", () => { + const event = tool(); + event.displayStatus = "failed"; + event.result = { + observation: "partial tool output", + status: "interrupted", + interrupted: true, + }; + + expect(projectNativeConversationItems([event])).toEqual([ + expect.objectContaining({ kind: "tool_call", callId: "call-1" }), + expect.objectContaining({ + kind: "tool_result", + callId: "call-1", + output: "partial tool output", + isError: true, + interrupted: true, + }), + ]); + }); + + it("does not synthesize an empty provider-native compact", () => { + const empty = compactMarker("empty-compact"); + empty.result = { + ...(empty.result ?? {}), + observation: "", + }; + + expect( + projectNativeConversationItems([ + message("u1", "user", "old question"), + empty, + message("a1", "assistant", "old answer"), + ]) + ).toEqual([ + expect.objectContaining({ kind: "message", role: "user" }), + expect.objectContaining({ kind: "message", role: "assistant" }), + ]); + }); + + it("scopes provider-local ids once and preserves the scoped identity", () => { + const first = message("codex-asst-7", "assistant", "first session"); + first.sessionId = "native-session-a"; + const second = message("codex-asst-7", "assistant", "second session"); + second.sessionId = "native-session-b"; + + const [firstItem, secondItem] = projectNativeConversationItems([ + first, + second, + ]); + expect(firstItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(secondItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(firstItem?.id).not.toBe(secondItem?.id); + + const copied = message( + "provider-renumbered-1", + "assistant", + "first session" + ); + copied.sessionId = "native-session-c"; + copied.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: firstItem?.id }; + expect(projectNativeConversationItems([copied])[0]?.id).toBe(firstItem?.id); + + const legacyCopy = message( + "provider-renumbered-legacy", + "assistant", + "legacy materialization" + ); + legacyCopy.sessionId = "native-session-d"; + legacyCopy.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: "codex-asst-7" }; + const legacyItem = projectNativeConversationItems([legacyCopy])[0]; + expect(legacyItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(legacyItem?.id).not.toBe("codex-asst-7"); + + const collidingProviderRow = message( + "provider-renumbered-other", + "assistant", + "another native session" + ); + collidingProviderRow.sessionId = "native-session-e"; + collidingProviderRow.args = { + [NATIVE_SOURCE_EVENT_ID_ARG]: "codex-asst-7", + }; + const collidingItem = projectNativeConversationItems([ + collidingProviderRow, + ])[0]; + expect(collidingItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(collidingItem?.id).not.toBe(legacyItem?.id); + }); + + it("does not trust a raw preserved id that collides inside one native session", () => { + const replayed = message( + "codex-asst-92", + "assistant", + "materialized earlier answer" + ); + replayed.sessionId = "native-session-a"; + replayed.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: "codex-asst-97" }; + + const genuine = message( + "codex-asst-97", + "assistant", + "genuine later answer" + ); + genuine.sessionId = "native-session-a"; + genuine.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: "codex-asst-97" }; + + const [replayedItem, genuineItem] = projectNativeConversationItems([ + replayed, + genuine, + ]); + expect(replayedItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(genuineItem?.id).toMatch(/^orgii_evt_[a-f0-9]{32}$/); + expect(replayedItem?.id).not.toBe(genuineItem?.id); + }); + + it("collapses persisted copies that already share a global native id", () => { + const globalId = "orgii_evt_0c2481a309205d2abd70fd14234c10f5"; + const original = message("codex-asst-92", "assistant", "answer"); + original.sessionId = "native-session-a"; + original.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: globalId }; + + const planeCopy = message("convplane-row-97", "assistant", "answer"); + planeCopy.sessionId = "canonical-stream"; + planeCopy.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: globalId }; + + expect(projectNativeConversationItems([original, planeCopy])).toEqual([ + expect.objectContaining({ id: globalId, text: "answer" }), + ]); + }); + + it("does not treat a reused provider-positional id from an earlier rollout as an echo", () => { + // Every native rollout of one execution child restarts at `codex-asst-0`, + // so a later turn's first commentary can share `codex-asst-182` with an + // unrelated row of the previous rollout. Only an explicit global identity + // or the copied-prefix semantics may collapse a candidate. + const earlierRollout = message( + "codex-asst-182", + "assistant", + "Examined exactly: SessionService.ts" + ); + const laterRollout = message( + "codex-asst-182", + "assistant", + "I'll inspect the requested files first." + ); + + expect( + removeKnownNativeConversationEchoes([earlierRollout], [laterRollout]).map( + (event) => event.id + ) + ).toEqual(["codex-asst-182"]); + }); + + it("keeps a genuine repeated item after the copied-prefix window closes", () => { + const historical = message("historical", "assistant", "same answer"); + const novel = message("novel", "assistant", "new answer"); + const repeated = message("repeated", "assistant", "same answer"); + + expect( + removeKnownNativeConversationEchoes([historical], [novel, repeated]).map( + (event) => event.id + ) + ).toEqual(["novel", "repeated"]); + }); + + it("supports native Agent plus verified Claude and Codex writers", () => { + expect(supportsNativeConversationTarget({})).toBe(true); + expect( + supportsNativeConversationTarget({ cliAgentType: "claude_code" }) + ).toBe(true); + expect(supportsNativeConversationTarget({ cliAgentType: "codex" })).toBe( + true + ); + expect( + supportsNativeConversationTarget({ cliAgentType: "cursor_cli" }) + ).toBe(false); + }); + + it("requires the target's authoritative reader to return the same native transcript", async () => { + const timeline = [message("u1", "user", "hello")]; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: timeline, + source: "native_store", + }); + + await expect( + materializeNativeConversation({ + sessionId: "agentsession-target", + timeline, + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 1 }, + }); + expect(mocks.invokeTauri).toHaveBeenCalledWith( + "materialize_native_conversation", + expect.objectContaining({ sessionId: "agentsession-target" }) + ); + }); + + it("never sends copied global identities twice across the native wire boundary", async () => { + const globalId = "orgii_evt_0c2481a309205d2abd70fd14234c10f5"; + const original = message("codex-asst-92", "assistant", "answer"); + original.sessionId = "cliagent-source"; + original.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: globalId }; + const replayCopy = message("convplane-row-97", "assistant", "answer"); + replayCopy.sessionId = "canonical-root"; + replayCopy.args = { [NATIVE_SOURCE_EVENT_ID_ARG]: globalId }; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: [original], + source: "native_store", + }); + + await expect( + materializeNativeConversation({ + sessionId: "agentsession-target", + timeline: [original, replayCopy], + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 1 }, + }); + expect(mocks.invokeTauri).toHaveBeenCalledWith( + "materialize_native_conversation", + { + sessionId: "agentsession-target", + items: [expect.objectContaining({ id: globalId, text: "answer" })], + } + ); + }); + + it("leaves an empty target fresh instead of inventing an unresumable native id", async () => { + await expect( + materializeNativeConversation({ + sessionId: "cli-session-empty", + timeline: [], + }) + ).resolves.toEqual({ + events: [], + receipt: { + nativeSessionId: "", + itemCount: 0, + fidelity: { level: "exact", omitted: [] }, + }, + }); + expect(mocks.invokeTauri).not.toHaveBeenCalled(); + expect(mocks.loadEvents).not.toHaveBeenCalled(); + }); + + it("lets Rust verify the authoritative native prefix before synchronizing", async () => { + const existing = [message("u1", "user", "hello")]; + const timeline = [...existing, message("a1", "assistant", "done")]; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 2, + }); + mocks.loadEvents.mockResolvedValue({ + events: timeline, + source: "native_store", + }); + + await expect( + synchronizeNativeConversation({ + sessionId: "cliagent-target", + timeline, + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 2 }, + }); + expect(mocks.invokeTauri).toHaveBeenCalledWith( + "synchronize_native_conversation", + { + sessionId: "cliagent-target", + completeItems: projectNativeConversationItems(timeline), + } + ); + }); + + it("accepts a synchronized provider transcript with rewritten tool ids", async () => { + const canonical = tool(); + const provider = { ...tool(), callId: "provider-call-a" } as SessionEvent; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 2, + }); + mocks.loadEvents.mockResolvedValue({ + events: [provider], + source: "native_store", + }); + + await expect( + synchronizeNativeConversation({ + sessionId: "cliagent-target", + timeline: [canonical], + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 2 }, + }); + }); + + it("fails closed when the provider reader does not round-trip the write", async () => { + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: [message("a1", "assistant", "different")], + source: "native_store", + }); + + await expect( + materializeNativeConversation({ + sessionId: "agentsession-target", + timeline: [message("u1", "user", "hello")], + }) + ).rejects.toThrow("round-trip verification failed"); + }); + + it("removes a failed CLI materialization without touching other native history", async () => { + mocks.invokeTauri.mockResolvedValueOnce({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: [message("a1", "assistant", "different")], + source: "cli_history", + }); + + await expect( + materializeNativeConversation({ + sessionId: "cliagent-target", + timeline: [message("u1", "user", "hello")], + }) + ).rejects.toThrow("round-trip verification failed"); + expect(mocks.invokeTauri).toHaveBeenNthCalledWith( + 2, + "discard_native_conversation_materialization", + { sessionId: "cliagent-target", nativeSessionId: "native-1" } + ); + }); +}); diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts new file mode 100644 index 0000000000..b8e8ebe60b --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts @@ -0,0 +1,842 @@ +import { v5 as uuidv5 } from "uuid"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isInternalLifecycleEvent } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import { conversationSenderStampOf } from "./conversationSenderMetadata"; +import { + type LocalConversationTarget, + NATIVE_CONVERSATION_CLI_TARGETS, + type NativeConversationCliTarget, +} from "./conversationTypes"; + +type NativeConversationItem = + | { + kind: "message"; + id: string; + role: "user" | "assistant"; + text: string; + images: string[]; + createdAt: string; + /** Stable ORG2 turn identity; provider transports may ignore it. */ + turnId?: string; + } + | { + kind: "tool_call"; + id: string; + callId: string; + name: string; + arguments: string; + createdAt: string; + } + | { + kind: "tool_result"; + id: string; + callId: string; + name: string; + output: string; + isError: boolean; + interrupted: boolean; + createdAt: string; + } + | { + kind: "context_summary"; + id: string; + summary: string; + createdAt: string; + }; + +interface NativeMaterializationWireReceipt { + nativeSessionId: string; + itemCount: number; +} + +export type NativeConversationFidelity = + | { level: "exact"; omitted: [] } + | { level: "lossy"; omitted: ["participant_authorship"] }; + +export interface NativeMaterializationReceipt extends NativeMaterializationWireReceipt { + /** Content is native; unsupported structured metadata is reported, never injected. */ + fidelity: NativeConversationFidelity; +} + +export const MAX_NATIVE_CONVERSATION_ITEMS = 100_000; +export const MAX_NATIVE_CONVERSATION_SERIALIZED_BYTES = 64 * 1024 * 1024; + +/** OpenAI's strictest current tool-call identifier envelope. */ +export const MAX_PORTABLE_TOOL_CALL_ID_LENGTH = 64; +const PORTABLE_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +const PORTABLE_TOOL_CALL_NAMESPACE = "9e7db8a3-94bf-5c58-9416-a244ba6e30d3"; + +/** + * Native providers commonly reuse positional ids (for example `codex-asst-7`) + * in every session. Scope legacy ids once, then carry that canonical identity + * through every subsequent native materialization and parse. + */ +const NATIVE_SOURCE_EVENT_ID_NAMESPACE = "45de8858-d25d-51df-a7cf-c7dedcb6d0f1"; +const NATIVE_SOURCE_EVENT_ID_PREFIX = "orgii_evt_"; + +/** Original event identity carried by synthesized/replayed projections. */ +export const NATIVE_SOURCE_EVENT_ID_ARG = "__orgiiSourceEventId"; + +/** + * Return an identity that has already crossed ORG2's global native boundary. + * Raw provider-local ids in the same field are deliberately not canonical: + * they still need session scoping in `nativeSourceEventId`. + */ +export function scopedNativeSourceEventIdOf( + event: SessionEvent +): string | null { + const sourceId = event.args?.[NATIVE_SOURCE_EVENT_ID_ARG]; + if ( + typeof sourceId === "string" && + sourceId.startsWith(NATIVE_SOURCE_EVENT_ID_PREFIX) + ) { + return sourceId; + } + return event.id.startsWith(NATIVE_SOURCE_EVENT_ID_PREFIX) ? event.id : null; +} + +export function nativeSourceEventId(event: SessionEvent): string { + // A materialized child created by current writers already carries the + // globally scoped root identity. Older writers and ordinary provider rows + // can expose only a provider-local positional id (for example + // `codex-asst-97`) in this field. That metadata can even point at a later + // genuine row in the same native Session, so it is not an identity boundary. + // Ignore every unscoped source hint and scope the row's own provider id; + // every later materialization carries the resulting `orgii_evt_*` id + // verbatim. + const scopedSourceId = scopedNativeSourceEventIdOf(event); + if (scopedSourceId) return scopedSourceId; + return `${NATIVE_SOURCE_EVENT_ID_PREFIX}${uuidv5( + `${event.sessionId}\0${event.id}`, + NATIVE_SOURCE_EVENT_ID_NAMESPACE + ).replace(/-/g, "")}`; +} + +function nativeConversationTurnId(event: SessionEvent): string | undefined { + const resultTurnId = (event.result as Record | undefined) + ?.turnIntentId; + if (typeof resultTurnId === "string" && resultTurnId.length > 0) { + return resultTurnId; + } + const argTurnId = event.args?.conversationTurnId; + return typeof argTurnId === "string" && argTurnId.length > 0 + ? argTurnId + : undefined; +} + +function eventText(event: SessionEvent): string { + const result = event.result as Record | undefined; + const message = result?.message as Record | undefined; + for (const candidate of [ + message?.content, + result?.content, + result?.observation, + result?.output, + event.displayText, + ]) { + if (typeof candidate === "string") return candidate; + } + return ""; +} + +function eventImages(event: SessionEvent): string[] { + const images = (event.result as Record | undefined)?.images; + if (!Array.isArray(images)) return []; + return images.filter( + (image): image is string => typeof image === "string" && image.length > 0 + ); +} + +function isUndeliveredUserEvent(event: SessionEvent): boolean { + if (event.source !== "user") return false; + const deliveryStatus = (event.result as Record | undefined) + ?.deliveryStatus; + return ( + event.displayStatus === "pending" || + event.displayStatus === "failed" || + deliveryStatus === "pending" || + deliveryStatus === "failed" + ); +} + +function transferableToolArgs(event: SessionEvent): Record { + return Object.fromEntries( + Object.entries(event.args ?? {}).filter( + ([key]) => + key !== "conversationTurnId" && + key !== "conversationSender" && + !key.startsWith("__orgii") + ) + ); +} + +function isPrivateProviderEvent(event: SessionEvent): boolean { + const action = event.actionType.toLowerCase(); + const fn = event.functionName.toLowerCase(); + return ( + action.includes("thinking") || + action.includes("reasoning") || + fn.includes("thinking") || + fn.includes("reasoning") + ); +} + +function isToolEvent(event: SessionEvent): boolean { + return ( + event.actionType === "tool_call" || + Boolean(event.callId && event.functionName) + ); +} + +function portableToolCallId(event: SessionEvent): string { + const sourceId = event.callId?.trim(); + if ( + sourceId && + sourceId.length <= MAX_PORTABLE_TOOL_CALL_ID_LENGTH && + PORTABLE_TOOL_CALL_ID_PATTERN.test(sourceId) + ) { + return sourceId; + } + + // Provider-native call IDs are pairing keys, not user-visible content. A + // stable UUID keeps the call/result relation exact while fitting the + // strictest supported provider instead of leaking namespaced event IDs. + const identity = sourceId || event.id; + return `call_${uuidv5(identity, PORTABLE_TOOL_CALL_NAMESPACE).replace( + /-/g, + "" + )}`; +} + +function hasToolResult(event: SessionEvent): boolean { + const resultStatus = event.result?.status; + return ( + event.displayStatus !== "running" && + event.displayStatus !== "pending" && + resultStatus !== "running" && + resultStatus !== "pending" + ); +} + +function toolResultFlags(event: SessionEvent): { + isError: boolean; + interrupted: boolean; +} { + const result = event.result as Record | undefined; + const status = + typeof result?.status === "string" ? result.status.toLowerCase() : ""; + const displayStatus = event.displayStatus?.toLowerCase() ?? ""; + const interrupted = + result?.interrupted === true || + status === "interrupted" || + displayStatus === "interrupted" || + displayStatus === "cancelled"; + return { + interrupted, + isError: + interrupted || + result?.isError === true || + result?.is_error === true || + ["error", "failed", "cancelled"].includes(status) || + ["error", "failed", "cancelled"].includes(displayStatus), + }; +} + +/** + * Provider-native conversation content. It preserves roles and tool pairing; + * it never renders history into a prompt. Provider-private reasoning, system + * policy and unsupported participant metadata are outside the item contract; + * callers that need the fidelity result use `projectNativeConversation`. + */ +export function projectNativeConversationItems( + events: readonly SessionEvent[] +): NativeConversationItem[] { + const items: NativeConversationItem[] = []; + const persistedUserMessageIds = new Set( + events.flatMap((event) => { + if (event.functionName !== "user_message") return []; + const messageId = (event.result as Record | undefined) + ?.messageId; + return typeof messageId === "string" && messageId.length > 0 + ? [messageId] + : []; + }) + ); + for (const event of events) { + if ( + event.actionType === "context_compacted" || + event.functionName === "context_compacted" + ) { + const summary = eventText(event); + if (summary.trim().length > 0) { + // The full canonical log remains intact for ORG2 history, but the + // provider's effective context is its latest native summary plus the + // structured suffix. Rebuild that message list instead of feeding the + // superseded prefix back until every runtime compacts again. + items.length = 0; + items.push({ + kind: "context_summary", + id: nativeSourceEventId(event), + summary, + createdAt: event.createdAt, + }); + } + continue; + } + if ( + event.isDelta || + isInternalLifecycleEvent(event) || + isPrivateProviderEvent(event) || + isUndeliveredUserEvent(event) + ) { + continue; + } + // Rust Agent persistence emits a low-level `user_input` acceptance row + // followed by the canonical `user_message` whose result.messageId points + // back to it. The UI collapses that pair to one bubble; the provider + // projection must do the same or every rebuilt runtime sees the prompt + // twice. A standalone imported `user_input` remains portable. + if ( + event.source === "user" && + event.functionName === "user_input" && + persistedUserMessageIds.has(event.id) + ) { + continue; + } + if (isToolEvent(event)) { + // An interrupted provider turn may leave an unresolved tool_use / + // function_call in its native store. A call without a result is not a + // portable conversation boundary: replaying it into another provider + // either violates that provider's message grammar or makes the next + // user message look like the missing tool result. Keep the user row, + // completed narration and every closed call/result pair, but drop only + // this unfinished tail. Standalone tool_result rows are likewise not a + // pair; normal ingestion merges them into their tool_call first. + if (event.actionType === "tool_result" || !hasToolResult(event)) { + continue; + } + const callId = portableToolCallId(event); + const name = event.functionName.trim(); + if (!name) { + throw new Error(`native transcript tool event ${event.id} has no name`); + } + items.push({ + kind: "tool_call", + id: `${nativeSourceEventId(event)}:call`, + callId, + name, + arguments: JSON.stringify(transferableToolArgs(event)), + createdAt: event.createdAt, + }); + if (hasToolResult(event)) { + const { isError, interrupted } = toolResultFlags(event); + items.push({ + kind: "tool_result", + id: `${nativeSourceEventId(event)}:result`, + callId, + name, + output: eventText(event), + isError, + interrupted, + createdAt: event.createdAt, + }); + } + continue; + } + if (event.source !== "user" && event.source !== "assistant") continue; + // A provider without structured participant metadata receives the exact + // human body. Never smuggle ORG2 markup into visible native user text; + // `projectNativeConversation` reports that authorship loss explicitly. + const text = eventText(event); + const images = eventImages(event); + if (!text && images.length === 0) continue; + const turnId = + event.source === "user" ? nativeConversationTurnId(event) : undefined; + items.push({ + kind: "message", + id: nativeSourceEventId(event), + role: event.source, + text, + images, + createdAt: event.createdAt, + ...(turnId ? { turnId } : {}), + }); + } + // The canonical timeline normally collapses copied source rows while it + // stitches native, plane and discussion segments. Old persisted plane rows + // can already carry the same globally scoped identity, though, and an + // execution overlay can reintroduce that copy after the stitch. Native item + // identity is the final transport boundary: two items with the same durable + // id are the same message/call/result, so keep the first instead of sending + // an invalid duplicate transcript to the provider writer. + const seenItemIds = new Set(); + return items.filter((item) => { + if (seenItemIds.has(item.id)) return false; + seenItemIds.add(item.id); + return true; + }); +} + +/** + * Remove provider-native echoes of a canonical prefix from a streamed suffix. + * + * A freshly materialized CLI session can expose its copied prefix after the + * newly accepted user row while its live EventStore projection settles. Native + * source ids survive that copy, so compare portable item identity instead of + * relying on provider event order or a raw array index. + */ +export function removeKnownNativeConversationEchoes( + knownEvents: readonly SessionEvent[], + candidates: readonly SessionEvent[] +): SessionEvent[] { + const knownItems = projectNativeConversationItems(knownEvents); + // Only an explicit globally scoped identity proves two rows are the same + // message. A provider-positional id (`codex-asst-97`) is reused by every + // native rollout of one execution child, so its session-scoped hash can + // collide with a different, genuinely new row from a later rollout. + const seen = new Set( + knownEvents + .map(scopedNativeSourceEventIdOf) + .filter((id): id is string => id !== null) + ); + const semanticCounts = new Map(); + let semanticPrefixOpen = true; + for (const item of knownItems) { + const key = JSON.stringify(semanticItem(item)); + semanticCounts.set(key, (semanticCounts.get(key) ?? 0) + 1); + } + return candidates.filter((event) => { + const items = projectNativeConversationItems([event]); + if (items.length === 0) return true; + const scopedId = scopedNativeSourceEventIdOf(event); + const hasKnownIds = scopedId !== null && seen.has(scopedId); + const keys = items.map((item) => JSON.stringify(semanticItem(item))); + const remaining = new Map(semanticCounts); + const hasKnownSemantics = keys.every((key) => { + const count = remaining.get(key) ?? 0; + if (count <= 0) return false; + remaining.set(key, count - 1); + return true; + }); + // Provider-local ids can change when a copied prefix crosses runtimes, so + // semantic matching is necessary while that prefix is being replayed. + // Once the first genuinely new portable row arrives, however, an equal + // later answer/tool result is a legitimate repetition and must survive. + // Globally scoped identity remains safe to collapse anywhere. + const isKnown = hasKnownIds || (semanticPrefixOpen && hasKnownSemantics); + if (!isKnown) semanticPrefixOpen = false; + if (isKnown) { + for (const key of keys) { + const count = semanticCounts.get(key) ?? 0; + if (count > 0) semanticCounts.set(key, count - 1); + } + } + if (scopedId) seen.add(scopedId); + return !isKnown; + }); +} + +export function projectNativeConversation(events: readonly SessionEvent[]): { + items: NativeConversationItem[]; + fidelity: NativeConversationFidelity; +} { + const items = projectNativeConversationItems(events); + const projectedUserIds = new Set( + items.flatMap((item) => + item.kind === "message" && item.role === "user" ? [item.id] : [] + ) + ); + const losesAuthorship = events.some( + (event) => + event.source === "user" && + projectedUserIds.has(nativeSourceEventId(event)) && + conversationSenderStampOf(event) !== null + ); + return { + items, + fidelity: losesAuthorship + ? { level: "lossy", omitted: ["participant_authorship"] } + : { level: "exact", omitted: [] }, + }; +} + +/** Mirror Rust's ingress cap before Tauri deserializes a potentially huge Vec. */ +export function assertNativeConversationPayloadWithinBounds( + items: readonly NativeConversationItem[], + limits: { maxItems?: number; maxBytes?: number } = {} +): number { + const maxItems = limits.maxItems ?? MAX_NATIVE_CONVERSATION_ITEMS; + const maxBytes = limits.maxBytes ?? MAX_NATIVE_CONVERSATION_SERIALIZED_BYTES; + if (items.length > maxItems) { + throw new Error( + `native transcript has ${items.length} items; limit is ${maxItems}` + ); + } + let bytes = 2; // JSON array brackets. + const encoder = new TextEncoder(); + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + // Rust reserializes serde-defaulted fields while validating. Measure that + // canonical shape so the TS preflight cannot pass a near-limit payload + // that Rust rejects only after allocating/deserializing the full Vec. + const validatedShape = + item.kind === "message" ? { ...item, turnId: item.turnId ?? null } : item; + bytes += encoder.encode(JSON.stringify(validatedShape)).length; + if (index > 0) bytes += 1; // JSON array comma. + if (bytes > maxBytes) { + throw new Error( + `native transcript is ${bytes} bytes; limit is ${maxBytes}` + ); + } + } + return bytes; +} + +export function sourceEventIdOfNativeItem( + item: NativeConversationItem +): string { + return item.kind === "message" + ? item.id + : item.id.replace(/:(?:call|result)$/, ""); +} + +/** + * Native CLIs can be killed before their newest fork is flushed. In that + * case the native reader deliberately falls back to the previous readable + * fork, while EventStore still holds the accepted user row and any durable + * partial output already streamed by the interrupted turn. Extend the native + * semantic prefix with exactly that portable suffix instead of blanking it + * during reconcile. Divergent histories fail closed and keep native truth. + */ +export function mergeInterruptedConversationProjection( + nativeEvents: readonly SessionEvent[], + projectedEvents: readonly SessionEvent[] +): SessionEvent[] { + const nativeItems = projectNativeConversationItems(nativeEvents); + const projectedItems = projectNativeConversationItems(projectedEvents); + if ( + nativeItems.length >= projectedItems.length || + !nativeConversationItemsArePrefix(nativeItems, projectedItems) + ) { + return [...nativeEvents]; + } + + const suffixSourceIds = new Set( + projectedItems.slice(nativeItems.length).map(sourceEventIdOfNativeItem) + ); + const nativeEventIds = new Set(nativeEvents.map((event) => event.id)); + const suffix = projectedEvents.filter( + (event) => + suffixSourceIds.has(nativeSourceEventId(event)) && + !nativeEventIds.has(event.id) + ); + return suffix.length > 0 ? [...nativeEvents, ...suffix] : [...nativeEvents]; +} + +function semanticItem(item: NativeConversationItem): unknown { + switch (item.kind) { + case "message": + return [item.kind, item.role, item.text, item.images]; + case "tool_call": + return [ + item.kind, + item.callId, + item.name, + canonicalJson(JSON.parse(item.arguments) as unknown), + ]; + case "tool_result": + return [ + item.kind, + item.callId, + item.name, + item.output, + item.isError, + item.interrupted, + ]; + case "context_summary": + return [item.kind, item.summary]; + } +} + +/** + * Provider-neutral semantic identity for one canonical event. Unlike event + * ids, this survives a native provider parser that exposes only positional + * ids after materialization. Callers must still match occurrences one-to-one: + * repeated equal messages in different turns are valid conversation events. + */ +export function nativeConversationEventSemanticKey( + event: SessionEvent +): string | null { + const items = projectNativeConversationItems([event]); + return items.length > 0 + ? JSON.stringify(items.map((item) => semanticItem(item))) + : null; +} + +function nativeItemShape(item: NativeConversationItem | undefined): string { + if (!item) return "missing"; + switch (item.kind) { + case "message": + return `message:${item.role}:text=${item.text.length}:images=${item.images.length}`; + case "tool_call": + return `tool_call:${item.name}:call=${item.callId}:arguments=${item.arguments.length}`; + case "tool_result": + return `tool_result:${item.name}:call=${item.callId}:output=${item.output.length}`; + case "context_summary": + return `context_summary:text=${item.summary.length}`; + } +} + +function nativeConversationMismatch( + expected: readonly NativeConversationItem[], + actual: readonly NativeConversationItem[] +): string { + const sharedLength = Math.min(expected.length, actual.length); + let firstMismatch = sharedLength; + for (let index = 0; index < sharedLength; index += 1) { + if ( + JSON.stringify(semanticItem(expected[index])) !== + JSON.stringify(semanticItem(actual[index])) + ) { + firstMismatch = index; + break; + } + } + return [ + `expected=${expected.length}`, + `actual=${actual.length}`, + `firstMismatch=${firstMismatch}`, + `expectedShape=${nativeItemShape(expected[firstMismatch])}`, + `actualShape=${nativeItemShape(actual[firstMismatch])}`, + ].join(" "); +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalJson(item)]) + ); + } + return value; +} + +export function nativeConversationItemsEqual( + left: readonly NativeConversationItem[], + right: readonly NativeConversationItem[] +): boolean { + return ( + left.length === right.length && + left.every( + (item, index) => + JSON.stringify(semanticItem(item)) === + JSON.stringify(semanticItem(right[index])) + ) + ); +} + +export function nativeConversationItemsArePrefix( + prefix: readonly NativeConversationItem[], + complete: readonly NativeConversationItem[] +): boolean { + return ( + prefix.length <= complete.length && + prefix.every( + (item, index) => + JSON.stringify(semanticItem(item)) === + JSON.stringify(semanticItem(complete[index])) + ) + ); +} + +function providerPortableSemanticItems( + items: readonly NativeConversationItem[] +): unknown[] | null { + const calls = new Map< + string, + { alias: number; name: string; hasResult: boolean } + >(); + const semantic: unknown[] = []; + for (const item of items) { + switch (item.kind) { + case "tool_call": { + if (calls.has(item.callId)) return null; + const call = { + alias: calls.size, + name: item.name, + hasResult: false, + }; + calls.set(item.callId, call); + let args: unknown; + try { + args = ["json", canonicalJson(JSON.parse(item.arguments) as unknown)]; + } catch { + // Invalid provider rows compare only by their exact raw payload; + // two unrelated parse failures must never collapse to one value. + args = ["raw", item.arguments]; + } + semantic.push([item.kind, call.alias, item.name, args]); + break; + } + case "tool_result": { + const call = calls.get(item.callId); + if (!call || call.hasResult || call.name !== item.name) return null; + call.hasResult = true; + // `interrupted` is ORG2-only refinement. Provider transcripts retain + // the portable error bit and output but cannot round-trip that flag. + semantic.push([ + item.kind, + call.alias, + item.name, + item.output, + item.isError, + ]); + break; + } + default: + semantic.push(semanticItem(item)); + } + } + return semantic; +} + +/** + * Prefix identity across two provider-native transcripts. Provider call ids + * are local pairing keys and can be rewritten when Codex history is rebuilt + * for Claude (or vice versa). Normalize each transcript's ids by first-use + * order so the call/result topology remains strict while equivalent provider + * ids do not make an otherwise exact materialized child look divergent. + */ +export function nativeConversationItemsAreProviderPortablePrefix( + prefix: readonly NativeConversationItem[], + complete: readonly NativeConversationItem[] +): boolean { + if (prefix.length > complete.length) return false; + const prefixSemantic = providerPortableSemanticItems(prefix); + const completeSemantic = providerPortableSemanticItems(complete); + if (!prefixSemantic || !completeSemantic) return false; + return prefixSemantic.every( + (item, index) => + JSON.stringify(item) === JSON.stringify(completeSemantic[index]) + ); +} + +function nativeConversationItemsAreProviderPortableEqual( + left: readonly NativeConversationItem[], + right: readonly NativeConversationItem[] +): boolean { + return ( + left.length === right.length && + nativeConversationItemsAreProviderPortablePrefix(left, right) + ); +} + +export function supportsNativeConversationTarget( + target: Pick +): boolean { + return ( + !target.cliAgentType || + NATIVE_CONVERSATION_CLI_TARGETS.includes( + target.cliAgentType as NativeConversationCliTarget + ) + ); +} + +export async function materializeNativeConversation(params: { + sessionId: string; + timeline: readonly SessionEvent[]; +}): Promise<{ events: SessionEvent[]; receipt: NativeMaterializationReceipt }> { + const { items, fidelity } = projectNativeConversation(params.timeline); + if (params.timeline.length > 0 && items.length === 0) { + throw new Error( + "conversation has no portable native role/tool transcript to materialize" + ); + } + // With no history there is nothing to migrate. Leave the fresh target + // unbound so its normal first send creates the provider-native session. + if (items.length === 0) { + return { + events: [], + receipt: { nativeSessionId: "", itemCount: 0, fidelity }, + }; + } + assertNativeConversationPayloadWithinBounds(items); + const wireReceipt = await invokeTauri( + "materialize_native_conversation", + { sessionId: params.sessionId, items } + ); + const receipt: NativeMaterializationReceipt = { ...wireReceipt, fidelity }; + try { + if (receipt.itemCount !== items.length) { + throw new Error( + `native materializer wrote ${receipt.itemCount} of ${items.length} items` + ); + } + const { events } = await loadAuthoritativeSessionEvents(params.sessionId); + const roundTripped = projectNativeConversationItems(events); + if (!nativeConversationItemsEqual(items, roundTripped)) { + throw new Error( + `native transcript round-trip verification failed; the target session was not started (${nativeConversationMismatch(items, roundTripped)})` + ); + } + return { events, receipt }; + } catch (error) { + if (isCliSession(params.sessionId)) { + await invokeTauri("discard_native_conversation_materialization", { + sessionId: params.sessionId, + nativeSessionId: receipt.nativeSessionId, + }).catch(() => undefined); + } + throw error; + } +} + +/** + * Bring an existing execution episode up to the canonical transcript before + * native resume. The complete structured role/tool history is written into + * the target provider's own transcript format; no delta is rendered as a + * user prompt. Only strict semantic-prefix growth is allowed; a branch or + * rewrite fails visibly rather than mutating or silently omitting native history. + */ +export async function synchronizeNativeConversation(params: { + sessionId: string; + timeline: readonly SessionEvent[]; +}): Promise<{ events: SessionEvent[]; receipt: NativeMaterializationReceipt }> { + const { items: complete, fidelity } = projectNativeConversation( + params.timeline + ); + assertNativeConversationPayloadWithinBounds(complete); + const wireReceipt = await invokeTauri( + "synchronize_native_conversation", + { + sessionId: params.sessionId, + completeItems: complete, + } + ); + const receipt: NativeMaterializationReceipt = { ...wireReceipt, fidelity }; + if (receipt.itemCount !== complete.length) { + throw new Error( + `native synchronizer wrote ${receipt.itemCount} of ${complete.length} items` + ); + } + const { events } = await loadAuthoritativeSessionEvents(params.sessionId); + if ( + !nativeConversationItemsAreProviderPortableEqual( + complete, + projectNativeConversationItems(events) + ) + ) { + throw new Error( + `native transcript synchronization round-trip verification failed (${nativeConversationMismatch(complete, projectNativeConversationItems(events))})` + ); + } + return { events, receipt }; +} diff --git a/src/engines/SessionCore/conversations/queuedConversationContract.ts b/src/engines/SessionCore/conversations/queuedConversationContract.ts new file mode 100644 index 0000000000..a66dfeb137 --- /dev/null +++ b/src/engines/SessionCore/conversations/queuedConversationContract.ts @@ -0,0 +1,156 @@ +import type { Store } from "jotai/vanilla/store"; + +import { + ConversationRootLocator, + LocalConversationTarget, + isConversationRootLocator, + isLocalConversationTarget, +} from "./conversationTypes"; + +export interface QueuedConversationDispatch { + kind: "canonical_conversation"; + /** Typed provider-neutral identity; all native execution episodes share it. */ + root: ConversationRootLocator; + /** Runtime/account/model/workspace frozen when the user pressed Send. */ + target: LocalConversationTarget; + /** Non-secret sender/account identity frozen at admission for remote roots. */ + dispatchIdentityKey?: string; +} + +/** Neutral subset consumed by a canonical authority executor. */ +export interface QueuedConversationMessage { + id: string; + turnIntentId: string; + sessionId: string; + content: string; + displayContent: string; + imageDataUrls?: string[]; + conversationDispatch?: QueuedConversationDispatch; +} + +export interface QueuedConversationExecutionMessage extends QueuedConversationMessage { + status: "preparing" | "accepted"; + runnerSessionId?: string; + runnerEventStartIndex?: number; +} + +export const MAX_QUEUED_CONVERSATION_MESSAGE_CHARS = 8 * 1024 * 1024; +export const MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL = 32 * 1024 * 1024; + +export function queuedConversationMessageCharSize( + message: Pick< + QueuedConversationMessage, + "content" | "displayContent" | "imageDataUrls" + > +): number { + return ( + message.content.length + + message.displayContent.length + + (message.imageDataUrls ?? []).reduce( + (total, image) => total + image.length, + 0 + ) + ); +} + +/** Shared persisted-payload schema for the UI queue and execution owner. */ +export function isQueuedConversationMessagePayload( + value: unknown +): value is QueuedConversationMessage { + if (!value || typeof value !== "object") return false; + const item = value as Partial; + const dispatch = item.conversationDispatch; + return Boolean( + typeof item.id === "string" && + typeof item.turnIntentId === "string" && + typeof item.sessionId === "string" && + typeof item.content === "string" && + typeof item.displayContent === "string" && + (item.imageDataUrls === undefined || + (Array.isArray(item.imageDataUrls) && + item.imageDataUrls.every((image) => typeof image === "string"))) && + dispatch?.kind === "canonical_conversation" && + isConversationRootLocator(dispatch.root) && + isLocalConversationTarget(dispatch.target) && + (dispatch.dispatchIdentityKey === undefined || + typeof dispatch.dispatchIdentityKey === "string") && + queuedConversationMessageCharSize(item as QueuedConversationMessage) <= + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS + ); +} + +/** Lifecycle boundaries exposed by the existing durable message queue. */ +export interface QueuedConversationDispatchCallbacks { + /** Provider accepted the turn; persist `accepted` on the same queue row. */ + onAccepted: (runnerSessionId: string) => void | Promise; + /** A writable native execution episode is ready for presentation. */ + onRunnerReady?: ( + runnerSessionId: string, + eventStartIndex: number + ) => void | Promise; +} + +/** Another window currently owns this canonical root; keep the row queued. */ +export class QueuedConversationBusyError extends Error { + constructor() { + super("canonical conversation is running in another window"); + this.name = "QueuedConversationBusyError"; + } +} + +/** The durable row is valid but cannot run under the current local identity. */ +export class QueuedConversationBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = "QueuedConversationBlockedError"; + } +} + +/** An accepted provider turn is not readable yet; retry recovery, never send. */ +export class QueuedConversationRecoveryPendingError extends Error { + constructor(message = "accepted conversation turn is not recoverable yet") { + super(message); + this.name = "QueuedConversationRecoveryPendingError"; + } +} + +/** Accepted native state contradicts the canonical root and needs inspection. */ +export class QueuedConversationRecoveryBlockedError extends Error { + constructor(message: string) { + super(message); + this.name = "QueuedConversationRecoveryBlockedError"; + } +} + +/** + * The provider accepted the turn and then failed it definitively without + * producing a tail. Keep the user's row visible with the reason and hold it + * for an explicit retry; never rerun the provider on its own. + */ +export class QueuedConversationTurnFailedError extends Error { + constructor(message = "conversation turn failed") { + super(message); + this.name = "QueuedConversationTurnFailedError"; + } +} + +/** The canonical user turn has a durable terminal failure; do not requeue it. */ +export class QueuedConversationTurnClosedError extends Error { + constructor(message = "canonical conversation turn is already closed") { + super(message); + this.name = "QueuedConversationTurnClosedError"; + } +} + +/** + * Dependency-inversion seam for canonical-conversation delivery. + * + * This is a contract, not a queue or executor owner. SessionCore continues to + * own the only durable queue; feature composition supplies a dispatcher + * without making the queue depend on UI or Cloud modules. + */ +export type QueuedConversationDispatcher = ( + store: Store, + message: QueuedConversationExecutionMessage, + callbacks: QueuedConversationDispatchCallbacks +) => Promise; diff --git a/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts b/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts index 6faab1ce8a..59a6cd9fe2 100644 --- a/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts +++ b/src/engines/SessionCore/core/atoms/__tests__/actions.test.ts @@ -15,7 +15,10 @@ import type { loadSessionAtom as LoadSessionAtomType, } from "../actions"; import type { eventsAtom as EventsAtomType } from "../events"; -import type { transcriptReplaceEpochAtom as TranscriptReplaceEpochAtomType } from "../metadata"; +import type { + pendingSyntheticEventAtom as PendingSyntheticEventAtomType, + transcriptReplaceEpochAtom as TranscriptReplaceEpochAtomType, +} from "../metadata"; vi.mock("../../store/EventStoreProxy", () => ({ eventStoreProxy: { @@ -48,13 +51,15 @@ let appendEventsAtom: typeof AppendEventsAtomType; let clearSessionAtom: typeof ClearSessionAtomType; let loadSessionAtom: typeof LoadSessionAtomType; let eventsAtom: typeof EventsAtomType; +let pendingSyntheticEventAtom: typeof PendingSyntheticEventAtomType; let transcriptReplaceEpochAtom: typeof TranscriptReplaceEpochAtomType; beforeAll(async () => { ({ appendEventsAtom, clearSessionAtom, loadSessionAtom } = await import("../actions")); ({ eventsAtom } = await import("../events")); - ({ transcriptReplaceEpochAtom } = await import("../metadata")); + ({ pendingSyntheticEventAtom, transcriptReplaceEpochAtom } = + await import("../metadata")); }); beforeEach(() => { @@ -552,6 +557,41 @@ describe("loadSessionAtom", () => { ]); }); + it("replace: restores a parked next-turn user row after the Rust snapshot was already overwritten", () => { + const store = createStore(); + const priorAssistant = makeReplayEvent( + "claudecodeapp-asst-0", + "previous turn complete", + "assistant", + "2026-05-16T00:00:02.000Z" + ); + const nextTurn = { + ...makeUserMessageEvent("user-input-next", "continue exploring", { + synthetic: true, + }), + createdAt: "2026-05-16T00:00:03.000Z", + }; + + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [priorAssistant], + }); + // Models the delayed native reconcile race: the Rust replace notification + // has already removed the EventStore copy, leaving only the parked row. + store.set(pendingSyntheticEventAtom, nextTurn); + store.set(loadSessionAtom, { + sessionId: "session-1", + events: [priorAssistant], + replace: true, + }); + + expect(store.get(eventsAtom).map((event) => event.id)).toEqual([ + "claudecodeapp-asst-0", + "user-input-next", + ]); + expect(store.get(pendingSyntheticEventAtom)?.id).toBe("user-input-next"); + }); + it("carries optimistic user images onto a live persisted echo", () => { const store = createStore(); const images = ["data:image/png;base64,BBB"]; diff --git a/src/engines/SessionCore/core/atoms/actions.ts b/src/engines/SessionCore/core/atoms/actions.ts index 58ac486303..c188a1569f 100644 --- a/src/engines/SessionCore/core/atoms/actions.ts +++ b/src/engines/SessionCore/core/atoms/actions.ts @@ -16,7 +16,6 @@ import { REPLAY_CONFIG } from "@src/config/workspace/replayConfig"; import { clearLoadedPayloads } from "@src/engines/SessionCore/payloads"; import { clearLoadedTurnRegistry } from "@src/engines/SessionCore/turns/loadedTurnRegistry"; import { createLogger } from "@src/hooks/logger"; -import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import { isVisibleInChat } from "../../ingestion/visibilityFilters"; @@ -37,7 +36,6 @@ import { getUserMessageContent, getUserMessageImages, hasUserMessageImages, - syntheticMatchesQueuedMessage, syntheticSettledByScope, withUserMessageImages, } from "./actions.userMessageSync"; @@ -291,40 +289,21 @@ export const loadSessionAtom = atom( const argsMap = extendRunningArgsCache(eventsForLoad); const enrichedEvents = applyRunningArgs(argsMap, eventsForLoad); - const queuedMessagesForSession = get(messageQueueAtom).filter( - (message) => message.sessionId === sessionId - ); - const queuedSyntheticEvents = new Set(); - for (const event of enrichedEvents) { - if ( - isSyntheticUserInputEvent(event) && - queuedMessagesForSession.some((message) => - syntheticMatchesQueuedMessage(event, message) - ) - ) { - queuedSyntheticEvents.add(event.id); - } - } - const transcriptEvents = - queuedSyntheticEvents.size > 0 - ? enrichedEvents.filter((event) => !queuedSyntheticEvents.has(event.id)) - : enrichedEvents; - - // Deduplicate: when events already contains the synthetic event (e.g. - // the initial loadSessionAtom call from launchSession passes it directly), - // don't prepend a second copy. Synthetic events that correspond to a - // still-parked frontend queue item are not transcript turns yet; keeping - // them here makes queued follow-ups cross the rendered round boundary - // before dispatch. + // Queue state is delivery metadata, not a second transcript. Never remove + // a canonical user row merely because its durable queue job is still + // parked or recovering: pending/failed rows must survive hydration and a + // repeated prompt is a distinct turn. Exact event-id dedupe below is the + // only safe transcript dedupe boundary. + const transcriptEvents = enrichedEvents; + + // Deduplicate exact event identities only. Queue delivery state is + // projected separately and matching by text used to hide a different + // repeated message during hydration. let mergedEvents: SessionEvent[]; if (syntheticUserEvents.length > 0) { const enrichedIds = new Set(transcriptEvents.map((evt) => evt.id)); const uniqueSynthetic = syntheticUserEvents.filter( - (evt) => - !enrichedIds.has(evt.id) && - !queuedMessagesForSession.some((message) => - syntheticMatchesQueuedMessage(evt, message) - ) + (evt) => !enrichedIds.has(evt.id) ); if (uniqueSynthetic.length > 0) { // A rescued synthetic newer than the replayed transcript is a diff --git a/src/engines/SessionCore/core/atoms/actions.userMessageSync.ts b/src/engines/SessionCore/core/atoms/actions.userMessageSync.ts index 1b256cbcda..aee701df6c 100644 --- a/src/engines/SessionCore/core/atoms/actions.userMessageSync.ts +++ b/src/engines/SessionCore/core/atoms/actions.userMessageSync.ts @@ -6,6 +6,7 @@ * matching a synthetic user-input event against a still-parked frontend * message-queue entry. Extracted from actions.ts. */ +import { turnIntentIdOf } from "../../sync/utils/activityIds"; import type { SessionEvent } from "../types"; function normalizeUserText(value: string | undefined): string { @@ -54,9 +55,22 @@ export function withUserMessageImages( */ export function syntheticSettledByScope( event: SessionEvent, - scope: { matchingContents: string[]; olderThan?: string } | null + scope: { + matchingContents: string[]; + matchingTurnIntentIds: string[]; + olderThan?: string; + } | null ): boolean { if (!scope) return false; + const turnIntentId = turnIntentIdOf(event); + // A submit-boundary placeholder has a durable logical identity. Timestamp + // order is not evidence for these rows: native replay/materialization can + // legitimately re-stamp an older turn after the new optimistic row was + // created. Only the matching backend intent may settle it. Content and + // timestamp remain the compatibility path for legacy placeholders. + if (turnIntentId) { + return scope.matchingTurnIntentIds.includes(turnIntentId); + } const targets = new Set(scope.matchingContents.map(normalizeUserText)); const eventTexts = [ normalizeUserText(event.displayText), @@ -67,27 +81,3 @@ export function syntheticSettledByScope( scope.olderThan && event.createdAt && event.createdAt < scope.olderThan ); } - -export function syntheticMatchesQueuedMessage( - event: SessionEvent, - queued: { sessionId: string; content: string; displayContent: string } -): boolean { - if (event.sessionId !== queued.sessionId) return false; - const eventText = normalizeUserText(event.displayText); - const resultMessage = event.result?.message; - const eventContent = normalizeUserText( - typeof resultMessage === "object" && - resultMessage !== null && - "content" in resultMessage - ? String(resultMessage.content ?? "") - : event.displayText - ); - const queuedDisplay = normalizeUserText(queued.displayContent); - const queuedContent = normalizeUserText(queued.content); - return ( - eventText === queuedDisplay || - eventText === queuedContent || - eventContent === queuedDisplay || - eventContent === queuedContent - ); -} diff --git a/src/engines/SessionCore/core/atoms/metadata.ts b/src/engines/SessionCore/core/atoms/metadata.ts index 01be609f4b..8d539f5d30 100644 --- a/src/engines/SessionCore/core/atoms/metadata.ts +++ b/src/engines/SessionCore/core/atoms/metadata.ts @@ -157,9 +157,10 @@ isLoadingMoreAtom.debugLabel = "session/isLoadingMore"; // ============================================ /** - * Holds the synthetic user event injected by launchSession so it survives - * clearSessionAtom. loadSessionAtom consumes and merges it when the real - * data arrives, then clears the atom. + * Holds the visible session's newest synthetic user event so it survives a + * session switch or a delayed transcript replace. loadSessionAtom consumes + * and merges it until the provider's real echo arrives, then clears the atom. + * Background sessions must not overwrite this foreground slot. */ export const pendingSyntheticEventAtom = atom(null); pendingSyntheticEventAtom.debugLabel = "session/pendingSyntheticEvent"; diff --git a/src/engines/SessionCore/core/store/EventStoreProxy.ts b/src/engines/SessionCore/core/store/EventStoreProxy.ts index 921954eaff..33ce26abc0 100644 --- a/src/engines/SessionCore/core/store/EventStoreProxy.ts +++ b/src/engines/SessionCore/core/store/EventStoreProxy.ts @@ -538,6 +538,7 @@ class EventStoreProxyImpl { return rpc.sessionCore.eventStore.removeSyntheticUserInputs({ sessionId: sessionId ?? null, matchingContents: scope?.matchingContents, + matchingTurnIntentIds: scope?.matchingTurnIntentIds, olderThan: scope?.olderThan, }); } diff --git a/src/engines/SessionCore/core/store/eventStoreEvents.ts b/src/engines/SessionCore/core/store/eventStoreEvents.ts index e8ad755095..6c22e55aac 100644 --- a/src/engines/SessionCore/core/store/eventStoreEvents.ts +++ b/src/engines/SessionCore/core/store/eventStoreEvents.ts @@ -1,4 +1,7 @@ -import { isBackendUserMessageEvent } from "@src/engines/SessionCore/sync/utils/activityIds"; +import { + isBackendUserMessageEvent, + turnIntentIdOf, +} from "@src/engines/SessionCore/sync/utils/activityIds"; import type { SessionEvent } from "../types"; @@ -17,6 +20,7 @@ export function isRealUserEvent(event: SessionEvent): boolean { export interface SyntheticEvictionScope { matchingContents: string[]; + matchingTurnIntentIds: string[]; olderThan?: string; } @@ -32,9 +36,12 @@ export function syntheticEvictionScopeForRealUserEvents( events: SessionEvent[] ): SyntheticEvictionScope | null { const contents = new Set(); + const turnIntentIds = new Set(); let olderThan: string | undefined; for (const event of events) { if (!isRealUserEvent(event)) continue; + const turnIntentId = turnIntentIdOf(event); + if (turnIntentId) turnIntentIds.add(turnIntentId); if (event.displayText) contents.add(event.displayText); const message = event.result?.message; if ( @@ -49,6 +56,11 @@ export function syntheticEvictionScopeForRealUserEvents( olderThan = event.createdAt; } } - if (contents.size === 0 && !olderThan) return null; - return { matchingContents: [...contents], olderThan }; + if (contents.size === 0 && turnIntentIds.size === 0 && !olderThan) + return null; + return { + matchingContents: [...contents], + matchingTurnIntentIds: [...turnIntentIds], + olderThan, + }; } diff --git a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts index 74ea8cfeb8..31ac086465 100644 --- a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts +++ b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts @@ -5,10 +5,14 @@ import { derivedSnapshotAtom, streamingDeltaContentAtom, } from "@src/engines/SessionCore/core/atoms/events"; -import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; +import { + pendingSyntheticEventAtom, + sessionIdAtom, +} from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsAtom } from "@src/engines/SessionCore/derived/chatEvents"; import { messagesEventsAtom } from "@src/engines/SessionCore/derived/simulatorEvents"; +import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; function makeSnapshot(chatEvents: SessionEvent[] = [], streaming = true) { return { @@ -72,6 +76,203 @@ afterEach(() => { }); describe("chatEventsAtom live streaming overlay", () => { + it("publishes recovered delivery ownership when message text and tail stay unchanged", () => { + const store = createStore(); + store.set(sessionIdAtom, "session-1"); + const failed = makeChatEvent( + "queued-user:legacy:", + "2026-06-06T20:00:00Z", + { + source: "user", + functionName: "user_message", + actionType: "raw", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "legacy", + message: { role: "user", content: "retry me" }, + }, + } + ); + const tail = makeChatEvent("error", "2026-06-06T20:00:01Z"); + store.set(derivedSnapshotAtom, makeSnapshot([failed, tail], false)); + const before = store.get(chatEventsAtom); + const { queueMessageId: _removed, ...recoveredResult } = failed.result; + const recovered = { ...failed, result: recoveredResult }; + store.set(derivedSnapshotAtom, makeSnapshot([recovered, tail], false)); + const after = store.get(chatEventsAtom); + expect(after).not.toBe(before); + expect(after[0].result.queueMessageId).toBeUndefined(); + }); + + it("projects a durable queued turn immediately and replaces it by intent identity", () => { + const store = createStore(); + store.set(sessionIdAtom, "session-1"); + store.set(derivedSnapshotAtom, makeSnapshot([], false)); + store.set(messageQueueAtom, [ + { + id: "queue-1", + turnIntentId: "turn-queued", + sessionId: "session-1", + content: "same request", + displayContent: "same request", + priority: "next", + status: "queued", + createdAt: "2026-06-06T20:00:01.000Z", + }, + ]); + + expect(store.get(chatEventsAtom)).toEqual([ + expect.objectContaining({ + id: "queued-user-turn-queued", + displayText: "same request", + displayStatus: "pending", + result: expect.objectContaining({ + deliveryStatus: "pending", + queueMessageId: "queue-1", + turnIntentId: "turn-queued", + }), + }), + ]); + + const providerRow = makeChatEvent( + "provider-user-turn-queued", + "2026-06-06T20:00:02.000Z", + { + source: "user", + functionName: "user", + displayVariant: "message", + displayText: "same request", + result: { + turnIntentId: "turn-queued", + message: { content: "same request", role: "user" }, + }, + } + ); + store.set(derivedSnapshotAtom, makeSnapshot([providerRow], false)); + expect(store.get(chatEventsAtom)).toEqual([providerRow]); + }); + + it("projects a held durable delivery failure as failed after hydration", () => { + const store = createStore(); + store.set(sessionIdAtom, "session-1"); + store.set(derivedSnapshotAtom, makeSnapshot([], false)); + store.set(messageQueueAtom, [ + { + id: "queue-failed", + turnIntentId: "turn-failed", + sessionId: "session-1", + content: "retry this request", + displayContent: "retry this request", + priority: "next", + requiresExplicitDispatch: true, + status: "queued", + deliveryError: "provider unavailable", + createdAt: "2026-06-06T20:00:01.000Z", + }, + ]); + + expect(store.get(chatEventsAtom)).toEqual([ + expect.objectContaining({ + id: "queued-user-turn-failed", + displayText: "retry this request", + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "provider unavailable", + queueMessageId: "queue-failed", + turnIntentId: "turn-failed", + }), + }), + ]); + }); + + it("overlays a durable delivery failure onto its unpatched optimistic row", () => { + const store = createStore(); + store.set(sessionIdAtom, "session-1"); + const optimisticRow = makeChatEvent( + "queued-user:queue-stale:", + "2026-06-06T20:00:01.000Z", + { + source: "user", + functionName: "user", + displayVariant: "message", + displayStatus: "pending", + displayText: "retry this request", + result: { + deliveryStatus: "pending", + queueMessageId: "queue-stale", + turnIntentId: "turn-stale", + message: { content: "retry this request", role: "user" }, + }, + } + ); + store.set(derivedSnapshotAtom, makeSnapshot([optimisticRow], false)); + store.set(messageQueueAtom, [ + { + id: "queue-stale", + turnIntentId: "turn-stale", + sessionId: "session-1", + content: "retry this request", + displayContent: "retry this request", + priority: "next", + requiresExplicitDispatch: true, + status: "queued", + deliveryError: "shared session is no longer available", + createdAt: "2026-06-06T20:00:01.000Z", + }, + ]); + + expect(store.get(chatEventsAtom)).toEqual([ + expect.objectContaining({ + id: "queued-user:queue-stale:", + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "shared session is no longer available", + queueMessageId: "queue-stale", + turnIntentId: "turn-stale", + }), + }), + ]); + }); + + it("suppresses the pending overlay after the provider's real user echo", () => { + const store = createStore(); + const pending = makeChatEvent( + "user-input-pending", + "2026-06-06T20:00:01.000Z", + { + source: "user", + functionName: "user_message", + uiCanonical: "", + actionType: "user_message", + displayText: "next request", + result: { syntheticUserInput: true, message: "next request" }, + displayVariant: "message", + } + ); + const echo = makeChatEvent( + "provider-user-echo", + "2026-06-06T20:00:02.000Z", + { + source: "user", + functionName: "user", + uiCanonical: "user", + actionType: "user_message", + displayText: "next request", + result: { message: { content: "next request" } }, + displayVariant: "message", + } + ); + store.set(sessionIdAtom, "session-1"); + store.set(pendingSyntheticEventAtom, pending); + store.set(derivedSnapshotAtom, makeSnapshot([echo], false)); + + expect(store.get(chatEventsAtom)).toEqual([echo]); + }); + it("renders live assistant text without writing a durable EventStore event", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-06T20:00:00.000Z")); diff --git a/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts b/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts index 38edaa339d..0f49945685 100644 --- a/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts +++ b/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts @@ -1,10 +1,7 @@ import { createStore } from "jotai"; import { describe, expect, it, vi } from "vitest"; -import { - messageQueueHydratedAtom, - queueFlushRequestAtom, -} from "@src/store/ui/messageQueueAtom"; +import { messageQueueHydratedAtom } from "@src/store/ui/messageQueueAtom"; import { turnLifecycleSignalAtom } from "../../control/turnLifecycle"; import { queueDispatchSyncInputsAtom } from "../queueDispatchSyncInputsAtom"; @@ -14,14 +11,12 @@ describe("queueDispatchSyncInputsAtom", () => { const store = createStore(); store.set(messageQueueHydratedAtom, true); - store.set(queueFlushRequestAtom, 2); store.set(turnLifecycleSignalAtom, 7); expect(store.get(queueDispatchSyncInputsAtom)).toMatchObject({ - queue: [], - hydrated: true, + deliveries: [], + queueHydrated: true, turnLifecycleSignal: 7, - flushRequest: 2, editing: false, }); }); diff --git a/src/engines/SessionCore/derived/chatEvents.ts b/src/engines/SessionCore/derived/chatEvents.ts index 1b80032baa..a0e63eced8 100644 --- a/src/engines/SessionCore/derived/chatEvents.ts +++ b/src/engines/SessionCore/derived/chatEvents.ts @@ -6,7 +6,11 @@ */ import { atom } from "jotai"; -import { isSyntheticUserInputEvent } from "@src/engines/SessionCore/sync/utils/activityIds"; +import { createSyntheticUserEvent } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { + isSyntheticUserInputEvent, + turnIntentIdOf, +} from "@src/engines/SessionCore/sync/utils/activityIds"; import { type QueuedMessage, messageQueueAtom, @@ -82,18 +86,6 @@ function normalizeEventText(value: string | null | undefined): string { return (value ?? "").replace(/\s+/g, " ").trim(); } -function getSyntheticUserText(event: SessionEvent): string { - const resultMessage = event.result?.message; - if ( - typeof resultMessage === "object" && - resultMessage !== null && - "content" in resultMessage - ) { - return normalizeEventText(String(resultMessage.content ?? "")); - } - return normalizeEventText(event.displayText); -} - export function filterQueuedSyntheticUserEvents( events: SessionEvent[], queuedMessages: QueuedMessage[] @@ -101,20 +93,33 @@ export function filterQueuedSyntheticUserEvents( if (queuedMessages.length === 0) return events; const queuedBySession = new Map>(); for (const message of queuedMessages) { - let texts = queuedBySession.get(message.sessionId); - if (!texts) { - texts = new Set(); - queuedBySession.set(message.sessionId, texts); + let turnIntentIds = queuedBySession.get(message.sessionId); + if (!turnIntentIds) { + turnIntentIds = new Set(); + queuedBySession.set(message.sessionId, turnIntentIds); } - texts.add(normalizeEventText(message.content)); - texts.add(normalizeEventText(message.displayContent)); + turnIntentIds.add(message.turnIntentId); } return events.filter((event) => { if (!isSyntheticUserInputEvent(event) || !event.sessionId) return true; - const queuedTexts = queuedBySession.get(event.sessionId); - if (!queuedTexts) return true; - return !queuedTexts.has(getSyntheticUserText(event)); + // New queue entries are canonical transcript rows with an explicit + // delivery lifecycle. Keep them visible beside the queue footer; only + // hide legacy queue placeholders that had no delivery contract. + if ( + event.result?.deliveryStatus === "pending" || + event.result?.deliveryStatus === "sent" || + event.result?.deliveryStatus === "failed" + ) { + return true; + } + const queuedTurnIntentIds = queuedBySession.get(event.sessionId); + if (!queuedTurnIntentIds) return true; + const turnIntentId = turnIntentIdOf(event); + // Legacy placeholders without a canonical identity are not safe to hide: + // matching by text made a later repeated prompt disappear. Only the exact + // queue-owned placeholder may be suppressed. + return !turnIntentId || !queuedTurnIntentIds.has(turnIntentId); }); } @@ -193,6 +198,79 @@ export function appendLiveAssistantEvent( return [...withoutLive, liveEvent]; } +/** + * Project durable queue rows as ordinary pending user turns immediately. + * + * The queue remains the sole dispatch authority; this is only its transcript + * projection. Once dispatch appends the real optimistic row, the shared + * turnIntentId suppresses this projection without text matching or a second + * queue. That gives queued/runtime-switch sends the same pending-message UX + * as direct sends while preserving crash recovery. + */ +export function appendQueuedUserEvents( + events: SessionEvent[], + sessionId: string | null, + queuedMessages: readonly QueuedMessage[] +): SessionEvent[] { + if (!sessionId || queuedMessages.length === 0) return events; + const representedTurnIntents = new Map(); + events.forEach((event, index) => { + const turnIntentId = turnIntentIdOf(event); + if (turnIntentId && !representedTurnIntents.has(turnIntentId)) { + representedTurnIntents.set(turnIntentId, index); + } + }); + let next = events; + for (const message of queuedMessages) { + if (message.sessionId !== sessionId) continue; + const representedIndex = representedTurnIntents.get(message.turnIntentId); + if (representedIndex !== undefined) { + // The durable queue row is the failure owner. When its optimistic + // transcript row could not be patched (the session was not loaded + // while the dispatcher classified the failure), the persisted row still + // reads "pending"; overlay the queue verdict so the bubble shows the + // error and its retry instead of sending forever. + const existing = next[representedIndex]; + if ( + !message.deliveryError || + !existing || + existing.result?.["queueMessageId"] !== message.id || + existing.result?.["deliveryStatus"] !== "pending" + ) { + continue; + } + if (next === events) next = [...events]; + next[representedIndex] = { + ...existing, + displayStatus: "failed", + result: { + ...existing.result, + deliveryStatus: "failed", + deliveryError: message.deliveryError, + }, + }; + continue; + } + const pending = createSyntheticUserEvent( + sessionId, + message.displayContent, + { + id: `queued-user-${message.turnIntentId}`, + createdAt: message.createdAt, + imageDataUrls: message.imageDataUrls, + turnIntentId: message.turnIntentId, + deliveryStatus: message.deliveryError ? "failed" : "pending", + deliveryError: message.deliveryError, + queueMessageId: message.id, + } + ); + if (next === events) next = [...events]; + next.push(pending); + representedTurnIntents.set(message.turnIntentId, next.length - 1); + } + return next; +} + export const chatEventsAtom = atom((get) => { const snap = get(derivedSnapshotAtom); const sessionId = get(sessionIdAtom); @@ -216,7 +294,11 @@ export const chatEventsAtom = atom((get) => { const queuedMessages = get(messageQueueAtom); if (snap && "chatEvents" in snap) { - const rawChatEvents = snap.chatEvents; + const rawChatEvents = appendQueuedUserEvents( + snap.chatEvents, + sessionId, + queuedMessages + ); // Fast path — skip the expensive derivation on unchanged frames. // @@ -238,7 +320,7 @@ export const chatEventsAtom = atom((get) => { liveContent === _prevLiveContent && rawChatEvents.length === _prevRawChatEvents.length && rawChatEvents.every((evt, i) => evt.id === _prevRawChatEvents[i].id) && - allArgsStable(rawChatEvents, _prevRawChatEvents) && + allActionFieldsStable(rawChatEvents, _prevRawChatEvents) && allPlanContentStable(rawChatEvents, _prevRawChatEvents) && (streaming ? lastEventStableIgnoreDisplayText(rawChatEvents, _prevRawChatEvents) @@ -258,7 +340,7 @@ export const chatEventsAtom = atom((get) => { _prevQueuedMessages = queuedMessages; _prevLiveContent = liveContent; - const argsChanged = !allArgsStable(next, _prevChatEvents); + const actionsChanged = !allActionFieldsStable(next, _prevChatEvents); const planContentChanged = !allPlanContentStable(next, _prevChatEvents); if ( @@ -267,7 +349,7 @@ export const chatEventsAtom = atom((get) => { (streaming ? lastEventStableIgnoreDisplayText(next, _prevChatEvents) : lastEventStable(next, _prevChatEvents)) && - !argsChanged && + !actionsChanged && !planContentChanged ) { return _prevChatEvents; @@ -279,7 +361,11 @@ export const chatEventsAtom = atom((get) => { // Fallback: no DerivedSnapshot yet (session switch, initial load, or only a // raw StreamingSnapshot without chatEvents). Filter JS-side, same as // messagesEventsAtom / simulatorEventsAtom do in their own fallback paths. - const events = get(eventsAtom); + const events = appendQueuedUserEvents( + get(eventsAtom), + sessionId, + queuedMessages + ); return appendLiveAssistantEvent( derivePlanDisplayEvents( filterQueuedSyntheticUserEvents( @@ -329,7 +415,7 @@ function lastEventStableIgnoreDisplayText( } /** - * Check that no event's routing-relevant args have changed. + * Check that no event's routing or user-delivery action fields have changed. * * We only check the fields that affect which adapter/block is rendered, * specifically `args.action` and `args.subagentSessionId`. A deep @@ -343,17 +429,37 @@ function lastEventStableIgnoreDisplayText( * check above would otherwise return the stale array and React would skip * the re-render that switches TitleOnlyAdapter → SubagentAdapter. */ -function allArgsStable(next: SessionEvent[], prev: SessionEvent[]): boolean { +function allActionFieldsStable( + next: SessionEvent[], + prev: SessionEvent[] +): boolean { if (next.length !== prev.length) return false; for (let i = 0; i < next.length; i++) { const na = next[i].args as Record | undefined; const pa = prev[i].args as Record | undefined; if (na?.["action"] !== pa?.["action"]) return false; if (na?.["subagentSessionId"] !== pa?.["subagentSessionId"]) return false; + // A failed user row need not be the tail. Ownership recovery changes its + // Retry payload without changing visible text or the trailing error row. + if (next[i].source === "user" || prev[i].source === "user") { + if (next[i].displayStatus !== prev[i].displayStatus) return false; + for (const key of USER_DELIVERY_ACTION_KEYS) { + if (next[i].result?.[key] !== prev[i].result?.[key]) return false; + } + } } return true; } +const USER_DELIVERY_ACTION_KEYS = [ + "queueMessageId", + "deliveryOwnerRetired", + "deliveryStatus", + "deliveryError", + "turnIntentId", + "syntheticUserInput", +] as const; + function allPlanContentStable( next: SessionEvent[], prev: SessionEvent[] diff --git a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts index 7ec4fe7b8f..c4dd1f0a75 100644 --- a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts +++ b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts @@ -1,11 +1,10 @@ import { atom } from "jotai"; import { - type QueuedMessage, - messageQueueAtom, + type MessageDeliveryRecord, + messageDeliveryRecordsAtom, messageQueueHydratedAtom, queueEditingAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { turnLifecycleSignalAtom } from "../control/turnLifecycle"; @@ -17,19 +16,17 @@ import { turnLifecycleSignalAtom } from "../control/turnLifecycle"; * Jotai emits one notification per dependency batch instead of up to five. */ export interface QueueDispatchSyncInputs { - queue: QueuedMessage[]; - hydrated: boolean; + deliveries: MessageDeliveryRecord[]; + queueHydrated: boolean; turnLifecycleSignal: number; - flushRequest: number; editing: boolean; } export const queueDispatchSyncInputsAtom = atom( (get) => ({ - queue: get(messageQueueAtom), - hydrated: get(messageQueueHydratedAtom), + deliveries: get(messageDeliveryRecordsAtom), + queueHydrated: get(messageQueueHydratedAtom), turnLifecycleSignal: get(turnLifecycleSignalAtom), - flushRequest: get(queueFlushRequestAtom), editing: get(queueEditingAtom), }) ); diff --git a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts index d332414312..d6c0f1a8c1 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -50,6 +50,7 @@ import type { SessionEvent } from "../core/types"; import { ensureCursorIdeEventsInStore } from "../sync/adapters/cursorIdeAdapter"; import { appendLiveAssistantEvent, + appendQueuedUserEvents, filterQueuedSyntheticUserEvents, } from "./chatEvents"; import { areChatTranscriptsStructurallyEqual } from "./chatTranscriptStructure"; @@ -186,7 +187,11 @@ function deriveFamilyChatEvents( return appendLiveAssistantEvent( derivePlanDisplayEvents( filterQueuedSyntheticUserEvents( - extractSessionChatEvents(snapshot), + appendQueuedUserEvents( + extractSessionChatEvents(snapshot), + sessionId, + queuedMessages + ), queuedMessages as QueuedMessage[] ) ), diff --git a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts index 1ef6e6a482..4f1714159b 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/launchPayload.test.ts @@ -56,6 +56,27 @@ describe("launchPayload", () => { expect(session.cliAgentType).toBe("opencode"); }); + it("persists the selected agent definition on the optimistic session row", () => { + const session = buildSessionFromLaunchResult({ + agentExecMode: "build", + effectiveSource: null, + isBackgroundLaunch: false, + launchAgentDefinitionId: "builtin:sde", + result: { + sessionId: "sdeagent-1", + category: DISPATCH_CATEGORY.RUST_AGENT, + name: "SDE session", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + userInput: "hello", + background: false, + model: "gpt-5.5", + }, + }); + + expect(session.agentDefinitionId).toBe("builtin:sde"); + }); + it("falls back to the launch platform for the optimistic CLI session row", () => { const session = buildSessionFromLaunchResult({ agentExecMode: "build", diff --git a/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts b/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts index cd42da6cef..e9e57daabe 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts @@ -3,21 +3,63 @@ import { createStore } from "jotai/vanilla"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + type ActiveMessageDelivery, type QueuedMessage, + activeMessageDeliveriesAtom, + messageDeliveryRecordsAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, messageQueueHydratedAtom, } from "@src/store/ui/messageQueueAtom"; -import { hydrateMessageQueue } from "../messageQueuePersistence"; +import { + cancelQueuedMessageDeliveries, + flushMessageQueuePersistence, + handoffQueuedMessageToActiveDelivery, + hydrateMessageQueue, + reconcileOrphanedOptimisticQueueProjections, + refreshMessageDeliveries, + returnActiveDeliveryToMessageQueue, +} from "../messageQueuePersistence"; const mocks = vi.hoisted(() => ({ load: vi.fn(), persist: vi.fn(), + handoff: vi.fn(), + returnToQueue: vi.fn(), + cancelQueued: vi.fn(), + removeOptimistic: vi.fn(), + findOwners: vi.fn(), + getEvents: vi.fn(), + updateById: vi.fn(), + subscribe: vi.fn(), })); vi.mock("@src/store/ui/messageQueueRepository", () => ({ - loadDurableMessageQueue: mocks.load, + loadDurableMessageDeliveries: mocks.load, persistDurableMessageQueue: mocks.persist, + handoffDurableMessageDelivery: mocks.handoff, + returnDurableMessageDeliveryToQueue: mocks.returnToQueue, + removeDurableQueuedMessageDeliveries: mocks.cancelQueued, + findDurableMessageDeliveryOwnerIds: mocks.findOwners, +})); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getEvents: mocks.getEvents, + updateById: mocks.updateById, + subscribe: mocks.subscribe, + }, + isStreamingSnapshot: (snapshot: { streaming?: boolean }) => + snapshot.streaming === true, +})); + +vi.mock("@src/engines/SessionCore/services/userIntentDispatch", () => ({ + isOptimisticQueueUserEventId: (eventId: string) => + eventId.startsWith("queued-user:") && eventId.endsWith(":"), + optimisticQueueUserEventId: (queueMessageId: string) => + `queued-user:${queueMessageId}:`, + removeOptimisticQueueUserDelivery: mocks.removeOptimistic, })); function message( @@ -37,15 +79,49 @@ function message( }; } +function activeDelivery( + id: string, + overrides: Partial = {} +): ActiveMessageDelivery { + return { + ...message(id), + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + status: "accepted", + runnerSessionId: `runner-${id}`, + ...overrides, + }; +} + describe("messageQueuePersistence", () => { beforeEach(() => { - mocks.load.mockReset().mockResolvedValue([]); + mocks.load.mockReset().mockResolvedValue({ queue: [], active: [] }); mocks.persist.mockReset().mockResolvedValue(undefined); + mocks.handoff.mockReset(); + mocks.returnToQueue.mockReset(); + mocks.cancelQueued.mockReset().mockResolvedValue([]); + mocks.removeOptimistic.mockReset().mockResolvedValue(undefined); + mocks.findOwners.mockReset().mockResolvedValue(new Set()); + mocks.getEvents.mockReset().mockResolvedValue([]); + mocks.updateById.mockReset().mockResolvedValue(true); + mocks.subscribe.mockReset().mockReturnValue(vi.fn()); }); - it("hydrates before opening the dispatch gate", async () => { + it("parks a legacy queued row before opening the dispatch gate", async () => { const durable = message("durable"); - mocks.load.mockResolvedValue([durable]); + mocks.load.mockResolvedValue({ queue: [durable], active: [] }); const store = createStore(); expect(store.get(messageQueueHydratedAtom)).toBe(false); @@ -61,6 +137,209 @@ describe("messageQueuePersistence", () => { expect(mocks.persist).toHaveBeenCalledWith([recovered]); }); + it("keeps a canonical queued row runnable after restart", async () => { + const durable = message("canonical", { + conversationDispatch: activeDelivery("canonical").conversationDispatch, + }); + mocks.load.mockResolvedValue({ queue: [durable], active: [] }); + const store = createStore(); + + await hydrateMessageQueue(store); + + expect(store.get(messageQueueAtom)).toEqual([durable]); + expect(store.get(messageQueueHydratedAtom)).toBe(true); + expect(mocks.persist).toHaveBeenCalledWith([durable]); + }); + + it("preserves a canonical Send Now priority across restart", async () => { + const durable = message("canonical-now", { + priority: "now", + conversationDispatch: + activeDelivery("canonical-now").conversationDispatch, + }); + mocks.load.mockResolvedValue({ queue: [durable], active: [] }); + const store = createStore(); + + await hydrateMessageQueue(store); + + expect(store.get(messageQueueAtom)).toEqual([durable]); + expect(mocks.persist).toHaveBeenCalledWith([durable]); + }); + + it("preserves an explicitly held canonical row across restart", async () => { + const durable = message("canonical-held", { + priority: "now", + requiresExplicitDispatch: true, + conversationDispatch: + activeDelivery("canonical-held").conversationDispatch, + }); + mocks.load.mockResolvedValue({ queue: [durable], active: [] }); + const store = createStore(); + + await hydrateMessageQueue(store); + + expect(store.get(messageQueueAtom)).toEqual([durable]); + expect(mocks.persist).toHaveBeenCalledWith([durable]); + }); + + it.each(["pending", "failed"])( + "recovers an ownerless %s projection in place after owner hydration", + async (deliveryStatus) => { + const orphan = { + id: "queued-user:legacy-orphan:", + sessionId: "session-orphan", + source: "user", + displayText: "@VantaNode inspect this", + displayStatus: deliveryStatus, + result: { + syntheticUserInput: true, + deliveryStatus, + ...(deliveryStatus === "failed" + ? { deliveryError: "database is locked" } + : {}), + queueMessageId: "legacy-orphan", + turnIntentId: "intent-legacy-orphan", + message: { role: "user", content: "@VantaNode inspect this" }, + images: ["data:image/png;base64,keep"], + mentions: [{ id: "vanta", label: "VantaNode" }], + }, + }; + mocks.getEvents.mockResolvedValue([orphan]); + + await reconcileOrphanedOptimisticQueueProjections("session-orphan"); + + expect(mocks.findOwners).toHaveBeenCalledWith(["legacy-orphan"]); + expect(mocks.removeOptimistic).not.toHaveBeenCalled(); + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:legacy-orphan:", + { + displayStatus: "failed", + result: expect.objectContaining({ + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: + deliveryStatus === "failed" + ? "database is locked" + : expect.stringContaining( + "pending delivery could not be recovered" + ), + turnIntentId: "intent-legacy-orphan", + message: { role: "user", content: "@VantaNode inspect this" }, + images: ["data:image/png;base64,keep"], + mentions: [{ id: "vanta", label: "VantaNode" }], + }), + }, + "session-orphan" + ); + const patch = mocks.updateById.mock.calls[0]?.[1] as { + result: Record; + }; + expect(patch.result).not.toHaveProperty("queueMessageId"); + } + ); + + it("retries reconciliation when queue hydration precedes EventStore hydration", async () => { + const orphan = { + id: "queued-user:late-orphan:", + sessionId: "session-late", + source: "user", + displayStatus: "pending", + result: { + syntheticUserInput: true, + deliveryStatus: "pending", + queueMessageId: "late-orphan", + }, + }; + const store = createStore(); + await hydrateMessageQueue(store); + expect(mocks.getEvents).not.toHaveBeenCalled(); + + mocks.getEvents.mockResolvedValue([orphan]); + const inspectSnapshot = mocks.subscribe.mock.calls.at(-1)?.[0] as ( + snapshot: { chatEvents: (typeof orphan)[]; eventCount: number }, + sessionId: string + ) => void; + inspectSnapshot({ chatEvents: [orphan], eventCount: 1 }, "session-late"); + + await vi.waitFor(() => + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:late-orphan:", + expect.objectContaining({ displayStatus: "failed" }), + "session-late" + ) + ); + }); + + it.each(["pending", "failed"])( + "preserves %s rows with any durable owner and accepted provider rows", + async (deliveryStatus) => { + const owned = { + id: "queued-user:owned:", + sessionId: "session-owned", + source: "user", + displayStatus: deliveryStatus, + result: { + syntheticUserInput: true, + deliveryStatus, + queueMessageId: "owned", + }, + }; + const sent = { + ...owned, + id: "queued-user:sent:", + displayStatus: "completed", + result: { + ...owned.result, + deliveryStatus: "sent", + queueMessageId: "sent", + }, + }; + const providerOwned = { + ...owned, + id: "provider-user-row", + result: { message: { role: "user", content: "keep me" } }, + }; + mocks.getEvents.mockResolvedValue([owned, sent, providerOwned]); + mocks.findOwners.mockResolvedValue(new Set(["owned"])); + + await reconcileOrphanedOptimisticQueueProjections("session-owned"); + + expect(mocks.findOwners).toHaveBeenCalledWith(["owned"]); + expect(mocks.removeOptimistic).not.toHaveBeenCalled(); + expect(mocks.updateById).not.toHaveBeenCalled(); + } + ); + + it.each(["pending", "failed"])( + "preserves a %s row accepted while durable ownership is being checked", + async (deliveryStatus) => { + const pending = { + id: "queued-user:accepting:", + sessionId: "session-accepting", + source: "user", + displayStatus: deliveryStatus, + result: { + syntheticUserInput: true, + deliveryStatus, + queueMessageId: "accepting", + }, + }; + const sent = { + ...pending, + displayStatus: "completed", + result: { ...pending.result, deliveryStatus: "sent" }, + }; + mocks.getEvents + .mockResolvedValueOnce([pending]) + .mockResolvedValueOnce([sent]); + + await reconcileOrphanedOptimisticQueueProjections("session-accepting"); + + expect(mocks.findOwners).toHaveBeenCalledWith(["accepting"]); + expect(mocks.updateById).not.toHaveBeenCalled(); + } + ); + it("deduplicates by turn intent and lets live mutations win hydration races", async () => { const durable = message("durable", { turnIntentId: "shared-intent" }); const live = message("live", { @@ -69,7 +348,7 @@ describe("messageQueuePersistence", () => { }); const store = createStore(); store.set(messageQueueAtom, [live]); - mocks.load.mockResolvedValue([durable]); + mocks.load.mockResolvedValue({ queue: [durable], active: [] }); await hydrateMessageQueue(store); @@ -86,4 +365,301 @@ describe("messageQueuePersistence", () => { expect(mocks.persist).toHaveBeenCalledWith([next]); }); + + it("exposes the current durable queue write as a commit barrier", async () => { + const store = createStore(); + await hydrateMessageQueue(store); + let release!: () => void; + mocks.persist.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + + const next = message("barrier"); + store.set(messageQueueAtom, [next]); + let flushed = false; + const flush = flushMessageQueuePersistence(store).then(() => { + flushed = true; + }); + await Promise.resolve(); + expect(flushed).toBe(false); + expect(mocks.persist).toHaveBeenCalledWith([next]); + + release(); + await flush; + expect(flushed).toBe(true); + }); + + it("keeps a failed background write visible to the commit barrier", async () => { + const store = createStore(); + await hydrateMessageQueue(store); + mocks.persist.mockRejectedValueOnce(new Error("disk unavailable")); + const next = message("failed-write"); + store.set(messageQueueAtom, [next]); + + await expect(flushMessageQueuePersistence(store)).rejects.toThrow( + "disk unavailable" + ); + expect(store.get(messageQueueAtom)).toEqual([next]); + + const later = message("later-write"); + store.set(messageQueueAtom, [next, later]); + await expect(flushMessageQueuePersistence(store)).resolves.toBeUndefined(); + expect(mocks.persist).toHaveBeenLastCalledWith([next, later]); + }); + + it("retries a transient durable read and installs persistence after recovery", async () => { + const store = createStore(); + mocks.load + .mockRejectedValueOnce(new Error("store starting")) + .mockResolvedValueOnce({ queue: [], active: [] }); + + await expect(hydrateMessageQueue(store)).rejects.toThrow("store starting"); + expect(store.get(messageQueueHydratedAtom)).toBe(false); + + await hydrateMessageQueue(store); + mocks.persist.mockClear(); + const next = message("after-recovery"); + store.set(messageQueueAtom, [next]); + + expect(mocks.load).toHaveBeenCalledTimes(2); + expect(mocks.persist).toHaveBeenCalledWith([next]); + }); + + it("does not resurrect a deletion while a durable refresh is racing it", async () => { + const old = message("old"); + let durable: QueuedMessage[] = [old]; + let releasePersist!: () => void; + mocks.load.mockImplementation(async () => ({ queue: durable, active: [] })); + const store = createStore(); + await hydrateMessageQueue(store); + mocks.persist.mockImplementation( + (snapshot: QueuedMessage[]) => + new Promise((resolve) => { + releasePersist = () => { + durable = snapshot; + resolve(); + }; + }) + ); + + store.set(messageQueueAtom, []); + const refresh = refreshMessageDeliveries(store); + await Promise.resolve(); + expect(store.get(messageQueueAtom)).toEqual([]); + releasePersist(); + await refresh; + + expect(store.get(messageQueueAtom)).toEqual([]); + }); + + it("hydrates accepted recovery metadata into the same delivery registry", async () => { + const queued = message("shared"); + const active = activeDelivery("shared", { + runnerSessionId: "cliagent-runner", + }); + mocks.load.mockResolvedValue({ queue: [queued], active: [active] }); + const store = createStore(); + + await hydrateMessageQueue(store); + + expect(store.get(messageQueueAtom)).toEqual([]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([active]); + + const next = message("next"); + store.set(messageQueueAtom, [next]); + expect(store.get(messageQueueAtom)).toEqual([next]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([active]); + }); + + it("cancels the durable queued owner and its exact optimistic projection", async () => { + const queued = message("cancel", { sessionId: "native-session" }); + const sibling = message("keep"); + const active = activeDelivery("active"); + mocks.cancelQueued.mockResolvedValue([queued]); + const store = createStore(); + store.set(messageDeliveryRecordsAtom, [queued, sibling, active]); + + await cancelQueuedMessageDeliveries(store, [queued.id]); + + expect(mocks.cancelQueued).toHaveBeenCalledWith([ + { id: queued.id, turnIntentId: queued.turnIntentId }, + ]); + expect(mocks.removeOptimistic).toHaveBeenCalledWith({ + sessionId: "native-session", + queueMessageId: queued.id, + }); + expect(store.get(messageQueueAtom)).toEqual([sibling]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([active]); + expect(store.get(messageQueueHandoffIdsAtom)).toEqual(new Set()); + }); + + it("does not cancel a queue row while its ownership handoff is frozen", async () => { + const frozen = message("frozen"); + const cancellable = message("cancellable"); + mocks.cancelQueued.mockResolvedValue([cancellable]); + const store = createStore(); + store.set(messageQueueAtom, [frozen, cancellable]); + store.set(messageQueueHandoffIdsAtom, new Set([frozen.id])); + + await cancelQueuedMessageDeliveries(store, [frozen.id, cancellable.id]); + + expect(mocks.cancelQueued).toHaveBeenCalledWith([ + { id: cancellable.id, turnIntentId: cancellable.turnIntentId }, + ]); + expect(store.get(messageQueueAtom)).toEqual([frozen]); + expect(store.get(messageQueueHandoffIdsAtom)).toEqual(new Set([frozen.id])); + }); + + it("preserves a concurrent enqueue while cancellation is pending", async () => { + const cancelled = message("cancelled"); + const concurrent = message("concurrent"); + let release!: () => void; + mocks.cancelQueued.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve([cancelled]); + }) + ); + const store = createStore(); + store.set(messageQueueAtom, [cancelled]); + + const cancellation = cancelQueuedMessageDeliveries(store, [cancelled.id]); + await vi.waitFor(() => expect(mocks.cancelQueued).toHaveBeenCalledOnce()); + store.set(messageQueueAtom, (current) => [...current, concurrent]); + release(); + await cancellation; + + expect(store.get(messageQueueAtom)).toEqual([concurrent]); + }); + + it("restores only the owner whose optimistic projection could not be removed", async () => { + const cancelled = message("cancelled"); + const retained = message("retained"); + mocks.cancelQueued.mockResolvedValue([cancelled, retained]); + mocks.removeOptimistic.mockImplementation( + async ({ queueMessageId }: { queueMessageId: string }) => { + if (queueMessageId === retained.id) throw new Error("event store busy"); + } + ); + const store = createStore(); + store.set(messageQueueAtom, [cancelled, retained]); + + await expect( + cancelQueuedMessageDeliveries(store, [cancelled.id, retained.id]) + ).rejects.toThrow( + "failed to remove 1 optimistic queued message projection" + ); + + expect(mocks.persist).toHaveBeenCalledWith([retained]); + expect(store.get(messageQueueAtom)).toEqual([retained]); + expect(store.get(messageQueueHandoffIdsAtom)).toEqual(new Set()); + }); + + it("publishes the durable active snapshot after a queue handoff", async () => { + const queued = message("queued", { + conversationDispatch: activeDelivery("queued").conversationDispatch, + }); + const delivery = activeDelivery("queued", { + status: "preparing", + runnerSessionId: undefined, + }); + const peer = activeDelivery("peer"); + mocks.handoff.mockResolvedValue({ + delivery, + queue: [], + active: [peer, delivery], + }); + const store = createStore(); + store.set(messageQueueAtom, [queued]); + + await handoffQueuedMessageToActiveDelivery(store, delivery); + + expect(store.get(messageQueueAtom)).toEqual([]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([peer, delivery]); + }); + + it("preserves a concurrent enqueue while queue handoff is pending", async () => { + const queued = message("queued", { + conversationDispatch: activeDelivery("queued").conversationDispatch, + }); + const concurrent = message("concurrent"); + const delivery = activeDelivery("queued", { + status: "preparing", + runnerSessionId: undefined, + }); + let release!: () => void; + mocks.handoff.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({ delivery, queue: [], active: [delivery] }); + }) + ); + const store = createStore(); + store.set(messageQueueAtom, [queued]); + + const handoff = handoffQueuedMessageToActiveDelivery(store, delivery); + await vi.waitFor(() => expect(mocks.handoff).toHaveBeenCalledOnce()); + store.set(messageQueueAtom, (current) => [...current, concurrent]); + release(); + await handoff; + + expect(store.get(messageQueueAtom)).toEqual([concurrent]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([delivery]); + }); + + it("publishes the durable active snapshot when an owner returns to queue", async () => { + const returning = message("returning", { + conversationDispatch: activeDelivery("returning").conversationDispatch, + requiresExplicitDispatch: true, + }); + const staleLocalPeer = activeDelivery("stale-peer"); + const durablePeer = activeDelivery("durable-peer"); + mocks.returnToQueue.mockResolvedValue({ + message: returning, + active: [durablePeer], + }); + const store = createStore(); + store.set(messageDeliveryRecordsAtom, [ + staleLocalPeer, + activeDelivery("returning"), + ]); + + await returnActiveDeliveryToMessageQueue(store, "returning", returning); + + expect(store.get(messageQueueAtom)).toEqual([returning]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([durablePeer]); + }); + + it("preserves a concurrent enqueue while an owner return is pending", async () => { + const returning = message("returning", { + conversationDispatch: activeDelivery("returning").conversationDispatch, + requiresExplicitDispatch: true, + }); + const concurrent = message("concurrent"); + let release!: () => void; + mocks.returnToQueue.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve({ message: returning, active: [] }); + }) + ); + const store = createStore(); + store.set(messageDeliveryRecordsAtom, [activeDelivery("returning")]); + + const restoration = returnActiveDeliveryToMessageQueue( + store, + "returning", + returning + ); + await vi.waitFor(() => expect(mocks.returnToQueue).toHaveBeenCalledOnce()); + store.set(messageQueueAtom, [concurrent]); + release(); + await restoration; + + expect(store.get(messageQueueAtom)).toEqual([concurrent, returning]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]); + }); }); diff --git a/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts b/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts index 709378b925..d0ab6e44f8 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts @@ -3,31 +3,56 @@ import { Provider, createStore } from "jotai"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { turnLifecycleSignalAtom } from "@src/engines/SessionCore/control/turnLifecycle"; import { + QueuedConversationBlockedError, + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, + QueuedConversationTurnFailedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { UserIntentSendError } from "@src/engines/SessionCore/services/userIntentDispatch"; +import { + type ActiveMessageDelivery, type QueuedMessage, + activeMessageDeliveriesAtom, + isActiveMessageDelivery, + messageDeliveryRecordsAtom, messageQueueAtom, + messageQueueHydratedAtom, } from "@src/store/ui/messageQueueAtom"; import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; import { useQueueDispatch } from "../useQueueDispatch"; const SESSION_ID = "agent-builtin:sde-queued-worker"; +type JotaiStore = ReturnType; const mocks = vi.hoisted(() => ({ append: vi.fn(), beginOptimisticTurn: vi.fn(), beginTurnDispatch: vi.fn(), + beginTurnStopping: vi.fn(), cancelTurn: vi.fn(), + canonicalUpdateFailure: false, + clearTurnLifecycleSession: vi.fn(), + dispatchCanonicalConversation: vi.fn(), confirmTurnRunning: vi.fn(), failOptimisticTurn: vi.fn(), getSession: vi.fn(), + getPersistedEvents: vi.fn(), + getTurnGeneration: vi.fn(), getTurnPhase: vi.fn(), markSessionActive: vi.fn(), markTurnTerminal: vi.fn(), messageError: vi.fn(), messageWarning: vi.fn(), - removeByIdPrefix: vi.fn(), + loadDurableMessageDeliveries: vi.fn(), + persistDurableMessageQueue: vi.fn(), + restoreTurnWorkingAfterInterruptFailure: vi.fn(), sendMessage: vi.fn(), + updateById: vi.fn(), + upsert: vi.fn(), })); vi.mock("@src/api/tauri/agent", () => ({ @@ -54,9 +79,14 @@ vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { const { atom } = await import("jotai/vanilla"); return { beginTurnDispatch: mocks.beginTurnDispatch, + beginTurnStopping: mocks.beginTurnStopping, + clearTurnLifecycleSession: mocks.clearTurnLifecycleSession, confirmTurnRunning: mocks.confirmTurnRunning, + getTurnGeneration: mocks.getTurnGeneration, getTurnPhase: mocks.getTurnPhase, markTurnTerminal: mocks.markTurnTerminal, + restoreTurnWorkingAfterInterruptFailure: + mocks.restoreTurnWorkingAfterInterruptFailure, turnLifecycleSignalAtom: atom(0), }; }); @@ -64,16 +94,156 @@ vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { append: mocks.append, - removeByIdPrefix: mocks.removeByIdPrefix, + getPersistedEvents: mocks.getPersistedEvents, + updateById: mocks.updateById, + upsert: mocks.upsert, }, })); +vi.mock( + "@src/engines/SessionCore/hooks/session/messageQueuePersistence", + () => { + let currentStore: JotaiStore | null = null; + return { + hydrateMessageQueue: async (store: JotaiStore) => { + currentStore = store; + const snapshot = await mocks.loadDurableMessageDeliveries(); + const activeIntents = new Set( + snapshot.active.map( + (delivery: ActiveMessageDelivery) => delivery.turnIntentId + ) + ); + const queueByIntent = new Map(); + for (const message of snapshot.queue as QueuedMessage[]) { + queueByIntent.set(message.turnIntentId, message); + } + for (const message of store.get(messageQueueAtom)) { + queueByIntent.set(message.turnIntentId, message); + } + store.set(messageDeliveryRecordsAtom, [ + ...[...queueByIntent.values()].filter( + (message) => !activeIntents.has(message.turnIntentId) + ), + ...snapshot.active, + ]); + await mocks.persistDurableMessageQueue(store.get(messageQueueAtom)); + store.set(messageQueueHydratedAtom, true); + }, + disposeMessageQueuePersistence: () => undefined, + refreshMessageDeliveries: async () => undefined, + assertDurableActiveDeliveryIsRootHead: async (id: string) => { + const owner = currentStore + ?.get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === id); + if (!owner) throw new Error("missing active delivery owner"); + return owner; + }, + handoffQueuedMessageToActiveDelivery: async ( + targetStore: JotaiStore, + delivery: ActiveMessageDelivery + ) => { + currentStore = targetStore; + targetStore.set(messageDeliveryRecordsAtom, (current) => [ + ...current.filter( + (record) => + record.id !== delivery.id && + record.turnIntentId !== delivery.turnIntentId + ), + delivery, + ]); + }, + returnActiveDeliveryToMessageQueue: async ( + targetStore: JotaiStore, + id: string, + message: QueuedMessage + ) => { + targetStore.set(messageDeliveryRecordsAtom, (current) => [ + ...current.filter( + (record) => record.id !== id && record.id !== message.id + ), + message, + ]); + }, + updateActiveMessageDelivery: async ( + targetStore: JotaiStore, + id: string, + update: Partial + ) => { + if (mocks.canonicalUpdateFailure) { + throw new Error("durable execution store unavailable"); + } + let updated: ActiveMessageDelivery | null = null; + targetStore.set(messageDeliveryRecordsAtom, (current) => + current.map((record) => { + if (!isActiveMessageDelivery(record) || record.id !== id) { + return record; + } + updated = { ...record, ...update }; + return updated; + }) + ); + return updated; + }, + removeActiveMessageDelivery: async ( + targetStore: JotaiStore, + id: string + ) => { + targetStore.set(messageDeliveryRecordsAtom, (current) => + current.filter((record) => record.id !== id) + ); + }, + replaceActiveMessageDeliveryLocally: ( + targetStore: JotaiStore, + id: string, + update: Partial + ) => { + targetStore.set(messageDeliveryRecordsAtom, (current) => + current.map((record) => + isActiveMessageDelivery(record) && record.id === id + ? { ...record, ...update } + : record + ) + ); + }, + }; + } +); + vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ SessionService: { sendMessage: mocks.sendMessage }, })); -vi.mock("@src/engines/SessionCore/sync/adapters/shared", () => ({ - createSyntheticUserEvent: () => ({ id: "synthetic-user-event" }), +vi.mock("@src/engines/SessionCore/sync/adapters/shared/eventFactories", () => ({ + createSyntheticUserEvent: ( + sessionId: string, + content: string, + options?: Record + ) => ({ + id: options?.id ?? "synthetic-user-event", + chunk_id: null, + sessionId, + createdAt: "2026-07-18T00:00:00.000Z", + functionName: "user_message", + uiCanonical: "", + actionType: "raw", + source: "user", + args: {}, + result: { + syntheticUserInput: true, + message: { content, role: "user" }, + ...(options ?? {}), + }, + displayText: content, + displayStatus: + options?.deliveryStatus === "failed" + ? "failed" + : options?.deliveryStatus === "pending" + ? "pending" + : "completed", + displayVariant: "message", + activityStatus: "agent", + isDelta: false, + }), })); vi.mock("@src/hooks/logger", () => ({ @@ -89,6 +259,16 @@ vi.mock("@src/store/session", () => ({ markSessionActive: mocks.markSessionActive, })); +vi.mock("@src/store/ui/messageQueueRepository", () => ({ + getMessageQueueOwnerKey: async () => "queue:main", + isPrimaryMessageQueueOwnerKey: (key: string) => key === "queue:main", + persistDurableMessageQueue: mocks.persistDurableMessageQueue, + withCanonicalConversationTurnLock: async ( + _root: unknown, + run: () => Promise + ) => await run(), +})); + vi.mock("@src/util/platform/tauri/init", () => ({ invokeTauri: vi.fn(), })); @@ -128,8 +308,71 @@ function makeQueuedMessage(): QueuedMessage { }; } +function makeCanonicalMessage( + id: string, + conversationId = "root-1" +): QueuedMessage { + return { + ...makeQueuedMessage(), + id, + turnIntentId: `turn-intent-${id}`, + priority: "next", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId, + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + }; +} + +function installLifecycleSimulation(): void { + const phases = new Map(); + const generations = new Map(); + mocks.beginTurnDispatch.mockImplementation((scopeKey: string) => { + const generation = (generations.get(scopeKey) ?? 0) + 1; + generations.set(scopeKey, generation); + phases.set(scopeKey, "dispatching"); + return generation; + }); + mocks.beginTurnStopping.mockImplementation((scopeKey: string) => { + phases.set(scopeKey, "stopping"); + }); + mocks.clearTurnLifecycleSession.mockImplementation((scopeKey: string) => { + phases.delete(scopeKey); + generations.delete(scopeKey); + }); + mocks.confirmTurnRunning.mockImplementation((scopeKey: string) => { + phases.set(scopeKey, "working"); + }); + mocks.getTurnGeneration.mockImplementation( + (scopeKey: string) => generations.get(scopeKey) ?? 0 + ); + mocks.getTurnPhase.mockImplementation( + (scopeKey: string) => phases.get(scopeKey) ?? "idle" + ); + mocks.markTurnTerminal.mockImplementation((scopeKey: string) => { + phases.set(scopeKey, "idle"); + }); + mocks.restoreTurnWorkingAfterInterruptFailure.mockImplementation( + (scopeKey: string) => { + if (phases.get(scopeKey) === "stopping") { + phases.set(scopeKey, "working"); + } + } + ); +} + function QueueDispatchHarness(): null { - useQueueDispatch(); + useQueueDispatch(mocks.dispatchCanonicalConversation); return null; } @@ -138,21 +381,40 @@ describe("useQueueDispatch Agent Org intervention", () => { let store: ReturnType; beforeEach(() => { + mocks.canonicalUpdateFailure = false; mocks.append.mockReset().mockResolvedValue(undefined); mocks.beginOptimisticTurn.mockReset(); mocks.beginTurnDispatch.mockReset().mockReturnValue(11); + mocks.beginTurnStopping.mockReset(); mocks.cancelTurn.mockReset().mockResolvedValue(undefined); + mocks.clearTurnLifecycleSession.mockReset(); + mocks.dispatchCanonicalConversation + .mockReset() + .mockImplementation(async (_store, message, callbacks) => { + await callbacks.onAccepted(message.sessionId); + return { terminalStatus: "completed" }; + }); mocks.confirmTurnRunning.mockReset(); mocks.failOptimisticTurn.mockReset(); mocks.getSession.mockReset().mockResolvedValue(null); + mocks.getPersistedEvents.mockReset().mockResolvedValue([]); + mocks.getTurnGeneration.mockReset().mockReturnValue(11); mocks.getTurnPhase.mockReset().mockReturnValue("idle"); mocks.markSessionActive.mockReset(); mocks.markTurnTerminal.mockReset(); mocks.messageError.mockReset(); mocks.messageWarning.mockReset(); - mocks.removeByIdPrefix.mockReset().mockResolvedValue(1); + mocks.loadDurableMessageDeliveries + .mockReset() + .mockResolvedValue({ queue: [], active: [] }); + mocks.persistDurableMessageQueue.mockReset().mockResolvedValue(undefined); + mocks.restoreTurnWorkingAfterInterruptFailure.mockReset(); mocks.sendMessage.mockReset().mockResolvedValue(undefined); + mocks.updateById.mockReset().mockResolvedValue(true); + mocks.upsert.mockReset().mockResolvedValue(undefined); + installLifecycleSimulation(); store = createStore(); + store.set(messageDeliveryRecordsAtom, []); root = createSmokeRoot(); }); @@ -189,7 +451,66 @@ describe("useQueueDispatch Agent Org intervention", () => { expect(mocks.append.mock.invocationCallOrder[0]).toBeLessThan( mocks.sendMessage.mock.invocationCallOrder[0] ); - expect(store.get(messageQueueAtom)).toEqual([]); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + }); + + it("keeps ordinary queue delivery closed when durable recovery is unavailable", async () => { + const timeout = vi + .spyOn(window, "setTimeout") + .mockImplementation(() => 1 as never); + mocks.loadDurableMessageDeliveries.mockRejectedValueOnce( + new Error("delivery store unavailable") + ); + + await mountWithQueuedMessage(); + + await vi.waitFor(() => + expect(mocks.loadDurableMessageDeliveries).toHaveBeenCalled() + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(store.get(messageQueueAtom)).toEqual([makeQueuedMessage()]); + timeout.mockRestore(); + }); + + it("keeps queued delivery closed when the durable snapshot cannot be read", async () => { + const timeout = vi + .spyOn(window, "setTimeout") + .mockImplementation(() => 1 as never); + mocks.loadDurableMessageDeliveries.mockRejectedValue( + new Error("delivery store unavailable") + ); + + await mountWithQueuedMessage(); + await vi.waitFor(() => + expect(mocks.loadDurableMessageDeliveries).toHaveBeenCalled() + ); + + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(store.get(messageQueueAtom)).toEqual([makeQueuedMessage()]); + timeout.mockRestore(); + }); + + it("retries canonical hydration after a transient startup failure", async () => { + let retry: (() => void) | undefined; + const timeout = vi + .spyOn(window, "setTimeout") + .mockImplementation((handler: TimerHandler) => { + retry = handler as () => void; + return 1 as never; + }); + mocks.loadDurableMessageDeliveries + .mockRejectedValueOnce(new Error("store warming up")) + .mockResolvedValueOnce({ queue: [], active: [] }); + + await mountWithMessages([makeCanonicalMessage("canonical-cold-store")]); + await vi.waitFor(() => expect(retry).toBeTypeOf("function")); + expect(mocks.dispatchCanonicalConversation).not.toHaveBeenCalled(); + + retry?.(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce() + ); + timeout.mockRestore(); }); it("does not let a blocked Send Now freeze another idle session", async () => { @@ -203,8 +524,9 @@ describe("useQueueDispatch Agent Org intervention", () => { displayContent: "independent follow-up", priority: "next", }; + const currentPhase = mocks.getTurnPhase.getMockImplementation()!; mocks.getTurnPhase.mockImplementation((sessionId: string) => - sessionId === SESSION_ID ? "working" : "idle" + sessionId === SESSION_ID ? "working" : currentPhase(sessionId) ); await mountWithMessages([blocked, ready]); @@ -214,29 +536,936 @@ describe("useQueueDispatch Agent Org intervention", () => { expect.objectContaining({ sessionId: ready.sessionId }) ) ); - expect(mocks.cancelTurn).toHaveBeenCalledWith(SESSION_ID, "force-send"); - expect(store.get(messageQueueAtom)).toEqual([ - expect.objectContaining({ id: blocked.id }), - ]); + expect(mocks.cancelTurn).toHaveBeenCalledWith( + SESSION_ID, + "force-send", + expect.objectContaining({ onError: expect.any(Function) }) + ); + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ id: blocked.id }), + ]) + ); }); - it("removes the optimistic queued event when backend dispatch fails", async () => { + it("transfers a send-stage failure from the queue card to one failed bubble", async () => { mocks.sendMessage.mockRejectedValue(new Error("backend send unavailable")); await mountWithQueuedMessage(); await vi.waitFor(() => - expect(mocks.removeByIdPrefix).toHaveBeenCalledWith( + expect(mocks.updateById).toHaveBeenCalledWith( "synthetic-user-event", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "backend send unavailable", + }), + }), + SESSION_ID + ) + ); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "queued-intervention-1", + requiresExplicitDispatch: true, + deliveryError: "backend send unavailable", + }), + ]) + ); + }); + + it("releases the dispatch lock when failure notification throws", async () => { + mocks.sendMessage.mockRejectedValueOnce(new Error("backend unavailable")); + mocks.messageError.mockImplementationOnce(() => { + throw new Error("notification unavailable"); + }); + const first = makeQueuedMessage(); + const next = { ...first, id: "next-message", turnIntentId: "next-intent" }; + await mountWithMessages([first, next]); + + await vi.waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: first.id, + deliveryError: "backend unavailable", + requiresExplicitDispatch: true, + }), + ]) + ); + }); + + it("retains the queue card when the optimistic row could not be stored", async () => { + mocks.append.mockRejectedValue(new Error("event store unavailable")); + + await mountWithQueuedMessage(); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "queued-intervention-1", + requiresExplicitDispatch: true, + }), + ]) + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); + + it("reconciles a canonical optimistic row in place after provider acceptance", async () => { + installLifecycleSimulation(); + const canonical = makeCanonicalMessage("canonical-user-event"); + + await mountWithMessages([canonical]); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce() + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(mocks.append).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-user-event:", + expect.objectContaining({ + displayStatus: "completed", + result: expect.objectContaining({ + deliveryStatus: "sent", + queueMessageId: "canonical-user-event", + turnIntentId: "turn-intent-canonical-user-event", + }), + }), + SESSION_ID + ); + }); + + it("waits for the concrete source turn before handing off a canonical row", async () => { + let sourcePhase = "working"; + mocks.getTurnPhase.mockImplementation((sessionId: string) => + sessionId === SESSION_ID ? sourcePhase : "idle" + ); + const canonical = makeCanonicalMessage("canonical-behind-source-turn"); + + await mountWithMessages([canonical]); + await vi.waitFor(() => + expect(mocks.persistDurableMessageQueue).toHaveBeenCalled() + ); + expect(mocks.dispatchCanonicalConversation).not.toHaveBeenCalled(); + expect(store.get(messageQueueAtom)).toEqual([canonical]); + + sourcePhase = "idle"; + store.set(turnLifecycleSignalAtom, (value) => value + 1); + + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce() + ); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + }); + + it("interrupts the concrete source for canonical Send Now before handoff", async () => { + mocks.getTurnPhase.mockImplementation((sessionId: string) => + sessionId === SESSION_ID ? "working" : "idle" + ); + const canonical = { + ...makeCanonicalMessage("canonical-source-send-now"), + priority: "now" as const, + }; + + await mountWithMessages([canonical]); + + await vi.waitFor(() => + expect(mocks.cancelTurn).toHaveBeenCalledWith( + SESSION_ID, + "force-send", + expect.objectContaining({ onError: expect.any(Function) }) + ) + ); + expect(mocks.dispatchCanonicalConversation).not.toHaveBeenCalled(); + }); + + it("does not bypass a held natural FIFO head in the same scope", async () => { + const held = { + ...makeQueuedMessage(), + id: "held-head", + turnIntentId: "intent-held-head", + priority: "next" as const, + requiresExplicitDispatch: true, + }; + const blockedSibling = { + ...makeQueuedMessage(), + id: "blocked-sibling", + turnIntentId: "intent-blocked-sibling", + priority: "next" as const, + }; + const independent = { + ...makeQueuedMessage(), + id: "independent-head", + turnIntentId: "intent-independent-head", + sessionId: "agent-builtin:sde-independent", + priority: "next" as const, + }; + + await mountWithMessages([held, blockedSibling, independent]); + + await vi.waitFor(() => + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: independent.sessionId }) + ) + ); + expect(mocks.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ turnIntentId: blockedSibling.turnIntentId }) + ); + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([held, blockedSibling]) + ); + }); + + it("lets a later natural message bypass a failed retry row in the same scope", async () => { + const failed = { + ...makeCanonicalMessage("failed-head"), + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + }; + const next = { + ...makeCanonicalMessage("natural-after-failure"), + content: "continue after the failed turn", + displayContent: "continue after the failed turn", + }; + + await mountWithMessages([failed, next]); + + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce() + ); + expect( + mocks.dispatchCanonicalConversation.mock.calls[0]?.[1] + ).toMatchObject({ id: next.id, turnIntentId: next.turnIntentId }); + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([failed]) + ); + }); + + it("recovers an accepted canonical execution before deleting its pending queue twin", async () => { + installLifecycleSimulation(); + let finishPersistence!: () => void; + const persistenceGate = new Promise((resolve) => { + finishPersistence = resolve; + }); + let finishRecovery!: () => void; + const recoveryTerminal = new Promise((resolve) => { + finishRecovery = resolve; + }); + const queuedTwin = makeCanonicalMessage("canonical-cold-start"); + const recovered: ActiveMessageDelivery = { + ...queuedTwin, + conversationDispatch: queuedTwin.conversationDispatch!, + status: "accepted", + runnerSessionId: "runner-cold-start", + }; + mocks.loadDurableMessageDeliveries.mockResolvedValueOnce({ + queue: [queuedTwin], + active: [recovered], + }); + mocks.persistDurableMessageQueue.mockReturnValue(persistenceGate); + mocks.dispatchCanonicalConversation.mockImplementationOnce(async () => { + await recoveryTerminal; + return { terminalStatus: "completed" }; + }); + + await mountWithMessages([]); + + await vi.waitFor(() => + expect(mocks.persistDurableMessageQueue).toHaveBeenCalledWith([]) + ); + expect(mocks.dispatchCanonicalConversation).not.toHaveBeenCalled(); + finishPersistence(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce() + ); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + expect( + mocks.dispatchCanonicalConversation.mock.calls[0]?.[1] + ).toMatchObject({ + id: queuedTwin.id, + status: "accepted", + runnerSessionId: "runner-cold-start", + }); + finishRecovery(); + }); + + it("does not manufacture a virtual-root Session terminal", async () => { + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + await callbacks.onAccepted(message.sessionId); + return { terminalStatus: "cancelled" }; + } + ); + + await mountWithMessages([makeCanonicalMessage("canonical-cancelled")]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]) + ); + expect(mocks.markTurnTerminal).not.toHaveBeenCalled(); + }); + + it("transfers a prepared canonical failure from the queue card to its failed bubble", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new UserIntentSendError("native launch failed", "native-user-event") + ); + + await mountWithMessages([makeCanonicalMessage("canonical-failed")]); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-failed", + requiresExplicitDispatch: true, + deliveryError: "native launch failed", + }), + ]) + ); + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-failed:", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "native launch failed", + }), + }), + SESSION_ID + ); + expect(mocks.messageError).not.toHaveBeenCalled(); + }); + + it("holds an accepted turn that the provider failed outright as a failed row", async () => { + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + await callbacks.onAccepted(`runner-${message.id}`); + throw new QueuedConversationTurnFailedError( + "The requested model is not available" + ); + } + ); + + await mountWithMessages([makeCanonicalMessage("canonical-rejected")]); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-rejected", + requiresExplicitDispatch: true, + deliveryError: "The requested model is not available", + }), + ]) + ); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]); + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-rejected:", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "The requested model is not available", + }), + }), + SESSION_ID + ); + expect(mocks.messageError).toHaveBeenCalledOnce(); + }); + + it("retains an accepted canonical owner for recovery without immediately resending", async () => { + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + await callbacks.onAccepted(`runner-${message.id}`); + throw new UserIntentSendError( + "native send failed", + "native-user-event" + ); + } + ); + + await mountWithMessages([ + makeCanonicalMessage("canonical-accepted-failed"), + ]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-accepted-failed", + status: "accepted", + runnerSessionId: "runner-canonical-accepted-failed", + retryAttempt: 1, + retryAt: expect.any(String), + }), + ]) + ); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + expect(mocks.getPersistedEvents).not.toHaveBeenCalled(); + expect(mocks.messageError).not.toHaveBeenCalled(); + }); + + it("keeps an admission-blocked execution as the same failed transcript row", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new QueuedConversationBlockedError("switch Cloud account") + ); + + await mountWithMessages([makeCanonicalMessage("canonical-blocked")]); + await vi.waitFor(() => + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-blocked:", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "switch Cloud account", + }), + }), SESSION_ID ) ); + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-blocked", + requiresExplicitDispatch: true, + deliveryError: "switch Cloud account", + }), + ]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + }); + it("keeps a pre-acceptance recovery failure as the same retryable transcript row", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new QueuedConversationRecoveryBlockedError( + "turn intent became stale before provider execution" + ) + ); + + await mountWithMessages([makeCanonicalMessage("canonical-stale-recovery")]); + + await vi.waitFor(() => + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-stale-recovery:", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "turn intent became stale before provider execution", + }), + }), + SESSION_ID + ) + ); expect(store.get(messageQueueAtom)).toEqual([ expect.objectContaining({ - id: "queued-intervention-1", + id: "canonical-stale-recovery", requiresExplicitDispatch: true, + deliveryError: "turn intent became stale before provider execution", + }), + ]); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]); + }); + + it("retires an accepted recovery mismatch without creating a fresh provider turn", async () => { + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + await callbacks.onAccepted(`runner-${message.id}`); + throw new QueuedConversationRecoveryBlockedError( + "accepted runner diverged from canonical transcript" + ); + } + ); + + await mountWithMessages([ + makeCanonicalMessage("canonical-accepted-mismatch"), + ]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]) + ); + expect(store.get(messageQueueAtom)).toEqual([]); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + expect(mocks.updateById).toHaveBeenCalledWith( + "queued-user:canonical-accepted-mismatch:", + expect.objectContaining({ + displayStatus: "completed", + result: expect.objectContaining({ deliveryStatus: "sent" }), + }), + SESSION_ID + ); + }); + + it.each(["recovery-blocked", "turn-closed"])( + "restores a reconciled-away failed row before retiring its %s owner", + async (verdict) => { + mocks.updateById.mockImplementation(async (_id, patch) => + patch.result?.deliveryOwnerRetired ? false : true + ); + let finishProjection!: () => void; + mocks.upsert.mockImplementation( + () => + new Promise((resolve) => { + finishProjection = resolve; + }) + ); + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + if (verdict === "turn-closed") { + throw new QueuedConversationTurnClosedError("terminal verdict"); + } + await callbacks.onAccepted(`runner-${message.id}`); + throw new QueuedConversationRecoveryBlockedError("terminal verdict"); + } + ); + const message = makeCanonicalMessage("canonical-retirement-upsert"); + await mountWithMessages([message]); + await vi.waitFor(() => expect(mocks.upsert).toHaveBeenCalledOnce()); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ id: message.id }), + ]); + expect(mocks.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: `queued-user:${message.id}:`, + result: expect.objectContaining({ + turnIntentId: message.turnIntentId, + deliveryStatus: "failed", + deliveryOwnerRetired: true, + }), + }), + SESSION_ID + ); + finishProjection(); + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]) + ); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + expect(store.get(messageQueueAtom)).toEqual([]); + } + ); + + it.each(["missing row", "persistence rejection"])( + "retains accepted ownership until retirement is projected after %s", + async (failure) => { + let projectionAvailable = false; + mocks.upsert.mockImplementation(async () => { + if (!projectionAvailable) + throw new Error("projection repair unavailable"); + }); + mocks.updateById.mockImplementation(async (_id, patch) => { + if (patch.result?.deliveryOwnerRetired && !projectionAvailable) { + if (failure === "persistence rejection") { + throw new Error("EventStore unavailable"); + } + return false; + } + return true; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + if (message.status !== "accepted") { + await callbacks.onAccepted(`runner-${message.id}`); + } + throw new QueuedConversationRecoveryBlockedError( + "accepted runner diverged from canonical transcript" + ); + } + ); + const message = { + ...makeCanonicalMessage("canonical-retirement-recovery"), + displayContent: "@VantaNode keep this message", + imageDataUrls: ["data:image/png;base64,preserved"], + }; + await mountWithMessages([message]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: message.id, + turnIntentId: message.turnIntentId, + status: "accepted", + retryAttempt: 1, + displayContent: message.displayContent, + imageDataUrls: message.imageDataUrls, + }), + ]) + ); + expect(store.get(messageQueueAtom)).toEqual([]); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + + // The same existing recovery owner wakes after projection is available; + // it must not mint or resend a different provider intent. + projectionAvailable = true; + store.set(messageDeliveryRecordsAtom, (records) => + records.map((record) => ({ ...record, retryAt: undefined })) + ); + store.set(turnLifecycleSignalAtom, (value) => value + 1); + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]) + ); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2); + expect(mocks.dispatchCanonicalConversation.mock.calls[1][1]).toEqual( + expect.objectContaining({ + id: message.id, + turnIntentId: message.turnIntentId, + status: "accepted", + }) + ); + expect(mocks.updateById).toHaveBeenLastCalledWith( + `queued-user:${message.id}:`, + expect.objectContaining({ + displayText: message.displayContent, + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryOwnerRetired: true, + }), + }), + SESSION_ID + ); + } + ); + + it("never returns an accepted execution when a late identity check blocks", async () => { + mocks.dispatchCanonicalConversation.mockImplementationOnce( + async (_store, message, callbacks) => { + await callbacks.onAccepted(`runner-${message.id}`); + throw new QueuedConversationBlockedError("account changed late"); + } + ); + + await mountWithMessages([makeCanonicalMessage("canonical-late-blocked")]); + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-late-blocked", + status: "accepted", + retryAttempt: 1, + }), + ]) + ); + expect(store.get(messageQueueAtom)).toEqual([]); + }); + + it("retains a canonical queue card when no optimistic row was stored", async () => { + mocks.updateById.mockResolvedValueOnce(false); + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new Error("native preparation failed") + ); + const message = makeCanonicalMessage("canonical-unprepared"); + + await mountWithMessages([message]); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: message.id, + requiresExplicitDispatch: true, + }), + ]) + ); + }); + + it("retains a canonical queue card when the failed projection cannot be persisted", async () => { + mocks.updateById.mockRejectedValueOnce( + new Error("failed delivery persistence unavailable") + ); + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new UserIntentSendError("native launch failed", "native-user-event") + ); + const message = makeCanonicalMessage("canonical-failed-persistence"); + + await mountWithMessages([message]); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: message.id, + requiresExplicitDispatch: true, + deliveryError: "native launch failed", + }), + ]) + ); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + + store.set(turnLifecycleSignalAtom, (value) => value + 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + }); + + it("retains a preparing execution when canonical result publication is pending", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new QueuedConversationRecoveryPendingError("cloud offline") + ); + const message = makeCanonicalMessage("canonical-result-pending"); + + await mountWithMessages([message]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: message.id, + status: "preparing", + retryAttempt: 1, + }), + ]) + ); + expect(store.get(messageQueueAtom)).toEqual([]); + }); + + it("backs off locally when recovery metadata cannot be persisted", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new QueuedConversationRecoveryPendingError("cloud offline") + ); + mocks.canonicalUpdateFailure = true; + const message = makeCanonicalMessage("canonical-store-offline"); + + await mountWithMessages([message]); + + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: message.id, + retryAt: expect.any(String), + }), + ]) + ); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + }); + + it("serializes two canonical turns for one root through the execution owner", async () => { + installLifecycleSimulation(); + let releaseFirst!: () => void; + const firstTerminal = new Promise((resolve) => { + releaseFirst = resolve; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + const runnerId = `runner-${message.id}`; + await callbacks.onRunnerReady?.(runnerId, 0); + await callbacks.onAccepted(runnerId); + if (message.id === "canonical-first") await firstTerminal; + return { terminalStatus: "completed" }; + } + ); + + await mountWithMessages([ + makeCanonicalMessage("canonical-first"), + makeCanonicalMessage("canonical-second"), + ]); + + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1) + ); + expect(mocks.dispatchCanonicalConversation.mock.calls[0]?.[1].id).toBe( + "canonical-first" + ); + expect(store.get(activeMessageDeliveriesAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-first", + status: "accepted", + runnerSessionId: "runner-canonical-first", }), ]); + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ id: "canonical-second", status: "queued" }), + ]); + + releaseFirst(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + expect(mocks.dispatchCanonicalConversation.mock.calls[1]?.[1].id).toBe( + "canonical-second" + ); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + }); + + it("drains a canonical follow-up enqueued while the first turn is active", async () => { + installLifecycleSimulation(); + let releaseFirst!: () => void; + const firstTerminal = new Promise((resolve) => { + releaseFirst = resolve; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + const runnerId = `runner-${message.id}`; + await callbacks.onRunnerReady?.(runnerId, 0); + await callbacks.onAccepted(runnerId); + if (message.id === "canonical-active") await firstTerminal; + return { terminalStatus: "completed" }; + } + ); + + await mountWithMessages([makeCanonicalMessage("canonical-active")]); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1) + ); + + const followUp = makeCanonicalMessage("canonical-during-active"); + store.set(messageQueueAtom, (current) => [...current, followUp]); + expect(store.get(messageQueueAtom)).toContainEqual(followUp); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1); + + releaseFirst(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + expect(mocks.dispatchCanonicalConversation.mock.calls[1]?.[1].id).toBe( + "canonical-during-active" + ); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + }); + + it("runs independent canonical roots concurrently", async () => { + installLifecycleSimulation(); + let acceptFirst!: () => void; + const firstAcceptance = new Promise((resolve) => { + acceptFirst = resolve; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + const runnerId = `runner-${message.id}`; + await callbacks.onRunnerReady?.(runnerId, 0); + if (message.id === "root-a") await firstAcceptance; + await callbacks.onAccepted(runnerId); + return { terminalStatus: "completed" }; + } + ); + + await mountWithMessages([ + makeCanonicalMessage("root-a", "conversation-a"), + makeCanonicalMessage("root-b", "conversation-b"), + ]); + + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + expect(store.get(messageQueueAtom)).toEqual([]); + acceptFirst(); + await vi.waitFor(() => + expect(store.get(activeMessageDeliveriesAtom)).toEqual([]) + ); + }); + + it("routes canonical Send Now through the active native runner", async () => { + installLifecycleSimulation(); + let releaseFirst!: () => void; + const firstTerminal = new Promise((resolve) => { + releaseFirst = resolve; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + const runnerId = `runner-${message.id}`; + mocks.beginTurnDispatch(runnerId); + mocks.confirmTurnRunning(runnerId); + await callbacks.onRunnerReady?.(runnerId, 0); + await callbacks.onAccepted(runnerId); + if (message.id === "canonical-running") await firstTerminal; + mocks.markTurnTerminal(runnerId); + return { terminalStatus: "completed" }; + } + ); + const forceSend = { + ...makeCanonicalMessage("canonical-force-send"), + priority: "now" as const, + }; + + await mountWithMessages([makeCanonicalMessage("canonical-running")]); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1) + ); + store.set(messageQueueAtom, (current) => [...current, forceSend]); + + await vi.waitFor(() => + expect(mocks.cancelTurn).toHaveBeenCalledWith( + "runner-canonical-running", + "force-send", + expect.objectContaining({ onError: expect.any(Function) }) + ) + ); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1); + releaseFirst(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + }); + + it("keeps a terminal canonical execution as a barrier without cancelling it", async () => { + installLifecycleSimulation(); + let releaseFirst!: () => void; + const firstSettlement = new Promise((resolve) => { + releaseFirst = resolve; + }); + mocks.dispatchCanonicalConversation.mockImplementation( + async (_store, message, callbacks) => { + const runnerId = `runner-${message.id}`; + await callbacks.onRunnerReady?.(runnerId, 0); + await callbacks.onAccepted(runnerId); + if (message.id === "canonical-terminal-owner") { + await firstSettlement; + } + return { terminalStatus: "completed" }; + } + ); + const forceSend = { + ...makeCanonicalMessage("canonical-after-terminal-owner"), + priority: "now" as const, + }; + + await mountWithMessages([makeCanonicalMessage("canonical-terminal-owner")]); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1) + ); + store.set(messageQueueAtom, (current) => [...current, forceSend]); + + await vi.waitFor(() => + expect(mocks.getTurnPhase).toHaveBeenCalledWith( + "runner-canonical-terminal-owner" + ) + ); + expect(mocks.cancelTurn).not.toHaveBeenCalled(); + expect(store.get(messageQueueAtom)).toContainEqual(forceSend); + + releaseFirst(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + }); + + it("holds Send Now visibly when the interrupt transport rejects", async () => { + mocks.getTurnPhase.mockImplementation((sessionId: string) => + sessionId === SESSION_ID ? "working" : "idle" + ); + mocks.getTurnGeneration.mockReturnValue(7); + mocks.cancelTurn.mockImplementation( + async (_sessionId, _reason, options) => { + options?.onError?.("interrupt transport unavailable"); + } + ); + + await mountWithQueuedMessage(); + + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "queued-intervention-1", + priority: "next", + requiresExplicitDispatch: true, + }), + ]) + ); + expect(mocks.restoreTurnWorkingAfterInterruptFailure).toHaveBeenCalledWith( + SESSION_ID, + { generation: 7 } + ); + expect(mocks.messageError).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("interrupt transport unavailable"), + }) + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); }); }); diff --git a/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts b/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts index 470a2e9a4c..592d03bc14 100644 --- a/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts +++ b/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts @@ -1,38 +1,147 @@ import type { Store } from "jotai/vanilla/store"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import { + type Snapshot, + eventStoreProxy, + isStreamingSnapshot, +} from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + isOptimisticQueueUserEventId, + optimisticQueueUserEventId, + removeOptimisticQueueUserDelivery, +} from "@src/engines/SessionCore/services/userIntentDispatch"; +import { createLogger } from "@src/hooks/logger"; +import { + type ActiveMessageDelivery, + type MessageDeliveryRecord, type QueuedMessage, boundQueuedMessages, + isActiveMessageDelivery, + isQueuedMessageDelivery, + messageDeliveryRecordsAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, messageQueueHydratedAtom, } from "@src/store/ui/messageQueueAtom"; import { - loadDurableMessageQueue, + type ActiveMessageDeliveryUpdate, + assertDurableActiveDeliveryIsRootHead, + findDurableMessageDeliveryOwnerIds, + handoffDurableMessageDelivery, + loadDurableMessageDeliveries, persistDurableMessageQueue, + removeDurableActiveMessageDelivery, + removeDurableQueuedMessageDeliveries, + returnDurableMessageDeliveryToQueue, + updateDurableActiveMessageDelivery, } from "@src/store/ui/messageQueueRepository"; +const log = createLogger("MessageQueuePersistence"); +const MUTATION_CHANNEL_NAME = "orgii:message-deliveries"; +const MAX_RECONCILED_ORPHAN_SESSIONS = 100; +const ORPHAN_SCAN_TAIL_SIZE = 25; +const ORPHANED_QUEUE_DELIVERY_ERROR = + "This message was not sent because its pending delivery could not be recovered. Retry to send it again."; + +const queueRevisionByStore = new WeakMap(); +const queuePersistByStore = new WeakMap>(); +const lastObservedQueueByStore = new WeakMap(); const hydrationByStore = new WeakMap>(); const unsubscribeByStore = new WeakMap void>(); +const mutationGenerationByStore = new WeakMap(); +const externalRefreshByStore = new WeakMap>(); +const orphanReconciliationUnsubscribeByStore = new WeakMap void>(); +const reconciledOrphanSessionsByStore = new WeakMap>(); +const orphanReconciliationInFlightByStore = new WeakMap< + Store, + Map> +>(); +const hydratedStores = new Set(); +let mutationChannel: BroadcastChannel | null = null; + +function sameQueue( + left: readonly QueuedMessage[] | undefined, + right: readonly QueuedMessage[] +): boolean { + return ( + left !== undefined && + left.length === right.length && + left.every((message, index) => message === right[index]) + ); +} + +function persistQueueBestEffort(store: Store): Promise { + const write = persistDurableMessageQueue(store.get(messageQueueAtom)); + queuePersistByStore.set(store, write); + return write; +} + +/** + * Wait until the current queue projection has reached the existing durable + * delivery registry. Callers use this as a commit barrier before publishing + * side effects whose recovery depends on that queue row. + */ +export async function flushMessageQueuePersistence( + store: Store +): Promise { + let write = queuePersistByStore.get(store) ?? persistQueueBestEffort(store); + for (;;) { + await write; + const latest = queuePersistByStore.get(store); + if (!latest || latest === write) return; + write = latest; + } +} + +function installQueuePersistence(store: Store): void { + if (unsubscribeByStore.has(store)) return; + lastObservedQueueByStore.set(store, store.get(messageQueueAtom)); + const unsubscribe = store.sub(messageDeliveryRecordsAtom, () => { + const queue = store.get(messageQueueAtom); + if (sameQueue(lastObservedQueueByStore.get(store), queue)) return; + lastObservedQueueByStore.set(store, queue); + queueRevisionByStore.set(store, (queueRevisionByStore.get(store) ?? 0) + 1); + void persistQueueBestEffort(store).catch((error: unknown) => { + log.error("failed to persist queue", error); + }); + }); + unsubscribeByStore.set(store, unsubscribe); +} -function mergeQueues( +function mergeHydratedQueue( durable: readonly QueuedMessage[], - live: readonly QueuedMessage[] + live: readonly QueuedMessage[], + active: readonly ActiveMessageDelivery[] ): QueuedMessage[] { + const activeIntents = new Set(active.map((row) => row.turnIntentId)); const byIntent = new Map(); - // A persisted row may have crossed the backend-ACK/dequeue crash window. On - // recovery we cannot prove whether it was accepted, so never auto-replay it: - // keep it visible and require an explicit Send Now. Live rows created during - // hydration are known to belong to this renderer and therefore retain their - // natural dispatch policy. for (const message of durable) { - byIntent.set(message.turnIntentId, { - ...message, - priority: "next", - requiresExplicitDispatch: true, - }); + if (activeIntents.has(message.turnIntentId)) continue; + // Plain legacy rows can still straddle the old backend-ACK/dequeue crash + // window, so recovery parks them for an explicit Send Now. Canonical rows + // cross provider acceptance only after an atomic queued -> active owner + // handoff; a row that is still queued was never accepted and is therefore + // safe to drain with its persisted priority/hold state. Preserve an + // explicit admission, Stop, or failure hold instead of inventing one for + // every canonical row after each app restart. + byIntent.set( + message.turnIntentId, + message.conversationDispatch + ? message + : { + ...message, + priority: "next", + requiresExplicitDispatch: true, + } + ); + } + for (const message of live) { + if (!activeIntents.has(message.turnIntentId)) { + byIntent.set(message.turnIntentId, message); + } } - // Live mutations made while the async disk read was pending win. - for (const message of live) byIntent.set(message.turnIntentId, message); return boundQueuedMessages( [...byIntent.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt) @@ -40,39 +149,490 @@ function mergeQueues( ); } +function publishRecords( + store: Store, + queue: readonly QueuedMessage[], + active: readonly ActiveMessageDelivery[] +): void { + const nextQueue = [...queue]; + lastObservedQueueByStore.set(store, nextQueue); + store.set(messageDeliveryRecordsAtom, [...nextQueue, ...active]); +} + +function mutationGeneration(store: Store): number { + return mutationGenerationByStore.get(store) ?? 0; +} + +function noteLocalMutation(store: Store): void { + mutationGenerationByStore.set(store, mutationGeneration(store) + 1); +} + +function ensureMutationChannel(): BroadcastChannel | null { + if (mutationChannel || typeof BroadcastChannel === "undefined") { + return mutationChannel; + } + mutationChannel = new BroadcastChannel(MUTATION_CHANNEL_NAME); + mutationChannel.addEventListener("message", () => { + for (const store of hydratedStores) { + mutationGenerationByStore.set(store, mutationGeneration(store) + 1); + if (externalRefreshByStore.has(store)) continue; + const refresh = (async () => { + let observed: number; + do { + observed = mutationGeneration(store); + await refreshMessageDeliveries(store); + } while (observed !== mutationGeneration(store)); + })() + .catch((error) => + console.warn( + "[messageQueuePersistence] cross-window refresh failed", + error + ) + ) + .finally(() => externalRefreshByStore.delete(store)); + externalRefreshByStore.set(store, refresh); + } + }); + return mutationChannel; +} + +function broadcastMutation(): void { + ensureMutationChannel()?.postMessage({ type: "changed" }); +} + +function unresolvedQueueMessageId(event: SessionEvent): string | null { + const queueMessageId = event.result?.queueMessageId; + const deliveryStatus = event.result?.deliveryStatus; + if ( + event.source !== "user" || + (deliveryStatus !== "pending" && deliveryStatus !== "failed") || + event.displayStatus !== deliveryStatus || + event.result?.deliveryOwnerRetired === true || + event.result?.syntheticUserInput !== true || + typeof queueMessageId !== "string" || + !isOptimisticQueueUserEventId(event.id) || + event.id !== optimisticQueueUserEventId(queueMessageId) + ) { + return null; + } + return queueMessageId; +} + +function rememberReconciledSession(store: Store, sessionId: string): void { + const sessions = reconciledOrphanSessionsByStore.get(store) ?? new Set(); + sessions.delete(sessionId); + sessions.add(sessionId); + while (sessions.size > MAX_RECONCILED_ORPHAN_SESSIONS) { + const oldest = sessions.values().next().value as string | undefined; + if (!oldest) break; + sessions.delete(oldest); + } + reconciledOrphanSessionsByStore.set(store, sessions); +} + /** - * Hydrate then subscribe one Jotai store. The WeakMap ownership supports test - * stores and multiple windows without app-lifetime listener leaks. + * Recover legacy pending/failed projections that have no execution owner. + * + * The durable registry is read after the EventStore candidate snapshot, so a + * correctly admitted message (owner committed before projection append) can + * never be classified as an orphan. A second EventStore read rejects a row + * that became sent while the owner was settling. The visible user row + * remains intact; only its stale queue ownership claim is removed so Retry + * can re-enter the canonical submit path. */ +export async function reconcileOrphanedOptimisticQueueProjections( + sessionId: string +): Promise { + const events = await eventStoreProxy.getEvents(sessionId); + if (events.length === 0) return false; + const candidateIds = events + .map(unresolvedQueueMessageId) + .filter((id): id is string => Boolean(id)); + if (candidateIds.length === 0) return true; + + const ownerIds = await findDurableMessageDeliveryOwnerIds(candidateIds); + const orphanIds = new Set(candidateIds.filter((id) => !ownerIds.has(id))); + if (orphanIds.size === 0) return true; + + // Re-read after the durable lookup. Provider acceptance patches pending to + // sent before retiring its active owner; never delete that accepted row on + // the basis of the earlier snapshot. + const currentOrphans = (await eventStoreProxy.getEvents(sessionId)).filter( + (event) => { + const queueMessageId = unresolvedQueueMessageId(event); + return Boolean(queueMessageId && orphanIds.has(queueMessageId)); + } + ); + await Promise.all( + currentOrphans.map(async (event) => { + const result = { + ...event.result, + deliveryStatus: "failed", + deliveryError: + event.result?.deliveryStatus === "failed" + ? event.result.deliveryError + : ORPHANED_QUEUE_DELIVERY_ERROR, + }; + // A queueMessageId is a strong durable-ownership claim in Retry. The + // owner is proven absent, so retaining it would make Retry fail closed + // forever instead of using the ordinary submit/queue boundary. + Reflect.deleteProperty(result, "queueMessageId"); + const updated = await eventStoreProxy.updateById( + event.id, + { displayStatus: "failed", result }, + sessionId + ); + if (!updated) { + throw new Error( + `orphaned queue projection ${event.id} is no longer available` + ); + } + }) + ); + return true; +} + +function reconcileOrphanSessionOnce( + store: Store, + sessionId: string +): Promise { + if (reconciledOrphanSessionsByStore.get(store)?.has(sessionId)) { + return Promise.resolve(); + } + const inFlight = orphanReconciliationInFlightByStore.get(store) ?? new Map(); + const existing = inFlight.get(sessionId); + if (existing) return existing; + orphanReconciliationInFlightByStore.set(store, inFlight); + const reconciliation = reconcileOrphanedOptimisticQueueProjections(sessionId) + .then((inspected) => { + if (inspected) rememberReconciledSession(store, sessionId); + }) + .finally(() => inFlight.delete(sessionId)); + inFlight.set(sessionId, reconciliation); + return reconciliation; +} + +function installOrphanProjectionReconciliation(store: Store): void { + if (orphanReconciliationUnsubscribeByStore.has(store)) return; + const inspectSnapshot = (snapshot: Snapshot, sessionId: string) => { + if (!store.get(messageQueueHydratedAtom)) return; + if (reconciledOrphanSessionsByStore.get(store)?.has(sessionId)) return; + if (!isStreamingSnapshot(snapshot) && snapshot.eventCount > 0) { + void reconcileOrphanSessionOnce(store, sessionId).catch((error) => + log.warn( + "[messageQueuePersistence] orphan projection reconciliation failed", + { sessionId, error } + ) + ); + return; + } + const tail = snapshot.chatEvents.slice(-ORPHAN_SCAN_TAIL_SIZE); + const hasCandidate = tail.some((event) => unresolvedQueueMessageId(event)); + if (!hasCandidate) return; + void reconcileOrphanSessionOnce(store, sessionId).catch((error) => + log.warn( + "[messageQueuePersistence] orphan projection reconciliation failed", + { sessionId, error } + ) + ); + }; + orphanReconciliationUnsubscribeByStore.set( + store, + eventStoreProxy.subscribe(inspectSnapshot) + ); +} + +function hydrateMessageQueueFromSnapshot( + store: Store, + durable: readonly QueuedMessage[], + active: readonly ActiveMessageDelivery[] = [] +): void { + const queue = mergeHydratedQueue( + durable, + store.get(messageQueueAtom), + active + ); + publishRecords(store, queue, active); + installQueuePersistence(store); +} + +/** One hydration owner for queued, preparing, and accepted deliveries. */ export function hydrateMessageQueue(store: Store): Promise { const existing = hydrationByStore.get(store); if (existing) return existing; - - const hydration = loadDurableMessageQueue() - .then((durable) => { - store.set(messageQueueAtom, (live) => mergeQueues(durable, live)); - store.set(messageQueueHydratedAtom, true); - void persistDurableMessageQueue(store.get(messageQueueAtom)); - if (!unsubscribeByStore.has(store)) { - const unsubscribe = store.sub(messageQueueAtom, () => { - void persistDurableMessageQueue(store.get(messageQueueAtom)); - }); - unsubscribeByStore.set(store, unsubscribe); + hydratedStores.add(store); + ensureMutationChannel(); + const hydration = (async () => { + try { + let generation = mutationGeneration(store); + let snapshot = await loadDurableMessageDeliveries(); + while (generation !== mutationGeneration(store)) { + generation = mutationGeneration(store); + snapshot = await loadDurableMessageDeliveries(); } - }) - .catch(() => { - // The repository already logs the root error. Keep the queue usable in - // memory rather than blocking all sends when persistence is unavailable. + hydrateMessageQueueFromSnapshot(store, snapshot.queue, snapshot.active); + await persistQueueBestEffort(store); store.set(messageQueueHydratedAtom, true); - }); - + installOrphanProjectionReconciliation(store); + const activeSessionId = store.get(sessionIdAtom); + if (activeSessionId) { + await reconcileOrphanSessionOnce(store, activeSessionId).catch( + (error) => + log.warn( + "[messageQueuePersistence] initial orphan projection reconciliation failed", + { sessionId: activeSessionId, error } + ) + ); + } + } catch (error) { + store.set(messageQueueHydratedAtom, false); + hydrationByStore.delete(store); + hydratedStores.delete(store); + throw error; + } + })(); hydrationByStore.set(store, hydration); return hydration; } +/** Reconcile the live projections with the single durable delivery registry. */ +export async function refreshMessageDeliveries(store: Store): Promise { + for (;;) { + const revision = queueRevisionByStore.get(store) ?? 0; + await queuePersistByStore.get(store); + const snapshot = await loadDurableMessageDeliveries(); + if (revision !== (queueRevisionByStore.get(store) ?? 0)) continue; + publishRecords(store, snapshot.queue, snapshot.active); + return; + } +} + +/** + * Cancel exact, still-queued deliveries and their optimistic EventStore rows. + * + * The handoff set is the existing queue mutation freeze. Holding it across + * both durable stores prevents the dispatcher, edit, cancel, and reorder + * paths from racing this ownership transition. Rows that have already moved + * to `preparing`/`accepted` are rejected by the repository and remain intact. + */ +export async function cancelQueuedMessageDeliveries( + store: Store, + messageIds: readonly string[] +): Promise { + if (messageIds.length === 0) return; + const requestedIds = new Set(messageIds); + const alreadyFrozen = store.get(messageQueueHandoffIdsAtom); + const candidates = store + .get(messageQueueAtom) + .filter( + (message) => + requestedIds.has(message.id) && !alreadyFrozen.has(message.id) + ); + if (candidates.length === 0) return; + + const candidateIds = new Set(candidates.map((message) => message.id)); + store.set(messageQueueHandoffIdsAtom, (current) => { + const next = new Set(current); + for (const id of candidateIds) next.add(id); + return next; + }); + + try { + const removed = await removeDurableQueuedMessageDeliveries( + candidates.map(({ id, turnIntentId }) => ({ id, turnIntentId })) + ); + if (removed.length === 0) return; + + const removals = await Promise.allSettled( + removed.map(async (message) => { + await removeOptimisticQueueUserDelivery({ + sessionId: message.sessionId, + queueMessageId: message.id, + }); + return message; + }) + ); + const cancelledIds = new Set(); + const failed: QueuedMessage[] = []; + for (let index = 0; index < removals.length; index += 1) { + const result = removals[index]; + const message = removed[index]; + if (!message) continue; + if (result?.status === "fulfilled") cancelledIds.add(message.id); + else failed.push(message); + } + + // If EventStore cleanup fails, restore only those rows as cancellable + // queue owners. Successful cancellations remain absent. The current live + // queue is the existing authority for concurrent enqueues and preserves + // their order and payloads in the same snapshot transaction. + if (failed.length > 0) { + await persistDurableMessageQueue( + store + .get(messageQueueAtom) + .filter((message) => !cancelledIds.has(message.id)) + ); + } + + noteLocalMutation(store); + const records = store.get(messageDeliveryRecordsAtom); + publishRecords( + store, + records + .filter(isQueuedMessageDelivery) + .filter((message) => !cancelledIds.has(message.id)), + records.filter(isActiveMessageDelivery) + ); + broadcastMutation(); + + if (failed.length > 0) { + const firstFailure = removals.find( + (result): result is PromiseRejectedResult => + result.status === "rejected" + ); + const detail = + firstFailure?.reason instanceof Error + ? `: ${firstFailure.reason.message}` + : ""; + throw new Error( + `failed to remove ${failed.length} optimistic queued message projection(s)${detail}` + ); + } + } finally { + store.set(messageQueueHandoffIdsAtom, (current) => { + if (![...candidateIds].some((id) => current.has(id))) return current; + const next = new Set(current); + for (const id of candidateIds) next.delete(id); + return next; + }); + } +} + +export async function handoffQueuedMessageToActiveDelivery( + store: Store, + delivery: ActiveMessageDelivery +): Promise { + const result = await handoffDurableMessageDelivery(delivery); + noteLocalMutation(store); + const records = store.get(messageDeliveryRecordsAtom); + const queue = records + .filter(isQueuedMessageDelivery) + .filter((message) => message.turnIntentId !== delivery.turnIntentId); + publishRecords(store, queue, result.active); + broadcastMutation(); +} + +export async function returnActiveDeliveryToMessageQueue( + store: Store, + deliveryId: string, + message: QueuedMessage +): Promise { + store.set(messageQueueHandoffIdsAtom, (current) => { + const next = new Set(current); + next.add(message.id); + return next; + }); + try { + const result = await returnDurableMessageDeliveryToQueue( + deliveryId, + message + ); + noteLocalMutation(store); + const records = store.get(messageDeliveryRecordsAtom); + const queue = [ + ...records + .filter(isQueuedMessageDelivery) + .filter( + (candidate) => + candidate.id !== result.message.id && + candidate.turnIntentId !== result.message.turnIntentId + ), + result.message, + ]; + publishRecords(store, queue, result.active); + broadcastMutation(); + return true; + } finally { + store.set(messageQueueHandoffIdsAtom, (current) => { + if (!current.has(message.id)) return current; + const next = new Set(current); + next.delete(message.id); + return next; + }); + } +} + +export async function updateActiveMessageDelivery( + store: Store, + deliveryId: string, + update: ActiveMessageDeliveryUpdate +): Promise { + const updated = await updateDurableActiveMessageDelivery(deliveryId, update); + noteLocalMutation(store); + if (updated) { + const records = store.get(messageDeliveryRecordsAtom); + publishRecords( + store, + records.filter(isQueuedMessageDelivery), + records + .filter(isActiveMessageDelivery) + .map((candidate) => (candidate.id === deliveryId ? updated : candidate)) + ); + } + broadcastMutation(); + return updated; +} + +export async function removeActiveMessageDelivery( + store: Store, + deliveryId: string +): Promise { + await removeDurableActiveMessageDelivery(deliveryId); + noteLocalMutation(store); + const records = store.get(messageDeliveryRecordsAtom); + publishRecords( + store, + records.filter(isQueuedMessageDelivery), + records + .filter(isActiveMessageDelivery) + .filter((candidate) => candidate.id !== deliveryId) + ); + broadcastMutation(); +} + +export { assertDurableActiveDeliveryIsRootHead }; + export function disposeMessageQueuePersistence(store: Store): void { unsubscribeByStore.get(store)?.(); unsubscribeByStore.delete(store); hydrationByStore.delete(store); + queueRevisionByStore.delete(store); + queuePersistByStore.delete(store); + lastObservedQueueByStore.delete(store); + mutationGenerationByStore.delete(store); + externalRefreshByStore.delete(store); + orphanReconciliationUnsubscribeByStore.get(store)?.(); + orphanReconciliationUnsubscribeByStore.delete(store); + reconciledOrphanSessionsByStore.delete(store); + orphanReconciliationInFlightByStore.delete(store); + hydratedStores.delete(store); store.set(messageQueueHydratedAtom, false); + if (hydratedStores.size === 0) { + mutationChannel?.close(); + mutationChannel = null; + } +} + +export function replaceActiveMessageDeliveryLocally( + store: Store, + deliveryId: string, + update: ActiveMessageDeliveryUpdate +): void { + store.set(messageDeliveryRecordsAtom, (records: MessageDeliveryRecord[]) => + records.map((record) => + isActiveMessageDelivery(record) && record.id === deliveryId + ? { ...record, ...update } + : record + ) + ); } diff --git a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts index 14b94d9abb..487dc2575b 100644 --- a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts +++ b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts @@ -1,7 +1,11 @@ /** * useQueueDispatch Hook — the single queue dispatcher. * - * SINGLETON — must be mounted exactly once (in GlobalSessionSync). + * WINDOW-STORE SINGLETON — mount exactly once for each Jotai/window store. + * The main window mounts it from GlobalSessionSync; a detached SessionWindow + * mounts its own instance because its durable queue is keyed by window label. + * Cross-window turns for the same canonical root are serialized by the + * injected executor's process-wide root lock. * * Drains `messageQueueAtom` strictly against the turn-lifecycle FSM * (`turnLifecycle.ts`). There is exactly one rule set: @@ -30,28 +34,32 @@ import { type AgentExecMode, resolveSessionAgentExecMode, } from "@src/config/sessionCreatorConfig"; -import { - beginOptimisticTurn, - failOptimisticTurn, -} from "@src/engines/SessionCore/control/optimisticTurnStatus"; import { cancelTurnForTimelineBoundary } from "@src/engines/SessionCore/control/sessionTimelineBoundary"; -import { publishTurnIntentDispatch } from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; import { - beginTurnDispatch, - confirmTurnRunning, + getTurnGeneration, getTurnPhase, - markTurnTerminal, + restoreTurnWorkingAfterInterruptFailure, } from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + QueuedConversationBlockedError, + QueuedConversationBusyError, + type QueuedConversationDispatcher, + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, + QueuedConversationTurnFailedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; import { queueDispatchSyncInputsAtom } from "@src/engines/SessionCore/derived/queueDispatchSyncInputsAtom"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; -import { createSyntheticUserEvent } from "@src/engines/SessionCore/sync/adapters/shared"; +import { + dispatchUserIntent, + isUserIntentSendError, + setOptimisticQueueUserDelivery, +} from "@src/engines/SessionCore/services/userIntentDispatch"; import { createLogger } from "@src/hooks/logger"; -import { markSessionActive } from "@src/store/session"; import { closePostStopDispatchEpisodeAtom, lastUserMessageAtom, - setSessionRuntimeStatusAtom, } from "@src/store/session/cliSessionStatusAtom"; import { type LastModelSelection, @@ -59,17 +67,26 @@ import { } from "@src/store/session/creatorDefaultModelAtom"; import { sessionMapAtom } from "@src/store/session/sessionAtom"; import { + type ActiveMessageDelivery, type QueuedMessage, + activeMessageDeliveriesAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, messageQueueHydratedAtom, queueEditingAtom, + queuedMessageScopeKey, } from "@src/store/ui/messageQueueAtom"; +import { + getMessageQueueOwnerKey, + isPrimaryMessageQueueOwnerKey, + persistDurableMessageQueue, + withCanonicalConversationTurnLock, +} from "@src/store/ui/messageQueueRepository"; import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { isAgentSession, isCliSession, - isCursorIdeSession, } from "@src/util/session/sessionDispatch"; import { @@ -77,29 +94,68 @@ import { classifyBackendSessionStatus, } from "./backendDispatchVerdict"; import { + assertDurableActiveDeliveryIsRootHead, disposeMessageQueuePersistence, + handoffQueuedMessageToActiveDelivery, hydrateMessageQueue, + refreshMessageDeliveries, + removeActiveMessageDelivery, + replaceActiveMessageDeliveryLocally, + returnActiveDeliveryToMessageQueue, + updateActiveMessageDelivery, } from "./messageQueuePersistence"; const log = createLogger("useQueueDispatch"); -const MAX_SENT_QUEUE_ID_CACHE = 200; +/** Re-check cadence while the backend reports the session still busy. */ +const QUEUE_BACKEND_RECHECK_MS = 3_000; +const CANONICAL_RECOVERY_RETRY_MAX_MS = 60_000; +const CANONICAL_HYDRATION_RETRY_MAX_MS = 30_000; -/** - * Natural follow-ups stay visible in the queue UI for at least this long so - * a fast turn completion does not make the queued bubble flash and vanish. - * Explicit "now" dispatches skip this — the user just asked for it. - */ -const MIN_QUEUE_VISIBLE_MS = 1_200; +function canonicalRecoveryDelayMs(attempt: number): number { + return Math.min( + QUEUE_BACKEND_RECHECK_MS * 2 ** Math.max(0, attempt - 1), + CANONICAL_RECOVERY_RETRY_MAX_MS + ); +} -function queuedMessageAgeMs(message: QueuedMessage): number { - const createdAtMs = Date.parse(message.createdAt); - if (!Number.isFinite(createdAtMs)) return MIN_QUEUE_VISIBLE_MS; - return Date.now() - createdAtMs; +function queuedRetryFromDelivery( + delivery: ActiveMessageDelivery, + error?: unknown +): QueuedMessage { + const { + originQueueKey: _originQueueKey, + runnerSessionId: _runnerSessionId, + runnerEventStartIndex: _runnerEventStartIndex, + retryAt: _retryAt, + retryAttempt: _retryAttempt, + ...message + } = delivery; + return { + ...message, + priority: "next", + requiresExplicitDispatch: true, + status: "queued", + ...(error + ? { + deliveryError: error instanceof Error ? error.message : String(error), + } + : {}), + }; } -/** Re-check cadence while the backend reports the session still busy. */ -const QUEUE_BACKEND_RECHECK_MS = 3_000; +function optimisticDeliveryProjectionParams( + message: QueuedMessage | ActiveMessageDelivery +) { + return { + sessionId: message.sessionId, + visibleText: message.displayContent, + imageDataUrls: message.imageDataUrls, + turnIntentId: message.turnIntentId, + queueMessageId: message.id, + createdAt: message.createdAt, + }; +} /** * Authoritative pre-dispatch gate for the natural FIFO drain. @@ -132,43 +188,189 @@ async function getBackendDispatchVerdict( } } -export function useQueueDispatch(): void { +export function useQueueDispatch( + executeCanonicalConversation?: QueuedConversationDispatcher +): void { const store = useStore(); + const messageQueueOwnerKeyRef = useRef(null); useEffect(() => { - void hydrateMessageQueue(store); - return () => disposeMessageQueuePersistence(store); + let disposed = false; + let hydrationRetryTimer: number | null = null; + let hydrationAttempt = 0; + const recoverDelivery = async (): Promise => { + try { + messageQueueOwnerKeyRef.current = await getMessageQueueOwnerKey(); + await hydrateMessageQueue(store); + } catch (error) { + log.error( + "[useQueueDispatch] delivery recovery hydration failed closed:", + error + ); + if (!disposed) { + hydrationAttempt += 1; + const delay = Math.min( + QUEUE_BACKEND_RECHECK_MS * 2 ** (hydrationAttempt - 1), + CANONICAL_HYDRATION_RETRY_MAX_MS + ); + hydrationRetryTimer = window.setTimeout(() => { + hydrationRetryTimer = null; + void recoverDelivery().catch((error: unknown) => { + log.error("Delivery recovery callback failed", error); + }); + }, delay); + } + } + }; + void recoverDelivery().catch((error: unknown) => { + log.error("Delivery recovery callback failed", error); + }); + return () => { + disposed = true; + if (hydrationRetryTimer !== null) { + window.clearTimeout(hydrationRetryTimer); + } + disposeMessageQueuePersistence(store); + }; + }, [store]); + + useEffect(() => { + let refreshInFlight: Promise | null = null; + let trailingRefresh = false; + const refreshDeliveryProjection = () => { + if (!store.get(messageQueueHydratedAtom)) return; + if (refreshInFlight) { + trailingRefresh = true; + return; + } + refreshInFlight = (async () => { + do { + trailingRefresh = false; + await refreshMessageDeliveries(store); + } while (trailingRefresh); + })() + .catch((error) => + log.warn( + "[useQueueDispatch] failed to refresh delivery projection:", + error + ) + ) + .finally(() => { + refreshInFlight = null; + }); + }; + const refreshIfVisible = () => { + if ( + typeof document === "undefined" || + document.visibilityState === "visible" + ) { + refreshDeliveryProjection(); + } + }; + window.addEventListener("focus", refreshIfVisible); + window.addEventListener("online", refreshIfVisible); + document.addEventListener("visibilitychange", refreshIfVisible); + return () => { + window.removeEventListener("focus", refreshIfVisible); + window.removeEventListener("online", refreshIfVisible); + document.removeEventListener("visibilitychange", refreshIfVisible); + }; }, [store]); // ── Dispatch lock ───────────────────────────────────────────────────────── - // One dispatch at a time, globally. The in-flight id additionally guards - // the window between a successful send and the dequeue write. + // One dispatch at a time in this window store. The in-flight id additionally + // guards the window between a successful send and the dequeue write. const dispatchLockRef = useRef(false); const inFlightMessageIdRef = useRef(null); // Send Now interrupt bookkeeping: one boundary interrupt per message. const interruptRequestedByMessageIdRef = useRef>(new Set()); - // Already-sent ids (bounded LRU) so a stale queue snapshot can never - // double-send a message that already became a user turn. - const sentQueuedMessageIdsRef = useRef>(new Set()); - const sentQueuedMessageIdOrderRef = useRef([]); - const rememberSentQueueId = useCallback((messageId: string) => { - if (sentQueuedMessageIdsRef.current.has(messageId)) return; - sentQueuedMessageIdsRef.current.add(messageId); - sentQueuedMessageIdOrderRef.current.push(messageId); - while ( - sentQueuedMessageIdOrderRef.current.length > MAX_SENT_QUEUE_ID_CACHE - ) { - const expiredId = sentQueuedMessageIdOrderRef.current.shift(); - if (expiredId) sentQueuedMessageIdsRef.current.delete(expiredId); - } - }, []); + const acceptQueuedMessage = useCallback( + (messageId: string) => { + interruptRequestedByMessageIdRef.current.delete(messageId); + store.set(messageQueueAtom, (current) => + current.filter((candidate) => candidate.id !== messageId) + ); + }, + [store] + ); + + const settleQueuedMessageFailure = useCallback( + async (message: QueuedMessage, error: unknown) => { + // The durable delivery record remains the retry/edit owner until an + // accepted send retires it. EventStore is only its transcript + // projection; transferring ownership to that cache made failed rows + // disappear after a restart or imported-history refresh. + if (message.conversationDispatch) { + try { + await setOptimisticQueueUserDelivery( + optimisticDeliveryProjectionParams(message), + "failed", + error + ); + } catch (projectionError) { + log.error( + "[useQueueDispatch] could not fail canonical transcript row:", + projectionError + ); + } + } + const detail = error instanceof Error ? error.message : String(error); + store.set(messageQueueAtom, (current) => + current.some((candidate) => candidate.id === message.id) + ? current.map((candidate) => + candidate.id === message.id + ? { + ...candidate, + status: "queued", + priority: "next", + requiresExplicitDispatch: true, + deliveryError: detail, + } + : candidate + ) + : [ + ...current, + { + ...message, + status: "queued", + priority: "next", + requiresExplicitDispatch: true, + deliveryError: detail, + }, + ] + ); + interruptRequestedByMessageIdRef.current.delete(message.id); + Message.error({ + content: `Failed to send message: ${detail}`, + duration: 5000, + }); + }, + [store] + ); - // Pending wake-up for MIN_QUEUE_VISIBLE_MS waits. + // One bounded wake-up owner for backend-busy and accepted-delivery retries. const wakeTimerRef = useRef(null); + const wakeAtRef = useRef(null); const tryDispatchNextRef = useRef<() => void>(() => {}); + const scheduleWakeAt = useCallback((wakeAt: number) => { + if (wakeAtRef.current !== null && wakeAtRef.current <= wakeAt) return; + if (wakeTimerRef.current !== null) { + window.clearTimeout(wakeTimerRef.current); + } + wakeAtRef.current = wakeAt; + wakeTimerRef.current = window.setTimeout( + () => { + wakeTimerRef.current = null; + wakeAtRef.current = null; + tryDispatchNextRef.current(); + }, + Math.max(0, wakeAt - Date.now()) + ); + }, []); + const dispatchMessage = useCallback( (msg: QueuedMessage, onDone: () => void) => { const { sessionId, content, displayContent, imageDataUrls } = msg; @@ -189,19 +391,6 @@ export function useQueueDispatch(): void { resolveSessionAgentExecMode(session?.agentExecMode); const { model, accountId } = resolveModelForMessage(lastModelSelection); - // Synchronous turn reserve BEFORE any await: from this instant every - // submit and every other dispatch pass observes the session as busy. - const dispatchGeneration = beginTurnDispatch(sessionId); - publishTurnIntentDispatch(msg.turnIntentId, { - sessionId, - generation: dispatchGeneration, - }); - - // An explicit dispatch concludes any pending stop episode. - if (msg.priority === "now") { - store.set(closePostStopDispatchEpisodeAtom, sessionId); - } - // Capture the payload for Stop-restore before the async append. store.set(lastUserMessageAtom, { sessionId, @@ -209,116 +398,497 @@ export function useQueueDispatch(): void { imageDataUrls, }); - beginOptimisticTurn(sessionId, "queue"); - void (async () => { - let userEventId: string | null = null; try { - const userEvent = createSyntheticUserEvent( - sessionId, - displayContent, - { - imageDataUrls, - turnIntentId: msg.turnIntentId, - } - ); - userEventId = userEvent.id; - await eventStoreProxy.append([userEvent], sessionId); // Pass displayContent as displayText when it differs from content // (i.e. skill pills were expanded) so the persisted event stores // the pill format and re-editing shows the pill, not the YAML. const displayTextForDispatch = content !== displayContent ? displayContent : undefined; - await SessionService.sendMessage({ + await dispatchUserIntent({ sessionId, - content, - displayText: displayTextForDispatch, - model, - accountId, - mode: agentExecMode, + visibleText: displayContent, imageDataUrls, - clientMessageId: `queued:${sessionId}:${msg.id}`, - turnIntentId: msg.turnIntentId, - turnIntentSource: msg.priority === "now" ? "force_send" : "queue", - directUserIntent: true, + runtimeStatusSource: "queue", + queueMessageId: msg.id, + send: { + content, + displayText: displayTextForDispatch, + model, + accountId, + mode: agentExecMode, + clientMessageId: `queued:${sessionId}:${msg.id}`, + turnIntentId: msg.turnIntentId, + turnIntentSource: msg.priority === "now" ? "force_send" : "queue", + directUserIntent: true, + }, }); - // Backend accepted the message — confirm the turn as running. - confirmTurnRunning(sessionId); - // Bump activity timestamps so the just-flushed session surfaces in - // "recent activity" views without waiting for the next refresh. - markSessionActive(sessionId); - rememberSentQueueId(msg.id); - store.set(messageQueueAtom, (prev) => - prev.filter((item) => item.id !== msg.id) - ); - onDone(); - if (isCursorIdeSession(sessionId)) { - // Cursor IDE sessions have no turn lifecycle (no terminal event - // stream) — close the turn right after a successful handoff. - store.set(setSessionRuntimeStatusAtom, { - sessionId, - status: "idle", - source: "queue", - }); - markTurnTerminal(sessionId, "completed", { - generation: dispatchGeneration, - }); - } + acceptQueuedMessage(msg.id); } catch (err) { log.error("[useQueueDispatch] dispatch failed:", err); - if (userEventId) { - try { - await eventStoreProxy.removeByIdPrefix(userEventId, sessionId); - } catch (cleanupError) { - log.warn( - "[useQueueDispatch] failed to remove optimistic user event:", - cleanupError + await settleQueuedMessageFailure(msg, err); + } finally { + onDone(); + } + })().catch((error: unknown) => { + log.error("Queued dispatch settlement failed", error); + }); + }, + [acceptQueuedMessage, settleQueuedMessageFailure, store] + ); + + const activeDeliveryIdsRef = useRef>(new Set()); + + const retryActiveDelivery = useCallback( + async (delivery: ActiveMessageDelivery) => { + const attempt = (delivery.retryAttempt ?? 0) + 1; + await updateActiveMessageDelivery(store, delivery.id, { + retryAttempt: attempt, + retryAt: new Date( + Date.now() + canonicalRecoveryDelayMs(attempt) + ).toISOString(), + }); + }, + [store] + ); + + const projectActiveCanonicalFailure = useCallback( + async ( + delivery: ActiveMessageDelivery, + error: unknown + ): Promise => { + let projected = false; + try { + projected = await setOptimisticQueueUserDelivery( + optimisticDeliveryProjectionParams(delivery), + "failed", + error + ); + } catch (projectionError) { + log.error( + "[useQueueDispatch] could not fail canonical transcript row:", + projectionError + ); + return false; + } + return projected; + }, + [] + ); + + const returnFailedCanonicalDeliveryToQueue = useCallback( + async ( + delivery: ActiveMessageDelivery, + error?: unknown + ): Promise => { + try { + await returnActiveDeliveryToMessageQueue( + store, + delivery.id, + queuedRetryFromDelivery(delivery, error) + ); + return true; + } catch (returnError) { + log.error( + "[useQueueDispatch] could not restore failed queue row:", + returnError + ); + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === delivery.id); + if (current) await retryActiveDelivery(current); + return false; + } + }, + [retryActiveDelivery, store] + ); + + const startRunnableActiveDeliveries = useCallback(() => { + if (!store.get(messageQueueHydratedAtom)) return; + if (!executeCanonicalConversation) return; + const now = Date.now(); + const deliveries = store.get(activeMessageDeliveriesAtom); + const ownerKey = messageQueueOwnerKeyRef.current; + if (!ownerKey) return; + const claimedRoots = new Set(); + for (const delivery of deliveries) { + if (!activeDeliveryIdsRef.current.has(delivery.id)) continue; + claimedRoots.add(conversationRootKey(delivery.conversationDispatch.root)); + } + const runnable = deliveries.filter((delivery) => { + if (activeDeliveryIdsRef.current.has(delivery.id)) return false; + if ( + !isPrimaryMessageQueueOwnerKey(ownerKey) && + delivery.originQueueKey !== ownerKey + ) { + return false; + } + const rootKey = conversationRootKey(delivery.conversationDispatch.root); + if (claimedRoots.has(rootKey)) return false; + // Claim the durable FIFO head before evaluating its wake condition. + // A blocked/backing-off head must prevent a later turn for the same + // canonical root from materializing against a transcript missing it. + claimedRoots.add(rootKey); + const retryAt = Date.parse(delivery.retryAt ?? ""); + if (Number.isFinite(retryAt) && retryAt > now) return false; + return true; + }); + const nextRetryAt = deliveries.reduce( + (earliest, delivery) => { + const retryAt = Date.parse(delivery.retryAt ?? ""); + if (!Number.isFinite(retryAt) || retryAt <= now) return earliest; + return earliest === undefined || retryAt < earliest + ? retryAt + : earliest; + }, + undefined + ); + if (nextRetryAt !== undefined) scheduleWakeAt(nextRetryAt); + + for (const delivery of runnable) { + const deliveryId = delivery.id; + activeDeliveryIdsRef.current.add(deliveryId); + void withCanonicalConversationTurnLock( + delivery.conversationDispatch.root, + async () => { + // The atom only wakes the dispatcher. The durable row read under the + // root lock is the sole launch authority and carries the latest + // accepted/runner recovery metadata from every webview. + const currentDelivery = + await assertDurableActiveDeliveryIsRootHead(deliveryId); + let accepted = currentDelivery.status === "accepted"; + const message = currentDelivery; + await executeCanonicalConversation(store, message, { + onRunnerReady: async (runnerSessionId, runnerEventStartIndex) => { + await updateActiveMessageDelivery(store, currentDelivery.id, { + runnerSessionId, + runnerEventStartIndex, + retryAt: undefined, + }); + }, + onAccepted: async (runnerSessionId) => { + accepted = true; + await updateActiveMessageDelivery(store, currentDelivery.id, { + status: "accepted", + runnerSessionId, + retryAt: undefined, + }); + await setOptimisticQueueUserDelivery( + optimisticDeliveryProjectionParams(currentDelivery), + "sent" + ).catch((projectionError) => { + log.error( + "[useQueueDispatch] could not accept canonical transcript row:", + projectionError + ); + return false; + }); + }, + }) + .then(async () => { + await removeActiveMessageDelivery(store, currentDelivery.id); + }) + .catch(async (error: unknown) => { + if ( + error instanceof QueuedConversationRecoveryBlockedError || + error instanceof QueuedConversationTurnClosedError + ) { + if ( + !accepted && + !(error instanceof QueuedConversationTurnClosedError) + ) { + // A definitive pre-acceptance recovery verdict (for example, + // a restart-reconciled stale turn intent) is an ordinary + // failed send. Keep the user's row visible and retryable; + // only an already-accepted provider owner may be retired + // without returning it to the queue. + if ( + !(await projectActiveCanonicalFailure( + currentDelivery, + error + )) + ) { + log.warn( + "[useQueueDispatch] failed recovery projection will be restored from delivery owner" + ); + } + if ( + !(await returnFailedCanonicalDeliveryToQueue( + currentDelivery, + error + )) + ) + return; + Message.error({ content: error.message, duration: 5000 }); + return; + } + // These terminal verdicts prove automatic recovery cannot run + // the provider, including a Cloud-published startup failure. + // Retire the execution owner without synthesizing a + // retry of the already accepted intent; the durable provider/ + // Cloud failure row remains the visible terminal result. Mark + // that row so an explicit Retry mints a fresh intent instead + // of waiting for an owner that no longer exists. + const retiredProjection = await setOptimisticQueueUserDelivery( + optimisticDeliveryProjectionParams(currentDelivery), + "failed", + error, + { ownerRetired: true } + ).catch((projectionError) => { + log.error( + "[useQueueDispatch] could not mark retired canonical transcript row:", + projectionError + ); + return false; + }); + if (!retiredProjection) { + // Reconciliation may have replaced the exact optimistic id, + // or persistence may be unavailable. Until the failed row + // owns Retry, retain this accepted delivery and use its + // existing bounded recovery wake-up; never lose both owners. + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === currentDelivery.id); + if (current) await retryActiveDelivery(current); + return; + } + await removeActiveMessageDelivery(store, currentDelivery.id); + Message.error({ content: error.message, duration: 5000 }); + return; + } + if (error instanceof QueuedConversationBlockedError) { + if (accepted) { + // No adapter may demote an intent after the irreversible + // provider-acceptance boundary. Treat a late identity/account + // verdict as recovery work against the same native turn. + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === currentDelivery.id); + if (current) await retryActiveDelivery(current); + return; + } + // Admission failed before provider acceptance. The optimistic + // EventStore row is already the visible retry/edit owner, so + // fail that exact row rather than retracting it into a card. + if ( + !(await projectActiveCanonicalFailure(currentDelivery, error)) + ) { + log.warn( + "[useQueueDispatch] failed transcript projection will be restored from delivery owner" + ); + } + if ( + !(await returnFailedCanonicalDeliveryToQueue( + currentDelivery, + error + )) + ) + return; + Message.error({ + content: error.message, + duration: 5000, + }); + return; + } + if (error instanceof QueuedConversationRecoveryPendingError) { + // The canonical user event or provider acceptance boundary may + // already be durable even when the result/tail cannot be read or + // published yet. Keep this execution owner in place regardless + // of its current phase and retry idempotent recovery only. + log.error( + "[useQueueDispatch] canonical execution needs recovery:", + error + ); + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === currentDelivery.id); + if (current) await retryActiveDelivery(current); + return; + } + if (error instanceof QueuedConversationTurnFailedError) { + // The provider closed the accepted turn with a definitive + // failure and no tail. The optimistic row is the visible retry + // owner: fail it with the reason and hold it for an explicit + // resend instead of reconnecting to a turn that cannot recover. + if ( + !(await projectActiveCanonicalFailure(currentDelivery, error)) + ) { + log.warn( + "[useQueueDispatch] failed transcript projection will be restored from delivery owner" + ); + } + if ( + !(await returnFailedCanonicalDeliveryToQueue( + currentDelivery, + error + )) + ) + return; + Message.error({ content: error.message, duration: 5000 }); + return; + } + if (accepted) { + // Acceptance is an irreversible boundary: the provider may have + // executed tools even when recovery/tail staging later failed. + // Retain this durable owner and reconnect to the SAME turn after a + // bounded backoff. The adapter's accepted path is recovery-only; + // it must never fall back to a fresh provider send. + log.error( + "[useQueueDispatch] accepted canonical execution needs recovery:", + error + ); + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === currentDelivery.id); + if (!current) return; + await retryActiveDelivery(current); + return; + } + if (isUserIntentSendError(error)) { + if ( + !(await projectActiveCanonicalFailure(currentDelivery, error)) + ) + log.warn( + "[useQueueDispatch] failed transcript projection will be restored from delivery owner" + ); + await returnFailedCanonicalDeliveryToQueue( + currentDelivery, + error + ); + return; + } + if ( + !(await projectActiveCanonicalFailure(currentDelivery, error)) + ) + log.warn( + "[useQueueDispatch] failed transcript projection will be restored from delivery owner" + ); + if ( + !(await returnFailedCanonicalDeliveryToQueue( + currentDelivery, + error + )) + ) + return; + Message.error({ + content: `Failed to continue conversation: ${ + error instanceof Error ? error.message : String(error) + }`, + duration: 5000, + }); + }) + .catch((settlementError: unknown) => { + // A failed retry/remove/return write must not become an unhandled + // rejection followed by an immediate provider retry loop. Keep the + // durable owner projected locally with a bounded wake; focus/online + // reconciliation will re-read the authoritative document sooner if + // storage recovers. + log.error( + "[useQueueDispatch] canonical settlement persistence failed:", + settlementError ); - } + const retryAt = new Date( + Date.now() + QUEUE_BACKEND_RECHECK_MS + ).toISOString(); + replaceActiveMessageDeliveryLocally(store, currentDelivery.id, { + retryAt, + }); + }); + } + ) + .catch(async (lockError: unknown) => { + if (lockError instanceof QueuedConversationBusyError) { + await refreshMessageDeliveries(store); + const current = store + .get(activeMessageDeliveriesAtom) + .find((candidate) => candidate.id === delivery.id); + if (current) await retryActiveDelivery(current); + return; } - // IPC failed before the backend received the message: close the - // reserved turn and park the message so it does not retry in a - // tight loop — the user can fix the issue and press Send Now. - failOptimisticTurn(sessionId, "queue"); - markTurnTerminal(sessionId, "failed", { - generation: dispatchGeneration, - }); - store.set(messageQueueAtom, (prev) => - prev.map((item) => - item.id === msg.id - ? { ...item, priority: "next", requiresExplicitDispatch: true } - : item - ) + log.error( + "[useQueueDispatch] canonical root lock/claim failed:", + lockError + ); + const retryAt = new Date( + Date.now() + QUEUE_BACKEND_RECHECK_MS + ).toISOString(); + replaceActiveMessageDeliveryLocally(store, delivery.id, { retryAt }); + }) + .finally(() => { + activeDeliveryIdsRef.current.delete(delivery.id); + tryDispatchNextRef.current(); + }); + } + }, [ + executeCanonicalConversation, + projectActiveCanonicalFailure, + returnFailedCanonicalDeliveryToQueue, + retryActiveDelivery, + scheduleWakeAt, + store, + ]); + + const dispatchCanonicalMessage = useCallback( + (msg: QueuedMessage, onDone: () => void) => { + if (!msg.conversationDispatch) { + onDone(); + return; + } + const delivery: ActiveMessageDelivery = { + ...msg, + conversationDispatch: msg.conversationDispatch, + status: "preparing", + }; + store.set(messageQueueHandoffIdsAtom, (current: ReadonlySet) => { + const next = new Set(current); + next.add(msg.id); + return next; + }); + void persistDurableMessageQueue(store.get(messageQueueAtom)) + .then(() => handoffQueuedMessageToActiveDelivery(store, delivery)) + .then(() => { + tryDispatchNextRef.current(); + }) + .catch((error) => settleQueuedMessageFailure(msg, error)) + .finally(() => { + store.set( + messageQueueHandoffIdsAtom, + (current: ReadonlySet) => { + if (!current.has(msg.id)) return current; + const next = new Set(current); + next.delete(msg.id); + return next; + } ); onDone(); - const detail = err instanceof Error ? err.message : String(err); - Message.error({ - content: `Failed to send message: ${detail}`, - duration: 5000, - }); - } - })(); + }); }, - [rememberSentQueueId, store] + [settleQueuedMessageFailure, store] ); const tryDispatchNext = useCallback(() => { - if (wakeTimerRef.current !== null) { - window.clearTimeout(wakeTimerRef.current); - wakeTimerRef.current = null; - } - if (dispatchLockRef.current) return; if (!store.get(messageQueueHydratedAtom)) return; + startRunnableActiveDeliveries(); + if (dispatchLockRef.current) return; if (store.get(queueEditingAtom)) return; const queue = store.get(messageQueueAtom); if (queue.length === 0) return; const candidates = queue.filter( - (msg) => - msg.id !== inFlightMessageIdRef.current && - !sentQueuedMessageIdsRef.current.has(msg.id) + (msg) => msg.id !== inFlightMessageIdRef.current ); + const activeCanonicalExecution = (message: QueuedMessage) => { + const descriptor = message.conversationDispatch; + if (!descriptor) return undefined; + const rootKey = conversationRootKey(descriptor.root); + return store + .get(activeMessageDeliveriesAtom) + .find( + (delivery) => + conversationRootKey(delivery.conversationDispatch.root) === rootKey + ); + }; // ── Explicit "now" dispatches take absolute precedence per session ─────── // A blocked Send Now for session A must not freeze an idle session B. Scan @@ -326,11 +896,28 @@ export function useQueueDispatch(): void { // most one interrupt for each active message while continuing the pass. const explicitMessages = candidates.filter((msg) => msg.priority === "now"); for (const explicitMsg of explicitMessages) { - const phase = getTurnPhase(explicitMsg.sessionId); - if (phase === "idle") { + const canonicalExecution = activeCanonicalExecution(explicitMsg); + const canonicalRunnerSessionId = canonicalExecution?.runnerSessionId; + const phase = explicitMsg.conversationDispatch + ? canonicalRunnerSessionId + ? getTurnPhase(canonicalRunnerSessionId) + : canonicalExecution + ? "dispatching" + : getTurnPhase(explicitMsg.sessionId) + : getTurnPhase(queuedMessageScopeKey(explicitMsg)); + // An execution row is the canonical root's accepted/recovery barrier. + // Even when its concrete runner is already terminal, the next turn must + // wait for tail publication and owner removal instead of racing recovery. + if (phase === "idle" && !canonicalExecution) { + // One shared admission/dispatch policy owns the Stop episode for both + // ordinary Sessions and canonical runtime continuations. + store.set(closePostStopDispatchEpisodeAtom, explicitMsg.sessionId); dispatchLockRef.current = true; inFlightMessageIdRef.current = explicitMsg.id; - dispatchMessage(explicitMsg, () => { + const dispatch = explicitMsg.conversationDispatch + ? dispatchCanonicalMessage + : dispatchMessage; + dispatch(explicitMsg, () => { if (inFlightMessageIdRef.current === explicitMsg.id) { inFlightMessageIdRef.current = null; } @@ -343,17 +930,39 @@ export function useQueueDispatch(): void { (phase === "working" || phase === "dispatching") && !interruptRequestedByMessageIdRef.current.has(explicitMsg.id) ) { + const interruptSessionId = explicitMsg.conversationDispatch + ? canonicalExecution + ? canonicalRunnerSessionId + : explicitMsg.sessionId + : explicitMsg.sessionId; + // The canonical root may still be preparing its native Session. Until + // onRunnerReady publishes an addressable Session there is nothing the + // ordinary timeline-boundary interrupt can target. + if (!interruptSessionId) continue; // Send Now against an active turn: interrupt it once. The provider's // cancelled terminal flips the FSM idle, which re-triggers this pass. interruptRequestedByMessageIdRef.current.add(explicitMsg.id); - void cancelTurnForTimelineBoundary( - explicitMsg.sessionId, - "force-send" - ).catch((error) => { - // A failed interrupt must be retryable. Keeping the id in this set - // would strand the message until an unrelated lifecycle signal. - interruptRequestedByMessageIdRef.current.delete(explicitMsg.id); - log.warn("[useQueueDispatch] force-send interrupt failed:", error); + const interruptGeneration = getTurnGeneration(interruptSessionId); + let interruptFailureHandled = false; + const handleInterruptFailure = (detail: string) => { + if (interruptFailureHandled) return; + interruptFailureHandled = true; + restoreTurnWorkingAfterInterruptFailure(interruptSessionId, { + generation: interruptGeneration, + }); + void settleQueuedMessageFailure(explicitMsg, new Error(detail)).catch( + (error: unknown) => + log.error("Force-send failure settlement failed", error) + ); + log.warn("[useQueueDispatch] force-send interrupt failed:", detail); + }; + void cancelTurnForTimelineBoundary(interruptSessionId, "force-send", { + queueSessionId: explicitMsg.sessionId, + onError: handleInterruptFailure, + }).catch((error) => { + handleInterruptFailure( + error instanceof Error ? error.message : String(error) + ); }); } // `stopping` and already-requested interrupts wait for their own @@ -361,79 +970,116 @@ export function useQueueDispatch(): void { } // ── Natural FIFO drain ────────────────────────────────────────────────── + // A Stop-held head is still the FIFO head. A definitive pre-acceptance + // failure is different: its failed bubble remains independently + // retryable/editable, but it never entered provider history and therefore + // must not freeze later natural messages in the same scope. + const naturalHeadIds = new Set(); + const naturalScopes = new Set(); + for (const candidate of candidates) { + if (candidate.priority === "now") continue; + if (candidate.requiresExplicitDispatch && candidate.deliveryError) + continue; + const scopeKey = queuedMessageScopeKey(candidate); + if (naturalScopes.has(scopeKey)) continue; + naturalScopes.add(scopeKey); + naturalHeadIds.add(candidate.id); + } for (const msg of candidates) { if (msg.priority === "now") continue; + if (!naturalHeadIds.has(msg.id)) continue; if (msg.requiresExplicitDispatch) continue; // held by a user Stop - if (getTurnPhase(msg.sessionId) !== "idle") continue; // turn active - const remainingVisibleMs = MIN_QUEUE_VISIBLE_MS - queuedMessageAgeMs(msg); - if (remainingVisibleMs > 0) { - wakeTimerRef.current = window.setTimeout(() => { - wakeTimerRef.current = null; + if (msg.conversationDispatch) { + if (activeCanonicalExecution(msg)) continue; + // Before the queue owns a canonical execution, the visible/source + // Session may still be running its initial or preceding native turn. + // Reuse the ordinary concrete-session FSM gate instead of treating the + // absence of an execution receipt as proof that the root is idle. + if (getTurnPhase(msg.sessionId) !== "idle") continue; + dispatchLockRef.current = true; + inFlightMessageIdRef.current = msg.id; + dispatchCanonicalMessage(msg, () => { + if (inFlightMessageIdRef.current === msg.id) { + inFlightMessageIdRef.current = null; + } + dispatchLockRef.current = false; tryDispatchNextRef.current(); - }, remainingVisibleMs); + }); return; } + const scopeKey = queuedMessageScopeKey(msg); + if (getTurnPhase(scopeKey) !== "idle") continue; // turn active dispatchLockRef.current = true; inFlightMessageIdRef.current = msg.id; // Authoritative gate: the FSM can be forced idle without a real // provider terminal (watchdog / dead-man / rewind). Confirm with the // backend before injecting a natural follow-up into the session. - void getBackendDispatchVerdict(msg.sessionId).then((verdict) => { - if (inFlightMessageIdRef.current !== msg.id) return; - if (verdict === "busy" || verdict === "unknown") { - // Still executing or backend state is unknown — back off and - // re-check. Never infer idle from a failed status read. - inFlightMessageIdRef.current = null; - dispatchLockRef.current = false; - if (wakeTimerRef.current === null) { - wakeTimerRef.current = window.setTimeout(() => { - wakeTimerRef.current = null; - tryDispatchNextRef.current(); - }, QUEUE_BACKEND_RECHECK_MS); + void getBackendDispatchVerdict(msg.sessionId) + .then((verdict) => { + if (inFlightMessageIdRef.current !== msg.id) return; + if (verdict === "busy" || verdict === "unknown") { + // Still executing or backend state is unknown — back off and + // re-check. Never infer idle from a failed status read. + inFlightMessageIdRef.current = null; + dispatchLockRef.current = false; + scheduleWakeAt(Date.now() + QUEUE_BACKEND_RECHECK_MS); + return; } - return; - } - if (verdict === "dead") { - // The session terminated as failed/killed — a natural dispatch - // would be accepted by the IPC layer and then silently swallowed - // (no scheduler turn ever runs in a dead session). Park the - // message visibly instead: it stays in the queue UI flagged for - // explicit dispatch, so the user can Send Now (restart attempt), - // edit it, or move it elsewhere. Never silently drop it. - inFlightMessageIdRef.current = null; - dispatchLockRef.current = false; - store.set(messageQueueAtom, (prev) => - prev.map((item) => - item.id === msg.id - ? { ...item, requiresExplicitDispatch: true } - : item - ) - ); - Message.warning({ - content: `Session has ended — queued message was kept on hold. Use Send Now to dispatch it explicitly.`, - duration: 6000, - }); - tryDispatchNextRef.current(); - return; - } - if (getTurnPhase(msg.sessionId) !== "idle") { - // FSM re-busied while we were checking (a real dispatch won). - inFlightMessageIdRef.current = null; - dispatchLockRef.current = false; - tryDispatchNextRef.current(); - return; - } - dispatchMessage(msg, () => { - if (inFlightMessageIdRef.current === msg.id) { + if (verdict === "dead") { + // The session terminated as failed/killed — a natural dispatch + // would be accepted by the IPC layer and then silently swallowed + // (no scheduler turn ever runs in a dead session). Park the + // message visibly instead: it stays in the queue UI flagged for + // explicit dispatch, so the user can Send Now (restart attempt), + // edit it, or move it elsewhere. Never silently drop it. + inFlightMessageIdRef.current = null; + dispatchLockRef.current = false; + store.set(messageQueueAtom, (prev) => + prev.map((item) => + item.id === msg.id + ? { ...item, requiresExplicitDispatch: true } + : item + ) + ); + Message.warning({ + content: `Session has ended — queued message was kept on hold. Use Send Now to dispatch it explicitly.`, + duration: 6000, + }); + tryDispatchNextRef.current(); + return; + } + if (getTurnPhase(msg.sessionId) !== "idle") { + // FSM re-busied while we were checking (a real dispatch won). inFlightMessageIdRef.current = null; + dispatchLockRef.current = false; + tryDispatchNextRef.current(); + return; } + dispatchMessage(msg, () => { + if (inFlightMessageIdRef.current === msg.id) { + inFlightMessageIdRef.current = null; + } + dispatchLockRef.current = false; + tryDispatchNextRef.current(); + }); + }) + .catch((error: unknown) => { + if (inFlightMessageIdRef.current !== msg.id) return; + inFlightMessageIdRef.current = null; dispatchLockRef.current = false; - tryDispatchNextRef.current(); + log.error("Backend queue verdict handling failed", error); + scheduleWakeAt(Date.now() + QUEUE_BACKEND_RECHECK_MS); }); - }); return; } - }, [dispatchMessage, store]); + }, [ + dispatchCanonicalMessage, + dispatchMessage, + scheduleWakeAt, + settleQueuedMessageFailure, + startRunnableActiveDeliveries, + store, + ]); useEffect(() => { tryDispatchNextRef.current = tryDispatchNext; @@ -447,6 +1093,7 @@ export function useQueueDispatch(): void { if (wakeTimerRef.current !== null) { window.clearTimeout(wakeTimerRef.current); wakeTimerRef.current = null; + wakeAtRef.current = null; } }; }, [store, tryDispatchNext]); diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx index 4164d67983..934383492d 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/index.tsx @@ -254,6 +254,7 @@ export function useSessionLaunch( agentExecMode, effectiveSource, isBackgroundLaunch, + launchAgentDefinitionId: launchParams.agentDefinitionId, launchCliAgentType: launchParams.platform, launchOrgContext: resolvedWorkItemContext ?? undefined, result, diff --git a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts index e50fc4ec1c..c8961f8c4a 100644 --- a/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts +++ b/src/engines/SessionCore/hooks/session/useSessionCreator/useSessionLaunch/launchPayload.ts @@ -306,6 +306,7 @@ export function buildSessionFromLaunchResult(options: { agentExecMode: AgentExecMode; effectiveSource: SessionSource | null; isBackgroundLaunch: boolean; + launchAgentDefinitionId?: string; launchCliAgentType?: SessionLaunchResult["cliAgentType"]; launchOrgContext?: Partial; result: SessionLaunchResult; @@ -314,6 +315,7 @@ export function buildSessionFromLaunchResult(options: { agentExecMode, effectiveSource, isBackgroundLaunch, + launchAgentDefinitionId, launchCliAgentType, launchOrgContext, result, @@ -335,6 +337,9 @@ export function buildSessionFromLaunchResult(options: { | typeof DISPATCH_CATEGORY.CLI_AGENT, model: result.model ?? undefined, cliAgentType: result.cliAgentType ?? launchCliAgentType ?? undefined, + ...(launchAgentDefinitionId + ? { agentDefinitionId: launchAgentDefinitionId } + : {}), agentExecMode, ...(result.agentOrgId ? { agentIconId: AGENT_ORG_ICON_ID, agentOrgId: result.agentOrgId } diff --git a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts index 3cd0007caf..9c4ff42186 100644 --- a/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts +++ b/src/engines/SessionCore/hooks/session/useSessionDiscovery.ts @@ -22,7 +22,10 @@ import type { } from "@src/api/tauri/rpc/schemas/validation"; import { loadSharedLocalKeys } from "@src/hooks/keyVault/sharedLocalKeyStore"; import { createLogger } from "@src/hooks/logger"; -import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; +import { + agentRegistryAtom, + agentRegistryDiscoveryStateAtom, +} from "@src/store/session/agentRegistryAtom"; const log = createLogger("useSessionDiscovery"); @@ -160,6 +163,9 @@ export function useSessionDiscovery( const mountedRef = useRef(true); const setAgentRegistry = useSetAtom(agentRegistryAtom); + const setAgentRegistryDiscoveryState = useSetAtom( + agentRegistryDiscoveryStateAtom + ); useEffect(() => { mountedRef.current = true; @@ -209,6 +215,10 @@ export function useSessionDiscovery( if (!mountedRef.current) return; setLoading(true); setError(null); + // Keep a previously usable registry usable during an explicit refresh. + setAgentRegistryDiscoveryState((current) => + current === "ready" ? current : "loading" + ); try { const [apiProviders, rawAgents, allKeys] = await Promise.all([ @@ -221,6 +231,7 @@ export function useSessionDiscovery( // Populate agentRegistryAtom so useAgentCompatibility stays current setAgentRegistry({ agents: rawAgents, apiProviders }); + setAgentRegistryDiscoveryState("ready"); const mappedProviders = buildProviderInfoList(apiProviders, allKeys); const mappedAgents = mapAgents(rawAgents); @@ -235,11 +246,14 @@ export function useSessionDiscovery( err instanceof Error ? err.message : "Failed to load session data"; log.error("[useSessionDiscovery] Refresh failed:", err); setError(errorMessage); + setAgentRegistryDiscoveryState((current) => + current === "ready" ? current : "error" + ); onError?.(err as Error); } finally { if (mountedRef.current) setLoading(false); } - }, [onSuccess, onError, setAgentRegistry]); + }, [onSuccess, onError, setAgentRegistry, setAgentRegistryDiscoveryState]); // ============================================ // Effects diff --git a/src/engines/SessionCore/ingestion/visibilityFilters.ts b/src/engines/SessionCore/ingestion/visibilityFilters.ts index 5946eacf12..0d35deb6ed 100644 --- a/src/engines/SessionCore/ingestion/visibilityFilters.ts +++ b/src/engines/SessionCore/ingestion/visibilityFilters.ts @@ -13,6 +13,24 @@ */ import type { SessionEvent } from "../core/types"; +const INTERNAL_LIFECYCLE_ACTION_TYPES = new Set([ + "task_start", + "task_completed", + "task_failed", + "stage_error", +]); + +/** + * Internal execution bookkeeping is presentation metadata, not conversation + * history. Keep this predicate shared by chat visibility and native transcript + * projection so a renderer hint can never promote lifecycle rows to tools. + */ +export function isInternalLifecycleEvent( + event: Pick +): boolean { + return INTERNAL_LIFECYCLE_ACTION_TYPES.has(event.actionType); +} + // ============================================ // Visibility Filters // ============================================ @@ -34,12 +52,7 @@ export function isVisibleInChat(event: SessionEvent): boolean { // Hide task lifecycle and stage errors from chat (no UI components). // Mirrors Rust is_visible_in_chat() in derived.rs. - if ( - event.actionType === "task_start" || - event.actionType === "task_completed" || - event.actionType === "task_failed" || - event.actionType === "stage_error" - ) { + if (isInternalLifecycleEvent(event)) { return false; } diff --git a/src/engines/SessionCore/services/SessionService.ts b/src/engines/SessionCore/services/SessionService.ts index e6f89dbe18..06b8c28481 100644 --- a/src/engines/SessionCore/services/SessionService.ts +++ b/src/engines/SessionCore/services/SessionService.ts @@ -27,10 +27,6 @@ import { import { rpc } from "@src/api/tauri/rpc"; import { ROUTES } from "@src/config/routes"; import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; -import { - buildPendingForkHandoff, - markForkHandoffConsumed, -} from "@src/features/TeamCollaboration/forkSession"; import { createLogger } from "@src/hooks/logger"; import { navigateApp } from "@src/router/navigateApp"; import { collectAdeContext } from "@src/services/context/collectors"; @@ -303,6 +299,7 @@ export const SessionService = { turnIntentId, turnIntentSource, directUserIntent, + allowNativeContextRecovery, } = params; // Gate ADE context on the session row's persisted repo so a session // on repo A doesn't ship repo B's editor / git / LSP state when the @@ -321,38 +318,11 @@ export const SessionService = { ); } - // Fork relay (design §16.11): the FIRST real message sent to a forked - // session carries a bounded digest of the inherited teammate history, - // because the agent's LLM context is rebuilt from `agent_messages` — - // which a fork starts without. `displayText` keeps the user's own words - // in the transcript; the marker is consumed only after the send - // succeeds, so a failed send retries with the handoff intact. No-op for - // every non-forked session (durable one-shot marker, armed at fork time). - let effectiveContent = content; - let effectiveDisplayText = displayText; - let forkHandoffArmed = false; - if (!isResume) { - try { - const forkHandoff = await buildPendingForkHandoff(sessionId, content); - if (forkHandoff) { - effectiveContent = forkHandoff.content; - effectiveDisplayText = displayText ?? forkHandoff.displayText; - forkHandoffArmed = true; - } - } catch (handoffError) { - // Handoff assembly must never block a send — the fork still works, - // just without inherited context on this turn. - logger.warn( - `Fork handoff assembly failed for ${sessionId}: ${String(handoffError)}` - ); - } - } - try { await adapter.sendMessage({ sessionId, - content: effectiveContent, - displayText: effectiveDisplayText, + content, + displayText, model: model || undefined, accountId: accountId || undefined, mode: mode || undefined, @@ -362,12 +332,10 @@ export const SessionService = { turnIntentId, turnIntentSource, directUserIntent, + allowNativeContextRecovery, adeContext, sessionRepoPath: sessionRow?.repoPath ?? null, }); - if (forkHandoffArmed) { - markForkHandoffConsumed(sessionId); - } // Float the row to the top of "today" in the sidebar without // waiting for the next session list refresh. The backend will // emit its own fresh `updated_at` on the next `loadSessions`, diff --git a/src/engines/SessionCore/services/optimisticOutgoingDelivery.test.ts b/src/engines/SessionCore/services/optimisticOutgoingDelivery.test.ts new file mode 100644 index 0000000000..8ddd81f200 --- /dev/null +++ b/src/engines/SessionCore/services/optimisticOutgoingDelivery.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { deliverOptimisticOutgoing } from "./optimisticOutgoingDelivery"; + +describe("deliverOptimisticOutgoing", () => { + it("keeps an accepted transport result when projection diagnostics throw", async () => { + const send = vi.fn(async () => "accepted"); + const reporterError = new Error("reporter failed"); + + await expect( + deliverOptimisticOutgoing({ + send, + markSent: async () => { + throw new Error("sent projection failed"); + }, + markFailed: vi.fn(), + onProjectionError: async () => { + throw reporterError; + }, + }) + ).resolves.toBe("accepted"); + expect(send).toHaveBeenCalledOnce(); + }); + + it("keeps the original transport rejection when projection diagnostics throw", async () => { + const transportError = new Error("transport failed"); + const send = vi.fn(async () => { + throw transportError; + }); + + await expect( + deliverOptimisticOutgoing({ + send, + markSent: vi.fn(), + markFailed: async () => { + throw new Error("failed projection failed"); + }, + onProjectionError: async () => { + throw new Error("reporter failed"); + }, + }) + ).rejects.toBe(transportError); + expect(send).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/engines/SessionCore/services/optimisticOutgoingDelivery.ts b/src/engines/SessionCore/services/optimisticOutgoingDelivery.ts new file mode 100644 index 0000000000..f245086401 --- /dev/null +++ b/src/engines/SessionCore/services/optimisticOutgoingDelivery.ts @@ -0,0 +1,49 @@ +/** + * Transport-neutral pending -> sent/failed boundary for optimistic messages. + * + * The owning repository decides how a row is stored and projected. This + * coordinator only guarantees that a transport rejection patches the same + * optimistic row as failed instead of retracting it or restoring the draft. + */ +export async function deliverOptimisticOutgoing(params: { + send: () => Promise; + markSent: (result: TResult) => void | Promise; + markFailed: (error: unknown) => void | Promise; + onProjectionError?: ( + phase: "sent" | "failed", + error: unknown + ) => void | Promise; +}): Promise { + const reportProjectionError = async ( + phase: "sent" | "failed", + error: unknown + ): Promise => { + try { + await params.onProjectionError?.(phase, error); + } catch { + // Diagnostics are best-effort. An error reporter must never replace the + // transport rejection or turn an already-accepted delivery into a retry. + } + }; + let result: TResult; + try { + result = await params.send(); + } catch (error) { + try { + await params.markFailed(error); + } catch (projectionError) { + await reportProjectionError("failed", projectionError); + } + throw error; + } + // The transport has accepted the message. A local projection failure from + // this point onward must not be reclassified as a send failure: canonical + // reconciliation/refresh can still repair the optimistic row, whereas a + // retry would duplicate an already-accepted user intent. + try { + await params.markSent(result); + } catch (projectionError) { + await reportProjectionError("sent", projectionError); + } + return result; +} diff --git a/src/engines/SessionCore/services/types.ts b/src/engines/SessionCore/services/types.ts index 7c7a8ee0c9..a722844679 100644 --- a/src/engines/SessionCore/services/types.ts +++ b/src/engines/SessionCore/services/types.ts @@ -114,6 +114,14 @@ export interface SessionSendMessageParams { * adapters apply it immediately after their command accepts the rerun. */ directUserIntent?: boolean; + /** + * Permission for guarded provider-native context recovery. This never + * triggers compaction by itself: the transport still requires an explicit + * context-exhausted terminal with no assistant/tool output, and retries the + * user turn at most once. Canonical continuation enables it only after the + * target episode is synchronized or freshly materialized. + */ + allowNativeContextRecovery?: boolean; /** * When `true`, this is a user-initiated Resume after a failed turn. * Backend runs deletion-based orphan tool-use filter. diff --git a/src/engines/SessionCore/services/userIntentDispatch.test.ts b/src/engines/SessionCore/services/userIntentDispatch.test.ts new file mode 100644 index 0000000000..038b0400d5 --- /dev/null +++ b/src/engines/SessionCore/services/userIntentDispatch.test.ts @@ -0,0 +1,687 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; + +import { + adoptAcceptedUserIntent, + appendOptimisticQueueUserDelivery, + confirmUserIntentPreparation, + dispatchUserIntent, + optimisticQueueUserEventId, + prepareUserIntent, + removeOptimisticQueueUserDelivery, + setOptimisticQueueUserDelivery, + settleUserIntentLifecycle, +} from "./userIntentDispatch"; + +const mocks = vi.hoisted(() => { + const atomValues = new Map(); + const store = { + get: vi.fn((atom: unknown) => atomValues.get(atom)), + set: vi.fn((atom: unknown, update: unknown) => { + const previous = atomValues.get(atom); + atomValues.set( + atom, + typeof update === "function" + ? (update as (value: unknown) => unknown)(previous) + : update + ); + }), + }; + return { + atomValues, + store, + append: vi.fn(), + getEvents: vi.fn(), + removeByIdPrefix: vi.fn(), + updateById: vi.fn(), + upsert: vi.fn(), + sendMessage: vi.fn(), + beginOptimisticTurn: vi.fn(), + failOptimisticTurn: vi.fn(), + beginTurnDispatch: vi.fn(), + getTurnGeneration: vi.fn(), + getTurnPhase: vi.fn(), + confirmTurnRunning: vi.fn(), + markTurnTerminal: vi.fn(), + markSessionActive: vi.fn(), + publishTurnIntentDispatch: vi.fn(), + createSyntheticUserEvent: vi.fn(), + logError: vi.fn(), + }; +}); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + append: mocks.append, + getEvents: mocks.getEvents, + removeByIdPrefix: mocks.removeByIdPrefix, + updateById: mocks.updateById, + upsert: mocks.upsert, + }, +})); + +vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ + SessionService: { sendMessage: mocks.sendMessage }, +})); + +vi.mock("@src/engines/SessionCore/control/optimisticTurnStatus", () => ({ + beginOptimisticTurn: mocks.beginOptimisticTurn, + failOptimisticTurn: mocks.failOptimisticTurn, +})); + +vi.mock("@src/engines/SessionCore/control/turnLifecycle", () => ({ + beginTurnDispatch: mocks.beginTurnDispatch, + getTurnGeneration: mocks.getTurnGeneration, + getTurnPhase: mocks.getTurnPhase, + confirmTurnRunning: mocks.confirmTurnRunning, + markTurnTerminal: mocks.markTurnTerminal, +})); + +vi.mock("@src/engines/SessionCore/control/turnIntentDispatchLifecycle", () => ({ + publishTurnIntentDispatch: mocks.publishTurnIntentDispatch, +})); + +vi.mock("@src/store/session", () => ({ + markSessionActive: mocks.markSessionActive, +})); + +vi.mock("@src/util/session/sessionDispatch", () => ({ + isCursorIdeSession: () => false, +})); + +vi.mock("@src/engines/SessionCore/sync/adapters/shared/eventFactories", () => ({ + createSyntheticUserEvent: mocks.createSyntheticUserEvent, +})); + +vi.mock("@src/util/core/state/instrumentedStore", () => ({ + isStoreInitialized: () => false, + getInstrumentedStore: () => mocks.store, +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ error: mocks.logError }), +})); + +function syntheticEvent(sessionId: string, id: string) { + return { + id, + chunk_id: id, + sessionId, + createdAt: "2026-08-30T00:00:00.000Z", + functionName: "user_message", + uiCanonical: "user_message", + actionType: "raw", + args: {}, + result: {}, + source: "user", + displayText: "hello", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as const; +} + +describe("userIntentDispatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.atomValues.clear(); + mocks.beginTurnDispatch.mockReturnValue(7); + mocks.getTurnGeneration.mockReset().mockReturnValue(7); + mocks.getTurnPhase.mockReset().mockReturnValue("dispatching"); + mocks.append.mockResolvedValue(undefined); + mocks.getEvents.mockResolvedValue([]); + mocks.removeByIdPrefix.mockResolvedValue(1); + mocks.updateById.mockResolvedValue(true); + mocks.upsert.mockReset().mockResolvedValue(undefined); + mocks.sendMessage.mockResolvedValue(undefined); + mocks.createSyntheticUserEvent.mockImplementation((sessionId: string) => + syntheticEvent(sessionId, `user-${sessionId}`) + ); + }); + + it("prepares the existing queue row when the root is also the execution session", async () => { + const actual = await vi.importActual< + typeof import("@src/engines/SessionCore/sync/adapters/shared/eventFactories") + >("@src/engines/SessionCore/sync/adapters/shared/eventFactories"); + mocks.createSyntheticUserEvent.mockImplementation( + actual.createSyntheticUserEvent + ); + const params = { + sessionId: "cliagent-root", + visibleText: "continue in the original runtime", + turnIntentId: "intent-root-return", + queueMessageId: "queue-root-return", + }; + const queued = await appendOptimisticQueueUserDelivery(params); + const preparation = await prepareUserIntent({ + ...params, + userEventId: queued.id, + }); + + // Rust reconciles user rows by durable intent before inserting them. A + // second synthetic id would be discarded, leaving delivery updates aimed + // at a row that never existed. Both producers must use the queue identity. + expect(preparation.userEvent.id).toBe(queued.id); + await dispatchUserIntent({ + ...params, + preparation, + send: { + content: params.visibleText, + turnIntentId: params.turnIntentId, + turnIntentSource: "user_submit", + }, + }); + expect(mocks.updateById).toHaveBeenCalledWith( + queued.id, + expect.objectContaining({ + result: expect.objectContaining({ deliveryStatus: "sent" }), + }), + params.sessionId + ); + }); + + it("keeps one queue-owned EventStore row through pending, failed, and cleanup", async () => { + const actual = await vi.importActual< + typeof import("@src/engines/SessionCore/sync/adapters/shared/eventFactories") + >("@src/engines/SessionCore/sync/adapters/shared/eventFactories"); + mocks.createSyntheticUserEvent.mockImplementation( + actual.createSyntheticUserEvent + ); + const params = { + sessionId: "imported-session", + visibleText: "@teammate inspect this", + imageDataUrls: ["data:image/png;base64,a"], + turnIntentId: "intent-canonical", + queueMessageId: "queue-canonical", + createdAt: "2026-08-30T01:02:03.000Z", + }; + + const pending = await appendOptimisticQueueUserDelivery(params); + expect(pending).toMatchObject({ + id: optimisticQueueUserEventId("queue-canonical"), + displayText: "@teammate inspect this", + result: { + images: ["data:image/png;base64,a"], + deliveryStatus: "pending", + turnIntentId: "intent-canonical", + queueMessageId: "queue-canonical", + }, + }); + expect(mocks.append).toHaveBeenCalledWith([pending], "imported-session"); + + await expect( + setOptimisticQueueUserDelivery(params, "failed", new Error("offline")) + ).resolves.toBe(true); + expect(mocks.updateById).toHaveBeenCalledWith( + optimisticQueueUserEventId("queue-canonical"), + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + message: { content: "@teammate inspect this", role: "user" }, + images: ["data:image/png;base64,a"], + deliveryStatus: "failed", + deliveryError: "offline", + }), + }), + "imported-session" + ); + + await expect( + setOptimisticQueueUserDelivery( + { ...params, turnIntentId: "intent-retry" }, + "pending" + ) + ).resolves.toBe(true); + await expect( + setOptimisticQueueUserDelivery( + { ...params, turnIntentId: "intent-retry" }, + "sent" + ) + ).resolves.toBe(true); + expect(mocks.append).toHaveBeenCalledTimes(1); + expect(mocks.updateById).toHaveBeenNthCalledWith( + 2, + optimisticQueueUserEventId("queue-canonical"), + expect.objectContaining({ + displayText: "@teammate inspect this", + displayStatus: "pending", + result: expect.objectContaining({ + images: ["data:image/png;base64,a"], + deliveryStatus: "pending", + turnIntentId: "intent-retry", + }), + }), + "imported-session" + ); + expect(mocks.updateById).toHaveBeenNthCalledWith( + 3, + optimisticQueueUserEventId("queue-canonical"), + expect.objectContaining({ + displayText: "@teammate inspect this", + displayStatus: "completed", + result: expect.objectContaining({ + images: ["data:image/png;base64,a"], + deliveryStatus: "sent", + turnIntentId: "intent-retry", + }), + }), + "imported-session" + ); + + // Normal pending/sent/failed projection misses do not invent a new row. + mocks.updateById.mockResolvedValue(false); + for (const status of ["pending", "sent", "failed"] as const) { + await expect( + setOptimisticQueueUserDelivery(params, status) + ).resolves.toBe(false); + } + expect(mocks.upsert).not.toHaveBeenCalled(); + + // Only a terminal accepted owner can transfer its complete retry payload + // to the same stable failed row after native reconciliation removed it. + await expect( + setOptimisticQueueUserDelivery(params, "failed", "terminal verdict", { + ownerRetired: true, + }) + ).resolves.toBe(true); + expect(mocks.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + id: optimisticQueueUserEventId(params.queueMessageId), + sessionId: params.sessionId, + displayText: params.visibleText, + displayStatus: "failed", + result: expect.objectContaining({ + images: params.imageDataUrls, + queueMessageId: params.queueMessageId, + turnIntentId: params.turnIntentId, + deliveryStatus: "failed", + deliveryError: "terminal verdict", + deliveryOwnerRetired: true, + }), + }), + params.sessionId + ); + mocks.upsert.mockRejectedValueOnce(new Error("durable projection failed")); + await expect( + setOptimisticQueueUserDelivery(params, "failed", "terminal verdict", { + ownerRetired: true, + }) + ).rejects.toThrow("durable projection failed"); + + await removeOptimisticQueueUserDelivery(params); + expect(mocks.removeByIdPrefix).toHaveBeenCalledWith( + optimisticQueueUserEventId("queue-canonical"), + "imported-session" + ); + }); + + it("adopts an accepted turn through the shared intent/generation mapping", () => { + const adopted = adoptAcceptedUserIntent({ + sessionId: "cliagent-recovered", + turnIntentId: "intent-recovered", + runtimeStatusSource: "dispatch", + }); + + expect(adopted).toEqual({ + sessionId: "cliagent-recovered", + turnIntentId: "intent-recovered", + generation: 7, + runtimeStatusSource: "dispatch", + }); + expect(mocks.beginTurnDispatch).toHaveBeenCalledOnce(); + expect(mocks.publishTurnIntentDispatch).toHaveBeenCalledWith( + "intent-recovered", + { sessionId: "cliagent-recovered", generation: 7 } + ); + expect(mocks.beginOptimisticTurn).toHaveBeenCalledWith( + "cliagent-recovered", + "dispatch" + ); + expect(mocks.confirmTurnRunning).toHaveBeenCalledWith("cliagent-recovered"); + + settleUserIntentLifecycle(adopted, "completed"); + expect(mocks.markTurnTerminal).toHaveBeenCalledWith( + "cliagent-recovered", + "completed", + { generation: 7 } + ); + }); + + it("owns the complete direct-dispatch lifecycle and exact send payload", async () => { + const result = await dispatchUserIntent({ + sessionId: "cliagent-1", + visibleText: "visible", + imageDataUrls: ["data:image/png;base64,a"], + runtimeStatusSource: "dispatch", + send: { + content: "agent-facing", + displayText: "visible", + model: "gpt-5.6-sol", + accountId: "account-1", + mode: "build", + clientMessageId: "direct:1", + turnIntentId: "intent-1", + turnIntentSource: "user_submit", + directUserIntent: true, + allowNativeContextRecovery: true, + }, + }); + + expect(result.preparation).toMatchObject({ + sessionId: "cliagent-1", + generation: 7, + userEvent: { id: "user-cliagent-1" }, + }); + expect(mocks.publishTurnIntentDispatch).toHaveBeenCalledWith("intent-1", { + sessionId: "cliagent-1", + generation: 7, + }); + expect(mocks.beginOptimisticTurn).toHaveBeenCalledWith( + "cliagent-1", + "dispatch" + ); + expect(mocks.append).toHaveBeenCalledWith( + [expect.objectContaining({ id: "user-cliagent-1" })], + "cliagent-1" + ); + expect(mocks.sendMessage).toHaveBeenCalledWith({ + sessionId: "cliagent-1", + content: "agent-facing", + displayText: "visible", + model: "gpt-5.6-sol", + accountId: "account-1", + mode: "build", + imageDataUrls: ["data:image/png;base64,a"], + clientMessageId: "direct:1", + turnIntentId: "intent-1", + turnIntentSource: "user_submit", + directUserIntent: true, + allowNativeContextRecovery: true, + }); + expect(mocks.confirmTurnRunning).toHaveBeenCalledWith("cliagent-1"); + expect(mocks.updateById).toHaveBeenCalledWith( + "user-cliagent-1", + expect.objectContaining({ + displayStatus: "completed", + result: expect.objectContaining({ deliveryStatus: "sent" }), + }), + "cliagent-1" + ); + expect(mocks.beginOptimisticTurn.mock.invocationCallOrder[0]).toBeLessThan( + mocks.append.mock.invocationCallOrder[0] + ); + expect(mocks.append.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendMessage.mock.invocationCallOrder[0] + ); + }); + + it("keeps the exact synthetic row failed when send fails", async () => { + mocks.sendMessage.mockRejectedValueOnce(new Error("send failed")); + + await expect( + dispatchUserIntent({ + sessionId: "agentsession-1", + visibleText: "hello", + runtimeStatusSource: "launch", + send: { + content: "hello", + turnIntentId: "intent-failed", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).rejects.toThrow("send failed"); + + expect(mocks.failOptimisticTurn).toHaveBeenCalledWith( + "agentsession-1", + "launch" + ); + expect(mocks.markTurnTerminal).toHaveBeenCalledWith( + "agentsession-1", + "failed", + { generation: 7 } + ); + expect(mocks.updateById).toHaveBeenCalledWith( + "user-agentsession-1", + expect.objectContaining({ + displayStatus: "failed", + result: expect.objectContaining({ + deliveryStatus: "failed", + deliveryError: "send failed", + }), + }), + "agentsession-1" + ); + }); + + it("diagnoses a missing accepted-row projection without resending transport", async () => { + mocks.updateById.mockResolvedValueOnce(false); + + await expect( + dispatchUserIntent({ + sessionId: "cliagent-projection-missing", + visibleText: "hello", + send: { + content: "hello", + turnIntentId: "intent-projection-missing", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).resolves.toMatchObject({ + userEvent: { + result: expect.objectContaining({ deliveryStatus: "sent" }), + }, + }); + + expect(mocks.sendMessage).toHaveBeenCalledOnce(); + expect(mocks.logError).toHaveBeenCalledWith( + "Failed to project sent delivery for cliagent-projection-missing", + expect.objectContaining({ + message: expect.stringContaining("optimistic user event"), + }) + ); + }); + + it("accepts an authoritative user replacement for the same sent turn", async () => { + mocks.updateById.mockResolvedValueOnce(false); + mocks.getEvents.mockResolvedValueOnce([ + { + ...syntheticEvent("agent-authoritative", "authoritative-user"), + result: { + backendPersisted: true, + turnIntentId: "intent-authoritative", + }, + }, + ]); + + await expect( + dispatchUserIntent({ + sessionId: "agent-authoritative", + visibleText: "hello", + send: { + content: "hello", + turnIntentId: "intent-authoritative", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).resolves.toMatchObject({ + userEvent: { + result: expect.objectContaining({ deliveryStatus: "sent" }), + }, + }); + + expect(mocks.sendMessage).toHaveBeenCalledOnce(); + expect(mocks.getEvents).toHaveBeenCalledWith("agent-authoritative"); + expect(mocks.logError).not.toHaveBeenCalled(); + }); + + it("does not use an authoritative sent row to hide a failed projection", async () => { + const transportError = new Error("send failed once"); + mocks.sendMessage.mockRejectedValueOnce(transportError); + mocks.updateById.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mocks.getEvents.mockResolvedValueOnce([ + { + ...syntheticEvent("agent-failed", "authoritative-user"), + result: { + backendPersisted: true, + turnIntentId: "intent-failed-authoritative", + }, + }, + ]); + + await expect( + dispatchUserIntent({ + sessionId: "agent-failed", + visibleText: "hello", + send: { + content: "hello", + turnIntentId: "intent-failed-authoritative", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).rejects.toThrow("send failed once"); + + expect(mocks.sendMessage).toHaveBeenCalledOnce(); + expect(mocks.getEvents).not.toHaveBeenCalled(); + expect(mocks.logError).toHaveBeenCalledWith( + "Failed to project failed delivery for agent-failed", + expect.objectContaining({ + message: expect.stringContaining("optimistic user event"), + }) + ); + }); + + it("diagnoses a rejected failed-row projection without retrying transport", async () => { + const transportError = new Error("send failed once"); + const projectionError = new Error("event store unavailable"); + mocks.sendMessage.mockRejectedValueOnce(transportError); + mocks.updateById.mockRejectedValueOnce(projectionError); + + await expect( + dispatchUserIntent({ + sessionId: "cliagent-projection-rejected", + visibleText: "hello", + send: { + content: "hello", + turnIntentId: "intent-projection-rejected", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).rejects.toThrow("send failed once"); + + expect(mocks.sendMessage).toHaveBeenCalledOnce(); + expect(mocks.logError).toHaveBeenCalledWith( + "Failed to project failed delivery for cliagent-projection-rejected", + projectionError + ); + }); + + it("prepares once, reuses the same row/generation, and supports early working state", async () => { + mocks.atomValues.set(sessionIdAtom, "cliagent-1"); + const preparation = await prepareUserIntent({ + sessionId: "cliagent-1", + visibleText: "hello", + turnIntentId: "intent-prepared", + runtimeStatusSource: "launch", + }); + + confirmUserIntentPreparation(preparation); + const result = await dispatchUserIntent({ + sessionId: "cliagent-1", + visibleText: "hello", + preparation, + send: { + content: "hello", + turnIntentId: "intent-prepared", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }); + + expect(result.preparation).toBe(preparation); + expect(mocks.beginTurnDispatch).toHaveBeenCalledTimes(1); + expect(mocks.createSyntheticUserEvent).toHaveBeenCalledTimes(1); + // Adoption is idempotently re-appended after transcript synchronization. + expect(mocks.append).toHaveBeenCalledTimes(2); + expect(mocks.confirmTurnRunning).toHaveBeenCalledTimes(2); + }); + + it.each(["stopped", "superseded"])( + "does not send a %s prepared native turn", + async (reason) => { + const lifecycle = await vi.importActual< + typeof import("@src/engines/SessionCore/control/turnLifecycle") + >("@src/engines/SessionCore/control/turnLifecycle"); + const sessionId = "cliagent-stop-preparation"; + mocks.beginTurnDispatch.mockImplementationOnce( + lifecycle.beginTurnDispatch + ); + mocks.getTurnGeneration.mockImplementation(lifecycle.getTurnGeneration); + mocks.getTurnPhase.mockImplementation(lifecycle.getTurnPhase); + try { + const preparation = await prepareUserIntent({ + sessionId, + visibleText: "hello", + turnIntentId: "intent-stop", + }); + lifecycle.beginTurnStopping(sessionId); + expect(lifecycle.getTurnPhase(sessionId)).toBe("stopping"); + if (reason === "superseded") lifecycle.beginTurnDispatch(sessionId); + await expect( + dispatchUserIntent({ + sessionId, + visibleText: "hello", + preparation, + send: { + content: "hello", + turnIntentId: "intent-stop", + turnIntentSource: "user_submit", + }, + }) + ).rejects.toThrow(); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + if (reason === "superseded") { + expect(mocks.failOptimisticTurn).not.toHaveBeenCalled(); + expect(mocks.markTurnTerminal).not.toHaveBeenCalled(); + } + } finally { + lifecycle.clearTurnLifecycleSession(sessionId); + } + } + ); + + it("rejects a preparation from a different concrete session", async () => { + const source = await prepareUserIntent({ + sessionId: "cliagent-source", + visibleText: "hello", + turnIntentId: "intent-transfer", + runtimeStatusSource: "launch", + }); + await expect( + dispatchUserIntent({ + sessionId: "cliagent-target", + visibleText: "hello", + preparation: source, + send: { + content: "hello", + turnIntentId: "intent-transfer", + turnIntentSource: "user_submit", + directUserIntent: true, + }, + }) + ).rejects.toThrow("prepared user intent does not match this dispatch"); + + expect(mocks.markTurnTerminal).toHaveBeenCalledWith( + "cliagent-source", + "failed", + { generation: 7 } + ); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/services/userIntentDispatch.ts b/src/engines/SessionCore/services/userIntentDispatch.ts new file mode 100644 index 0000000000..ab5601abdb --- /dev/null +++ b/src/engines/SessionCore/services/userIntentDispatch.ts @@ -0,0 +1,559 @@ +/** + * One non-React direct user-intent dispatch boundary. + * + * UI hooks still decide whether a prompt queues, how duplicate clicks are + * suppressed, and which runtime/model/account to use. Once a concrete Session + * is ready, every direct path comes through this module so the synthetic user + * row, turn generation, optimistic footer, backend acceptance, and rollback + * cannot drift between ordinary sends and conversation continuations. + */ +import { + beginOptimisticTurn, + failOptimisticTurn, +} from "@src/engines/SessionCore/control/optimisticTurnStatus"; +import { publishTurnIntentDispatch } from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; +import { + type TurnTerminalStatus, + beginTurnDispatch, + confirmTurnRunning, + getTurnGeneration, + getTurnPhase, + markTurnTerminal, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { deliverOptimisticOutgoing } from "@src/engines/SessionCore/services/optimisticOutgoingDelivery"; +import type { SessionSendMessageParams } from "@src/engines/SessionCore/services/types"; +import { createSyntheticUserEvent } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { + isSyntheticUserInputEvent, + turnIntentIdOf, +} from "@src/engines/SessionCore/sync/utils/activityIds"; +import { createLogger } from "@src/hooks/logger"; +import { markSessionActive } from "@src/store/session"; +import { + type SessionRuntimeStatusSource, + setSessionRuntimeStatusAtom, +} from "@src/store/session/cliSessionStatusAtom"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; + +const log = createLogger("UserIntentDispatch"); +const OPTIMISTIC_QUEUE_USER_EVENT_ID_PREFIX = "queued-user:"; + +export interface UserIntentPreparation { + sessionId: string; + userEvent: SessionEvent; + generation: number; + turnIntentId: string; + runtimeStatusSource: SessionRuntimeStatusSource; +} + +/** + * Lifecycle handle for a provider turn that was already accepted before this + * renderer attached to it. Unlike UserIntentPreparation it owns no synthetic + * user row: the durable provider/canonical transcript already owns that row. + */ +export interface AdoptedUserIntent { + sessionId: string; + generation: number; + turnIntentId: string; + runtimeStatusSource: SessionRuntimeStatusSource; +} + +interface PrepareUserIntentParams { + sessionId: string; + visibleText: string; + imageDataUrls?: string[]; + turnIntentId: string; + runtimeStatusSource?: SessionRuntimeStatusSource; + /** Preserve the durable queue identity on a newly created optimistic row. */ + queueMessageId?: string; + /** Reuse an existing optimistic projection owned by this exact Session. */ + userEventId?: string; + /** Runs after the synchronous lifecycle reserve and before EventStore I/O. */ + beforeAppend?: () => void | Promise; +} + +export interface OptimisticUserDeliveryProjectionParams { + sessionId: string; + visibleText: string; + imageDataUrls?: string[]; + turnIntentId: string; + queueMessageId: string; + createdAt?: string; +} + +type UserIntentSendParams = Omit< + SessionSendMessageParams, + "sessionId" | "imageDataUrls" | "turnIntentId" +> & { + turnIntentId: string; +}; + +export interface DispatchUserIntentParams extends Omit< + PrepareUserIntentParams, + "turnIntentId" +> { + preparation?: UserIntentPreparation; + send: UserIntentSendParams; +} + +export interface DispatchUserIntentResult { + preparation: UserIntentPreparation; + userEvent: SessionEvent; +} + +/** + * A dispatch attempt failed after the optimistic row was durably appended. + * Queue callers use this boundary to transfer retry ownership from the queue + * card to the visible failed transcript row. Preparation/storage failures do + * not use this error because the queue row is still the only durable copy. + */ +export class UserIntentSendError extends Error { + readonly cause: unknown; + readonly userEventId: string; + + constructor(error: unknown, userEventId: string) { + super( + error instanceof Error + ? error.message + : error == null + ? "Failed to send message" + : String(error) + ); + this.name = "UserIntentSendError"; + this.cause = error; + this.userEventId = userEventId; + } +} + +export function isUserIntentSendError( + error: unknown +): error is UserIntentSendError { + return error instanceof UserIntentSendError; +} + +type UserIntentPreparationState = "prepared" | "accepted" | "failed"; +const preparationStates = new WeakMap< + UserIntentPreparation, + UserIntentPreparationState +>(); + +function deliveryEvent( + event: SessionEvent, + status: "pending" | "sent" | "failed", + error?: unknown +): SessionEvent { + const reason = + status === "failed" + ? error instanceof Error + ? error.message + : error == null + ? "Failed to send message" + : String(error) + : undefined; + return { + ...event, + displayStatus: + status === "pending" + ? "pending" + : status === "failed" + ? "failed" + : "completed", + result: { + ...event.result, + deliveryStatus: status, + ...(reason ? { deliveryError: reason } : {}), + }, + }; +} + +/** + * Stable EventStore identity for the queue-owned optimistic transcript row. + * + * The queue id, rather than message text or turn id, distinguishes a retry + * from the failed row it supersedes. The turn id still reconciles this row + * with the provider/native echo once that authoritative event arrives. + */ +export function optimisticQueueUserEventId(queueMessageId: string): string { + // The terminal delimiter makes removeByIdPrefix an exact lookup for this + // queue row: another queue id cannot extend this complete prefix. + return `${OPTIMISTIC_QUEUE_USER_EVENT_ID_PREFIX}${queueMessageId}:`; +} + +/** Whether an EventStore row is owned by the canonical queue projection. */ +export function isOptimisticQueueUserEventId(eventId: string): boolean { + return ( + eventId.startsWith(OPTIMISTIC_QUEUE_USER_EVENT_ID_PREFIX) && + eventId.endsWith(":") + ); +} + +export interface OptimisticUserDeliveryOptions { + /** + * The durable delivery owner was retired after a terminal provider/Cloud + * verdict. The failed row is then the only remaining owner of the intent, + * so an explicit retry must mint a fresh submission instead of waiting for + * a queue row that will never return. + */ + ownerRetired?: boolean; +} + +function optimisticQueueUserEvent( + params: OptimisticUserDeliveryProjectionParams, + status: "pending" | "sent" | "failed", + error?: unknown, + options?: OptimisticUserDeliveryOptions +): SessionEvent { + const reason = + status === "failed" + ? error instanceof Error + ? error.message + : error == null + ? "Failed to send message" + : String(error) + : undefined; + return createSyntheticUserEvent(params.sessionId, params.visibleText, { + id: optimisticQueueUserEventId(params.queueMessageId), + createdAt: params.createdAt, + imageDataUrls: params.imageDataUrls, + turnIntentId: params.turnIntentId, + deliveryStatus: status, + deliveryError: reason, + queueMessageId: params.queueMessageId, + ...(options?.ownerRetired ? { deliveryOwnerRetired: true } : {}), + }); +} + +/** + * Persist the canonical queue's visible user row before handing the turn to + * any provider/materializer. This is an EventStore projection only; the + * existing durable message queue remains the sole dispatch authority. + */ +export async function appendOptimisticQueueUserDelivery( + params: OptimisticUserDeliveryProjectionParams +): Promise { + const event = optimisticQueueUserEvent(params, "pending"); + await eventStoreProxy.append([event], params.sessionId); + return event; +} + +/** Patch the exact queue-owned EventStore row in place. */ +export async function setOptimisticQueueUserDelivery( + params: OptimisticUserDeliveryProjectionParams, + status: "pending" | "sent" | "failed", + error?: unknown, + options?: OptimisticUserDeliveryOptions +): Promise { + const event = optimisticQueueUserEvent(params, status, error, options); + const updated = await eventStoreProxy.updateById( + event.id, + { + // Retry/edit-resend keeps the stable queue-owned row id. Patch the + // complete user-facing payload as well as its delivery state so the same + // bubble can move failed -> pending -> sent without an append/remove + // cycle or stale text/attachments. + displayText: event.displayText, + displayStatus: event.displayStatus, + result: event.result, + }, + params.sessionId + ); + if (!updated && status === "failed" && options?.ownerRetired) { + // A terminal native reconciliation may have replaced the optimistic id. + // The caller still owns the durable accepted delivery and its complete + // payload. Restore that SAME projection before retiring the delivery; + // otherwise Retry has neither a visible failure nor a durable owner. + // Upsert is idempotent if another projection restored it concurrently. + await eventStoreProxy.upsert(event, params.sessionId); + return true; + } + return updated; +} + +/** Remove only an admission attempt that never entered the durable queue. */ +export async function removeOptimisticQueueUserDelivery( + params: Pick< + OptimisticUserDeliveryProjectionParams, + "sessionId" | "queueMessageId" + > +): Promise { + await eventStoreProxy.removeByIdPrefix( + optimisticQueueUserEventId(params.queueMessageId), + params.sessionId + ); +} + +async function setUserIntentDelivery( + preparation: UserIntentPreparation, + status: "pending" | "sent" | "failed", + error?: unknown +): Promise { + const next = deliveryEvent(preparation.userEvent, status, error); + preparation.userEvent = next; + const updated = await eventStoreProxy.updateById( + next.id, + { displayStatus: next.displayStatus, result: next.result }, + preparation.sessionId + ); + if (updated) return; + + // Rust Agent can publish its authoritative user row between transport + // acceptance and this sent-state projection. EventStore intentionally + // replaces the optimistic row while preserving the durable turn intent, so + // the old synthetic id is no longer patchable. Treat only that exact + // accepted replacement as settled; failed delivery must retain its visible + // failure owner and an unrelated user row is not evidence for this turn. + if (status === "sent") { + // Read the resident EventStore that just rejected the old id. The SQLite + // cache is write-batched and can legitimately lag this replacement. + const events = await eventStoreProxy.getEvents(preparation.sessionId); + const authoritativeUserSettled = events.some( + (event) => + event.source === "user" && + !isSyntheticUserInputEvent(event) && + turnIntentIdOf(event) === preparation.turnIntentId + ); + if (authoritativeUserSettled) return; + } + + throw new Error( + `optimistic user event ${next.id} is missing from ${preparation.sessionId}` + ); +} + +/** + * Reserve a turn and persist its canonical optimistic user row before any + * slower transcript preparation. The returned value is dispatched in that + * same concrete Session; canonical roots keep their own queue-visible row. + */ +export async function prepareUserIntent( + params: PrepareUserIntentParams +): Promise { + const runtimeStatusSource = params.runtimeStatusSource ?? "dispatch"; + const generation = beginTurnDispatch(params.sessionId); + publishTurnIntentDispatch(params.turnIntentId, { + sessionId: params.sessionId, + generation, + }); + beginOptimisticTurn(params.sessionId, runtimeStatusSource); + + let userEvent: SessionEvent | null = null; + try { + await params.beforeAppend?.(); + userEvent = createSyntheticUserEvent(params.sessionId, params.visibleText, { + id: params.userEventId, + imageDataUrls: params.imageDataUrls, + turnIntentId: params.turnIntentId, + deliveryStatus: "pending", + queueMessageId: params.queueMessageId, + }); + await eventStoreProxy.append([userEvent], params.sessionId); + const preparation = { + sessionId: params.sessionId, + userEvent, + generation, + turnIntentId: params.turnIntentId, + runtimeStatusSource, + }; + preparationStates.set(preparation, "prepared"); + return preparation; + } catch (error) { + failOptimisticTurn(params.sessionId, runtimeStatusSource); + markTurnTerminal(params.sessionId, "failed", { generation }); + if (userEvent) { + const failed = deliveryEvent(userEvent, "failed", error); + await eventStoreProxy + .updateById( + failed.id, + { displayStatus: failed.displayStatus, result: failed.result }, + params.sessionId + ) + .catch(() => false); + } + throw error; + } +} + +/** + * Reconnect the ordinary user-intent lifecycle to an already accepted native + * turn. Keeping this beside prepareUserIntent is important: both fresh sends + * and crash recovery must publish the same turnIntentId -> generation mapping + * consumed by the CLI/Agent lifecycle coordinators. + */ +export function adoptAcceptedUserIntent(params: { + sessionId: string; + turnIntentId: string; + runtimeStatusSource?: SessionRuntimeStatusSource; +}): AdoptedUserIntent { + const runtimeStatusSource = params.runtimeStatusSource ?? "dispatch"; + const generation = beginTurnDispatch(params.sessionId); + publishTurnIntentDispatch(params.turnIntentId, { + sessionId: params.sessionId, + generation, + }); + beginOptimisticTurn(params.sessionId, runtimeStatusSource); + confirmTurnRunning(params.sessionId); + return { + sessionId: params.sessionId, + generation, + turnIntentId: params.turnIntentId, + runtimeStatusSource, + }; +} + +/** + * Close the exact lifecycle generation after its authoritative native tail + * has settled. Provider adapters normally report this first; recovery calls + * this idempotently because the original terminal event may predate renderer + * attachment. + */ +export function settleUserIntentLifecycle( + intent: Pick, + status: TurnTerminalStatus +): boolean { + return markTurnTerminal(intent.sessionId, status, { + generation: intent.generation, + }); +} + +/** Keep a long pre-dispatch materialization outside the dispatch dead-man. */ +export function confirmUserIntentPreparation( + preparation: UserIntentPreparation +): void { + confirmTurnRunning(preparation.sessionId); +} + +/** + * Re-assert the optimistic runtime mirror after a foreground continuation has + * switched from its source Session to the newly materialized execution + * Session. The initial preparation intentionally happens before navigation so + * the user's row is visible immediately, but the session-scoped runtime-status + * gate drops that target write while the source Session still owns the view. + */ +export function activateUserIntentPreparation( + preparation: UserIntentPreparation +): void { + beginOptimisticTurn(preparation.sessionId, preparation.runtimeStatusSource); +} + +/** Keep an accepted Send visible as a failed row instead of retracting it. */ +export async function failUserIntentPreparation( + preparation: UserIntentPreparation, + error: unknown +): Promise { + if (preparationStates.get(preparation) !== "prepared") return; + if (getTurnGeneration(preparation.sessionId) === preparation.generation) { + failOptimisticTurn(preparation.sessionId, preparation.runtimeStatusSource); + markTurnTerminal(preparation.sessionId, "failed", { + generation: preparation.generation, + }); + } + await setUserIntentDelivery(preparation, "failed", error); + // Do not retire the preparation until the failed row crossed its durable + // EventStore barrier. A transient SQLite failure must remain retryable by + // the caller instead of converting a memory-only bubble into the sole owner. + preparationStates.set(preparation, "failed"); +} + +async function resolveUserIntentPreparation( + params: DispatchUserIntentParams +): Promise { + const existing = params.preparation; + if (!existing) { + return prepareUserIntent({ + sessionId: params.sessionId, + visibleText: params.visibleText, + imageDataUrls: params.imageDataUrls, + turnIntentId: params.send.turnIntentId, + runtimeStatusSource: params.runtimeStatusSource, + beforeAppend: params.beforeAppend, + queueMessageId: params.queueMessageId, + userEventId: params.userEventId, + }); + } + const state = preparationStates.get(existing); + if ( + state !== "prepared" || + existing.sessionId !== params.sessionId || + existing.turnIntentId !== params.send.turnIntentId + ) { + const error = new Error( + "prepared user intent does not match this dispatch" + ); + if (state === "prepared") { + await failUserIntentPreparation(existing, error); + } + throw error; + } + // Native materialization may replace EventStore between preparation and + // dispatch. Append is ID-deduped, so restore the exact same optimistic row. + try { + await eventStoreProxy.append([existing.userEvent], params.sessionId); + return existing; + } catch (error) { + await failUserIntentPreparation(existing, error); + throw error; + } +} + +/** + * Persist one user row and hand the exact turn to SessionService. Backend + * acceptance promotes the reserved generation to working; any pre-acceptance + * failure keeps that same row visible as failed and closes its generation. + */ +export async function dispatchUserIntent( + params: DispatchUserIntentParams +): Promise { + const preparation = await resolveUserIntentPreparation(params); + try { + await deliverOptimisticOutgoing({ + send: () => { + const phase = getTurnPhase(preparation.sessionId); + if ( + getTurnGeneration(preparation.sessionId) !== preparation.generation || + (phase !== "dispatching" && phase !== "working") + ) { + throw new Error( + "User intent was stopped or superseded before dispatch" + ); + } + return SessionService.sendMessage({ + sessionId: params.sessionId, + ...params.send, + imageDataUrls: params.imageDataUrls, + }); + }, + markSent: () => setUserIntentDelivery(preparation, "sent"), + markFailed: (error) => failUserIntentPreparation(preparation, error), + onProjectionError: (phase, error) => { + log.error( + `Failed to project ${phase} delivery for ${params.sessionId}`, + error + ); + }, + }); + } catch (error) { + // markFailed is idempotent and already patched the same EventStore row. + await failUserIntentPreparation(preparation, error); + throw new UserIntentSendError(error, preparation.userEvent.id); + } + // Transport acceptance is the delivery boundary. Later local bookkeeping + // must never downgrade that same row from sent to failed. + preparationStates.set(preparation, "accepted"); + confirmTurnRunning(params.sessionId); + markSessionActive(params.sessionId); + if (isCursorIdeSession(params.sessionId)) { + getInstrumentedStore().set(setSessionRuntimeStatusAtom, { + sessionId: params.sessionId, + status: "idle", + source: preparation.runtimeStatusSource, + }); + markTurnTerminal(params.sessionId, "completed", { + generation: preparation.generation, + }); + } + return { preparation, userEvent: preparation.userEvent }; +} diff --git a/src/engines/SessionCore/sync/__tests__/authoritativeSessionEvents.test.ts b/src/engines/SessionCore/sync/__tests__/authoritativeSessionEvents.test.ts new file mode 100644 index 0000000000..bd348fe198 --- /dev/null +++ b/src/engines/SessionCore/sync/__tests__/authoritativeSessionEvents.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { loadAuthoritativeSessionEvents } from "../authoritativeSessionEvents"; + +const mocks = vi.hoisted(() => ({ + loadAgentHistory: vi.fn(), + loadExternalPreview: vi.fn(), + loadExternalAuthoritativeHistory: vi.fn(), + loadCliHistory: vi.fn(), + loadPersistedEvents: vi.fn(), + getAdapterForSession: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getPersistedEvents: mocks.loadPersistedEvents, + }, +})); + +vi.mock("../adapters/cli/cliHistory", () => ({ + loadCliHistory: mocks.loadCliHistory, +})); + +vi.mock("../types", () => ({ + getAdapterForSession: mocks.getAdapterForSession, +})); + +const EVENT = { id: "event-1" } as SessionEvent; + +describe("loadAuthoritativeSessionEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAdapterForSession.mockReturnValue({ + category: "agent", + loadHistory: mocks.loadAgentHistory, + }); + }); + + it("reads a native Agent through its persisted native-message adapter", async () => { + mocks.loadAgentHistory.mockResolvedValue([EVENT]); + + await expect( + loadAuthoritativeSessionEvents("agentsession-native") + ).resolves.toEqual({ events: [EVENT], source: "agent_history" }); + expect(mocks.loadAgentHistory).toHaveBeenCalledOnce(); + expect(mocks.loadCliHistory).not.toHaveBeenCalled(); + }); + + it("reads a managed CLI through its provider transcript adapter", async () => { + mocks.loadCliHistory.mockResolvedValue([EVENT]); + + await expect( + loadAuthoritativeSessionEvents("cliagent-native") + ).resolves.toEqual({ events: [EVENT], source: "cli_history" }); + expect(mocks.loadCliHistory).toHaveBeenCalledOnce(); + expect(mocks.getAdapterForSession).not.toHaveBeenCalled(); + }); + + it("reads imported provider history through its external-history adapter", async () => { + mocks.loadExternalAuthoritativeHistory.mockResolvedValue([EVENT]); + mocks.getAdapterForSession.mockReturnValue({ + category: "external_history", + loadHistory: mocks.loadExternalPreview, + loadAuthoritativeHistory: mocks.loadExternalAuthoritativeHistory, + }); + + await expect( + loadAuthoritativeSessionEvents("claudecodeapp-native") + ).resolves.toEqual({ events: [EVENT], source: "external_history" }); + expect(mocks.loadExternalAuthoritativeHistory).toHaveBeenCalledOnce(); + expect(mocks.loadExternalPreview).not.toHaveBeenCalled(); + expect(mocks.loadCliHistory).not.toHaveBeenCalled(); + }); + + it("reads a teammate Cloud import from its complete persisted replay", async () => { + mocks.loadPersistedEvents.mockResolvedValue([EVENT]); + + await expect( + loadAuthoritativeSessionEvents("imported-session-cloud") + ).resolves.toEqual({ + events: [EVENT], + source: "collaboration_replay", + }); + expect(mocks.loadPersistedEvents).toHaveBeenCalledWith( + "imported-session-cloud" + ); + expect(mocks.getAdapterForSession).not.toHaveBeenCalled(); + }); + + it("fails closed rather than treating an imported UI preview as complete", async () => { + mocks.getAdapterForSession.mockReturnValue({ + category: "external_history", + loadHistory: mocks.loadExternalPreview, + }); + + await expect( + loadAuthoritativeSessionEvents("claudecodeapp-preview-only") + ).rejects.toThrow("No authoritative full-history reader"); + expect(mocks.loadExternalPreview).not.toHaveBeenCalled(); + }); + + it("fails closed without an authoritative native reader", async () => { + mocks.getAdapterForSession.mockReturnValue(undefined); + + await expect( + loadAuthoritativeSessionEvents("agentsession-native") + ).rejects.toThrow("No authoritative native history reader"); + }); + + it("fails closed for an adapter outside the authoritative categories", async () => { + mocks.getAdapterForSession.mockReturnValue({ + category: "unsupported", + loadHistory: mocks.loadAgentHistory, + }); + + await expect( + loadAuthoritativeSessionEvents("imported-unsupported") + ).rejects.toThrow("No authoritative native history reader"); + expect(mocks.loadAgentHistory).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts b/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts index c5aa65fb51..e9518c2997 100644 --- a/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts +++ b/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts @@ -1,26 +1,51 @@ -/** - * Native-transcript reconcile contract. - * - * For CLI agents whose own store is the transcript of record, the in-memory - * turn events are throwaway. This module decides when the canonical parse - * replaces them. The invariants: only registered "native" sessions reconcile, - * a reconcile is never scheduled twice concurrently, a stale session never - * dispatches, and the retry only re-dispatches when the parse actually grew. - * - * Timers are the only thing faked — every code path under test is real. - */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { selectConversationRunnerTail } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; import { - isNativeTranscriptSession, - registerSessionTranscriptSource, + reconcileNativeTranscript, + recoverNativeTranscriptAfterMismatch, scheduleNativeTranscriptReconcile, } from "../nativeTranscriptReconcile"; -const SETTLE_MS = 600; -const RETRY_MS = 2000; +const mocks = vi.hoisted(() => ({ + loadAuthoritative: vi.fn(), + getPersisted: vi.fn(), + set: vi.fn(), + setStreaming: vi.fn(), + cliStatus: vi.fn(), + closeTerminalEvents: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { status: mocks.cliStatus } }, +})); + +vi.mock("../adapters/cli/cliLifecycle", () => ({ + closeObservedCliTerminalEvents: mocks.closeTerminalEvents, + isCliTerminalStatus: (status: string | undefined) => + [ + "completed", + "failed", + "error", + "cancelled", + "abandoned", + "timeout", + ].includes(status ?? ""), +})); + +vi.mock("../authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadAuthoritative, +})); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getPersistedEvents: mocks.getPersisted, + set: mocks.set, + setStreaming: mocks.setStreaming, + }, +})); function makeEvent(id: string, sessionId: string): SessionEvent { return { @@ -41,247 +66,314 @@ function makeEvent(id: string, sessionId: string): SessionEvent { }; } -interface Harness { - loadCalls: number; - loads: SessionEvent[][]; - dispatches: Array<{ - sessionId: string; - events: SessionEvent[]; - replace?: boolean; - }>; - live: boolean; - deps: Parameters[1]; +function historySequence(sequence: SessionEvent[][]): void { + let call = 0; + mocks.loadAuthoritative.mockImplementation(async () => ({ + events: sequence[Math.min(call++, sequence.length - 1)] ?? [], + source: "cli_history", + })); } -function makeHarness( - sessionId: string, - historyByCall: Array -): Harness { - const harness: Harness = { - loadCalls: 0, - loads: [], - dispatches: [], - live: true, - deps: { - loadHistory: async () => { - const next = - historyByCall[Math.min(harness.loadCalls, historyByCall.length - 1)]; - harness.loadCalls += 1; - if (next instanceof Error) throw next; - harness.loads.push(next); - return next; - }, - dispatchLoadSession: (payload) => { - harness.dispatches.push(payload); - }, - isSessionLive: (id) => harness.live && id === sessionId, - }, - }; - return harness; -} - -describe("native transcript source registry", () => { - it("only marks a session native for the literal `native` source", () => { - registerSessionTranscriptSource("s-native", "native"); - registerSessionTranscriptSource("s-chunks", "chunks"); - - expect(isNativeTranscriptSession("s-native")).toBe(true); - expect(isNativeTranscriptSession("s-chunks")).toBe(false); - expect(isNativeTranscriptSession("s-never-registered")).toBe(false); - }); - - it("ignores an undefined source instead of clearing a known one", () => { - registerSessionTranscriptSource("s-keep", "native"); - registerSessionTranscriptSource("s-keep", undefined); - - expect(isNativeTranscriptSession("s-keep")).toBe(true); - }); - - it("ignores an empty-string source", () => { - registerSessionTranscriptSource("s-empty", ""); - - expect(isNativeTranscriptSession("s-empty")).toBe(false); - }); -}); - -describe("scheduleNativeTranscriptReconcile", () => { +describe("single-owner native transcript reconcile", () => { beforeEach(() => { - vi.useFakeTimers(); + vi.clearAllMocks(); + mocks.cliStatus.mockResolvedValue({ transcriptSource: "native" }); + mocks.closeTerminalEvents.mockResolvedValue(undefined); + mocks.getPersisted.mockResolvedValue([]); + mocks.set.mockResolvedValue(undefined); + mocks.setStreaming.mockResolvedValue(undefined); }); afterEach(() => { vi.useRealTimers(); }); - it("does nothing for a session that is not native-transcript", async () => { - const sessionId = "s-legacy"; - registerSessionTranscriptSource(sessionId, "chunks"); - const harness = makeHarness(sessionId, [[makeEvent("a", sessionId)]]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS + 10); - - expect(harness.loadCalls).toBe(0); - expect(harness.dispatches).toEqual([]); + it("does not schedule a non-native Session", async () => { + const sessionId = "reconcile-legacy"; + mocks.cliStatus.mockResolvedValue({ transcriptSource: "chunks" }); + historySequence([[makeEvent("a", sessionId)]]); + scheduleNativeTranscriptReconcile(sessionId); + await vi.waitFor(() => expect(mocks.cliStatus).toHaveBeenCalledOnce()); + expect(mocks.loadAuthoritative).not.toHaveBeenCalled(); + expect(mocks.set).not.toHaveBeenCalled(); }); - it("replaces the on-screen events once the settle delay elapses", async () => { - const sessionId = "s-replace"; - registerSessionTranscriptSource(sessionId, "native"); - const events = [makeEvent("u1", sessionId), makeEvent("a1", sessionId)]; - const harness = makeHarness(sessionId, [events]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - expect(harness.loadCalls).toBe(0); - - await vi.advanceTimersByTimeAsync(SETTLE_MS); + it("publishes the provider transcript and closes streaming", async () => { + const sessionId = "reconcile-publish"; + const events = [makeEvent("a", sessionId)]; + historySequence([events]); - expect(harness.dispatches).toEqual([{ sessionId, events, replace: true }]); + await expect(reconcileNativeTranscript(sessionId)).resolves.toEqual(events); + expect(mocks.loadAuthoritative).toHaveBeenCalledTimes(1); + expect(mocks.getPersisted).toHaveBeenCalledWith(sessionId); + expect(mocks.set).toHaveBeenCalledWith(events, sessionId); + expect(mocks.setStreaming).toHaveBeenCalledWith(false, sessionId); }); - it("re-dispatches on retry only when the parse grew", async () => { - const sessionId = "s-grew"; - registerSessionTranscriptSource(sessionId, "native"); - const first = [makeEvent("a", sessionId)]; - const second = [makeEvent("a", sessionId), makeEvent("b", sessionId)]; - const harness = makeHarness(sessionId, [first, second]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); + it("clears a stale projection when the authoritative transcript is empty", async () => { + const sessionId = "reconcile-authoritative-empty"; + historySequence([[]]); - expect(harness.loadCalls).toBe(2); - expect(harness.dispatches).toEqual([ - { sessionId, events: first, replace: true }, - { sessionId, events: second, replace: true }, - ]); + await expect(reconcileNativeTranscript(sessionId)).resolves.toEqual([]); + expect(mocks.loadAuthoritative).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledWith([], sessionId); + expect(mocks.setStreaming).toHaveBeenCalledWith(false, sessionId); }); - it("does not re-dispatch when the retry parse is the same size", async () => { - const sessionId = "s-same"; - registerSessionTranscriptSource(sessionId, "native"); - const events = [makeEvent("a", sessionId)]; - const harness = makeHarness(sessionId, [events, events]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); - - expect(harness.loadCalls).toBe(2); - expect(harness.dispatches).toHaveLength(1); + it("does not treat an unavailable authoritative loader as an empty transcript", async () => { + const sessionId = "reconcile-loader-unavailable"; + mocks.loadAuthoritative.mockRejectedValueOnce( + new Error("authoritative reader unavailable") + ); + + await expect(reconcileNativeTranscript(sessionId)).rejects.toThrow( + "authoritative reader unavailable" + ); + expect(mocks.set).not.toHaveBeenCalled(); + expect(mocks.setStreaming).not.toHaveBeenCalled(); }); - it("does not dispatch an empty parse, but still retries", async () => { - const sessionId = "s-empty-first"; - registerSessionTranscriptSource(sessionId, "native"); - const late = [makeEvent("late", sessionId)]; - const harness = makeHarness(sessionId, [[], late]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); + it("retries only after an explicit semantic mismatch and stops when recovered", async () => { + vi.useFakeTimers(); + const sessionId = "reconcile-late-flush"; + const first = [makeEvent("a", sessionId)]; + const grown = [...first, makeEvent("late", sessionId)]; + historySequence([grown]); + + const resultPromise = recoverNativeTranscriptAfterMismatch( + sessionId, + first, + (events) => events.length === grown.length + ); + expect(mocks.loadAuthoritative).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(249); + expect(mocks.loadAuthoritative).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await expect(resultPromise).resolves.toEqual(grown); + expect(mocks.loadAuthoritative).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenLastCalledWith(grown, sessionId); + }); - expect(harness.dispatches).toEqual([ - { sessionId, events: late, replace: true }, - ]); + it("bounds mismatch recovery when the native transcript never catches up", async () => { + vi.useFakeTimers(); + const sessionId = "reconcile-still-missing"; + const events = [makeEvent("before", sessionId)]; + historySequence([events]); + + const resultPromise = recoverNativeTranscriptAfterMismatch( + sessionId, + events, + () => false + ); + await vi.advanceTimersByTimeAsync(1_000); + await expect(resultPromise).resolves.toEqual(events); + expect(mocks.loadAuthoritative).toHaveBeenCalledTimes(2); + expect(mocks.set).toHaveBeenCalledTimes(2); }); - it("coalesces concurrent schedules for the same session", async () => { - const sessionId = "s-dedupe"; - registerSessionTranscriptSource(sessionId, "native"); + it("coalesces foreground and background callers into one reconcile", async () => { + const sessionId = "reconcile-coalesced"; const events = [makeEvent("a", sessionId)]; - const harness = makeHarness(sessionId, [events, events]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); - - expect(harness.loadCalls).toBe(2); - expect(harness.dispatches).toHaveLength(1); + historySequence([events]); + + const first = reconcileNativeTranscript(sessionId); + const second = reconcileNativeTranscript(sessionId); + scheduleNativeTranscriptReconcile(sessionId); + expect(second).toBe(first); + await first; + expect(mocks.loadAuthoritative).toHaveBeenCalledTimes(1); + expect(mocks.set).toHaveBeenCalledTimes(1); }); - it("releases the pending slot so a later turn can reconcile again", async () => { - const sessionId = "s-reschedule"; - registerSessionTranscriptSource(sessionId, "native"); - const first = [makeEvent("a", sessionId)]; - const harness = makeHarness(sessionId, [first]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); - const callsAfterFirst = harness.loadCalls; - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); - - expect(harness.loadCalls).toBeGreaterThan(callsAfterFirst); + it("upgrades an in-flight job to preserve an interrupted partial suffix", async () => { + const sessionId = "reconcile-interrupted"; + const native = [makeEvent("native", sessionId)]; + const partial = makeEvent("partial", sessionId); + mocks.cliStatus.mockResolvedValue({ + transcriptSource: "native", + status: "cancelled", + }); + historySequence([native]); + mocks.getPersisted.mockResolvedValue([...native, partial]); + + const first = reconcileNativeTranscript(sessionId); + const joined = reconcileNativeTranscript(sessionId, { + preserveInterruptedSuffix: true, + }); + expect(joined).toBe(first); + await expect(first).resolves.toEqual([...native, partial]); + expect(mocks.getPersisted).toHaveBeenCalledWith(sessionId); + expect(mocks.set).toHaveBeenCalledWith([...native, partial], sessionId); + expect(mocks.closeTerminalEvents).toHaveBeenCalledWith( + sessionId, + "cancelled" + ); + expect(mocks.closeTerminalEvents.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getPersisted.mock.invocationCallOrder[0] + ); }); - it("drops the reconcile entirely when the session is no longer on screen", async () => { - const sessionId = "s-stale"; - registerSessionTranscriptSource(sessionId, "native"); - const harness = makeHarness(sessionId, [[makeEvent("a", sessionId)]]); - harness.live = false; - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); + it("keeps a durable failed user delivery across terminal native reconcile", async () => { + const sessionId = "reconcile-failed-delivery"; + const native = [makeEvent("native", sessionId)]; + const failed = { + ...makeEvent("queued-user:q1:", sessionId), + functionName: "user_message", + actionType: "raw", + source: "user", + displayText: "retry me", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "provider unavailable", + turnIntentId: "turn-failed", + message: { role: "user", content: "retry me" }, + }, + } as SessionEvent; + historySequence([native]); + mocks.getPersisted.mockResolvedValue([failed]); - expect(harness.loadCalls).toBe(0); - expect(harness.dispatches).toEqual([]); + await expect(reconcileNativeTranscript(sessionId)).resolves.toEqual([ + ...native, + failed, + ]); + expect(mocks.set).toHaveBeenCalledWith([...native, failed], sessionId); }); - it("drops the dispatch when the session goes stale during the read", async () => { - const sessionId = "s-stale-mid"; - registerSessionTranscriptSource(sessionId, "native"); - const harness = makeHarness(sessionId, [[makeEvent("a", sessionId)]]); - const originalLoad = harness.deps.loadHistory; - harness.deps.loadHistory = async (id: string) => { - const events = await originalLoad(id); - harness.live = false; - return events; + it("keeps the exact accepted turn visible when native terminal rows replace live ids", async () => { + const sessionId = "reconcile-terminal-overlay"; + const user = (id: string, turnIntentId?: string): SessionEvent => ({ + ...makeEvent(id, sessionId), + source: "user", + functionName: "user_message", + uiCanonical: "user_message", + displayText: "repeat", + result: { + message: { role: "user", content: "repeat" }, + ...(turnIntentId ? { turnIntentId } : {}), + }, + }); + const oldUser = user("native-old-user"); + const oldAnswer = makeEvent("old-answer", sessionId); + const tool = (id: string, callId: string): SessionEvent => ({ + ...makeEvent(id, sessionId), + functionName: "read_file", + uiCanonical: "tool_call", + actionType: "tool_call", + callId, + args: { path: "/repo/README.md" }, + result: { status: "completed", output: "file contents" }, + displayVariant: "tool_call", + }); + const nativeUser = user("native-new-user"); + const compact = { + ...makeEvent("compact", sessionId), + functionName: "context_compacted", + actionType: "context_compacted", }; - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); - - expect(harness.loadCalls).toBe(1); - expect(harness.dispatches).toEqual([]); - }); - - it("skips the retry read entirely once the session leaves the screen", async () => { - const sessionId = "s-stale-before-retry"; - registerSessionTranscriptSource(sessionId, "native"); - const first = [makeEvent("a", sessionId)]; - const harness = makeHarness(sessionId, [first, first]); - - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS); - expect(harness.dispatches).toHaveLength(1); - - harness.live = false; - await vi.advanceTimersByTimeAsync(RETRY_MS); - - expect(harness.loadCalls).toBe(1); - expect(harness.dispatches).toHaveLength(1); + const final = makeEvent("native-final", sessionId); + const following = user("native-following-user"); + const otherAnswer = makeEvent("other-answer", sessionId); + const native = [ + oldUser, + oldAnswer, + tool("native-tool", "provider_alias"), + nativeUser, + compact, + final, + following, + otherAnswer, + ]; + historySequence([native]); + mocks.getPersisted.mockResolvedValue([ + oldUser, + oldAnswer, + tool("projected-tool", "old_call_id"), + user("optimistic-current", "intent-current"), + { + ...makeEvent("live-final", sessionId), + result: { turnIntentId: "intent-current" }, + }, + { + ...user("queued-next", "intent-next"), + displayStatus: "pending", + result: { + ...user("queued-next", "intent-next").result, + deliveryStatus: "pending", + }, + }, + ]); + const settled = await reconcileNativeTranscript(sessionId); + expect(settled[0].result?.turnIntentId).toBeUndefined(); + expect(settled[2].result?.turnIntentId).toBeUndefined(); + expect(settled[3].result?.turnIntentId).toBe("intent-current"); + expect(settled[5].displayText).toBe("native-final"); + expect(settled[6].result?.turnIntentId).toBeUndefined(); + expect( + selectConversationRunnerTail( + { + runnerSessionId: sessionId, + turnId: "intent-current", + eventStartIndex: 2, + }, + settled + ).map((event) => event.id) + ).toEqual(["compact", "native-final"]); + expect(mocks.set).toHaveBeenCalledWith(settled, sessionId); }); - it("swallows a failing history read and frees the pending slot", async () => { - const sessionId = "s-throws"; - registerSessionTranscriptSource(sessionId, "native"); - const recovered = [makeEvent("a", sessionId)]; - const harness = makeHarness(sessionId, [ - new Error("native store locked"), - recovered, + it("does not move intent identity onto equal text after a divergent native prefix", async () => { + const sessionId = "reconcile-divergent-prefix"; + const user = { + ...makeEvent("user", sessionId), + source: "user", + result: { + turnIntentId: "current", + message: { role: "user", content: "repeat" }, + }, + } as SessionEvent; + const nativeUser = { + ...user, + result: { message: { role: "user", content: "repeat" } }, + }; + const native = [ + makeEvent("different-history", sessionId), + nativeUser, + makeEvent("answer", sessionId), + ]; + historySequence([native]); + mocks.getPersisted.mockResolvedValue([ + makeEvent("old-history", sessionId), + user, ]); + await expect(reconcileNativeTranscript(sessionId)).resolves.toEqual(native); + }); - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); + it("does not erase delivery metadata when the projection read fails", async () => { + const sessionId = "reconcile-projection-unavailable"; + historySequence([[makeEvent("native", sessionId)]]); + mocks.getPersisted.mockRejectedValueOnce( + new Error("projection unavailable") + ); + await expect(reconcileNativeTranscript(sessionId)).rejects.toThrow( + "projection unavailable" + ); + expect(mocks.set).not.toHaveBeenCalled(); + }); - expect(harness.dispatches).toEqual([]); + it("releases a failed job so a later terminal can retry", async () => { + const sessionId = "reconcile-retry-after-error"; + mocks.loadAuthoritative.mockRejectedValueOnce(new Error("store locked")); - // The slot was released, so the next terminal status can retry. - scheduleNativeTranscriptReconcile(sessionId, harness.deps); - await vi.advanceTimersByTimeAsync(SETTLE_MS); + const failed = reconcileNativeTranscript(sessionId); + const failureAssertion = expect(failed).rejects.toThrow("store locked"); + await failureAssertion; - expect(harness.dispatches).toEqual([ - { sessionId, events: recovered, replace: true }, - ]); + const recovered = [makeEvent("recovered", sessionId)]; + historySequence([recovered]); + const retry = reconcileNativeTranscript(sessionId); + await expect(retry).resolves.toEqual(recovered); }); }); diff --git a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts index 810d2cfa03..57945d2c05 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts @@ -5,9 +5,14 @@ import type { SessionAdapter } from "../types"; const mocks = vi.hoisted(() => ({ applyPostLoadResult: vi.fn(), + capturePostLoadLifecycleSnapshot: vi.fn(() => ({ + lastTerminal: null, + generation: 0, + })), dispatchLoadSession: vi.fn(), getEvents: vi.fn(), hydrateSessionStoreBeforeDisplay: vi.fn(), + isCollaborationImportedSession: vi.fn(() => false), loadInitialTurnWindow: vi.fn(), loadPersistedHistory: vi.fn(), messageError: vi.fn(), @@ -34,7 +39,7 @@ vi.mock("@src/engines/SessionCore/ingestion/visibilityFilters", () => ({ vi.mock("@src/util/session/sessionDispatch", () => ({ composerIdFromSessionId: () => null, - isCollaborationImportedSession: () => false, + isCollaborationImportedSession: mocks.isCollaborationImportedSession, isImportedHistorySession: () => false, })); @@ -52,6 +57,8 @@ vi.mock("../sessionSyncReconcile", () => ({ vi.mock("../sessionSyncStateHelpers", () => ({ applyPostLoadResult: mocks.applyPostLoadResult, + capturePostLoadLifecycleSnapshot: mocks.capturePostLoadLifecycleSnapshot, + isPostLoadRunStatusSuperseded: vi.fn(() => false), })); vi.mock("../sessionSyncUtils", () => ({ @@ -80,10 +87,48 @@ function createActions() { describe("runSessionSwitchOrchestrator reconciliation", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.isCollaborationImportedSession.mockReturnValue(false); mocks.switchSession.mockResolvedValue(true); mocks.getEvents.mockResolvedValue([{ id: "visible" }]); }); + it("uses the complete persisted projection on an imported-session cache hit", async () => { + const sessionId = "imported-session-retry"; + const events = [{ id: "history" }, { id: "failed-delivery" }]; + mocks.isCollaborationImportedSession.mockReturnValue(true); + mocks.loadPersistedHistory.mockResolvedValue(events); + const adapter = { + category: "agent", + postLoad: vi.fn().mockResolvedValue({ runStatus: "idle" }), + } as unknown as SessionAdapter; + runSessionSwitchOrchestrator({ + sessionId, + adapter, + abortController: new AbortController(), + refs: { liveSessionIdRef: { current: sessionId } }, + actions: createActions(), + setPendingPlanApprovals: vi.fn(), + logger: { error: vi.fn() } as never, + }); + + await vi.waitFor(() => + expect(mocks.dispatchLoadSession).toHaveBeenCalledOnce() + ); + expect(mocks.loadPersistedHistory).toHaveBeenCalledWith( + adapter, + sessionId, + expect.any(AbortSignal) + ); + expect(mocks.hydrateSessionStoreBeforeDisplay).toHaveBeenCalledWith( + sessionId, + events + ); + expect(mocks.dispatchLoadSession).toHaveBeenCalledWith( + expect.objectContaining({ events }) + ); + expect(mocks.loadInitialTurnWindow).not.toHaveBeenCalled(); + }); + it.each([ [undefined, false], ["idle", false], diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts index ed144c8375..3ad1e0265b 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts @@ -7,12 +7,19 @@ * matter are (a) a stale session must never win, and (b) a native-transcript * session must never merge a replay next to live in-memory turn events. * - * Only the timer (`waitForReconcileDelay`) and the Rust event store are mocked. - * `sessionSyncUtils`' hydration helpers, `nativeTranscriptReconcile`'s registry, - * the status narrowing and the Jotai session store all run for real. + * Only the timer (`waitForReconcileDelay`), the Rust event store, and the + * failed-delivery cache lookup are mocked. `sessionSyncUtils`' hydration + * helpers, the durable transcript-source result, status narrowing and the + * Jotai session store all run for real. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + beginTurnDispatch, + getTurnPhase, + markTurnRunning, + resetTurnLifecycleForTests, +} from "@src/engines/SessionCore/control/turnLifecycle"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { ContextUsageSnapshot } from "@src/store/session/cliSessionStatusAtom"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; @@ -22,7 +29,6 @@ import { getInstrumentedStore, } from "@src/util/core/state/instrumentedStore"; -import { registerSessionTranscriptSource } from "../nativeTranscriptReconcile"; import { applySwitchPostLoadResult, reconcileInFlightHistory, @@ -62,6 +68,22 @@ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: store.api, })); +vi.mock( + "@src/engines/SessionCore/storage/cacheAdapter", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/engines/SessionCore/storage/cacheAdapter") + >(); + return { + ...actual, + // These reconciliation fixtures exercise provider history. They have no + // synthetic failed-send sidecar in SQLite. + getSessionMetadata: vi.fn(async () => null), + }; + } +); + const timer = vi.hoisted(() => ({ delays: [] as number[] })); vi.mock("../sessionSyncUtils", async (importOriginal) => { @@ -188,12 +210,11 @@ function settle(): Promise { describe("reconcileInFlightHistory", () => { beforeEach(() => { vi.clearAllMocks(); + resetTurnLifecycleForTests(); store.reset(); timer.delays.length = 0; createInstrumentedStore(); getInstrumentedStore().set(sessionsAtom, []); - // Default: not a native-transcript session. - registerSessionTranscriptSource(SESSION_ID, "chunks"); }); it("merges the replay next to live events and stops on a terminal run status", async () => { @@ -450,8 +471,7 @@ describe("reconcileInFlightHistory", () => { expect(recorded.contextTokens).toEqual([7]); expect(recorded.contextUsage).toEqual([usage]); expect(recorded.runtimeStatus).toEqual(["failed"]); - // A terminal status returns before the run error is applied. - expect(recorded.runtimeError).toEqual([]); + expect(recorded.runtimeError).toEqual(["provider exploded"]); }); it("applies the run error when the status is still in flight", async () => { @@ -474,6 +494,24 @@ describe("reconcileInFlightHistory", () => { expect(recorded.runtimeStatus).toEqual(["running", "completed"]); }); + it("rejects a terminal snapshot when a newer dispatch wins the read race", async () => { + markTurnRunning(SESSION_ID); + const adapter = makeAdapter({ + history: [makeEvent("a")], + postLoad: () => { + beginTurnDispatch(SESSION_ID); + return { runStatus: "completed" }; + }, + }); + const { recorded, actions } = makeActions(); + + reconcileInFlightHistory(SESSION_ID, adapter, liveRefs(), actions); + await settle(); + + expect(recorded.runtimeStatus).toEqual([]); + expect(getTurnPhase(SESSION_ID)).toBe("dispatching"); + }); + it("hydrates and dispatches even when the adapter has no postLoad", async () => { const adapter = makeAdapter({ history: [makeEvent("a")] }); const { recorded, actions } = makeActions(); @@ -489,14 +527,10 @@ describe("reconcileInFlightHistory", () => { }); describe("native-transcript sessions", () => { - beforeEach(() => { - registerSessionTranscriptSource(SESSION_ID, "native"); - }); - it("replaces the on-screen events only when the store is empty", async () => { const adapter = makeAdapter({ history: [makeEvent("replayed")], - postLoad: { runStatus: "completed" }, + postLoad: { runStatus: "completed", transcriptSource: "native" }, }); const { recorded, actions } = makeActions(); @@ -518,7 +552,11 @@ describe("reconcileInFlightHistory", () => { store.eventsBySession.set(SESSION_ID, [makeEvent("live-bubble")]); const adapter = makeAdapter({ history: [makeEvent("replayed")], - postLoad: { runStatus: "completed", contextTokens: 55 }, + postLoad: { + runStatus: "completed", + contextTokens: 55, + transcriptSource: "native", + }, }); const { recorded, actions } = makeActions(); @@ -536,7 +574,7 @@ describe("reconcileInFlightHistory", () => { it("is idempotent across retries: a second tick re-replaces, never appends", async () => { const adapter = makeAdapter({ history: [makeEvent("replayed")], - postLoad: { runStatus: "running" }, + postLoad: { runStatus: "running", transcriptSource: "native" }, // Emulate the real store: the `set` above leaves events behind, so // every later tick must take the "store not empty" branch. }); @@ -557,7 +595,7 @@ describe("reconcileInFlightHistory", () => { }); const adapter = makeAdapter({ history: [makeEvent("replayed")], - postLoad: { runStatus: "running" }, + postLoad: { runStatus: "running", transcriptSource: "native" }, }); const { recorded, actions } = makeActions(); @@ -577,7 +615,7 @@ describe("reconcileInFlightHistory", () => { }); const adapter = makeAdapter({ history: [makeEvent("replayed")], - postLoad: { runStatus: "running" }, + postLoad: { runStatus: "running", transcriptSource: "native" }, }); const { recorded, actions } = makeActions(); diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.sessionListStatus.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.sessionListStatus.test.ts index 9115098765..942a83d8a8 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.sessionListStatus.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.sessionListStatus.test.ts @@ -17,7 +17,12 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetTurnLifecycleForTests } from "@src/engines/SessionCore/control/turnLifecycle"; +import { + getTurnPhase, + markTurnRunning, + markTurnTerminal, + resetTurnLifecycleForTests, +} from "@src/engines/SessionCore/control/turnLifecycle"; import type { ContextBreakdown, ContextUsageSnapshot, @@ -34,6 +39,7 @@ import { import { applyPostLoadResult, + capturePostLoadLifecycleSnapshot, createSessionEventHandlerCallbacks, } from "../sessionSyncStateHelpers"; import type { SessionEventHandlerStateActions } from "../sessionSyncStateHelpers"; @@ -180,6 +186,24 @@ describe("applyPostLoadResult writes a validated status to the session list", () expectRowStatus("cancelled"); }); + + it("does not let a stale running post-load resurrect a terminal turn", () => { + markTurnRunning(SESSION_ID); + const lifecycleSnapshot = capturePostLoadLifecycleSnapshot(SESSION_ID); + markTurnTerminal(SESSION_ID, "completed"); + getInstrumentedStore().set(sessionsAtom, (sessions) => + sessions.map((session) => ({ ...session, status: "completed" })) + ); + const { actions, runtimeStatus } = makePostLoadActions(); + + applyPostLoadResult(SESSION_ID, { runStatus: "running" }, actions, { + lifecycleSnapshot, + }); + + expect(runtimeStatus).toEqual([]); + expectRowStatus("completed"); + expect(getTurnPhase(SESSION_ID)).toBe("idle"); + }); }); // --------------------------------------------------------------------------- diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts index 95ab8a3d50..eccee3c7de 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncStateHelpers.test.ts @@ -21,6 +21,7 @@ import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore" const mocks = vi.hoisted(() => ({ getTurnIntentDispatch: vi.fn(), + getTurnGeneration: vi.fn(() => 0), })); vi.mock("@src/engines/SessionCore/control/turnIntentDispatchLifecycle", () => ({ @@ -41,8 +42,10 @@ vi.mock("@src/store/session", () => ({ })); vi.mock("@src/engines/SessionCore/control/turnLifecycle", () => ({ - markTurnRunning: vi.fn(), - markTurnTerminal: vi.fn(), + getLastTurnTerminal: vi.fn(() => null), + getTurnGeneration: mocks.getTurnGeneration, + markTurnRunning: vi.fn(() => true), + markTurnTerminal: vi.fn(() => true), toTurnTerminalStatus: (status: string) => status === "failed" || status === "error" || status === "timeout" ? "failed" @@ -79,6 +82,7 @@ describe("session sync state callbacks", () => { beforeEach(() => { vi.clearAllMocks(); mocks.getTurnIntentDispatch.mockReturnValue(undefined); + mocks.getTurnGeneration.mockReturnValue(0); }); it("clears live streaming content before completed status can leave Stop UI stuck", () => { @@ -163,6 +167,7 @@ describe("session sync state callbacks", () => { sessionId: "session-1", generation: 17, }); + mocks.getTurnGeneration.mockReturnValue(17); const callbacks = createSessionEventHandlerCallbacks( "session-1", createActions(), @@ -180,6 +185,46 @@ describe("session sync state callbacks", () => { }); }); + it("rejects an attributed terminal from an older turn generation", () => { + mocks.getTurnIntentDispatch.mockReturnValue({ + sessionId: "session-1", + generation: 16, + }); + mocks.getTurnGeneration.mockReturnValue(17); + const actions = createActions(); + const callbacks = createSessionEventHandlerCallbacks( + "session-1", + actions, + vi.fn() + ); + + callbacks.onStatusChange?.("completed", undefined, { + turnIntentId: "stale-intent-16", + }); + + expect(markTurnTerminal).not.toHaveBeenCalled(); + expect(actions.setSessionRuntimeStatus).not.toHaveBeenCalled(); + expect(actions.setPendingCancel).not.toHaveBeenCalled(); + expect(updateSessionStatus).not.toHaveBeenCalled(); + }); + + it("does not update presentation when the lifecycle rejects a terminal", () => { + vi.mocked(markTurnTerminal).mockReturnValueOnce(false); + const actions = createActions(); + const callbacks = createSessionEventHandlerCallbacks( + "session-1", + actions, + vi.fn() + ); + + callbacks.onStatusChange?.("completed"); + + expect(actions.setSessionRuntimeStatus).not.toHaveBeenCalled(); + expect(actions.setPendingCancel).not.toHaveBeenCalled(); + expect(eventStoreProxy.unpinSession).not.toHaveBeenCalled(); + expect(updateSessionStatus).not.toHaveBeenCalled(); + }); + it("rejects a terminal intent attributed to another session", () => { mocks.getTurnIntentDispatch.mockReturnValue({ sessionId: "session-other", @@ -256,6 +301,22 @@ describe("session sync state callbacks", () => { expect(markTurnRunning).toHaveBeenNthCalledWith(2, "session-1"); }); + it("does not update presentation when the lifecycle rejects running", () => { + vi.mocked(markTurnRunning).mockReturnValueOnce(false); + const actions = createActions(); + const callbacks = createSessionEventHandlerCallbacks( + "session-1", + actions, + vi.fn() + ); + + callbacks.onStatusChange?.("running"); + + expect(actions.setSessionRuntimeStatus).not.toHaveBeenCalled(); + expect(eventStoreProxy.pinSession).not.toHaveBeenCalled(); + expect(actions.dismissCanvasAtNewTurn).not.toHaveBeenCalled(); + }); + it("calls dismissCanvasAtNewTurn with the session id when status is 'running'", () => { const actions = createActions(); const callbacks = createSessionEventHandlerCallbacks( diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts index e2a446caaf..b0958c9543 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncUtils.test.ts @@ -2,15 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { loadPersistedHistory } from "../sessionSyncUtils"; +import { + loadOwnSessionInitialEvents, + loadPersistedHistory, +} from "../sessionSyncUtils"; import type { SessionAdapter } from "../types"; const cacheAdapterMock = vi.hoisted(() => ({ + getSessionMetadata: vi.fn(), loadInitialTurnWindow: vi.fn(), loadEvents: vi.fn(), })); vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ + getSessionMetadata: cacheAdapterMock.getSessionMetadata, loadInitialTurnWindow: cacheAdapterMock.loadInitialTurnWindow, loadEvents: cacheAdapterMock.loadEvents, })); @@ -32,6 +37,7 @@ function makeAdapter( describe("loadPersistedHistory", () => { beforeEach(() => { vi.clearAllMocks(); + cacheAdapterMock.getSessionMetadata.mockResolvedValue(null); }); it("returns turn-window events when the event cache has rows", async () => { @@ -73,6 +79,66 @@ describe("loadPersistedHistory", () => { ); }); + it("rehydrates a failed collaboration-replay send outside its turn window", async () => { + const indexedHistory = [ + { + ...makeEvent("imported-session-restart~native-user"), + createdAt: "2026-09-05T10:00:00Z", + }, + { + ...makeEvent("imported-session-restart~native-assistant"), + createdAt: "2026-09-05T10:00:01Z", + }, + ]; + const failed = { + ...makeEvent("queued-user:queued-cloud-follow-up:"), + sessionId: "imported-session-restart", + createdAt: "2026-09-05T10:01:00Z", + source: "user", + functionName: "user_message", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "provider-native execution diverged", + turnIntentId: "cloud-turn-failed", + message: { role: "user", content: "queued follow-up" }, + }, + } as SessionEvent; + cacheAdapterMock.loadInitialTurnWindow.mockResolvedValue({ + turns: [{ turnId: "indexed-native-turn" }], + events: indexedHistory, + }); + cacheAdapterMock.getSessionMetadata.mockResolvedValue({ + sessionId: "imported-session-restart", + eventCount: 3, + cachedAt: 1, + }); + cacheAdapterMock.loadEvents.mockResolvedValue([...indexedHistory, failed]); + const adapter = makeAdapter("agent", []); + + const result = await loadPersistedHistory( + adapter, + "imported-session-restart", + new AbortController().signal + ); + + expect(result.map((event) => event.id)).toEqual([ + "imported-session-restart~native-user", + "imported-session-restart~native-assistant", + "queued-user:queued-cloud-follow-up:", + ]); + expect(result[2]).toBe(failed); + // The no-adapter loader uses this same entry point before registration. + expect( + await loadOwnSessionInitialEvents("imported-session-restart") + ).toEqual(result); + expect(adapter.loadHistory).not.toHaveBeenCalled(); + expect(cacheAdapterMock.loadEvents).toHaveBeenCalledWith( + "imported-session-restart" + ); + }); + it("falls back to adapter.loadHistory when the event cache is empty", async () => { cacheAdapterMock.loadInitialTurnWindow.mockResolvedValue({ turns: [], @@ -125,4 +191,70 @@ describe("loadPersistedHistory", () => { expect(result).toBe(fallback); expect(cacheAdapterMock.loadInitialTurnWindow).not.toHaveBeenCalled(); }); + + it("rehydrates a failed CLI user turn beside native history after restart", async () => { + const nativeHistory = [ + { + ...makeEvent("native-user"), + createdAt: "2026-09-05T10:00:00Z", + }, + { + ...makeEvent("native-assistant"), + createdAt: "2026-09-05T10:00:01Z", + }, + ]; + const failed = { + ...makeEvent("queued-user:q1:"), + sessionId: "cliagent-restart", + createdAt: "2026-09-05T10:01:00Z", + source: "user", + functionName: "user_message", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "provider unavailable", + turnIntentId: "turn-failed", + message: { role: "user", content: "please retry" }, + }, + } as SessionEvent; + cacheAdapterMock.getSessionMetadata.mockResolvedValue({ + sessionId: "cliagent-restart", + eventCount: 1, + cachedAt: 1, + }); + cacheAdapterMock.loadEvents.mockResolvedValue([failed]); + const adapter = makeAdapter("cli", nativeHistory); + + const result = await loadPersistedHistory( + adapter, + "cliagent-restart", + new AbortController().signal + ); + + expect(result.map((event) => event.id)).toEqual([ + "native-user", + "native-assistant", + "queued-user:q1:", + ]); + expect(result[2]).toBe(failed); + expect(adapter.loadHistory).toHaveBeenCalledTimes(1); + expect(cacheAdapterMock.loadEvents).toHaveBeenCalledWith( + "cliagent-restart" + ); + }); + + it("does not reparse native CLI history through an empty event cache", async () => { + const history = [makeEvent("native")]; + const adapter = makeAdapter("cli", history); + + const result = await loadPersistedHistory( + adapter, + "cliagent-no-overlay", + new AbortController().signal + ); + + expect(result).toBe(history); + expect(cacheAdapterMock.loadEvents).not.toHaveBeenCalled(); + }); }); diff --git a/src/engines/SessionCore/sync/__tests__/useSessionEventIngestion.test.ts b/src/engines/SessionCore/sync/__tests__/useSessionEventIngestion.test.ts new file mode 100644 index 0000000000..8103423180 --- /dev/null +++ b/src/engines/SessionCore/sync/__tests__/useSessionEventIngestion.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + subscribeToSessionEventIngestion, + useSessionEventIngestion, +} from "../useSessionEventIngestion"; + +const mocks = vi.hoisted(() => ({ + createEventHandler: vi.fn(), + dispose: vi.fn(), + handleEvent: vi.fn(), + listener: null as ((raw: string) => void) | null, + subscribeToSessionEvents: vi.fn(), + unsubscribeChannel: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/sync/adapters", () => ({})); +vi.mock("@src/engines/SessionCore/sync/types", () => ({ + getAdapterForSession: () => ({ + createEventHandler: mocks.createEventHandler, + }), +})); +vi.mock("@src/engines/SessionCore/sync/useSessionChannel", () => ({ + subscribeToSessionEvents: mocks.subscribeToSessionEvents, +})); + +function Harness({ sessionId }: { sessionId: string | null }) { + useSessionEventIngestion(sessionId); + return null; +} + +describe("useSessionEventIngestion", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + mocks.createEventHandler.mockReset(); + mocks.dispose.mockReset(); + mocks.handleEvent.mockReset(); + mocks.subscribeToSessionEvents.mockReset(); + mocks.unsubscribeChannel.mockReset(); + mocks.createEventHandler.mockReturnValue({ + dispose: mocks.dispose, + handleEvent: mocks.handleEvent, + }); + mocks.listener = null; + mocks.subscribeToSessionEvents.mockImplementation( + (_sessionId: string, listener: (raw: string) => void) => { + mocks.listener = listener; + return mocks.unsubscribeChannel; + } + ); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("feeds a hidden session's shared channel into its registered adapter", () => { + act(() => + root.render( + React.createElement(Harness, { sessionId: "cliagent-hidden" }) + ) + ); + + expect(mocks.createEventHandler).toHaveBeenCalledWith( + "cliagent-hidden", + {} + ); + expect(mocks.subscribeToSessionEvents).toHaveBeenCalledWith( + "cliagent-hidden", + expect.any(Function) + ); + + act(() => { + mocks.listener?.( + JSON.stringify({ + type: "code_session.activity", + session_id: "cliagent-hidden", + data: { type: "assistant", content: "live output" }, + }) + ); + }); + + expect(mocks.handleEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: "code_session.activity", + session_id: "cliagent-hidden", + }) + ); + }); + + it("disposes the old adapter and does not subscribe without a session", () => { + act(() => + root.render( + React.createElement(Harness, { sessionId: "cliagent-hidden" }) + ) + ); + act(() => root.render(React.createElement(Harness, { sessionId: null }))); + + expect(mocks.dispose).toHaveBeenCalledOnce(); + expect(mocks.unsubscribeChannel).toHaveBeenCalledOnce(); + }); + + it("shares one stateful handler across hidden surfaces", () => { + const disposeFirst = subscribeToSessionEventIngestion("cliagent-shared"); + const disposeSecond = subscribeToSessionEventIngestion("cliagent-shared"); + + expect(mocks.createEventHandler).toHaveBeenCalledOnce(); + expect(mocks.subscribeToSessionEvents).toHaveBeenCalledOnce(); + disposeFirst(); + expect(mocks.dispose).not.toHaveBeenCalled(); + expect(mocks.unsubscribeChannel).not.toHaveBeenCalled(); + disposeSecond(); + expect(mocks.dispose).toHaveBeenCalledOnce(); + expect(mocks.unsubscribeChannel).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts index 1d77c4635c..1e6dfc269f 100644 --- a/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts +++ b/src/engines/SessionCore/sync/adapters/__tests__/externalHistoryAdapter.loading.test.ts @@ -39,6 +39,39 @@ describe("external history loading", () => { forgetTranscriptSignature("codexapp-large"); }); + it("loads every native chunk for authoritative continuation without using the UI preview", async () => { + const previewChunks = vi.fn().mockResolvedValue([chunk()]); + const fullChunks = [ + chunk(), + { ...chunk(), chunk_id: "chunk-2", function: "assistant_message" }, + { ...chunk(), chunk_id: "chunk-3", function: "tool_result" }, + ]; + const loadFullTranscriptChunks = vi.fn().mockResolvedValue(fullChunks); + const events = [{ id: "event-1" }, { id: "event-2" }]; + mocks.getSource.mockReturnValue({ + loadPreviewChunks: previewChunks, + loadFullTranscriptChunks, + }); + mocks.processChunks.mockResolvedValue(events); + + await expect( + externalHistoryAdapter.loadAuthoritativeHistory!( + "claudecodeapp-large", + new AbortController().signal + ) + ).resolves.toEqual(events); + + expect(loadFullTranscriptChunks).toHaveBeenCalledOnce(); + expect(loadFullTranscriptChunks).toHaveBeenCalledWith( + "claudecodeapp-large" + ); + expect(previewChunks).not.toHaveBeenCalled(); + expect(mocks.processChunks).toHaveBeenCalledWith( + fullChunks, + "claudecodeapp-large" + ); + }); + it("shares one parse across overlapping initial and refresh loads", async () => { let resolveChunks: ((chunks: ActivityChunk[]) => void) | undefined; const loadPreviewChunks = vi.fn( diff --git a/src/engines/SessionCore/sync/adapters/cli/__tests__/cliHistory.test.ts b/src/engines/SessionCore/sync/adapters/cli/__tests__/cliHistory.test.ts new file mode 100644 index 0000000000..965346dfbb --- /dev/null +++ b/src/engines/SessionCore/sync/adapters/cli/__tests__/cliHistory.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { loadCliTranscriptRevision } from "../cliHistory"; + +const mocks = vi.hoisted(() => ({ + transcriptRevision: vi.fn(), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { transcriptRevision: mocks.transcriptRevision } }, +})); + +describe("loadCliTranscriptRevision", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves the legacy, unavailable, and stable native revision states", async () => { + mocks.transcriptRevision.mockResolvedValueOnce({ + native: false, + revision: null, + }); + await expect(loadCliTranscriptRevision("legacy")).resolves.toBeUndefined(); + + mocks.transcriptRevision.mockResolvedValueOnce({ + native: true, + revision: null, + }); + await expect(loadCliTranscriptRevision("unavailable")).resolves.toBeNull(); + + mocks.transcriptRevision.mockResolvedValueOnce({ + native: true, + revision: "native-file-v1:123:456", + }); + await expect(loadCliTranscriptRevision("stable")).resolves.toBe( + "native-file-v1:123:456" + ); + }); +}); diff --git a/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts b/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts index 870fb7072f..5da15b5a4b 100644 --- a/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts +++ b/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts @@ -117,6 +117,9 @@ const store = vi.hoisted(() => { } ), getEvents: vi.fn(async (sessionId?: string) => [...list(sessionId)]), + getPersistedEvents: vi.fn(async (sessionId: string) => [ + ...list(sessionId), + ]), setStreaming: vi.fn(async (streaming: boolean, sessionId?: string) => { streamingLog.push({ streaming, sessionId }); }), @@ -228,11 +231,15 @@ function makeChunk(overrides: Partial): ActivityChunk { }; } -function activityEvent(chunk: ActivityChunk): RawSessionEvent { +function activityEvent( + chunk: ActivityChunk, + turnIntentId?: string +): RawSessionEvent { return { type: "code_session.activity", session_id: SESSION_ID, chunk: chunk as unknown as Record, + ...(turnIntentId ? { turn_intent_id: turnIntentId } : {}), }; } @@ -386,6 +393,31 @@ describe("createCliEventHandler ingestion boundary", () => { expect(eventsFor().map((event) => event.id)).toEqual(["chunk-future"]); }); + it("keeps opaque provider results unchanged when attributing a turn", async () => { + rustBridge.normalizeChunkRust.mockImplementation( + async (chunk: ActivityChunk, sessionId: string) => ({ + ...normalizeLikeRust(chunk, sessionId), + result: chunk.result, + }) + ); + const results: unknown[] = [null, "opaque", ["opaque"]]; + for (const [index, result] of results.entries()) { + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: `opaque-${index}`, + action_type: "provider_event", + result: result as ActivityChunk["result"], + }), + "turn-opaque" + ) + ); + } + await flush(); + + expect(eventsFor().map((event) => event.result)).toEqual(results); + }); + it("logs the failure and stores nothing when the normalize RPC rejects", async () => { // The previous shape of this test only wrapped the dispatch in // `expect(...).not.toThrow()`. That can never fail: the rejection lives @@ -425,6 +457,39 @@ describe("createCliEventHandler ingestion boundary", () => { // ------------------------------------------------------------------------- describe("assistant / thinking streaming", () => { + it("attributes live message and thinking projections to the runner turn", async () => { + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "intent-message", + action_type: "assistant_delta", + result: { content: "answering", is_delta: true }, + }), + "turn-live" + ) + ); + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "intent-thinking", + action_type: "llm_thinking_delta", + result: { thought: "reasoning", is_delta: true }, + }), + "turn-live" + ) + ); + await flush(); + + expect(eventsFor()).toHaveLength(2); + expect( + eventsFor().map((event) => + typeof event.result === "object" && event.result !== null + ? Reflect.get(event.result, "turnIntentId") + : undefined + ) + ).toEqual(["turn-live", "turn-live"]); + }); + it("accumulates message deltas under one stable stream id", async () => { handler.handleEvent( activityEvent( @@ -887,7 +952,8 @@ describe("createCliEventHandler ingestion boundary", () => { makeChunk({ action_type: "assistant_delta", result: { content: "partial", is_delta: true }, - }) + }), + "turn-live" ) ); await flush(); @@ -897,6 +963,12 @@ describe("createCliEventHandler ingestion boundary", () => { await flush(); expect(eventsFor().map((event) => event.id)).toEqual([completeEvent.id]); + const completedResult = eventsFor()[0].result; + expect( + typeof completedResult === "object" && completedResult !== null + ? Reflect.get(completedResult, "turnIntentId") + : undefined + ).toBe("turn-live"); }); it("suppresses a late final activity chunk that repeats a completed id", async () => { @@ -1026,7 +1098,8 @@ describe("createCliEventHandler ingestion boundary", () => { describe("tool_call_delta accumulation", () => { function toolDelta( result: Record, - chunkId = `td-${Math.random()}` + chunkId = `td-${Math.random()}`, + turnIntentId?: string ): RawSessionEvent { return activityEvent( makeChunk({ @@ -1034,7 +1107,8 @@ describe("createCliEventHandler ingestion boundary", () => { action_type: "tool_call_delta", function: "tool_call", result, - }) + }), + turnIntentId ); } @@ -1087,6 +1161,29 @@ describe("createCliEventHandler ingestion boundary", () => { }); }); + it("attributes a partial tool projection to the runner turn", async () => { + handler.handleEvent( + toolDelta( + { + index: 0, + tool_call_id: "call-intent", + tool_name: "read_file", + arguments_delta: '{"path":"README.md"}', + }, + "tool-intent", + "turn-live" + ) + ); + await flush(); + + const toolResult = eventsFor()[0].result; + expect( + typeof toolResult === "object" && toolResult !== null + ? Reflect.get(toolResult, "turnIntentId") + : undefined + ).toBe("turn-live"); + }); + it("keeps concurrent tool calls on separate buffers keyed by index", async () => { handler.handleEvent( toolDelta({ @@ -1308,6 +1405,309 @@ describe("createCliEventHandler ingestion boundary", () => { }); }); + it("keeps visible assistant partial text but fences an unresolved tool on cancel", async () => { + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "partial-answer", + action_type: "assistant_delta", + result: { content: "I inspected the router.", is_delta: true }, + }) + ) + ); + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "pending-tool", + action_type: "tool_call_delta", + function: "read_file", + result: { + tool_call_id: "call-pending", + tool_name: "read_file", + arguments_delta: '{"path":"src/router.ts"}', + }, + }) + ) + ); + await flush(); + + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await flush(); + + expect( + eventsFor().find((event) => event.id === "partial-answer") ?? + eventsFor().find((event) => + String(event.id).startsWith("stream-msg-ts-") + ) + ).toMatchObject({ + displayText: "I inspected the router.", + displayStatus: "completed", + isDelta: false, + result: { status: "completed" }, + }); + expect( + eventsFor().find((event) => event.id === "tool-call-call-pending") + ).toMatchObject({ + displayStatus: "completed", + isDelta: false, + result: { status: "pending", interrupted: true }, + }); + expect(callbacks.agentCompletes).toBe(1); + }); + + it("closes interrupted tool output as a portable error result", async () => { + await store.api.upsert( + { + id: "partial-tool-output", + chunk_id: "partial-tool-output", + sessionId: SESSION_ID, + createdAt: "2026-08-01T00:00:00.000Z", + functionName: "run_command_line", + uiCanonical: "run_command_line", + actionType: "tool_call", + args: { command: "pnpm test" }, + callId: "call-partial-output", + result: { + status: "running", + output: "Tests 12 passed\n", + observation: "Tests 12 passed\n", + }, + source: "assistant", + displayText: "Tests 12 passed\n", + displayStatus: "running", + displayVariant: "tool_call", + activityStatus: "agent", + }, + SESSION_ID + ); + + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await flush(); + + expect( + eventsFor().find((event) => event.id === "partial-tool-output") + ).toMatchObject({ + displayStatus: "completed", + isDelta: false, + result: { + status: "interrupted", + interrupted: true, + output: "Tests 12 passed\n", + }, + }); + }); + + it("does not expose the terminal runtime state before partial rows close", async () => { + await store.api.upsert( + { + id: "slow-partial", + sessionId: SESSION_ID, + displayStatus: "running", + result: { status: "running" }, + source: "assistant", + }, + SESSION_ID + ); + let releaseBarrier: (() => void) | undefined; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + store.api.upsert.mockImplementationOnce(async () => { + await barrier; + }); + + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await vi.waitFor(() => expect(store.api.upsert).toHaveBeenCalledTimes(2)); + + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe("idle"); + releaseBarrier?.(); + await flush(); + + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe( + "cancelled" + ); + expect(callbacks.agentCompletes).toBe(1); + }); + + it("waits for an in-flight streamed row before publishing terminal state", async () => { + let releaseWrite: (() => void) | undefined; + const writeBarrier = new Promise((resolve) => { + releaseWrite = resolve; + }); + store.api.upsert.mockImplementationOnce(async (event, sessionId) => { + await writeBarrier; + const bucket = store.list(sessionId); + const index = bucket.findIndex( + (candidate) => candidate.id === event.id + ); + if (index >= 0) bucket[index] = { ...bucket[index], ...event }; + else bucket.push(event); + }); + + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "late-partial", + action_type: "assistant_delta", + result: { content: "durable before terminal", is_delta: true }, + }) + ) + ); + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await flush(); + + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe("idle"); + expect(callbacks.agentCompletes).toBe(0); + + releaseWrite?.(); + await vi.waitFor(() => expect(callbacks.agentCompletes).toBe(1)); + + expect( + eventsFor().find((event) => + String(event.id).startsWith("stream-msg-ts-") + ) + ).toMatchObject({ + displayText: "durable before terminal", + displayStatus: "completed", + isDelta: false, + result: { status: "completed" }, + }); + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe( + "cancelled" + ); + }); + + it("does not leave terminal state stuck when an EventStore write stalls", async () => { + vi.useFakeTimers(); + let releaseWrite: (() => void) | undefined; + const writeBarrier = new Promise((resolve) => { + releaseWrite = resolve; + }); + store.api.upsert.mockImplementationOnce(async (event, sessionId) => { + await writeBarrier; + store.list(sessionId).push(event); + }); + + try { + handler.handleEvent( + activityEvent( + makeChunk({ + chunk_id: "stalled-partial", + action_type: "assistant_delta", + result: { content: "late but durable", is_delta: true }, + }) + ) + ); + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await vi.advanceTimersByTimeAsync(3_999); + + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe( + "idle" + ); + expect(callbacks.agentCompletes).toBe(0); + + await vi.advanceTimersByTimeAsync(1); + + expect(getInstrumentedStore().get(sessionRuntimeStatusAtom)).toBe( + "cancelled" + ); + expect(callbacks.agentCompletes).toBe(1); + } finally { + releaseWrite?.(); + await vi.runAllTimersAsync(); + vi.useRealTimers(); + } + }); + + it("terminalizes cold persisted partial text and tool output after memory eviction", async () => { + store.list(SESSION_ID).push( + { + id: "cold-partial-answer", + sessionId: SESSION_ID, + actionType: "assistant_delta", + source: "assistant", + displayText: "I inspected the hidden runner.", + displayStatus: "running", + displayVariant: "message", + activityStatus: "agent", + result: { + content: "I inspected the hidden runner.", + status: "running", + }, + isDelta: true, + }, + { + id: "cold-partial-tool", + sessionId: SESSION_ID, + actionType: "tool_call", + functionName: "run_command_line", + source: "assistant", + displayText: "found one match\n", + displayStatus: "running", + displayVariant: "tool_call", + activityStatus: "agent", + callId: "call-cold-tool", + args: { command: "rg hidden" }, + result: { + status: "running", + output: "found one match\n", + observation: "found one match\n", + }, + isDelta: true, + } + ); + // Simulate an unmounted/evicted renderer window. The durable EventStore + // reader still returns both rows from the cache. + store.api.getEvents.mockResolvedValueOnce([]); + + handler.handleEvent({ + type: "code_session.status_changed", + session_id: SESSION_ID, + status: "cancelled", + }); + await flush(); + + expect(store.api.getPersistedEvents).toHaveBeenCalledWith(SESSION_ID); + expect( + eventsFor().find((event) => event.id === "cold-partial-answer") + ).toMatchObject({ + displayStatus: "completed", + isDelta: false, + result: { status: "completed" }, + }); + expect( + eventsFor().find((event) => event.id === "cold-partial-tool") + ).toMatchObject({ + displayStatus: "completed", + isDelta: false, + result: { + status: "interrupted", + interrupted: true, + output: "found one match\n", + }, + }); + }); + it("force-closes still-running events when the session ends", async () => { await store.api.upsert( { diff --git a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts index 82b8fc51da..53f0148923 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliHistory.ts @@ -9,7 +9,6 @@ import type { CliSessionStatus, } from "@src/types/session/session"; -import { registerSessionTranscriptSource } from "../../nativeTranscriptReconcile"; import type { PostLoadResult } from "../../types"; const log = createLogger("CliAdapter"); @@ -42,6 +41,20 @@ export async function loadCliHistory( return events.map(convertResultImages); } +/** + * Read the provider file set's opaque revision through the same Rust binding + * that owns CLI transcript replay. `undefined` means this is a legacy DB + * transcript; `null` means a native transcript is currently + * unbound/unavailable and must not be cached as a stable canonical snapshot. + */ +export async function loadCliTranscriptRevision( + sessionId: string +): Promise { + const result = await rpc.cli.transcriptRevision({ sessionId }); + if (!result.native) return undefined; + return result.revision ?? null; +} + export async function postLoadCliSession( sessionId: string, signal: AbortSignal @@ -53,7 +66,9 @@ export async function postLoadCliSession( })) as StoredSession | null; if (signal.aborted || !storedSession) return result; - registerSessionTranscriptSource(sessionId, storedSession.transcriptSource); + if (storedSession.transcriptSource) { + result.transcriptSource = storedSession.transcriptSource; + } if (typeof storedSession.totalTokens === "number") { result.contextTokens = storedSession.totalTokens; diff --git a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts index f2f5bd377e..0e05512282 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts @@ -4,6 +4,7 @@ import { } from "@src/engines/SessionCore/control/turnLifecycle"; import { isTurnBlockingRuntimeEvent } from "@src/engines/SessionCore/core/runningEventGate"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; import { setSessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; import type { CliSessionStatus } from "@src/types/session/session"; @@ -14,6 +15,45 @@ import { const log = createLogger("CliAdapter"); +// Keep the renderer terminal mirror within the dual-instance completion SLO. +// The underlying persistence promise is deliberately not cancelled: if a +// delayed EventStore RPC eventually settles, the adapter's existing +// reconcileTerminalEventsIfNeeded callback closes the late row. +const CLI_TERMINAL_PERSISTENCE_DEADLINE_MS = 4_000; + +/** + * EventStore writes started by the session-scoped CLI adapter. Native + * transcript reconciliation can be woken by the backend terminal intent + * before those async writes settle, so both paths join this one barrier. + */ +const pendingCliEventPersistence = new Map>>(); + +export function trackCliEventPersistence( + sessionId: string, + operation: Promise +): void { + const pending = pendingCliEventPersistence.get(sessionId) ?? new Set(); + pendingCliEventPersistence.set(sessionId, pending); + pending.add(operation); + const release = () => { + pending.delete(operation); + if (pending.size === 0) pendingCliEventPersistence.delete(sessionId); + }; + operation.then(release, release); +} + +export async function waitForCliEventPersistence( + sessionId: string +): Promise { + // A settling normalization can enqueue its EventStore write in the same + // microtask. Re-read the set until the session has no observed work left. + for (;;) { + const pending = pendingCliEventPersistence.get(sessionId); + if (!pending || pending.size === 0) return; + await Promise.allSettled([...pending]); + } +} + const CLI_TERMINAL_STATUSES = new Set([ "completed", "failed", @@ -25,16 +65,50 @@ const CLI_TERMINAL_STATUSES = new Set([ ]); export function isCliTerminalStatus( - status: CliSessionStatus | undefined + status: string | undefined ): status is CliSessionStatus { - return status !== undefined && CLI_TERMINAL_STATUSES.has(status); + return ( + status !== undefined && + CLI_TERMINAL_STATUSES.has(status as CliSessionStatus) + ); } -async function closeObservedCliTerminalEvents( +/** + * True when a terminal CLI row may own provider-portable EventStore output + * that never reached the provider transcript. Keep this derived from the + * central terminal classification so continuation cannot drift into a second + * raw-status allowlist. + */ +export function isInterruptedCliTerminalStatus( + status: string | undefined +): boolean { + if (!status || !CLI_TERMINAL_STATUSES.has(status as CliSessionStatus)) { + return false; + } + return cliTerminalStatus(status as CliSessionStatus) !== "completed"; +} + +function durableInterruptedToolOutput(event: SessionEvent): string | null { + for (const candidate of [event.result?.output, event.result?.observation]) { + if (typeof candidate === "string" && candidate.length > 0) { + return candidate; + } + } + return null; +} + +export async function closeObservedCliTerminalEvents( sessionId: string, status: CliSessionStatus ): Promise { - const events = await eventStoreProxy.getEvents(sessionId); + await waitForCliEventPersistence(sessionId); + // Interrupted native Sessions can be hidden, cold-started, or evicted from + // the in-memory turn window. Their portable suffix is durable EventStore + // state, so close against full persisted history. Completed turns stay on + // the cheap resident path because their provider transcript is authoritative. + const events = isInterruptedCliTerminalStatus(status) + ? await eventStoreProxy.getPersistedEvents(sessionId) + : await eventStoreProxy.getEvents(sessionId); const closableEvents = events.filter((event) => { if (event.sessionId && event.sessionId !== sessionId) return false; return isTurnBlockingRuntimeEvent(event); @@ -43,47 +117,94 @@ async function closeObservedCliTerminalEvents( const displayStatus = status === "failed" || status === "error" ? "failed" : "completed"; await Promise.all( - closableEvents.map((event) => - eventStoreProxy.upsert( + closableEvents.map((event) => { + const unresolvedToolCall = + event.actionType === "tool_call" || + Boolean(event.callId && event.functionName); + const interruptedToolHasOutput = + unresolvedToolCall && durableInterruptedToolOutput(event) !== null; + return eventStoreProxy.upsert( { ...event, displayStatus, activityStatus: "processed", - result: { ...event.result, status: displayStatus }, + // A visible assistant stream is useful partial conversation text, + // so terminalize it into a portable message. A running tool call is + // different: no provider may receive it without a paired result. + // Keep it in ORG2 as interrupted diagnostics, but leave a pending + // result fence when it has no output, so native projection drops it + // until a real result arrives and replaces this row. Output already + // observed before Stop is durable conversation state: close that + // pair as an interrupted result so another runtime receives it as a + // provider-native error result instead of silently losing stdout. + result: unresolvedToolCall + ? { + ...event.result, + status: interruptedToolHasOutput ? "interrupted" : "pending", + interrupted: true, + } + : { ...event.result, status: displayStatus }, isDelta: false, }, sessionId - ) - ) + ); + }) ); } export function markCliRuntimeRunning( sessionId: string, generation?: number -): void { - markTurnRunning(sessionId, { generation }); - if (!isStoreInitialized()) return; +): boolean { + if (!markTurnRunning(sessionId, { generation })) return false; + if (!isStoreInitialized()) return true; getInstrumentedStore().set(setSessionRuntimeStatusAtom, { sessionId, status: "running", source: "sync", }); + return true; } -export function markObservedCliTerminalStatus( +export async function markObservedCliTerminalStatus( sessionId: string, status: CliSessionStatus | undefined -): void { - if (!isCliTerminalStatus(status) || !isStoreInitialized()) return; +): Promise { + if (!isCliTerminalStatus(status) || !isStoreInitialized()) { + return; + } + let deadlineTimer: ReturnType | undefined; + const terminalPersistence = closeObservedCliTerminalEvents( + sessionId, + status + ).then( + () => "settled" as const, + (error) => { + log.warn("[cliAdapter] failed to close terminal CLI events:", error); + return "settled" as const; + } + ); + const deadline = new Promise<"deadline">((resolve) => { + deadlineTimer = setTimeout( + () => resolve("deadline"), + CLI_TERMINAL_PERSISTENCE_DEADLINE_MS + ); + }); + const persistenceResult = await Promise.race([terminalPersistence, deadline]); + if (deadlineTimer) clearTimeout(deadlineTimer); + if (persistenceResult === "deadline") { + log.warn( + `[cliAdapter] terminal EventStore reconciliation exceeded ${CLI_TERMINAL_PERSISTENCE_DEADLINE_MS}ms; publishing terminal state while late writes keep reconciling`, + { sessionId, status } + ); + } + // Runtime/model switching is exposed by this terminal mirror. Publish it + // only after every visible partial row crossed the EventStore barrier. getInstrumentedStore().set(setSessionRuntimeStatusAtom, { sessionId, status, source: "sync", }); - void closeObservedCliTerminalEvents(sessionId, status).catch((error) => { - log.warn("[cliAdapter] failed to close terminal CLI events:", error); - }); } export function cliTerminalStatus( diff --git a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts index e6612cff40..e6c90edb2e 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliTransport.ts @@ -22,6 +22,7 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { imageDataUrls, adeContext, directUserIntent, + allowNativeContextRecovery, } = input; const turnIntentId = input.turnIntentId ?? newMessageId(); const clientMessageId = input.clientMessageId ?? newMessageId(); @@ -38,6 +39,9 @@ export async function sendCliMessage(input: AdapterSendInput): Promise { ? { images: imageDataUrls } : {}), ...(adeContext ? { ideContext: adeContext } : {}), + ...(allowNativeContextRecovery + ? { allowNativeContextRecovery: true } + : {}), }, }); diff --git a/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts b/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts index 57f5e09fcd..92f4094a12 100644 --- a/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts +++ b/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts @@ -44,6 +44,7 @@ import type { AgentWSEvent, PermissionRequestEvent } from "../shared/types"; import { isCliTerminalStatus, markObservedCliTerminalStatus, + trackCliEventPersistence, } from "./cliLifecycle"; import { buildCliStreamingEvent } from "./streamingEvent"; @@ -63,9 +64,11 @@ export function createCliEventHandler( let msgContent = ""; let msgStreamId = ""; let msgStartedAt = ""; + let msgTurnIntentId: string | undefined; let thinkContent = ""; let thinkStreamId = ""; let thinkStartedAt = ""; + let thinkTurnIntentId: string | undefined; let observedTerminalStatus: CliSessionStatus | undefined; const finalizedStreamEventIds = new Set(); const toolCallDeltaBuffers = new Map< @@ -84,12 +87,14 @@ export function createCliEventHandler( msgContent = ""; msgStreamId = ""; msgStartedAt = ""; + msgTurnIntentId = undefined; } function clearThinkingStream(): void { thinkContent = ""; thinkStreamId = ""; thinkStartedAt = ""; + thinkTurnIntentId = undefined; } function clearToolCallDeltaBuffers(): void { @@ -108,13 +113,48 @@ export function createCliEventHandler( function reconcileTerminalEventsIfNeeded(): void { if (!observedTerminalStatus) return; - markObservedCliTerminalStatus(sessionId, observedTerminalStatus); + void markObservedCliTerminalStatus(sessionId, observedTerminalStatus).catch( + (error: unknown) => { + log.error("CLI terminal reconciliation failed", error); + } + ); + } + + /** + * Register the exact normalization + EventStore write with the CLI + * lifecycle barrier. The terminal status and background native reconcile + * may otherwise overtake this promise and snapshot an older transcript. + */ + function persistObservedEvent(operation: Promise): void { + trackCliEventPersistence(sessionId, operation); + void operation.then(reconcileTerminalEventsIfNeeded).catch((error) => { + log.warn("[CliAdapter] normalizeChunkRust failed:", error); + }); } function asString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } + /** Preserve an exact runner identity without reshaping opaque provider data. */ + function withTurnIntentId( + event: SessionEvent, + turnIntentId: string | undefined + ): SessionEvent { + if ( + !turnIntentId || + !event.result || + typeof event.result !== "object" || + Array.isArray(event.result) + ) { + return event; + } + return { + ...event, + result: { ...event.result, turnIntentId }, + }; + } + function getStore() { return isStoreInitialized() ? getInstrumentedStore() : null; } @@ -194,7 +234,10 @@ export function createCliEventHandler( * malformed-frame edge. Only the card is skipped now; the transcript row * is written either way, and the skip is logged. */ - function handlePlanApprovalActivity(chunk: ActivityChunk): boolean { + function handlePlanApprovalActivity( + chunk: ActivityChunk, + turnIntentId: string | undefined + ): boolean { if (chunk.action_type !== "plan_approval") return false; const args = chunk.args ?? {}; const planPath = asString(args.planPath); @@ -221,17 +264,18 @@ export function createCliEventHandler( }) ); } - normalizeChunkRust(chunk, sessionId) - .then((event) => { - eventStoreProxy.upsert(event, sessionId); - }) - .catch((error) => { - log.warn("[CliAdapter] normalizeChunkRust failed:", error); - }); + persistObservedEvent( + normalizeChunkRust(chunk, sessionId).then((event) => + eventStoreProxy.upsert(withTurnIntentId(event, turnIntentId), sessionId) + ) + ); return true; } - function handleToolCallDeltaActivity(chunk: ActivityChunk): void { + function handleToolCallDeltaActivity( + chunk: ActivityChunk, + turnIntentId: string | undefined + ): void { setStreamingMode(true); const indexValue = chunk.result?.index; const index = typeof indexValue === "number" ? indexValue : 0; @@ -260,20 +304,28 @@ export function createCliEventHandler( const parsed = parsePartialToolArgs(nextBuffer.argsJson); const args = buildToolArgsFromParsed(parsed); - eventStoreProxy.upsert( - makeToolCallEvent( - `tool-call-${nextBuffer.toolCallId}`, - sessionId, - nextBuffer.toolName, - nextBuffer.toolCallId, - args, - true - ), - sessionId + persistObservedEvent( + eventStoreProxy.upsert( + withTurnIntentId( + makeToolCallEvent( + `tool-call-${nextBuffer.toolCallId}`, + sessionId, + nextBuffer.toolName, + nextBuffer.toolCallId, + args, + true + ), + turnIntentId + ), + sessionId + ) ); } - function handleActivity(chunk: ActivityChunk): void { + function handleActivity( + chunk: ActivityChunk, + turnIntentId: string | undefined + ): void { if ( chunk.function === "user_message" && (chunk.action_type === "raw" || chunk.action_type === "raw_event") @@ -286,7 +338,7 @@ export function createCliEventHandler( const isDelta = chunk.result?.is_delta === true; const actionType = chunk.action_type; - if (handlePlanApprovalActivity(chunk)) return; + if (handlePlanApprovalActivity(chunk, turnIntentId)) return; const isMessageType = actionType === "assistant" || @@ -297,7 +349,7 @@ export function createCliEventHandler( actionType === "llm_thinking" || actionType === "llm_thinking_delta"; if (actionType === "tool_call_delta") { - handleToolCallDeltaActivity(chunk); + handleToolCallDeltaActivity(chunk, turnIntentId); return; } @@ -311,16 +363,22 @@ export function createCliEventHandler( msgStreamId = createStreamMessageId(sessionId); msgStartedAt = chunk.created_at || new Date().toISOString(); } + msgTurnIntentId ??= turnIntentId; msgContent = capStreamContent(mergeStreamingText(msgContent, deltaText)); - eventStoreProxy.upsert( - buildCliStreamingEvent( - msgStreamId, - sessionId, - msgContent, - "message", - msgStartedAt - ), - sessionId + persistObservedEvent( + eventStoreProxy.upsert( + withTurnIntentId( + buildCliStreamingEvent( + msgStreamId, + sessionId, + msgContent, + "message", + msgStartedAt + ), + msgTurnIntentId + ), + sessionId + ) ); return; } @@ -336,18 +394,24 @@ export function createCliEventHandler( thinkStreamId = createStreamThinkingId(sessionId); thinkStartedAt = chunk.created_at || new Date().toISOString(); } + thinkTurnIntentId ??= turnIntentId; thinkContent = capStreamContent( mergeStreamingText(thinkContent, deltaText) ); - eventStoreProxy.upsert( - buildCliStreamingEvent( - thinkStreamId, - sessionId, - thinkContent, - "thinking", - thinkStartedAt - ), - sessionId + persistObservedEvent( + eventStoreProxy.upsert( + withTurnIntentId( + buildCliStreamingEvent( + thinkStreamId, + sessionId, + thinkContent, + "thinking", + thinkStartedAt + ), + thinkTurnIntentId + ), + sessionId + ) ); return; } @@ -355,51 +419,37 @@ export function createCliEventHandler( // Final message/thinking chunks replace any TS typewriter placeholder. if (isMessageType || isThinkingType) { const tempId = isMessageType ? msgStreamId : thinkStreamId; - const reconcileAfterFinalEvent = () => { - reconcileTerminalEventsIfNeeded(); - }; - normalizeChunkRust(chunk, sessionId) - .then((event) => { + const persistence = normalizeChunkRust(chunk, sessionId).then( + async (event) => { + event = withTurnIntentId(event, turnIntentId); if (finalizedStreamEventIds.has(event.id)) return; if (tempId && tempId !== event.id) { if (isMessageType) clearMessageStream(); else clearThinkingStream(); rememberFinalizedStreamEvent(event.id); - eventStoreProxy - .replaceAndRemove(tempId, event, sessionId) - .then(reconcileAfterFinalEvent); + await eventStoreProxy.replaceAndRemove(tempId, event, sessionId); return; } - eventStoreProxy - .append([event], sessionId) - .then(reconcileAfterFinalEvent); - }) - .catch((error) => { - log.warn("[CliAdapter] normalizeChunkRust failed:", error); - }); + await eventStoreProxy.append([event], sessionId); + } + ); + persistObservedEvent(persistence); return; } - normalizeChunkRust(chunk, sessionId) - .then((event) => { - if (actionType === "tool_call") { - for (const [index, buffer] of toolCallDeltaBuffers.entries()) { - if (buffer.toolCallId && buffer.toolCallId === event.callId) { - toolCallDeltaBuffers.delete(index); - } + const persistence = normalizeChunkRust(chunk, sessionId).then((event) => { + event = withTurnIntentId(event, turnIntentId); + if (actionType === "tool_call") { + for (const [index, buffer] of toolCallDeltaBuffers.entries()) { + if (buffer.toolCallId && buffer.toolCallId === event.callId) { + toolCallDeltaBuffers.delete(index); } - eventStoreProxy - .upsert(event, sessionId) - .then(reconcileTerminalEventsIfNeeded); - return; } - eventStoreProxy - .append([event], sessionId) - .then(reconcileTerminalEventsIfNeeded); - }) - .catch((error) => { - log.warn("[CliAdapter] normalizeChunkRust failed:", error); - }); + return eventStoreProxy.upsert(event, sessionId); + } + return eventStoreProxy.append([event], sessionId); + }); + persistObservedEvent(persistence); } function handleStreamingComplete(raw: RawSessionEvent): void { @@ -416,35 +466,39 @@ export function createCliEventHandler( if (streamType === "message") { const tsTempId = msgStreamId; + const turnIntentId = msgTurnIntentId; clearMessageStream(); - const reconcileAfterCompleteMessage = () => { - reconcileTerminalEventsIfNeeded(); - }; + const attributedEvent = withTurnIntentId(completeEvent, turnIntentId); if (tsTempId && tsTempId !== completeEvent.id) { - eventStoreProxy - .replaceAndRemove(tsTempId, completeEvent, sessionId) - .then(reconcileAfterCompleteMessage); + const persistence = eventStoreProxy.replaceAndRemove( + tsTempId, + attributedEvent, + sessionId + ); + persistObservedEvent(persistence); } else { - eventStoreProxy - .upsert(completeEvent, sessionId) - .then(reconcileAfterCompleteMessage); + const persistence = eventStoreProxy.upsert(attributedEvent, sessionId); + persistObservedEvent(persistence); } } else if (streamType === "thinking") { const tsTempId = thinkStreamId; + const turnIntentId = thinkTurnIntentId; clearThinkingStream(); + const attributedEvent = withTurnIntentId(completeEvent, turnIntentId); if (tsTempId && tsTempId !== completeEvent.id) { - eventStoreProxy - .replaceAndRemove(tsTempId, completeEvent, sessionId) - .then(reconcileTerminalEventsIfNeeded); + const persistence = eventStoreProxy.replaceAndRemove( + tsTempId, + attributedEvent, + sessionId + ); + persistObservedEvent(persistence); } else { - eventStoreProxy - .upsert(completeEvent, sessionId) - .then(reconcileTerminalEventsIfNeeded); + const persistence = eventStoreProxy.upsert(attributedEvent, sessionId); + persistObservedEvent(persistence); } } else { - eventStoreProxy - .upsert(completeEvent, sessionId) - .then(reconcileTerminalEventsIfNeeded); + const persistence = eventStoreProxy.upsert(completeEvent, sessionId); + persistObservedEvent(persistence); } } @@ -458,9 +512,16 @@ export function createCliEventHandler( clearThinkingStream(); clearToolCallDeltaBuffers(); setStreamingMode(false); - markObservedCliTerminalStatus(sessionId, observedTerminalStatus); if (status === "cancelled") cancelled = true; - callbacks.onAgentComplete?.(); + // Do not expose the runtime as switchable until visible partial message + // buffers and interrupted tool-call fences are durably terminalized. + // Otherwise a fast Stop -> runtime switch can read the old native fork + // before EventStore owns the interrupted suffix. + void markObservedCliTerminalStatus(sessionId, observedTerminalStatus) + .then(() => callbacks.onAgentComplete?.()) + .catch((error: unknown) => { + log.error("CLI terminal completion failed", error); + }); } if (isSessionRuntimeExecuting(status)) { @@ -507,7 +568,10 @@ export function createCliEventHandler( } else if (raw.type === "agent:plan_approval_archived") { handlePlanApprovalArchivedBroadcast(raw); } else if (raw.type === "code_session.activity" && raw.chunk) { - handleActivity(raw.chunk as unknown as ActivityChunk); + handleActivity( + raw.chunk as unknown as ActivityChunk, + asString(raw.turn_intent_id) ?? asString(raw.turnIntentId) + ); } else if (raw.type === "agent:streaming_complete") { handleStreamingComplete(raw); } else if (raw.type === "code_session.status_changed") { diff --git a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts index 57dd842fda..639ce40e1e 100644 --- a/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts +++ b/src/engines/SessionCore/sync/adapters/externalHistoryAdapter.ts @@ -173,11 +173,36 @@ async function loadExternalHistory( return signal.aborted ? [] : events; } +async function loadAuthoritativeExternalHistory( + sessionId: string, + signal: AbortSignal +): Promise { + const source = getImportedHistorySourceBySessionId(sessionId); + if (!source) { + throw new Error( + `No imported-history source is registered for ${sessionId}` + ); + } + if (signal.aborted) return []; + + // Do not reuse the UI preview cache here. Native continuation and migration + // require every durable role/tool event, including the prefix intentionally + // omitted by a large transcript's initial viewport window. + const chunks = await source.loadFullTranscriptChunks(sessionId); + if (signal.aborted || !Array.isArray(chunks) || chunks.length === 0) { + return []; + } + const events = await processChunksRust(chunks, sessionId); + return signal.aborted ? [] : events; +} + export const externalHistoryAdapter: ExternalHistorySessionAdapter = { category: "external_history", loadHistory: loadExternalHistory, + loadAuthoritativeHistory: loadAuthoritativeExternalHistory, + loadHistoryFromObservedSignature: (sessionId, signal, observedSignature) => loadExternalHistory(sessionId, signal, observedSignature), diff --git a/src/engines/SessionCore/sync/adapters/shared/eventFactories.ts b/src/engines/SessionCore/sync/adapters/shared/eventFactories.ts index 5438c378b1..86c7fae6af 100644 --- a/src/engines/SessionCore/sync/adapters/shared/eventFactories.ts +++ b/src/engines/SessionCore/sync/adapters/shared/eventFactories.ts @@ -277,6 +277,8 @@ export function createSyntheticUserEvent( sessionId: string, content: string, options?: { + /** Reuse one optimistic row while retrying the same logical turn. */ + id?: string; createdAt?: string; imageDataUrls?: string[]; /** @@ -288,14 +290,23 @@ export function createSyntheticUserEvent( * Send Now). */ turnIntentId?: string; + /** Frontend delivery state for an optimistic user turn. */ + deliveryStatus?: "pending" | "sent" | "failed"; + deliveryError?: string; + queueMessageId?: string; + /** Terminal delivery transferred retry ownership to this durable row. */ + deliveryOwnerRetired?: boolean; } ): SessionEvent { // Synthetic user placeholders are distinguished by their frontend-only // event shape, not by ID prefix. CLI backend user events can also use // user-input-* IDs, so consumers must use isSyntheticUserInputEvent(). - const id = `${ID_PREFIX.USER_INPUT}${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const id = + options?.id ?? + `${ID_PREFIX.USER_INPUT}${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; const images = options?.imageDataUrls; const turnIntentId = options?.turnIntentId; + const deliveryStatus = options?.deliveryStatus; return { id, chunk_id: null, @@ -312,9 +323,24 @@ export function createSyntheticUserEvent( syntheticUserInput: true, ...(images && images.length > 0 ? { images } : {}), ...(turnIntentId ? { turnIntentId } : {}), + ...(deliveryStatus ? { deliveryStatus } : {}), + ...(options?.deliveryError + ? { deliveryError: options.deliveryError } + : {}), + ...(options?.queueMessageId + ? { queueMessageId: options.queueMessageId } + : {}), + ...(options?.deliveryOwnerRetired === true + ? { deliveryOwnerRetired: true } + : {}), }, displayText: content, - displayStatus: "completed", + displayStatus: + deliveryStatus === "pending" + ? "pending" + : deliveryStatus === "failed" + ? "failed" + : "completed", displayVariant: "message", activityStatus: "agent", isDelta: false, diff --git a/src/engines/SessionCore/sync/authoritativeSessionEvents.ts b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts new file mode 100644 index 0000000000..bc7430fede --- /dev/null +++ b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts @@ -0,0 +1,78 @@ +/** + * Canonical full-history read for one managed or imported local Session. + * + * Rust-native, managed CLI, and read-only external-history sessions are read + * through their established native-history adapters. EventStore is a + * render/cache projection and can be empty immediately after a transcript is + * seeded, so it cannot prove that a provider-native materialization + * round-tripped. + */ +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + isCliSession, + isCollaborationImportedSession, +} from "@src/util/session/sessionDispatch"; + +import { loadCliHistory } from "./adapters/cli/cliHistory"; +import { getAdapterForSession } from "./types"; + +export interface AuthoritativeSessionEvents { + events: SessionEvent[]; + source: + | "agent_history" + | "cli_history" + | "external_history" + | "collaboration_replay"; +} + +export async function loadAuthoritativeSessionEvents( + sessionId: string, + signal: AbortSignal = new AbortController().signal +): Promise { + if (isCliSession(sessionId)) { + return { + events: await loadCliHistory(sessionId, signal), + source: "cli_history", + }; + } + + if (isCollaborationImportedSession(sessionId)) { + return { + // A collaboration import is already the complete, cursor-verified Cloud + // replay persisted by collabSessionImport. It is deliberately not a + // provider external-history session and therefore has no native adapter. + events: await eventStoreProxy.getPersistedEvents(sessionId), + source: "collaboration_replay", + }; + } + + const adapter = getAdapterForSession(sessionId); + if ( + !adapter || + (adapter.category !== "agent" && adapter.category !== "external_history") + ) { + throw new Error( + `No authoritative native history reader is registered for ${sessionId}` + ); + } + if ( + adapter.category === "external_history" && + !adapter.loadAuthoritativeHistory + ) { + throw new Error( + `No authoritative full-history reader is registered for ${sessionId}` + ); + } + const events = + adapter.category === "external_history" + ? await adapter.loadAuthoritativeHistory!(sessionId, signal) + : await adapter.loadHistory(sessionId, signal); + return { + events, + source: + adapter.category === "external_history" + ? "external_history" + : "agent_history", + }; +} diff --git a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts index 4c7ef8f2bb..94d235fa8d 100644 --- a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts +++ b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts @@ -1,94 +1,275 @@ /** - * Post-turn reconcile for native-transcript CLI sessions. + * Single post-turn owner for provider-native transcript reconciliation. * - * Native-mode sessions stream ephemeral (in-memory only) events during a - * turn; the transcript of record is the CLI's own store, read back through - * `cli_agent_chunks` (which routes to the imported-history loaders). When a - * turn reaches a terminal status we reload once after a short settle delay - * so the in-memory events are replaced by the canonical parse, and retry - * once more in case the CLI flushed its store slightly after exiting. - * - * The registry is populated by the CLI adapter's postLoad (from - * `cli_agent_status.transcriptSource`); legacy sessions never reconcile. + * Native CLI adapters stream an ephemeral EventStore projection while the + * provider writes its own transcript. Once a turn is terminal, every caller + * (the visible Session sync and background canonical continuation) joins the + * same per-Session promise. This module alone reads the settled native file, + * preserves a durable interrupted suffix, replaces EventStore, and closes + * streaming. Conversation code may inspect the returned events, but must not + * race this owner with a second replace/merge pipeline. */ +import { rpc } from "@src/api/tauri/rpc"; +import { + mergeInterruptedConversationProjection, + nativeConversationItemsAreProviderPortablePrefix, + nativeSourceEventId, + projectNativeConversationItems, + sourceEventIdOfNativeItem, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + closeObservedCliTerminalEvents, + isCliTerminalStatus, +} from "@src/engines/SessionCore/sync/adapters/cli/cliLifecycle"; -const transcriptSourceBySession = new Map(); +import { loadAuthoritativeSessionEvents } from "./authoritativeSessionEvents"; +import { mergeFailedUserDeliveryProjection } from "./sessionSyncUtils"; -const RECONCILE_SETTLE_MS = 600; -const RECONCILE_RETRY_MS = 2000; +const MISMATCH_RECOVERY_DELAYS_MS = [250, 750] as const; -export function registerSessionTranscriptSource( - sessionId: string, - transcriptSource: string | undefined -): void { - if (transcriptSource) { - transcriptSourceBySession.set(sessionId, transcriptSource); +async function hasDurableNativeTranscript(sessionId: string): Promise { + const session = await rpc.cli.status({ sessionId }); + return session?.transcriptSource === "native"; +} + +export interface NativeTranscriptReconcileOptions { + /** Preserve provider-portable output that survived an interrupted flush. */ + preserveInterruptedSuffix?: boolean; +} + +interface ReconcileJob { + preserveInterruptedSuffix: boolean; + promise: Promise; +} + +const reconcileJobs = new Map(); + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function mergeProjection( + nativeEvents: readonly SessionEvent[], + projectedEvents: readonly SessionEvent[], + preserveInterruptedSuffix: boolean +): SessionEvent[] { + const interrupted = + preserveInterruptedSuffix && projectedEvents.length > 0 + ? mergeInterruptedConversationProjection(nativeEvents, projectedEvents) + : [...nativeEvents]; + return mergeFailedUserDeliveryProjection( + preserveAcceptedTurnIdentity(interrupted, projectedEvents), + projectedEvents + ); +} + +/** + * Native files do not know ORG2's accepted intent. Transfer only that metadata + * across the ordinary projection replacement, after proving the entire + * pre-turn history and accepted user message still match in order. Matching + * one prompt by text is insufficient: consecutive identical prompts are valid. + * Provider content remains authoritative; no streamed body is retained here. + */ +function preserveAcceptedTurnIdentity( + nativeEvents: readonly SessionEvent[], + projectedEvents: readonly SessionEvent[] +): SessionEvent[] { + // Identity belongs to the full audit log, not the compacted effective model + // context. Ignore compaction control rows only for this comparison so a + // compact in the current turn cannot erase its accepted user boundary. + const fullLog = (events: readonly SessionEvent[]) => + events.filter( + (event) => + event.actionType !== "context_compacted" && + event.functionName !== "context_compacted" + ); + const projectedItems = projectNativeConversationItems( + fullLog(projectedEvents) + ); + let anchor = -1; + for (let index = projectedItems.length - 1; index >= 0; index -= 1) { + const item = projectedItems[index]; + if (item.kind === "message" && item.role === "user" && item.turnId) { + anchor = index; + break; + } + } + if (anchor < 0) return nativeEvents as SessionEvent[]; + const accepted = projectedItems[anchor]; + if (accepted.kind !== "message" || !accepted.turnId) { + return nativeEvents as SessionEvent[]; } + const nativeItems = projectNativeConversationItems(fullLog(nativeEvents)); + if ( + !nativeConversationItemsAreProviderPortablePrefix( + projectedItems.slice(0, anchor + 1), + nativeItems + ) + ) { + return nativeEvents as SessionEvent[]; + } + const acceptedSourceId = sourceEventIdOfNativeItem(nativeItems[anchor]); + const userIndex = nativeEvents.findIndex( + (event) => nativeSourceEventId(event) === acceptedSourceId + ); + if ( + userIndex < 0 || + (nativeEvents[userIndex].result?.turnIntentId && + nativeEvents[userIndex].result?.turnIntentId !== accepted.turnId) + ) { + return nativeEvents as SessionEvent[]; + } + const nextUser = nativeEvents.findIndex( + (event, index) => index > userIndex && event.source === "user" + ); + return nativeEvents.map((event, index) => + index >= userIndex && + (nextUser < 0 || index < nextUser) && + !event.result?.turnIntentId + ? { ...event, result: { ...event.result, turnIntentId: accepted.turnId } } + : event + ); } -export function isNativeTranscriptSession(sessionId: string): boolean { - return transcriptSourceBySession.get(sessionId) === "native"; +async function publishNativeProjection( + sessionId: string, + nativeEvents: readonly SessionEvent[], + projectedEvents: readonly SessionEvent[], + preserveInterruptedSuffix: boolean +): Promise { + const events = mergeProjection( + nativeEvents, + projectedEvents, + preserveInterruptedSuffix + ); + // An authoritative empty transcript is still an authoritative replacement. + // Skipping the write here would leave stale streamed/projected rows visible + // after the provider history was cleared or reset. + await eventStoreProxy.set(events, sessionId); + return events; } -interface ReconcileDeps { - loadHistory: (sessionId: string) => Promise; - dispatchLoadSession: (payload: { - sessionId: string; - events: SessionEvent[]; - /** - * The native replay IS the canonical transcript: loadSessionAtom must - * replace the in-memory turn events (synthetic user bubble, streamed - * placeholders) instead of merging next to them — their ids never match - * the replayed rows, so a merge renders every turn twice. - */ - replace?: boolean; - }) => void; - /** The session still on screen? Stale reconciles are dropped. */ - isSessionLive: (sessionId: string) => boolean; +async function runReconcile( + sessionId: string, + job: ReconcileJob +): Promise { + // `code_sessions.transcript_source` is the authority. Hidden/background + // continuations may never mount a CLI adapter, so an in-memory UI registry + // cannot decide whether provider-native reconciliation is required. + const session = await rpc.cli.status({ sessionId }); + if (session?.transcriptSource !== "native") { + return loadAuthoritativeSessionEvents(sessionId).then( + ({ events }) => events + ); + } + if (job.preserveInterruptedSuffix && isCliTerminalStatus(session.status)) { + // The durable turn-intent terminal can wake a background continuation + // before the mounted CLI handler finishes closing its visible partial + // rows. Join the same EventStore barrier here so reconciliation never + // snapshots a still-delta assistant message or a still-running tool. + await closeObservedCliTerminalEvents(sessionId, session.status); + } + // The backend converges the provider transcript before broadcasting the + // terminal lifecycle. One authoritative read is therefore the normal path. + // Check the mutable preserve flag after every await so a foreground caller + // can still upgrade an in-flight background job without a settle delay. A + // normal completed turn never pays for a second full-history cache read. + const nativeEvents = await loadAuthoritativeSessionEvents(sessionId).then( + ({ events }) => events + ); + let preserveApplied = false; + const publishCurrentProjection = async (): Promise => { + // Accepted intent metadata and failed delivery rows belong to ORG2 even + // on success. Read them before replacement; a failed read must not erase + // the retry owner or make current output disappear while Cloud publishes. + const projectedEvents = await eventStoreProxy.getPersistedEvents(sessionId); + preserveApplied = job.preserveInterruptedSuffix; + return await publishNativeProjection( + sessionId, + nativeEvents, + projectedEvents, + job.preserveInterruptedSuffix + ); + }; + + let published = await publishCurrentProjection(); + if (job.preserveInterruptedSuffix && !preserveApplied) { + published = await publishCurrentProjection(); + } + + await eventStoreProxy.setStreaming(false, sessionId); + if (job.preserveInterruptedSuffix && !preserveApplied) { + published = await publishCurrentProjection(); + } + return published; } -const pendingReconciles = new Set(); +/** + * Exceptional recovery after the caller has proved that the authoritative + * read is missing its expected semantic prefix/user anchor. Normal terminal + * reconciliation never enters this bounded retry path. + */ +export async function recoverNativeTranscriptAfterMismatch( + sessionId: string, + initialEvents: SessionEvent[], + isRecovered: (events: readonly SessionEvent[]) => boolean, + options: NativeTranscriptReconcileOptions = {} +): Promise { + let events = initialEvents; + if (isRecovered(events)) return events; -export function scheduleNativeTranscriptReconcile( + for (const retryDelay of MISMATCH_RECOVERY_DELAYS_MS) { + await delay(retryDelay); + events = await reconcileNativeTranscript(sessionId, options); + if (isRecovered(events)) break; + } + return events; +} + +/** Await the unique native reconcile for a Session. */ +export function reconcileNativeTranscript( sessionId: string, - deps: ReconcileDeps -): void { - if (!isNativeTranscriptSession(sessionId)) return; - if (pendingReconciles.has(sessionId)) return; - pendingReconciles.add(sessionId); - - const runOnce = async (): Promise => { - if (!deps.isSessionLive(sessionId)) return -1; - const events = await deps.loadHistory(sessionId); - if (!deps.isSessionLive(sessionId)) return -1; - if (events.length > 0) { - deps.dispatchLoadSession({ sessionId, events, replace: true }); + options: NativeTranscriptReconcileOptions = {} +): Promise { + const existing = reconcileJobs.get(sessionId); + if (existing) { + if (options.preserveInterruptedSuffix) { + existing.preserveInterruptedSuffix = true; } - return events.length; - }; + return existing.promise; + } - void (async () => { - try { - await new Promise((resolve) => setTimeout(resolve, RECONCILE_SETTLE_MS)); - const firstCount = await runOnce(); - if (firstCount < 0) return; - // One retry catches a store flushed slightly after process exit; only - // re-dispatch when the parse actually grew (no pointless flicker). - await new Promise((resolve) => setTimeout(resolve, RECONCILE_RETRY_MS)); - if (!deps.isSessionLive(sessionId)) return; - const events = await deps.loadHistory(sessionId); - if ( - events.length > Math.max(firstCount, 0) && - deps.isSessionLive(sessionId) - ) { - deps.dispatchLoadSession({ sessionId, events, replace: true }); - } - } catch { - // Best-effort: the ephemeral in-memory events remain on screen; the - // next session open replays from the native store anyway. - } finally { - pendingReconciles.delete(sessionId); + const job: ReconcileJob = { + preserveInterruptedSuffix: Boolean(options.preserveInterruptedSuffix), + promise: Promise.resolve([]), + }; + job.promise = runReconcile(sessionId, job).finally(() => { + if (reconcileJobs.get(sessionId) === job) { + reconcileJobs.delete(sessionId); } - })(); + }); + reconcileJobs.set(sessionId, job); + return job.promise; +} + +/** Fire-and-forget bridge used by the ordinary visible Session lifecycle. */ +export function scheduleNativeTranscriptReconcile( + sessionId: string, + options: NativeTranscriptReconcileOptions = {} +): void { + // This fire-and-forget path is invoked for legacy chunk-backed CLI sessions + // too. Check the durable row before entering reconciliation so their + // terminal event does not trigger a needless full-history read. The actual + // reconcile rechecks the same authority and coalesces concurrent callers. + void hasDurableNativeTranscript(sessionId) + .then((isNative) => + isNative ? reconcileNativeTranscript(sessionId, options) : undefined + ) + .catch(() => { + // The ephemeral projection stays visible and a later open/recovery can + // retry from the provider transcript. Scheduling must never throw into a + // status event handler. + }); } diff --git a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts index f729d9f454..8f01d277b8 100644 --- a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts +++ b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts @@ -17,6 +17,8 @@ import { reconcileInFlightHistory } from "./sessionSyncReconcile"; import { type SessionLoadStateActions, applyPostLoadResult, + capturePostLoadLifecycleSnapshot, + isPostLoadRunStatusSuperseded, } from "./sessionSyncStateHelpers"; import type { SessionSyncRefs } from "./sessionSyncTypes"; import { @@ -123,6 +125,7 @@ async function handleCacheHit( actions.setLoadStatus("loading"); + const postLoadLifecycle = capturePostLoadLifecycleSnapshot(sessionId); const postResult = adapter.postLoad ? await adapter.postLoad(sessionId, abortController.signal) : null; @@ -133,12 +136,32 @@ async function handleCacheHit( // follow-up turn — treating that window as not-in-flight lets a stale // history replace wipe the just-sent message. const cacheHitInFlight = - isInFlightRunStatus(postResult?.runStatus) || isTurnActive(sessionId); + (!isPostLoadRunStatusSuperseded( + sessionId, + postResult?.runStatus, + postLoadLifecycle + ) && + isInFlightRunStatus(postResult?.runStatus)) || + isTurnActive(sessionId); let displayEvents = await eventStoreProxy.getEvents(sessionId); if (abortController.signal.aborted) return; if (!cacheHitInFlight) { - if (adapter.category === "agent") { + if ( + adapter.category === "agent" && + isCollaborationImportedSession(sessionId) + ) { + // The round window excludes local failed-delivery sidecars. Reuse the + // same persisted projection as a cold load so a cache hit cannot erase + // Retry/Edit after that cold load restored it. + displayEvents = await loadPersistedHistory( + adapter, + sessionId, + abortController.signal + ); + if (abortController.signal.aborted) return; + await hydrateSessionStoreBeforeDisplay(sessionId, displayEvents); + } else if (adapter.category === "agent") { await eventStoreProxy.loadInitialTurnWindow( sessionId, isCollaborationImportedSession(sessionId) ? 0 : undefined @@ -197,7 +220,9 @@ async function handleCacheHit( ) { reconcileInFlightHistory(sessionId, adapter, refs, actions); } - applyPostLoadResult(sessionId, postResult, actions); + applyPostLoadResult(sessionId, postResult, actions, { + lifecycleSnapshot: postLoadLifecycle, + }); } async function handleCursorIdeCacheHit( @@ -253,13 +278,20 @@ async function handleCacheMiss( actions.setLoadStatus("loading"); + const postLoadLifecycle = capturePostLoadLifecycleSnapshot(sessionId); const missPostResult = adapter.postLoad ? await adapter.postLoad(sessionId, abortController.signal) : null; if (abortController.signal.aborted) return; const missInFlight = - isInFlightRunStatus(missPostResult?.runStatus) || isTurnActive(sessionId); + (!isPostLoadRunStatusSuperseded( + sessionId, + missPostResult?.runStatus, + postLoadLifecycle + ) && + isInFlightRunStatus(missPostResult?.runStatus)) || + isTurnActive(sessionId); const events = !missInFlight ? await loadPersistedHistory(adapter, sessionId, abortController.signal) : await adapter.loadHistory(sessionId, abortController.signal); @@ -280,7 +312,9 @@ async function handleCacheMiss( reconcileInFlightHistory(sessionId, adapter, refs, actions); } - applyPostLoadResult(sessionId, missPostResult, actions); + applyPostLoadResult(sessionId, missPostResult, actions, { + lifecycleSnapshot: postLoadLifecycle, + }); rehydratePendingPlanApproval( sessionId, diff --git a/src/engines/SessionCore/sync/sessionSyncReconcile.ts b/src/engines/SessionCore/sync/sessionSyncReconcile.ts index 47b2f595a0..355e4ff4f5 100644 --- a/src/engines/SessionCore/sync/sessionSyncReconcile.ts +++ b/src/engines/SessionCore/sync/sessionSyncReconcile.ts @@ -1,10 +1,9 @@ import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { updateSessionStatus } from "@src/store/session"; -import { isNativeTranscriptSession } from "./nativeTranscriptReconcile"; import { type SessionLoadStateActions, applyPostLoadResult, + capturePostLoadLifecycleSnapshot, } from "./sessionSyncStateHelpers"; import type { SessionSyncRefs } from "./sessionSyncTypes"; import { @@ -12,8 +11,6 @@ import { hydrateSessionStoreBeforeDisplay, isTerminalRunStatus, loadPersistedHistory, - toCliSessionStatus, - toSessionListStatus, waitForReconcileDelay, } from "./sessionSyncUtils"; import type { SessionAdapter } from "./types"; @@ -44,6 +41,7 @@ export function reconcileInFlightHistory( await waitForReconcileDelay(delayMs); if (refs.liveSessionIdRef.current !== sessionId) return; + const postLoadLifecycle = capturePostLoadLifecycleSnapshot(sessionId); const postResult = adapter.postLoad ? await adapter.postLoad(sessionId, reconcileController.signal) : null; @@ -69,7 +67,7 @@ export function reconcileInFlightHistory( // EMPTY store (switched into a still-running session after a restart or // eviction) is hydrated, with replace semantics so a retry tick stays // idempotent; the terminal reconcile owns the final canonical replace. - if (isNativeTranscriptSession(sessionId)) { + if (postResult?.transcriptSource === "native") { const existingEvents = await eventStoreProxy.getEvents(sessionId); if (refs.liveSessionIdRef.current !== sessionId) return; if (existingEvents.length === 0) { @@ -95,26 +93,11 @@ export function reconcileInFlightHistory( actions.dispatchLoadSession({ sessionId, events: persistedEvents }); } - if (postResult?.contextTokens !== undefined) { - actions.setSessionContextTokens(postResult.contextTokens); - } - if (postResult?.contextUsage !== undefined) { - actions.setSessionContextUsage(postResult.contextUsage); - } - if (postResult?.runStatus !== undefined) { - // `runStatus` is the raw wire string. Narrow ONCE and feed both - // sinks from the narrowed value — the runtime atom and the session - // list row must never disagree, and a value outside the union must - // not reach `Session.status`, which drives sidebar grouping, Kanban - // lanes and every terminal-status predicate. - const runStatus = toCliSessionStatus(postResult.runStatus); - actions.setSessionRuntimeStatus(runStatus); - updateSessionStatus(sessionId, toSessionListStatus(runStatus)); - if (isTerminalRunStatus(postResult.runStatus)) return; - } - if (postResult?.runError !== undefined) { - actions.setSessionRuntimeError(postResult.runError); - } + applyPostLoadResult(sessionId, postResult, actions, { + lifecycleSnapshot: postLoadLifecycle, + acceptTerminalForUnchangedGeneration: true, + }); + if (isTerminalRunStatus(postResult?.runStatus)) return; } }; diff --git a/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts b/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts index 294417ff51..677f238725 100644 --- a/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts +++ b/src/engines/SessionCore/sync/sessionSyncStateHelpers.ts @@ -3,6 +3,8 @@ import type { SetStateAction } from "react"; import { wasRecentlyOptimisticallyStarted } from "@src/engines/SessionCore/control/optimisticTurnStatus"; import { getTurnIntentDispatch } from "@src/engines/SessionCore/control/turnIntentDispatchLifecycle"; import { + getLastTurnTerminal, + getTurnGeneration, isTurnActive, markTurnRunning, markTurnTerminal, @@ -93,7 +95,10 @@ export interface SessionEventHandlerStateActions { * native-store parse once a terminal status lands. No-op for legacy * (chunk-persisted) sessions. */ - scheduleNativeTranscriptReconcile?: (sessionId: string) => void; + scheduleNativeTranscriptReconcile?: ( + sessionId: string, + terminalStatus: string + ) => void; } const TERMINAL_HANDLER_STATUSES = new Set([ @@ -108,6 +113,50 @@ const RUNNING_HANDLER_STATUSES = new Set([ "waiting_for_user", "waiting_for_funds", ]); + +interface PostLoadLifecycleSnapshot { + readonly lastTerminal: ReturnType; + readonly generation: number; +} + +/** + * Capture the terminal edge visible when an async adapter post-load begins. + * Object identity is intentional: every accepted terminal replaces the + * lifecycle record, so a later comparison detects even two terminals in the + * same millisecond without relying on wall-clock ordering. + */ +export function capturePostLoadLifecycleSnapshot( + sessionId: string +): PostLoadLifecycleSnapshot { + return { + lastTerminal: getLastTurnTerminal(sessionId), + generation: getTurnGeneration(sessionId), + }; +} + +interface ApplyPostLoadResultOptions { + readonly lifecycleSnapshot?: PostLoadLifecycleSnapshot; + /** Reconcile may accept a terminal only if no newer dispatch won the race. */ + readonly acceptTerminalForUnchangedGeneration?: boolean; +} + +/** + * A post-load `running` snapshot must not resurrect a turn that reached a + * provider terminal while the DB/runtime read was in flight. + */ +export function isPostLoadRunStatusSuperseded( + sessionId: string, + runStatus: string | undefined, + snapshot: PostLoadLifecycleSnapshot | undefined +): boolean { + return Boolean( + snapshot && + runStatus && + RUNNING_HANDLER_STATUSES.has(runStatus) && + getLastTurnTerminal(sessionId) !== snapshot.lastTerminal + ); +} + export function resetSessionSwitchState( actions: SessionSwitchStateActions, sessionId?: string, @@ -149,7 +198,8 @@ export function applyPostLoadResult( | "setSessionContextUsage" | "setSessionRuntimeStatus" | "setSessionRuntimeError" - > + >, + options: ApplyPostLoadResultOptions = {} ): void { if (!postResult) return; if (postResult.contextTokens !== undefined) { @@ -159,6 +209,15 @@ export function applyPostLoadResult( actions.setSessionContextUsage(postResult.contextUsage); } if (postResult.runStatus !== undefined) { + if ( + isPostLoadRunStatusSuperseded( + sessionId, + postResult.runStatus, + options.lifecycleSnapshot + ) + ) { + return; + } if ( TERMINAL_HANDLER_STATUSES.has(postResult.runStatus) && isTurnActive(sessionId) @@ -168,7 +227,12 @@ export function applyPostLoadResult( // already dispatching/working — applying that stale terminal would // close the live turn's FSM and flip the composer mid-run. The live // status broadcast owns the transition; skip the stale snapshot. - return; + const acceptsReconcileTerminal = Boolean( + options.acceptTerminalForUnchangedGeneration && + options.lifecycleSnapshot && + getTurnGeneration(sessionId) === options.lifecycleSnapshot.generation + ); + if (!acceptsReconcileTerminal) return; } // `PostLoadResult.runStatus` is the raw wire string. Narrow it ONCE here // and feed both destinations from the narrowed value: the runtime atom and @@ -176,14 +240,23 @@ export function applyPostLoadResult( // values outside the union (and the CLI-only `installing`) reach sidebar // grouping, Kanban lanes and every terminal-status predicate. const runStatus = toCliSessionStatus(postResult.runStatus); - actions.setSessionRuntimeStatus(runStatus); if (TERMINAL_HANDLER_STATUSES.has(postResult.runStatus)) { - markTurnTerminal(sessionId, toTurnTerminalStatus(postResult.runStatus)); + const accepted = markTurnTerminal( + sessionId, + toTurnTerminalStatus(postResult.runStatus), + { + generation: options.acceptTerminalForUnchangedGeneration + ? options.lifecycleSnapshot?.generation + : undefined, + } + ); + if (!accepted) return; } else if (RUNNING_HANDLER_STATUSES.has(postResult.runStatus)) { // Restored a session whose turn is still in flight — open the turn so // queueing decisions see it as active until the provider terminal lands. - markTurnRunning(sessionId); + if (!markTurnRunning(sessionId)) return; } + actions.setSessionRuntimeStatus(runStatus); updateSessionStatus(sessionId, toSessionListStatus(runStatus)); } if (postResult.runError !== undefined) { @@ -258,29 +331,46 @@ export function createSessionEventHandlerCallbacks( // session status. Finality attribution and presentation state must move // together or not at all. if (terminalDispatch && terminalDispatch.sessionId !== sessionId) return; + // The same rule applies across turns of one session. A delayed terminal + // from generation N must not flip the runtime mirror to completed after + // the user has already reserved generation N+1 during native-history + // preparation; markTurnTerminal rejects it, so reject the presentation + // writes here as well. + if ( + terminalDispatch && + terminalDispatch.generation !== getTurnGeneration(sessionId) + ) { + return; + } // `status` is the raw wire string off the provider event. Narrow once so // the runtime atom and the session-list row below are both written from // a validated value rather than an `as` cast. const cliStatus = toCliSessionStatus(status); - actions.setSessionRuntimeStatus(cliStatus); - if (status === "failed" && errorMessage) { - actions.setSessionRuntimeError(errorMessage); - } + let lifecycleAccepted = true; if (TERMINAL_HANDLER_STATUSES.has(status)) { // Turn finality has exactly one ingestion point: a terminal status // here. Intermediate signals already returned above. - markTurnTerminal( + lifecycleAccepted = markTurnTerminal( sessionId, toTurnTerminalStatus(meta?.turnStatus ?? status), { generation: terminalDispatch?.generation } ); + } else if (isSessionRuntimeExecuting(status)) { + lifecycleAccepted = markTurnRunning(sessionId); + } + if (!lifecycleAccepted) return; + + actions.setSessionRuntimeStatus(cliStatus); + if (status === "failed" && errorMessage) { + actions.setSessionRuntimeError(errorMessage); + } + if (TERMINAL_HANDLER_STATUSES.has(status)) { actions.setPendingCancel(false); eventStoreProxy.unpinSession(sessionId); updateSessionStatus(sessionId, toSessionListStatus(cliStatus)); - actions.scheduleNativeTranscriptReconcile?.(sessionId); + actions.scheduleNativeTranscriptReconcile?.(sessionId, status); } if (isSessionRuntimeExecuting(status)) { - markTurnRunning(sessionId); actions.setSessionRuntimeError(null); eventStoreProxy.pinSession(sessionId); actions.setSessionRolledBack(false); diff --git a/src/engines/SessionCore/sync/sessionSyncUtils.ts b/src/engines/SessionCore/sync/sessionSyncUtils.ts index 85e9f469b2..4256417cda 100644 --- a/src/engines/SessionCore/sync/sessionSyncUtils.ts +++ b/src/engines/SessionCore/sync/sessionSyncUtils.ts @@ -10,9 +10,11 @@ import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { + getSessionMetadata, loadEvents, loadInitialTurnWindow, } from "@src/engines/SessionCore/storage/cacheAdapter"; +import { isSyntheticUserInputEvent } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; import type { CliSessionStatus, @@ -131,10 +133,62 @@ export async function loadOwnSessionInitialEvents( sessionId, isCollaborationImportedSession(sessionId) ? 0 : undefined ); - if (window.turns.length === 0) { - return loadEvents(sessionId); + const history = + window.turns.length === 0 ? await loadEvents(sessionId) : window.events; + if (!isCollaborationImportedSession(sessionId)) return history; + // Both registered-agent and not-yet-registered imports use this loader. + // Round windows omit local delivery sidecars; restore them here rather + // than making adapter registration determine whether Retry is visible. + const failedProjection = await loadFailedUserDeliveryProjection(sessionId); + return mergeFailedUserDeliveryProjection(history, failedProjection); +} + +/** + * Provider-native history cannot contain a user turn rejected before native + * acceptance. EventStore persists that one terminal delivery projection so + * Retry/Edit remains visible after restart; merge only those rows back into + * the UI history. Pending dispatch still belongs to the durable queue and + * accepted turns still belong to the provider transcript. + */ +export function mergeFailedUserDeliveryProjection( + history: readonly SessionEvent[], + projected: readonly SessionEvent[] +): SessionEvent[] { + const historyIds = new Set(history.map((event) => event.id)); + const failed = projected.filter( + (event) => + !historyIds.has(event.id) && + isSyntheticUserInputEvent(event) && + event.result?.deliveryStatus === "failed" && + typeof event.result?.turnIntentId === "string" && + event.result.turnIntentId.length > 0 + ); + if (failed.length === 0) return history as SessionEvent[]; + + const merged = [...history]; + for (const event of failed) { + const insertAt = merged.findIndex( + (candidate) => candidate.createdAt > event.createdAt + ); + if (insertAt < 0) merged.push(event); + else merged.splice(insertAt, 0, event); } - return window.events; + return merged; +} + +async function loadFailedUserDeliveryProjection( + sessionId: string +): Promise { + // Avoid cache_load_session_events' provider fallback when no SQLite rows + // exist; a large native transcript must be parsed exactly once per load. + const metadata = await getSessionMetadata(sessionId); + if (!metadata || metadata.eventCount === 0) return []; + const cached = await loadEvents(sessionId); + return cached.filter( + (event) => + isSyntheticUserInputEvent(event) && + event.result?.deliveryStatus === "failed" + ); } export async function loadPersistedHistory( @@ -144,12 +198,16 @@ export async function loadPersistedHistory( ): Promise { if (adapter.category === "agent") { const events = await loadOwnSessionInitialEvents(sessionId); - if (events.length > 0 || signal.aborted) { - return events; - } - return adapter.loadHistory(sessionId, signal); + if (signal.aborted) return []; + return events.length > 0 + ? events + : await adapter.loadHistory(sessionId, signal); } - return adapter.loadHistory(sessionId, signal); + const history = await adapter.loadHistory(sessionId, signal); + if (signal.aborted || adapter.category !== "cli") return history; + const failedProjection = await loadFailedUserDeliveryProjection(sessionId); + if (signal.aborted) return []; + return mergeFailedUserDeliveryProjection(history, failedProjection); } export async function hydrateSessionStoreBeforeDisplay( diff --git a/src/engines/SessionCore/sync/types.ts b/src/engines/SessionCore/sync/types.ts index 8ad38714ce..3e61668af7 100644 --- a/src/engines/SessionCore/sync/types.ts +++ b/src/engines/SessionCore/sync/types.ts @@ -63,6 +63,8 @@ export interface PostLoadResult { runStatus?: string; /** Session error message (sets sessionRuntimeErrorAtom). */ runError?: string | null; + /** Durable transcript owner reported by the session backend. */ + transcriptSource?: string; } // ============================================================================ @@ -166,6 +168,8 @@ export interface AdapterSendInput { turnIntentSource: TurnIntentSource; /** True only for a real user-authored prompt (not resume/wake/continuation). */ directUserIntent?: boolean; + /** Permit guarded native recovery after canonical synchronization. */ + allowNativeContextRecovery?: boolean; /** * When `true`, this is a user-initiated Resume after a failed turn. * The backend runs deletion-based orphan tool-use filter instead of @@ -193,6 +197,19 @@ export interface SessionAdapter { */ loadHistory(sessionId: string, signal: AbortSignal): Promise; + /** + * Load the complete, lossless persisted transcript for operations whose + * correctness depends on the entire conversation (native materialization, + * migration, and canonical verification). Most managed adapters can omit + * this because `loadHistory` is already complete. Imported-history adapters + * must implement it because their normal `loadHistory` is intentionally a + * bounded UI preview. + */ + loadAuthoritativeHistory?( + sessionId: string, + signal: AbortSignal + ): Promise; + /** * Post-load setup: restore session status, token counts, etc. * Returns metadata for the unified hook to apply to global atoms. diff --git a/src/engines/SessionCore/sync/useSessionEventIngestion.ts b/src/engines/SessionCore/sync/useSessionEventIngestion.ts new file mode 100644 index 0000000000..e326ec4aa5 --- /dev/null +++ b/src/engines/SessionCore/sync/useSessionEventIngestion.ts @@ -0,0 +1,70 @@ +import { useEffect } from "react"; + +import { parseRawSessionEvent } from "@src/engines/SessionCore/core/schemas"; +import "@src/engines/SessionCore/sync/adapters"; +import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; +import { subscribeToSessionEvents } from "@src/engines/SessionCore/sync/useSessionChannel"; + +interface SharedSessionEventIngestion { + disposeHandler: () => void; + unsubscribeChannel: () => void; + subscribers: number; +} + +const sharedSessionEventIngestions = new Map< + string, + SharedSessionEventIngestion +>(); + +/** + * Share the stateful adapter handler as well as the backend channel. Streaming + * handlers accumulate deltas, so two handlers must not consume the same frame + * and manufacture two temporary rows for one provider stream. + */ +export function subscribeToSessionEventIngestion( + sessionId: string +): () => void { + let shared = sharedSessionEventIngestions.get(sessionId); + if (!shared) { + const adapter = getAdapterForSession(sessionId); + if (!adapter) return () => undefined; + const handler = adapter.createEventHandler(sessionId, {}); + const unsubscribeChannel = subscribeToSessionEvents(sessionId, (raw) => { + handler.handleEvent(parseRawSessionEvent(raw)); + }); + shared = { + disposeHandler: () => handler.dispose(), + unsubscribeChannel, + subscribers: 0, + }; + sharedSessionEventIngestions.set(sessionId, shared); + } + shared.subscribers += 1; + + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + const current = sharedSessionEventIngestions.get(sessionId); + if (!current) return; + current.subscribers -= 1; + if (current.subscribers > 0) return; + sharedSessionEventIngestions.delete(sessionId); + current.unsubscribeChannel(); + current.disposeHandler(); + }; +} + +/** + * Feed one session's existing adapter from its shared backend IPC channel. + * + * Visible SessionCore surfaces already mount this edge through useSessionSync. + * Hidden executions (group members and canonical-conversation runners) use + * this hook so their live frames reach the same EventStore ingestion owner. + */ +export function useSessionEventIngestion(sessionId: string | null): void { + useEffect(() => { + if (!sessionId) return; + return subscribeToSessionEventIngestion(sessionId); + }, [sessionId]); +} diff --git a/src/engines/SessionCore/sync/useSessionSync.ts b/src/engines/SessionCore/sync/useSessionSync.ts index 93d378519d..fdbac93b6f 100644 --- a/src/engines/SessionCore/sync/useSessionSync.ts +++ b/src/engines/SessionCore/sync/useSessionSync.ts @@ -40,6 +40,7 @@ import { pendingPlanApprovalsAtom } from "@src/store/session/planApprovalAtom"; import { wpReadOnlyAtom } from "@src/store/ui/chatPanel/miscAtoms"; import "./adapters"; +import { isInterruptedCliTerminalStatus } from "./adapters/cli/cliLifecycle"; import { useExternalHistoryAutoRefresh } from "./externalHistoryAutoRefresh"; import { scheduleNativeTranscriptReconcile } from "./nativeTranscriptReconcile"; import { @@ -191,19 +192,13 @@ export function useSessionSync( ); const scheduleReconcile = useCallback( - (sid: string) => { + (sid: string, terminalStatus: string) => { scheduleNativeTranscriptReconcile(sid, { - loadHistory: async (target) => { - const adapter = getAdapterForSession(target); - if (!adapter) return []; - const controller = new AbortController(); - return adapter.loadHistory(target, controller.signal); - }, - dispatchLoadSession, - isSessionLive: (target) => liveSessionIdRef.current === target, + preserveInterruptedSuffix: + isInterruptedCliTerminalStatus(terminalStatus), }); }, - [dispatchLoadSession] + [] ); const handlerActions = useMemo( diff --git a/src/features/ConversationContinuation/canonicalConversationDispatcher.recovery.test.ts b/src/features/ConversationContinuation/canonicalConversationDispatcher.recovery.test.ts new file mode 100644 index 0000000000..878c915013 --- /dev/null +++ b/src/features/ConversationContinuation/canonicalConversationDispatcher.recovery.test.ts @@ -0,0 +1,279 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import type { QueuedConversationExecutionMessage } from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { + QueuedConversationRecoveryPendingError, + QueuedConversationTurnFailedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; + +import { dispatchQueuedCanonicalConversation } from "./canonicalConversationDispatcher"; + +const mocks = vi.hoisted(() => ({ + order: [] as string[], + loadTimeline: vi.fn(), + continueLocal: vi.fn(), + recoverLocal: vi.fn(), + dispatchCloud: vi.fn(), + authorityLive: vi.fn(() => true), + cliStatus: vi.fn( + async (): Promise<{ errorMessage?: string | null } | null> => null + ), +})); + +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { status: mocks.cliStatus } }, +})); +vi.mock("@src/api/tauri/externalHistory", () => ({ + getImportedHistorySourceBySessionId: vi.fn(() => undefined), +})); +vi.mock( + "@src/engines/SessionCore/conversations/localConversationExecutionTail", + () => ({ loadLocalCanonicalConversationTimeline: mocks.loadTimeline }) +); +vi.mock( + "@src/engines/SessionCore/conversations/localConversationContinuation", + () => ({ + continueLocalConversationAfterTimelineLoad: mocks.continueLocal, + localConversationRootForSession: (sessionId: string) => ({ + authority: "local-session", + authorityScope: [], + conversationId: sessionId, + }), + recoverLocalConversationTurn: mocks.recoverLocal, + }) +); +vi.mock( + "@src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter", + () => ({ + dispatchQueuedCloudConversation: mocks.dispatchCloud, + }) +); +vi.mock("@src/features/Org2Cloud/org2CloudRemoteSessionsAtom", async () => { + const { atom } = await import("jotai"); + return { org2CloudRemoteSessionsAtom: atom({}) }; +}); +vi.mock( + "@src/features/Org2Cloud/SessionConversation/cloudConversationAuthority", + () => ({ cloudConversationAuthorityIsLive: mocks.authorityLive }) +); +vi.mock("@src/store/session", async () => { + const { atom } = await import("jotai"); + return { + sessionsAtom: atom([ + { session_id: "source-session", name: "Source" }, + { session_id: "sdeagent-expired", name: "Expired share" }, + { + session_id: "runner-rejected", + name: "Runner", + error_message: + '{"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The requested model is not available"}}', + }, + ]), + }; +}); +vi.mock("./externalHistoryContinuation", () => ({ + resolveExternalHistoryContinuation: vi.fn(), +})); + +function message(): QueuedConversationExecutionMessage { + return { + id: "queue-1", + turnIntentId: "turn-1", + sessionId: "source-session", + content: "continue", + displayContent: "continue", + status: "preparing", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "source-session", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }, + }; +} + +describe("queued local conversation runner recovery", () => { + it("loads the verified root plus execution-child timeline at queue head", async () => { + const canonicalTimeline = [{ id: "root" }, { id: "claude-tail" }]; + mocks.loadTimeline.mockResolvedValueOnce(canonicalTimeline); + mocks.continueLocal.mockImplementationOnce(async (params) => { + expect(await params.loadTimeline()).toBe(canonicalTimeline); + expect(params.queueMessageId).toBe(message().id); + }); + + await dispatchQueuedCanonicalConversation(createStore(), message(), { + onAccepted: vi.fn(), + }); + + expect(mocks.loadTimeline).toHaveBeenCalledWith( + message().conversationDispatch?.root + ); + }); + + it("keeps recovery pending when a native child's durable runner receipt fails", async () => { + mocks.order.length = 0; + mocks.continueLocal.mockImplementation(async (params) => { + await params.onSessionReady?.("cliagent-child", 7); + }); + const receiptFailure = new Error("disk temporarily unavailable"); + const onRunnerReady = vi.fn(async () => { + mocks.order.push("persist"); + throw receiptFailure; + }); + + await expect( + dispatchQueuedCanonicalConversation(createStore(), message(), { + onAccepted: vi.fn(), + onRunnerReady, + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.order).toEqual(["persist"]); + expect(onRunnerReady).toHaveBeenCalledWith("cliagent-child", 7); + }); + + it("runs a Cloud-rooted turn locally once the owner's Cloud root row is gone", async () => { + mocks.dispatchCloud.mockReset(); + mocks.continueLocal.mockReset(); + mocks.loadTimeline.mockResolvedValue({ + sourceSession: undefined, + sessions: [], + timeline: [], + }); + mocks.continueLocal.mockResolvedValue(undefined); + mocks.authorityLive.mockReturnValue(false); + const store = createStore(); + const cloudMessage: QueuedConversationExecutionMessage = { + ...message(), + sessionId: "sdeagent-expired", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "sdeagent-expired", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + dispatchIdentityKey: "https://cloud.example|user-1", + }, + }; + + await dispatchQueuedCanonicalConversation(store, cloudMessage, { + onAccepted: vi.fn(), + }); + + expect(mocks.authorityLive).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ session_id: "sdeagent-expired" }), + target: { orgId: "org-1", sessionId: "sdeagent-expired" }, + }) + ); + expect(mocks.dispatchCloud).not.toHaveBeenCalled(); + expect(mocks.continueLocal).toHaveBeenCalledWith( + expect.objectContaining({ + root: { + authority: "local-session", + authorityScope: [], + conversationId: "sdeagent-expired", + }, + }) + ); + }); + + it("keeps the Cloud authority while the owner's root row is still listed", async () => { + mocks.dispatchCloud.mockReset(); + mocks.dispatchCloud.mockResolvedValue(undefined); + mocks.authorityLive.mockReturnValue(true); + const store = createStore(); + const cloudMessage: QueuedConversationExecutionMessage = { + ...message(), + sessionId: "sdeagent-expired", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "sdeagent-expired", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + dispatchIdentityKey: "https://cloud.example|user-1", + }, + }; + + await dispatchQueuedCanonicalConversation(store, cloudMessage, { + onAccepted: vi.fn(), + }); + + expect(mocks.dispatchCloud).toHaveBeenCalledTimes(1); + }); + + it("holds a definitively failed local turn with the provider's reason", async () => { + mocks.continueLocal.mockReset(); + mocks.continueLocal.mockResolvedValue({ + sessionId: "runner-rejected", + terminalStatus: "failed", + agentTail: [], + }); + + await expect( + dispatchQueuedCanonicalConversation(createStore(), message(), { + onAccepted: vi.fn(), + }) + ).rejects.toMatchObject({ + name: "QueuedConversationTurnFailedError", + message: "The requested model is not available", + }); + }); + + it("reads the provider's reason from the runner row when the store has none", async () => { + mocks.continueLocal.mockReset(); + mocks.continueLocal.mockResolvedValue({ + sessionId: "runner-cold", + terminalStatus: "failed", + agentTail: [], + }); + mocks.cliStatus.mockResolvedValueOnce({ + errorMessage: + '{"type":"error","status":400,"error":{"message":"The model is not supported"}}', + }); + + await expect( + dispatchQueuedCanonicalConversation(createStore(), message(), { + onAccepted: vi.fn(), + }) + ).rejects.toMatchObject({ message: "The model is not supported" }); + expect(mocks.cliStatus).toHaveBeenCalledWith({ sessionId: "runner-cold" }); + }); + + it("does not fail a turn that produced a tail before its terminal", async () => { + mocks.continueLocal.mockReset(); + mocks.continueLocal.mockResolvedValue({ + sessionId: "runner-rejected", + terminalStatus: "failed", + agentTail: [{ id: "partial" } as never], + }); + + await expect( + dispatchQueuedCanonicalConversation(createStore(), message(), { + onAccepted: vi.fn(), + }) + ).resolves.toBeUndefined(); + expect(QueuedConversationTurnFailedError).toBeDefined(); + }); +}); diff --git a/src/features/ConversationContinuation/canonicalConversationDispatcher.ts b/src/features/ConversationContinuation/canonicalConversationDispatcher.ts new file mode 100644 index 0000000000..614550d548 --- /dev/null +++ b/src/features/ConversationContinuation/canonicalConversationDispatcher.ts @@ -0,0 +1,258 @@ +import type { Store } from "jotai/vanilla/store"; + +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { rpc } from "@src/api/tauri/rpc"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + type ContinueLocalConversationResult, + continueLocalConversationAfterTimelineLoad, + localConversationRootForSession, + recoverLocalConversationTurn, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { loadLocalCanonicalConversationTimeline } from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; +import type { + QueuedConversationDispatcher, + QueuedConversationExecutionMessage, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnFailedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { cloudConversationAuthorityIsLive } from "@src/features/Org2Cloud/SessionConversation/cloudConversationAuthority"; +import { dispatchQueuedCloudConversation } from "@src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter"; +import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import type { Session } from "@src/store/session"; +import { sessionsAtom } from "@src/store/session"; + +import { resolveExternalHistoryContinuation } from "./externalHistoryContinuation"; + +function sessionById(store: Store, sessionId: string): Session | undefined { + return store + .get(sessionsAtom) + .find((candidate) => candidate.session_id === sessionId); +} + +async function dispatchQueuedLocalConversation( + store: Store, + message: QueuedConversationExecutionMessage, + callbacks: Parameters[2] +): Promise { + const descriptor = message.conversationDispatch; + if (!descriptor) throw new Error("canonical conversation target is missing"); + const { root } = descriptor; + let { target } = descriptor; + const sourceSession = sessionById(store, message.sessionId); + let title = sourceSession?.name ?? "Conversation"; + if (getImportedHistorySourceBySessionId(message.sessionId)) { + const resolved = await resolveExternalHistoryContinuation({ + sourceSessionId: message.sessionId, + sourceSession, + target, + }); + target = resolved.target; + title = resolved.title; + } + + if ( + root.authority !== "local-session" && + root.authority !== "imported-history" + ) { + throw new Error( + `unsupported local conversation authority: ${root.authority}` + ); + } + let runnerReady = false; + let providerAccepted = message.status === "accepted"; + const announceRunner = async ( + sessionId: string, + eventStartIndex: number + ): Promise => { + runnerReady = true; + try { + await callbacks.onRunnerReady?.(sessionId, eventStartIndex); + } catch (error) { + // The native child already exists at this boundary. Keep the global + // execution owner so the same turn can reconnect to that child; treating + // this as an ordinary send failure would create another native episode. + throw new QueuedConversationRecoveryPendingError( + `runner ${sessionId} recovery receipt could not be persisted: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + }; + const continuationParams = { + root, + title, + // Local/imported roots accumulate durable provider-native execution + // episodes. Read the verified child suffixes as part of the same canonical + // timeline instead of repeatedly rebuilding from the original source only. + loadTimeline: () => loadLocalCanonicalConversationTimeline(root), + displayText: message.displayContent, + agentContent: message.content, + imageDataUrls: message.imageDataUrls, + target, + turnIntentId: message.turnIntentId, + queueMessageId: message.id, + onSessionPreparing: (sessionId: string) => + announceRunner(sessionId, Number.MAX_SAFE_INTEGER), + onSessionReady: announceRunner, + onTurnAccepted: async (sessionId: string) => { + providerAccepted = true; + await callbacks.onAccepted(sessionId); + }, + }; + const settleTerminal = async ( + result: ContinueLocalConversationResult | null | undefined + ) => { + if ( + !result || + result.terminalStatus !== "failed" || + result.agentTail.length > 0 + ) { + return; + } + // The provider accepted the turn and then rejected it outright (for + // example a model the account cannot use). Nothing landed on the root, + // so the user's row must carry the reason and stay retryable instead of + // sitting under "Agent is idle" as if it had been answered. + throw new QueuedConversationTurnFailedError( + await localTurnFailureReason(store, result.sessionId) + ); + }; + if (message.runnerSessionId) { + const recovered = await recoverLocalConversationTurn({ + ...continuationParams, + timeline: await continuationParams.loadTimeline(), + runnerSessionId: message.runnerSessionId, + eventStartIndex: message.runnerEventStartIndex, + }); + if (recovered) { + await settleTerminal(recovered); + return; + } + if (message.status === "accepted") { + throw new QueuedConversationRecoveryPendingError(); + } + } + let result: ContinueLocalConversationResult | undefined; + try { + result = + await continueLocalConversationAfterTimelineLoad(continuationParams); + } catch (error) { + if ( + error instanceof QueuedConversationRecoveryPendingError && + !runnerReady && + !providerAccepted + ) { + // Candidate/source inspection happens before a visible native runner or + // provider boundary. Keep the user's intent visible in the existing + // held queue instead of hiding it behind an execution retry loop. + throw new QueuedConversationBlockedError(error.message); + } + throw error; + } + await settleTerminal(result); +} + +const DEFAULT_LOCAL_TURN_FAILURE = "Agent request failed"; + +async function localTurnFailureReason( + store: Store, + runnerSessionId: string +): Promise { + const fromStore = sessionById(store, runnerSessionId)?.error_message?.trim(); + const raw = + fromStore || + (await rpc.cli + .status({ sessionId: runnerSessionId }) + .then((stored) => + ( + stored as { errorMessage?: string | null } | null + )?.errorMessage?.trim() + ) + .catch(() => undefined)); + if (!raw) return DEFAULT_LOCAL_TURN_FAILURE; + return parseFailureReason(raw); +} + +function parseFailureReason(raw: string): string { + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + const nested = (parsed as { error?: { message?: unknown } }).error; + if (nested && typeof nested.message === "string" && nested.message) { + return nested.message; + } + const direct = (parsed as { message?: unknown }).message; + if (typeof direct === "string" && direct) return direct; + } + } catch { + // Not JSON: the stored text is already the human-readable reason. + } + return raw; +} + +/** + * A durable Cloud-rooted turn may outlive its Cloud plane: the owner-local + * session was shared, the org's replay retention expired that copy, and the + * root row left the listing for good. The binding already continues such a + * session locally for new sends; a retained or retried queue row must follow + * the same verdict instead of asking the Cloud plane to admit it again. + */ +export function resolveQueuedConversationRoot( + store: Store, + message: QueuedConversationExecutionMessage, + root: ConversationRootLocator +): ConversationRootLocator { + if (root.authority !== "org2-cloud") return root; + const [first, second] = root.authorityScope; + const orgId = second ?? first; + if (!orgId || message.status === "accepted") return root; + const session = sessionById(store, message.sessionId); + if ( + !session || + session.session_id !== root.conversationId || + cloudConversationAuthorityIsLive({ + session, + target: { orgId, sessionId: root.conversationId }, + entry: store.get(org2CloudRemoteSessionsAtom)[orgId], + loadingSource: undefined, + }) + ) { + return root; + } + return ( + localConversationRootForSession( + session.session_id, + session.cliAgentType, + session.agentDefinitionId + ) ?? root + ); +} + +/** The sole canonical executor injected into SessionCore's existing queue. */ +export const dispatchQueuedCanonicalConversation: QueuedConversationDispatcher = + async (store, message, callbacks) => { + const descriptor = message.conversationDispatch; + if (!descriptor || descriptor.kind !== "canonical_conversation") { + throw new Error("queued message is not a canonical conversation turn"); + } + const root = resolveQueuedConversationRoot(store, message, descriptor.root); + if (root.authority === "org2-cloud") { + return await dispatchQueuedCloudConversation( + store, + message, + root, + callbacks + ); + } + return await dispatchQueuedLocalConversation( + store, + root === descriptor.root + ? message + : { ...message, conversationDispatch: { ...descriptor, root } }, + callbacks + ); + }; diff --git a/src/features/ConversationContinuation/externalHistoryContinuation.test.ts b/src/features/ConversationContinuation/externalHistoryContinuation.test.ts new file mode 100644 index 0000000000..14d7b6fc30 --- /dev/null +++ b/src/features/ConversationContinuation/externalHistoryContinuation.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { exists } from "@tauri-apps/plugin-fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type ImportedHistorySource, + externalHistoryCliResumePlan, + getImportedHistorySourceBySessionId, +} from "@src/api/tauri/externalHistory"; + +import { + resolveExternalHistoryContinuation, + resolveExternalHistoryContinuationSource, + resolveExternalHistoryWorkspace, +} from "./externalHistoryContinuation"; + +vi.mock("@tauri-apps/plugin-fs", () => ({ exists: vi.fn() })); +vi.mock("@src/api/tauri/externalHistory", async (importOriginal) => ({ + ...(await importOriginal()), + externalHistoryCliResumePlan: vi.fn(), + getImportedHistorySourceBySessionId: vi.fn(), +})); + +const source: ImportedHistorySource = { + sourceId: "codex_app", + listCategory: "external_history:codex_app", + prefix: "codexapp-", + iconId: "codex", + displayName: "Codex App", + groupLabel: "Codex App", + listable: true, + replayable: true, + supportsWindowedReplay: false, + cliResume: { agentType: "codex", displayName: "Codex" }, + dispatchCategory: "external_history", + loadPreviewChunks: vi.fn(), + loadFullTranscriptChunks: vi.fn(), +}; + +const target = { + cliAgentType: "codex", + accountId: "codex-local", + model: "gpt-test", + workspaceRepoPath: "/local/repo", +} as const; + +describe("external history continuation resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getImportedHistorySourceBySessionId).mockReturnValue(source); + vi.mocked(externalHistoryCliResumePlan).mockResolvedValue(null); + vi.mocked(exists).mockResolvedValue(true); + }); + + it("returns only canonical identity, title, and a device-valid target", async () => { + await expect( + resolveExternalHistoryContinuation({ + sourceSessionId: "codexapp-source-1", + sourceSession: { + session_id: "codexapp-source-1", + status: "completed", + created_at: "2026-07-13T00:00:00Z", + updated_at: "2026-07-13T00:00:00Z", + name: "Imported review", + }, + target, + }) + ).resolves.toEqual({ + title: "Continue Imported review", + target, + }); + }); + + it("uses the imported native cwd without exposing its provider UUID", async () => { + vi.mocked(externalHistoryCliResumePlan).mockResolvedValueOnce({ + source: "claude_code", + cliAgentType: "claude_code", + defaultBinary: "claude", + resumeArgs: ["--resume", "native-source-id"], + nativeSessionId: "00000000-0000-4000-8000-000000000456", + cwd: "/source/repo", + requiresCwd: true, + displayCommand: "claude --resume native-source-id", + cwdExists: true, + sourceAvailable: true, + }); + + const resolved = await resolveExternalHistoryContinuation({ + sourceSessionId: "codexapp-source-1", + target: { ...target, workspaceRepoPath: null }, + }); + + expect(resolved.target.workspaceRepoPath).toBe("/source/repo"); + expect(JSON.stringify(resolved)).not.toContain("native-source-id"); + }); + + it("falls back to the current workspace when imported paths are stale", async () => { + vi.mocked(externalHistoryCliResumePlan).mockResolvedValueOnce({ + source: "claude_code", + cliAgentType: "claude_code", + defaultBinary: "claude", + resumeArgs: ["--resume", "native-source-id"], + nativeSessionId: "00000000-0000-4000-8000-000000000456", + cwd: "/deleted/source", + requiresCwd: true, + displayCommand: "claude --resume native-source-id", + cwdExists: false, + sourceAvailable: true, + }); + vi.mocked(exists).mockImplementation( + async (path) => path === "/current/repo" + ); + + const resolved = await resolveExternalHistoryContinuation({ + sourceSessionId: "codexapp-source-1", + target: { ...target, workspaceRepoPath: "/deleted/remembered" }, + fallbackWorkspaceRepoPath: "/current/repo", + }); + + expect(resolved.target.workspaceRepoPath).toBe("/current/repo"); + }); + + it("drops all stale paths instead of launching in a missing cwd", async () => { + await expect( + resolveExternalHistoryWorkspace({ + selectedPath: "/deleted/remembered", + sourcePath: "/deleted/source", + fallbackPath: "/deleted/current", + pathExists: async () => false, + }) + ).resolves.toBeNull(); + }); + + it("reads only cwd from the provider resume plan", async () => { + vi.mocked(externalHistoryCliResumePlan).mockResolvedValueOnce({ + source: "codex_app", + cliAgentType: "codex", + defaultBinary: "codex", + resumeArgs: ["resume", "native-source-id"], + nativeSessionId: "00000000-0000-4000-8000-000000000123", + cwd: "/source/repo", + requiresCwd: false, + displayCommand: "codex resume native-source-id", + cwdExists: true, + sourceAvailable: true, + }); + + await expect( + resolveExternalHistoryContinuationSource("codexapp-source-1") + ).resolves.toEqual({ cwd: "/source/repo" }); + }); + + it("rejects an unregistered imported source", async () => { + vi.mocked(getImportedHistorySourceBySessionId).mockReturnValue(undefined); + await expect( + resolveExternalHistoryContinuation({ + sourceSessionId: "missing", + target, + }) + ).rejects.toThrow("No imported-history source is registered"); + }); +}); diff --git a/src/features/ConversationContinuation/externalHistoryContinuation.ts b/src/features/ConversationContinuation/externalHistoryContinuation.ts new file mode 100644 index 0000000000..e3dc6e2266 --- /dev/null +++ b/src/features/ConversationContinuation/externalHistoryContinuation.ts @@ -0,0 +1,97 @@ +import { exists } from "@tauri-apps/plugin-fs"; + +import { + externalHistoryCliResumePlan, + getImportedHistorySourceBySessionId, +} from "@src/api/tauri/externalHistory"; +import type { LocalConversationTarget } from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { Session } from "@src/store/session"; +import { toFsPluginPath } from "@src/util/file/pathUtils"; + +interface ExternalHistoryContinuationResolution { + title: string; + target: LocalConversationTarget; +} + +async function pathExistsOnThisDevice(path: string): Promise { + try { + return await exists(toFsPluginPath(path)); + } catch { + return false; + } +} + +/** + * Resolve the checkout for a native continuation on this device. + * + * Imported histories can retain an absolute cwd for a deleted worktree or a + * different machine. Never hand that stale path to the provider process. The + * user's current workspace is the automatic fallback; this keeps continuation + * send-only and avoids introducing a workspace picker. + */ +export async function resolveExternalHistoryWorkspace(params: { + selectedPath?: string | null; + sourcePath?: string | null; + fallbackPath?: string | null; + pathExists?: (path: string) => Promise; +}): Promise { + const pathExists = params.pathExists ?? pathExistsOnThisDevice; + const candidates = [ + params.selectedPath, + params.sourcePath, + params.fallbackPath, + ]; + const seen = new Set(); + for (const candidate of candidates) { + const path = candidate?.trim(); + if (!path || seen.has(path)) continue; + seen.add(path); + if (await pathExists(path)) return path; + } + return null; +} + +export async function resolveExternalHistoryContinuationSource( + sourceSessionId: string +): Promise<{ cwd: string | null }> { + const nativePlan = await externalHistoryCliResumePlan(sourceSessionId); + return { cwd: nativePlan?.cwd ?? null }; +} + +/** + * Thin imported-history adapter. + * + * Imported providers contribute only identity, title and a device-valid cwd. + * Execution discovery, native synchronization, queue lifecycle and episode + * reuse stay in the generic canonical-conversation path. + */ +export async function resolveExternalHistoryContinuation(params: { + sourceSessionId: string; + sourceSession?: Session; + target: LocalConversationTarget; + fallbackWorkspaceRepoPath?: string | null; +}): Promise { + const source = getImportedHistorySourceBySessionId(params.sourceSessionId); + if (!source) { + throw new Error( + `No imported-history source is registered for ${params.sourceSessionId}` + ); + } + const sourceTitle = + params.sourceSession?.name || `${source.displayName} history`; + const sourceContinuation = await resolveExternalHistoryContinuationSource( + params.sourceSessionId + ); + const workspaceRepoPath = await resolveExternalHistoryWorkspace({ + selectedPath: params.target.workspaceRepoPath, + sourcePath: sourceContinuation.cwd, + fallbackPath: params.fallbackWorkspaceRepoPath, + }); + return { + title: `Continue ${sourceTitle}`, + target: { + ...params.target, + workspaceRepoPath, + }, + }; +} diff --git a/src/features/Org2Cloud/CloudSessionDownloadProgressCard.test.ts b/src/features/Org2Cloud/CloudSessionDownloadProgressCard.test.ts index 74ace4ce61..b898cd482d 100644 --- a/src/features/Org2Cloud/CloudSessionDownloadProgressCard.test.ts +++ b/src/features/Org2Cloud/CloudSessionDownloadProgressCard.test.ts @@ -8,6 +8,7 @@ import { type CloudSessionDownloadProgress, cloudSessionDownloadProgressAtom, } from "./cloudSessionDownloadProgressAtom"; +import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; vi.mock("react-i18next", () => ({ useTranslation: () => ({ @@ -30,12 +31,22 @@ function renderProgress( overrides: Partial = {} ): string { const store = createStore(); + store.set(org2CloudAuthAtom, { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "jwt-1", + refreshToken: "refresh-1", + expiresAt: 4_000_000_000, + }); store.set( cloudSessionDownloadProgressAtom, new Map([ [ "session-1", { + authIdentityKey: "https://cloud.example.test|user-1", rowId: "row-1", orgId: "org-1", loadedEvents: 138, diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts index 6781167a1f..cd6ad17d31 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts @@ -1,12 +1,66 @@ -import { describe, expect, it, vi } from "vitest"; +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Org2CloudCommentError } from "../org2CloudCommentsClient"; +import type { ComposerSnapshot } from "@src/components/ComposerInput"; +import type { SmokeRoot } from "@src/test/reactSmokeHarness"; +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import type { CloudOrgMember } from "../org2CloudClient"; +import { + type CloudSessionComment, + Org2CloudCommentError, +} from "../org2CloudCommentsClient"; import { SessionCommentDeliveryError } from "../org2CloudSessionCommentsAtom"; import { + type SessionCommentsContextValue, + SessionCommentsProvider, addCommentWithSessionAdmissionRecovery, + buildCloudCommentRetryCasSteps, buildCloudCommentSourceEventIdMap, + cloudCommentRetryAttemptKey, + useSessionCommentsContext, } from "./SessionCommentsContext"; +const mocks = vi.hoisted(() => ({ + addComment: vi.fn(), + getCloudCapabilities: vi.fn(), + loadCloudOrgMembers: vi.fn(), + ownerRun: vi.fn(), + useSessionComments: vi.fn(), +})); + +vi.mock("../org2CloudSessionCommentsAtom", async (importOriginal) => ({ + ...(await importOriginal()), + useSessionComments: mocks.useSessionComments, +})); + +vi.mock("../sessionCommentTarget", async (importOriginal) => ({ + ...(await importOriginal()), + useSessionCommentTarget: ( + _session: unknown, + targetOverride?: { orgId: string; sessionId: string } | null + ) => targetOverride ?? null, +})); + +vi.mock("../org2CloudMembersCoordinator", () => ({ + loadCloudOrgMembers: mocks.loadCloudOrgMembers, +})); + +vi.mock("../org2CloudCapabilities", () => ({ + getCloudCapabilities: mocks.getCloudCapabilities, +})); + +vi.mock("../useOwnedCloudCommentAgentRun", () => ({ + useOwnedCloudCommentAgentRun: () => ({ + available: false, + run: mocks.ownerRun, + }), +})); + const LIVE_MESSAGE_ID = "70c0418c-eb0c-4a84-8a52-1bca10e605b7"; describe("buildCloudCommentSourceEventIdMap", () => { @@ -75,26 +129,30 @@ describe("addCommentWithSessionAdmissionRecovery", () => { expect(add).toHaveBeenCalledTimes(2); }); - it("retries the retained optimistic row instead of inserting a duplicate", async () => { - const input = { body: "hello" }; - const deliveryError = new SessionCommentDeliveryError( + it("keeps the retained row's identity when admission repair itself fails", async () => { + const retained = new SessionCommentDeliveryError( "local-comment-1", - input, new Org2CloudCommentError("ORG2_SESSION_NOT_FOUND", 404) ); const add = vi.fn(async () => { - throw deliveryError; + throw retained; + }); + const repairError = new Error("sync pass rejected"); + const repair = vi.fn(async () => { + throw repairError; }); - const repair = vi.fn(async () => undefined); - const retried = { id: "comment-1" } as never; - const retryRetained = vi.fn(async () => retried); - await expect( - addCommentWithSessionAdmissionRecovery(add, repair, retryRetained) - ).resolves.toBe(retried); + const rejection = await addCommentWithSessionAdmissionRecovery( + add, + repair + ).catch((error: unknown) => error); + + expect(rejection).toBeInstanceOf(SessionCommentDeliveryError); + expect((rejection as SessionCommentDeliveryError).commentId).toBe( + "local-comment-1" + ); + expect((rejection as SessionCommentDeliveryError).cause).toBe(repairError); expect(add).toHaveBeenCalledOnce(); - expect(repair).toHaveBeenCalledOnce(); - expect(retryRetained).toHaveBeenCalledWith(deliveryError); }); it("does not recreate a missing imported teammate session", async () => { @@ -108,27 +166,255 @@ describe("addCommentWithSessionAdmissionRecovery", () => { ).rejects.toBe(error); expect(add).toHaveBeenCalledOnce(); }); +}); - it("preserves retained delivery ownership when admission repair fails", async () => { - const input = { body: "hello" }; - const deliveryError = new SessionCommentDeliveryError( - "local-comment-1", - input, - new Org2CloudCommentError("ORG2_SESSION_NOT_FOUND", 404) +describe("SessionCommentsProvider failed Team Chat retry", () => { + const auth: Org2CloudAuthState = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "viewer", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_000_000_000, + }; + const members: CloudOrgMember[] = [ + { userId: "alice", displayName: "Alice", role: "member", status: "active" }, + { userId: "bob", displayName: "Bob", role: "member", status: "active" }, + ]; + const failedComment: CloudSessionComment = { + id: "optimistic-comment-1", + eventId: "event-1", + authorUserId: "viewer", + body: "@Bob optimistic edit", + createdAt: "2026-08-31T00:00:00.000Z", + kind: "user", + mentionedUserIds: ["bob"], + clientDeliveryStatus: "failed", + clientRetryExpectedBody: "@Alice original body", + clientRetryExpectedMentionedUserIds: ["alice"], + }; + let root: SmokeRoot | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.loadCloudOrgMembers.mockResolvedValue({ auth, members }); + mocks.getCloudCapabilities.mockResolvedValue({ + teamInboxMentions: true, + }); + }); + + afterEach(async () => { + await root?.unmount(); + root = null; + }); + + it("reconciles a lost edited response before a later edit and claims one retry", async () => { + let resolveAdd!: (comment: CloudSessionComment) => void; + const addPromise = new Promise((resolve) => { + resolveAdd = resolve; + }); + mocks.addComment.mockReturnValue(addPromise); + mocks.useSessionComments.mockReturnValue({ + comments: [failedComment], + viewerOwnsSession: false, + state: "ready", + refresh: vi.fn(), + addComment: mocks.addComment, + editComment: vi.fn(), + deleteComment: vi.fn(), + resolveComment: vi.fn(), + }); + + const captureContext = + vi.fn<(value: SessionCommentsContextValue | null) => void>(); + const Harness = () => { + const context = useSessionCommentsContext(); + useEffect(() => captureContext(context), [context]); + return createElement("output", { + "data-members": context?.mentionableMembers.length ?? 0, + }); + }; + const store = createStore(); + store.set(org2CloudAuthAtom, auth); + root = createSmokeRoot(); + await root.render( + createElement( + Provider, + { store }, + createElement( + SessionCommentsProvider, + { + session: null, + targetOverride: { orgId: "org-1", sessionId: "session-1" }, + events: null, + }, + createElement(Harness) + ) + ) ); - const repairError = new Error("push failed"); - const add = vi.fn(async () => { - throw deliveryError; + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); }); + expect( + root.container.querySelector("output")?.getAttribute("data-members") + ).toBe("2"); + const getContext = () => { + const context = captureContext.mock.lastCall?.[0] ?? null; + if (!context) { + throw new Error("Session comments context was not mounted"); + } + return context; + }; - await expect( - addCommentWithSessionAdmissionRecovery(add, async () => { - throw repairError; - }) - ).rejects.toMatchObject({ - name: "SessionCommentDeliveryError", - commentId: "local-comment-1", - cause: repairError, + const editedMentionSnapshot: ComposerSnapshot = { + parts: [ + { + kind: "pill", + attrs: { + filePath: "member://bob", + fileName: "Former Bob", + isFolder: false, + iconType: "member", + lineStart: null, + lineEnd: null, + }, + }, + { kind: "text", text: " edited body" }, + ], + }; + const first = getContext().retryComment( + failedComment.id, + "@Former Bob edited body", + editedMentionSnapshot + ); + const duplicate = getContext().retryComment( + failedComment.id, + "@Alice duplicate body" + ); + + expect(mocks.addComment).toHaveBeenCalledOnce(); + expect(mocks.addComment).toHaveBeenCalledWith({ + body: failedComment.body, + eventId: "event-1", + parentId: undefined, + mentionedUserIds: failedComment.mentionedUserIds, + optimisticId: failedComment.id, + replaceExisting: true, + expectedBody: failedComment.clientRetryExpectedBody, + expectedMentionedUserIds: + failedComment.clientRetryExpectedMentionedUserIds, }); + + resolveAdd({ + ...failedComment, + clientDeliveryStatus: "sent", + }); + await Promise.all([first, duplicate]); + expect(mocks.addComment).toHaveBeenCalledTimes(2); + expect(mocks.addComment).toHaveBeenNthCalledWith(2, { + body: "@Former Bob edited body", + eventId: "event-1", + parentId: undefined, + mentionedUserIds: ["bob"], + optimisticId: failedComment.id, + replaceExisting: true, + expectedBody: failedComment.body, + expectedMentionedUserIds: failedComment.mentionedUserIds, + }); + }); + + it("plans one-step and two-step CAS retries from the durable baseline", () => { + expect( + buildCloudCommentRetryCasSteps({ + failed: failedComment, + nextBody: "@Alice final edit", + nextMentionedUserIds: ["alice"], + edited: true, + }) + ).toEqual([ + { + body: failedComment.body, + mentionedUserIds: ["bob"], + replaceExisting: true, + expectedBody: "@Alice original body", + expectedMentionedUserIds: ["alice"], + }, + { + body: "@Alice final edit", + mentionedUserIds: ["alice"], + replaceExisting: true, + expectedBody: failedComment.body, + expectedMentionedUserIds: ["bob"], + }, + ]); + + expect( + buildCloudCommentRetryCasSteps({ + failed: failedComment, + nextBody: failedComment.body, + nextMentionedUserIds: ["bob"], + edited: false, + }) + ).toEqual([ + { + body: failedComment.body, + mentionedUserIds: ["bob"], + replaceExisting: true, + expectedBody: "@Alice original body", + expectedMentionedUserIds: ["alice"], + }, + ]); + + expect( + buildCloudCommentRetryCasSteps({ + failed: { + body: "@Alice original body", + mentionedUserIds: ["alice"], + }, + nextBody: "@Bob first edit", + nextMentionedUserIds: ["bob"], + edited: true, + }) + ).toEqual([ + { + body: "@Bob first edit", + mentionedUserIds: ["bob"], + replaceExisting: true, + expectedBody: "@Alice original body", + expectedMentionedUserIds: ["alice"], + }, + ]); + }); + + it("keeps retries isolated across endpoint/account identities", () => { + const base = { + orgId: "org-1", + sessionId: "session-1", + commentId: "comment-1", + }; + expect( + cloudCommentRetryAttemptKey({ + ...base, + authIdentityKey: "https://cloud-a.test|viewer", + }) + ).not.toBe( + cloudCommentRetryAttemptKey({ + ...base, + authIdentityKey: "https://cloud-b.test|viewer", + }) + ); + expect( + cloudCommentRetryAttemptKey({ + ...base, + authIdentityKey: "https://cloud-a.test|viewer", + }) + ).not.toBe( + cloudCommentRetryAttemptKey({ + ...base, + authIdentityKey: "https://cloud-a.test|other-user", + }) + ); }); }); diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx index 6f756b8b2b..322156a4f9 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx @@ -27,11 +27,17 @@ import React, { useState, } from "react"; +import type { ComposerSnapshot } from "@src/components/ComposerInput"; import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; import type { Session } from "@src/store/session/sessionAtom/types"; import { stripCopyEventNamespace } from "../../TeamCollaboration/copyEventId"; import { getSessionForkedFrom } from "../../TeamCollaboration/forkSession"; +import { + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatMentionedUserIds, +} from "../SessionConversation/teamChatMentions"; import { collectAddressableThreads } from "../addressComments"; import { addressRunActiveAtom } from "../addressCommentsRun"; import { @@ -41,11 +47,13 @@ import { } from "../org2CloudAuthAtom"; import { getCloudCapabilities } from "../org2CloudCapabilities"; import type { CloudOrgMember } from "../org2CloudClient"; -import type { - CloudCommentResolution, - CloudSessionComment, +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, + type CloudCommentResolution, + type CloudSessionComment, + isOrg2CommentErrorCode, } from "../org2CloudCommentsClient"; -import { isOrg2CommentErrorCode } from "../org2CloudCommentsClient"; import { loadCloudOrgMembers } from "../org2CloudMembersCoordinator"; import { org2CloudOrgsAtom, @@ -59,6 +67,7 @@ import { type AddCommentInput, type CloudSessionCommentsFetchState, type GroupedCommentThreads, + OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, SessionCommentDeliveryError, groupCommentThreads, useSessionComments, @@ -74,6 +83,121 @@ import type { CommentAnchorEventIdentity } from "./commentAnchorIdentities"; const CLOUD_ADMIN_ROLES = new Set(["owner", "admin"]); const RUST_NATIVE_TRANSIENT_USER_EVENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const activeCloudCommentRetryAttempts = new Map(); + +export function cloudCommentRetryAttemptKey(input: { + authIdentityKey: string; + orgId: string; + sessionId: string; + commentId: string; +}): string { + return [ + input.authIdentityKey, + input.orgId, + input.sessionId, + input.commentId, + ].join("\u001f"); +} + +interface CloudCommentRetryCasStep { + body: string; + mentionedUserIds: string[]; + replaceExisting: boolean; + expectedBody?: string; + expectedMentionedUserIds?: string[]; +} + +function sameMentionedUserIds( + left: readonly string[], + right: readonly string[] +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +/** + * Plan an idempotent retry without guessing which side of a lost response + * Cloud committed. If an earlier edited retry changed A -> B but its response + * was lost, a later edit to C must first replay/confirm A -> B and only then + * CAS B -> C. Sending C with expected A directly would conflict forever when + * Cloud already contains B. + */ +export function buildCloudCommentRetryCasSteps(input: { + failed: Pick< + CloudSessionComment, + | "body" + | "mentionedUserIds" + | "clientRetryExpectedBody" + | "clientRetryExpectedMentionedUserIds" + >; + nextBody: string; + nextMentionedUserIds: readonly string[]; + edited: boolean; +}): CloudCommentRetryCasStep[] { + const currentMentionedUserIds = [...(input.failed.mentionedUserIds ?? [])]; + const nextMentionedUserIds = [...input.nextMentionedUserIds]; + const originalExpectedBody = input.failed.clientRetryExpectedBody; + const originalExpectedMentionedUserIds = [ + ...(input.failed.clientRetryExpectedMentionedUserIds ?? + currentMentionedUserIds), + ]; + const changedAgain = + input.edited && + (input.nextBody !== input.failed.body || + !sameMentionedUserIds(nextMentionedUserIds, currentMentionedUserIds)); + + if (originalExpectedBody !== undefined && changedAgain) { + return [ + { + body: input.failed.body, + mentionedUserIds: currentMentionedUserIds, + replaceExisting: true, + expectedBody: originalExpectedBody, + expectedMentionedUserIds: originalExpectedMentionedUserIds, + }, + { + body: input.nextBody, + mentionedUserIds: nextMentionedUserIds, + replaceExisting: true, + expectedBody: input.failed.body, + expectedMentionedUserIds: currentMentionedUserIds, + }, + ]; + } + + const replaceExisting = input.edited || originalExpectedBody !== undefined; + return [ + { + body: input.nextBody, + mentionedUserIds: nextMentionedUserIds, + replaceExisting, + ...(replaceExisting + ? { + expectedBody: originalExpectedBody ?? input.failed.body, + expectedMentionedUserIds: + originalExpectedBody !== undefined + ? originalExpectedMentionedUserIds + : currentMentionedUserIds, + } + : {}), + }, + ]; +} + +function claimCloudCommentRetryAttempt(key: string): symbol | null { + if (activeCloudCommentRetryAttempts.has(key)) return null; + const attempt = Symbol(key); + activeCloudCommentRetryAttempts.set(key, attempt); + return attempt; +} + +function releaseCloudCommentRetryAttempt(key: string, attempt: symbol): void { + if (activeCloudCommentRetryAttempts.get(key) === attempt) { + activeCloudCommentRetryAttempts.delete(key); + } +} export type { CommentAnchorEventIdentity }; @@ -85,10 +209,7 @@ export type { CommentAnchorEventIdentity }; */ export async function addCommentWithSessionAdmissionRecovery( add: () => Promise, - repair: (() => Promise) | null, - retryRetained?: ( - error: SessionCommentDeliveryError - ) => Promise + repair: (() => Promise) | null ): Promise { try { return await add(); @@ -102,17 +223,13 @@ export async function addCommentWithSessionAdmissionRecovery( await repair(); } catch (repairError) { if (error instanceof SessionCommentDeliveryError) { - throw new SessionCommentDeliveryError( - error.commentId, - error.input, - repairError - ); + throw new SessionCommentDeliveryError(error.commentId, repairError); } throw repairError; } - return error instanceof SessionCommentDeliveryError && retryRetained - ? retryRetained(error) - : add(); + // `add` carries the same optimisticId, so the replay re-sends the very + // row the first attempt retained instead of creating a second one. + return add(); } } @@ -162,6 +279,8 @@ sessionCommentPresentEventIdsAtom.debugLabel = export interface SessionCommentsContextValue { target: SessionCommentTarget; state: CloudSessionCommentsFetchState; + /** Raw rows used by the shared canonical timeline assembler. */ + comments: readonly CloudSessionComment[]; grouped: GroupedCommentThreads; /** * Map a local (possibly fork/import-namespaced) event id to the source-plane @@ -186,11 +305,12 @@ export interface SessionCommentsContextValue { mentionableMembers: readonly CloudOrgMember[]; refresh: () => void; addComment: (input: AddCommentInput) => Promise; + /** Retry a visible failed Team Chat row, optionally with edited text. */ retryComment: ( commentId: string, editedBody?: string, - editedMentionedUserIds?: string[] - ) => Promise; + composerSnapshot?: ComposerSnapshot + ) => Promise; /** * Batch follow-up (design 2026-07-11): address every unresolved thread as * one owner-only agent round, then post one parsed reply per thread. A @@ -328,6 +448,8 @@ export function useSessionCommentViewer(target: SessionCommentTarget | null): { export interface SessionCommentsProviderProps { session: Session | null | undefined; + /** Canonical Cloud conversation coordinates carried by a native episode. */ + targetOverride?: SessionCommentTarget | null; /** * Events currently present in the replay stream (anchor presence for * orphan bucketing). `null` = presence UNKNOWN (snapshot not hydrated @@ -343,13 +465,23 @@ export interface SessionCommentsProviderProps { * dialog stays available. */ turnAnchorsVisible?: boolean; - children: React.ReactNode; + children?: React.ReactNode; } export const SessionCommentsProvider: React.FC< SessionCommentsProviderProps -> = ({ session, events, turnAnchorsVisible = true, children }) => { - const target = useSessionCommentTarget(session); +> = ({ + session, + targetOverride, + events, + turnAnchorsVisible = true, + children, +}) => { + const target = useSessionCommentTarget(session, targetOverride); + const retryAuth = useAtomValue(org2CloudAuthAtom); + const retryAuthIdentityKey = retryAuth + ? org2CloudAuthIdentityKey(retryAuth) + : null; // Comments live on the SOURCE session's plane, anchored by the raw source // event id shared across all users. A fork/import copy carries namespaced // local ids, so anchor matching must happen in source-id space. @@ -388,7 +520,6 @@ export const SessionCommentsProvider: React.FC< state, refresh, addComment, - retryComment, editComment, deleteComment, resolveComment, @@ -399,6 +530,12 @@ export const SessionCommentsProvider: React.FC< ); const addCommentWithRecovery = useCallback( (input: AddCommentInput): Promise => { + const stableInput: AddCommentInput = { + ...input, + optimisticId: + input.optimisticId ?? + `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, + }; const locallyOwnedTarget = Boolean( session && target && @@ -407,7 +544,7 @@ export const SessionCommentsProvider: React.FC< !getSessionForkedFrom(session) ); return addCommentWithSessionAdmissionRecovery( - () => addComment(input), + () => addComment(stableInput), locallyOwnedTarget && target ? async () => { org2CloudSyncEngine.invalidatePushedMetadataHash( @@ -416,14 +553,91 @@ export const SessionCommentsProvider: React.FC< ); await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); } - : null, - (error) => retryComment(error.commentId) + : null ); }, - [addComment, retryComment, session, target] + [addComment, session, target] ); - const viewer = useSessionCommentViewer(target); const mentionableMembers = useSessionCommentMentionableMembers(target); + const retryComment = useCallback( + async ( + commentId: string, + editedBody?: string, + composerSnapshot?: ComposerSnapshot + ): Promise => { + const failed = comments.find((comment) => comment.id === commentId); + if ( + !target || + !retryAuth || + !retryAuthIdentityKey || + !failed || + failed.clientDeliveryStatus !== "failed" + ) { + return; + } + const body = editedBody ?? failed.body; + if (!isTeamChatBodyWithinLimit(body)) { + throw new Error( + `Team Chat messages must be ${CLOUD_COMMENT_MAX_BODY_LENGTH} characters or fewer` + ); + } + // The atom update that flips failed -> pending is visible on the next + // render. Claim synchronously across every provider/pane as well so two + // retry clicks in that window cannot issue duplicate Cloud writes. The + // attempt token makes cleanup compare-and-swap safe across remounts. + // Endpoint/account identity is part of the key: an old request must not + // block or release the same logical row after an auth switch. + const retryKey = cloudCommentRetryAttemptKey({ + authIdentityKey: retryAuthIdentityKey, + orgId: target.orgId, + sessionId: target.sessionId, + commentId, + }); + const attempt = claimCloudCommentRetryAttempt(retryKey); + if (!attempt) return; + try { + const mentionedUserIds = + editedBody === undefined + ? (failed.mentionedUserIds ?? []) + : resolveTeamChatMentionedUserIds( + body, + mentionableMembers, + composerSnapshot, + retryAuth.userId + ); + if (!isTeamChatMentionAudienceWithinLimit(mentionedUserIds)) { + throw new Error( + `@all is unavailable when it would notify more than ${CLOUD_COMMENT_MAX_MENTIONED_USER_IDS} people` + ); + } + const steps = buildCloudCommentRetryCasSteps({ + failed, + nextBody: body, + nextMentionedUserIds: mentionedUserIds, + edited: editedBody !== undefined, + }); + for (const step of steps) { + await addCommentWithRecovery({ + ...step, + eventId: failed.eventId, + parentId: failed.parentId, + optimisticId: failed.id, + }); + } + } finally { + releaseCloudCommentRetryAttempt(retryKey, attempt); + } + }, + [ + addCommentWithRecovery, + comments, + mentionableMembers, + retryAuth, + retryAuthIdentityKey, + target, + ] + ); + const viewer = useSessionCommentViewer(target); const setPresentRegistry = useSetAtom(sessionCommentPresentEventIdsAtom); // Publish the replay stream's event ids for the header notes dialog — @@ -510,6 +724,7 @@ export const SessionCommentsProvider: React.FC< return { target, state, + comments, grouped, toSourceEventId, turnAnchorsVisible, @@ -533,6 +748,7 @@ export const SessionCommentsProvider: React.FC< }, [ target, state, + comments, grouped, toSourceEventId, turnAnchorsVisible, diff --git a/src/features/Org2Cloud/SessionComments/SessionCommentsRetryProjection.test.ts b/src/features/Org2Cloud/SessionComments/SessionCommentsRetryProjection.test.ts new file mode 100644 index 0000000000..ed770e43e6 --- /dev/null +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsRetryProjection.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +/** + * End-to-end seam for a NON-edited Team Chat retry: the real comments atom, + * the real `SessionCommentsContext.retryComment` owner, the real canonical + * discussion projection, and the real `useUserMessageDeliveryActions` + * adapter that `UserChatItem` renders its failed/retry chrome from. + * + * The sibling suites each mock one half of that chain (the atom suite mocks + * the wire, the context suite mocks the atom), so neither proves that a + * successful retry actually removes the failed row from the rendered + * projection — the invariant the rendered dual-instance C3 scenario asserts. + */ +import { Provider, createStore } from "jotai"; +import { act, createElement, useEffect } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useUserMessageDeliveryActions } from "@src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { assembleCanonicalConversationTimeline } from "../SessionConversation/canonicalConversationTimeline"; +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import type { CloudSessionComment } from "../org2CloudCommentsClient"; +import { org2CloudSessionCommentsAtom } from "../org2CloudSessionCommentsAtom"; +import { + type SessionCommentsContextValue, + SessionCommentsProvider, + useSessionCommentsContext, +} from "./SessionCommentsContext"; + +const mocks = vi.hoisted(() => ({ + addSessionComment: vi.fn(), + listSessionComments: vi.fn(), + loadCloudOrgMembers: vi.fn(), + getCloudCapabilities: vi.fn(), + ownerRun: vi.fn(), +})); + +vi.mock("../org2CloudCommentsClient", async (importOriginal) => ({ + ...(await importOriginal()), + addSessionComment: mocks.addSessionComment, + listSessionComments: mocks.listSessionComments, +})); + +vi.mock("../org2CloudSessionCommentsAtom.freshToken", () => ({ + useCloudFreshAccessToken: () => async () => "access-token", +})); + +vi.mock("../org2CloudCommentsBus", async (importOriginal) => ({ + ...(await importOriginal()), + broadcastCommentsChangedToPeers: vi.fn(), +})); + +vi.mock("../sessionCommentTarget", async (importOriginal) => ({ + ...(await importOriginal()), + useSessionCommentTarget: ( + _session: unknown, + targetOverride?: { orgId: string; sessionId: string } | null + ) => targetOverride ?? null, +})); + +vi.mock("../org2CloudMembersCoordinator", () => ({ + loadCloudOrgMembers: mocks.loadCloudOrgMembers, +})); + +vi.mock("../org2CloudCapabilities", () => ({ + getCloudCapabilities: mocks.getCloudCapabilities, +})); + +vi.mock("../useOwnedCloudCommentAgentRun", () => ({ + useOwnedCloudCommentAgentRun: () => ({ + available: false, + run: mocks.ownerRun, + }), +})); + +const FAILED_TESTID = "chat-message-delivery-failed"; +const PENDING_TESTID = "chat-message-delivery-pending"; + +const auth: Org2CloudAuthState = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "viewer", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_000_000_000, +}; + +let rowRetry: (() => void) | null = null; + +function deliveryStatusOf( + event: SessionEvent +): "pending" | "sent" | "failed" | null { + const raw = (event.result as Record | undefined) + ?.deliveryStatus; + if (raw === "pending" || raw === "sent" || raw === "failed") return raw; + if (event.displayStatus === "pending") return "pending"; + if (event.displayStatus === "failed") return "failed"; + return null; +} + +const ProjectedRow = ({ event }: { event: SessionEvent }) => { + const deliveryStatus = deliveryStatusOf(event); + const actions = useUserMessageDeliveryActions({ event, deliveryStatus }); + useEffect(() => { + if (deliveryStatus === "failed") rowRetry = actions.retry; + }); + return createElement("li", { + "data-testid": + deliveryStatus === "failed" + ? FAILED_TESTID + : deliveryStatus === "pending" + ? PENDING_TESTID + : "chat-message-delivery-sent", + }); +}; + +const ProjectedTranscript = () => { + const comments = useSessionCommentsContext(); + const timeline = assembleCanonicalConversationTimeline({ + family: null, + anchorBareSessionId: "session-1", + anchorEvents: [], + planeEvents: [], + comments: comments?.comments ?? [], + streamSessionId: "session-1", + viewer: { viewerUserId: "viewer" } as never, + ...(comments?.toSourceEventId + ? { toSourceEventId: comments.toSourceEventId } + : {}), + }); + return createElement( + "ul", + null, + timeline.map((event) => + createElement(ProjectedRow, { key: event.id, event }) + ) + ); +}; + +describe("Team Chat failed row retry reaches the rendered projection", () => { + let root: SmokeRoot | null = null; + const store = createStore(); + + beforeEach(() => { + vi.clearAllMocks(); + rowRetry = null; + store.set(org2CloudSessionCommentsAtom, {}); + store.set(org2CloudAuthAtom, auth); + mocks.listSessionComments.mockResolvedValue({ + comments: [], + viewerOwnsSession: true, + serverTime: "2026-09-03T00:00:00Z", + }); + mocks.loadCloudOrgMembers.mockResolvedValue({ + auth, + members: [ + { userId: "bob", displayName: "Bob", role: "member", status: "active" }, + ], + }); + mocks.getCloudCapabilities.mockResolvedValue({ teamInboxMentions: true }); + }); + + afterEach(async () => { + await root?.unmount(); + root = null; + }); + + it("clears the failed projection when an offline send is retried online", async () => { + let context: SessionCommentsContextValue | null = null; + const CaptureContext = () => { + const value = useSessionCommentsContext(); + useEffect(() => { + context = value; + }, [value]); + return null; + }; + root = createSmokeRoot(); + await root.render( + createElement( + Provider, + { store }, + createElement( + SessionCommentsProvider, + { + session: null, + targetOverride: { orgId: "org-1", sessionId: "session-1" }, + events: null, + }, + createElement(CaptureContext), + createElement(ProjectedTranscript) + ) + ) + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const container = root.container; + const countOf = (testId: string) => + container.querySelectorAll(`[data-testid="${testId}"]`).length; + + mocks.addSessionComment.mockRejectedValueOnce(new TypeError("Load failed")); + await act(async () => { + await (context as unknown as SessionCommentsContextValue) + .addComment({ body: "hello @Bob", mentionedUserIds: ["bob"] }) + .catch(() => undefined); + }); + + expect(countOf(FAILED_TESTID)).toBe(1); + expect(rowRetry).toBeTypeOf("function"); + + const delivered: CloudSessionComment = { + id: "server-comment-1", + authorUserId: "viewer", + authorDisplayName: "Viewer", + body: "hello @Bob", + createdAt: "2026-09-03T00:00:05.000Z", + kind: "user", + mentionedUserIds: ["bob"], + }; + mocks.addSessionComment.mockResolvedValueOnce(delivered); + await act(async () => { + rowRetry?.(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(countOf(FAILED_TESTID)).toBe(0); + expect(countOf(PENDING_TESTID)).toBe(0); + expect(mocks.addSessionComment).toHaveBeenCalledTimes(2); + const retryPayload = mocks.addSessionComment.mock.calls[1][1]; + expect(retryPayload).toMatchObject({ + body: "hello @Bob", + mentionedUserIds: ["bob"], + clientMessageKey: + mocks.addSessionComment.mock.calls[0][1].clientMessageKey, + replaceExisting: false, + }); + expect(retryPayload.expectedBody).toBeUndefined(); + expect( + store.get(org2CloudSessionCommentsAtom)["org-1|session-1"]?.comments + ).toEqual([delivered]); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/ConversationModePill.test.ts b/src/features/Org2Cloud/SessionConversation/ConversationModePill.test.ts index 0af1b79e28..3ccd7ae2de 100644 --- a/src/features/Org2Cloud/SessionConversation/ConversationModePill.test.ts +++ b/src/features/Org2Cloud/SessionConversation/ConversationModePill.test.ts @@ -10,6 +10,7 @@ import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanel/surfaceAtoms"; import { ConversationModePill } from "./ConversationModePill"; import { conversationComposerModeAtomFamily } from "./conversationComposerMode"; +import { useConversationComposerMode } from "./useConversationComposer"; const comments = vi.hoisted(() => ({ available: true, authenticated: true })); @@ -64,6 +65,23 @@ function renderPill(sessionId: string | null = SESSION_ID): void { }); } +function EffectiveMode({ sessionId }: { sessionId: string | null }) { + const [mode] = useConversationComposerMode(sessionId); + return createElement("output", { "data-testid": "effective-mode" }, mode); +} + +function renderEffectiveMode(sessionId: string | null = SESSION_ID): void { + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(EffectiveMode, { sessionId }) + ) + ); + }); +} + function button(label: string): HTMLButtonElement { const element = container.querySelector( `button[aria-label="${label}"]` @@ -168,6 +186,21 @@ describe("ConversationModePill", () => { } ); + it("falls back to Agent semantics when the target or auth disappears", () => { + store.set(conversationComposerModeAtomFamily(SESSION_ID), "team_chat"); + renderEffectiveMode(); + expect(container.textContent).toBe("team_chat"); + + comments.authenticated = false; + renderEffectiveMode(); + expect(container.textContent).toBe("prompt"); + + comments.authenticated = true; + comments.available = false; + renderEffectiveMode(); + expect(container.textContent).toBe("prompt"); + }); + it("does no idle tooltip work and cleans up after repeated open/unmount cycles", () => { const addListener = vi.spyOn(window, "addEventListener"); const removeListener = vi.spyOn(window, "removeEventListener"); diff --git a/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.test.ts b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.test.ts new file mode 100644 index 0000000000..f124ae7df3 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import type { ConversationSenderIdentity } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import type { SessionImportedFrom } from "@src/store/session"; + +import { + resolveOrg2ConversationEventSender, + resolveOrg2ConversationSourceSender, +} from "./Org2ConversationSenderMetadataProvider"; + +function remoteRow( + overrides: Partial = {} +): RemoteTeammateSessionMetadata { + return { + id: "row-1", + orgId: "org-1", + ownerMemberId: "member-1", + ownerUserId: "user-1", + ownerDisplayName: "Current Account Name", + ownerAvatarUrl: "https://example.com/current.png", + ownerIdentityKind: "human", + sourceSessionId: "source-1", + title: "Shared session", + eventsEpoch: undefined, + eventsFrozenSeq: undefined, + eventsCount: undefined, + eventsTailHash: undefined, + ...overrides, + }; +} + +function importedFrom( + overrides: Partial = {} +): SessionImportedFrom { + return { + orgId: "org-1", + sourceSessionId: "source-1", + ownerMemberId: "member-1", + epoch: 1, + seq: 2, + count: 3, + ...overrides, + }; +} + +describe("resolveOrg2ConversationSourceSender", () => { + it("combines persisted lineage with the authoritative source account row", () => { + expect( + resolveOrg2ConversationSourceSender({ + importedFrom: importedFrom({ ownerDisplayName: "Historical Name" }), + rows: [remoteRow()], + }) + ).toEqual({ + userId: "user-1", + displayName: "Historical Name", + avatarUrl: "https://example.com/current.png", + }); + }); + + it("uses the loading source before a local session row exists", () => { + expect( + resolveOrg2ConversationSourceSender({ + rows: [], + loadingSource: remoteRow({ + ownerDisplayName: "Loading Owner", + ownerAvatarUrl: undefined, + }), + }) + ).toEqual({ userId: "user-1", displayName: "Loading Owner" }); + }); + + it("returns null for genuinely unknown unstamped history", () => { + expect(resolveOrg2ConversationSourceSender({ rows: [] })).toBeNull(); + }); +}); + +describe("resolveOrg2ConversationEventSender", () => { + it("enriches a stamped remote id from the known account map", () => { + const accounts = new Map([ + [ + "user-2", + { + userId: "user-2", + displayName: "Grace Hopper", + avatarUrl: "https://example.com/grace.png", + }, + ], + ]); + + expect( + resolveOrg2ConversationEventSender({ userId: "user-2" }, accounts, null) + ).toEqual({ + userId: "user-2", + displayName: "Grace Hopper", + avatarUrl: "https://example.com/grace.png", + }); + }); + + it("keeps event-time presentation ahead of account fallback", () => { + const accounts = new Map([ + ["user-2", { userId: "user-2", displayName: "Current Name" }], + ]); + + expect( + resolveOrg2ConversationEventSender( + { userId: "user-2", displayName: "Event Name" }, + accounts, + null + ) + ).toEqual({ userId: "user-2", displayName: "Event Name" }); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx new file mode 100644 index 0000000000..ff3b575b39 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx @@ -0,0 +1,257 @@ +import { useAtomValue } from "jotai"; +import React, { useMemo } from "react"; + +import { ConversationSenderMetadataProvider } from "@src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext"; +import type { + ConversationSenderIdentity, + ConversationSenderStamp, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { resolveConversationViewerState } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import type { + Session, + SessionForkedFrom, + SessionImportedFrom, +} from "@src/store/session"; + +import { useSessionCommentsContext } from "../SessionComments/SessionCommentsContext"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; +import { parseCloudOrgSelectorValue } from "../org2CloudOrgsAtom"; +import { + org2CloudRemoteSessionsAtom, + remoteSessionsEntryForIdentity, +} from "../org2CloudRemoteSessionsAtom"; +import { useCloudSessionLoadingSource } from "../useCloudSessionDownloadSurface"; + +const EMPTY_REMOTE_ROWS: readonly RemoteTeammateSessionMetadata[] = []; + +function trimmed(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized || undefined; +} + +function remoteRowIdentity( + row: RemoteTeammateSessionMetadata | undefined +): ConversationSenderIdentity | null { + if (!row) return null; + return { + userId: trimmed(row.ownerUserId), + displayName: trimmed(row.ownerDisplayName), + avatarUrl: trimmed(row.ownerAvatarUrl), + }; +} + +function compactIdentity( + identity: ConversationSenderIdentity +): ConversationSenderIdentity | null { + const userId = trimmed(identity.userId); + const displayName = trimmed(identity.displayName); + const avatarUrl = trimmed(identity.avatarUrl); + return userId || displayName || avatarUrl + ? { + ...(userId ? { userId } : {}), + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + } + : null; +} + +interface Org2ConversationSourceSenderInput { + importedFrom?: SessionImportedFrom; + forkedFrom?: SessionForkedFrom; + rows: readonly RemoteTeammateSessionMetadata[]; + loadingSource?: RemoteTeammateSessionMetadata; +} + +/** + * Resolve imported/forked pre-stamp rows from their source metadata. This is + * the only compatibility fallback: it returns null instead of inventing a + * generic author when no authoritative name/account is available. + */ +export function resolveOrg2ConversationSourceSender({ + importedFrom, + forkedFrom, + rows, + loadingSource, +}: Org2ConversationSourceSenderInput): ConversationSenderIdentity | null { + const origin = importedFrom ?? forkedFrom; + if (!origin) return remoteRowIdentity(loadingSource); + const sourceRow = rows.find( + (row) => + row.orgId === origin.orgId && + row.sourceSessionId === origin.sourceSessionId + ); + const matchingLoadingSource = + loadingSource?.orgId === origin.orgId && + loadingSource.sourceSessionId === origin.sourceSessionId + ? loadingSource + : undefined; + return compactIdentity({ + userId: sourceRow?.ownerUserId ?? matchingLoadingSource?.ownerUserId, + displayName: + importedFrom?.ownerDisplayName ?? + forkedFrom?.ownerDisplayName ?? + sourceRow?.ownerDisplayName ?? + matchingLoadingSource?.ownerDisplayName, + avatarUrl: + importedFrom?.ownerAvatarUrl ?? + sourceRow?.ownerAvatarUrl ?? + matchingLoadingSource?.ownerAvatarUrl, + }); +} + +export function resolveOrg2ConversationEventSender( + stampedSender: ConversationSenderStamp | null, + knownAccounts: ReadonlyMap, + sourceSender: ConversationSenderIdentity | null +): ConversationSenderIdentity | null { + if (!stampedSender) return sourceSender; + const known = knownAccounts.get(stampedSender.userId); + return compactIdentity({ + userId: stampedSender.userId, + displayName: stampedSender.displayName ?? known?.displayName, + avatarUrl: stampedSender.avatarUrl ?? known?.avatarUrl, + }); +} + +function rememberAccount( + accounts: Map, + identity: ConversationSenderIdentity +): void { + const userId = trimmed(identity.userId); + if (!userId) return; + const previous = accounts.get(userId); + accounts.set(userId, { + userId, + displayName: + trimmed(previous?.displayName) ?? trimmed(identity.displayName), + avatarUrl: trimmed(previous?.avatarUrl) ?? trimmed(identity.avatarUrl), + }); +} + +interface Org2ConversationSenderMetadataProviderProps { + sessionId: string; + session: Session | null; + children: React.ReactNode; +} + +/** Subscribed Cloud composition adapter for the provider-neutral context. */ +function SubscribedOrg2ConversationSenderMetadataProvider({ + sessionId, + session, + children, +}: Org2ConversationSenderMetadataProviderProps): React.ReactElement { + const auth = useAtomValue(org2CloudAuthAtom); + const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); + const loadingSource = useCloudSessionLoadingSource(sessionId); + const comments = useSessionCommentsContext(); + const viewer = resolveConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null, + true + ); + const forkedFrom = useMemo( + () => (session ? getSessionForkedFrom(session) : undefined), + [session] + ); + const orgId = + comments?.target.orgId ?? + session?.importedFrom?.orgId ?? + forkedFrom?.orgId ?? + loadingSource?.orgId; + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const rows = useMemo( + () => + orgId + ? (remoteSessionsEntryForIdentity(remoteEntries[orgId], authIdentityKey) + ?.rows ?? EMPTY_REMOTE_ROWS) + : EMPTY_REMOTE_ROWS, + [authIdentityKey, orgId, remoteEntries] + ); + + const knownAccounts = useMemo(() => { + const accounts = new Map(); + for (const member of comments?.mentionableMembers ?? []) { + rememberAccount(accounts, { + userId: member.userId, + displayName: member.displayName, + }); + } + for (const row of rows) { + rememberAccount(accounts, { + userId: row.ownerUserId, + displayName: row.ownerDisplayName, + avatarUrl: row.ownerAvatarUrl, + }); + } + if (auth) { + rememberAccount(accounts, { + userId: auth.userId, + displayName: auth.profile?.displayName, + avatarUrl: auth.profile?.avatarUrl, + }); + } + return accounts; + }, [auth, comments?.mentionableMembers, rows]); + + const sourceSender = useMemo( + () => + resolveOrg2ConversationSourceSender({ + importedFrom: session?.importedFrom, + forkedFrom, + rows, + loadingSource, + }), + [forkedFrom, loadingSource, rows, session?.importedFrom] + ); + + const value = useMemo( + () => ({ + viewer, + resolveSender: ( + _event: SessionEvent, + stampedSender: ConversationSenderStamp | null + ) => + resolveOrg2ConversationEventSender( + stampedSender, + knownAccounts, + sourceSender + ), + }), + [knownAccounts, sourceSender, viewer] + ); + + return ( + + {children} + + ); +} + +/** + * Keep ordinary local chats off the Cloud sender-metadata subscriptions. + * SessionCommentsContext is already mounted by the parent and is the cheap, + * authoritative target gate; lineage and launch ownership cover imported or + * cloud sessions while their comment target is still resolving. + */ +export function Org2ConversationSenderMetadataProvider( + props: Org2ConversationSenderMetadataProviderProps +): React.ReactElement { + const comments = useSessionCommentsContext(); + const forkedFrom = props.session + ? getSessionForkedFrom(props.session) + : undefined; + const isCloudSession = Boolean( + comments?.target || + props.session?.importedFrom || + forkedFrom || + (props.session?.orgId && + parseCloudOrgSelectorValue(props.session.orgId) !== null) + ); + if (!isCloudSession) return <>{props.children}; + return ; +} diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts deleted file mode 100644 index 216304f9dd..0000000000 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - collectLandedTurnIds, - selectActiveRunners, -} from "./activeConversationRunnersAtom"; - -const row = (turnId: string, source: "user" | "assistant" | "system") => ({ - turnId, - event: { source }, -}); - -describe("collectLandedTurnIds", () => { - it("ignores the user row pushed ahead of the runner", () => { - expect(collectLandedTurnIds([row("t1", "user")])).toEqual(new Set()); - }); - - it("marks a turn landed once any agent row is on the plane", () => { - expect( - collectLandedTurnIds([ - row("t1", "user"), - row("t2", "user"), - row("t1", "assistant"), - ]) - ).toEqual(new Set(["t1"])); - expect(collectLandedTurnIds([row("t3", "system")])).toEqual( - new Set(["t3"]) - ); - }); -}); - -describe("selectActiveRunners", () => { - const runners = [ - { runnerSessionId: "r1", turnId: "t1" }, - { runnerSessionId: "r2", turnId: "t2" }, - ]; - - it("keeps a runner while only its user row is on the plane", () => { - const landed = collectLandedTurnIds([row("t1", "user"), row("t2", "user")]); - expect(selectActiveRunners(runners, landed)).toEqual(runners); - }); - - it("drops a runner once its agent tail landed", () => { - const landed = collectLandedTurnIds([ - row("t1", "user"), - row("t1", "assistant"), - row("t2", "user"), - ]); - expect(selectActiveRunners(runners, landed)).toEqual([runners[1]]); - }); -}); diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts deleted file mode 100644 index 50cd3eb86c..0000000000 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Live overlay registry for in-flight member turns. - * - * A member's send runs the turn in an invisible one-shot local runner and - * only publishes the agent tail to the plane at terminal — so without this, - * even the SENDER stares at their own message with no thinking, no tools, - * no "Agent worked for Ns" until the whole turn lands at once. - * - * The runner is LOCAL, so its events stream live through the normal - * per-session events atom. This registry tells the conversation stream - * which local runner sessions to tap and overlay while their turn is still - * running. Once the plane carries the turn's `turnId` (the tail push - * landed), the overlay is dropped in favour of the authoritative plane - * rows — keyed by turnId so the swap never double-renders. - * - * "Carries the turn" means an AGENT row under that turnId: the user's own - * message row is pushed under the same turnId BEFORE the runner exists, so - * matching any row would drop the overlay the instant it registered. - */ -import { atom } from "jotai"; - -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - -export interface ActiveConversationRunner { - runnerSessionId: string; - /** The turnId the tail is pushed under — the plane-landed drop signal. */ - turnId: string; -} - -/** plane rootSessionId → this device's in-flight member runners. */ -export const activeConversationRunnersAtom = atom< - Record ->({}); -activeConversationRunnersAtom.debugLabel = "activeConversationRunnersAtom"; - -/** Plane turnIds whose agent tail has landed (a non-user row is present). */ -export function collectLandedTurnIds( - rows: readonly { turnId: string; event: Pick }[] -): Set { - const landed = new Set(); - for (const row of rows) { - if (row.event.source !== "user") landed.add(row.turnId); - } - return landed; -} - -/** Runners still worth overlaying: their turn has no agent tail on the plane yet. */ -export function selectActiveRunners( - runners: readonly ActiveConversationRunner[], - landedTurnIds: ReadonlySet -): ActiveConversationRunner[] { - return runners.filter((runner) => !landedTurnIds.has(runner.turnId)); -} diff --git a/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.test.ts b/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.test.ts new file mode 100644 index 0000000000..d45eb8524a --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { + NATIVE_SOURCE_EVENT_ID_ARG, + projectNativeConversationItems, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +import type { CloudSessionComment } from "../org2CloudCommentsClient"; +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; +import { + CanonicalConversationFamilyUnavailableError, + MAX_CANONICAL_FAMILY_LOAD_CONCURRENCY, + assembleCanonicalConversationTimeline, + legacyConversationFamilyForTimeline, + loadCanonicalConversationTimeline, +} from "./canonicalConversationTimeline"; +import { + type ConversationFamilyMember, + resolveConversationFamily, +} from "./continuationEvents"; + +function event( + id: string, + source: "user" | "assistant", + createdAt: string, + turnIntentId?: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "local", + createdAt, + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user_message" : "assistant_message", + actionType: source === "user" ? "raw" : "assistant", + args: {}, + result: { + ...(source === "user" + ? { type: "user", message: { role: "user", content: id } } + : { observation: id }), + ...(turnIntentId ? { turnIntentId } : {}), + }, + source, + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function remoteRow( + overrides: Partial +): RemoteTeammateSessionMetadata { + return { + id: "row-root", + orgId: "org-1", + ownerMemberId: "member-a", + ownerUserId: "alice", + ownerDisplayName: "Alice", + ownerIdentityKind: "org_member", + sourceSessionId: "root", + title: "Conversation", + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 2, + eventsTailHash: "tail", + ...overrides, + } as RemoteTeammateSessionMetadata; +} + +const rootRow = remoteRow({}); +const forkRow = remoteRow({ + id: "row-fork", + ownerMemberId: "member-b", + ownerUserId: "bob", + ownerDisplayName: "Bob", + sourceSessionId: "fork", + forkedFrom: { + sourceSessionId: "root", + rootSessionId: "root", + forkedAt: "2026-08-20T10:00:00Z", + }, +}); +const family = resolveConversationFamily([rootRow, forkRow], "root"); +if (!family) throw new Error("family fixture missing"); + +const rootEvents = [ + event("root-user", "user", "2026-08-20T09:00:00Z"), + event("root-answer", "assistant", "2026-08-20T09:01:00Z"), +]; +const currentTurn = event( + "optimistic-current", + "user", + "2026-08-20T11:00:00Z", + "turn-current" +); +const forkEvents = [ + ...rootEvents.map((item) => ({ ...item, id: `fork~${item.id}` })), + event("fork-user", "user", "2026-08-20T10:01:00Z"), + event("fork-answer", "assistant", "2026-08-20T10:02:00Z"), + currentTurn, +]; +const planeEvents: CloudConversationEvent[] = [ + { + id: "plane-current", + rootSessionId: "root", + authorUserId: "alice", + authorDisplayName: "Alice", + turnId: "turn-current", + seq: 1, + event: { ...currentTurn, id: "plane-current-user" }, + createdAt: currentTurn.createdAt, + }, +]; +const comments: CloudSessionComment[] = [ + { + id: "comment-1", + authorUserId: "carol", + authorDisplayName: "Carol", + body: "team context", + createdAt: "2026-08-20T10:03:00Z", + mentionedUserIds: ["bob"], + }, +]; + +const common = { + family, + anchorBareSessionId: "root", + planeEvents, + comments, + streamSessionId: "surface", + viewer: { status: "known" as const, userId: "alice" }, +}; + +describe("canonical conversation timeline", () => { + it("keeps the anchor's failed retry row when a family replay already published its intent", () => { + const failed = { + ...currentTurn, + id: "queued-user:current:", + displayStatus: "failed" as const, + result: { + ...currentTurn.result, + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryOwnerRetired: true, + queueMessageId: "current", + }, + }; + const timeline = assembleCanonicalConversationTimeline({ + ...common, + anchorBareSessionId: "fork", + anchorEvents: [failed], + eventsByBareSessionId: new Map([["root", [currentTurn]]]), + viewer: { status: "known", userId: "bob" }, + planeEvents: planeEvents.map((row) => ({ + ...row, + authorUserId: "bob", + authorDisplayName: "Bob", + })), + comments: [], + }); + + expect(timeline).toHaveLength(1); + expect(timeline[0].id).toBe(failed.id); + expect(timeline[0].result).toEqual(failed.result); + }); + + it("gives UI assembly and execution loading the identical canonical base", async () => { + const eventsByBareSessionId = new Map([ + ["root", rootEvents], + ["fork", forkEvents], + ]); + const uiBase = assembleCanonicalConversationTimeline({ + ...common, + anchorEvents: rootEvents, + eventsByBareSessionId, + }); + const loadMemberEvents = vi.fn( + async (bareSessionId: string) => + eventsByBareSessionId.get(bareSessionId) ?? null + ); + const executionBase = await loadCanonicalConversationTimeline({ + ...common, + loadMemberEvents, + }); + + expect(executionBase).toEqual(uiBase); + expect(loadMemberEvents).toHaveBeenCalledTimes(2); + // Both pre-plane family segments survive; inherited root copies do not. + expect(executionBase.map((item) => item.id)).toEqual( + expect.arrayContaining([ + "root-user", + "root-answer", + "fork-user", + "fork-answer", + ]) + ); + expect(executionBase.some((item) => item.id === "fork~root-user")).toBe( + false + ); + // Optimistic/local and plane copies collapse by turn id. The continuation + // core's existing send-once boundary excludes this turn before native + // materialization and appends it through the provider exactly once. + expect( + executionBase.filter( + (item) => + (item.result as { turnIntentId?: string }).turnIntentId === + "turn-current" + ) + ).toHaveLength(1); + }); + + it("collapses exact global source copies on the family-less execution path", async () => { + const repeatedSourceId = "orgii_evt_0c2481a309205d2abd70fd14234cf0f5"; + const original = { + ...event("codex-asst-97", "assistant", "2026-08-20T09:00:00Z"), + sessionId: "native-codex", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: repeatedSourceId }, + displayText: "same answer", + }; + const replay = { + ...event("claude-renumbered-14", "assistant", "2026-08-20T09:00:00Z"), + sessionId: "native-claude", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: repeatedSourceId }, + displayText: "same answer", + }; + const distinct = { + ...event("claude-new-15", "assistant", "2026-08-20T09:01:00Z"), + sessionId: "native-claude", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: + "orgii_evt_11111111111111111111111111111111", + }, + // Identical content must not cause semantic deduplication. + displayText: "same answer", + }; + + const timeline = await loadCanonicalConversationTimeline({ + ...common, + family: null, + planeEvents: [], + comments: [], + loadMemberEvents: async () => [original, replay, distinct], + }); + + expect(timeline).toEqual([original, distinct]); + expect(projectNativeConversationItems(timeline)).toHaveLength(2); + }); + + it("keeps Team Chat as a portable user row with sender metadata", () => { + const timeline = assembleCanonicalConversationTimeline({ + ...common, + anchorEvents: rootEvents, + eventsByBareSessionId: new Map([ + ["root", rootEvents], + ["fork", forkEvents], + ]), + }); + const discussion = timeline.find( + (item) => item.id === "session-discussion-comment-1" + ); + expect(discussion).toMatchObject({ + source: "user", + displayText: "team context", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "carol", + displayName: "Carol", + }, + sessionDiscussion: { + mentionedUserIds: ["bob"], + }, + }, + }); + }); + + it("replaces a provider-native Team Chat echo with the Cloud discussion row", () => { + const initial = assembleCanonicalConversationTimeline({ + ...common, + family: null, + planeEvents: [], + anchorEvents: [], + }); + const [nativeDiscussion] = projectNativeConversationItems(initial); + if (!nativeDiscussion) + throw new Error("discussion fixture did not project"); + const nativeEcho = { + ...event( + "codex-user-echo", + "user", + "2026-08-20T10:03:00Z", + "provider-turn" + ), + sessionId: "native-codex", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: nativeDiscussion.id }, + displayText: "team context", + result: { + type: "user", + message: { role: "user", content: "team context" }, + turnIntentId: "provider-turn", + }, + } as SessionEvent; + + const timeline = assembleCanonicalConversationTimeline({ + ...common, + family: null, + planeEvents: [], + anchorEvents: [nativeEcho], + }); + + expect(projectNativeConversationItems(timeline)).toHaveLength(1); + expect(timeline).toHaveLength(1); + expect(timeline[0]).toMatchObject({ + id: "session-discussion-comment-1", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "carol", + displayName: "Carol", + }, + sessionDiscussion: { + mentionedUserIds: ["bob"], + }, + }, + }); + }); + + it("fails closed instead of silently omitting a required family member", async () => { + await expect( + loadCanonicalConversationTimeline({ + ...common, + loadMemberEvents: async (bareSessionId) => + bareSessionId === "root" ? rootEvents : null, + }) + ).rejects.toEqual( + expect.objectContaining< + Partial + >({ bareSessionId: "fork" }) + ); + }); + + it("treats post-plane forks as execution episodes rather than transcript sources", async () => { + const postPlane = remoteRow({ + id: "row-post-plane", + sourceSessionId: "post-plane", + ownerUserId: "carol", + ownerDisplayName: "Carol", + forkedFrom: { + sourceSessionId: "fork", + rootSessionId: "root", + forkedAt: "2026-08-20T12:00:00Z", + }, + }); + const extended = resolveConversationFamily( + [rootRow, forkRow, postPlane], + "root" + ); + expect( + legacyConversationFamilyForTimeline( + extended, + "root", + planeEvents, + "2026-08-20T11:00:00Z" + )?.map((member) => member.bareSessionId) + ).toEqual(["root", "fork"]); + + const loadMemberEvents = vi.fn(async (bareSessionId: string) => + bareSessionId === "root" ? rootEvents : forkEvents + ); + await loadCanonicalConversationTimeline({ + ...common, + family: extended, + planeHistoryStartedAt: "2026-08-20T11:00:00Z", + loadMemberEvents, + }); + expect(loadMemberEvents.mock.calls.map(([id]) => id)).not.toContain( + "post-plane" + ); + }); + + it("bounds compatibility family reads after loading the anchor first", async () => { + const members: ConversationFamilyMember[] = Array.from( + { length: 10 }, + (_, index) => ({ + bareSessionId: index === 0 ? "root" : `legacy-${index}`, + isRoot: index === 0, + row: remoteRow({ + id: `row-${index}`, + sourceSessionId: index === 0 ? "root" : `legacy-${index}`, + forkedFrom: + index === 0 + ? undefined + : { + sourceSessionId: "root", + rootSessionId: "root", + forkedAt: "2026-08-20T09:30:00Z", + }, + }), + }) + ); + let active = 0; + let peak = 0; + const loadMemberEvents = vi.fn(async (bareSessionId: string) => { + active += 1; + peak = Math.max(peak, active); + await Promise.resolve(); + active -= 1; + return bareSessionId === "root" ? rootEvents : []; + }); + + await loadCanonicalConversationTimeline({ + ...common, + family: members, + loadMemberEvents, + }); + + expect(loadMemberEvents.mock.calls[0]?.[0]).toBe("root"); + expect(peak).toBeLessThanOrEqual(MAX_CANONICAL_FAMILY_LOAD_CONCURRENCY); + expect(peak).toBeGreaterThan(1); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.ts b/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.ts new file mode 100644 index 0000000000..e65be8ffbe --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline.ts @@ -0,0 +1,249 @@ +/** + * The single canonical timeline projection shared by rendering and execution. + * Provider execution may load missing family members first; the UI may add a + * sender-local live runner overlay afterwards, but neither owns another base + * stitch/plane/discussion merge. + */ +import type { ConversationViewerState } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { nativeSourceEventId } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import type { CloudSessionComment } from "../org2CloudCommentsClient"; +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; +import { groupCommentThreads } from "../org2CloudSessionCommentsAtom"; +import { + type ConversationFamilyMember, + collapseConversationSourceCopies, + sourceEventIdOf, + stitchConversationSegments, +} from "./continuationEvents"; +import { mergePlaneIntoTranscript } from "./conversationTimeline"; +import { + buildDiscussionEvents, + mergeConversationEvents, +} from "./discussionEvents"; + +export interface CanonicalConversationTimelineInput { + family: readonly ConversationFamilyMember[] | null; + anchorBareSessionId: string; + anchorEvents: readonly SessionEvent[]; + eventsByBareSessionId?: ReadonlyMap; + planeEvents: readonly CloudConversationEvent[]; + /** First durable plane timestamp, even when `planeEvents` is a cached tail. */ + planeHistoryStartedAt?: string | null; + comments: readonly CloudSessionComment[]; + streamSessionId: string; + viewer: ConversationViewerState; + /** Optional local-id spelling repair used by the mounted comment surface. */ + toSourceEventId?: (eventId: string) => string; +} + +export const MAX_CANONICAL_FAMILY_LOAD_CONCURRENCY = 4; + +function timestampMs(value: string | undefined | null): number | null { + if (!value) return null; + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * The conversation plane owns every turn after its introduction. Fork rows + * created later are execution episodes, not extra transcript sources. Only + * the root/active anchor and pre-plane members participate in legacy + * stitching; an invalid boundary fails conservatively by retaining family. + */ +export function legacyConversationFamilyForTimeline( + family: readonly ConversationFamilyMember[] | null, + anchorBareSessionId: string, + planeEvents: readonly CloudConversationEvent[], + planeHistoryStartedAt?: string | null +): readonly ConversationFamilyMember[] | null { + if (!family || planeEvents.length === 0) return family; + const planeStart = timestampMs( + planeHistoryStartedAt ?? planeEvents[0]?.createdAt + ); + if (planeStart === null) return family; + return family.filter((member) => { + if (member.isRoot || member.bareSessionId === anchorBareSessionId) { + return true; + } + const forkedAt = timestampMs(member.row.forkedFrom?.forkedAt); + return forkedAt === null || forkedAt <= planeStart; + }); +} + +/** Assemble family, plane and Team Chat into one provider-portable prefix. */ +export function assembleCanonicalConversationTimeline( + input: CanonicalConversationTimelineInput +): SessionEvent[] { + const legacyFamily = legacyConversationFamilyForTimeline( + input.family, + input.anchorBareSessionId, + input.planeEvents, + input.planeHistoryStartedAt + ); + const familyBase = legacyFamily + ? stitchConversationSegments( + legacyFamily, + input.anchorBareSessionId, + input.anchorEvents, + input.eventsByBareSessionId ?? new Map() + ) + : collapseConversationSourceCopies(input.anchorEvents); + const transcript = + input.planeEvents.length > 0 + ? mergePlaneIntoTranscript( + familyBase, + input.planeEvents, + input.streamSessionId, + input.viewer + ) + : familyBase; + if (input.comments.length === 0) return transcript; + + const bySourceId = new Map(); + for (const event of transcript) { + const sourceIds = [sourceEventIdOf(event), event.id]; + if (input.toSourceEventId) { + sourceIds.push(input.toSourceEventId(event.id)); + } + for (const sourceId of sourceIds) { + if (!bySourceId.has(sourceId)) bySourceId.set(sourceId, event); + } + } + const grouped = groupCommentThreads( + input.comments, + new Set(bySourceId.keys()) + ); + const discussion = buildDiscussionEvents( + grouped, + input.streamSessionId, + bySourceId + ); + if (discussion.length === 0) return transcript; + + // A prior native continuation can already contain the provider echo of a + // Team Chat row. Cloud comments remain the authoritative representation + // because they retain authorship, mentions and thread metadata; remove the + // native echo before interleaving that same comment again. Earlier + // family/plane collapse cannot own this invariant because discussion is + // appended only here. Pending/failed optimistic comments are deliberately + // excluded: they are not provider history yet and must not displace the + // last delivered native row. + const deliveredDiscussionIds = new Set( + discussion + .filter( + (event) => + event.source === "user" && event.displayStatus === "completed" + ) + .map(nativeSourceEventId) + ); + const withoutNativeDiscussionEchoes = transcript.filter( + (event) => !deliveredDiscussionIds.has(nativeSourceEventId(event)) + ); + return mergeConversationEvents(withoutNativeDiscussionEchoes, discussion); +} + +export class CanonicalConversationFamilyUnavailableError extends Error { + constructor(readonly bareSessionId: string) { + super( + `canonical conversation family member is unavailable: ${bareSessionId}` + ); + this.name = "CanonicalConversationFamilyUnavailableError"; + } +} + +interface LoadCanonicalConversationTimelineInput extends Omit< + CanonicalConversationTimelineInput, + "anchorEvents" | "eventsByBareSessionId" +> { + /** null means the member should exist but is not locally recoverable yet. */ + loadMemberEvents: ( + bareSessionId: string, + member: ConversationFamilyMember | null + ) => Promise; +} + +/** + * Load every required family segment before using the same pure assembler as + * the UI. An execution prefix must never silently omit an available member. + */ +export async function loadCanonicalConversationTimeline( + input: LoadCanonicalConversationTimelineInput +): Promise { + const eventsByBareSessionId = new Map(); + const legacyFamily = legacyConversationFamilyForTimeline( + input.family, + input.anchorBareSessionId, + input.planeEvents, + input.planeHistoryStartedAt + ); + if (legacyFamily) { + const anchorMember = legacyFamily.find( + (member) => member.bareSessionId === input.anchorBareSessionId + ); + if (!anchorMember) { + throw new CanonicalConversationFamilyUnavailableError( + input.anchorBareSessionId + ); + } + const anchorEvents = await input.loadMemberEvents( + anchorMember.bareSessionId, + anchorMember + ); + if (!anchorEvents) { + throw new CanonicalConversationFamilyUnavailableError( + anchorMember.bareSessionId + ); + } + eventsByBareSessionId.set(anchorMember.bareSessionId, anchorEvents); + const remaining = legacyFamily.filter( + (member) => member.bareSessionId !== input.anchorBareSessionId + ); + let cursor = 0; + const workers = Array.from( + { + length: Math.min( + MAX_CANONICAL_FAMILY_LOAD_CONCURRENCY, + remaining.length + ), + }, + async () => { + for (;;) { + const index = cursor; + cursor += 1; + const member = remaining[index]; + if (!member) return; + const events = await input.loadMemberEvents( + member.bareSessionId, + member + ); + if (!events) { + throw new CanonicalConversationFamilyUnavailableError( + member.bareSessionId + ); + } + eventsByBareSessionId.set(member.bareSessionId, events); + } + } + ); + await Promise.all(workers); + } else { + const events = await input.loadMemberEvents( + input.anchorBareSessionId, + null + ); + if (!events) { + throw new CanonicalConversationFamilyUnavailableError( + input.anchorBareSessionId + ); + } + eventsByBareSessionId.set(input.anchorBareSessionId, events); + } + + return assembleCanonicalConversationTimeline({ + ...input, + anchorEvents: eventsByBareSessionId.get(input.anchorBareSessionId) ?? [], + eventsByBareSessionId, + }); +} diff --git a/src/features/Org2Cloud/SessionConversation/cloudConversationAuthority.ts b/src/features/Org2Cloud/SessionConversation/cloudConversationAuthority.ts new file mode 100644 index 0000000000..45dd6c1879 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/cloudConversationAuthority.ts @@ -0,0 +1,25 @@ +import type { Session } from "@src/store/session"; + +import type { CloudOrgRemoteSessionsEntry } from "../org2CloudRemoteSessionsAtom"; + +/** + * An owner-local session keeps its Cloud authority only while the org listing + * still carries its root row. Once that row is gone (replay retention expired + * or the engine retracted it) the Cloud plane can never admit another turn, + * so the session must continue as an ordinary local conversation instead of + * waiting forever for root metadata that will not return. Replay viewers and + * hydrating imports are not affected: their rows are the import input itself. + */ +export function cloudConversationAuthorityIsLive(params: { + session: Pick | undefined; + target: { orgId: string; sessionId: string } | null; + entry: Pick | undefined; + loadingSource: unknown; +}): boolean { + const { session, target, entry, loadingSource } = params; + if (!target || !session || session.importedFrom || loadingSource) return true; + if (entry?.state !== "ready") return true; + return entry.rows.some( + (candidate) => candidate.sourceSessionId === target.sessionId + ); +} diff --git a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts new file mode 100644 index 0000000000..d067937716 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts @@ -0,0 +1,1009 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { Org2CloudConversationError } from "@src/features/Org2Cloud/org2CloudConversationEventsClient"; +import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import { sessionsAtom } from "@src/store/session"; + +import { dispatchQueuedCloudConversation } from "./cloudConversationQueueAdapter"; + +const mocks = vi.hoisted(() => ({ + refreshAuth: vi.fn(), + capabilities: vi.fn(), + pushEvents: vi.fn(), + refreshPlane: vi.fn(), + runConversationTurn: vi.fn(), + listComments: vi.fn(), + loadCanonical: vi.fn(), + importRemote: vi.fn(), + buildFetchClient: vi.fn(), + cloudDeviceIdentity: vi.fn(), + admitTurn: vi.fn(), + claimTurn: vi.fn(), + renewTurn: vi.fn(), + markAccepted: vi.fn(), + finishTurn: vi.fn(), +})); + +vi.mock("@src/api/tauri/cloudDevice", () => ({ + cloudDeviceIdentity: mocks.cloudDeviceIdentity, +})); + +vi.mock( + "@src/engines/SessionCore/conversations/canonicalConversationEvents", + () => ({ loadCanonicalConversationEvents: mocks.loadCanonical }) +); + +vi.mock("@src/features/Org2Cloud/org2CloudCommentsClient", () => ({ + listSessionComments: mocks.listComments, +})); + +vi.mock("@src/features/Org2Cloud/org2CloudBackendAdapter", () => ({ + buildCloudSessionFetchClient: mocks.buildFetchClient, +})); + +vi.mock("@src/features/TeamCollaboration/engine/collabSessionImport", () => ({ + importRemoteSession: mocks.importRemote, +})); + +vi.mock("@src/features/Org2Cloud/org2CloudAuthAction", () => ({ + refreshOrg2CloudAuthForAction: mocks.refreshAuth, +})); + +vi.mock("@src/features/Org2Cloud/org2CloudCapabilities", () => ({ + getCloudCapabilitiesConfirmed: mocks.capabilities, +})); + +vi.mock("@src/features/Org2Cloud/org2CloudConversationTurnClient", () => ({ + admitCloudConversationTurn: mocks.admitTurn, + claimCloudConversationTurn: mocks.claimTurn, + renewCloudConversationTurn: mocks.renewTurn, + markCloudConversationTurnAccepted: mocks.markAccepted, + finishCloudConversationTurn: mocks.finishTurn, +})); + +vi.mock( + "@src/features/Org2Cloud/org2CloudConversationEventsClient", + async (importOriginal) => ({ + ...(await importOriginal()), + pushConversationEventsChunked: mocks.pushEvents, + }) +); + +vi.mock("./conversationPlaneAtom", async (importOriginal) => ({ + ...(await importOriginal()), + refreshConversationPlaneEntry: mocks.refreshPlane, +})); + +vi.mock("./conversationTurnRunner", async (importOriginal) => ({ + ...(await importOriginal()), + runConversationTurn: mocks.runConversationTurn, +})); + +const AUTH = { + kind: "org2_cloud" as const, + supabaseUrl: "https://cloud.example", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_000_000_000, +}; + +const REFRESHED_AUTH = { + ...AUTH, + accessToken: "access-after-plane-refresh", + refreshToken: "refresh-after-plane-refresh", +}; + +const ROOT = { + authority: "org2-cloud" as const, + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", +}; + +const MESSAGE = { + id: "message-1", + turnIntentId: "turn-1", + sessionId: "imported-session", + content: "continue", + displayContent: "continue", + status: "preparing" as const, + conversationDispatch: { + kind: "canonical_conversation" as const, + root: ROOT, + target: { + cliAgentType: "codex" as const, + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + dispatchIdentityKey: "https://cloud.example|user-1", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.refreshAuth.mockImplementation(async (auth) => ({ + status: "ready", + auth, + })); + mocks.capabilities.mockResolvedValue({ + confirmed: true, + capabilities: { + conversationEvents: true, + conversationEventsIdempotency: true, + conversationTurnCoordination: false, + }, + }); + mocks.pushEvents.mockResolvedValue({ firstSeq: 1, lastSeq: 1 }); + mocks.listComments.mockResolvedValue({ comments: [] }); + mocks.buildFetchClient.mockReturnValue({}); + mocks.importRemote.mockResolvedValue({ + localSessionId: "imported-fork", + updated: true, + }); + mocks.refreshPlane.mockResolvedValue({ state: "ready", events: [] }); + mocks.cloudDeviceIdentity.mockResolvedValue({ + deviceId: "11111111-1111-4111-8111-111111111111", + machineLabel: "test-mac", + }); + mocks.admitTurn.mockResolvedValue({ + turnId: "turn-1", + enqueueSeq: 1, + status: "queued", + firstSeq: 1, + lastSeq: 1, + }); + mocks.claimTurn.mockResolvedValue({ + outcome: "claimed", + turnId: "turn-1", + status: "claimed", + enqueueSeq: 1, + leaseExpiresAt: "2026-09-05T10:00:30.000Z", + }); + mocks.renewTurn.mockResolvedValue({ + turnId: "turn-1", + status: "claimed", + leaseExpiresAt: "2026-09-05T10:00:40.000Z", + }); + mocks.markAccepted.mockResolvedValue({ + turnId: "turn-1", + status: "accepted", + acceptedAt: "2026-09-05T10:00:01.000Z", + leaseExpiresAt: "2026-09-05T10:00:31.000Z", + }); + mocks.finishTurn.mockResolvedValue({ + turnId: "turn-1", + status: "completed", + finishedAt: "2026-09-05T10:00:02.000Z", + }); + mocks.loadCanonical.mockImplementation(async (sessionId: string) => ({ + source: "native_store", + events: + sessionId === "root" + ? [ + { + id: "root-pre-plane", + chunk_id: "root-pre-plane", + sessionId, + createdAt: "2026-08-20T09:00:00Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: "root history", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + }, + ] + : [ + { + id: "fork-pre-plane", + chunk_id: "fork-pre-plane", + sessionId, + createdAt: "2026-08-20T10:00:00Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: "fork history", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + }, + ], + })); +}); + +function readyStore() { + const store = createStore(); + store.set(org2CloudAuthAtom, AUTH); + store.set(org2CloudRemoteSessionsAtom, { + "org-1": { + identityKey: "https://cloud.example|user-1", + state: "ready", + fetchedAt: 1, + rows: [ + { + id: "row-root", + orgId: "org-1", + ownerMemberId: "member-1", + ownerUserId: "user-1", + ownerDisplayName: "Owner", + ownerIdentityKind: "human", + sourceSessionId: "shared-root", + title: "Root", + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 0, + eventsTailHash: "root-tail", + }, + ], + }, + }); + return store; +} + +function enableTurnCoordination() { + mocks.capabilities.mockResolvedValue({ + confirmed: true, + capabilities: { + conversationEvents: true, + conversationEventsIdempotency: true, + conversationTurnCoordination: true, + }, + }); +} + +const ASSISTANT_TAIL_EVENT = { + id: "assistant-tail", + chunk_id: "assistant-tail", + sessionId: "runner", + createdAt: "2026-09-05T10:00:02.000Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: "done", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], +} as const; + +describe("dispatchQueuedCloudConversation coordination", () => { + it("refreshes the execution timeline after acquiring the Cloud FIFO head", async () => { + enableTurnCoordination(); + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner", + terminalStatus: "completed", + }); + mocks.claimTurn.mockImplementationOnce(async () => { + mocks.listComments.mockResolvedValue({ + comments: [ + { + id: "arrived-during-claim", + authorUserId: "user-2", + authorDisplayName: "Teammate", + body: "new context while admission was in flight", + createdAt: "2026-09-05T10:00:00Z", + }, + ], + }); + return { outcome: "claimed", turnId: "turn-1", status: "claimed" }; + }); + await dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + const claimOrder = mocks.claimTurn.mock.invocationCallOrder[0]!; + const reads = mocks.refreshPlane.mock.invocationCallOrder; + expect(reads.some((order) => order > claimOrder)).toBe(true); + expect(reads.at(-1)).toBeLessThan( + mocks.runConversationTurn.mock.invocationCallOrder[0]! + ); + expect(mocks.refreshPlane.mock.calls[1]?.[0].invalidationKey).not.toBe( + mocks.refreshPlane.mock.calls[0]?.[0].invalidationKey + ); + expect(mocks.runConversationTurn.mock.calls[0]?.[0].queueMessageId).toBe( + MESSAGE.id + ); + expect(mocks.runConversationTurn.mock.calls[0]?.[0].timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "session-discussion-arrived-during-claim", + }), + ]) + ); + }); + + it("atomically admits the exact user event instead of using the old push path", async () => { + enableTurnCoordination(); + mocks.runConversationTurn.mockImplementationOnce(async (params) => { + await params.onBeforeTurnDispatch?.("runner"); + await params.onTurnAccepted?.("runner"); + return { runnerSessionId: "runner", terminalStatus: "completed" }; + }); + + await dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(mocks.admitTurn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + orgId: "org-1", + rootSessionId: "shared-root", + turnId: "turn-1", + event: expect.objectContaining({ + source: "user", + result: expect.objectContaining({ turnIntentId: "turn-1" }), + }), + }), + expect.any(Object) + ); + expect(mocks.pushEvents).not.toHaveBeenCalled(); + expect(mocks.finishTurn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: "completed" }), + expect.any(Object) + ); + }); + + it("fails closed when one user event would require non-atomic wire chunks", async () => { + enableTurnCoordination(); + + await expect( + dispatchQueuedCloudConversation( + readyStore(), + { + ...MESSAGE, + content: "x".repeat(70_000), + displayContent: "x".repeat(70_000), + }, + ROOT, + { onAccepted: vi.fn() } + ) + ).rejects.toBeInstanceOf(QueuedConversationBlockedError); + + expect(mocks.admitTurn).not.toHaveBeenCalled(); + expect(mocks.pushEvents).not.toHaveBeenCalled(); + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + }); + + it("returns a waiting claim to the existing queue retry owner", async () => { + enableTurnCoordination(); + mocks.claimTurn.mockResolvedValueOnce({ + outcome: "waiting", + turnId: "turn-1", + status: "queued", + enqueueSeq: 2, + headTurnId: "turn-ahead", + headStatus: "accepted", + retryAfterMs: 1000, + }); + + await expect( + dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + expect(mocks.markAccepted).not.toHaveBeenCalled(); + expect(mocks.finishTurn).not.toHaveBeenCalled(); + }); + + it("retains the active owner when admission succeeded but claim reconciliation fails", async () => { + enableTurnCoordination(); + mocks.claimTurn.mockRejectedValueOnce( + Object.assign(new Error("claim conflict"), { status: 409 }) + ); + + await expect( + dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.admitTurn).toHaveBeenCalledOnce(); + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + expect(mocks.finishTurn).not.toHaveBeenCalled(); + }); + + it("reclaims the same turn after reload and reconnects its accepted runner", async () => { + enableTurnCoordination(); + const onAccepted = vi.fn(); + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner-reload", + terminalStatus: "completed", + }); + + await dispatchQueuedCloudConversation( + readyStore(), + { + ...MESSAGE, + status: "accepted", + runnerSessionId: "runner-reload", + runnerEventStartIndex: 17, + }, + ROOT, + { onAccepted } + ); + + expect(mocks.claimTurn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + turnId: "turn-1", + deviceId: "11111111-1111-4111-8111-111111111111", + }), + expect.any(Object) + ); + expect(mocks.markAccepted).toHaveBeenCalledOnce(); + expect(onAccepted).toHaveBeenCalledWith("runner-reload"); + expect(mocks.runConversationTurn).toHaveBeenCalledWith( + expect.objectContaining({ + recovery: { + runnerSessionId: "runner-reload", + eventStartIndex: 17, + providerAccepted: true, + }, + }) + ); + }); + + it("does not treat a recovered Cloud acceptance as local provider acceptance", async () => { + enableTurnCoordination(); + const order: string[] = []; + const onAccepted = vi.fn(async () => { + order.push("provider-accepted"); + }); + mocks.claimTurn.mockResolvedValueOnce({ + outcome: "accepted", + turnId: "turn-1", + status: "accepted", + enqueueSeq: 1, + acceptedAt: "2026-09-05T10:00:01.000Z", + leaseExpiresAt: "2026-09-05T10:00:31.000Z", + }); + mocks.runConversationTurn.mockImplementationOnce(async (params) => { + order.push("runner-recovery"); + expect(params.recovery).toEqual({ + runnerSessionId: "runner-preparing", + eventStartIndex: 17, + providerAccepted: false, + }); + await params.onTurnAccepted?.("runner-preparing"); + return { + runnerSessionId: "runner-preparing", + terminalStatus: "completed", + }; + }); + + await dispatchQueuedCloudConversation( + readyStore(), + { + ...MESSAGE, + runnerSessionId: "runner-preparing", + runnerEventStartIndex: 17, + }, + ROOT, + { onAccepted } + ); + + expect(order).toEqual(["runner-recovery", "provider-accepted"]); + expect(onAccepted).toHaveBeenCalledOnce(); + }); + + it("marks the Cloud lease accepted immediately before provider dispatch", async () => { + enableTurnCoordination(); + const order: string[] = []; + mocks.markAccepted.mockImplementationOnce(async () => { + order.push("accepted"); + return { + turnId: "turn-1", + status: "accepted", + acceptedAt: "2026-09-05T10:00:01.000Z", + leaseExpiresAt: "2026-09-05T10:00:31.000Z", + }; + }); + mocks.runConversationTurn.mockImplementationOnce(async (params) => { + await params.onBeforeTurnDispatch?.("runner"); + order.push("provider"); + await params.onTurnAccepted?.("runner"); + return { runnerSessionId: "runner", terminalStatus: "completed" }; + }); + + await dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(order).toEqual(["accepted", "provider"]); + }); + + it("publishes the provider tail before finishing the Cloud ledger row", async () => { + enableTurnCoordination(); + const order: string[] = []; + mocks.pushEvents.mockImplementation(async () => { + order.push("publish"); + return { firstSeq: 2, lastSeq: 2 }; + }); + mocks.finishTurn.mockImplementationOnce(async () => { + order.push("finish"); + return { + turnId: "turn-1", + status: "completed", + finishedAt: "2026-09-05T10:00:02.000Z", + }; + }); + mocks.runConversationTurn.mockImplementationOnce(async (params) => { + await params.onBeforeTurnDispatch?.("runner"); + await params.onTurnAccepted?.("runner"); + await params.publishTail("turn-1", [ASSISTANT_TAIL_EVENT]); + return { runnerSessionId: "runner", terminalStatus: "completed" }; + }); + + await dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(order).toEqual(["publish", "finish"]); + }); + + it("owns one bounded renewal timer and clears it when the turn finishes", async () => { + vi.useFakeTimers(); + try { + enableTurnCoordination(); + let finishProvider: (() => void) | undefined; + mocks.runConversationTurn.mockImplementationOnce(async (params) => { + await params.onBeforeTurnDispatch?.("runner"); + await params.onTurnAccepted?.("runner"); + await new Promise((resolve) => { + finishProvider = resolve; + }); + return { runnerSessionId: "runner", terminalStatus: "completed" }; + }); + + const dispatch = dispatchQueuedCloudConversation( + readyStore(), + MESSAGE, + ROOT, + { onAccepted: vi.fn() } + ); + await vi.advanceTimersByTimeAsync(10_000); + expect(mocks.renewTurn).toHaveBeenCalledOnce(); + finishProvider?.(); + await dispatch; + await vi.advanceTimersByTimeAsync(30_000); + expect(mocks.renewTurn).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the original push workflow when the capability is disabled", async () => { + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner", + terminalStatus: "completed", + }); + + await dispatchQueuedCloudConversation(readyStore(), MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(mocks.pushEvents).toHaveBeenCalledOnce(); + expect(mocks.cloudDeviceIdentity).not.toHaveBeenCalled(); + expect(mocks.admitTurn).not.toHaveBeenCalled(); + expect(mocks.claimTurn).not.toHaveBeenCalled(); + expect(mocks.renewTurn).not.toHaveBeenCalled(); + expect(mocks.markAccepted).not.toHaveBeenCalled(); + expect(mocks.finishTurn).not.toHaveBeenCalled(); + }); +}); + +describe("dispatchQueuedCloudConversation failure classification", () => { + it("uses auth committed by the plane refresh for every later Cloud read", async () => { + const store = readyStore(); + mocks.refreshPlane.mockImplementationOnce(async ({ setAuth }) => { + setAuth(REFRESHED_AUTH); + return { state: "ready", events: [] }; + }); + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner", + terminalStatus: "completed", + }); + + await dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(mocks.listComments).toHaveBeenCalledWith( + REFRESHED_AUTH.accessToken, + "org-1", + "shared-root", + { + endpoint: { + supabaseUrl: REFRESHED_AUTH.supabaseUrl, + anonKey: REFRESHED_AUTH.supabaseAnonKey, + }, + } + ); + expect(mocks.buildFetchClient).toHaveBeenCalledWith( + REFRESHED_AUTH.accessToken, + expect.objectContaining({ anonKey: REFRESHED_AUTH.supabaseAnonKey }) + ); + expect(mocks.pushEvents).toHaveBeenCalledWith( + REFRESHED_AUTH.accessToken, + expect.objectContaining({ turnId: "turn-1" }), + expect.objectContaining({ + supabaseUrl: REFRESHED_AUTH.supabaseUrl, + anonKey: REFRESHED_AUTH.supabaseAnonKey, + }) + ); + }); + + it("publishes one terminal event and closes after a definitive post-admission 4xx", async () => { + const store = readyStore(); + mocks.runConversationTurn.mockRejectedValueOnce( + new Org2CloudConversationError("ORG2_FORBIDDEN", 403) + ); + + await expect( + dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationTurnClosedError); + + expect(mocks.pushEvents).toHaveBeenCalledTimes(2); + expect(mocks.pushEvents.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + turnId: "turn-1", + events: [ + expect.objectContaining({ + source: "system", + displayStatus: "failed", + }), + ], + }) + ); + expect(mocks.runConversationTurn).toHaveBeenCalledOnce(); + }); + + it("retains recovery ownership after a retryable post-admission 5xx", async () => { + const store = readyStore(); + mocks.runConversationTurn.mockRejectedValueOnce( + new Org2CloudConversationError("temporary upstream failure", 503) + ); + + await expect( + dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + expect(mocks.pushEvents).toHaveBeenCalledOnce(); + expect(mocks.runConversationTurn).toHaveBeenCalledOnce(); + }); + + it("retries a timed-out terminal-tail publication under the same accepted turn", async () => { + const store = readyStore(); + mocks.pushEvents + .mockResolvedValueOnce({ firstSeq: 1, lastSeq: 1 }) + .mockRejectedValueOnce( + new DOMException( + "Cloud request timed out after 15000ms.", + "TimeoutError" + ) + ) + .mockResolvedValue({ firstSeq: 1, lastSeq: 2 }); + mocks.runConversationTurn.mockImplementation(async (params) => { + await params.onTurnAccepted?.("runner"); + await params.publishTail(params.turnIntentId, [ASSISTANT_TAIL_EVENT]); + return { runnerSessionId: "runner", terminalStatus: "completed" }; + }); + const onAccepted = vi.fn(); + + await expect( + dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { onAccepted }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + + await expect( + dispatchQueuedCloudConversation( + store, + { + ...MESSAGE, + status: "accepted", + runnerSessionId: "runner", + runnerEventStartIndex: 42, + }, + ROOT, + { onAccepted } + ) + ).resolves.toBeUndefined(); + + expect(mocks.runConversationTurn).toHaveBeenCalledTimes(2); + expect(mocks.runConversationTurn.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + turnIntentId: "turn-1", + recovery: { + runnerSessionId: "runner", + eventStartIndex: 42, + providerAccepted: true, + }, + }) + ); + expect(mocks.pushEvents).toHaveBeenCalledTimes(4); + expect(mocks.pushEvents.mock.calls.map((call) => call[1]?.turnId)).toEqual([ + "turn-1", + "turn-1", + "turn-1", + "turn-1", + ]); + }); + + it("leaves an already-bound native suffix to accepted-turn recovery", async () => { + const store = readyStore(); + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner-accepted", + terminalStatus: "completed", + }); + + await dispatchQueuedCloudConversation( + store, + { + ...MESSAGE, + status: "accepted", + runnerSessionId: "runner-accepted", + runnerEventStartIndex: 42, + }, + ROOT, + { onAccepted: vi.fn() } + ); + + expect(mocks.pushEvents).toHaveBeenCalledOnce(); + expect(mocks.runConversationTurn).toHaveBeenCalledWith( + expect.objectContaining({ + recovery: { + runnerSessionId: "runner-accepted", + eventStartIndex: 42, + providerAccepted: true, + }, + }) + ); + }); + + it("fails a local shared session visibly once its Cloud root row is gone", async () => { + enableTurnCoordination(); + const store = readyStore(); + store.set(sessionsAtom, [ + { + session_id: "shared-root", + name: "Root", + status: "completed", + created_at: "2026-08-20T09:00:00Z", + updated_at: "2026-08-20T09:00:00Z", + }, + ]); + store.set(org2CloudRemoteSessionsAtom, { + "org-1": { + identityKey: "https://cloud.example|user-1", + state: "ready", + fetchedAt: 1, + rows: [], + }, + }); + + await expect( + dispatchQueuedCloudConversation( + store, + { ...MESSAGE, sessionId: "shared-root" }, + ROOT, + { onAccepted: vi.fn() } + ) + ).rejects.toBeInstanceOf(QueuedConversationBlockedError); + + expect(mocks.admitTurn).not.toHaveBeenCalled(); + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + }); + + it("fails a replay viewer's send when the ready Cloud listing has no root", async () => { + enableTurnCoordination(); + const store = readyStore(); + store.set(sessionsAtom, [ + { + session_id: "imported-session", + name: "Root", + status: "completed", + created_at: "2026-08-20T09:00:00Z", + updated_at: "2026-08-20T09:00:00Z", + importedFrom: { + orgId: "org-1", + sourceSessionId: "shared-root", + sourceEndpointUrl: "https://cloud.example", + ownerMemberId: "member-1", + epoch: 1, + seq: 0, + count: 0, + }, + }, + ]); + store.set(org2CloudRemoteSessionsAtom, { + "org-1": { + identityKey: "https://cloud.example|user-1", + state: "ready", + fetchedAt: 1, + rows: [], + }, + }); + + await expect( + dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationBlockedError); + expect(mocks.admitTurn).not.toHaveBeenCalled(); + expect(mocks.claimTurn).not.toHaveBeenCalled(); + expect(mocks.pushEvents).not.toHaveBeenCalled(); + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + }); + + it("keeps an unhydrated Cloud listing recovery-pending without dispatching", async () => { + enableTurnCoordination(); + const store = readyStore(); + store.set(org2CloudRemoteSessionsAtom, {}); + + await expect( + dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + expect(mocks.admitTurn).not.toHaveBeenCalled(); + expect(mocks.runConversationTurn).not.toHaveBeenCalled(); + }); + + it("imports every available family member before executing the canonical timeline", async () => { + const store = createStore(); + store.set(org2CloudAuthAtom, AUTH); + store.set(sessionsAtom, [ + { + session_id: "shared-root", + name: "Root", + status: "completed", + created_at: "2026-08-20T09:00:00Z", + updated_at: "2026-08-20T09:00:00Z", + }, + ]); + store.set(org2CloudRemoteSessionsAtom, { + "org-1": { + identityKey: "https://cloud.example|user-1", + state: "ready", + fetchedAt: 1, + rows: [ + { + id: "row-root", + orgId: "org-1", + ownerMemberId: "member-1", + ownerUserId: "user-1", + ownerDisplayName: "Owner", + ownerIdentityKind: "human", + sourceSessionId: "shared-root", + title: "Root", + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 1, + eventsTailHash: "root-tail", + }, + { + id: "row-fork", + orgId: "org-1", + ownerMemberId: "member-2", + ownerUserId: "user-2", + ownerDisplayName: "Teammate", + ownerIdentityKind: "human", + sourceSessionId: "fork-1", + title: "Fork", + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 1, + eventsTailHash: "fork-tail", + forkedFrom: { + sourceSessionId: "shared-root", + rootSessionId: "shared-root", + forkedAt: "2026-08-20T10:00:00Z", + }, + }, + ], + }, + }); + mocks.loadCanonical.mockImplementation(async (sessionId: string) => ({ + source: "native_store", + events: [ + { + id: + sessionId === "imported-fork" ? "fork-pre-plane" : "root-pre-plane", + chunk_id: "chunk", + sessionId, + createdAt: + sessionId === "imported-fork" + ? "2026-08-20T10:00:00Z" + : "2026-08-20T09:00:00Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: sessionId, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + }, + ], + })); + mocks.refreshPlane.mockResolvedValue({ + state: "ready", + events: [], + }); + mocks.listComments.mockResolvedValue({ + comments: [ + { + id: "discussion-1", + authorUserId: "user-2", + authorDisplayName: "Teammate", + body: "team context", + createdAt: "2026-08-20T10:01:00Z", + }, + ], + }); + mocks.runConversationTurn.mockResolvedValue({ + runnerSessionId: "runner", + terminalStatus: "completed", + }); + + await dispatchQueuedCloudConversation(store, MESSAGE, ROOT, { + onAccepted: vi.fn(), + }); + + expect(mocks.importRemote).toHaveBeenCalledWith( + expect.objectContaining({ + orgId: "org-1", + remoteSession: expect.objectContaining({ sourceSessionId: "fork-1" }), + }) + ); + const timeline = mocks.runConversationTurn.mock.calls[0]?.[0] + ?.timeline as Array<{ id: string; source: string; args: unknown }>; + expect(timeline.map((event) => event.id)).toEqual( + expect.arrayContaining([ + "root-pre-plane", + "fork-pre-plane", + "session-discussion-discussion-1", + ]) + ); + expect( + timeline.find((event) => event.id === "session-discussion-discussion-1") + ).toMatchObject({ + source: "user", + args: { + conversationSender: { + userId: "user-2", + displayName: "Teammate", + }, + }, + }); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts new file mode 100644 index 0000000000..351df8ab7f --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts @@ -0,0 +1,759 @@ +import type { Store } from "jotai/vanilla/store"; + +import { cloudDeviceIdentity } from "@src/api/tauri/cloudDevice"; +import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + conversationTurnIdOf, + localConversationRootForSession, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import type { + QueuedConversationDispatchCallbacks, + QueuedConversationExecutionMessage, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { refreshOrg2CloudAuthForAction } from "@src/features/Org2Cloud/org2CloudAuthAction"; +import { + type Org2CloudAuthState, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { buildCloudSessionFetchClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter"; +import { getCloudCapabilitiesConfirmed } from "@src/features/Org2Cloud/org2CloudCapabilities"; +import { listSessionComments } from "@src/features/Org2Cloud/org2CloudCommentsClient"; +import { + Org2CloudConversationError, + conversationEventsForPush, + pushConversationEventsChunked, +} from "@src/features/Org2Cloud/org2CloudConversationEventsClient"; +import { + admitCloudConversationTurn, + claimCloudConversationTurn, + finishCloudConversationTurn, + markCloudConversationTurnAccepted, + renewCloudConversationTurn, +} from "@src/features/Org2Cloud/org2CloudConversationTurnClient"; +import { isRetryableCloudRequestError } from "@src/features/Org2Cloud/org2CloudFetchRetry"; +import { endpointForOrigin } from "@src/features/Org2Cloud/org2CloudOrgEndpointRouter"; +import { + org2CloudRemoteSessionsAtom, + remoteSessionsEntryForIdentity, +} from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import { + findImportedSession, + normalizeSourceEndpointUrl, +} from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import { importRemoteSession } from "@src/features/TeamCollaboration/engine/collabSessionImport"; +import { createLogger } from "@src/hooks/logger"; +import type { Session } from "@src/store/session"; +import { sessionsAtom } from "@src/store/session"; + +import { + CanonicalConversationFamilyUnavailableError, + loadCanonicalConversationTimeline, +} from "./canonicalConversationTimeline"; +import { + type ConversationFamilyMember, + resolveConversationFamily, +} from "./continuationEvents"; +import { + bumpConversationPlaneSignal, + conversationPlaneAtom, + conversationPlaneKey, + conversationPlaneSignalAtom, + loadCompleteConversationPlaneEvents, + refreshConversationPlaneEntry, +} from "./conversationPlaneAtom"; +import { + buildPushedUserEvent, + closeConversationTurnWithFailure, + runConversationTurn, +} from "./conversationTurnRunner"; + +const log = createLogger("CloudConversationQueueAdapter"); +const CLOUD_TURN_LEASE_SECONDS = 30; +const CLOUD_TURN_RENEW_INTERVAL_MS = 10_000; + +interface CloudTurnLeaseRenewal { + stop: () => Promise; +} + +/** + * One in-flight turn owns one recursive renewal timer. This is lease + * maintenance for the existing queue owner, not a second dispatcher/watcher. + */ +function startCloudTurnLeaseRenewal( + renew: () => Promise +): CloudTurnLeaseRenewal { + let stopped = false; + let timer: ReturnType | undefined; + let inFlight: Promise | undefined; + const schedule = () => { + if (stopped) return; + timer = setTimeout(() => { + timer = undefined; + inFlight = renew() + .catch((error) => { + log.warn("Cloud conversation turn lease renewal failed", error); + }) + .finally(() => { + inFlight = undefined; + schedule(); + }); + }, CLOUD_TURN_RENEW_INTERVAL_MS); + }; + schedule(); + return { + stop: async () => { + if (stopped) return; + stopped = true; + if (timer !== undefined) clearTimeout(timer); + await inFlight; + }, + }; +} + +function sessionById(store: Store, sessionId: string): Session | undefined { + return store + .get(sessionsAtom) + .find((candidate) => candidate.session_id === sessionId); +} + +function cloudLocator(root: ConversationRootLocator): { + orgId: string; + rootSessionId: string; + sourceEndpointUrl?: string; +} { + if ( + root.authority !== "org2-cloud" || + (root.authorityScope.length !== 1 && root.authorityScope.length !== 2) + ) { + throw new Error("invalid Cloud conversation identity"); + } + const [first, second] = root.authorityScope; + const orgId = second ?? first; + if (!orgId) throw new Error("invalid Cloud conversation identity"); + return { + orgId, + rootSessionId: root.conversationId, + ...(second ? { sourceEndpointUrl: first } : {}), + }; +} + +/** Cloud authority adapter for the application's existing durable queue. */ +export async function dispatchQueuedCloudConversation( + store: Store, + message: QueuedConversationExecutionMessage, + root: ConversationRootLocator, + callbacks: QueuedConversationDispatchCallbacks +): Promise { + const descriptor = message.conversationDispatch; + if (!descriptor) throw new Error("canonical conversation target is missing"); + const { orgId, rootSessionId, sourceEndpointUrl } = cloudLocator(root); + + const expectedIdentityKey = descriptor.dispatchIdentityKey; + if (!expectedIdentityKey) { + throw new QueuedConversationBlockedError( + "This restored Cloud turn predates sender binding; edit and send it again under the current account" + ); + } + const requireBoundAuth = (): Org2CloudAuthState => { + const current = store.get(org2CloudAuthAtom); + if (!current) { + throw new QueuedConversationBlockedError("cloud sign-in required"); + } + if (org2CloudAuthIdentityKey(current) !== expectedIdentityKey) { + throw new QueuedConversationBlockedError( + "This queued turn belongs to a different Cloud account; switch back to its author account to send it" + ); + } + if ( + sourceEndpointUrl && + normalizeSourceEndpointUrl(current.supabaseUrl) !== sourceEndpointUrl + ) { + throw new QueuedConversationBlockedError( + "This queued turn belongs to a different Cloud deployment" + ); + } + return current; + }; + const refreshBoundAuth = async (): Promise => { + const current = requireBoundAuth(); + const result = await refreshOrg2CloudAuthForAction(current, (update) => + store.set(org2CloudAuthAtom, update) + ); + if (result.status === "unavailable") { + throw new QueuedConversationRecoveryPendingError( + "cloud auth refresh is temporarily unavailable" + ); + } + if (result.status !== "ready") { + throw new QueuedConversationBlockedError( + result.status === "expired" + ? "cloud sign-in expired" + : "cloud account changed during delivery" + ); + } + const fresh = result.auth; + if (org2CloudAuthIdentityKey(fresh) !== expectedIdentityKey) { + throw new QueuedConversationBlockedError( + "cloud account changed during delivery" + ); + } + requireBoundAuth(); + return fresh; + }; + + const auth = await refreshBoundAuth(); + const authIdentityKey = expectedIdentityKey; + const endpoint = { + supabaseUrl: auth.supabaseUrl, + anonKey: auth.supabaseAnonKey, + }; + const capabilityProbe = await getCloudCapabilitiesConfirmed( + auth.accessToken, + endpoint + ); + if ( + !capabilityProbe.confirmed || + !capabilityProbe.capabilities.conversationEventsIdempotency + ) { + if (!capabilityProbe.confirmed) { + throw new QueuedConversationRecoveryPendingError( + "Cloud conversation capability probe is temporarily unavailable" + ); + } + throw new QueuedConversationBlockedError( + "Cloud conversation idempotency is unavailable; refusing an unsafe retry" + ); + } + const coordinationEnabled = + capabilityProbe.capabilities.conversationTurnCoordination === true; + + // Build the canonical user payload once. It is admitted to the shared plane + // before the local provider turn starts; retries reuse the stable turn id. + const userEvents = await conversationEventsForPush( + buildPushedUserEvent( + message.displayContent, + message.content, + message.imageDataUrls, + new Date().toISOString(), + message.turnIntentId + ) + ); + if ( + coordinationEnabled && + (userEvents.length !== 1 || userEvents[0]?.source !== "user") + ) { + // 0028 atomically admits exactly one Agent-directed user event. The + // existing large-event codec expands an oversized event into system + // chunks, which cannot be admitted without splitting publication from + // FIFO ownership. Fail closed rather than bypass cross-device ordering. + throw new QueuedConversationBlockedError( + "This message is too large for coordinated Cloud execution" + ); + } + requireBoundAuth(); + let userEventPublished = false; + let coordinationDeviceId: string | undefined; + let coordinationClaimed = false; + let coordinationAlreadyTerminal = false; + let coordinationAccepted = false; + let leaseRenewal: CloudTurnLeaseRenewal | undefined; + const stopLeaseRenewal = async () => { + const current = leaseRenewal; + leaseRenewal = undefined; + await current?.stop(); + }; + const coordinationIdentity = () => { + if (!coordinationDeviceId) { + throw new Error("Cloud conversation coordination has no device owner"); + } + return { + orgId, + rootSessionId, + turnId: message.turnIntentId, + deviceId: coordinationDeviceId, + }; + }; + const markCoordinationAccepted = async () => { + if (!coordinationEnabled || coordinationAccepted) return; + try { + const fresh = await refreshBoundAuth(); + await markCloudConversationTurnAccepted( + fresh.accessToken, + { + ...coordinationIdentity(), + leaseSeconds: CLOUD_TURN_LEASE_SECONDS, + }, + { + supabaseUrl: fresh.supabaseUrl, + anonKey: fresh.supabaseAnonKey, + } + ); + coordinationAccepted = true; + requireBoundAuth(); + } catch (error) { + if (isRetryableCloudRequestError(error)) { + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } + throw error; + } + }; + const finishCoordination = async ( + status: "completed" | "failed" | "cancelled" + ) => { + if (!coordinationEnabled || !coordinationClaimed) return; + try { + const fresh = await refreshBoundAuth(); + await finishCloudConversationTurn( + fresh.accessToken, + { ...coordinationIdentity(), status }, + { + supabaseUrl: fresh.supabaseUrl, + anonKey: fresh.supabaseAnonKey, + } + ); + requireBoundAuth(); + } catch (error) { + // Provider/tail completion is already durable locally and possibly in + // Cloud. Keep this same queue owner until the idempotent finish receipt + // succeeds; never turn a bookkeeping failure into another provider run. + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } + }; + const publishTail = async (turnId: string, events: SessionEvent[]) => { + const fresh = await refreshBoundAuth(); + const freshEndpoint = { + supabaseUrl: fresh.supabaseUrl, + anonKey: fresh.supabaseAnonKey, + }; + await pushConversationEventsChunked( + fresh.accessToken, + { + orgId, + rootSessionId, + turnId, + events, + }, + freshEndpoint + ); + requireBoundAuth(); + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + }; + + const loadTimeline = async (): Promise<{ + sourceSession: Session | undefined; + sessions: Session[]; + timeline: SessionEvent[]; + }> => { + const key = conversationPlaneKey({ + authIdentityKey, + orgId, + rootSessionId, + }); + const plane = await refreshConversationPlaneEntry({ + store, + auth, + orgId, + rootSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries: (update) => store.set(conversationPlaneAtom, update), + setAuth: (update) => store.set(org2CloudAuthAtom, update), + invalidationKey: `signal:${ + store.get(conversationPlaneSignalAtom)[orgId] ?? 0 + }`, + }); + if (plane.state !== "ready") { + throw new Org2CloudConversationError( + "ORG2_VALIDATION: canonical conversation plane is unavailable" + ); + } + // The plane loader may refresh and commit a newer access token. Every + // subsequent read in this attempt must use that same current auth snapshot + // rather than the token captured before the plane refresh. + const currentAuth = requireBoundAuth(); + const currentEndpoint = { + supabaseUrl: currentAuth.supabaseUrl, + anonKey: currentAuth.supabaseAnonKey, + }; + const planeEvents = plane.hasEarlierEvents + ? await loadCompleteConversationPlaneEvents( + currentAuth.accessToken, + { orgId, rootSessionId }, + currentEndpoint + ) + : plane.events; + requireBoundAuth(); + + const sourceSession = sessionById(store, message.sessionId); + const sessions = store.get(sessionsAtom); + const remoteEntry = remoteSessionsEntryForIdentity( + store.get(org2CloudRemoteSessionsAtom)[orgId], + authIdentityKey + ); + if (!remoteEntry || remoteEntry.state !== "ready") { + throw new QueuedConversationRecoveryPendingError( + "Cloud conversation family metadata is not ready" + ); + } + const family = resolveConversationFamily(remoteEntry.rows, rootSessionId); + const rootRow = remoteEntry.rows.find( + (row) => row.sourceSessionId === rootSessionId + ); + if (!rootRow) { + // The identity-bound listing is ready, so absence is an admission + // failure for viewers as well as owners, not an unbounded hydration + // retry. Keep the failed intent visible and editable. Only owner-local + // sessions may re-resolve to local authority; viewers must not bypass + // a revoked/expired share by silently changing authority. + if (sourceSession && !sourceSession.importedFrom) { + throw new QueuedConversationBlockedError( + "This shared session is no longer available in Cloud; retry to continue it locally" + ); + } + throw new QueuedConversationBlockedError( + "This shared session is no longer available in Cloud; refresh or ask its owner to share it again" + ); + } + const listing = await listSessionComments( + currentAuth.accessToken, + orgId, + rootSessionId, + { endpoint: currentEndpoint } + ); + requireBoundAuth(); + const fetchClient = buildCloudSessionFetchClient(currentAuth.accessToken, { + ...endpointForOrigin(currentAuth.supabaseUrl), + anonKey: currentAuth.supabaseAnonKey, + }); + const loadMemberEvents = async ( + bareSessionId: string, + member: ConversationFamilyMember | null + ): Promise => { + const row = member?.row ?? rootRow; + const local = + sessions.find((session) => session.session_id === bareSessionId) ?? + findImportedSession( + sessions, + orgId, + bareSessionId, + currentAuth.supabaseUrl + ); + // External native histories are intentionally absent from sessionsAtom + // but remain readable by their canonical id. + let localSessionId = + local?.session_id ?? + (message.sessionId === bareSessionId ? message.sessionId : undefined); + if (!localSessionId) { + if ( + row.deletedAt || + row.eventsEpoch === undefined || + row.eventsCount === undefined || + row.eventsCount === 0 + ) { + return []; + } + const imported = await importRemoteSession({ + client: fetchClient, + orgId, + remoteSession: row, + sourceEndpointUrl: currentAuth.supabaseUrl, + }); + localSessionId = imported?.localSessionId; + } + if (!localSessionId) return null; + return (await loadCanonicalConversationEvents(localSessionId)).events; + }; + try { + return { + sourceSession, + sessions, + timeline: await loadCanonicalConversationTimeline({ + family, + anchorBareSessionId: rootSessionId, + planeEvents, + planeHistoryStartedAt: plane.historyStartedAt, + comments: listing.comments, + streamSessionId: message.sessionId, + viewer: { status: "known", userId: currentAuth.userId }, + loadMemberEvents, + }), + }; + } catch (error) { + if (error instanceof CanonicalConversationFamilyUnavailableError) { + throw new QueuedConversationRecoveryPendingError(error.message); + } + throw error; + } + }; + try { + const loaded = await loadTimeline(); + const sourceSession = loaded.sourceSession; + const sessions = loaded.sessions; + const executionRoot = + sourceSession && + !sourceSession.importedFrom && + sourceSession.session_id === rootSessionId + ? (localConversationRootForSession( + sourceSession.session_id, + sourceSession.cliAgentType, + sourceSession.agentDefinitionId + ) ?? undefined) + : undefined; + + // A retry may already have admitted this exact user row. It is never part + // of the provider prefix: the selected runtime receives it exactly once + // through the ordinary dispatch path below. + const preTurnTimeline = loaded.timeline.filter( + (event) => conversationTurnIdOf(event) !== message.turnIntentId + ); + // Native/App-origin history is published by the existing full-replay + // owner before Cloud authority is admitted. This turn adapter owns only + // the new plane user row and its provider tail; appending old local child + // history here would create a second writer and place it at today's plane + // sequence rather than its original transcript position. + let timeline = preTurnTimeline; + + // Crossing into this RPC can be irreversible when its response is lost: + // Cloud may already contain the idempotent human row. Ambiguous failures + // keep the same turn owner; a definitive 4xx remains editable because it + // proves that this write did not commit. + const admissionAuth = requireBoundAuth(); + if (coordinationEnabled) { + const device = await cloudDeviceIdentity(); + coordinationDeviceId = device.deviceId; + await admitCloudConversationTurn( + admissionAuth.accessToken, + { + orgId, + rootSessionId, + turnId: message.turnIntentId, + event: userEvents[0]!, + }, + { + supabaseUrl: admissionAuth.supabaseUrl, + anonKey: admissionAuth.supabaseAnonKey, + } + ); + userEventPublished = true; + requireBoundAuth(); + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + const claimAuth = await refreshBoundAuth(); + const claim = await claimCloudConversationTurn( + claimAuth.accessToken, + { + ...coordinationIdentity(), + leaseSeconds: CLOUD_TURN_LEASE_SECONDS, + }, + { + supabaseUrl: claimAuth.supabaseUrl, + anonKey: claimAuth.supabaseAnonKey, + } + ); + requireBoundAuth(); + if (claim.outcome === "waiting") { + throw new QueuedConversationRecoveryPendingError( + `another Cloud turn is ahead; retry after ${claim.retryAfterMs}ms` + ); + } + if (claim.outcome === "terminal") { + coordinationAlreadyTerminal = true; + if (claim.status === "completed") return; + throw new QueuedConversationTurnClosedError( + `Cloud conversation turn is already ${claim.status}` + ); + } + coordinationClaimed = true; + coordinationAccepted = claim.outcome === "accepted"; + leaseRenewal = startCloudTurnLeaseRenewal(async () => { + const current = requireBoundAuth(); + await renewCloudConversationTurn( + current.accessToken, + { + ...coordinationIdentity(), + leaseSeconds: CLOUD_TURN_LEASE_SECONDS, + }, + { + supabaseUrl: current.supabaseUrl, + anonKey: current.supabaseAnonKey, + } + ); + }); + // The predecessor may finish between the admission preflight read and + // our successful claim. Materialize from history read under FIFO + // ownership, never from that potentially stale preflight snapshot. + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + timeline = (await loadTimeline()).timeline.filter( + (event) => conversationTurnIdOf(event) !== message.turnIntentId + ); + if (message.status === "accepted" || claim.outcome === "accepted") { + if (!message.runnerSessionId) { + throw new QueuedConversationRecoveryPendingError( + "accepted Cloud turn is waiting for its local runner address" + ); + } + await markCoordinationAccepted(); + // Cloud `accepted` means this device crossed the non-stealable FIFO + // boundary immediately before provider dispatch. It is not itself + // proof that the local provider accepted the turn. After a crash in + // that narrow window, keep the durable delivery `preparing` so native + // turn-intent recovery may either reconnect or perform the first send. + // Only a delivery already persisted as accepted may restore that + // irreversible local boundary here. + if (message.status === "accepted") { + try { + await callbacks.onAccepted(message.runnerSessionId); + } catch (error) { + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } + } + } + } else { + // Pre-0028 endpoints retain the exact existing idempotent publication + // path. Capability rollout is additive and never changes old workflow. + await pushConversationEventsChunked( + admissionAuth.accessToken, + { + orgId, + rootSessionId, + turnId: message.turnIntentId, + events: userEvents, + }, + { + supabaseUrl: admissionAuth.supabaseUrl, + anonKey: admissionAuth.supabaseAnonKey, + } + ); + userEventPublished = true; + requireBoundAuth(); + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + } + + let accepted = false; + const accept = async (sessionId: string) => { + if (accepted) return; + accepted = true; + await callbacks.onAccepted(sessionId); + }; + const result = await runConversationTurn({ + root: executionRoot ?? root, + conversationTitle: + sourceSession?.name ?? + sessions.find((session) => session.session_id === rootSessionId) + ?.name ?? + "Conversation", + displayText: message.displayContent, + agentContent: message.content, + imageDataUrls: message.imageDataUrls, + timeline, + target: descriptor.target, + turnIntentId: message.turnIntentId, + queueMessageId: message.id, + ...(message.runnerSessionId + ? { + recovery: { + runnerSessionId: message.runnerSessionId, + eventStartIndex: message.runnerEventStartIndex, + providerAccepted: message.status === "accepted", + }, + } + : {}), + publishTail, + onRunnerReady: async (runnerSessionId, turnId, eventStartIndex) => { + void turnId; + await callbacks.onRunnerReady?.(runnerSessionId, eventStartIndex); + }, + onBeforeTurnDispatch: markCoordinationAccepted, + onTurnAccepted: accept, + }); + // Cloud publication is part of the accepted execution's completion. If it + // failed, the same durable row reconnects to this native turn and retries + // the idempotent push without running the provider again. + await accept(result.runnerSessionId); + await stopLeaseRenewal(); + await finishCoordination(result.terminalStatus); + } catch (error) { + if (error instanceof QueuedConversationRecoveryPendingError) { + throw error; + } + if (error instanceof QueuedConversationTurnClosedError) { + if (coordinationClaimed && !coordinationAlreadyTerminal) { + await stopLeaseRenewal(); + await finishCoordination("failed"); + } + throw error; + } + if (isRetryableCloudRequestError(error)) { + // The response may have been lost after the idempotent write committed, + // or a 5xx may recover. Keep exactly this owner and turn id; recovery + // never sends a second provider request after acceptance. + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } + if (coordinationEnabled && userEventPublished && !coordinationClaimed) { + // Admission and FIFO ownership are one logical handoff. A definitive + // claim rejection must retain the active execution owner; the queue's + // ordinary blocked path demotes/removes it and would orphan this Cloud + // FIFO head. Retry the same idempotent claim until it can be reconciled. + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); + } + if (!userEventPublished) { + // A definitive rejection before the canonical user row exists is still + // editable. Return it to the existing visible held queue instead of + // creating a provider turn or retrying an unchanged 4xx forever. + throw new QueuedConversationBlockedError( + error instanceof Error ? error.message : String(error) + ); + } + // The user row is durable and the failure is definitive. Close the same + // visible turn through the shared terminal-event boundary. If that final + // write is ambiguous it alone remains recovery-pending; a definitive 4xx + // closes the owner and never reruns the provider. + try { + return await closeConversationTurnWithFailure({ + rootLabel: `org2-cloud:${rootSessionId}`, + error, + turnIntentId: message.turnIntentId, + publishTail, + }); + } catch (closeError) { + if ( + closeError instanceof QueuedConversationTurnClosedError && + coordinationClaimed + ) { + await stopLeaseRenewal(); + await finishCoordination("failed"); + } + throw closeError; + } + } finally { + await stopLeaseRenewal(); + } +} diff --git a/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts b/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts index 1405d4cd94..840c59831b 100644 --- a/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts +++ b/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { NATIVE_SOURCE_EVENT_ID_ARG } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { - CONVERSATION_SENDER_ARG, resolveConversationFamily, stitchConversationSegments, } from "./continuationEvents"; @@ -208,6 +209,60 @@ describe("stitchConversationSegments", () => { }); }); + it("drops a renumbered native copy by its preserved global source identity", () => { + const sourceId = "orgii_evt_0c2481a309205d2abd70fd14234cf0f5"; + const rootReply = { + ...evt("codex-asst-97", "2026-08-20T09:00:00Z"), + sessionId: "native-root", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + }; + const inheritedReply = { + ...evt("claude-renumbered-14", "2026-08-20T09:00:00Z"), + sessionId: "native-fork", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + }; + const forkReply = { + ...evt("claude-new-15", "2026-08-20T10:05:00Z"), + sessionId: "native-fork", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: + "orgii_evt_11111111111111111111111111111111", + }, + }; + + const stitched = stitchConversationSegments( + family, + "root-1", + [rootReply], + new Map([["fork-b", [inheritedReply, forkReply]]]) + ); + + expect(stitched).toEqual([rootReply, forkReply]); + }); + + it("drops a renumbered native copy inside the same loaded segment", () => { + const sourceId = "orgii_evt_0c2481a309205d2abd70fd14234cf0f5"; + const original = { + ...evt("codex-asst-97", "2026-08-20T09:00:00Z"), + sessionId: "native-root", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + }; + const replay = { + ...evt("codex-renumbered-101", "2026-08-20T09:00:00Z"), + sessionId: "native-root", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + }; + + const stitched = stitchConversationSegments( + family, + "root-1", + [original, replay], + new Map() + ); + + expect(stitched).toEqual([original]); + }); + it("keeps inherited copies when the root segment is not loaded", () => { const inheritedUser = { ...evt("local-b~root-u1", "2026-08-20T08:59:00Z"), diff --git a/src/features/Org2Cloud/SessionConversation/continuationEvents.ts b/src/features/Org2Cloud/SessionConversation/continuationEvents.ts index 63f341fad7..f528110743 100644 --- a/src/features/Org2Cloud/SessionConversation/continuationEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/continuationEvents.ts @@ -1,3 +1,8 @@ +import { + CONVERSATION_SENDER_ARG, + type ConversationSenderStamp, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { scopedNativeSourceEventIdOf } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { stripCopyEventNamespace } from "@src/features/TeamCollaboration/copyEventId"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; @@ -7,20 +12,67 @@ import { buildCloudSessionThreads, } from "../cloudSessionThreads"; -/** Per-event sender stamp read by UserChatItem for stitched family rows. */ -export const CONVERSATION_SENDER_ARG = "conversationSender"; - -export interface ConversationSenderStamp { - userId: string; - displayName: string; -} - export interface ConversationFamilyMember { bareSessionId: string; row: RemoteTeammateSessionMetadata; isRoot: boolean; } +const MATERIALIZED_TURN_PREFIX = "org2-turn-v1."; +const MATERIALIZED_EVENT_PREFIX = "org2-native-v1."; + +interface MaterializedEventIdentity { + sourceEventId: string; + turnId?: string; +} + +function decodeBase64Url(value: string): string | null { + try { + const padded = `${value.replace(/-/g, "+").replace(/_/g, "/")}${"=".repeat( + (4 - (value.length % 4)) % 4 + )}`; + const bytes = Uint8Array.from(atob(padded), (character) => + character.charCodeAt(0) + ); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +function materializedEventIdentity( + rawEventId: string +): MaterializedEventIdentity | null { + const eventId = rawEventId.startsWith("user-message-") + ? rawEventId.slice("user-message-".length) + : rawEventId; + if (eventId.startsWith(MATERIALIZED_TURN_PREFIX)) { + const [turn, source] = eventId + .slice(MATERIALIZED_TURN_PREFIX.length) + .split(".", 2); + const turnId = decodeBase64Url(turn ?? ""); + const sourceEventId = decodeBase64Url(source ?? ""); + return turnId && sourceEventId ? { sourceEventId, turnId } : null; + } + if (eventId.startsWith(MATERIALIZED_EVENT_PREFIX)) { + const [source] = eventId + .slice(MATERIALIZED_EVENT_PREFIX.length) + .split(".", 1); + const sourceEventId = decodeBase64Url(source ?? ""); + return sourceEventId ? { sourceEventId } : null; + } + return null; +} + +function peelCopyEventNamespaces(event: SessionEvent): string { + let id = stripCopyEventNamespace(event.sessionId, event.id); + for (;;) { + const split = id.indexOf("~"); + if (split <= 0 || id.slice(0, split).includes(":")) return id; + id = id.slice(split + 1); + } +} + /** * Ordered family for one conversation: root first, then forks by fork time. * `null` when the anchor session has no fork family in the org's rows. @@ -66,7 +118,12 @@ function stampSegmentSender( ): SessionEvent[] { const stamp: ConversationSenderStamp = { userId: member.row.ownerUserId, - displayName: member.row.ownerDisplayName, + ...(member.row.ownerDisplayName.trim() + ? { displayName: member.row.ownerDisplayName.trim() } + : {}), + ...(member.row.ownerAvatarUrl + ? { avatarUrl: member.row.ownerAvatarUrl } + : {}), }; return events.map((event) => event.source === "user" @@ -84,12 +141,45 @@ function stampSegmentSender( * event ids carry colons, session ids never do. */ export function sourceEventIdOf(event: SessionEvent): string { - let id = stripCopyEventNamespace(event.sessionId, event.id); - for (;;) { - const split = id.indexOf("~"); - if (split <= 0 || id.slice(0, split).includes(":")) return id; - id = id.slice(split + 1); + // Native materialization can renumber the provider row while preserving the + // original globally scoped event identity in metadata. This is the same + // source identity used by the native projection and therefore must win over + // the local row id when family/plane segments fold copied prefixes. Raw + // provider-local values are intentionally ignored by the helper. + const nativeSourceId = scopedNativeSourceEventIdOf(event); + if (nativeSourceId) return nativeSourceId; + const id = peelCopyEventNamespaces(event); + return materializedEventIdentity(id)?.sourceEventId ?? id; +} + +/** + * Keep the first row for each exact canonical source identity. + * + * This deliberately does not compare content, timestamps, roles or tool + * payloads. Two otherwise identical rows with different source identities are + * distinct conversation events; only a replay carrying the same durable + * source id is a copy. + */ +export function collapseConversationSourceCopies( + events: readonly SessionEvent[], + seenSourceIds: Set = new Set() +): SessionEvent[] { + const fresh: SessionEvent[] = []; + for (const event of events) { + const sourceId = sourceEventIdOf(event); + if (seenSourceIds.has(sourceId)) continue; + seenSourceIds.add(sourceId); + fresh.push(event); } + return fresh; +} + +/** Turn identity recovered from a native Agent row materialized by ORG2. */ +export function materializedConversationTurnIdOf( + event: SessionEvent +): string | null { + const id = peelCopyEventNamespaces(event); + return materializedEventIdentity(id)?.turnId ?? null; } /** @@ -103,7 +193,7 @@ export function sourceEventIdOf(event: SessionEvent): string { * streams in like any arriving message. * * Native org2 forks COPY the parent transcript into the fork (unlike - * external-history forks, which start empty and inherit invisibly), so a + * external-history continuations, which start empty and inherit invisibly), so a * later segment can carry duplicates of everything an earlier segment * already rendered — with the wrong author stamped on them. Cross-segment * dedup by source event id keeps only the first (correctly attributed) @@ -122,10 +212,9 @@ export function stitchConversationSegments( events: readonly SessionEvent[], member: ConversationFamilyMember ) => { - const fresh = events.filter( - (event) => !seenSourceIds.has(sourceEventIdOf(event)) - ); - for (const event of fresh) seenSourceIds.add(sourceEventIdOf(event)); + // Update the shared Set while walking: one native segment can itself + // contain the original materialized row plus a provider-renumbered replay. + const fresh = collapseConversationSourceCopies(events, seenSourceIds); stitched.push(...stampSegmentSender(fresh, member)); }; for (const member of family) { diff --git a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.test.ts b/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.test.ts deleted file mode 100644 index ef84bc9d10..0000000000 --- a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - -import { - buildOwnerUserRow, - findUserEventByIntent, - sliceOwnerTurnTail, -} from "./conversationOwnerPublisher"; - -function event(overrides: Partial): SessionEvent { - return { - id: "evt", - chunk_id: "evt", - sessionId: "owner-session", - createdAt: "2026-08-21T10:00:00Z", - functionName: "assistant_message", - uiCanonical: "assistant_message", - actionType: "assistant", - args: {}, - result: {}, - source: "assistant", - displayText: "hello", - displayStatus: "completed", - displayVariant: "message", - activityStatus: "agent", - payloadRefs: [], - ...overrides, - } as SessionEvent; -} - -function userEvent(id: string, turnIntentId: string, synthetic = true) { - return event({ - id, - functionName: "user_message", - source: "user", - displayText: `ask ${turnIntentId}`, - result: { - type: "user", - message: { content: `ask ${turnIntentId}`, role: "user" }, - ...(synthetic ? { syntheticUserInput: true } : {}), - turnIntentId, - }, - }); -} - -describe("findUserEventByIntent", () => { - it("finds the user row minted for the dispatch and ignores other turns", () => { - const events = [userEvent("u1", "tii-1"), userEvent("u2", "tii-2")]; - expect(findUserEventByIntent(events, "tii-2")?.id).toBe("u2"); - expect(findUserEventByIntent(events, "tii-9")).toBeNull(); - }); -}); - -describe("buildOwnerUserRow", () => { - it("pushes only the visible words under the local id and intent", () => { - const local = userEvent("u1", "tii-1"); - const pushed = buildOwnerUserRow(local, "what the user typed"); - expect(pushed.id).toBe("u1"); - expect(pushed.displayText).toBe("what the user typed"); - expect(pushed.result).toEqual({ - type: "user", - message: { content: "what the user typed", role: "user" }, - turnIntentId: "tii-1", - }); - expect(pushed.createdAt).toBe(local.createdAt); - }); -}); - -describe("sliceOwnerTurnTail", () => { - it("collects the agent rows after the turn's user row up to the next turn", () => { - const events = [ - event({ id: "old-reply" }), - userEvent("u1", "tii-1"), - event({ id: "thinking-1", source: "assistant" }), - userEvent("u1-backend", "tii-1", false), - event({ id: "tool-1", source: "system" }), - event({ id: "reply-1" }), - userEvent("u2", "tii-2"), - event({ id: "reply-2" }), - ]; - expect(sliceOwnerTurnTail(events, "tii-1")?.map((item) => item.id)).toEqual( - ["thinking-1", "tool-1", "reply-1"] - ); - expect(sliceOwnerTurnTail(events, "tii-2")?.map((item) => item.id)).toEqual( - ["reply-2"] - ); - }); - - it("returns null when the dispatch removed its user row", () => { - expect(sliceOwnerTurnTail([event({ id: "x" })], "tii-1")).toBeNull(); - }); -}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts b/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts deleted file mode 100644 index 5121cfe7c4..0000000000 --- a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Owner publisher — the owner's half of "every turn is on the plane". - * - * A member's turn reaches the plane through its one-shot runner; the - * owner's turn runs in the owner's own session and used to reach other - * clients only through the session replay (slow, and ordered by sender - * clock against the plane). This publishes the owner's turn to the plane - * under a turnId exactly like a member turn — the user row as soon as the - * dispatch persisted it, the agent tail at the turn's terminal — so the - * plane's seq is the one order for every turn of the conversation. - * - * The pushed user row reuses the local synthetic event's id and - * turn-intent id; the pushed tail reuses the local event ids. That is what - * lets every client fold the plane rows onto their local twins instead of - * rendering a second copy. - */ -import { - getLastTurnTerminal, - getTurnPhase, - turnLifecycleSignalAtom, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import { extractChatEvents } from "@src/engines/SessionCore/core/store/useSessionEvents"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; -import { createLogger } from "@src/hooks/logger"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; - -import { - boundConversationEventForPush, - pushConversationEventsChunked, -} from "../org2CloudConversationEventsClient"; -import { conversationEventKey } from "./conversationTimeline"; - -export { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; - -const log = createLogger("ConversationOwnerPublisher"); - -const TURN_DEADLINE_MS = 15 * 60_000; - -export function findUserEventByIntent( - events: readonly SessionEvent[], - turnIntentId: string -): SessionEvent | null { - return events.find((event) => turnIntentIdOf(event) === turnIntentId) ?? null; -} - -/** - * The clean user row for the plane: the user's visible words only (the - * agent copy may carry the injected conversation context), under the local - * event's id and turn-intent id so it folds onto the local row everywhere. - */ -export function buildOwnerUserRow( - userEvent: SessionEvent, - displayText: string -): SessionEvent { - const turnIntentId = turnIntentIdOf(userEvent); - return { - id: userEvent.id, - chunk_id: userEvent.id, - sessionId: "conversation", - createdAt: userEvent.createdAt, - functionName: "user_message", - uiCanonical: "user_message", - actionType: "raw", - args: {}, - result: { - type: "user", - message: { content: displayText, role: "user" }, - ...(turnIntentId ? { turnIntentId } : {}), - }, - source: "user", - displayText, - displayStatus: "completed", - displayVariant: "message", - activityStatus: "agent", - payloadRefs: [], - } as SessionEvent; -} - -/** - * The agent tail of one turn: every non-user event after the turn's user - * row, up to the next turn's user row. `null` when the user row is not in - * the transcript (the dispatch failed and removed it). - */ -export function sliceOwnerTurnTail( - events: readonly SessionEvent[], - turnIntentId: string -): SessionEvent[] | null { - const start = events.findIndex( - (event) => turnIntentIdOf(event) === turnIntentId - ); - if (start < 0) return null; - const turnKey = conversationEventKey(events[start]); - const tail: SessionEvent[] = []; - for (let index = start + 1; index < events.length; index += 1) { - const event = events[index]; - if (event.source === "user") { - if (conversationEventKey(event) !== turnKey) break; - continue; - } - tail.push(event); - } - return tail; -} - -function waitForUserEvent( - sessionId: string, - turnIntentId: string, - deadlineMs: number -): Promise { - const cached = eventStoreProxy.getLatestSessionSnapshot(sessionId); - const immediate = cached - ? findUserEventByIntent(extractChatEvents(cached), turnIntentId) - : null; - if (immediate) return Promise.resolve(immediate); - return new Promise((resolve, reject) => { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) { - reject(new Error("owner turn user row never persisted")); - return; - } - let unsubscribe: (() => void) | null = null; - const timer = setTimeout(() => { - unsubscribe?.(); - reject(new Error("owner turn user row never persisted")); - }, remainingMs); - unsubscribe = eventStoreProxy.subscribeSession(sessionId, (snapshot) => { - const found = findUserEventByIntent( - extractChatEvents(snapshot), - turnIntentId - ); - if (!found) return; - clearTimeout(timer); - unsubscribe?.(); - resolve(found); - }); - }); -} - -function waitForTurnEnd( - sessionId: string, - userEventMs: number, - deadlineMs: number -): Promise { - const store = getInstrumentedStore(); - const isDone = (): boolean => - getTurnPhase(sessionId) === "idle" && - (getLastTurnTerminal(sessionId)?.at ?? 0) >= userEventMs; - if (isDone()) return Promise.resolve(); - return new Promise((resolve, reject) => { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) { - reject(new Error("owner turn timed out")); - return; - } - let unsubscribe: (() => void) | null = null; - const timer = setTimeout(() => { - unsubscribe?.(); - reject(new Error("owner turn timed out")); - }, remainingMs); - const check = (): void => { - if (!isDone()) return; - clearTimeout(timer); - unsubscribe?.(); - resolve(); - }; - unsubscribe = store.sub(turnLifecycleSignalAtom, check); - check(); - }); -} - -export interface PublishOwnerTurnParams { - /** Resolved before every push — a long turn outlives a captured token. */ - getAccessToken: () => Promise; - orgId: string; - rootSessionId: string; - /** The owner's own session — the conversation root. */ - sessionId: string; - /** The intent id the dispatch was minted with; keys the local user row. */ - turnIntentId: string; - displayText: string; - /** Fires after each successful push (signal-bump hook). */ - onPushed?: () => void; -} - -export interface PublishOwnerTurnResult { - turnId: string; - pushedEventCount: number; -} - -export async function publishOwnerTurn( - params: PublishOwnerTurnParams -): Promise { - const deadlineMs = Date.now() + TURN_DEADLINE_MS; - const turnId = crypto.randomUUID(); - const userEvent = await waitForUserEvent( - params.sessionId, - params.turnIntentId, - deadlineMs - ); - await pushConversationEventsChunked(await params.getAccessToken(), { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId, - events: [ - boundConversationEventForPush( - buildOwnerUserRow(userEvent, params.displayText) - ), - ], - }); - params.onPushed?.(); - - const userEventMs = new Date(userEvent.createdAt).getTime(); - await waitForTurnEnd( - params.sessionId, - Number.isFinite(userEventMs) ? userEventMs : 0, - deadlineMs - ); - const persisted = await eventStoreProxy - .getPersistedEvents(params.sessionId) - .catch(() => [] as SessionEvent[]); - const tail = sliceOwnerTurnTail(persisted, params.turnIntentId) ?? []; - if (tail.length > 0) { - await pushConversationEventsChunked(await params.getAccessToken(), { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId, - events: tail.map(boundConversationEventForPush), - }); - params.onPushed?.(); - } - log.info( - `published owner turn ${turnId}: 1 + ${tail.length} event(s) to ${params.orgId}:${params.rootSessionId}` - ); - return { turnId, pushedEventCount: 1 + tail.length }; -} diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts index 345bdb5e8f..a49da01f03 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts @@ -1,70 +1,316 @@ -import { describe, expect, it } from "vitest"; +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; import { - type ConversationPlaneEntry, - MAX_TRACKED_CONVERSATIONS, - setPlaneEntry, + boundConversationPlaneWindow, + conversationPlaneAtom, + conversationPlaneKey, + loadCompleteConversationPlaneEvents, + refreshConversationPlaneEntry, } from "./conversationPlaneAtom"; -const entry = (lastSeq: number): ConversationPlaneEntry => ({ - state: "ready", - events: [], - lastSeq, -}); +const mocks = vi.hoisted(() => ({ + ensureFreshSession: vi.fn(), + getCloudCapabilitiesConfirmed: vi.fn(), + listConversationEvents: vi.fn(), +})); -function fill(count: number): Record { - let entries: Record = {}; - for (let index = 0; index < count; index += 1) { - entries = setPlaneEntry(entries, `conversation-${index}`, entry(index)); - } - return entries; +vi.mock("../org2CloudClient", async (importOriginal) => ({ + ...(await importOriginal()), + ensureFreshSession: mocks.ensureFreshSession, +})); + +vi.mock("../org2CloudCapabilities", async (importOriginal) => ({ + ...(await importOriginal()), + getCloudCapabilitiesConfirmed: mocks.getCloudCapabilitiesConfirmed, +})); + +vi.mock("../org2CloudConversationEventsClient", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("../org2CloudConversationEventsClient") + >()), + listConversationEvents: mocks.listConversationEvents, +})); + +function row(seq: number, text = `event-${seq}`): CloudConversationEvent { + const event = { + id: `event-${seq}`, + chunk_id: `event-${seq}`, + sessionId: "root", + createdAt: `2026-08-20T10:00:${String(seq).padStart(2, "0")}Z`, + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: { observation: text }, + source: "assistant", + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; + return { + id: `row-${seq}`, + rootSessionId: "root", + authorUserId: "alice", + turnId: `turn-${seq}`, + seq, + event, + createdAt: event.createdAt, + }; +} + +const AUTH = { + kind: "org2_cloud" as const, + supabaseUrl: "https://cloud.invalid", + supabaseAnonKey: "anon", + userId: "alice", + accessToken: "token", + refreshToken: "refresh", + expiresAt: 4_102_444_800, +}; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; } -describe("setPlaneEntry", () => { - it("writes the entry through unchanged", () => { - const entries = setPlaneEntry({}, "a", entry(7)); - expect(entries.a).toEqual(entry(7)); +describe("conversation plane render window", () => { + it("retains only the bounded visible tail and exposes the durable gap", () => { + const window = boundConversationPlaneWindow([row(1), row(2), row(3)], { + maxEvents: 2, + maxBytes: Number.MAX_SAFE_INTEGER, + }); + + expect(window.events.map((event) => event.seq)).toEqual([2, 3]); + expect(window.firstSeq).toBe(2); + expect(window.hasEarlierEvents).toBe(true); }); - it("holds at the cap instead of growing per conversation opened", () => { - // The regression this guards: one entry per conversation ever opened, - // each carrying that conversation's whole event list, kept forever. - const entries = fill(MAX_TRACKED_CONVERSATIONS + 20); - expect(Object.keys(entries)).toHaveLength(MAX_TRACKED_CONVERSATIONS); + it("does not retain a single event beyond the entry byte ceiling", () => { + const window = boundConversationPlaneWindow([row(1, "large")], { + maxEvents: 10, + maxBytes: 1, + }); + + expect(window.events).toEqual([]); + expect(window.approximateBytes).toBe(0); + expect(window.firstSeq).toBeNull(); + expect(window.hasEarlierEvents).toBe(true); }); +}); + +describe("complete conversation plane pull", () => { + const ENDPOINT = { supabaseUrl: "https://cloud.invalid", anonKey: "anon" }; - it("evicts least-recently-written conversations first", () => { - const entries = fill(MAX_TRACKED_CONVERSATIONS + 2); - expect(entries["conversation-0"]).toBeUndefined(); - expect(entries["conversation-1"]).toBeUndefined(); - expect(entries["conversation-2"]).toBeDefined(); + beforeEach(() => { + mocks.listConversationEvents.mockReset(); }); - it("keeps a conversation alive when it is written again", () => { - let entries = fill(MAX_TRACKED_CONVERSATIONS); - // Re-writing conversation-0 moves it to the most-recent position, so the - // next overflow takes conversation-1 instead. - entries = setPlaneEntry(entries, "conversation-0", entry(99)); - entries = setPlaneEntry(entries, "fresh", entry(0)); + it("follows the wire cursor across a fully quarantined page", async () => { + mocks.listConversationEvents + .mockResolvedValueOnce({ + events: [], + hasMore: true, + lastSeq: 4, + quarantined: 2, + }) + .mockResolvedValueOnce({ + events: [row(5)], + hasMore: false, + lastSeq: 5, + quarantined: 0, + }); + + const events = await loadCompleteConversationPlaneEvents( + "token", + { orgId: "org-1", rootSessionId: "root" }, + ENDPOINT + ); - expect(entries["conversation-0"]).toEqual(entry(99)); - expect(entries["conversation-1"]).toBeUndefined(); + expect(events.map((event) => event.seq)).toEqual([5]); + expect( + mocks.listConversationEvents.mock.calls.map( + (call) => (call[1] as { afterSeq: number }).afterSeq + ) + ).toEqual([0, 4]); }); - it("never evicts the key currently being written", () => { - const entries = setPlaneEntry(fill(MAX_TRACKED_CONVERSATIONS), "new", { - state: "loading", + it("stops when the wire cursor cannot advance", async () => { + mocks.listConversationEvents.mockResolvedValue({ events: [], + hasMore: true, lastSeq: 0, + quarantined: 0, + }); + + await expect( + loadCompleteConversationPlaneEvents( + "token", + { orgId: "org-1", rootSessionId: "root" }, + ENDPOINT + ) + ).resolves.toEqual([]); + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(1); + }); +}); + +describe("conversation plane singleflight", () => { + beforeEach(() => { + mocks.ensureFreshSession.mockReset(); + mocks.ensureFreshSession.mockResolvedValue(AUTH); + mocks.getCloudCapabilitiesConfirmed.mockReset(); + mocks.getCloudCapabilitiesConfirmed.mockResolvedValue({ + confirmed: true, + capabilities: { conversationEvents: true }, + }); + mocks.listConversationEvents.mockReset(); + }); + + function harness() { + const store = createStore(); + store.set(org2CloudAuthAtom, AUTH); + const locator = { + authIdentityKey: org2CloudAuthIdentityKey(AUTH), + orgId: "org-1", + rootSessionId: "root", + }; + const key = conversationPlaneKey(locator); + const refresh = (invalidationKey?: string) => + refreshConversationPlaneEntry({ + store, + auth: AUTH, + orgId: locator.orgId, + rootSessionId: locator.rootSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries: (update) => store.set(conversationPlaneAtom, update), + setAuth: (update) => store.set(org2CloudAuthAtom, update), + ...(invalidationKey ? { invalidationKey } : {}), + }); + return { refresh }; + } + + it("lets ordinary readers join without manufacturing a trailing pull", async () => { + const page = deferred<{ + events: CloudConversationEvent[]; + hasMore: boolean; + lastSeq: number; + quarantined: number; + }>(); + mocks.listConversationEvents.mockReturnValue(page.promise); + const { refresh } = harness(); + + const first = refresh(); + const joined = refresh(); + + expect(joined).toBe(first); + await vi.waitFor(() => { + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(1); + }); + page.resolve({ + events: [row(1)], + hasMore: false, + lastSeq: 1, + quarantined: 0, }); - expect(entries.new).toBeDefined(); - expect(Object.keys(entries)).toHaveLength(MAX_TRACKED_CONVERSATIONS); + await Promise.all([first, joined]); + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(1); }); - it("does not mutate the record it is given", () => { - const before = fill(3); - const snapshot = { ...before }; - setPlaneEntry(before, "another", entry(1)); - expect(before).toEqual(snapshot); + it("runs one trailing pull only for a newer invalidation", async () => { + const firstPage = deferred<{ + events: CloudConversationEvent[]; + hasMore: boolean; + lastSeq: number; + quarantined: number; + }>(); + mocks.listConversationEvents + .mockReturnValueOnce(firstPage.promise) + .mockResolvedValueOnce({ + events: [], + hasMore: false, + lastSeq: 1, + quarantined: 0, + }); + const { refresh } = harness(); + + const first = refresh("signal:1"); + const sameSignal = refresh("signal:1"); + const newerSignal = refresh("signal:2"); + + expect(sameSignal).toBe(first); + expect(newerSignal).not.toBe(first); + await vi.waitFor(() => { + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(1); + }); + firstPage.resolve({ + events: [row(1)], + hasMore: false, + lastSeq: 1, + quarantined: 0, + }); + await Promise.all([first, sameSignal, newerSignal]); + + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(2); + expect( + mocks.listConversationEvents.mock.calls.map( + ([, params]) => params.afterSeq + ) + ).toEqual([0, 1]); + }); + + it("still services a newer invalidation after the joined request fails", async () => { + const firstPage = deferred<{ + events: CloudConversationEvent[]; + hasMore: boolean; + lastSeq: number; + quarantined: number; + }>(); + mocks.listConversationEvents + .mockReturnValueOnce(firstPage.promise) + .mockResolvedValueOnce({ + events: [row(1)], + hasMore: false, + lastSeq: 1, + quarantined: 0, + }); + const { refresh } = harness(); + + const first = refresh("signal:1"); + const firstOutcome = first.then( + () => "resolved", + () => "rejected" + ); + const newerSignal = refresh("signal:2"); + await vi.waitFor(() => { + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(1); + }); + firstPage.reject(new Error("network failed")); + + await expect(newerSignal).resolves.toMatchObject({ + state: "ready", + lastSeq: 1, + }); + await expect(firstOutcome).resolves.toBe("rejected"); + expect(mocks.listConversationEvents).toHaveBeenCalledTimes(2); }); }); diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts index a5445d2bea..ccb313ac30 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts @@ -1,24 +1,47 @@ /** * Client store for the 0024 conversation-events plane: per-conversation - * incremental fetch keyed by `(orgId, rootSessionId)` with a dense - * server-assigned seq cursor. Capability-gated — a pre-0024 backend leaves - * every entry "unsupported" and the fork-wire fallback stays in charge. + * incremental fetch keyed by `(authIdentity, orgId, rootSessionId)` with a + * dense server-assigned seq cursor. `authIdentity` includes the Cloud endpoint + * and account, so two accounts that can name the same org/session never share + * cached events or a single-flight request. Capability-gated — a pre-0024 + * backend leaves every entry "unsupported" and the fork-wire fallback stays + * in charge. */ -import { atom, useAtomValue, useSetAtom, useStore } from "jotai"; -import { useEffect } from "react"; +import { + atom, + type createStore, + useAtomValue, + useSetAtom, + useStore, +} from "jotai"; +import { useEffect, useMemo } from "react"; import { createLogger } from "@src/hooks/logger"; +import { BoundedMap } from "@src/util/collections/BoundedMap"; -import { commitRefreshedAuth, org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + commitRefreshedAuth, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; import { getCloudCapabilitiesConfirmed } from "../org2CloudCapabilities"; import { ensureFreshSession } from "../org2CloudClient"; import { type CloudConversationEvent, + decodeConversationEventChunks, listConversationEvents, } from "../org2CloudConversationEventsClient"; import type { SessionCommentTarget } from "../sessionCommentTarget"; const log = createLogger("ConversationPlane"); +const MAX_CONVERSATION_PLANE_ENTRIES = 64; +const MAX_CONVERSATION_PLANE_BYTES = 128 * 1024 * 1024; +/** A mounted conversation keeps only a renderable tail, never its full life. */ +export const MAX_CONVERSATION_PLANE_ENTRY_EVENTS = 2_000; +export const MAX_CONVERSATION_PLANE_ENTRY_BYTES = 16 * 1024 * 1024; +const MAX_CONVERSATION_PLANE_PULL_BYTES = 128 * 1024 * 1024; +const MAX_CONVERSATION_PLANE_SIGNALS = 64; export type ConversationPlaneState = | "idle" @@ -28,91 +51,527 @@ export type ConversationPlaneState = | "error"; export interface ConversationPlaneEntry { + /** Endpoint + account privacy/cache boundary for this snapshot. */ + authIdentityKey: string; + orgId: string; + rootSessionId: string; state: ConversationPlaneState; /** Ordered by seq asc; deduped by wire id. */ events: CloudConversationEvent[]; + /** First retained logical row; null when the visible window is empty. */ + firstSeq: number | null; + /** True when durable Cloud history exists before `firstSeq`. */ + hasEarlierEvents: boolean; + /** Timestamp of the first logical plane row, retained after tail eviction. */ + historyStartedAt: string | null; lastSeq: number; + /** Cached payload estimate; avoids serializing every retained transcript. */ + approximateBytes?: number; +} + +export interface ConversationPlaneLocator { + authIdentityKey: string; + orgId: string; + rootSessionId: string; } -const EMPTY_ENTRY: ConversationPlaneEntry = { - state: "idle", - events: [], - lastSeq: 0, -}; +const KEY_SEPARATOR = "\u001f"; + +function emptyEntry(locator: ConversationPlaneLocator): ConversationPlaneEntry { + return { + ...locator, + state: "idle", + events: [], + firstSeq: null, + hasEarlierEvents: false, + historyStartedAt: null, + lastSeq: 0, + approximateBytes: 0, + }; +} export function conversationPlaneKey( - orgId: string, - rootSessionId: string + locator: ConversationPlaneLocator ): string { - return `${orgId}:${rootSessionId}`; + return [locator.authIdentityKey, locator.orgId, locator.rootSessionId].join( + KEY_SEPARATOR + ); } export const conversationPlaneAtom = atom< Record >({}); -/** - * How many conversations keep a fetched plane entry. - * - * The store used to hold one entry per conversation ever opened, forever, and - * each entry carries that conversation's full event list — so it grew without - * bound across a long session. Eviction is safe and self-healing: a dropped - * entry falls back to `EMPTY_ENTRY`, whose `lastSeq` of 0 makes the next open - * refetch the conversation from the start. - */ -export const MAX_TRACKED_CONVERSATIONS = 24; +/** orgId → monotonically increasing signal counter (realtime bump). */ +export const conversationPlaneSignalAtom = atom>({}); -/** - * Rewrite `key` to the most-recently-used position and drop the least recently - * used entries beyond the cap. - * - * Recency rides on object key order, which is insertion order for string keys: - * deleting `key` before re-adding it moves it to the end, so the oldest - * entries sit at the front and are pruned first. `key` itself is never a - * victim — the caller is writing it right now. - */ -export function setPlaneEntry( - entries: Record, +type ConversationPlaneEntries = Record; +type JotaiStore = ReturnType; +type SetConversationPlaneEntries = ( + update: (current: ConversationPlaneEntries) => ConversationPlaneEntries +) => void; +type SetCloudAuth = ( + update: (current: Org2CloudAuthState | null) => Org2CloudAuthState | null +) => void; + +interface RefreshConversationPlaneParams { + store: JotaiStore; + auth: Org2CloudAuthState; + orgId: string; + rootSessionId: string; + getEntry: () => ConversationPlaneEntry | undefined; + setEntries: SetConversationPlaneEntries; + setAuth: SetCloudAuth; + /** + * Stable identity of a real invalidation that initiated this refresh. + * Concurrent readers with the same identity join one request; a newer + * identity arriving while it is in flight schedules exactly one trailing + * pull. Ordinary readers omit it and only join. + */ + invalidationKey?: string; +} + +interface ConversationPlaneRequestState { + activeIdentityKey: string | null; + epoch: number; + inFlightByKey: Map>; + trailingRefreshKeys: Set; + activeInvalidationByKey: Map; + trailingInvalidationByKey: Map; +} + +const requestStateByStore = new WeakMap< + JotaiStore, + ConversationPlaneRequestState +>(); + +function requestStateFor(store: JotaiStore): ConversationPlaneRequestState { + let state = requestStateByStore.get(store); + if (!state) { + state = { + activeIdentityKey: null, + epoch: 0, + inFlightByKey: new Map(), + trailingRefreshKeys: new Set(), + activeInvalidationByKey: new Map(), + trailingInvalidationByKey: new Map(), + }; + requestStateByStore.set(store, state); + } + return state; +} + +function boundedRecordWrite( + current: Record, key: string, - entry: ConversationPlaneEntry, - maxEntries: number = MAX_TRACKED_CONVERSATIONS -): Record { - const { [key]: _replaced, ...rest } = entries; - const next: Record = { - ...rest, - [key]: entry, - }; - const keys = Object.keys(next); - if (keys.length <= maxEntries) return next; - for (const victim of keys.slice(0, keys.length - maxEntries)) { - if (victim === key) continue; - delete next[victim]; + value: T, + maxSize: number +): Record { + const bounded = new BoundedMap({ maxSize }); + for (const [existingKey, existingValue] of Object.entries(current)) { + bounded.set(existingKey, existingValue); } - return next; + bounded.set(key, value); + return Object.fromEntries(bounded.entries()); } -/** orgId → monotonically increasing signal counter (realtime bump). */ -export const conversationPlaneSignalAtom = atom>({}); +function writeConversationPlaneEntry( + current: ConversationPlaneEntries, + key: string, + entry: ConversationPlaneEntry +): ConversationPlaneEntries { + const bounded = boundedRecordWrite( + current, + key, + entry, + MAX_CONVERSATION_PLANE_ENTRIES + ); + const entries = Object.entries(bounded); + let approximateBytes = entries.reduce( + (total, [, value]) => total + conversationPlaneEntryBytes(value), + 0 + ); + while ( + approximateBytes > MAX_CONVERSATION_PLANE_BYTES && + entries.length > 1 + ) { + const oldestIndex = entries.findIndex(([candidate]) => candidate !== key); + if (oldestIndex < 0) break; + const removed = entries.splice(oldestIndex, 1)[0]; + if (!removed) break; + approximateBytes -= conversationPlaneEntryBytes(removed[1]); + } + return Object.fromEntries(entries); +} -const inFlightByKey = new Set(); +function conversationPlaneEntryBytes(entry: ConversationPlaneEntry): number { + return entry.approximateBytes ?? approximateEventBytes(entry.events); +} + +function approximateEventBytes( + events: readonly CloudConversationEvent[] +): number { + return events.reduce( + (total, event) => total + JSON.stringify(event).length * 2, + 0 + ); +} -function mergePlaneEvents( - previous: ConversationPlaneEntry, +function appendWirePageWithinPullBound( + destination: CloudConversationEvent[], + page: readonly CloudConversationEvent[], + currentBytes: number +): number { + const nextBytes = currentBytes + approximateEventBytes(page); + if (nextBytes > MAX_CONVERSATION_PLANE_PULL_BYTES) { + throw new Error( + `canonical conversation plane exceeds the ${MAX_CONVERSATION_PLANE_PULL_BYTES}-byte reconstruction bound` + ); + } + destination.push(...page); + return nextBytes; +} + +export interface ConversationPlaneWindow { + events: CloudConversationEvent[]; + approximateBytes: number; + firstSeq: number | null; + hasEarlierEvents: boolean; +} + +/** + * Bound the in-memory render window without moving the durable `lastSeq` + * cursor backwards. The Cloud plane remains the history authority; callers + * that need native reconstruction use `loadCompleteConversationPlaneEvents` + * rather than treating this UI tail as a checkpoint. + */ +export function boundConversationPlaneWindow( + events: readonly CloudConversationEvent[], + options: { maxEvents?: number; maxBytes?: number } = {} +): ConversationPlaneWindow { + const maxEvents = options.maxEvents ?? MAX_CONVERSATION_PLANE_ENTRY_EVENTS; + const maxBytes = options.maxBytes ?? MAX_CONVERSATION_PLANE_ENTRY_BYTES; + let start = Math.max(0, events.length - Math.max(0, maxEvents)); + let approximateBytes = approximateEventBytes(events.slice(start)); + while (start < events.length && approximateBytes > maxBytes) { + approximateBytes -= approximateEventBytes([events[start]]); + start += 1; + } + const retained = events.slice(start); + return { + events: retained, + approximateBytes, + firstSeq: retained[0]?.seq ?? null, + hasEarlierEvents: start > 0, + }; +} + +function appendConversationEvents( + base: readonly CloudConversationEvent[], incoming: readonly CloudConversationEvent[] -): ConversationPlaneEntry { - if (incoming.length === 0) { - return { ...previous, state: "ready" }; +): CloudConversationEvent[] { + if (incoming.length === 0) return [...base]; + const incomingOrdered = incoming.every( + (event, index) => index === 0 || incoming[index - 1]!.seq <= event.seq + ); + const baseTip = base.at(-1)?.seq ?? 0; + if (incomingOrdered && baseTip <= incoming[0]!.seq) { + return [...base, ...incoming]; + } + // Defensive fallback for a server/schema regression; normal incremental + // pages never pay this full-history sort. + return [...base, ...incoming].sort((left, right) => left.seq - right.seq); +} + +/** Load the exact durable plane for execution; this result is never cached. */ +export async function loadCompleteConversationPlaneEvents( + accessToken: string, + params: { orgId: string; rootSessionId: string }, + endpoint: { supabaseUrl: string; anonKey: string } +): Promise { + let afterSeq = 0; + const wireEvents: CloudConversationEvent[] = []; + let wireBytes = 0; + for (;;) { + const page = await listConversationEvents( + accessToken, + { ...params, afterSeq }, + endpoint + ); + wireBytes = appendWirePageWithinPullBound( + wireEvents, + page.events, + wireBytes + ); + // Quarantined rows leave no readable event but still own a seq; follow the + // wire cursor so one poisoned row cannot stall the pull, and stop as soon + // as it fails to advance. + const advanced = page.lastSeq > afterSeq; + afterSeq = Math.max(afterSeq, page.lastSeq); + if (!page.hasMore || !advanced) break; } - const known = new Set(previous.events.map((event) => event.id)); - const fresh = incoming.filter((event) => !known.has(event.id)); - const events = [...previous.events, ...fresh].sort( - (left, right) => left.seq - right.seq + return decodeConversationEventChunks(wireEvents); +} + +function activateConversationPlaneIdentity( + store: JotaiStore, + authIdentityKey: string | null, + setEntries: SetConversationPlaneEntries +): ConversationPlaneRequestState { + const state = requestStateFor(store); + if (state.activeIdentityKey === authIdentityKey) return state; + state.activeIdentityKey = authIdentityKey; + state.epoch += 1; + state.inFlightByKey.clear(); + state.trailingRefreshKeys.clear(); + state.activeInvalidationByKey.clear(); + state.trailingInvalidationByKey.clear(); + setEntries((current) => { + const retained = Object.fromEntries( + Object.entries(current).filter( + ([, entry]) => entry.authIdentityKey === authIdentityKey + ) + ); + return Object.keys(retained).length === Object.keys(current).length + ? current + : retained; + }); + return state; +} + +function storeHasConversationPlaneIdentity( + store: JotaiStore, + authIdentityKey: string +): boolean { + const current = store.get(org2CloudAuthAtom); + return Boolean( + current && org2CloudAuthIdentityKey(current) === authIdentityKey ); +} + +function locatorForRequest( + params: Pick< + RefreshConversationPlaneParams, + "auth" | "orgId" | "rootSessionId" + > +): ConversationPlaneLocator { return { - state: "ready", - events, - lastSeq: events.length > 0 ? events[events.length - 1].seq : 0, + authIdentityKey: org2CloudAuthIdentityKey(params.auth), + orgId: params.orgId, + rootSessionId: params.rootSessionId, + }; +} + +function entryMatchesLocator( + entry: ConversationPlaneEntry | undefined, + locator: ConversationPlaneLocator +): entry is ConversationPlaneEntry { + return ( + entry?.authIdentityKey === locator.authIdentityKey && + entry.orgId === locator.orgId && + entry.rootSessionId === locator.rootSessionId + ); +} + +/** + * One authoritative loader shared by the mounted transcript and the submit + * boundary. A capable backend must never race through the legacy visible-fork + * path merely because its first plane fetch is still in flight. + */ +export function refreshConversationPlaneEntry( + params: RefreshConversationPlaneParams +): Promise { + const locator = locatorForRequest(params); + if ( + !storeHasConversationPlaneIdentity(params.store, locator.authIdentityKey) + ) { + return Promise.reject( + new Error("cloud auth identity changed before plane refresh") + ); + } + const key = conversationPlaneKey(locator); + const requestState = activateConversationPlaneIdentity( + params.store, + locator.authIdentityKey, + params.setEntries + ); + const requestEpoch = requestState.epoch; + const isCurrentRequest = () => + requestState.activeIdentityKey === locator.authIdentityKey && + requestState.epoch === requestEpoch; + const existing = requestState.inFlightByKey.get(key); + if (existing) { + const requestedInvalidation = params.invalidationKey; + const latestInvalidation = + requestState.trailingInvalidationByKey.get(key) ?? + requestState.activeInvalidationByKey.get(key); + if ( + requestedInvalidation === undefined || + requestedInvalidation === latestInvalidation + ) { + return existing; + } + requestState.trailingRefreshKeys.add(key); + requestState.trailingInvalidationByKey.set(key, requestedInvalidation); + const runTrailingRefresh = () => { + if (!requestState.trailingRefreshKeys.delete(key)) return null; + const trailingInvalidation = + requestState.trailingInvalidationByKey.get(key) ?? + requestedInvalidation; + requestState.trailingInvalidationByKey.delete(key); + return refreshConversationPlaneEntry({ + ...params, + invalidationKey: trailingInvalidation, + }); + }; + return existing.then( + (entry) => runTrailingRefresh() ?? entry, + (error: unknown) => { + const trailing = runTrailingRefresh(); + if (trailing) return trailing; + throw error; + } + ); + } + + if (params.invalidationKey !== undefined) { + requestState.activeInvalidationByKey.set(key, params.invalidationKey); + } + + const load = (async (): Promise => { + const storedBefore = params.getEntry(); + const before = entryMatchesLocator(storedBefore, locator) + ? storedBefore + : emptyEntry(locator); + if (before.state !== "ready") { + params.setEntries((current) => + isCurrentRequest() + ? writeConversationPlaneEntry(current, key, { + ...before, + state: "loading", + }) + : current + ); + } + try { + const fresh = await ensureFreshSession(params.auth); + if (!fresh) throw new Error("cloud auth refresh failed"); + if ( + !isCurrentRequest() || + org2CloudAuthIdentityKey(fresh) !== locator.authIdentityKey + ) { + throw new Error("cloud auth identity changed during plane refresh"); + } + commitRefreshedAuth(params.setAuth, params.auth, fresh); + const endpoint = { + supabaseUrl: fresh.supabaseUrl, + anonKey: fresh.supabaseAnonKey, + }; + const probe = await getCloudCapabilitiesConfirmed( + fresh.accessToken, + endpoint + ); + if (!probe.capabilities.conversationEvents) { + if (!probe.confirmed) { + throw new Error( + "conversation plane capability probe was unconfirmed" + ); + } + const unsupported = { + ...emptyEntry(locator), + state: "unsupported", + } as const; + params.setEntries((current) => + isCurrentRequest() + ? writeConversationPlaneEntry(current, key, unsupported) + : current + ); + return unsupported; + } + + const stored = params.getEntry(); + const base = entryMatchesLocator(stored, locator) ? stored : before; + let afterSeq = base.lastSeq; + const incomingWireEvents: CloudConversationEvent[] = []; + let incomingWireBytes = 0; + for (;;) { + const page = await listConversationEvents( + fresh.accessToken, + { + orgId: params.orgId, + rootSessionId: params.rootSessionId, + afterSeq, + }, + endpoint + ); + if (!isCurrentRequest()) { + throw new Error("cloud auth identity changed during plane refresh"); + } + incomingWireBytes = appendWirePageWithinPullBound( + incomingWireEvents, + page.events, + incomingWireBytes + ); + const advanced = page.lastSeq > afterSeq; + afterSeq = Math.max(afterSeq, page.lastSeq); + if (!page.hasMore || !advanced) break; + } + const decodedIncoming = + await decodeConversationEventChunks(incomingWireEvents); + const known = new Set(base.events.map((event) => event.id)); + const novelIncoming = decodedIncoming.filter( + (event) => !known.has(event.id) + ); + const completeVisibleInput = appendConversationEvents( + base.events, + novelIncoming + ); + const window = boundConversationPlaneWindow(completeVisibleInput); + const resolved: ConversationPlaneEntry = { + ...base, + state: "ready", + events: window.events, + firstSeq: window.firstSeq, + hasEarlierEvents: base.hasEarlierEvents || window.hasEarlierEvents, + historyStartedAt: + base.historyStartedAt ?? completeVisibleInput[0]?.createdAt ?? null, + // Chunk envelopes collapse to one logical event. Advance only after + // every fetched page decodes successfully, so a partial group never + // becomes a visible ready snapshot or consumes its retry cursor. + lastSeq: afterSeq, + approximateBytes: window.approximateBytes, + }; + params.setEntries((current) => + isCurrentRequest() + ? writeConversationPlaneEntry(current, key, resolved) + : current + ); + return resolved; + } catch (error) { + params.setEntries((current) => { + if (!isCurrentRequest()) return current; + const storedCurrent = current[key]; + const previous = entryMatchesLocator(storedCurrent, locator) + ? storedCurrent + : emptyEntry(locator); + if (previous.state === "ready") return current; + return writeConversationPlaneEntry(current, key, { + ...previous, + state: "error", + }); + }); + throw error; + } + })(); + requestState.inFlightByKey.set(key, load); + const clearInFlight = () => { + if (requestState.inFlightByKey.get(key) === load) { + requestState.inFlightByKey.delete(key); + requestState.activeInvalidationByKey.delete(key); + } }; + void load.then(clearInFlight, clearInFlight); + return load; } /** @@ -125,76 +584,66 @@ export function useConversationPlaneEvents( target: SessionCommentTarget | null ): ConversationPlaneEntry { const auth = useAtomValue(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; const store = useStore(); const setAuth = useSetAtom(org2CloudAuthAtom); const entries = useAtomValue(conversationPlaneAtom); const setEntries = useSetAtom(conversationPlaneAtom); const signals = useAtomValue(conversationPlaneSignalAtom); + const setSignals = useSetAtom(conversationPlaneSignalAtom); const targetOrgId = target?.orgId; const targetSessionId = target?.sessionId; const signal = targetOrgId ? (signals[targetOrgId] ?? 0) : 0; - const key = - targetOrgId && targetSessionId - ? conversationPlaneKey(targetOrgId, targetSessionId) - : null; - const entry = key ? (entries[key] ?? EMPTY_ENTRY) : EMPTY_ENTRY; + const locator = useMemo( + () => + authIdentityKey && targetOrgId && targetSessionId + ? { + authIdentityKey, + orgId: targetOrgId, + rootSessionId: targetSessionId, + } + : null, + [authIdentityKey, targetOrgId, targetSessionId] + ); + const key = locator ? conversationPlaneKey(locator) : null; + const entry = locator + ? entryMatchesLocator(entries[key!], locator) + ? entries[key!] + : emptyEntry(locator) + : emptyEntry({ authIdentityKey: "", orgId: "", rootSessionId: "" }); useEffect(() => { - if (!targetOrgId || !targetSessionId || !key || !auth) return; - const currentEntry = store.get(conversationPlaneAtom)[key] ?? EMPTY_ENTRY; - const entryState = currentEntry.state; - if (entryState === "unsupported") return; - if (inFlightByKey.has(key)) return; - inFlightByKey.add(key); + const previousIdentity = requestStateFor(store).activeIdentityKey; + activateConversationPlaneIdentity(store, authIdentityKey, setEntries); + if (previousIdentity !== authIdentityKey) setSignals({}); + }, [authIdentityKey, setEntries, setSignals, store]); + + useEffect(() => { + if (!targetOrgId || !targetSessionId || !key || !auth || !locator) return; + const storedCurrent = store.get(conversationPlaneAtom)[key]; + const currentEntry = entryMatchesLocator(storedCurrent, locator) + ? storedCurrent + : emptyEntry(locator); + if (currentEntry.state === "unsupported") return; void (async () => { - try { - const fresh = await ensureFreshSession(auth); - if (!fresh) return; - commitRefreshedAuth(setAuth, auth, fresh); - const probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); - if (!probe.capabilities.conversationEvents) { - if (probe.confirmed) { - setEntries((current) => - setPlaneEntry(current, key, { - ...EMPTY_ENTRY, - state: "unsupported", - }) - ); - } - return; - } - let afterSeq = currentEntry.lastSeq; - for (;;) { - const page = await listConversationEvents(fresh.accessToken, { - orgId: targetOrgId, - rootSessionId: targetSessionId, - afterSeq, - }); - setEntries((current) => - setPlaneEntry( - current, - key, - mergePlaneEvents(current[key] ?? EMPTY_ENTRY, page.events) - ) - ); - if (!page.hasMore || page.events.length === 0) break; - afterSeq = page.events[page.events.length - 1].seq; - } - } catch (error) { - log.warn(`conversation plane fetch failed for ${key}`, error); - setEntries((current) => { - const previous = current[key] ?? EMPTY_ENTRY; - if (previous.state === "ready") return current; - return setPlaneEntry(current, key, { ...previous, state: "error" }); - }); - } finally { - inFlightByKey.delete(key); - } - })(); + await refreshConversationPlaneEntry({ + store, + auth, + orgId: targetOrgId, + rootSessionId: targetSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries, + setAuth, + invalidationKey: `signal:${signal}`, + }); + })().catch((error: unknown) => { + log.warn(`conversation plane fetch failed for ${key}`, error); + }); }, [ targetOrgId, targetSessionId, key, + locator, auth, setAuth, setEntries, @@ -212,5 +661,12 @@ export function bumpConversationPlaneSignal( ) => void, orgId: string ): void { - set((current) => ({ ...current, [orgId]: (current[orgId] ?? 0) + 1 })); + set((current) => + boundedRecordWrite( + current, + orgId, + (current[orgId] ?? 0) + 1, + MAX_CONVERSATION_PLANE_SIGNALS + ) + ); } diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.test.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.test.ts new file mode 100644 index 0000000000..e6693d208f --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + NATIVE_SOURCE_EVENT_ID_ARG, + projectNativeConversationItems, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; +import { buildConversationPlaneStreamEvents } from "./conversationPlaneEvents"; + +function assistant( + sessionId: string, + text: string, + rowId: string +): CloudConversationEvent { + const event = { + id: `${sessionId}-event`, + chunk_id: `${sessionId}-event`, + sessionId, + createdAt: "2026-09-05T10:00:00.000Z", + functionName: "assistant", + uiCanonical: "assistant", + actionType: "assistant", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: "codex-asst-97" }, + result: { content: text }, + source: "assistant", + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; + return { + id: rowId, + rootSessionId: "root", + authorUserId: "user", + turnId: `turn-${rowId}`, + seq: Number(rowId.replace(/\D/g, "")), + event, + createdAt: event.createdAt, + }; +} + +describe("buildConversationPlaneStreamEvents", () => { + it("scopes reused legacy provider ids before replacing the native session id", () => { + const streamed = buildConversationPlaneStreamEvents( + [ + assistant("codex-episode-a", "first", "row-1"), + assistant("codex-episode-b", "second", "row-2"), + ], + "shared-root" + ); + const items = projectNativeConversationItems(streamed); + + expect(items).toHaveLength(2); + expect(items.map((item) => item.id)).toEqual([ + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + expect.stringMatching(/^orgii_evt_[a-f0-9]{32}$/), + ]); + expect(new Set(items.map((item) => item.id))).toHaveProperty("size", 2); + expect(items.map((item) => item.id)).not.toContain("codex-asst-97"); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts index 77da3fec8b..d4abf11c83 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts @@ -1,10 +1,14 @@ -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - -import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; import { CONVERSATION_SENDER_ARG, type ConversationSenderStamp, -} from "./continuationEvents"; +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { + NATIVE_SOURCE_EVENT_ID_ARG, + nativeSourceEventId, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; const PLANE_ID_PREFIX = "convplane-"; @@ -23,7 +27,10 @@ export function buildConversationPlaneStreamEvents( const inner = row.event; const stamp: ConversationSenderStamp = { userId: row.authorUserId, - displayName: row.authorDisplayName?.trim() || row.authorUserId, + ...(row.authorDisplayName?.trim() + ? { displayName: row.authorDisplayName.trim() } + : {}), + ...(row.authorAvatarUrl ? { avatarUrl: row.authorAvatarUrl } : {}), }; const stamped: SessionEvent = { ...inner, @@ -33,8 +40,15 @@ export function buildConversationPlaneStreamEvents( createdAt: inner.createdAt || row.createdAt, args: inner.source === "user" - ? { ...inner.args, [CONVERSATION_SENDER_ARG]: stamp } - : inner.args, + ? { + ...inner.args, + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(inner), + [CONVERSATION_SENDER_ARG]: stamp, + } + : { + ...inner.args, + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(inner), + }, }; return stamped; }); diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.test.ts b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.test.ts new file mode 100644 index 0000000000..68670ff50d --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from "vitest"; + +import { + NATIVE_SOURCE_EVENT_ID_ARG, + nativeSourceEventId, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + conversationRunnerOverlaysEqual, + selectConversationRunnerTail, +} from "./conversationRunnerOverlay"; + +function event(text: string): SessionEvent { + return { + id: "runlive-answer", + chunk_id: "runlive-answer", + sessionId: "root", + createdAt: "2026-08-20T10:00:00Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: { observation: text }, + source: "assistant", + displayText: text, + displayStatus: "running", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function completedTool( + id: string, + output: string, + sourceEventId?: string +): SessionEvent { + return { + ...event(output), + id, + chunk_id: id, + functionName: "read_file", + uiCanonical: "tool_call", + actionType: "tool_call", + callId: `call-${id}`, + args: { + path: "/repo/README.md", + ...(sourceEventId ? { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceEventId } : {}), + }, + result: { status: "completed", output }, + displayStatus: "completed", + displayVariant: "tool_call", + } as SessionEvent; +} + +describe("conversation runner overlay stability", () => { + it("reuses equal projections but publishes visible streaming changes", () => { + const first = event("working"); + expect( + conversationRunnerOverlaysEqual( + [first], + [{ ...first, args: first.args, result: first.result }] + ) + ).toBe(true); + expect( + conversationRunnerOverlaysEqual( + [first], + [{ ...first, displayText: "new output" }] + ) + ).toBe(false); + }); + + it("does not expose a fresh child's materialized prefix before the current turn lands", () => { + const runner = { + runnerSessionId: "fresh-child", + turnId: "current-turn", + // This belongs to the raw provider transcript, not this filtered array. + eventStartIndex: 1, + }; + const historical = event("historical answer"); + + expect(selectConversationRunnerTail(runner, [historical])).toEqual([]); + + const currentUser = { + ...event("current prompt"), + id: "current-user", + chunk_id: "current-user", + source: "user", + functionName: "user_message", + result: { turnIntentId: "current-turn" }, + } as SessionEvent; + const currentAssistant = { + ...event("current answer"), + id: "current-assistant", + chunk_id: "current-assistant", + result: { + observation: "current answer", + turnIntentId: "current-turn", + }, + }; + expect( + selectConversationRunnerTail(runner, [ + historical, + currentUser, + currentAssistant, + ]) + ).toEqual([currentAssistant]); + }); + + it("selects only exact current-turn output from a fresh child's materialized prefix", () => { + const runner = { + runnerSessionId: "fresh-codex-child", + turnId: "current-turn", + eventStartIndex: 40, + }; + const historicalAnswer = { + ...event("historical answer"), + id: "historical-answer", + chunk_id: "historical-answer", + }; + const historicalTool = completedTool( + "historical-tool", + "old file contents" + ); + const currentUser = { + ...event("inspect the current repository"), + id: "current-user", + chunk_id: "current-user", + source: "user", + functionName: "user_message", + result: { turnIntentId: "current-turn" }, + } as SessionEvent; + const copiedAnswer = { + ...historicalAnswer, + id: "codex-copy-answer", + chunk_id: "codex-copy-answer", + // Current native writers persist the globally scoped canonical identity. + // A raw provider-local hint is deliberately untrusted because it can + // collide with a genuine later row in the same provider Session. + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(historicalAnswer), + }, + }; + const copiedTool = completedTool( + "codex-copy-tool", + "old file contents", + nativeSourceEventId(historicalTool) + ); + const currentTool = completedTool("current-tool", "current file contents"); + currentTool.result.turnIntentId = "current-turn"; + const currentAnswer = { + ...event("still working"), + id: "current-answer", + chunk_id: "current-answer", + result: { observation: "still working", turnIntentId: "current-turn" }, + }; + + expect( + selectConversationRunnerTail(runner, [ + currentUser, + copiedAnswer, + copiedTool, + currentTool, + currentAnswer, + ]).map((candidate) => candidate.id) + ).toEqual(["current-tool", "current-answer"]); + }); + + it("shows intent-stamped native output without a private user anchor", () => { + const runner = { + runnerSessionId: "reused-codex-child", + turnId: "current-turn", + // Raw provider history and the visible chat projection have different + // lengths, so this value must not be applied to the projected array. + eventStartIndex: 40, + }; + const historicalAnswer = { + ...event("historical answer"), + id: "historical-answer", + chunk_id: "historical-answer", + }; + const copiedHistoricalAnswer = { + ...historicalAnswer, + id: "copied-historical-answer", + chunk_id: "copied-historical-answer", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(historicalAnswer), + }, + }; + const nativeUserWithoutPrivateIntent = { + ...event("inspect the repository"), + id: "native-current-user", + chunk_id: "native-current-user", + source: "user", + functionName: "user_message", + result: { message: { role: "user", content: "inspect the repository" } }, + } as SessionEvent; + const currentTool = completedTool("current-tool", "package contents"); + currentTool.result.turnIntentId = "current-turn"; + const currentProgress = { + ...event("still inspecting"), + id: "current-progress", + chunk_id: "current-progress", + result: { observation: "still inspecting", turnIntentId: "current-turn" }, + }; + + expect( + selectConversationRunnerTail(runner, [ + copiedHistoricalAnswer, + nativeUserWithoutPrivateIntent, + currentTool, + currentProgress, + ]).map((candidate) => candidate.id) + ).toEqual(["current-tool", "current-progress"]); + }); + + it("uses the exact accepted user boundary for Rust Agent output", () => { + const runner = { + runnerSessionId: "sde-child", + turnId: "current-turn", + eventStartIndex: 14, + }; + const acceptedUser = { + ...event("inspect the repository"), + id: "accepted-user", + chunk_id: "accepted-user", + source: "user", + functionName: "user_message", + result: { turnIntentId: "current-turn" }, + } as SessionEvent; + const copiedPrefix = { + ...event("copied historical answer"), + id: "copied-prefix", + chunk_id: "copied-prefix", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId( + event("historical answer") + ), + }, + }; + const currentTool = completedTool("sde-tool", "package contents"); + const currentAnswer = { + ...event("repository summary"), + id: "sde-answer", + chunk_id: "sde-answer", + // Rust Agent terminal/error rows already carry the durable identity, + // while ordinary tool rows in the same turn currently rely on the + // accepted-user boundary. Both must survive in original order. + result: { + observation: "repository summary", + turnIntentId: "current-turn", + }, + }; + const anotherTurn = { + ...event("another answer"), + id: "another-answer", + chunk_id: "another-answer", + }; + const anotherUser = { + ...event("next prompt"), + id: "another-user", + chunk_id: "another-user", + source: "user", + functionName: "user_message", + result: { turnIntentId: "other-turn" }, + } as SessionEvent; + + expect( + selectConversationRunnerTail(runner, [ + acceptedUser, + copiedPrefix, + currentTool, + currentAnswer, + anotherUser, + anotherTurn, + ]).map((candidate) => candidate.id) + ).toEqual(["sde-tool", "sde-answer"]); + }); + + it("does not expose a fresh native prefix without an intent anchor", () => { + const runner = { + runnerSessionId: "fresh-native-child", + turnId: "current-turn", + eventStartIndex: 2, + }; + const historical = { + ...event("historical answer"), + id: "historical-answer", + chunk_id: "historical-answer", + }; + const copied = { + ...historical, + id: "copied-historical-answer", + chunk_id: "copied-historical-answer", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(historical), + }, + }; + + expect(selectConversationRunnerTail(runner, [copied])).toEqual([]); + }); + + it("does not expose any native row while the runner boundary is preparing", () => { + const historical = { + ...event("historical answer"), + id: "historical-answer", + chunk_id: "historical-answer", + }; + const current = { + ...event("current output"), + id: "current-output", + chunk_id: "current-output", + }; + + expect( + selectConversationRunnerTail( + { + runnerSessionId: "preparing-child", + turnId: "current-turn", + eventStartIndex: Number.MAX_SAFE_INTEGER, + }, + [historical, current] + ) + ).toEqual([]); + }); + + it("keeps a same-text assistant when its current-turn identity is explicit", () => { + const repeatedAnswer = { + ...event("same answer"), + id: "repeated-answer", + chunk_id: "repeated-answer", + result: { observation: "same answer", turnIntentId: "current-turn" }, + }; + + expect( + selectConversationRunnerTail( + { + runnerSessionId: "reused-child", + turnId: "current-turn", + eventStartIndex: 20, + }, + [repeatedAnswer] + ).map((candidate) => candidate.id) + ).toEqual(["repeated-answer"]); + }); + + it("never applies the raw provider index to an untagged chat projection", () => { + const runner = { + runnerSessionId: "fresh-empty-child", + turnId: "current-turn", + eventStartIndex: 1, + }; + const materialized = { + ...event("materialized setup"), + id: "materialized", + chunk_id: "materialized", + }; + const current = { + ...event("current output"), + id: "current", + chunk_id: "current", + }; + + expect(selectConversationRunnerTail(runner, [materialized])).toEqual([]); + expect( + selectConversationRunnerTail(runner, [materialized, current]) + ).toEqual([]); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts new file mode 100644 index 0000000000..49cd4075cf --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts @@ -0,0 +1,118 @@ +import { scopedNativeSourceEventIdOf } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; + +interface ConversationRunnerOverlay { + runnerSessionId: string; + turnId: string; + eventStartIndex: number; +} + +function runnerTurnIntentIdOf(event: SessionEvent): string | null { + const value = (event.result as { turnIntentId?: unknown } | undefined) + ?.turnIntentId; + return typeof value === "string" && value.length > 0 ? value : null; +} + +export function collectLandedTurnIds( + rows: readonly { turnId: string; event: Pick }[] +): Set { + const landed = new Set(); + for (const row of rows) { + if (row.event.source !== "user") landed.add(row.turnId); + } + return landed; +} + +export function selectConversationRunnerTail( + runner: ConversationRunnerOverlay, + events: readonly SessionEvent[] +): SessionEvent[] { + // onSessionPreparing publishes this sentinel before native + // materialization/synchronization has established a readable boundary. + // Do not attempt prefix reconciliation until onSessionReady replaces it. + if (runner.eventStartIndex === Number.MAX_SAFE_INTEGER) return []; + // `eventStartIndex` belongs to the full provider transcript, while this + // function consumes the filtered chat projection. Hidden provider rows make + // those index spaces incomparable. The CLI runner stamps every live + // projection with its already-durable turn intent at the single emit + // boundary. Prefer that exact identity: semantic text matching can erase a + // legitimate repeated answer, and numeric slicing can expose a freshly + // materialized historical prefix. + // Rust Agent persists an exact accepted user row but does not repeat the + // durable intent on each assistant/tool row. Its existing turn boundary is + // still exact: take only the contiguous non-user suffix until the next user, + // excluding any explicitly materialized native-prefix projection. + let acceptedUserIndex = -1; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.source === "user" && turnIntentIdOf(event) === runner.turnId) { + acceptedUserIndex = index; + break; + } + } + if (acceptedUserIndex >= 0) { + // Rust Agent already persists the accepted user row with the durable turn + // intent, but its assistant/tool producer does not repeat that identity + // on every row. That exact user boundary is safe: current writers mark + // replayed native-prefix rows with their canonical source identity, and a + // distinct turn intent always belongs to another turn. This preserves the + // existing SDE path without falling back to text, timestamps, or the raw + // provider index. + const following = events.slice(acceptedUserIndex + 1); + const nextUserIndex = following.findIndex( + (event) => event.source === "user" + ); + const currentTurnEnd = + nextUserIndex >= 0 + ? acceptedUserIndex + 1 + nextUserIndex + : events.length; + return events.filter((event, index) => { + if (event.source === "user") return false; + const eventTurnIntentId = runnerTurnIntentIdOf(event); + if (eventTurnIntentId === runner.turnId) return true; + return ( + eventTurnIntentId === null && + index > acceptedUserIndex && + index < currentTurnEnd && + !scopedNativeSourceEventIdOf(event) + ); + }); + } + return events.filter( + (event) => + event.source !== "user" && runnerTurnIntentIdOf(event) === runner.turnId + ); +} + +export function buildConversationRunnerOverlay( + runner: ConversationRunnerOverlay, + events: readonly SessionEvent[], + canonicalSessionId: string +): SessionEvent[] { + return selectConversationRunnerTail(runner, events).map((event) => ({ + ...event, + id: `runlive-${event.id}`, + chunk_id: `runlive-${event.id}`, + sessionId: canonicalSessionId, + })); +} + +/** Avoid replacing the overlay when only an unrelated queue atom changed. */ +export function conversationRunnerOverlaysEqual( + left: readonly SessionEvent[] | undefined, + right: readonly SessionEvent[] +): boolean { + if (!left || left.length !== right.length) return false; + return left.every((event, index) => { + const candidate = right[index]; + return ( + event.id === candidate?.id && + event.displayStatus === candidate.displayStatus && + event.displayText === candidate.displayText && + event.isDelta === candidate.isDelta && + event.args === candidate.args && + event.result === candidate.result + ); + }); +} diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx b/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx deleted file mode 100644 index a68844a0b6..0000000000 --- a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The live runner scope for a mounted conversation surface. - * - * A member's turn runs in an invisible one-shot local runner, so the mounted - * imported session stays idle — its planning indicator and streaming-delta - * footer never light up, and a long turn looks frozen (no "Thinking…", no - * activity) until the tail lands. The conversation stream publishes the - * in-flight runner's sessionId here; the chat footer reads it and scopes its - * running/typing indicator to the runner instead of the idle mounted session. - * - * `null` when no member turn from this device is in flight (owner sessions, - * ordinary sessions, or between turns) — the footer falls back to the mounted - * session exactly as before. - */ -import { createContext, useContext } from "react"; - -const ConversationRunnerScopeContext = createContext(null); - -export const ConversationRunnerScopeProvider = - ConversationRunnerScopeContext.Provider; - -export function useConversationRunnerScope(): string | null { - return useContext(ConversationRunnerScopeContext); -} diff --git a/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts index 01cb92e4a5..6efbdb7ce5 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { + NATIVE_SOURCE_EVENT_ID_ARG, + projectNativeConversationItems, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; -import { CONVERSATION_SENDER_ARG } from "./continuationEvents"; import { conversationEventKey, mergePlaneIntoTranscript, @@ -59,6 +63,9 @@ function row( } describe("conversationEventKey", () => { + const encoded = (value: string) => + btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + it("keys user rows on the turn intent so synthetic, backend and plane rows collapse", () => { const synthetic = userEvent({ id: "user-input-1", @@ -86,9 +93,64 @@ describe("conversationEventKey", () => { expect(conversationEventKey(copy)).toBe("event:evt-9"); expect(conversationEventKey(event({ id: "evt-9" }))).toBe("event:evt-9"); }); + + it("recovers canonical identity from a native Agent materialization row", () => { + const user = userEvent({ + id: `imported-session-x~user-message-org2-turn-v1.${encoded("turn-9")}.${encoded("source-user-9")}.nonce`, + sessionId: "imported-session-x", + result: { message: { role: "user", content: "continue" } }, + }); + const assistant = event({ + id: `imported-session-x~org2-native-v1.${encoded("source-answer-9")}.nonce`, + sessionId: "imported-session-x", + }); + expect(conversationEventKey(user)).toBe("intent:turn-9"); + expect(conversationEventKey(assistant)).toBe("event:source-answer-9"); + }); + + it("recovers materialized turn identity through stacked import namespaces", () => { + const user = userEvent({ + id: `fork-copy~import-copy~user-message-org2-turn-v1.${encoded("turn-stacked")}.${encoded("source-stacked")}.nonce`, + sessionId: "fork-copy", + result: { message: { role: "user", content: "continue again" } }, + }); + expect(conversationEventKey(user)).toBe("intent:turn-stacked"); + }); }); describe("mergePlaneIntoTranscript", () => { + it("retains the local failed delivery owner over a published copy of the same intent", () => { + const published = userEvent({ + id: "published-user", + result: { turnIntentId: "failed-turn" }, + }); + const failed = userEvent({ + id: "queued-user:failed-turn:", + displayStatus: "failed", + result: { + syntheticUserInput: true, + turnIntentId: "failed-turn", + queueMessageId: "failed-turn", + deliveryStatus: "failed", + deliveryOwnerRetired: true, + }, + }); + + for (const base of [ + [published, failed], + [failed, published], + ]) { + const merged = mergePlaneIntoTranscript( + base, + [row(1, published, { turnId: "failed-turn" })], + "owner-session", + { status: "known", userId: "owner" } + ); + + expect(merged).toEqual([failed]); + } + }); + const ownerUser = userEvent({ id: "user-input-1", createdAt: "2026-08-21T10:00:00Z", @@ -118,12 +180,10 @@ describe("mergePlaneIntoTranscript", () => { row(1, { ...ownerUser, sessionId: "conversation" }), row(2, ownerReply), ]; - const merged = mergePlaneIntoTranscript( - base, - rows, - "owner-session", - "owner" - ); + const merged = mergePlaneIntoTranscript(base, rows, "owner-session", { + status: "known", + userId: "owner", + }); expect(merged).toHaveLength(2); expect(merged[0]).toBe(ownerUser); expect(merged[1]).toBe(ownerReply); @@ -140,7 +200,7 @@ describe("mergePlaneIntoTranscript", () => { [copyUser], rows, "imported-session-x", - "member" + { status: "known", userId: "member" } ); expect(asMember[0].id).toBe(copyUser.id); expect(asMember[0].args[CONVERSATION_SENDER_ARG]).toEqual({ @@ -151,11 +211,66 @@ describe("mergePlaneIntoTranscript", () => { [ownerUser], rows, "owner-session", - "owner" + { status: "known", userId: "owner" } ); expect(asOwner[0]).toBe(ownerUser); }); + it("does not stamp a local self twin while viewer auth is loading", () => { + const rows = [row(1, { ...ownerUser, sessionId: "conversation" })]; + + const loading = mergePlaneIntoTranscript( + [ownerUser], + rows, + "owner-session", + { status: "loading" } + ); + const hydrated = mergePlaneIntoTranscript( + [ownerUser], + rows, + "owner-session", + { status: "known", userId: "owner" } + ); + + expect(loading[0]).toBe(ownerUser); + expect(hydrated[0]).toBe(ownerUser); + expect(loading[0].args[CONVERSATION_SENDER_ARG]).toBeUndefined(); + }); + + it("preserves an existing remote stamp while viewer auth is loading", () => { + const remoteTwin = userEvent({ + id: "imported-session-x~user-input-1", + sessionId: "imported-session-x", + result: { syntheticUserInput: true, turnIntentId: "tii-1" }, + args: { + [CONVERSATION_SENDER_ARG]: { userId: "owner" }, + }, + }); + const rows = [row(1, { ...ownerUser, sessionId: "conversation" })]; + + const loading = mergePlaneIntoTranscript( + [remoteTwin], + rows, + "imported-session-x", + { status: "loading" } + ); + const hydrated = mergePlaneIntoTranscript( + [remoteTwin], + rows, + "imported-session-x", + { status: "known", userId: "member" } + ); + + expect(loading[0]).toBe(remoteTwin); + expect(loading[0].args[CONVERSATION_SENDER_ARG]).toEqual({ + userId: "owner", + }); + expect(hydrated[0].args[CONVERSATION_SENDER_ARG]).toEqual({ + userId: "owner", + displayName: "Owner", + }); + }); + it("orders plane-backed turns by seq even when a sender clock is skewed", () => { const skewedMemberUser = { ...memberUser, @@ -168,12 +283,10 @@ describe("mergePlaneIntoTranscript", () => { row(3, skewedMemberUser, { authorUserId: "member", turnId: "t-m" }), row(4, memberReply, { authorUserId: "member", turnId: "t-m" }), ]; - const merged = mergePlaneIntoTranscript( - base, - rows, - "owner-session", - "owner" - ); + const merged = mergePlaneIntoTranscript(base, rows, "owner-session", { + status: "known", + userId: "owner", + }); expect(merged.map((item) => item.displayText)).toEqual([ "hello", "owner reply", @@ -201,12 +314,10 @@ describe("mergePlaneIntoTranscript", () => { row(3, memberUser, { authorUserId: "member" }), row(4, memberReply, { authorUserId: "member" }), ]; - const merged = mergePlaneIntoTranscript( - base, - rows, - "owner-session", - "owner" - ); + const merged = mergePlaneIntoTranscript(base, rows, "owner-session", { + status: "known", + userId: "owner", + }); expect(merged.map((item) => item.displayText)).toEqual([ "legacy", "hello", @@ -217,6 +328,137 @@ describe("mergePlaneIntoTranscript", () => { ]); }); + it("matches a positional native/import echo to its plane event semantically", () => { + const canonical = event({ + id: "member-answer", + displayText: "same source event", + }); + const nativeEcho = event({ + // Codex exposes positional ids after parsing a materialized transcript, + // so this intentionally cannot match the plane row by event id. + id: "imported-session-x~codex-asst-10", + sessionId: "imported-session-x", + displayText: "same source event", + }); + + const merged = mergePlaneIntoTranscript( + [nativeEcho], + [row(1, canonical, { authorUserId: "member" })], + "imported-session-x", + { status: "known", userId: "viewer" } + ); + + expect(merged).toHaveLength(1); + expect(merged[0]).toBe(nativeEcho); + }); + + it("matches repeated equal native messages one-to-one instead of collapsing the conversation", () => { + const first = event({ id: "native-a", displayText: "OK" }); + const second = event({ id: "native-b", displayText: "OK" }); + const planeFirst = event({ id: "plane-a", displayText: "OK" }); + const planeSecond = event({ id: "plane-b", displayText: "OK" }); + + const merged = mergePlaneIntoTranscript( + [first, second], + [row(1, planeFirst), row(2, planeSecond)], + "owner-session", + { status: "known", userId: "owner" } + ); + + expect(merged).toEqual([first, second]); + }); + + it("collapses a plane row that republishes an existing source identity", () => { + const first = event({ id: "member-answer", displayText: "answer" }); + const republished = event({ + id: "org2-native-v1.bWVtYmVyLWFuc3dlcg.nonce", + displayText: "answer", + }); + + const merged = mergePlaneIntoTranscript( + [], + [row(1, first), row(2, republished)], + "owner-session", + { status: "known", userId: "owner" } + ); + + expect(merged).toHaveLength(1); + expect(merged[0].displayText).toBe("answer"); + }); + + it("collapses a global native source copy even when user turn intents differ", () => { + const sourceId = "orgii_evt_0c2481a309205d2abd70fd14234cf0f5"; + const nativeUser = userEvent({ + id: "codex-user-97", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + result: { + turnIntentId: "native-intent", + message: { role: "user", content: "retry me" }, + }, + displayText: "retry me", + }); + const planeUser = userEvent({ + id: "claude-user-14", + sessionId: "conversation", + args: { [NATIVE_SOURCE_EVENT_ID_ARG]: sourceId }, + result: { + turnIntentId: "plane-intent", + message: { role: "user", content: "retry me" }, + }, + displayText: "retry me", + }); + const sameTextDifferentSource = userEvent({ + id: "claude-user-15", + sessionId: "conversation", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: + "orgii_evt_11111111111111111111111111111111", + }, + result: { + turnIntentId: "distinct-intent", + message: { role: "user", content: "retry me" }, + }, + displayText: "retry me", + }); + const failed = userEvent({ + id: "failed-user", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: + "orgii_evt_22222222222222222222222222222222", + }, + result: { + turnIntentId: "failed-intent", + deliveryStatus: "failed", + message: { role: "user", content: "failed retry" }, + }, + displayStatus: "failed", + displayText: "failed retry", + }); + + const merged = mergePlaneIntoTranscript( + [nativeUser, failed], + [ + row(1, planeUser, { turnId: "plane-intent" }), + row(2, sameTextDifferentSource, { turnId: "distinct-intent" }), + ], + "owner-session", + { status: "known", userId: "owner" } + ); + + expect( + merged.filter( + (item) => item.args[NATIVE_SOURCE_EVENT_ID_ARG] === sourceId + ) + ).toEqual([nativeUser]); + expect( + merged.filter((item) => item.displayText === "retry me") + ).toHaveLength(2); + expect(merged).toContain(failed); + expect( + projectNativeConversationItems(merged).map((item) => item.id) + ).toEqual([sourceId, "orgii_evt_11111111111111111111111111111111"]); + }); + it("returns the base untouched without plane rows", () => { const base = [ownerUser, ownerReply]; expect(mergePlaneIntoTranscript(base, [], "owner-session")).toEqual(base); diff --git a/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts b/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts index bf4df73798..38cd3ebc4c 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts @@ -9,12 +9,21 @@ * imported replay copy of it) keeps its local identity and takes the plane's * position; local events that predate the plane keep the timestamp merge. */ +import { + CONVERSATION_SENDER_ARG, + CONVERSATION_VIEWER_LOADING, + type ConversationSenderStamp, + type ConversationViewerState, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { CONVERSATION_TURN_ID_ARG } from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { nativeConversationEventSemanticKey } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isSyntheticUserInputEvent } from "@src/engines/SessionCore/sync/utils/activityIds"; import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; import { - CONVERSATION_SENDER_ARG, - type ConversationSenderStamp, + collapseConversationSourceCopies, + materializedConversationTurnIdOf, sourceEventIdOf, } from "./continuationEvents"; import { buildConversationPlaneStreamEvents } from "./conversationPlaneEvents"; @@ -32,6 +41,8 @@ export function conversationEventKey(event: SessionEvent): string { if (typeof intent === "string" && intent.length > 0) { return `intent:${intent}`; } + const materializedIntent = materializedConversationTurnIdOf(event); + if (materializedIntent) return `intent:${materializedIntent}`; } return `event:${sourceEventIdOf(event)}`; } @@ -41,17 +52,25 @@ function timestampMs(value: string | undefined): number { return Number.isFinite(ms) ? ms : 0; } -function stampSender( +function stampPlaneMetadata( event: SessionEvent, - row: CloudConversationEvent + row: CloudConversationEvent, + includeSender: boolean ): SessionEvent { const stamp: ConversationSenderStamp = { userId: row.authorUserId, - displayName: row.authorDisplayName?.trim() || row.authorUserId, + ...(row.authorDisplayName?.trim() + ? { displayName: row.authorDisplayName.trim() } + : {}), + ...(row.authorAvatarUrl ? { avatarUrl: row.authorAvatarUrl } : {}), }; return { ...event, - args: { ...event.args, [CONVERSATION_SENDER_ARG]: stamp }, + args: { + ...event.args, + ...(includeSender ? { [CONVERSATION_SENDER_ARG]: stamp } : {}), + [CONVERSATION_TURN_ID_ARG]: row.turnId, + }, }; } @@ -70,26 +89,77 @@ export function mergePlaneIntoTranscript( base: readonly SessionEvent[], rows: readonly CloudConversationEvent[], streamSessionId: string, - viewerUserId?: string | null + viewer: ConversationViewerState = CONVERSATION_VIEWER_LOADING ): SessionEvent[] { if (rows.length === 0) return [...base]; - const twins = new Map(); + // A provider-native owner can fold a plane turn into its own transcript and + // later publish that Session replay. Imports then contain both the original + // plane identity and a namespaced native echo of it. Collapse those copies + // before matching plane rows; repeated equal text with distinct source ids + // remains distinct. + const uniqueBase: SessionEvent[] = []; + const baseIndexByKey = new Map(); for (const event of base) { + const key = conversationEventKey(event); + const existingIndex = baseIndexByKey.get(key); + if (existingIndex !== undefined) { + // Publishing a user row does not prove provider execution succeeded. + // The sender's durable failed projection owns Retry/Edit, even when a + // replay of the published prompt appears earlier in the family. Keep + // that projection at the same position instead of hiding its failure + // behind a completed copy. Distinct intents remain independent. + if ( + isSyntheticUserInputEvent(event) && + event.result?.deliveryStatus === "failed" + ) { + uniqueBase[existingIndex] = event; + } + continue; + } + baseIndexByKey.set(key, uniqueBase.length); + uniqueBase.push(event); + } + const twins = new Map(); + const semanticTwins = new Map(); + for (const event of uniqueBase) { const key = conversationEventKey(event); if (!twins.has(key)) twins.set(key, event); + const semanticKey = nativeConversationEventSemanticKey(event); + if (semanticKey) { + const candidates = semanticTwins.get(semanticKey) ?? []; + candidates.push(event); + semanticTwins.set(semanticKey, candidates); + } } const planeStream = buildConversationPlaneStreamEvents(rows, streamSessionId); const claimed = new Set(); const planeItems: { event: SessionEvent; ms: number }[] = []; + const seenPlaneKeys = new Set(); let floorMs = 0; rows.forEach((row, index) => { - const twin = twins.get(conversationEventKey(row.event)); + const key = conversationEventKey(row.event); + // The plane is idempotent per wire row, while an older client may still + // have republished a materialized echo under a new row id. Source identity + // is the canonical idempotency boundary for rendering and rematerializing. + if (seenPlaneKeys.has(key)) return; + seenPlaneKeys.add(key); + let twin = twins.get(key); + if (!twin || claimed.has(twin)) { + const semanticKey = nativeConversationEventSemanticKey(row.event); + twin = semanticKey + ? semanticTwins + .get(semanticKey) + ?.find((candidate) => !claimed.has(candidate)) + : undefined; + } let event: SessionEvent; if (twin && !claimed.has(twin)) { claimed.add(twin); event = - row.event.source === "user" && row.authorUserId !== viewerUserId - ? stampSender(twin, row) + row.event.source === "user" && + viewer.status !== "loading" && + (viewer.status === "signed_out" || row.authorUserId !== viewer.userId) + ? stampPlaneMetadata(twin, row, true) : twin; } else { event = planeStream[index]; @@ -99,7 +169,7 @@ export function mergePlaneIntoTranscript( }); const merged: SessionEvent[] = []; let cursor = 0; - for (const event of base) { + for (const event of uniqueBase) { if (claimed.has(event)) continue; const eventMs = timestampMs(event.createdAt); while (cursor < planeItems.length && planeItems[cursor].ms < eventMs) { @@ -112,5 +182,11 @@ export function mergePlaneIntoTranscript( merged.push(planeItems[cursor].event); cursor += 1; } - return merged; + // User rows normally match by turn intent so optimistic/backend/plane + // lifecycle copies retain one visible bubble. A native replay can preserve + // the same globally scoped source event under a different turn intent, + // though; in that case intent matching alone admits the same canonical item + // twice. The source boundary is the final idempotency owner. It compares no + // content, so genuinely repeated messages with distinct source ids survive. + return collapseConversationSourceCopies(merged); } diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts new file mode 100644 index 0000000000..259e6b50b9 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import { + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { Org2CloudConversationError } from "@src/features/Org2Cloud/org2CloudConversationEventsClient"; + +import { + buildPushedUserEvent, + runConversationTurn, +} from "./conversationTurnRunner"; + +const mocks = vi.hoisted(() => ({ + continueLocalConversation: vi.fn(), +})); + +vi.mock( + "@src/engines/SessionCore/conversations/localConversationContinuation", + async (importOriginal) => ({ + ...(await importOriginal()), + continueLocalConversation: mocks.continueLocalConversation, + }) +); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.continueLocalConversation.mockImplementation(async (params) => { + params.onSessionReady?.("cliagent-owner", 3); + return { + sessionId: "cliagent-owner", + terminalStatus: "completed", + agentTail: [], + }; + }); +}); + +describe("buildPushedUserEvent", () => { + it("keeps visible text separate from the exact agent-facing native content", () => { + const event = buildPushedUserEvent( + "Use my review skill", + "review instructions\nUse my review skill", + ["data:image/png;base64,AAAA"], + "2026-08-26T00:00:00.000Z", + "turn-1" + ); + + expect(event.displayText).toBe("Use my review skill"); + expect(projectNativeConversationItems([event])).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "review instructions\nUse my review skill", + images: ["data:image/png;base64,AAAA"], + }), + ]); + }); +}); + +describe("runConversationTurn", () => { + it("binds a fresh hidden runner during preparation, then exposes its exact native prefix", async () => { + const onRunnerReady = vi.fn(); + const publishTail = vi.fn(); + mocks.continueLocalConversation.mockImplementationOnce(async (params) => { + await params.onSessionPreparing?.("cliagent-fresh"); + await params.onSessionReady?.("cliagent-fresh", 7); + return { + sessionId: "cliagent-fresh", + terminalStatus: "completed", + agentTail: [], + }; + }); + + const result = await runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-fresh", + queueMessageId: "queue-fresh", + onRunnerReady, + publishTail, + }); + + expect(mocks.continueLocalConversation).toHaveBeenCalledWith( + expect.objectContaining({ queueMessageId: "queue-fresh" }) + ); + expect(onRunnerReady.mock.calls).toEqual([ + ["cliagent-fresh", "turn-fresh", Number.MAX_SAFE_INTEGER], + ["cliagent-fresh", "turn-fresh", 7], + ]); + expect(result).toEqual( + expect.objectContaining({ + terminalStatus: "completed", + }) + ); + }); + + it("reuses an owner's local native root while publishing to the shared plane", async () => { + const executionRoot = { + authority: "local-session", + authorityScope: [], + conversationId: "cliagent-owner", + } as const; + + await runConversationTurn({ + root: executionRoot, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-owner", + publishTail: vi.fn(), + }); + + expect(mocks.continueLocalConversation).toHaveBeenCalledWith( + expect.objectContaining({ root: executionRoot }) + ); + }); + + it("scopes provider-positional tail ids to the durable turn before publishing", async () => { + // A native rollout restarts positional ids, so `codex-asst-182` from this + // turn must never be shadowed by an earlier turn's `codex-asst-182` that + // already lives on the Cloud plane. + const publishTail = vi.fn().mockResolvedValue(undefined); + const positional = { + id: "codex-asst-182", + chunk_id: "codex-asst-182", + sessionId: "cliagent-reused", + createdAt: "2026-09-07T00:00:00.000Z", + functionName: "assistant", + uiCanonical: "agent_message", + actionType: "assistant", + args: {}, + result: { content: "I'll inspect the requested files first." }, + source: "assistant", + displayText: "I'll inspect the requested files first.", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as unknown as SessionEvent; + mocks.continueLocalConversation.mockResolvedValueOnce({ + sessionId: "cliagent-reused", + terminalStatus: "completed", + agentTail: [positional], + }); + + await runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-scoped", + publishTail, + }); + + const [, published] = publishTail.mock.calls[0] ?? []; + expect(published).toEqual([ + expect.objectContaining({ + id: "convturn-turn-scoped-codex-asst-182", + chunk_id: "convturn-turn-scoped-codex-asst-182", + displayText: "I'll inspect the requested files first.", + }), + ]); + expect(projectNativeConversationItems(published)).toEqual([ + expect.objectContaining({ kind: "message", role: "assistant" }), + ]); + }); + + it("publishes a non-portable transcript error when execution fails after the user row", async () => { + const failure = new Error("native materialization failed"); + const publishTail = vi.fn().mockResolvedValue(undefined); + mocks.continueLocalConversation.mockImplementationOnce(async () => { + throw failure; + }); + + await expect( + runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-failed", + publishTail, + }) + ).rejects.toBeInstanceOf(QueuedConversationTurnClosedError); + + expect(publishTail).toHaveBeenCalledOnce(); + const [failureTurnId, failureEvents] = publishTail.mock.calls[0] ?? []; + expect(failureTurnId).toBe("turn-failed"); + expect(failureEvents).toEqual([ + expect.objectContaining({ + id: "convturn-error-turn-failed", + source: "system", + actionType: "error", + displayVariant: "error", + displayStatus: "failed", + result: expect.objectContaining({ + error: "native materialization failed", + turnIntentId: "turn-failed", + }), + }), + ]); + expect(projectNativeConversationItems(failureEvents)).toEqual([]); + }); + + it("retains recovery ownership when a pre-accept failure cannot publish", async () => { + mocks.continueLocalConversation.mockRejectedValueOnce( + new Error("native materialization failed") + ); + + await expect( + runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-publish-retry", + publishTail: vi + .fn() + .mockRejectedValue( + new Org2CloudConversationError("temporary upstream failure", 503) + ), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); + }); + + it("closes a failed turn after one definitive 4xx terminal-publication rejection", async () => { + mocks.continueLocalConversation.mockRejectedValueOnce( + new Error("native materialization failed") + ); + const publishTail = vi + .fn() + .mockRejectedValue(new Org2CloudConversationError("ORG2_FORBIDDEN", 403)); + + await expect( + runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-terminal-4xx", + publishTail, + }) + ).rejects.toBeInstanceOf(QueuedConversationTurnClosedError); + + expect(publishTail).toHaveBeenCalledOnce(); + expect(mocks.continueLocalConversation).toHaveBeenCalledOnce(); + }); + + it("keeps a transient local continuation failure retryable without publishing a terminal", async () => { + const publishTail = vi.fn(); + const pending = new QueuedConversationRecoveryPendingError( + "native transcript is still settling" + ); + mocks.continueLocalConversation.mockRejectedValueOnce(pending); + + await expect( + runConversationTurn({ + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-transient", + publishTail, + }) + ).rejects.toBe(pending); + + expect(publishTail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index 02f341a319..f4fa7eb2ea 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -1,193 +1,74 @@ /** - * Conversation turn runner — the write half of the 0024 conversation-events - * plane (design: docs/conversation-events-plane-design-2026-08-21.md). + * Cloud-plane adapter for the provider-neutral local continuation core. * - * When a member chats in a conversation they do not own, the turn executes - * in a LOCAL, invisible one-shot runner session on their machine - * (sender-runs / sender-pays) and the resulting events are pushed — - * author-stamped — to the shared plane. No fork, no transcript copy, no new - * sidebar entity. - * - * ONE-SHOT per turn: `SessionService.create` is the only dispatch primitive - * proven headless (Routine/work-item background runs ride it), so every - * turn gets a fresh runner with the full bounded conversation context - * injected (the external-history handoff pattern) — never a dispatch into - * an unmounted surface. Runner sessions are plumbing: the caller forces - * their cloud sync OFF, and `collectConversationRunnerSessionIds` hides - * them from My Sessions. - * - * Push order is Slack-shaped: the user's message row goes out FIRST (every - * client sees it instantly), the agent tail follows under the same turnId - * when the local run completes. + * Cloud stores and orders canonical events; it never executes an Agent and + * never receives a local credential. The current local app selects one of its + * own runtimes, continues a normal persisted child Session, and publishes only + * that turn's normalized tail back to the shared plane. */ -import Message from "@src/components/Message"; +import type { TurnTerminalStatus } from "@src/engines/SessionCore/control/turnLifecycle"; import { - getLastTurnTerminal, - turnLifecycleSignalAtom, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; -import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; -import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; + CONVERSATION_TURN_ID_ARG, + type ConversationRootLocator, + type LocalConversationTarget, + continueLocalConversation, + recoverLocalConversationTurn, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; import { - clearForkSetupMemory, - loadForkSetupMemory, - saveForkSetupMemory, -} from "@src/features/TeamCollaboration/forkSetupMemory"; + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; -import i18n from "@src/i18n"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; -import { - boundConversationEventForPush, - pushConversationEvents, - pushConversationEventsChunked, -} from "../org2CloudConversationEventsClient"; +import { conversationEventsForPush } from "../org2CloudConversationEventsClient"; +import { isRetryableCloudRequestError } from "../org2CloudFetchRetry"; const log = createLogger("ConversationTurnRunner"); -const RUNNER_REGISTRY_KEY = "orgii:conversation-runners-v1"; -const TURN_DEADLINE_MS = 15 * 60_000; -const CONTEXT_MAX_ENTRIES = 60; -const CONTEXT_MAX_ENTRY_CHARS = 600; -const CONTEXT_MAX_TOTAL_CHARS = 18_000; - -interface RunnerRegistryEntry { - /** Every one-shot runner this device created for the conversation. */ - runnerSessionIds: string[]; - updatedAt: string; -} - -type RunnerRegistry = Record; - -function registryKey(orgId: string, rootSessionId: string): string { - return `${orgId}:${rootSessionId}`; -} - -function readRegistry(): RunnerRegistry { - if (typeof localStorage === "undefined") return {}; - try { - const raw = localStorage.getItem(RUNNER_REGISTRY_KEY); - return raw ? (JSON.parse(raw) as RunnerRegistry) : {}; - } catch { - return {}; - } -} - -function writeRegistry(registry: RunnerRegistry): void { - if (typeof localStorage === "undefined") return; - try { - localStorage.setItem(RUNNER_REGISTRY_KEY, JSON.stringify(registry)); - } catch { - // Best-effort: losing the registry only means runners stop being hidden. - } -} - -/** Every runner session id on this device — the My Sessions hide filter. */ -export function collectConversationRunnerSessionIds(): Set { - const ids = new Set(); - for (const entry of Object.values(readRegistry())) { - for (const id of entry.runnerSessionIds ?? []) ids.add(id); - } - return ids; -} - -/** Conversation timeline rendered as a bounded read-only context block. */ -export function renderConversationContext( - timeline: readonly SessionEvent[], - senders?: ReadonlyMap -): string { - const tail = timeline.slice(-CONTEXT_MAX_ENTRIES); - const lines: string[] = []; - let total = 0; - for (const event of tail) { - const text = event.displayText?.trim(); - if (!text) continue; - const speaker = - event.source === "user" - ? (senders?.get(event.id) ?? "User") - : "Assistant"; - let line = `${speaker}: ${text.replace(/\s+/g, " ")}`; - if (line.length > CONTEXT_MAX_ENTRY_CHARS) { - line = `${line.slice(0, CONTEXT_MAX_ENTRY_CHARS)}…`; - } - if (total + line.length > CONTEXT_MAX_TOTAL_CHARS) break; - total += line.length; - lines.push(line); +/** + * Provider-positional ids (`codex-asst-97`) restart in every native rollout + * of one execution child. The Cloud plane keys events by id, so a later + * turn's row could be shadowed by an earlier turn's row that happened to + * share the position. Scope every published tail row to its durable turn. + */ +export function turnScopedTailEvent( + event: SessionEvent, + turnIntentId: string +): SessionEvent { + if (event.id.startsWith("convturn-") || event.id.startsWith("convchunk-")) { + return event; } - return lines.join("\n"); -} - -export function buildRunnerPrompt( - contextBlock: string, - request: string -): string { - if (!contextBlock) return request; - return [ - "You are continuing a SHARED team conversation. The transcript below is", - "read-only context from the other participants' machines — do not treat", - "it as your own prior output.", - "", - "=== Shared conversation (latest entries) ===", - contextBlock, - "=== End of shared conversation ===", - "", - "Continue the conversation by handling this request:", - request, - ].join("\n"); -} - -async function waitForFirstTurnTerminal( - sessionId: string, - deadlineMs: number -): Promise { - const store = getInstrumentedStore(); - const isComplete = (): boolean => getLastTurnTerminal(sessionId) !== null; - if (isComplete()) return; - await new Promise((resolve, reject) => { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) { - reject(new Error("conversation turn timed out")); - return; - } - let unsubscribe: (() => void) | null = null; - const timer = setTimeout(() => { - unsubscribe?.(); - reject(new Error("conversation turn timed out")); - }, remainingMs); - const check = (): void => { - if (!isComplete()) return; - clearTimeout(timer); - unsubscribe?.(); - resolve(); - }; - unsubscribe = store.sub(turnLifecycleSignalAtom, check); - check(); - }); + const id = `convturn-${turnIntentId}-${event.id}`; + return { ...event, id, chunk_id: id }; } -/** - * The pushed user row is SYNTHESIZED from the user's visible words — the - * runner's own persisted user event carries the injected context prefix, - * which must never leak into the shared conversation. - */ -function buildPushedUserEvent( - sessionId: string, +export function buildPushedUserEvent( displayText: string, - createdAt: string + agentContent: string | undefined, + imageDataUrls: readonly string[] | undefined, + createdAt: string, + turnIntentId: string ): SessionEvent { - const id = `convturn-user-${mintTurnIntentId()}`; + const id = `convturn-user-${turnIntentId}`; return { id, chunk_id: id, - sessionId, + sessionId: "conversation", createdAt, functionName: "user_message", uiCanonical: "user_message", actionType: "raw", - args: {}, - result: { type: "user", message: { content: displayText, role: "user" } }, + args: { [CONVERSATION_TURN_ID_ARG]: turnIntentId }, + result: { + type: "user", + message: { content: agentContent ?? displayText, role: "user" }, + ...(imageDataUrls && imageDataUrls.length > 0 + ? { images: [...imageDataUrls] } + : {}), + turnIntentId, + }, source: "user", displayText, displayStatus: "completed", @@ -197,154 +78,225 @@ function buildPushedUserEvent( } as SessionEvent; } -export interface RunConversationTurnParams { - /** - * Resolved before EVERY push. A turn can outlive the access token that - * was valid at dispatch (a 10-minute tool-heavy turn did, live), so the - * tail push must never reuse a token captured at the start. - */ - getAccessToken: () => Promise; - orgId: string; - rootSessionId: string; +function buildPushedDispatchFailureEvent( + error: unknown, + createdAt: string, + turnIntentId: string +): SessionEvent { + const id = `convturn-error-${turnIntentId}`; + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "Agent request failed"; + return { + id, + chunk_id: id, + sessionId: "conversation", + createdAt, + functionName: "error", + uiCanonical: "error", + actionType: "error", + args: { [CONVERSATION_TURN_ID_ARG]: turnIntentId }, + result: { error: message, success: false, turnIntentId }, + // A system error renders through the existing AgentErrorChatItem but is + // deliberately absent from provider-native role/tool materialization. + source: "system", + displayText: message, + displayStatus: "failed", + displayVariant: "error", + activityStatus: "processed", + payloadRefs: [], + } as SessionEvent; +} + +interface RunConversationTurnParams { + root: ConversationRootLocator; conversationTitle: string; displayText: string; agentContent?: string; imageDataUrls?: string[]; - /** Merged conversation timeline for the read-only context prefix. */ + /** Canonical merged transcript immediately before this turn. */ timeline: readonly SessionEvent[]; - sourceScopeKey?: string; - sourceModel?: string; - /** - * Called as soon as the one-shot runner session id is known, with the - * turnId the tail will be pushed under. The caller overlays the runner's - * LIVE events until the plane carries this turnId. - */ - onRunnerReady?: (runnerSessionId: string, turnId: string) => void; - /** - * Fires after push #1 (the user's message row) lands on the plane — the - * composer unblocks here; the agent tail streams in later under the same - * turnId. - */ - onUserMessagePublished?: () => void; - /** Fires after each successful push (signal-bump hook). */ - onPushed?: () => void; + /** Composer-selected local runtime/account/model. Never resolved by a modal. */ + target: LocalConversationTarget; + turnIntentId: string; + queueMessageId?: string; + recovery?: { + runnerSessionId: string; + eventStartIndex?: number; + providerAccepted: boolean; + }; + onRunnerReady?: ( + sessionId: string, + turnId: string, + eventStartIndex: number + ) => void | Promise; + /** Cloud lease acceptance, immediately before provider dispatch. */ + onBeforeTurnDispatch?: (sessionId: string) => void | Promise; + /** Local provider accepted the turn; distinct from Cloud user publication. */ + onTurnAccepted?: (sessionId: string) => void | Promise; + /** Idempotently publish the normalized provider tail to the Cloud plane. */ + publishTail: (turnId: string, events: SessionEvent[]) => Promise; } -export interface RunConversationTurnResult { +interface RunConversationTurnResult { runnerSessionId: string; - pushedEventCount: number; + terminalStatus: TurnTerminalStatus; +} + +interface CloseConversationTurnWithFailureParams { + rootLabel: string; + error: unknown; + turnIntentId: string; + publishTail: (turnId: string, events: SessionEvent[]) => Promise; +} + +/** + * The one Cloud terminal-failure boundary used before and during provider + * execution. The event id is stable per turn, so retrying an ambiguous + * publication cannot create a second visible error row. + */ +export async function closeConversationTurnWithFailure( + params: CloseConversationTurnWithFailureParams +): Promise { + try { + const failureEvents = await conversationEventsForPush( + buildPushedDispatchFailureEvent( + params.error, + new Date().toISOString(), + params.turnIntentId + ) + ); + await params.publishTail(params.turnIntentId, failureEvents); + } catch (publishError) { + log.warn( + `failed to publish execution error for ${params.rootLabel}`, + publishError + ); + if ( + publishError instanceof QueuedConversationRecoveryPendingError || + isRetryableCloudRequestError(publishError) + ) { + // The canonical user event already exists, so removing this execution + // owner on an ambiguous publication would strand a pending turn. The + // same idempotent terminal event is retried without running a provider. + throw new QueuedConversationRecoveryPendingError( + "conversation failure result could not be published yet" + ); + } + // A definitive 4xx/validation rejection cannot become successful by + // retaining a forever-retrying owner. The failed publication was recorded + // in the local log and this exact durable turn is now terminal. + } + throw new QueuedConversationTurnClosedError( + params.error instanceof Error ? params.error.message : String(params.error) + ); } export async function runConversationTurn( params: RunConversationTurnParams ): Promise { - const key = registryKey(params.orgId, params.rootSessionId); - const contextBlock = renderConversationContext(params.timeline); - const request = params.agentContent ?? params.displayText; - const deadlineMs = Date.now() + TURN_DEADLINE_MS; - const dispatchIso = new Date().toISOString(); - const turnId = crypto.randomUUID(); - - // The execution setup must exist BEFORE the user's words go public — a - // cancelled setup dialog cancels the whole send. Per-repo-scope memory - // keeps this silent after the first confirmation (the forkTeammateSession - // idiom): dialog once, remember, reuse with a toast; a failed remembered - // launch clears the memory and re-prompts exactly once below. - const remembered = loadForkSetupMemory(params.sourceScopeKey); - let usedRememberedSetup = Boolean(remembered); - let setup = - remembered ?? - (await requestForkSessionSetup({ - sourceTitle: params.conversationTitle, - sourceScopeKey: params.sourceScopeKey, - sourceModel: params.sourceModel, - })); - if (!remembered) saveForkSetupMemory(params.sourceScopeKey, setup); - - await pushConversationEvents(await params.getAccessToken(), { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId, - events: [ - boundConversationEventForPush( - buildPushedUserEvent("conversation", params.displayText, dispatchIso) - ), - ], - }); - params.onPushed?.(); - params.onUserMessagePublished?.(); + const turnIntentId = params.turnIntentId; + const root = params.root; + const rootLabel = `${root.authority}:${root.conversationId}`; + log.info( + `resolved execution for ${rootLabel}; ` + + `selected=${params.target.cliAgentType ?? "native"}` + ); - const createRunner = () => - SessionService.create({ - task: buildRunnerPrompt(contextBlock, request), - imageDataUrls: params.imageDataUrls, - name: params.conversationTitle, - repoPath: setup.workspaceRepoPath ?? undefined, - model: setup.execution.model, - accountId: setup.execution.accountId, - keySource: "own_key", - agentDefinitionId: setup.execution.agentDefinitionId, - mode: "build", - }); - let created; + let result: Awaited>; + let providerAccepted = params.recovery?.providerAccepted === true; try { - created = await createRunner(); + const continuationParams = { + root, + title: params.conversationTitle, + timeline: params.timeline, + displayText: params.displayText, + agentContent: params.agentContent, + imageDataUrls: params.imageDataUrls, + target: params.target, + turnIntentId, + queueMessageId: params.queueMessageId, + // Bind the root surface to the hidden execution immediately. The + // maximum prefix suppresses history overlay until materialization + // reports the exact native boundary through onSessionReady below. + onSessionPreparing: (sessionId: string) => + params.onRunnerReady?.( + sessionId, + turnIntentId, + Number.MAX_SAFE_INTEGER + ), + onSessionReady: (sessionId: string, eventStartIndex: number) => + params.onRunnerReady?.(sessionId, turnIntentId, eventStartIndex), + onBeforeTurnDispatch: params.onBeforeTurnDispatch, + onTurnAccepted: async (sessionId: string) => { + providerAccepted = true; + await params.onTurnAccepted?.(sessionId); + }, + }; + const recovered = params.recovery + ? await recoverLocalConversationTurn({ + ...continuationParams, + runnerSessionId: params.recovery.runnerSessionId, + eventStartIndex: params.recovery.eventStartIndex, + }) + : null; + if (!recovered && params.recovery?.providerAccepted) { + throw new QueuedConversationRecoveryPendingError(); + } + result = recovered ?? (await continueLocalConversation(continuationParams)); } catch (error) { - if (!usedRememberedSetup) throw error; - // The remembered setup went stale (checkout moved, account or model - // removed). Drop it and fall back to the dialog once. - log.warn("remembered runner setup failed; re-prompting", error); - clearForkSetupMemory(params.sourceScopeKey); - setup = await requestForkSessionSetup({ - sourceTitle: params.conversationTitle, - sourceScopeKey: params.sourceScopeKey, - sourceModel: params.sourceModel, + // Discovery, materialization and accepted-turn reconciliation can fail + // transiently. Keep the same durable execution owner and retry it; never + // convert a recovery-pending verdict into a permanent Cloud error row. + if (error instanceof QueuedConversationRecoveryPendingError) throw error; + // The human message is already a successful Cloud-plane event. If the + // local runtime then fails during create/materialize/send, publish one + // ordinary transcript error beside it; otherwise the shared root looks + // permanently unanswered after its transient runner overlay disappears. + if ( + providerAccepted && + !(error instanceof QueuedConversationRecoveryBlockedError) + ) { + throw error; + } + return closeConversationTurnWithFailure({ + rootLabel, + error, + turnIntentId, + publishTail: params.publishTail, }); - saveForkSetupMemory(params.sourceScopeKey, setup); - usedRememberedSetup = false; - created = await createRunner(); - } - if (usedRememberedSetup) { - Message.info( - i18n.t("navigation:collaboration.session.forkSetupReused", { - model: setup.execution.model ?? setup.execution.agentDefinitionId, - }) - ); } - const runnerSessionId = created.sessionId; - const registry = readRegistry(); - const entry = registry[key]; - writeRegistry({ - ...registry, - [key]: { - runnerSessionIds: [...(entry?.runnerSessionIds ?? []), runnerSessionId], - updatedAt: dispatchIso, - }, - }); - params.onRunnerReady?.(runnerSessionId, turnId); - await waitForFirstTurnTerminal(runnerSessionId, deadlineMs); - - const persisted = await eventStoreProxy - .getPersistedEvents(runnerSessionId) - .catch(() => [] as SessionEvent[]); - // The runner's own user event carries the injected context prefix (never - // pushed — the clean user row already went out in push #1); the agent and - // tool tail is the shared payload. - const agentTail = persisted - .filter((event) => event.source !== "user") - .map(boundConversationEventForPush); + const terminalTail = + result.terminalStatus === "failed" && result.agentTail.length === 0 + ? [ + buildPushedDispatchFailureEvent( + new Error("Agent request failed"), + new Date().toISOString(), + turnIntentId + ), + ] + : result.agentTail; + const agentTail = ( + await Promise.all( + terminalTail + .map((event) => turnScopedTailEvent(event, turnIntentId)) + .map(conversationEventsForPush) + ) + ).flat(); if (agentTail.length > 0) { - await pushConversationEventsChunked(await params.getAccessToken(), { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId, - events: agentTail, - }); - params.onPushed?.(); + // The accepted canonical execution row remains the only crash-recovery + // owner until this idempotent publish succeeds. A retry reconnects to the + // same native turn and re-reads its tail; it never runs the provider twice. + await params.publishTail(turnIntentId, agentTail); } log.info( - `pushed conversation turn ${turnId}: 1 + ${agentTail.length} event(s) to ${key}` + `continued ${rootLabel} in ${result.sessionId}; ` + + `staged ${agentTail.length} agent event(s)` ); - return { runnerSessionId, pushedEventCount: 1 + agentTail.length }; + return { + runnerSessionId: result.sessionId, + terminalStatus: result.terminalStatus, + }; } diff --git a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts index 8d623dd9ec..a4ee1f548f 100644 --- a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts +++ b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import type { - GroupedCommentThreads, - SessionComment, -} from "../org2CloudSessionCommentsAtom.types"; -import { CONVERSATION_SENDER_ARG } from "./continuationEvents"; +import type { CloudSessionComment } from "../org2CloudCommentsClient"; +import type { GroupedCommentThreads } from "../org2CloudSessionCommentsAtom.types"; import { SESSION_DISCUSSION_EVENT, buildDiscussionEvents, @@ -14,7 +13,7 @@ import { mergeConversationEvents, } from "./discussionEvents"; -function comment(overrides: Partial): SessionComment { +function comment(overrides: Partial): CloudSessionComment { return { id: "c-1", authorUserId: "user-1", @@ -22,7 +21,7 @@ function comment(overrides: Partial): SessionComment { body: "looks good", createdAt: "2026-08-20T10:00:00Z", ...overrides, - } as SessionComment; + } as CloudSessionComment; } function transcriptEvent(overrides: Partial): SessionEvent { @@ -91,7 +90,7 @@ describe("buildDiscussionEvents", () => { expect(rows).toHaveLength(2); expect(rows[0].uiCanonical).toBe(SESSION_DISCUSSION_EVENT); - expect(rows[0].source).toBe("system"); + expect(rows[0].source).toBe("user"); const top = discussionPayloadOf(rows[0]); expect(top?.anchorLocalEventId).toBe("local-evt-9"); expect(top?.anchorExcerpt).toBe("please refactor the auth module"); @@ -138,16 +137,25 @@ describe("buildDiscussionEvents", () => { userId: "user-1", displayName: "Alice", }); + expect(projectNativeConversationItems(rows)).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "looks good", + }), + ]); }); - it("projects retained Team Chat delivery state into the native message", () => { + it("keeps a failed outgoing Team Chat message visible and retryable", () => { const rows = buildDiscussionEvents( grouped({ sessionLevel: [ { top: comment({ + id: "local-comment-failed", clientDeliveryStatus: "failed", - clientDeliveryError: "offline", + clientDeliveryError: "network unavailable", + mentionedUserIds: ["user-2"], }), replies: [], }, @@ -156,11 +164,11 @@ describe("buildDiscussionEvents", () => { "session-1", new Map() ); + expect(rows[0].displayStatus).toBe("failed"); - expect(discussionPayloadOf(rows[0])).toMatchObject({ - deliveryStatus: "failed", - deliveryError: "offline", - }); + expect(rows[0].result["deliveryStatus"]).toBe("failed"); + expect(rows[0].result["deliveryError"]).toBe("network unavailable"); + expect(discussionPayloadOf(rows[0])?.mentionedUserIds).toEqual(["user-2"]); }); it("keeps the card renderer for anchored threads and agent reports", () => { diff --git a/src/features/Org2Cloud/SessionConversation/discussionEvents.ts b/src/features/Org2Cloud/SessionConversation/discussionEvents.ts index 34f2db91af..a33b789e59 100644 --- a/src/features/Org2Cloud/SessionConversation/discussionEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/discussionEvents.ts @@ -1,15 +1,14 @@ +import { + CONVERSATION_SENDER_ARG, + type ConversationSenderStamp, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import type { CloudSessionComment } from "../org2CloudCommentsClient"; import type { CommentThread, GroupedCommentThreads, - SessionComment, - SessionCommentDeliveryStatus, } from "../org2CloudSessionCommentsAtom.types"; -import { - CONVERSATION_SENDER_ARG, - type ConversationSenderStamp, -} from "./continuationEvents"; export const SESSION_DISCUSSION_EVENT = "session_discussion"; @@ -32,8 +31,6 @@ export interface DiscussionEventPayload { anchorOrphaned: boolean; /** Account ids the author explicitly @-mentioned (team-inbox targets). */ mentionedUserIds: string[]; - deliveryStatus: SessionCommentDeliveryStatus; - deliveryError: string | null; } export function discussionPayloadOf( @@ -51,7 +48,7 @@ interface DiscussionAnchor { } function commentToDiscussionEvent( - comment: SessionComment, + comment: CloudSessionComment, sessionId: string, anchor: DiscussionAnchor | null ): SessionEvent | null { @@ -71,8 +68,6 @@ function commentToDiscussionEvent( anchorExcerpt: anchor?.excerpt ?? null, anchorOrphaned: anchor?.orphaned ?? false, mentionedUserIds: comment.mentionedUserIds ?? [], - deliveryStatus: comment.clientDeliveryStatus ?? "sent", - deliveryError: comment.clientDeliveryError ?? null, }; const base = { id: `${DISCUSSION_ID_PREFIX}${comment.id}`, @@ -81,41 +76,52 @@ function commentToDiscussionEvent( createdAt: comment.createdAt, displayText: body, displayStatus: - payload.deliveryStatus === "pending" + comment.clientDeliveryStatus === "pending" ? "pending" - : payload.deliveryStatus === "failed" + : comment.clientDeliveryStatus === "failed" ? "failed" : "completed", displayVariant: "message", activityStatus: "agent", payloadRefs: [], }; - if ( - payload.kind === "user" && - !payload.anchorLocalEventId && - !payload.anchorOrphaned - ) { - // Plain Team chat: a first-class user message in the stream — same - // bubble, same turn grouping, attribution via the sender stamp. + if (payload.kind === "user") { + // Every human discussion message is part of the canonical conversation, + // including comments anchored to an earlier event. Plain Team Chat uses + // the ordinary bubble; anchored comments keep the richer card renderer, + // while both retain user role + sender provenance for native replay. const stamp: ConversationSenderStamp = { userId: comment.authorUserId, - displayName: comment.authorDisplayName?.trim() || comment.authorUserId, + ...(comment.authorDisplayName?.trim() + ? { displayName: comment.authorDisplayName.trim() } + : {}), }; return { ...base, functionName: SESSION_DISCUSSION_EVENT, - uiCanonical: "user_message", + uiCanonical: + !payload.anchorLocalEventId && !payload.anchorOrphaned + ? "user_message" + : SESSION_DISCUSSION_EVENT, actionType: "raw", args: { sessionDiscussion: payload, [CONVERSATION_SENDER_ARG]: stamp, }, - result: { type: "user", message: { content: body, role: "user" } }, + result: { + type: "user", + message: { content: body, role: "user" }, + ...(comment.clientDeliveryStatus + ? { deliveryStatus: comment.clientDeliveryStatus } + : {}), + ...(comment.clientDeliveryError + ? { deliveryError: comment.clientDeliveryError } + : {}), + }, source: "user", } as SessionEvent; } - // Anchored threads and agent reports keep the card renderer: they carry - // context (turn reference, agent provenance) a plain bubble cannot show. + // Agent reports remain system cards rather than portable human prompts. return { ...base, functionName: SESSION_DISCUSSION_EVENT, diff --git a/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts b/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts index be9f9ce718..ed546ba87c 100644 --- a/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts +++ b/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest"; import { buildTeamChatMentionOptions, + hasUnsupportedTeamChatAudiencePill, + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatAudienceTargets, + resolveTeamChatMentionedUserIds, resolveTeamChatMentions, } from "./teamChatMentions"; @@ -16,17 +21,38 @@ describe("buildTeamChatMentionOptions", () => { it("lists every other member by display name, falling back to the id", () => { const options = buildTeamChatMentionOptions(members, "u-vince", "Team"); expect(options.map((option) => option.label)).toEqual([ + "all", "Ann", "Ann Lee", "u-blank", ]); expect(options[0]).toEqual({ + id: "team-chat:all", + label: "all", + groupLabel: "Team", + audienceTarget: { kind: "all" }, + }); + expect(options[1]).toEqual({ id: "u-ann", label: "Ann", description: "member", groupLabel: "Team", + audienceTarget: { kind: "member", id: "u-ann" }, }); }); + + it("does not offer @all when the explicit-recipient wire cannot carry it", () => { + const largeRoster = Array.from({ length: 52 }, (_, index) => ({ + userId: `user-${index}`, + displayName: `User ${index}`, + role: "member", + })); + expect( + buildTeamChatMentionOptions(largeRoster, "user-0", "Team").some( + (option) => option.audienceTarget?.kind === "all" + ) + ).toBe(false); + }); }); describe("resolveTeamChatMentions", () => { @@ -58,3 +84,117 @@ describe("resolveTeamChatMentions", () => { expect(resolveTeamChatMentions("no mentions here", members)).toEqual([]); }); }); + +describe("resolveTeamChatAudienceTargets", () => { + it("keeps a pill's stable user id when its display label is ambiguous", () => { + expect( + resolveTeamChatAudienceTargets("@Ann please review", members, { + parts: [ + { + kind: "pill", + attrs: { + filePath: "member://u-ann-lee", + fileName: "Ann", + isFolder: false, + iconType: "member", + lineStart: null, + lineEnd: null, + }, + }, + { kind: "text", text: " please review" }, + ], + }) + ).toEqual([{ kind: "member", id: "u-ann-lee" }]); + }); + + it("supports typed @all and expands notifications to every other member", () => { + expect( + resolveTeamChatAudienceTargets("@all please review", members) + ).toEqual([{ kind: "all" }]); + expect( + resolveTeamChatMentionedUserIds( + "@all please review", + members, + undefined, + "u-vince" + ) + ).toEqual(["u-ann", "u-ann-lee", "u-blank"]); + }); + + it("surfaces an oversized @all audience before the Cloud request", () => { + const largeRoster = Array.from({ length: 52 }, (_, index) => ({ + userId: `user-${index}`, + displayName: `User ${index}`, + })); + const recipients = resolveTeamChatMentionedUserIds( + "@all please review", + largeRoster, + undefined, + "user-0" + ); + expect(recipients).toHaveLength(51); + expect(isTeamChatMentionAudienceWithinLimit(recipients)).toBe(false); + }); + + it("rejects structured member ids outside the current Cloud roster", () => { + expect( + resolveTeamChatMentionedUserIds("@Reviewer please review", members, { + parts: [ + { + kind: "pill", + attrs: { + filePath: "member://agent-org-member-9", + fileName: "Reviewer", + isFolder: false, + iconType: "member", + lineStart: null, + lineEnd: null, + }, + }, + { kind: "text", text: " please review" }, + ], + }) + ).toEqual([]); + }); + + it("never treats Agent or Agent Org pills as Team Chat audience", () => { + const snapshot = { + parts: [ + { + kind: "pill" as const, + attrs: { + filePath: "agent://reviewer", + fileName: "Reviewer", + isFolder: false, + iconType: "member" as const, + lineStart: null, + lineEnd: null, + }, + }, + { + kind: "pill" as const, + attrs: { + filePath: "agent_org://review-team", + fileName: "Review team", + isFolder: false, + iconType: "member" as const, + lineStart: null, + lineEnd: null, + }, + }, + { kind: "text" as const, text: " please review" }, + ], + }; + + expect(resolveTeamChatAudienceTargets("", members, snapshot)).toEqual([]); + expect(hasUnsupportedTeamChatAudiencePill(snapshot)).toBe(true); + }); +}); + +describe("isTeamChatBodyWithinLimit", () => { + it("mirrors the 4000-code-point Cloud comment limit", () => { + expect(isTeamChatBodyWithinLimit("a".repeat(4000))).toBe(true); + expect(isTeamChatBodyWithinLimit("😀".repeat(4000))).toBe(true); + expect(isTeamChatBodyWithinLimit("a".repeat(4001))).toBe(false); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/teamChatMentions.ts b/src/features/Org2Cloud/SessionConversation/teamChatMentions.ts index b704603aa5..b2dab8eb91 100644 --- a/src/features/Org2Cloud/SessionConversation/teamChatMentions.ts +++ b/src/features/Org2Cloud/SessionConversation/teamChatMentions.ts @@ -1,14 +1,23 @@ /** * Explicit @-mentions for Team chat. * - * The composer's @ menu inserts a member pill that serializes to `@` - * (see `serializePillNode`), so the submitted body carries names, not ids. - * Mentions are resolved back to account ids against the org roster at - * submit time and ride the comment wire as `mentionedUserIds` — the only - * thing that produces a team-inbox entry (Team chat never mentions anyone - * implicitly). + * The composer's @ menu inserts a member pill that serializes visibly to + * `@` while its submit-time snapshot retains `member://`. + * Typed pills therefore stay identity-stable; hand-typed mentions alone use + * the org roster fallback. Resolved ids ride the comment wire as + * `mentionedUserIds` — Team chat never notifies anyone implicitly. */ +import type { ComposerSnapshot } from "@src/components/ComposerInput"; import type { CustomMentionOption } from "@src/engines/ChatPanel/hooks/useInputArea/types"; +import { + type MessageAudienceTarget, + resolveMessageAudience, +} from "@src/features/TeamCollaboration/messageAudienceRouting"; + +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, +} from "../org2CloudCommentsClient"; export interface TeamChatMentionMember { userId: string; @@ -16,6 +25,17 @@ export interface TeamChatMentionMember { role?: string; } +export function isTeamChatBodyWithinLimit(body: string): boolean { + // PostgreSQL char_length counts Unicode code points, not UTF-16 units. + return Array.from(body).length <= CLOUD_COMMENT_MAX_BODY_LENGTH; +} + +export function isTeamChatMentionAudienceWithinLimit( + mentionedUserIds: readonly string[] +): boolean { + return mentionedUserIds.length <= CLOUD_COMMENT_MAX_MENTIONED_USER_IDS; +} + function mentionLabel(member: TeamChatMentionMember): string { return member.displayName?.trim() || member.userId; } @@ -33,15 +53,28 @@ export function buildTeamChatMentionOptions( viewerUserId: string | null, groupLabel: string ): CustomMentionOption[] { - return members + const memberOptions = members .filter((member) => member.userId !== viewerUserId) .map((member) => ({ id: member.userId, label: mentionLabel(member), description: member.role, groupLabel, + audienceTarget: { kind: "member" as const, id: member.userId }, })) .sort((left, right) => left.label.localeCompare(right.label)); + if (memberOptions.length === 0) return []; + return memberOptions.length <= CLOUD_COMMENT_MAX_MENTIONED_USER_IDS + ? [ + { + id: "team-chat:all", + label: "all", + groupLabel, + audienceTarget: { kind: "all" }, + }, + ...memberOptions, + ] + : memberOptions; } /** @@ -100,3 +133,132 @@ export function resolveTeamChatMentions( } return found; } + +function containsAllMention(body: string): boolean { + return /(^|[^\p{L}\p{N}_])@all(?=$|[^\p{L}\p{N}_])/iu.test(body); +} + +function targetsFromText( + body: string, + members: readonly TeamChatMentionMember[] +): MessageAudienceTarget[] { + const targets: MessageAudienceTarget[] = []; + if (containsAllMention(body)) targets.push({ kind: "all" }); + // `@all` is reserved for channel audience. Mask it before the display-name + // fallback so a member whose display name happens to be "all" cannot steal + // a hand-typed channel mention. + const memberBody = body.replace( + /(^|[^\p{L}\p{N}_])@all(?=$|[^\p{L}\p{N}_])/giu, + "$1" + ); + targets.push( + ...resolveTeamChatMentions(memberBody, members).map((id) => ({ + kind: "member" as const, + id, + })) + ); + return targets; +} + +function targetFromPill( + part: Extract +): MessageAudienceTarget | null { + if (part.attrs.iconType !== "member") return null; + if (part.attrs.filePath === "audience://all") return { kind: "all" }; + const match = part.attrs.filePath.match(/^member:\/\/(.+)$/); + if (!match) return null; + try { + const id = decodeURIComponent(match[1]).trim(); + if (!id) return null; + return { kind: "member", id }; + } catch { + return null; + } +} + +/** + * Agent and Agent Org pills can remain in the editor when its mode changes. + * They are a different address space from Cloud members, so Team Chat must + * reject the snapshot explicitly instead of displaying a pill that silently + * resolves to no human recipient. + */ +export function hasUnsupportedTeamChatAudiencePill( + snapshot?: ComposerSnapshot +): boolean { + return Boolean( + snapshot?.parts.some( + (part) => + part.kind === "pill" && + /^(agent|agent_org):\/\//.test(part.attrs.filePath) + ) + ); +} + +function uniqueTargets( + targets: readonly MessageAudienceTarget[] +): MessageAudienceTarget[] { + const seen = new Set(); + const result: MessageAudienceTarget[] = []; + for (const target of targets) { + const key = target.kind === "all" ? "all" : `${target.kind}:${target.id}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(target); + } + return result; +} + +/** + * Resolve the audience from the exact submit-time editor snapshot. Member + * pills carry account ids; only ordinary text fragments use the display-name + * fallback. This prevents a roster rename during an async submit from + * retargeting a message. + */ +export function resolveTeamChatAudienceTargets( + body: string, + members: readonly TeamChatMentionMember[], + snapshot?: ComposerSnapshot +): MessageAudienceTarget[] { + if (!snapshot) return uniqueTargets(targetsFromText(body, members)); + const targets: MessageAudienceTarget[] = []; + let snapshotHasContent = false; + for (const part of snapshot.parts) { + if (part.kind === "text") { + snapshotHasContent ||= part.text.length > 0; + targets.push(...targetsFromText(part.text, members)); + } else if (part.kind === "pill") { + snapshotHasContent = true; + const target = targetFromPill(part); + if (target) targets.push(target); + } + } + if (!snapshotHasContent) { + return uniqueTargets(targetsFromText(body, members)); + } + return uniqueTargets(targets); +} + +/** IDs that should receive human notifications for the current Team chat body. */ +export function resolveTeamChatMentionedUserIds( + body: string, + members: readonly TeamChatMentionMember[], + snapshot?: ComposerSnapshot, + viewerUserId?: string | null +): string[] { + const targets = resolveTeamChatAudienceTargets(body, members, snapshot); + const audience = resolveMessageAudience("team_chat", targets); + if (audience.human.scope === "channel") { + return [ + ...new Set( + members + .map((member) => member.userId) + .filter((id) => id !== viewerUserId) + ), + ]; + } + if (audience.human.scope !== "members") return []; + const rosterMemberIds = new Set(members.map((member) => member.userId)); + return audience.human.memberIds.filter( + (id) => id !== viewerUserId && rosterMemberIds.has(id) + ); +} diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts new file mode 100644 index 0000000000..84cfcadbad --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { cloudConversationAuthorityIsLive } from "./cloudConversationAuthority"; +import { conversationSourceFromCloudReplay } from "./useCloudConversationSource"; + +describe("Cloud conversation source", () => { + it("projects source runtime before the imported Session row exists", () => { + expect( + conversationSourceFromCloudReplay({ + orgId: "org-1", + remoteSession: { + id: "row-1", + orgId: "org-1", + ownerMemberId: "member-ada", + ownerUserId: "user-ada", + ownerDisplayName: "Ada Lovelace", + ownerIdentityKind: "human", + sourceSessionId: "claude-source", + title: "Runtime migration", + cliAgentType: "claude_code", + model: "claude-opus-5", + eventsEpoch: 1, + eventsFrozenSeq: 8, + eventsCount: 24, + eventsTailHash: "tail", + }, + workspaceRepoPath: null, + }) + ).toEqual({ + root: { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "claude-source", + }, + cliAgentType: "claude_code", + agentDefinitionId: undefined, + agentDisplayName: undefined, + model: "claude-opus-5", + initialTarget: null, + workspaceRepoPath: null, + }); + }); + + it("uses the resolved comment plane as the canonical Cloud root", () => { + expect( + conversationSourceFromCloudReplay({ + target: { orgId: "org-1", sessionId: "live-family-anchor" }, + remoteSession: { + id: "row-1", + orgId: "org-1", + ownerMemberId: "member-ada", + ownerUserId: "user-ada", + ownerDisplayName: "Ada Lovelace", + ownerIdentityKind: "human", + sourceSessionId: "live-family-anchor", + title: "Runtime migration", + forkedFrom: { + sourceSessionId: "expired-parent", + rootSessionId: "expired-root", + forkedAt: "2026-08-29T00:00:00.000Z", + }, + eventsEpoch: 1, + eventsFrozenSeq: 8, + eventsCount: 24, + eventsTailHash: "tail", + }, + workspaceRepoPath: "/local/repo", + })?.root + ).toEqual({ + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "live-family-anchor", + }); + }); + + describe("owner-local Cloud authority liveness", () => { + const target = { orgId: "org-1", sessionId: "local-root" }; + const row = { sourceSessionId: "local-root" } as never; + + it("keeps the Cloud authority while the root row is listed", () => { + expect( + cloudConversationAuthorityIsLive({ + session: { importedFrom: undefined }, + target, + entry: { state: "ready", rows: [row] }, + loadingSource: null, + }) + ).toBe(true); + }); + + it("keeps the Cloud authority until the listing has loaded", () => { + expect( + cloudConversationAuthorityIsLive({ + session: { importedFrom: undefined }, + target, + entry: { state: "loading", rows: [] }, + loadingSource: null, + }) + ).toBe(true); + expect( + cloudConversationAuthorityIsLive({ + session: { importedFrom: undefined }, + target, + entry: undefined, + loadingSource: null, + }) + ).toBe(true); + }); + + it("drops the Cloud authority for a local session whose root row expired", () => { + expect( + cloudConversationAuthorityIsLive({ + session: { importedFrom: undefined }, + target, + entry: { state: "ready", rows: [] }, + loadingSource: null, + }) + ).toBe(false); + }); + + it("never drops the Cloud authority of a replay viewer", () => { + expect( + cloudConversationAuthorityIsLive({ + session: { + importedFrom: { + orgId: "org-1", + sourceSessionId: "local-root", + } as never, + }, + target, + entry: { state: "ready", rows: [] }, + loadingSource: null, + }) + ).toBe(true); + expect( + cloudConversationAuthorityIsLive({ + session: undefined, + target, + entry: { state: "ready", rows: [] }, + loadingSource: { orgId: "org-1" }, + }) + ).toBe(true); + }); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts new file mode 100644 index 0000000000..3724cb247f --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts @@ -0,0 +1,231 @@ +import { atom, useAtomValue } from "jotai"; +import { useEffect, useMemo, useState } from "react"; + +import type { ConversationSource } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { normalizeSourceEndpointUrl } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import { resolveForkWorkspacePath } from "@src/features/TeamCollaboration/forkWorkspaceResolution"; +import { createLogger } from "@src/hooks/logger"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import type { Repo } from "@src/store/repo"; +import type { Session } from "@src/store/session"; +import { getExternalHistoryCliAgentType } from "@src/util/session/sessionDispatch"; + +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + type CloudOrgRemoteSessionsEntry, + org2CloudRemoteSessionsAtom, +} from "../org2CloudRemoteSessionsAtom"; +import type { SessionCommentTarget } from "../sessionCommentTarget"; +import { useCloudSessionLoadingSource } from "../useCloudSessionDownloadSurface"; +import { cloudConversationAuthorityIsLive } from "./cloudConversationAuthority"; + +const detachedRemoteSessionsAtom = atom< + Record +>({}); +const log = createLogger("CloudConversationSource"); + +export function conversationSourceFromCloudReplay(params: { + target?: SessionCommentTarget | null; + importedFrom?: Session["importedFrom"]; + orgId?: string; + remoteSession?: RemoteTeammateSessionMetadata; + sourceEndpointUrl?: string; + workspaceRepoPath: string | null; +}): ConversationSource | undefined { + const orgId = + params.target?.orgId ?? params.importedFrom?.orgId ?? params.orgId; + const sourceSessionId = + params.target?.sessionId ?? + params.importedFrom?.sourceSessionId ?? + params.remoteSession?.sourceSessionId; + if (!orgId || !sourceSessionId) return undefined; + // `useSessionCommentTarget` has already converged the family onto its live + // Cloud plane (including retention fallback). Never reroot that explicit + // authority a second time from stale lineage metadata. + const rootId = + params.target?.sessionId ?? + params.remoteSession?.forkedFrom?.rootSessionId ?? + sourceSessionId; + const endpoint = + params.importedFrom?.sourceEndpointUrl ?? params.sourceEndpointUrl; + return { + root: { + authority: "org2-cloud", + authorityScope: endpoint + ? [normalizeSourceEndpointUrl(endpoint), orgId] + : [orgId], + conversationId: rootId, + }, + cliAgentType: + params.importedFrom?.sourceDisplay?.cliAgentType ?? + params.remoteSession?.cliAgentType ?? + getExternalHistoryCliAgentType(rootId), + agentDefinitionId: + params.importedFrom?.sourceDisplay?.agentDefinitionId ?? + params.remoteSession?.agentDefinitionId, + agentDisplayName: + params.importedFrom?.sourceDisplay?.agentDisplayName ?? + params.remoteSession?.agentDisplayName, + model: + params.importedFrom?.sourceDisplay?.model ?? params.remoteSession?.model, + initialTarget: null, + workspaceRepoPath: params.workspaceRepoPath, + }; +} + +interface CloudConversationSourceInput { + sessionId: string | null | undefined; + session?: Session; + target: SessionCommentTarget | null; + sessions: readonly Session[]; + repos: readonly Repo[]; +} + +interface CloudConversationSourceResolution { + source: ConversationSource | undefined; + workspacePending: boolean; +} + +/** Resolve Cloud replay identity and its device-local checkout at the edge. */ +export function useCloudConversationSource({ + sessionId, + session, + target, + sessions, + repos, +}: CloudConversationSourceInput): CloudConversationSourceResolution { + const auth = useAtomValue(org2CloudAuthAtom); + const loadingSource = useCloudSessionLoadingSource(sessionId); + const importedFrom = session?.importedFrom; + const remoteEntries = useAtomValue( + target || importedFrom || loadingSource + ? org2CloudRemoteSessionsAtom + : detachedRemoteSessionsAtom + ); + const importedRemoteRow = useMemo(() => { + if (target) { + const targetRow = remoteEntries[target.orgId]?.rows.find( + (candidate) => candidate.sourceSessionId === target.sessionId + ); + if (targetRow) return targetRow; + } + if (importedFrom) { + return ( + remoteEntries[importedFrom.orgId]?.rows.find( + (candidate) => + candidate.sourceSessionId === importedFrom.sourceSessionId + ) ?? loadingSource + ); + } + return loadingSource; + }, [importedFrom, loadingSource, remoteEntries, target]); + const authorityLive = useMemo( + () => + cloudConversationAuthorityIsLive({ + session, + target, + entry: target ? remoteEntries[target.orgId] : undefined, + loadingSource, + }), + [loadingSource, remoteEntries, session, target] + ); + const importedOrgId = + target?.orgId ?? importedFrom?.orgId ?? loadingSource?.orgId; + const importedWorkspaceKey = importedRemoteRow + ? `${importedOrgId ?? ""}:${importedRemoteRow.id}` + : null; + const [importedWorkspaceResolution, setImportedWorkspaceResolution] = + useState<{ key: string; path: string | null } | null>(null); + const localWorkspaceInventoryKey = useMemo( + () => + [ + ...repos.map((repo) => repo.path), + ...sessions + // Imported rows may contain another device's absolute path. They + // are the input being resolved, never evidence that this machine's + // local workspace inventory has hydrated. + .filter((candidate) => !candidate.importedFrom) + .flatMap((candidate) => [ + candidate.repoRootPath, + candidate.worktreePath, + candidate.repoPath, + ]), + ] + .filter((path): path is string => Boolean(path)) + .sort() + .join("\n"), + [repos, sessions] + ); + + useEffect(() => { + let cancelled = false; + if (!importedRemoteRow || !importedWorkspaceKey) return; + // A scoped Team Session needs the local repo/session inventory before a + // missing match is authoritative. Keep the prior durable choice pending + // during cold-start hydration instead of collapsing it to null. + if (importedRemoteRow.repoScopeKey && !localWorkspaceInventoryKey) return; + void resolveForkWorkspacePath(importedRemoteRow) + .then((path) => { + if (!cancelled) { + setImportedWorkspaceResolution({ key: importedWorkspaceKey, path }); + } + }) + .catch((error: unknown) => { + // A failed probe is not evidence that the source has no workspace. + // Keep it pending until the existing inventory invalidation retries. + if (!cancelled) log.error("Local workspace resolution failed", error); + }); + return () => { + cancelled = true; + }; + }, [importedRemoteRow, importedWorkspaceKey, localWorkspaceInventoryKey]); + + const workspacePending = Boolean( + importedRemoteRow && + importedWorkspaceKey && + importedWorkspaceResolution?.key !== importedWorkspaceKey + ); + const importedWorkspacePath = + importedWorkspaceResolution?.key === importedWorkspaceKey + ? importedWorkspaceResolution.path + : null; + const source = useMemo( + () => + !authorityLive + ? undefined + : conversationSourceFromCloudReplay({ + target, + importedFrom, + orgId: importedOrgId, + remoteSession: importedRemoteRow, + sourceEndpointUrl: auth?.supabaseUrl, + // Imported rows may carry the owner's absolute path. Only the shared + // repo-scope resolver may produce a workspace for this device. + workspaceRepoPath: + !importedFrom && !loadingSource + ? (session?.repoRootPath ?? + session?.worktreePath ?? + session?.repoPath ?? + null) + : importedWorkspacePath, + }), + [ + authorityLive, + importedFrom, + loadingSource, + importedOrgId, + importedRemoteRow, + importedWorkspacePath, + auth?.supabaseUrl, + session?.repoPath, + session?.repoRootPath, + session?.worktreePath, + target, + ] + ); + + return useMemo( + () => ({ source, workspacePending }), + [source, workspacePending] + ); +} diff --git a/src/features/Org2Cloud/SessionConversation/useConversationComposer.test.ts b/src/features/Org2Cloud/SessionConversation/useConversationComposer.test.ts index 5ebec5695f..c7e3b9bb59 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationComposer.test.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationComposer.test.ts @@ -57,11 +57,7 @@ describe("Team Chat composer delivery ownership", () => { it("marks transport failures as retained so the editor is not restored", async () => { mocks.addComment.mockRejectedValueOnce( - new SessionCommentDeliveryError( - "local-comment-1", - { body: "hello" }, - new Error("offline") - ) + new SessionCommentDeliveryError("local-comment-1", new Error("offline")) ); root = createSmokeRoot(); await root.render( @@ -97,7 +93,7 @@ describe("Team Chat composer delivery ownership", () => { ).rejects.toBe(error); }); - it("rejects before inserting an anonymous unretryable row", async () => { + it("never enters team chat for a signed-out viewer, so no anonymous row is inserted", async () => { mocks.viewerUserId = null; root = createSmokeRoot(); await root.render( @@ -111,6 +107,63 @@ describe("Team Chat composer delivery ownership", () => { await expect( api.submit({ displayText: "hello", agentContent: "hello" }) + ).resolves.toBe(false); + expect(mocks.addComment).not.toHaveBeenCalled(); + }); + + it("rejects an unsupported attachment before any row is inserted", async () => { + root = createSmokeRoot(); + await root.render( + createElement(Harness, { + onReady: (value) => { + api = value; + }, + }) + ); + await act(async () => api.setMode("team_chat")); + + await expect( + api.submit({ + displayText: "hello", + agentContent: "hello", + imageDataUrls: ["data:image/png;base64,AAA"], + }) + ).rejects.toBeInstanceOf(SubmitValidationError); + expect(mocks.addComment).not.toHaveBeenCalled(); + }); + + it("rejects a stale Agent pill instead of silently sending it as Team Chat", async () => { + root = createSmokeRoot(); + await root.render( + createElement(Harness, { + onReady: (value) => { + api = value; + }, + }) + ); + await act(async () => api.setMode("team_chat")); + + await expect( + api.submit({ + displayText: "@Reviewer please review", + agentContent: "@Reviewer please review", + composerSnapshot: { + parts: [ + { + kind: "pill", + attrs: { + filePath: "agent://reviewer", + fileName: "Reviewer", + isFolder: false, + iconType: "member", + lineStart: null, + lineEnd: null, + }, + }, + { kind: "text", text: " please review" }, + ], + }, + }) ).rejects.toBeInstanceOf(SubmitValidationError); expect(mocks.addComment).not.toHaveBeenCalled(); }); diff --git a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts index bd69972fc8..fe581df418 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts @@ -7,15 +7,20 @@ import { SubmitRetainedDeliveryError, SubmitValidationError, } from "@src/engines/ChatPanel/hooks/useInputArea/types"; -import { resolveMessageAudience } from "@src/features/TeamCollaboration/messageAudienceRouting"; import { useSessionCommentsContext } from "../SessionComments/SessionCommentsContext"; +import { CLOUD_COMMENT_MAX_MENTIONED_USER_IDS } from "../org2CloudCommentsClient"; import { SessionCommentDeliveryError } from "../org2CloudSessionCommentsAtom"; import { type ConversationComposerMode, conversationComposerModeAtomFamily, } from "./conversationComposerMode"; -import { resolveTeamChatMentions } from "./teamChatMentions"; +import { + hasUnsupportedTeamChatAudiencePill, + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatMentionedUserIds, +} from "./teamChatMentions"; export function useConversationComposerMode( sessionId: string | null @@ -23,7 +28,20 @@ export function useConversationComposerMode( const [mode, setMode] = useAtom( conversationComposerModeAtomFamily(sessionId ?? "") ); - return [sessionId ? mode : "prompt", setMode]; + const comments = useSessionCommentsContext(); + const teamChatAvailable = Boolean( + sessionId && comments?.target && comments.viewerUserId + ); + const effectiveMode = teamChatAvailable ? mode : "prompt"; + const setEffectiveMode = useCallback( + (nextMode: ConversationComposerMode) => { + setMode( + nextMode === "team_chat" && !teamChatAvailable ? "prompt" : nextMode + ); + }, + [setMode, teamChatAvailable] + ); + return [effectiveMode, setEffectiveMode]; } /** True when this composer can address a cloud discussion at all. */ @@ -36,8 +54,7 @@ export function useConversationTeamChatAvailable(): boolean { * Composer submit router. Team chat mode posts the text as a session * discussion message (comment wire); only explicit `@name` mentions in the * body notify anyone (team inbox). Prompt mode falls through to the - * surface's own override (imported-session fork, group-chat routing) or the - * default agent submit. + * surface's own Team Chat override or the default canonical Agent submit. */ export function useConversationSubmitOverride( sessionId: string | null, @@ -58,23 +75,31 @@ export function useConversationSubmitOverride( if (input.imageDataUrls?.length) { throw new SubmitValidationError(t("conversation.imagesUnsupported")); } + if (hasUnsupportedTeamChatAudiencePill(input.composerSnapshot)) { + throw new SubmitValidationError(t("conversation.teamChatTooltip")); + } const body = input.displayText.trim(); if (!body) return true; - const audience = resolveMessageAudience( - "team_chat", - resolveTeamChatMentions(body, comments.mentionableMembers).map( - (id) => ({ - kind: "member" as const, - id, - }) - ) + if (!isTeamChatBodyWithinLimit(body)) { + throw new SubmitValidationError( + t("navigation:cloud.channels.feed.errorTooLong") + ); + } + const mentionedUserIds = resolveTeamChatMentionedUserIds( + body, + comments.mentionableMembers, + input.composerSnapshot, + comments.viewerUserId ); + if (!isTeamChatMentionAudienceWithinLimit(mentionedUserIds)) { + throw new SubmitValidationError( + `@all is unavailable when it would notify more than ${CLOUD_COMMENT_MAX_MENTIONED_USER_IDS} people` + ); + } try { await comments.addComment({ body, - ...(audience.human.scope === "members" - ? { mentionedUserIds: audience.human.memberIds } - : {}), + ...(mentionedUserIds.length > 0 ? { mentionedUserIds } : {}), }); } catch (error) { if (error instanceof SessionCommentDeliveryError) { diff --git a/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts b/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts deleted file mode 100644 index 4d648122a9..0000000000 --- a/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Composer model-pill binding for team-conversation surfaces. - * - * Imported replay copies deliberately carry `model: undefined` (the - * composer used to be a fork entry), so the stock pill reads "Select - * model" forever, and a manual pick patches the imported row — which the - * next family refresh wipes. On the conversation plane the model that - * actually executes a member's turn is the remembered runner setup - * (`forkSetupMemory`, the same record `runConversationTurn` launches - * with), so the pill mirrors THAT: display the remembered model, and - * route picks back into the memory so they stick across sends, - * refreshes, and restarts. - */ -import { atom, useAtomValue } from "jotai"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; - -import { KEY_SOURCE, isHostedKey } from "@src/api/tauri/session"; -import type { AdvancedConfig } from "@src/features/SessionCreator/types"; -import { - forkSetupMemoryVersion, - loadForkSetupMemory, - saveForkSetupMemory, - subscribeForkSetupMemory, -} from "@src/features/TeamCollaboration/forkSetupMemory"; -import type { LastModelSelection } from "@src/store/session/creatorDefaultModelAtom"; -import { sessionByIdAtom } from "@src/store/session/sessionAtom"; - -import type { CloudOrgRemoteSessionsEntry } from "../org2CloudRemoteSessionsAtom"; -import { org2CloudRemoteSessionsAtom } from "../org2CloudRemoteSessionsAtom"; - -const detachedRemoteSessionsAtom = atom< - Record ->({}); - -export interface ConversationSetupPillBinding { - /** Remembered runner selection; null until the first setup is confirmed. */ - selection: LastModelSelection | null; - /** - * Persist a palette pick into the remembered runner setup. Returns false - * when there is nothing to update yet (no confirmed setup, or a hosted - * pick the own-key runner cannot launch) — the first send's setup dialog - * remains the authoritative fallback. - */ - applyModelPick: (config: AdvancedConfig) => boolean; -} - -export function useConversationSetupPillBinding( - sessionId: string | null | undefined -): ConversationSetupPillBinding | null { - const session = useAtomValue(sessionByIdAtom(sessionId ?? "")); - const importedFrom = session?.importedFrom; - const remoteEntries = useAtomValue( - importedFrom ? org2CloudRemoteSessionsAtom : detachedRemoteSessionsAtom - ); - const memoryVersion = useSyncExternalStore( - subscribeForkSetupMemory, - forkSetupMemoryVersion, - forkSetupMemoryVersion - ); - - // Same derivation the plane submit path uses for its setup lookup: the - // conversation ROOT row's repo scope keys the memory record. - const scopeKey = useMemo(() => { - if (!importedFrom) return undefined; - const rows = remoteEntries[importedFrom.orgId]?.rows; - const row = rows?.find( - (candidate) => candidate.sourceSessionId === importedFrom.sourceSessionId - ); - const rootId = - row?.forkedFrom?.rootSessionId ?? importedFrom.sourceSessionId; - const rootRow = rows?.find( - (candidate) => candidate.sourceSessionId === rootId - ); - return rootRow?.repoScopeKey; - }, [importedFrom, remoteEntries]); - - const selection = useMemo((): LastModelSelection | null => { - if (!importedFrom) return null; - void memoryVersion; - const remembered = loadForkSetupMemory(scopeKey); - if (!remembered) return null; - return { - keySource: KEY_SOURCE.OWN, - model: remembered.execution.model, - selectedAccountId: remembered.execution.accountId, - }; - }, [importedFrom, scopeKey, memoryVersion]); - - const applyModelPick = useCallback( - (config: AdvancedConfig): boolean => { - if (isHostedKey(config.keySource) || !config.model) return false; - const current = loadForkSetupMemory(scopeKey); - if (!current) return false; - saveForkSetupMemory(scopeKey, { - ...current, - execution: { - ...current.execution, - model: config.model, - accountId: config.selectedAccountId ?? current.execution.accountId, - }, - }); - return true; - }, - [scopeKey] - ); - - return useMemo( - () => (importedFrom ? { selection, applyModelPick } : null), - [importedFrom, selection, applyModelPick] - ); -} diff --git a/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.test.ts b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.test.ts new file mode 100644 index 0000000000..0d4aa2284c --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom +import { createElement } from "react"; +import { act } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import type { ConversationFamilyMember } from "./continuationEvents"; +import { useEnsureFamilyLoaded } from "./useEnsureFamilyLoaded"; + +const mocks = vi.hoisted(() => ({ + buildCloudSessionFetchClient: vi.fn(() => ({ kind: "test-client" })), + ensureFreshSession: vi.fn(), + importRemoteSession: vi.fn(), + setAuth: vi.fn(), + warn: vi.fn(), + conversationPlaneSignals: { "org-a": 0 } as Record, +})); + +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: (atom: { debugLabel?: string }) => + atom.debugLabel === "org2CloudAuthAtom" + ? AUTH + : mocks.conversationPlaneSignals, + useSetAtom: () => mocks.setAuth, +})); + +vi.mock("@src/features/Org2Cloud/org2CloudBackendAdapter", () => ({ + buildCloudSessionFetchClient: mocks.buildCloudSessionFetchClient, +})); + +vi.mock("@src/features/TeamCollaboration/engine/collabSessionImport", () => ({ + importRemoteSession: mocks.importRemoteSession, +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ warn: mocks.warn }), +})); + +vi.mock("../org2CloudClient", async (importOriginal) => ({ + ...(await importOriginal()), + ensureFreshSession: mocks.ensureFreshSession, +})); + +const AUTH = { + kind: "org2_cloud" as const, + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-a", + accessToken: "access-a", + refreshToken: "refresh-a", + expiresAt: 4_102_444_800, +}; + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function family(prefix: string, count: number): ConversationFamilyMember[] { + return Array.from({ length: count }, (_, index) => { + const bareSessionId = `${prefix}-${index}`; + return { + bareSessionId, + isRoot: index === 0, + row: { + id: `remote-${bareSessionId}`, + orgId: "org-a", + ownerMemberId: "member-a", + ownerUserId: "owner-a", + ownerDisplayName: "Ada", + ownerIdentityKind: "human", + sourceSessionId: bareSessionId, + title: bareSessionId, + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 1, + eventsTailHash: `tail-${bareSessionId}`, + }, + }; + }); +} + +const NO_LOADED_SESSIONS = new Set(); + +function Probe({ members }: { members: readonly ConversationFamilyMember[] }) { + useEnsureFamilyLoaded(members, NO_LOADED_SESSIONS, "anchor"); + return null; +} + +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useEnsureFamilyLoaded import claims", () => { + let root: SmokeRoot; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.conversationPlaneSignals["org-a"] = 0; + root = createSmokeRoot(); + }); + + afterEach(async () => { + await root.unmount(); + }); + + async function render(members: readonly ConversationFamilyMember[]) { + await root.render(createElement(Probe, { members })); + } + + it("claims only worker-owned tasks and releases them on cleanup", async () => { + const members = family("cancel-overflow", 6); + const refreshes = Array.from({ length: 4 }, () => deferred()); + let refreshIndex = 0; + mocks.ensureFreshSession.mockImplementation( + () => refreshes[refreshIndex++]?.promise ?? Promise.resolve(AUTH) + ); + + await render(members); + await vi.waitFor(() => { + expect(mocks.ensureFreshSession).toHaveBeenCalledTimes(4); + }); + + await root.unmount(); + mocks.ensureFreshSession.mockResolvedValue(AUTH); + root = createSmokeRoot(); + await render([...members]); + + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(6); + }); + expect( + mocks.importRemoteSession.mock.calls.map( + ([options]) => options.remoteSession.sourceSessionId + ) + ).toEqual( + expect.arrayContaining(members.map((member) => member.bareSessionId)) + ); + + for (const refresh of refreshes) refresh.resolve(AUTH); + await flushAsync(); + await render([...members]); + await flushAsync(); + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(6); + }); + + it.each([ + ["a null refresh", () => Promise.resolve(null)], + ["a rejected refresh", () => Promise.reject(new Error("refresh failed"))], + ])( + "releases a claim after %s so the same position can retry", + async (_, fail) => { + const members = family(`refresh-retry-${String(_)}`, 1); + mocks.ensureFreshSession.mockImplementationOnce(fail); + + await render(members); + await vi.waitFor(() => { + expect(mocks.ensureFreshSession).toHaveBeenCalledTimes(1); + }); + await flushAsync(); + + mocks.ensureFreshSession.mockResolvedValue(AUTH); + await render([...members]); + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(1); + }); + } + ); + + it("releases a claim after import failure so the same position can retry", async () => { + const members = family("import-retry", 1); + mocks.ensureFreshSession.mockResolvedValue(AUTH); + mocks.importRemoteSession.mockRejectedValueOnce(new Error("import failed")); + + await render(members); + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(1); + }); + await flushAsync(); + + mocks.importRemoteSession.mockResolvedValue(undefined); + await render([...members]); + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(2); + }); + }); + + it("retries a failed import from the global per-org recovery signal", async () => { + const members = family("foreground-retry", 1); + mocks.ensureFreshSession.mockResolvedValue(AUTH); + mocks.importRemoteSession.mockRejectedValueOnce(new Error("import failed")); + + await render(members); + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(1); + }); + await flushAsync(); + + mocks.importRemoteSession.mockResolvedValue(undefined); + mocks.conversationPlaneSignals["org-a"] += 1; + await render(members); + await vi.waitFor(() => { + expect(mocks.importRemoteSession).toHaveBeenCalledTimes(2); + }); + }); + + it("does not install foreground listeners per transcript surface", async () => { + const windowListener = vi.spyOn(window, "addEventListener"); + const documentListener = vi.spyOn(document, "addEventListener"); + mocks.ensureFreshSession.mockResolvedValue(AUTH); + mocks.importRemoteSession.mockResolvedValue(undefined); + + await render(family("no-surface-listeners", 1)); + await flushAsync(); + + expect(windowListener).not.toHaveBeenCalledWith( + expect.stringMatching(/^(blur|focus|online)$/), + expect.any(Function) + ); + expect(documentListener).not.toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function) + ); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts index 0cfdeb1215..d20e5a8bc1 100644 --- a/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts +++ b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts @@ -1,16 +1,22 @@ import { useAtomValue, useSetAtom } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { buildCloudSessionFetchClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter"; import { importRemoteSession } from "@src/features/TeamCollaboration/engine/collabSessionImport"; import { createLogger } from "@src/hooks/logger"; import { BoundedMap } from "@src/util/collections/BoundedMap"; -import { commitRefreshedAuth, org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + commitRefreshedAuth, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; import { ensureFreshSession } from "../org2CloudClient"; import type { ConversationFamilyMember } from "./continuationEvents"; +import { conversationPlaneSignalAtom } from "./conversationPlaneAtom"; const log = createLogger("ConversationFamilyLoader"); +const MAX_FAMILY_IMPORT_CONCURRENCY = 4; /** * Last import position attempted per family member, keyed by org + session. @@ -47,51 +53,156 @@ export function useEnsureFamilyLoaded( ): void { const auth = useAtomValue(org2CloudAuthAtom); const setAuth = useSetAtom(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const conversationPlaneSignals = useAtomValue(conversationPlaneSignalAtom); + const familyOrgId = family?.[0]?.row.orgId ?? null; + const recoverySignal = familyOrgId + ? (conversationPlaneSignals[familyOrgId] ?? 0) + : 0; + const failedImportRef = useRef(false); + const previousRecoverySignalRef = useRef({ + orgId: familyOrgId, + value: recoverySignal, + }); + const [failedImportRetryVersion, setFailedImportRetryVersion] = useState(0); + // A background failure must remain retryable, but retry only at an + // invalidation owned by the app-wide Cloud realtime boundary. Its global + // foreground recovery already bumps the same per-org signal, so transcript + // surfaces must not install their own browser listeners. `importRemoteSession` + // owns the actual per-source serialization and durable cursor/no-op decision. useEffect(() => { - if (!family || !auth) return; - for (const member of family) { + const previous = previousRecoverySignalRef.current; + previousRecoverySignalRef.current = { + orgId: familyOrgId, + value: recoverySignal, + }; + if ( + !failedImportRef.current || + !familyOrgId || + previous.orgId !== familyOrgId || + previous.value === recoverySignal + ) { + return; + } + failedImportRef.current = false; + setFailedImportRetryVersion((version) => version + 1); + }, [familyOrgId, recoverySignal]); + + useEffect(() => { + const requestAuth = auth; + if (!family || !requestAuth || !authIdentityKey) return; + const pending = family.filter((member) => { const bareSessionId = member.bareSessionId; + const row = member.row; if ( - bareSessionId === anchorBareSessionId || - loadedBareSessionIds.has(bareSessionId) + !( + bareSessionId !== anchorBareSessionId && + !loadedBareSessionIds.has(bareSessionId) && + !row.deletedAt && + row.eventsEpoch !== undefined && + Boolean(row.eventsCount) && + row.id !== `local-${bareSessionId}` + ) ) { - continue; - } - const row = member.row; - // Nothing fetchable: tombstoned, metadata-only (no events pushed), or - // the synthesized pseudo-row a fresh local fork gets before its push. - if (row.deletedAt || row.eventsEpoch === undefined || !row.eventsCount) { - continue; + return false; } - if (row.id === `local-${bareSessionId}`) continue; - const memberKey = `${row.orgId}:${bareSessionId}`; + const memberKey = `${authIdentityKey}:${row.orgId}:${bareSessionId}`; const position = `${row.eventsEpoch}:${row.eventsCount}`; - // `get` rather than `peek`: re-checking a member is what keeps it warm, - // so an actively followed conversation should not be the eviction victim. - if (attemptedImportPositions.get(memberKey) === position) continue; - attemptedImportPositions.set(memberKey, position); - void (async () => { + return attemptedImportPositions.get(memberKey) !== position; + }); + let cancelled = false; + let cursor = 0; + const activeClaims = new Map(); + const releaseClaim = (memberKey: string, position: string) => { + // Cleanup may already have released this worker's claim and a newer + // effect may since have claimed the same position. A late completion + // from the cancelled worker must not delete that newer claim. + if (activeClaims.get(memberKey) !== position) return; + activeClaims.delete(memberKey); + if (attemptedImportPositions.peek(memberKey) === position) { + attemptedImportPositions.delete(memberKey); + } + }; + const worker = async () => { + for (;;) { + if (cancelled) return; + const member = pending[cursor]; + cursor += 1; + if (!member) return; + const bareSessionId = member.bareSessionId; + const row = member.row; + const memberKey = `${authIdentityKey}:${row.orgId}:${bareSessionId}`; + const position = `${row.eventsEpoch}:${row.eventsCount}`; + // Claim only work this worker is about to execute. Claiming the full + // filtered batch up front strands entries beyond the worker limit if + // the active workers stop early (for example, on auth refresh failure). + if (attemptedImportPositions.peek(memberKey) === position) continue; + attemptedImportPositions.set(memberKey, position); + activeClaims.set(memberKey, position); try { - const fresh = await ensureFreshSession(auth); - if (!fresh) return; - commitRefreshedAuth(setAuth, auth, fresh); + const fresh = await ensureFreshSession(requestAuth); + if (!fresh) { + releaseClaim(memberKey, position); + failedImportRef.current = true; + return; + } + if ( + cancelled || + org2CloudAuthIdentityKey(fresh) !== authIdentityKey + ) { + releaseClaim(memberKey, position); + return; + } + commitRefreshedAuth(setAuth, requestAuth, fresh); await importRemoteSession({ client: buildCloudSessionFetchClient(fresh.accessToken), orgId: row.orgId, remoteSession: row, - sourceEndpointUrl: auth.supabaseUrl, + sourceEndpointUrl: requestAuth.supabaseUrl, }); + if (cancelled) { + releaseClaim(memberKey, position); + return; + } + // A successful position remains in attemptedImportPositions; it is + // the no-op guard until the owner's replay position advances. + activeClaims.delete(memberKey); } catch (error) { - // Leave the recorded position: a broken member should not retry in - // a loop on every render. The next push (new epoch/count) no longer - // matches the stored value, so it is retried then. + releaseClaim(memberKey, position); + failedImportRef.current = true; log.warn( `background family import failed for ${bareSessionId}`, error ); } - })(); - } - }, [family, loadedBareSessionIds, anchorBareSessionId, auth, setAuth]); + } + }; + void Promise.all( + Array.from( + { length: Math.min(MAX_FAMILY_IMPORT_CONCURRENCY, pending.length) }, + worker + ) + ).catch((error: unknown) => { + for (const [memberKey, position] of activeClaims) { + releaseClaim(memberKey, position); + } + if (!cancelled) failedImportRef.current = true; + log.error("Family import worker failed", error); + }); + return () => { + cancelled = true; + for (const [memberKey, position] of activeClaims) { + releaseClaim(memberKey, position); + } + }; + }, [ + family, + loadedBareSessionIds, + anchorBareSessionId, + auth, + authIdentityKey, + failedImportRetryVersion, + setAuth, + ]); } diff --git a/src/features/Org2Cloud/SessionConversation/usePinnedSession.ts b/src/features/Org2Cloud/SessionConversation/usePinnedSession.ts deleted file mode 100644 index 3134bf6161..0000000000 --- a/src/features/Org2Cloud/SessionConversation/usePinnedSession.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useAtomValue } from "jotai"; -import { useState } from "react"; - -import type { Session } from "@src/store/session"; -import { sessionByIdAtom } from "@src/store/session"; - -interface PinnedSessionState { - id: string; - session: Session; -} - -/** - * `sessionByIdAtom` resident-row lookup with a per-view pin: sidebar roster - * refreshes replace the sessions store wholesale, so a row opened from a - * non-roster source (imported replay copy, external-history page beyond the - * loaded window) can vanish from the atom seconds after opening. Identity - * metadata (importedFrom, forkedFrom, org tags) must not flicker away with - * it — the conversation surface keys its comments target and family anchor - * on those fields. The pin holds the last resident row for the SAME session - * id and releases as soon as the view moves to another session. - */ -export function usePinnedSession(sessionId: string): Session | undefined { - const live = useAtomValue(sessionByIdAtom(sessionId)) as Session | undefined; - const [pinned, setPinned] = useState(null); - - if (live && (pinned?.session !== live || pinned.id !== sessionId)) { - setPinned({ id: sessionId, session: live }); - } else if (!live && pinned && pinned.id !== sessionId) { - setPinned(null); - } - - if (live) return live; - return pinned?.id === sessionId ? pinned.session : undefined; -} diff --git a/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.test.ts b/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.test.ts index 83807daef7..d20d68d4af 100644 --- a/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.test.ts +++ b/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.test.ts @@ -44,8 +44,23 @@ describe("cloud download control atoms", () => { store.set(setCloudDownloadPendingPlayAtom, { localSessionId: "imported-session-abc", entry: { + authIdentityKey: "https://cloud.example.test|user-1", rowId: "row-1", orgId: "org-1", + sourceSession: { + id: "row-1", + orgId: "org-1", + ownerMemberId: "member-1", + ownerUserId: "user-1", + ownerDisplayName: "Ada", + ownerIdentityKind: "human", + sourceSessionId: "session-1", + title: "Shared session", + eventsEpoch: 1, + eventsFrozenSeq: 4, + eventsCount: 8, + eventsTailHash: "tail", + }, iconId: "codex", pendingEvents: 4450, etaMs: 17_000, diff --git a/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.ts b/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.ts index f0eb4fb987..537af49da0 100644 --- a/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.ts +++ b/src/features/Org2Cloud/cloudSessionDownloadControlAtoms.ts @@ -18,6 +18,8 @@ */ import { atom } from "jotai"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + export interface CloudPausedDownloadCursor { epoch: number; seq: number; @@ -64,8 +66,12 @@ export const clearCloudPausedDownloadAtom = atom( clearCloudPausedDownloadAtom.debugLabel = "org2cloud/clearPausedDownload"; export interface CloudPendingPlay { + /** Endpoint + account that authorized the source row. */ + authIdentityKey: string; rowId: string; orgId: string; + /** Authoritative source identity before the local replay row exists. */ + sourceSession: RemoteTeammateSessionMetadata; /** Canonical source icon shown before a local replay row exists. */ iconId: string; /** Safe remote workspace/branch labels retained until a local row exists. */ diff --git a/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.test.ts b/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.test.ts index f6b2e116da..da38f0c13b 100644 --- a/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.test.ts +++ b/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.test.ts @@ -16,6 +16,7 @@ function progress( overrides: Partial = {} ): CloudSessionDownloadProgress { return { + authIdentityKey: "https://cloud.example.test|user-1", rowId: "org:owner:session", orgId: "org", loadedEvents: 500, @@ -75,6 +76,7 @@ describe("createThrottledProgressReporter", () => { ) => ({ localSessionId: "imported-session-abc", progress: { + authIdentityKey: "https://cloud.example.test|user-1", rowId: "row-1", orgId: "org-1", loadedEvents, @@ -150,6 +152,7 @@ describe("completeCloudDownloadProgressWithLinger", () => { store.set(upsertCloudSessionDownloadProgressAtom, { localSessionId: "imported-session-abc", progress: { + authIdentityKey: "https://cloud.example.test|user-1", rowId: "row-1", orgId: "org-1", loadedEvents: 4000, diff --git a/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.ts b/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.ts index b065d7e3b3..be8249caf8 100644 --- a/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.ts +++ b/src/features/Org2Cloud/cloudSessionDownloadProgressAtom.ts @@ -12,6 +12,7 @@ */ import { atom, type createStore } from "jotai"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { formatDurationCompact } from "@src/util/time/formatDuration"; import type { @@ -20,9 +21,13 @@ import type { } from "./cloudSessionDownloadControlAtoms"; export interface CloudSessionDownloadProgress { + /** Endpoint + account that authorized the source row and transfer. */ + authIdentityKey: string; /** Remote row id (`RemoteTeammateSessionMetadata.id`) this download serves. */ rowId: string; orgId: string; + /** Source identity captured before the local replay row is materialized. */ + sourceSession?: RemoteTeammateSessionMetadata; /** Immutable remote labels copied from the source row for pre-import UI. */ sessionEnvironment?: CloudSessionEnvironmentIdentity; /** Immutable source-owner identity copied for the pre-import rail. */ diff --git a/src/features/Org2Cloud/cloudSessionReplayLifecycle.test.ts b/src/features/Org2Cloud/cloudSessionReplayLifecycle.test.ts index 8cf0b93258..4d11339d0e 100644 --- a/src/features/Org2Cloud/cloudSessionReplayLifecycle.test.ts +++ b/src/features/Org2Cloud/cloudSessionReplayLifecycle.test.ts @@ -6,6 +6,29 @@ import { runImmediateCloudSessionReplay, } from "./cloudSessionReplayLifecycle"; +const REMOTE_SESSION = { + id: "remote-row-1", + orgId: "org-1", + ownerMemberId: "member-ada", + ownerUserId: "user-ada", + ownerDisplayName: "Ada Lovelace", + ownerAvatarUrl: "https://example.com/ada.png", + ownerIdentityKind: "human", + sourceSessionId: "code-session-1", + title: "Portable runtime audit", + origin: { kind: "external_history", source: "codex_app" }, + repoScopeKey: "github.com/acme/ORGII.git", + branch: "develop", + baseBranch: "main", + worktreeBranch: "agent/session-1", + cliAgentType: "codex", + model: "gpt-5.6-sol", + eventsEpoch: 1, + eventsFrozenSeq: 42, + eventsCount: 953, + eventsTailHash: "tail-hash", +} as const; + function deferred() { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; @@ -81,25 +104,18 @@ describe("buildCloudPendingPlayEntry", () => { it("preserves the remote row and source brand before local import", () => { expect( buildCloudPendingPlayEntry({ - remoteSession: { - id: "remote-row-1", - origin: { kind: "external_history", source: "codex_app" }, - repoScopeKey: "github.com/acme/ORGII.git", - branch: "develop", - baseBranch: "main", - worktreeBranch: "agent/session-1", - ownerUserId: "user-ada", - ownerDisplayName: "Ada Lovelace", - ownerAvatarUrl: "https://example.com/ada.png", - }, + remoteSession: REMOTE_SESSION, + authIdentityKey: "https://cloud.example.test|user-1", orgId: "org-1", pendingEvents: 953, etaMs: 20_000, kind: "replay", }) ).toEqual({ + authIdentityKey: "https://cloud.example.test|user-1", rowId: "remote-row-1", orgId: "org-1", + sourceSession: REMOTE_SESSION, iconId: "codex", sessionEnvironment: { repoName: "ORGII", diff --git a/src/features/Org2Cloud/cloudSessionReplayLifecycle.ts b/src/features/Org2Cloud/cloudSessionReplayLifecycle.ts index baf6c04cb9..5067c04882 100644 --- a/src/features/Org2Cloud/cloudSessionReplayLifecycle.ts +++ b/src/features/Org2Cloud/cloudSessionReplayLifecycle.ts @@ -12,6 +12,7 @@ type CloudSessionPresentationInput = Partial< Pick< RemoteTeammateSessionMetadata, | "sourceSessionId" + | "forkedFrom" | "cliAgentType" | "agentDisplayName" | "agentDefinitionId" @@ -78,12 +79,14 @@ export function resolveCloudSessionReplayIconId( */ export function buildCloudPendingPlayEntry({ remoteSession, + authIdentityKey, orgId, pendingEvents, etaMs, kind, }: { - remoteSession: CloudSessionPresentationInput & { id: string }; + remoteSession: RemoteTeammateSessionMetadata; + authIdentityKey: string; orgId: string; pendingEvents: number; etaMs: number; @@ -91,8 +94,10 @@ export function buildCloudPendingPlayEntry({ }): CloudPendingPlay { const sessionOwner = resolveCloudSessionOwnerIdentity(remoteSession); return { + authIdentityKey, rowId: remoteSession.id, orgId, + sourceSession: remoteSession, iconId: resolveCloudSessionReplayIconId(remoteSession), sessionEnvironment: resolveCloudSessionEnvironmentIdentity(remoteSession), ...(sessionOwner ? { sessionOwner } : {}), diff --git a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts index 2fc9279916..5d0a3b12d0 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts @@ -105,6 +105,8 @@ const CONFIRMED_MEMBER_RUNTIME_TRUE: CloudCapabilitiesProbeResult = { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }, confirmed: true, }; @@ -364,6 +366,8 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }, confirmed: true, } satisfies CloudCapabilitiesProbeResult); @@ -399,6 +403,8 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }, confirmed: false, } satisfies CloudCapabilitiesProbeResult); @@ -447,6 +453,8 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }, confirmed: false, } satisfies CloudCapabilitiesProbeResult) diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index 55b82b6c77..4ccf1f8d37 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -36,6 +36,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, @@ -49,6 +51,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -70,6 +74,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -84,11 +90,18 @@ describe("getCloudCapabilities", () => { orgChannelMessages: true, orgChannelMessagesIdempotency: true, conversationEvents: false, + conversationEventsIdempotency: false, }); const capabilities = await getCloudCapabilities("jwt-1"); expect(capabilities.orgChannelMessagesIdempotency).toBe(true); }); + it("carries the 0028 turn-coordination flag through the wire rebuild", async () => { + rawMock.mockResolvedValueOnce({ conversationTurnCoordination: true }); + const capabilities = await getCloudCapabilities("jwt-1"); + expect(capabilities.conversationTurnCoordination).toBe(true); + }); + it("parses the 0007 homeEndpoints flag", async () => { rawMock.mockResolvedValueOnce({ broadcastSignals: true, @@ -107,6 +120,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -129,6 +144,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -145,6 +162,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, @@ -158,6 +177,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -175,6 +196,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ @@ -189,6 +212,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(rawMock).toHaveBeenCalledTimes(2); }); @@ -211,6 +236,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, @@ -224,6 +251,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -250,6 +279,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(await second).toEqual({ broadcastSignals: true, @@ -263,6 +294,8 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -285,6 +318,8 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -301,6 +336,7 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); const result = await getCloudCapabilitiesConfirmed("jwt-1"); expect(result.confirmed).toBe(true); @@ -323,6 +359,8 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -357,6 +395,8 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); // A cached hit is, by definition, a confirmed read — no second RPC. const result = await getCloudCapabilitiesConfirmed("jwt-1"); diff --git a/src/features/Org2Cloud/org2CloudCapabilities.ts b/src/features/Org2Cloud/org2CloudCapabilities.ts index 2a98c7cdd0..4c75b8faf9 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -28,6 +28,8 @@ const CloudCapabilitiesWireSchema = z.object({ orgChannelMessages: z.boolean().nullish().catch(undefined), orgChannelMessagesIdempotency: z.boolean().nullish().catch(undefined), conversationEvents: z.boolean().nullish().catch(undefined), + conversationEventsIdempotency: z.boolean().nullish().catch(undefined), + conversationTurnCoordination: z.boolean().nullish().catch(undefined), }); export interface CloudCapabilities { @@ -49,6 +51,10 @@ export interface CloudCapabilities { orgChannelMessagesIdempotency: boolean; /** 0024 multi-writer conversation-events plane (push/list RPCs). */ conversationEvents: boolean; + /** 0026 source-event receipts make ambiguous publication retry-safe. */ + conversationEventsIdempotency: boolean; + /** 0028 per-root FIFO admission and renewable device leases. */ + conversationTurnCoordination: boolean; } const LEGACY_CAPABILITIES: CloudCapabilities = { @@ -63,6 +69,8 @@ const LEGACY_CAPABILITIES: CloudCapabilities = { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }; export interface CloudCapabilitiesProbeResult { @@ -116,6 +124,10 @@ async function probeCloudCapabilities( orgChannelMessagesIdempotency: parsed.data.orgChannelMessagesIdempotency ?? false, conversationEvents: parsed.data.conversationEvents ?? false, + conversationEventsIdempotency: + parsed.data.conversationEventsIdempotency ?? false, + conversationTurnCoordination: + parsed.data.conversationTurnCoordination ?? false, }; capabilitiesByEndpoint.set(endpointKey, capabilities); return { capabilities, confirmed: true }; diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts index d65415858c..044d9d18c9 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.test.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.test.ts @@ -198,13 +198,20 @@ describe("addSessionComment", () => { }); it("uses the retry-safe RPC when a stable client message key is present", async () => { - fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); + fetchMock.mockResolvedValueOnce( + jsonResponse({ + comment: { + ...WIRE_COMMENT, + mentionedUserIds: ["user-2"], + }, + }) + ); await addSessionComment("jwt-1", { orgId: "org-1", sessionId: "sess-1", body: "Please review", - clientMessageKey: "agent-report:turn-1:c-1", + clientMessageKey: "optimistic-comment-1", mentionedUserIds: ["user-2", "user-2"], }); @@ -212,27 +219,48 @@ describe("addSessionComment", () => { `${ORG2_CLOUD_OFFICIAL_SUPABASE_URL}/rest/v1/rpc/cloud_add_session_comment_idempotent` ); expect(lastBody()).toMatchObject({ - p_client_message_key: "agent-report:turn-1:c-1", + p_client_message_key: "optimistic-comment-1", p_replace_existing: false, p_mentioned_user_ids: ["user-2"], }); }); - it("maps a mismatched retry key into a coded conflict", async () => { + it("fails closed when the retry-safe RPC has not deployed", async () => { fetchMock.mockResolvedValueOnce( - jsonResponse({ message: "ORG2_IDEMPOTENCY_CONFLICT" }, 400) + jsonResponse({ message: "Could not find the function" }, 404) ); - const error = await addSessionComment("jwt-1", { + await expect( + addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "hello", + clientMessageKey: "optimistic-comment-1", + }) + ).rejects.toThrow("Could not find the function"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("marks an edited retry explicitly without changing its stable key", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ comment: WIRE_COMMENT })); + + await addSessionComment("jwt-1", { orgId: "org-1", sessionId: "sess-1", - body: "changed payload", - clientMessageKey: "agent-report:turn-1:c-1", - }).catch((caught: unknown) => caught); + body: "edited body", + clientMessageKey: "optimistic-comment-1", + replaceExisting: true, + expectedBody: "original body", + expectedMentionedUserIds: ["user-2"], + }); - expect(isOrg2CommentErrorCode(error, "ORG2_IDEMPOTENCY_CONFLICT")).toBe( - true - ); + expect(lastBody()).toMatchObject({ + p_client_message_key: "optimistic-comment-1", + p_replace_existing: true, + p_expected_body: "original body", + p_expected_mentioned_user_ids: ["user-2"], + }); }); it("sends JWT bearer + Content-Profile", async () => { @@ -279,6 +307,21 @@ describe("addSessionComment", () => { }).catch((caught: unknown) => caught); expect(isOrg2CommentErrorCode(error, "ORG2_QUOTA_EXCEEDED")).toBe(true); }); + + it("maps a mismatched retry key into a coded conflict", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ message: "ORG2_IDEMPOTENCY_CONFLICT" }, 400) + ); + const error = await addSessionComment("jwt-1", { + orgId: "org-1", + sessionId: "sess-1", + body: "changed payload", + clientMessageKey: "optimistic-comment-1", + }).catch((caught: unknown) => caught); + expect(isOrg2CommentErrorCode(error, "ORG2_IDEMPOTENCY_CONFLICT")).toBe( + true + ); + }); }); describe("editSessionComment", () => { diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts index d0e1ec1f31..d7b007be15 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -25,11 +25,20 @@ import { z } from "zod/v4"; import { createLogger } from "@src/hooks/logger"; -import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; +import { + type CloudEndpoint, + ORG2_CLOUD_POSTGREST_SCHEMA, + getCloudEndpoint, +} from "./config"; import { fetchWithTransportRetry } from "./org2CloudFetchRetry"; const log = createLogger("Org2CloudCommentsClient"); +/** RPC-enforced body bound (0014 SIZE note) — mirrored in composers. */ +export const CLOUD_COMMENT_MAX_BODY_LENGTH = 4000; +/** RPC-enforced explicit-recipient bound (0028) — mirrored in Team Chat. */ +export const CLOUD_COMMENT_MAX_MENTIONED_USER_IDS = 50; + // --------------------------------------------------------------------------- // Error model // --------------------------------------------------------------------------- @@ -83,9 +92,9 @@ export function isOrg2CommentErrorCode( async function callCommentRpc( functionName: string, accessToken: string, - body: Record + body: Record, + endpoint: Pick = getCloudEndpoint() ): Promise { - const endpoint = getCloudEndpoint(); const response = await fetchWithTransportRetry( `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, { @@ -191,7 +200,19 @@ const CloudSessionCommentWireSchema = z.object({ export type CloudSessionComment = z.output< typeof CloudSessionCommentWireSchema ->; +> & { + /** Client-only delivery state for an optimistic Team Chat row. */ + clientDeliveryStatus?: "pending" | "sent" | "failed"; + /** Client-only error detail retained with a failed outgoing row. */ + clientDeliveryError?: string; + /** + * Original server-side values for an edited retry's CAS. They deliberately + * survive later failed edits; using the latest optimistic body here would + * make every subsequent retry conflict forever. + */ + clientRetryExpectedBody?: string; + clientRetryExpectedMentionedUserIds?: string[]; +}; const AddCommentResultSchema = z.object({ comment: CloudSessionCommentWireSchema, @@ -285,14 +306,63 @@ export interface AddSessionCommentInput { * null keeps the comment counted on the source plane. */ originSessionId?: string | null; - /** Stable retry key; identical retries return the same durable comment. */ + /** + * Stable client-generated key reused by delivery retries. A matching retry + * returns the original durable row; reusing the key for different content + * fails closed server-side. + */ clientMessageKey?: string; - /** Explicit edited-retry intent for compare-and-swap replacement. */ + /** Explicit edited-retry intent; never inferred from a payload mismatch. */ replaceExisting?: boolean; + /** Original failed-row body used as the edited retry compare-and-swap base. */ expectedBody?: string; + /** Original failed-row mentions used as the edited retry compare-and-swap base. */ expectedMentionedUserIds?: string[]; } +function isMissingCommentRpc(error: unknown): boolean { + return ( + error instanceof Org2CloudCommentError && + error.status === 404 && + /could not find the function/i.test(error.message) + ); +} + +async function callLegacyAddSessionComment( + accessToken: string, + body: Record, + hasMentions: boolean +): Promise { + try { + return await callCommentRpc( + hasMentions + ? "cloud_add_session_comment_with_mentions" + : "cloud_add_session_comment", + accessToken, + body + ); + } catch (error) { + // Graceful degradation to a pre-origin backend: PostgREST answers 404 + // when no function matches the argument set, so drop the additive origin + // arg and retry once. The comment still posts (counted on the source + // plane); per-fork attribution just waits for the migration. + if ( + "p_origin_session_id" in body && + !hasMentions && + isMissingCommentRpc(error) + ) { + const compatibleBody = { ...body }; + delete compatibleBody.p_origin_session_id; + return callCommentRpc( + "cloud_add_session_comment", + accessToken, + compatibleBody + ); + } + throw error; + } +} + /** * Any member who can read the session. Returns the created comment in the * listing wire shape, ready for optimistic insertion. @@ -319,7 +389,7 @@ export async function addSessionComment( const mentionedUserIds = [ ...new Set(input.mentionedUserIds?.filter(Boolean) ?? []), ]; - if (mentionedUserIds.length > 50) { + if (mentionedUserIds.length > CLOUD_COMMENT_MAX_MENTIONED_USER_IDS) { throw new Org2CloudCommentError("ORG2_VALIDATION"); } if (mentionedUserIds.length > 0) { @@ -327,9 +397,10 @@ export async function addSessionComment( } let payload: unknown; if (input.clientMessageKey) { - // Never fall back to an unkeyed write: a lost response would become a - // duplicate comment. The visible failed row remains retryable until the - // idempotent Cloud RPC is deployed. + // Fail closed if the server has not deployed 0028 yet. Falling back to + // an unkeyed write would turn a lost response into a duplicate message. + // Deployment therefore remains server-first; the visible optimistic row + // stays failed/retryable until the idempotent RPC is available. payload = await callCommentRpc( "cloud_add_session_comment_idempotent", accessToken, @@ -342,36 +413,12 @@ export async function addSessionComment( p_mentioned_user_ids: mentionedUserIds, } ); - return AddCommentResultSchema.parse(payload).comment; - } - try { - payload = await callCommentRpc( - mentionedUserIds.length > 0 - ? "cloud_add_session_comment_with_mentions" - : "cloud_add_session_comment", + } else { + payload = await callLegacyAddSessionComment( accessToken, - body + body, + mentionedUserIds.length > 0 ); - } catch (error) { - // Graceful degradation to a pre-origin backend: PostgREST answers 404 - // when no function matches the argument set, so drop the additive origin - // arg and retry once. The comment still posts (counted on the source - // plane); per-fork attribution just waits for the migration. - if ( - "p_origin_session_id" in body && - mentionedUserIds.length === 0 && - error instanceof Org2CloudCommentError && - error.status === 404 - ) { - delete body.p_origin_session_id; - payload = await callCommentRpc( - "cloud_add_session_comment", - accessToken, - body - ); - } else { - throw error; - } } return AddCommentResultSchema.parse(payload).comment; } @@ -479,9 +526,13 @@ export async function listSessionComments( accessToken: string, orgId: string, sessionId: string, - options?: { since?: string } + options?: { + since?: string; + endpoint?: Pick; + } ): Promise { - const endpointUrl = getCloudEndpoint().supabaseUrl; + const endpoint = options?.endpoint ?? getCloudEndpoint(); + const endpointUrl = endpoint.supabaseUrl; const since = options?.since !== undefined && !commentsDeltaUnsupportedEndpoints.has(endpointUrl) @@ -496,7 +547,8 @@ export async function listSessionComments( p_org_id: orgId, p_session_id: sessionId, p_since: since, - } + }, + endpoint ); const result = ListCommentsResultSchema.parse(payload); return { @@ -515,7 +567,8 @@ export async function listSessionComments( { p_org_id: orgId, p_session_id: sessionId, - } + }, + endpoint ); const result = ListCommentsResultSchema.parse(payload); return { diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts new file mode 100644 index 0000000000..7b98cbe906 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CLOUD_CONVERSATION_MAX_EVENT_BYTES, + CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES, + type CloudConversationEvent, + type ListConversationEventsResult, + Org2CloudConversationError, + boundConversationEventForPush, + conversationEventsForPush, + decodeConversationEventChunks, + listConversationEvents, + pushConversationEvents, +} from "./org2CloudConversationEventsClient"; + +function event(displayText: string): SessionEvent { + return { + id: "event-1", + chunk_id: "event-1", + sessionId: "session-1", + createdAt: "2026-08-26T00:00:00.000Z", + functionName: "user_message", + uiCanonical: "user_message", + actionType: "raw", + args: {}, + result: { message: { role: "user", content: displayText } }, + source: "user", + displayText, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +describe("boundConversationEventForPush", () => { + it("preserves exact events inside the wire limit", () => { + const input = event("hello"); + expect(boundConversationEventForPush(input)).toBe(input); + }); + + it("fails closed instead of truncating native conversation history", () => { + const input = event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)); + expect(() => boundConversationEventForPush(input)).toThrow( + Org2CloudConversationError + ); + expect(() => boundConversationEventForPush(input)).toThrow( + "ORG2_CONVERSATION_EVENT_TOO_LARGE" + ); + }); +}); + +function chunkRows(chunks: readonly SessionEvent[]): CloudConversationEvent[] { + return chunks.map((chunk, index) => ({ + id: `wire-${index}`, + rootSessionId: "session-1", + authorUserId: "user-1", + turnId: "turn-1", + seq: index + 1, + event: chunk, + createdAt: "2026-08-26T00:00:00.000Z", + })); +} + +function patchChunkMetadata( + row: CloudConversationEvent, + patch: Record +): CloudConversationEvent { + const current = row.event.args.conversationEventChunk as Record< + string, + unknown + >; + return { + ...row, + event: { + ...row.event, + args: { + ...row.event.args, + conversationEventChunk: { ...current, ...patch }, + }, + }, + }; +} + +describe("conversation event chunk codec", () => { + it("round-trips a valid oversized SessionEvent", async () => { + const input = event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)); + const rows = chunkRows(await conversationEventsForPush(input)); + + await expect(decodeConversationEventChunks(rows)).resolves.toEqual([ + expect.objectContaining({ id: input.id, event: input }), + ]); + }); + + it("rejects duplicate or missing chunk indices", async () => { + const chunks = await conversationEventsForPush( + event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)) + ); + const rows = chunkRows(chunks); + rows[1] = patchChunkMetadata(rows[1], { chunkIndex: 0 }); + + await expect(decodeConversationEventChunks(rows)).rejects.toThrow( + "duplicate or missing chunk indices" + ); + }); + + it("rejects inconsistent metadata within one logical event", async () => { + const chunks = await conversationEventsForPush( + event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)) + ); + const rows = chunkRows(chunks); + const metadata = rows[1].event.args.conversationEventChunk as { + byteLength: number; + }; + rows[1] = patchChunkMetadata(rows[1], { + byteLength: metadata.byteLength - 1, + }); + + await expect(decodeConversationEventChunks(rows)).rejects.toThrow( + "inconsistent conversation event chunk metadata" + ); + }); + + it("quarantines an oversized declared event instead of allocating its buffer", async () => { + const chunks = await conversationEventsForPush( + event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)) + ); + const rows = chunkRows(chunks).map((row) => + patchChunkMetadata(row, { + byteLength: CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES + 1, + }) + ); + + await expect(decodeConversationEventChunks(rows)).resolves.toEqual([]); + }); + + it("quarantines a malformed chunk envelope without dropping its neighbours", async () => { + const chunks = await conversationEventsForPush( + event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)) + ); + const rows = chunkRows(chunks).map((row) => + patchChunkMetadata(row, { sha256: "not-a-digest" }) + ); + const ordinary: CloudConversationEvent = { + id: "wire-plain", + rootSessionId: "session-1", + authorUserId: "user-1", + turnId: "turn-1", + seq: 99, + event: event("readable"), + createdAt: "2026-08-26T00:00:00.000Z", + }; + + await expect( + decodeConversationEventChunks([...rows, ordinary]) + ).resolves.toEqual([ordinary]); + }); + + it("rejects an oversized encoded part before base64 decoding", async () => { + const chunks = await conversationEventsForPush( + event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)) + ); + const rows = chunkRows(chunks); + rows[0] = { + ...rows[0], + event: { + ...rows[0].event, + result: { data: "A".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES) }, + }, + }; + + await expect(decodeConversationEventChunks(rows)).rejects.toThrow( + "encoded part limit" + ); + }); +}); + +describe("conversation event wire validation", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubListing(events: readonly unknown[], hasMore = false): void { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ events, hasMore }), { status: 200 }) + ) + ); + } + + function wireRow(seq: number, payload: unknown): unknown { + return { + id: `wire-${seq}`, + rootSessionId: "session-1", + authorUserId: "user-1", + turnId: "turn-1", + seq, + event: payload, + createdAt: "2026-08-26T00:00:00.000Z", + }; + } + + function list(): Promise { + return listConversationEvents( + "token", + { orgId: "org-1", rootSessionId: "session-1" }, + { supabaseUrl: "https://cloud.invalid", anonKey: "anon" } + ); + } + + it("quarantines a durable row whose event is not a canonical SessionEvent", async () => { + const readable = event("readable"); + stubListing([wireRow(1, { id: "poison" }), wireRow(2, readable)]); + + await expect(list()).resolves.toEqual({ + events: [expect.objectContaining({ id: "wire-2", event: readable })], + hasMore: false, + lastSeq: 2, + quarantined: 1, + }); + }); + + it("advances the wire cursor across a page of only poisoned rows", async () => { + stubListing([wireRow(7, { id: "poison" }), wireRow(8, null)], true); + + await expect(list()).resolves.toEqual({ + events: [], + hasMore: true, + lastSeq: 8, + quarantined: 2, + }); + }); + + it("still fails closed when the listing envelope itself is unreadable", async () => { + stubListing([{ id: "wire-1", seq: "not-a-number" }]); + + await expect(list()).rejects.toThrow( + "unparseable cloud_list_conversation_events payload" + ); + }); + + it("bounds a conversation RPC whose fetch never settles", async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})) + ); + const outcome = pushConversationEvents( + "token", + { + orgId: "org-1", + rootSessionId: "session-1", + turnId: "turn-1", + events: [event("hello")], + }, + { supabaseUrl: "https://cloud.invalid", anonKey: "anon" } + ).catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(15_000); + + await expect(outcome).resolves.toMatchObject({ name: "TimeoutError" }); + const signal = vi.mocked(fetch).mock.calls[0]?.[1]?.signal; + expect(signal?.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("bounds response-body decoding under the same RPC deadline", async () => { + vi.useFakeTimers(); + try { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: () => new Promise(() => {}), + })) + ); + const outcome = list().catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(15_000); + + await expect(outcome).resolves.toMatchObject({ name: "TimeoutError" }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts index 2e377f075a..0a5367942d 100644 --- a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts @@ -1,6 +1,6 @@ /** - * Managed-cloud conversation-events client (0024 plane; design: - * docs/conversation-events-plane-design-2026-08-21.md). + * Managed-cloud conversation-events client for the existing Team Session + * event plane. * * A conversation — keyed by `(orgId, rootSessionId)` — accepts turn events * from ANY org member, each stamped with its author. This is the wire that @@ -12,22 +12,49 @@ * Wrappers follow the `org2CloudCommentsClient` idiom: raw fetch, JWT * Bearer + `Content-Profile: org2_cloud`, whole-token `ORG2_*` code * extraction, throwing typed errors. Capability-gated by - * `getCloudCapabilities().conversationEvents` — callers on a pre-0024 - * backend must keep the fork-wire fallback. + * `getCloudCapabilities().conversationEvents`; unsupported endpoints fail + * closed instead of silently changing the conversation identity. */ import { z } from "zod/v4"; +import { SessionEventSchema } from "@src/engines/SessionCore/core/schemas"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { createLogger } from "@src/hooks/logger"; -import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; -import { fetchWithTransportRetry } from "./org2CloudFetchRetry"; +import { + type CloudEndpoint, + ORG2_CLOUD_POSTGREST_SCHEMA, + getCloudEndpoint, +} from "./config"; +import { + fetchWithTransportRetry, + runCloudRequestWithTimeout, +} from "./org2CloudFetchRetry"; +import { sha256Hex } from "./org2CloudOrgManagement"; const log = createLogger("Org2CloudConversationEvents"); +// Core's runtime schema deliberately retains forward-compatible fields with +// `catchall`; after that validation boundary the application-wide contract is +// the richer `SessionEvent` interface (for example typed extracted payloads). +const CloudSessionEventSchema = SessionEventSchema.transform( + (event) => event as SessionEvent +); + /** RPC-enforced bounds (0024) — mirrored before the wire. */ export const CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH = 200; export const CLOUD_CONVERSATION_MAX_EVENT_BYTES = 65536; +const CLOUD_CONVERSATION_CHUNK_DATA_BYTES = 32 * 1024; +/** Matches the native materializer's hard transcript ingress ceiling. */ +export const CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES = 64 * 1024 * 1024; +const CLOUD_CONVERSATION_MAX_CHUNKS_PER_EVENT = Math.ceil( + CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES / + CLOUD_CONVERSATION_CHUNK_DATA_BYTES +); +const CLOUD_CONVERSATION_MAX_CHUNK_BASE64_CHARS = + 4 * Math.ceil(CLOUD_CONVERSATION_CHUNK_DATA_BYTES / 3); +const CONVERSATION_EVENT_CHUNK_FUNCTION = "conversation_event_chunk"; +const CONVERSATION_EVENTS_RPC_TIMEOUT_MS = 15_000; export const ORG2_CONVERSATION_ERROR_CODES = [ "ORG2_VALIDATION", @@ -45,11 +72,18 @@ export type Org2ConversationErrorCode = export class Org2CloudConversationError extends Error { readonly code: Org2ConversationErrorCode | null; readonly status: number | null; + /** True only for a typed incomplete-read state that can converge later. */ + readonly recoveryPending: boolean; - constructor(message: string, status: number | null = null) { + constructor( + message: string, + status: number | null = null, + options: { recoveryPending?: boolean } = {} + ) { super(message); this.name = "Org2CloudConversationError"; this.status = status; + this.recoveryPending = options.recoveryPending === true; const tokens = message.match(/\bORG2_[A-Z_]+\b/g) ?? []; this.code = (tokens.find((token) => @@ -61,37 +95,40 @@ export class Org2CloudConversationError extends Error { async function callConversationRpc( functionName: string, accessToken: string, - body: Record + body: Record, + endpoint: Pick = getCloudEndpoint() ): Promise { - const endpoint = getCloudEndpoint(); - const response = await fetchWithTransportRetry( - `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, - { - method: "POST", - headers: { - apikey: endpoint.anonKey, - authorization: `Bearer ${accessToken}`, - "content-type": "application/json", - "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, - }, - body: JSON.stringify(body), + return runCloudRequestWithTimeout(async (signal) => { + const response = await fetchWithTransportRetry( + `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, + { + method: "POST", + headers: { + apikey: endpoint.anonKey, + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, + }, + body: JSON.stringify(body), + signal, + } + ); + const text = await response.text(); + let payload: unknown = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; } - ); - const text = await response.text(); - let payload: unknown = null; - try { - payload = text ? JSON.parse(text) : null; - } catch { - payload = null; - } - if (!response.ok) { - const message = - payload && typeof payload === "object" && "message" in payload - ? String((payload as { message: unknown }).message) - : `org2_cloud rpc ${functionName} failed with ${response.status}`; - throw new Org2CloudConversationError(message, response.status); - } - return payload; + if (!response.ok) { + const message = + payload && typeof payload === "object" && "message" in payload + ? String((payload as { message: unknown }).message) + : `org2_cloud rpc ${functionName} failed with ${response.status}`; + throw new Org2CloudConversationError(message, response.status); + } + return payload; + }, CONVERSATION_EVENTS_RPC_TIMEOUT_MS); } const CloudConversationEventWireSchema = z.object({ @@ -109,8 +146,14 @@ const CloudConversationEventWireSchema = z.object({ .transform((value) => value ?? undefined) .optional(), turnId: z.string(), - seq: z.number(), - /** Normalized SessionEvent payload, rendered natively by the stream. */ + seq: z.number().int().safe().nonnegative(), + /** + * Normalized SessionEvent payload, rendered natively by the stream. The + * envelope columns are server-owned and stay strict; the payload was + * written by whichever app version pushed the turn, so rows that no longer + * satisfy the schema are already durable and are quarantined per row + * instead of failing the page for every reader. + */ event: z.unknown(), createdAt: z.string(), }); @@ -133,6 +176,9 @@ const PushConversationEventsWireSchema = z.object({ export interface ListConversationEventsResult { events: CloudConversationEvent[]; hasMore: boolean; + /** Highest wire seq in the page, quarantined rows included. */ + lastSeq: number; + quarantined: number; } export async function listConversationEvents( @@ -142,7 +188,8 @@ export async function listConversationEvents( rootSessionId: string; afterSeq?: number; limit?: number; - } + }, + endpoint?: Pick ): Promise { const payload = await callConversationRpc( "cloud_list_conversation_events", @@ -152,7 +199,8 @@ export async function listConversationEvents( p_root_session_id: params.rootSessionId, p_after_seq: params.afterSeq ?? 0, p_limit: params.limit ?? 500, - } + }, + endpoint ); const parsed = ListConversationEventsWireSchema.safeParse(payload); if (!parsed.success) { @@ -161,9 +209,30 @@ export async function listConversationEvents( "unparseable cloud_list_conversation_events payload" ); } + const events: CloudConversationEvent[] = []; + const quarantined: string[] = []; + let lastSeq = params.afterSeq ?? 0; + for (const row of parsed.data.events) { + if (row.seq > lastSeq) lastSeq = row.seq; + const event = CloudSessionEventSchema.safeParse(row.event); + if (!event.success) { + quarantined.push(row.id); + continue; + } + events.push({ ...row, event: event.data }); + } + if (quarantined.length > 0) { + log.warn("quarantined non-conforming conversation rows", { + rootSessionId: params.rootSessionId, + count: quarantined.length, + rowIds: quarantined.slice(0, 10), + }); + } return { - events: parsed.data.events as CloudConversationEvent[], + events, hasMore: parsed.data.hasMore, + lastSeq, + quarantined: quarantined.length, }; } @@ -179,7 +248,8 @@ export async function pushConversationEvents( rootSessionId: string; turnId: string; events: readonly SessionEvent[]; - } + }, + endpoint?: Pick ): Promise { if (params.events.length === 0) { throw new Org2CloudConversationError("ORG2_VALIDATION: empty batch"); @@ -195,7 +265,8 @@ export async function pushConversationEvents( p_root_session_id: params.rootSessionId, p_turn_id: params.turnId, p_events: params.events, - } + }, + endpoint ); const parsed = PushConversationEventsWireSchema.safeParse(payload); if (!parsed.success) { @@ -218,7 +289,8 @@ export async function pushConversationEventsChunked( rootSessionId: string; turnId: string; events: readonly SessionEvent[]; - } + }, + endpoint?: Pick ): Promise { let result: PushConversationEventsResult | null = null; for ( @@ -226,13 +298,17 @@ export async function pushConversationEventsChunked( offset < params.events.length; offset += CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH ) { - result = await pushConversationEvents(accessToken, { - ...params, - events: params.events.slice( - offset, - offset + CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH - ), - }); + result = await pushConversationEvents( + accessToken, + { + ...params, + events: params.events.slice( + offset, + offset + CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH + ), + }, + endpoint + ); } if (!result) { throw new Org2CloudConversationError("ORG2_VALIDATION: empty batch"); @@ -241,23 +317,297 @@ export async function pushConversationEventsChunked( } /** - * Client-side mirror of the 64KB/event CHECK: oversized display payloads - * are truncated with a marker instead of failing the whole turn. The - * transcript stays honest — the marker names the elision. + * Client-side mirror of the 64KB/event CHECK. A canonical conversation is a + * native-resume source, so silently truncating text/tool/image data would + * create a session that looks continuous while the model received incomplete + * history. Fail closed until the transport has an exact large-payload codec. */ export function boundConversationEventForPush( event: SessionEvent ): SessionEvent { const size = new TextEncoder().encode(JSON.stringify(event)).length; if (size <= CLOUD_CONVERSATION_MAX_EVENT_BYTES) return event; - const truncated: SessionEvent = { - ...event, - args: { conversationTruncated: true }, - result: {}, - payloadRefs: [], - displayText: - event.displayText.slice(0, 4000) + - "\n… [truncated for the shared conversation]", - } as SessionEvent; - return truncated; + throw new Org2CloudConversationError( + `ORG2_CONVERSATION_EVENT_TOO_LARGE: event ${event.id} is ${size} bytes; exact native continuation requires the complete event` + ); +} + +interface ConversationEventChunkMetadata { + version: 1; + sourceEventId: string; + chunkIndex: number; + chunkCount: number; + byteLength: number; + sha256: string; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + // Reject before `atob` allocates attacker-controlled output. One Cloud row + // can contain at most one producer-sized chunk. + if (value.length > CLOUD_CONVERSATION_MAX_CHUNK_BASE64_CHARS) { + throw new Org2CloudConversationError( + "conversation event chunk exceeds the encoded part limit" + ); + } + let binary: string; + try { + binary = atob(value); + } catch { + throw new Org2CloudConversationError( + "conversation event chunk contains invalid base64" + ); + } + if (binary.length > CLOUD_CONVERSATION_CHUNK_DATA_BYTES) { + throw new Org2CloudConversationError( + "conversation event chunk exceeds the decoded part limit" + ); + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +type ConversationChunkRole = + | { kind: "ordinary" } + | { kind: "chunk"; metadata: ConversationEventChunkMetadata } + | { kind: "invalid" }; + +interface ConversationChunkRow { + row: CloudConversationEvent; + metadata: ConversationEventChunkMetadata; +} + +function conversationChunkRole(event: SessionEvent): ConversationChunkRole { + if (event.functionName !== CONVERSATION_EVENT_CHUNK_FUNCTION) { + return { kind: "ordinary" }; + } + const value = event.args?.conversationEventChunk; + if (!value || typeof value !== "object") return { kind: "invalid" }; + const metadata = value as Partial; + if ( + metadata.version !== 1 || + typeof metadata.sourceEventId !== "string" || + metadata.sourceEventId.length === 0 || + !Number.isSafeInteger(metadata.chunkIndex) || + !Number.isSafeInteger(metadata.chunkCount) || + !Number.isSafeInteger(metadata.byteLength) || + typeof metadata.sha256 !== "string" || + !/^[a-f0-9]{64}$/u.test(metadata.sha256) || + (metadata.chunkIndex as number) < 0 || + (metadata.chunkCount as number) < 1 || + (metadata.chunkCount as number) > CLOUD_CONVERSATION_MAX_CHUNKS_PER_EVENT || + (metadata.chunkIndex as number) >= (metadata.chunkCount as number) || + (metadata.byteLength as number) < 1 || + (metadata.byteLength as number) > CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES + ) { + return { kind: "invalid" }; + } + return { + kind: "chunk", + metadata: metadata as ConversationEventChunkMetadata, + }; +} + +/** Exact wire codec for events larger than the server's per-row limit. */ +export async function conversationEventsForPush( + event: SessionEvent +): Promise { + try { + return [boundConversationEventForPush(event)]; + } catch (error) { + if ( + !(error instanceof Org2CloudConversationError) || + error.code !== "ORG2_CONVERSATION_EVENT_TOO_LARGE" + ) { + throw error; + } + } + + const serialized = JSON.stringify(event); + const bytes = new TextEncoder().encode(serialized); + if (bytes.length > CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES) { + throw new Org2CloudConversationError( + `ORG2_CONVERSATION_EVENT_TOO_LARGE: event ${event.id} is ${bytes.length} bytes; logical event limit is ${CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES}` + ); + } + const digest = await sha256Hex(serialized); + const chunkCount = Math.ceil( + bytes.length / CLOUD_CONVERSATION_CHUNK_DATA_BYTES + ); + const chunks: SessionEvent[] = []; + for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) { + const data = bytes.subarray( + chunkIndex * CLOUD_CONVERSATION_CHUNK_DATA_BYTES, + (chunkIndex + 1) * CLOUD_CONVERSATION_CHUNK_DATA_BYTES + ); + const id = `convchunk-${digest}-${chunkIndex}`; + chunks.push( + boundConversationEventForPush({ + id, + chunk_id: id, + sessionId: event.sessionId, + createdAt: event.createdAt, + functionName: CONVERSATION_EVENT_CHUNK_FUNCTION, + uiCanonical: CONVERSATION_EVENT_CHUNK_FUNCTION, + actionType: "raw", + args: { + conversationEventChunk: { + version: 1, + sourceEventId: event.id, + chunkIndex, + chunkCount, + byteLength: bytes.length, + sha256: digest, + } satisfies ConversationEventChunkMetadata, + }, + result: { data: bytesToBase64(data) }, + source: "system", + displayText: "", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + payloadRefs: [], + } as SessionEvent) + ); + } + return chunks; +} + +/** Reassemble and SHA-256 verify complete chunk groups before projection. */ +export async function decodeConversationEventChunks( + rows: readonly CloudConversationEvent[] +): Promise { + const ordinary: CloudConversationEvent[] = []; + const groups = new Map(); + const quarantined: string[] = []; + for (const row of rows) { + const role = conversationChunkRole(row.event); + if (role.kind === "ordinary") { + ordinary.push(row); + continue; + } + if (role.kind === "invalid") { + quarantined.push(row.id); + continue; + } + const metadata = role.metadata; + const key = `${row.rootSessionId}\u001f${row.turnId}\u001f${metadata.sourceEventId}`; + const group = groups.get(key) ?? []; + group.push({ row, metadata }); + groups.set(key, group); + } + if (quarantined.length > 0) { + log.warn("quarantined malformed conversation event chunk rows", { + count: quarantined.length, + rowIds: quarantined.slice(0, 10), + }); + } + for (const group of groups.values()) { + const first = group[0]; + const firstMetadata = first.metadata; + if ( + group.some( + ({ row, metadata }) => + row.rootSessionId !== first.row.rootSessionId || + row.turnId !== first.row.turnId || + metadata.sourceEventId !== firstMetadata.sourceEventId || + metadata.sha256 !== firstMetadata.sha256 || + metadata.chunkCount !== firstMetadata.chunkCount || + metadata.byteLength !== firstMetadata.byteLength + ) + ) { + throw new Org2CloudConversationError( + `inconsistent conversation event chunk metadata for ${firstMetadata.sourceEventId}` + ); + } + if (group.length !== firstMetadata.chunkCount) { + throw new Org2CloudConversationError( + `incomplete conversation event ${firstMetadata.sourceEventId}: ${group.length}/${firstMetadata.chunkCount} chunks`, + null, + { recoveryPending: true } + ); + } + const indices = new Set(group.map(({ metadata }) => metadata.chunkIndex)); + // Every index was range-checked above. With exactly `chunkCount` rows, + // `chunkCount` unique values therefore proves the exact 0..n-1 set. + if (indices.size !== firstMetadata.chunkCount) { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} has duplicate or missing chunk indices` + ); + } + const ordered = [...group].sort( + (left, right) => left.metadata.chunkIndex - right.metadata.chunkIndex + ); + let accumulatedBytes = 0; + const parts = ordered.map(({ row }) => { + const data = (row.event.result as { data?: unknown } | undefined)?.data; + if (typeof data !== "string") { + throw new Org2CloudConversationError( + "invalid conversation event chunk" + ); + } + const part = base64ToBytes(data); + accumulatedBytes += part.length; + if ( + accumulatedBytes > firstMetadata.byteLength || + accumulatedBytes > CLOUD_CONVERSATION_MAX_LOGICAL_EVENT_BYTES + ) { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} exceeds its declared byte length` + ); + } + return part; + }); + const byteLength = parts.reduce((total, part) => total + part.length, 0); + if (byteLength !== firstMetadata.byteLength) { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} byte length mismatch` + ); + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.length; + } + const serialized = new TextDecoder().decode(bytes); + if ((await sha256Hex(serialized)) !== firstMetadata.sha256) { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} digest mismatch` + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(serialized); + } catch { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} contains invalid JSON` + ); + } + const parsedEvent = CloudSessionEventSchema.safeParse(decoded); + if (!parsedEvent.success) { + throw new Org2CloudConversationError( + `conversation event ${firstMetadata.sourceEventId} contains an invalid SessionEvent` + ); + } + const event = parsedEvent.data; + if (event.id !== firstMetadata.sourceEventId) { + throw new Org2CloudConversationError( + "conversation event chunk source identity mismatch" + ); + } + const last = ordered[ordered.length - 1].row; + ordinary.push({ ...last, id: event.id, event }); + } + return ordinary.sort((left, right) => left.seq - right.seq); } diff --git a/src/features/Org2Cloud/org2CloudConversationTurnClient.test.ts b/src/features/Org2Cloud/org2CloudConversationTurnClient.test.ts new file mode 100644 index 0000000000..47aac2e5e9 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudConversationTurnClient.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + admitCloudConversationTurn, + claimCloudConversationTurn, + finishCloudConversationTurn, + markCloudConversationTurnAccepted, + renewCloudConversationTurn, +} from "./org2CloudConversationTurnClient"; + +const ENDPOINT = { + supabaseUrl: "https://cloud.example", + anonKey: "anon", +}; +const TURN = { + orgId: "org-1", + rootSessionId: "root-1", + turnId: "turn-1", + deviceId: "11111111-1111-4111-8111-111111111111", +}; +const USER_EVENT = { + id: "user-1", + chunk_id: "user-1", + sessionId: "root-1", + createdAt: "2026-09-05T10:00:00.000Z", + functionName: "user_message", + uiCanonical: "user_message", + actionType: "raw", + args: {}, + result: { turnIntentId: "turn-1" }, + source: "user", + displayText: "continue", + displayStatus: "pending", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], +} as SessionEvent; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Cloud conversation turn RPC client", () => { + it("uses the 0028 RPC names and exact parameter contract", async () => { + const payloads = [ + { + turnId: "turn-1", + enqueueSeq: 1, + status: "queued", + firstSeq: 1, + lastSeq: 1, + }, + { + outcome: "claimed", + turnId: "turn-1", + status: "claimed", + enqueueSeq: 1, + leaseExpiresAt: "2026-09-05T10:00:30.000Z", + }, + { + turnId: "turn-1", + status: "claimed", + leaseExpiresAt: "2026-09-05T10:00:40.000Z", + }, + { + turnId: "turn-1", + status: "accepted", + acceptedAt: "2026-09-05T10:00:01.000Z", + leaseExpiresAt: "2026-09-05T10:00:31.000Z", + }, + { + turnId: "turn-1", + status: "completed", + finishedAt: "2026-09-05T10:00:02.000Z", + }, + ]; + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + Promise.resolve( + new Response(JSON.stringify(payloads.shift()), { status: 200 }) + ) + ); + vi.stubGlobal("fetch", fetchMock); + + await admitCloudConversationTurn( + "jwt", + { ...TURN, event: USER_EVENT }, + ENDPOINT + ); + await claimCloudConversationTurn( + "jwt", + { ...TURN, leaseSeconds: 30 }, + ENDPOINT + ); + await renewCloudConversationTurn( + "jwt", + { ...TURN, leaseSeconds: 30 }, + ENDPOINT + ); + await markCloudConversationTurnAccepted( + "jwt", + { ...TURN, leaseSeconds: 30 }, + ENDPOINT + ); + await finishCloudConversationTurn( + "jwt", + { ...TURN, status: "completed" }, + ENDPOINT + ); + + expect( + fetchMock.mock.calls.map(([url]) => String(url).split("/").at(-1)) + ).toEqual([ + "cloud_admit_conversation_turn", + "cloud_claim_conversation_turn", + "cloud_renew_conversation_turn", + "cloud_mark_conversation_turn_accepted", + "cloud_finish_conversation_turn", + ]); + expect( + JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) + ).toStrictEqual({ + p_org_id: "org-1", + p_root_session_id: "root-1", + p_turn_id: "turn-1", + p_event: USER_EVENT, + }); + expect( + JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)) + ).toStrictEqual({ + p_org_id: "org-1", + p_root_session_id: "root-1", + p_turn_id: "turn-1", + p_device_id: "11111111-1111-4111-8111-111111111111", + p_lease_seconds: 30, + }); + expect( + JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body)) + ).toStrictEqual({ + p_org_id: "org-1", + p_root_session_id: "root-1", + p_turn_id: "turn-1", + p_device_id: "11111111-1111-4111-8111-111111111111", + p_status: "completed", + }); + }); + + it("preserves HTTP status for queue retry classification", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + Promise.resolve( + new Response(JSON.stringify({ message: "temporary" }), { + status: 503, + }) + ) + ) + ); + + await expect( + claimCloudConversationTurn("jwt", { ...TURN, leaseSeconds: 30 }, ENDPOINT) + ).rejects.toMatchObject({ status: 503 }); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudConversationTurnClient.ts b/src/features/Org2Cloud/org2CloudConversationTurnClient.ts new file mode 100644 index 0000000000..5df5091331 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudConversationTurnClient.ts @@ -0,0 +1,261 @@ +import { z } from "zod/v4"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + type CloudEndpoint, + ORG2_CLOUD_POSTGREST_SCHEMA, + getCloudEndpoint, +} from "./config"; +import { + fetchWithTransportRetry, + runCloudRequestWithTimeout, +} from "./org2CloudFetchRetry"; + +const CONVERSATION_TURN_RPC_TIMEOUT_MS = 15_000; + +const TurnStatusSchema = z.enum([ + "queued", + "claimed", + "accepted", + "completed", + "failed", + "cancelled", +]); +const TerminalTurnStatusSchema = z.enum(["completed", "failed", "cancelled"]); + +const AdmitConversationTurnSchema = z.object({ + turnId: z.string(), + enqueueSeq: z.number().int().safe().positive(), + status: TurnStatusSchema, + firstSeq: z.number().int().safe().positive(), + lastSeq: z.number().int().safe().positive(), +}); + +const ClaimConversationTurnSchema = z.discriminatedUnion("outcome", [ + z.object({ + outcome: z.literal("claimed"), + turnId: z.string(), + status: z.literal("claimed"), + enqueueSeq: z.number().int().safe().positive(), + leaseExpiresAt: z.string(), + }), + z.object({ + outcome: z.literal("accepted"), + turnId: z.string(), + status: z.literal("accepted"), + enqueueSeq: z.number().int().safe().positive(), + leaseExpiresAt: z.string(), + retryAfterMs: z.number().int().nonnegative(), + }), + z.object({ + outcome: z.literal("waiting"), + turnId: z.string(), + status: TurnStatusSchema, + enqueueSeq: z.number().int().safe().positive(), + headTurnId: z.string().optional(), + headStatus: TurnStatusSchema.optional(), + leaseExpiresAt: z.string().optional(), + retryAfterMs: z.number().int().nonnegative(), + }), + z.object({ + outcome: z.literal("terminal"), + turnId: z.string(), + status: TerminalTurnStatusSchema, + enqueueSeq: z.number().int().safe().positive(), + }), +]); + +const RenewConversationTurnSchema = z.object({ + turnId: z.string(), + status: z.enum(["claimed", "accepted"]), + leaseExpiresAt: z.string(), +}); + +const AcceptConversationTurnSchema = z.object({ + turnId: z.string(), + status: z.literal("accepted"), + acceptedAt: z.string(), + leaseExpiresAt: z.string(), +}); + +const FinishConversationTurnSchema = z.object({ + turnId: z.string(), + status: TerminalTurnStatusSchema, + finishedAt: z.string(), +}); + +export type CloudConversationTurnClaim = z.output< + typeof ClaimConversationTurnSchema +>; +export type CloudConversationTurnTerminalStatus = z.output< + typeof TerminalTurnStatusSchema +>; + +export class Org2CloudConversationTurnError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null = null) { + super(message); + this.name = "Org2CloudConversationTurnError"; + this.status = status; + } +} + +async function callConversationTurnRpc( + functionName: string, + schema: z.ZodType, + accessToken: string, + body: Record, + endpoint: Pick = getCloudEndpoint() +): Promise { + const payload = await runCloudRequestWithTimeout(async (signal) => { + const response = await fetchWithTransportRetry( + `${endpoint.supabaseUrl}/rest/v1/rpc/${functionName}`, + { + method: "POST", + headers: { + apikey: endpoint.anonKey, + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + "content-profile": ORG2_CLOUD_POSTGREST_SCHEMA, + }, + body: JSON.stringify(body), + signal, + } + ); + const text = await response.text(); + let decoded: unknown = null; + try { + decoded = text ? JSON.parse(text) : null; + } catch { + decoded = null; + } + if (!response.ok) { + const message = + decoded && typeof decoded === "object" && "message" in decoded + ? String((decoded as { message: unknown }).message) + : `org2_cloud rpc ${functionName} failed with ${response.status}`; + throw new Org2CloudConversationTurnError(message, response.status); + } + return decoded; + }, CONVERSATION_TURN_RPC_TIMEOUT_MS); + const parsed = schema.safeParse(payload); + if (!parsed.success) { + throw new Org2CloudConversationTurnError( + `unparseable ${functionName} payload` + ); + } + return parsed.data; +} + +interface ConversationTurnIdentity { + orgId: string; + rootSessionId: string; + turnId: string; +} + +interface ClaimedConversationTurnIdentity extends ConversationTurnIdentity { + deviceId: string; +} + +export function admitCloudConversationTurn( + accessToken: string, + params: ConversationTurnIdentity & { event: SessionEvent }, + endpoint?: Pick +) { + return callConversationTurnRpc( + "cloud_admit_conversation_turn", + AdmitConversationTurnSchema, + accessToken, + { + p_org_id: params.orgId, + p_root_session_id: params.rootSessionId, + p_turn_id: params.turnId, + p_event: params.event, + }, + endpoint + ); +} + +export function claimCloudConversationTurn( + accessToken: string, + params: ClaimedConversationTurnIdentity & { leaseSeconds: number }, + endpoint?: Pick +): Promise { + return callConversationTurnRpc( + "cloud_claim_conversation_turn", + ClaimConversationTurnSchema, + accessToken, + { + p_org_id: params.orgId, + p_root_session_id: params.rootSessionId, + p_turn_id: params.turnId, + p_device_id: params.deviceId, + p_lease_seconds: params.leaseSeconds, + }, + endpoint + ); +} + +export function renewCloudConversationTurn( + accessToken: string, + params: ClaimedConversationTurnIdentity & { leaseSeconds: number }, + endpoint?: Pick +) { + return callConversationTurnRpc( + "cloud_renew_conversation_turn", + RenewConversationTurnSchema, + accessToken, + { + p_org_id: params.orgId, + p_root_session_id: params.rootSessionId, + p_turn_id: params.turnId, + p_device_id: params.deviceId, + p_lease_seconds: params.leaseSeconds, + }, + endpoint + ); +} + +export function markCloudConversationTurnAccepted( + accessToken: string, + params: ClaimedConversationTurnIdentity & { leaseSeconds: number }, + endpoint?: Pick +) { + return callConversationTurnRpc( + "cloud_mark_conversation_turn_accepted", + AcceptConversationTurnSchema, + accessToken, + { + p_org_id: params.orgId, + p_root_session_id: params.rootSessionId, + p_turn_id: params.turnId, + p_device_id: params.deviceId, + p_lease_seconds: params.leaseSeconds, + }, + endpoint + ); +} + +export function finishCloudConversationTurn( + accessToken: string, + params: ClaimedConversationTurnIdentity & { + status: CloudConversationTurnTerminalStatus; + }, + endpoint?: Pick +) { + return callConversationTurnRpc( + "cloud_finish_conversation_turn", + FinishConversationTurnSchema, + accessToken, + { + p_org_id: params.orgId, + p_root_session_id: params.rootSessionId, + p_turn_id: params.turnId, + p_device_id: params.deviceId, + p_status: params.status, + }, + endpoint + ); +} diff --git a/src/features/Org2Cloud/org2CloudFetchRetry.test.ts b/src/features/Org2Cloud/org2CloudFetchRetry.test.ts index 0ce166434c..f4c8c4e9fc 100644 --- a/src/features/Org2Cloud/org2CloudFetchRetry.test.ts +++ b/src/features/Org2Cloud/org2CloudFetchRetry.test.ts @@ -4,6 +4,7 @@ import { fetchWithTransportRetry, fetchWithTransportRetryAndTimeout, isFetchTransportError, + isRetryableCloudRequestError, runCloudRequestWithTimeout, } from "./org2CloudFetchRetry"; @@ -171,3 +172,32 @@ describe("isFetchTransportError", () => { expect(isFetchTransportError(null)).toBe(false); }); }); + +describe("isRetryableCloudRequestError", () => { + it("keeps only ambiguous transport, timeout, and 5xx failures retryable", () => { + expect(isRetryableCloudRequestError(new TypeError("Load failed"))).toBe( + true + ); + expect( + isRetryableCloudRequestError( + new DOMException("Cloud request timed out.", "TimeoutError") + ) + ).toBe(true); + expect(isRetryableCloudRequestError({ status: 503 })).toBe(true); + expect( + isRetryableCloudRequestError({ + status: null, + recoveryPending: true, + }) + ).toBe(true); + }); + + it("treats deterministic 4xx and client validation failures as terminal", () => { + expect(isRetryableCloudRequestError({ status: 400 })).toBe(false); + expect(isRetryableCloudRequestError({ status: 401 })).toBe(false); + expect(isRetryableCloudRequestError({ status: 404 })).toBe(false); + expect(isRetryableCloudRequestError(new Error("ORG2_VALIDATION"))).toBe( + false + ); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudFetchRetry.ts b/src/features/Org2Cloud/org2CloudFetchRetry.ts index 10d8f71bfe..8b1dbf61af 100644 --- a/src/features/Org2Cloud/org2CloudFetchRetry.ts +++ b/src/features/Org2Cloud/org2CloudFetchRetry.ts @@ -112,3 +112,30 @@ export function isFetchTransportError(error: unknown): boolean { TRANSPORT_ERROR_MESSAGES.has(error.message.trim().toLowerCase()) ); } + +/** + * Whether a failed Cloud RPC has an ambiguous outcome and may be retried by + * an idempotent operation owner. + * + * Raw-fetch clients expose HTTP failures as typed errors with a nullable + * `status`. A server response in the 4xx range is definitive: retrying it + * forever cannot change the rejected request. Network loss, a local request + * deadline, and 5xx responses do not prove whether the server committed the + * write, so callers with a stable idempotency key retain recovery ownership. + */ +export function isRetryableCloudRequestError(error: unknown): boolean { + if (isFetchTransportError(error)) return true; + if ( + error instanceof DOMException && + (error.name === "TimeoutError" || error.name === "AbortError") + ) { + return true; + } + if (!error || typeof error !== "object" || !("status" in error)) { + return false; + } + const typed = error as { recoveryPending?: unknown; status?: unknown }; + if (typed.recoveryPending === true) return true; + const status = typed.status; + return typeof status === "number" && status >= 500 && status <= 599; +} diff --git a/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.lifecycle.test.ts b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.lifecycle.test.ts new file mode 100644 index 0000000000..c7f681b533 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.lifecycle.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { afterEach, expect, it, vi } from "vitest"; + +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; +import { + bumpRemoteSessionsInvalidation, + org2CloudRemoteSessionsVersionAtom, + useCloudOrgRemoteSessions, +} from "./org2CloudRemoteSessionsAtom"; + +const mocks = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("./org2CloudClient", () => ({ + ensureFreshSession: async (auth: unknown) => auth, +})); +vi.mock("./org2CloudSyncClient", () => ({ listOrgSessions: mocks.list })); +vi.mock("./org2CloudAuthAtom", async () => { + const { atom } = await import("jotai"); + return { + org2CloudAuthAtom: atom({ + userId: "user", + supabaseUrl: "https://test.invalid", + accessToken: "test", + }), + org2CloudAuthIdentityKey: () => "https://test.invalid|user", + commitRefreshedAuth: () => true, + }; +}); + +const root = createSmokeRoot(); +afterEach(async () => { + await root.unmount(); + vi.restoreAllMocks(); + mocks.list.mockReset(); +}); + +it("loads an initially hidden listing when visible without requiring keyboard focus", async () => { + let visibility: DocumentVisibilityState = "hidden"; + vi.spyOn(document, "visibilityState", "get").mockImplementation( + () => visibility + ); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + mocks.list.mockResolvedValue({ sessions: [] }); + const store = createStore(); + expect(store.get(org2CloudAuthAtom)).not.toBeNull(); + function Listing() { + const { state } = useCloudOrgRemoteSessions("org"); + return createElement("div", null, state); + } + await root.render(createElement(Provider, { store }, createElement(Listing))); + expect(mocks.list).not.toHaveBeenCalled(); + + await act(async () => { + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(mocks.list).toHaveBeenCalledTimes(1); + expect(root.container.textContent).toBe("ready"); + + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(mocks.list).toHaveBeenCalledTimes(1); + + await act(async () => { + visibility = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + store.set(org2CloudRemoteSessionsVersionAtom, (previous) => + bumpRemoteSessionsInvalidation(previous, "org") + ); + }); + expect(mocks.list).toHaveBeenCalledTimes(1); + await act(async () => { + visibility = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(mocks.list).toHaveBeenCalledTimes(2); +}); diff --git a/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts index e9d3fa83a9..a0ad8a54e3 100644 --- a/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts +++ b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts @@ -480,6 +480,10 @@ export function useCloudOrgRemoteSessions( }, [ orgId, signedIn, + // A visible window may not own keyboard focus (for example beside the + // other instance). Resume deferred initial/invalidation work on reveal; + // the focused recovery below remains the full-refresh owner. + documentVisible, invalidationVersion, fullRefreshVersion, entrySnapshot, diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.delivery.test.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.delivery.test.ts index 4c00efa412..14cacca43d 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.delivery.test.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.delivery.test.ts @@ -42,8 +42,16 @@ vi.mock("./org2CloudCommentsBus", async (importOriginal) => { type CommentsApi = ReturnType; let api: CommentsApi; -function Harness({ onReady }: { onReady: (value: CommentsApi) => void }) { - const value = useSessionComments("org-1", "session-1"); +function Harness({ + onReady, + orgId = "org-1", + sessionId = "session-1", +}: { + onReady: (value: CommentsApi) => void; + orgId?: string | null; + sessionId?: string | null; +}) { + const value = useSessionComments(orgId, sessionId); useEffect(() => onReady(value), [onReady, value]); return null; } @@ -104,9 +112,9 @@ describe("Team Chat retained delivery", () => { expect(failure).toBeInstanceOf(SessionCommentDeliveryError); const localId = (failure as SessionCommentDeliveryError).commentId; const key = sessionCommentsKey("org-1", "session-1"); - expect( - store.get(org2CloudSessionCommentsAtom)[key].comments - ).toContainEqual( + const readComments = () => + store.get(org2CloudSessionCommentsAtom)[key].comments; + expect(readComments()).toContainEqual( expect.objectContaining({ id: localId, body: "hello @Bob", @@ -115,6 +123,7 @@ describe("Team Chat retained delivery", () => { clientDeliveryError: "offline", }) ); + const retainedAt = readComments()[0].createdAt; const delivered: CloudSessionComment = { id: "comment-1", @@ -125,20 +134,78 @@ describe("Team Chat retained delivery", () => { kind: "user", mentionedUserIds: ["user-3"], }; - mocks.addSessionComment.mockResolvedValueOnce(delivered); - await act(async () => - api.retryComment(localId, "edited @Carol", ["user-3"]) + let resolveSend!: (comment: CloudSessionComment) => void; + mocks.addSessionComment.mockReturnValueOnce( + new Promise((resolve) => { + resolveSend = resolve; + }) ); + // Retry is not a second mechanism: the owning surface re-sends the same + // row through addComment under its stable optimistic id. + let retry!: Promise; + await act(async () => { + retry = api.addComment({ + body: "edited @Carol", + mentionedUserIds: ["user-3"], + optimisticId: localId, + replaceExisting: true, + expectedBody: "hello @Bob", + expectedMentionedUserIds: ["user-2"], + }); + }); expect(mocks.addSessionComment).toHaveBeenLastCalledWith( "access-token", expect.objectContaining({ body: "edited @Carol", mentionedUserIds: ["user-3"], + clientMessageKey: localId, + replaceExisting: true, + expectedBody: "hello @Bob", + expectedMentionedUserIds: ["user-2"], }) ); - expect(store.get(org2CloudSessionCommentsAtom)[key].comments).toEqual([ - delivered, - ]); + // The retained row is edited in place — never retracted, never re-dated. + const pending = readComments(); + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + id: localId, + body: "edited @Carol", + mentionedUserIds: ["user-3"], + createdAt: retainedAt, + clientDeliveryStatus: "pending", + }); + expect(pending[0].clientDeliveryError).toBeUndefined(); + + await act(async () => { + resolveSend(delivered); + await retry; + }); + expect(readComments()).toEqual([delivered]); + }); + + it("rejects without claiming ownership when no row was inserted", async () => { + const targetless = createSmokeRoot(); + let targetlessApi!: CommentsApi; + await targetless.render( + createElement( + Provider, + { store }, + createElement(Harness, { + orgId: null, + sessionId: null, + onReady: (value: CommentsApi) => { + targetlessApi = value; + }, + }) + ) + ); + await act(async () => Promise.resolve()); + + await expect( + targetlessApi.addComment({ body: "hello" }) + ).rejects.not.toBeInstanceOf(SessionCommentDeliveryError); + expect(mocks.addSessionComment).not.toHaveBeenCalled(); + await targetless.unmount(); }); }); diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts index 537dabab96..54d54f89da 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts @@ -14,6 +14,7 @@ import { atom, useAtom, useAtomValue } from "jotai"; import { useCallback, useEffect, useRef } from "react"; +import { deliverOptimisticOutgoing } from "@src/engines/SessionCore/services/optimisticOutgoingDelivery"; import { createLogger } from "@src/hooks/logger"; import { @@ -59,13 +60,13 @@ import { rememberCompletedForceToken, } from "./org2CloudSessionCommentsAtom.forceTokenTracker"; import { useCloudFreshAccessToken } from "./org2CloudSessionCommentsAtom.freshToken"; +import { SessionCommentDeliveryError } from "./org2CloudSessionCommentsAtom.types"; import type { AddCommentInput, CloudSessionCommentsEntry, SessionComment, UseSessionCommentsResult, } from "./org2CloudSessionCommentsAtom.types"; -import { SessionCommentDeliveryError } from "./org2CloudSessionCommentsAtom.types"; // Re-exports: preserve this module's public import path for symbols that // now live in the sibling modules above (types / pure transforms / @@ -80,7 +81,6 @@ export type { SessionCommentDeliveryStatus, UseSessionCommentsResult, } from "./org2CloudSessionCommentsAtom.types"; -export { SessionCommentDeliveryError } from "./org2CloudSessionCommentsAtom.types"; export { MAX_SESSION_COMMENT_CACHE_ENTRIES, OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, @@ -101,6 +101,7 @@ export { shouldEvictSessionCommentsOnError, writeSessionCommentsEntry, } from "./org2CloudSessionCommentsAtom.commentTransforms"; +export { SessionCommentDeliveryError } from "./org2CloudSessionCommentsAtom.types"; export { useCloudFreshAccessToken } from "./org2CloudSessionCommentsAtom.freshToken"; const log = createLogger("Org2CloudSessionComments"); @@ -495,73 +496,15 @@ export function useSessionComments( ); }, []); - const deliverComment = useCallback( - async ( - commentId: string, - input: AddCommentInput - ): Promise => { - if (!orgId || !sessionId || !key) { - throw new Error("no cloud comment target"); - } - try { - const { accessToken, identityKey } = - await freshTokenForCurrentIdentity(); - const comment = await addSessionComment(accessToken, { - orgId, - sessionId, - body: input.body, - eventId: input.eventId, - parentId: input.parentId, - mentionedUserIds: input.mentionedUserIds, - ...(originSessionId && originSessionId !== sessionId - ? { originSessionId } - : {}), - }); - if (!isCurrentIdentity(identityKey)) return comment; - patchEntry(key, (comments) => - insertComment( - comments.filter((candidate) => candidate.id !== commentId), - comment - ) - ); - broadcastCommentsChangedToPeers(orgId, sessionId); - return comment; - } catch (error) { - let retained = false; - patchEntry(key, (comments) => - comments.map((comment) => { - if (comment.id !== commentId) return comment; - retained = true; - return { - ...comment, - clientDeliveryStatus: "failed", - clientDeliveryError: - error instanceof Error ? error.message : String(error), - }; - }) - ); - if (!retained) throw error; - throw new SessionCommentDeliveryError(commentId, input, error); - } - }, - [ - freshTokenForCurrentIdentity, - isCurrentIdentity, - key, - orgId, - originSessionId, - patchEntry, - sessionId, - ] - ); - const addComment = useCallback( async (input: AddCommentInput): Promise => { if (!orgId || !sessionId || !key) { throw new Error("no cloud comment target"); } - const optimistic: SessionComment = { - id: `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, + const optimistic: CloudSessionComment = { + id: + input.optimisticId ?? + `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, eventId: input.eventId, parentId: input.parentId, authorUserId: authRef.current?.userId ?? "", @@ -571,50 +514,97 @@ export function useSessionComments( kind: "user", mentionedUserIds: input.mentionedUserIds ?? [], clientDeliveryStatus: "pending", + ...(input.replaceExisting + ? { + clientRetryExpectedBody: input.expectedBody, + clientRetryExpectedMentionedUserIds: + input.expectedMentionedUserIds ?? [], + } + : {}), }; - patchEntry(key, (comments) => insertComment(comments, optimistic)); - return deliverComment(optimistic.id, input); - }, - [deliverComment, key, orgId, patchEntry, sessionId] - ); - - const retryComment = useCallback( - async ( - commentId: string, - editedBody?: string, - editedMentionedUserIds?: string[] - ): Promise => { - if (!key) throw new Error("no cloud comment target"); - let input: AddCommentInput | null = null; - patchEntry(key, (comments) => - comments.map((comment) => { - if ( - comment.id !== commentId || - !isOptimisticSessionCommentId(comment.id) || - comment.clientDeliveryStatus !== "failed" - ) { - return comment; - } - input = { - body: editedBody ?? comment.body, - eventId: comment.eventId, - parentId: comment.parentId, - mentionedUserIds: - editedMentionedUserIds ?? comment.mentionedUserIds, - }; - return { - ...comment, + // A retry re-sends under the SAME optimistic id: replace the row in + // place and keep its original timestamp so the retained message does + // not jump out of the transcript position the user is looking at. + patchEntry(key, (comments) => { + const retainedRow = comments.find( + (candidate) => candidate.id === optimistic.id + ); + return insertComment( + comments, + retainedRow + ? { ...optimistic, createdAt: retainedRow.createdAt } + : optimistic + ); + }); + let retained = false; + const delivered = await deliverOptimisticOutgoing({ + send: async () => { + const { accessToken, identityKey } = + await freshTokenForCurrentIdentity(); + const comment = await addSessionComment(accessToken, { + orgId, + sessionId, body: input.body, + eventId: input.eventId, + parentId: input.parentId, mentionedUserIds: input.mentionedUserIds, - clientDeliveryStatus: "pending", - clientDeliveryError: undefined, - }; - }) - ); - if (!input) throw new Error("failed Team Chat message was not found"); - return deliverComment(commentId, input); + clientMessageKey: optimistic.id, + replaceExisting: input.replaceExisting, + expectedBody: input.expectedBody, + expectedMentionedUserIds: input.expectedMentionedUserIds, + ...(originSessionId && originSessionId !== sessionId + ? { originSessionId } + : {}), + }); + return { comment, identityKey }; + }, + markSent: ({ comment, identityKey }) => { + if (!isCurrentIdentity(identityKey)) return; + // Replace the local echo with the server-authored row atomically. + patchEntry(key, (comments) => + insertComment( + comments.filter((candidate) => candidate.id !== optimistic.id), + comment + ) + ); + broadcastCommentsChangedToPeers(orgId, sessionId); + }, + markFailed: (error) => { + patchEntry(key, (comments) => { + retained = comments.some( + (candidate) => candidate.id === optimistic.id + ); + return patchComment(comments, optimistic.id, { + clientDeliveryStatus: "failed", + clientDeliveryError: + error instanceof Error ? error.message : String(error), + }); + }); + }, + onProjectionError: (phase, error) => { + log.error( + `Failed to project ${phase} Cloud comment delivery for ${sessionId}`, + error + ); + }, + }).catch((error: unknown) => { + // Only claim delivery ownership when a failed row is actually on + // screen. Otherwise the composer is still the sole copy of the text + // and must restore it. + if (!retained) throw error; + throw new SessionCommentDeliveryError(optimistic.id, error); + }); + return delivered.comment; }, - [deliverComment, key, patchEntry] + [ + orgId, + sessionId, + originSessionId, + key, + freshTokenForCurrentIdentity, + isCurrentIdentity, + patchEntry, + ] ); const editComment = useCallback( @@ -727,7 +717,6 @@ export function useSessionComments( refresh, insertLocalComment, addComment, - retryComment, editComment, deleteComment, resolveComment, diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts index 6fd54446b1..f2d4956376 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts @@ -11,11 +11,12 @@ import type { export type SessionCommentDeliveryStatus = "pending" | "sent" | "failed"; -/** Server comment plus transient delivery state for a locally-authored row. */ -export interface SessionComment extends CloudSessionComment { - clientDeliveryStatus?: SessionCommentDeliveryStatus; - clientDeliveryError?: string; -} +/** + * Named alias for the canonical comment row. Transient delivery state + * (`clientDeliveryStatus`, `clientDeliveryError`, the retry CAS anchors) + * lives on `CloudSessionComment` itself — there is no second row shape. + */ +export type SessionComment = CloudSessionComment; export type CloudSessionCommentsFetchState = | "idle" @@ -26,7 +27,7 @@ export type CloudSessionCommentsFetchState = export interface CloudSessionCommentsEntry { /** Prevents cached bodies from crossing an account or endpoint switch. */ identityKey?: string; - comments: SessionComment[]; + comments: CloudSessionComment[]; /** Server-derived permission for spending this session owner's local model. */ viewerOwnsSession: boolean; state: CloudSessionCommentsFetchState; @@ -47,9 +48,9 @@ export interface CloudSessionCommentsEntry { export type SessionCommentsFetchDecision = "claim" | "skip" | "queue_force"; export interface CommentThread { - top: SessionComment; + top: CloudSessionComment; /** Direct replies, (createdAt, id) asc. Flat: replies never nest. */ - replies: SessionComment[]; + replies: CloudSessionComment[]; } export interface GroupedCommentThreads { @@ -71,24 +72,40 @@ export interface AddCommentInput { parentId?: string; /** Active cloud-org members explicitly notified by this comment. */ mentionedUserIds?: string[]; + /** + * Stable client id for admission recovery and explicit retries. It is also + * the Cloud RPC idempotency key, so a lost response cannot create a second + * durable comment when the same failed row is retried. + */ + optimisticId?: string; + /** The user edited a previously failed row before retrying it. */ + replaceExisting?: boolean; + /** Original failed-row body for an edited retry's server-side CAS. */ + expectedBody?: string; + /** Original failed-row mentions for an edited retry's server-side CAS. */ + expectedMentionedUserIds?: string[]; } +/** + * The send failed AFTER the optimistic row was retained as a visible failed + * row. `commentId` is that row's stable local id — the same `optimisticId` + * the owning surface re-sends under. A plain rejection means nothing was + * retained and the caller still owns the only copy of the user's text. + */ export class SessionCommentDeliveryError extends Error { readonly commentId: string; - readonly input: AddCommentInput; readonly cause: unknown; - constructor(commentId: string, input: AddCommentInput, cause: unknown) { + constructor(commentId: string, cause: unknown) { super(cause instanceof Error ? cause.message : String(cause)); this.name = "SessionCommentDeliveryError"; this.commentId = commentId; - this.input = input; this.cause = cause; } } export interface UseSessionCommentsResult { - comments: SessionComment[]; + comments: CloudSessionComment[]; viewerOwnsSession: boolean; state: CloudSessionCommentsFetchState; /** Refetch now, ignoring the TTL. */ @@ -100,14 +117,16 @@ export interface UseSessionCommentsResult { * refetch. No RPC fires; the next TTL refetch reconciles regardless. */ insertLocalComment: (comment: CloudSessionComment) => void; - /** Resolves with the created comment (already inserted). Delivery failure - * retains the optimistic row as failed and throws its stable local id. */ + /** + * Resolves with the created comment (already inserted). On a transport + * rejection the optimistic row stays visible as failed — body and mention + * pills intact — and the rejection is a `SessionCommentDeliveryError` + * naming that row, so the caller must not restore its draft. Retry is not + * a separate operation: the owning surface calls `addComment` again with + * the same `optimisticId` (plus the `replaceExisting`/`expected*` CAS + * anchors when the user edited the text first). + */ addComment: (input: AddCommentInput) => Promise; - retryComment: ( - commentId: string, - editedBody?: string, - editedMentionedUserIds?: string[] - ) => Promise; editComment: (commentId: string, body: string) => Promise; deleteComment: (commentId: string) => Promise; resolveComment: ( diff --git a/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts b/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts new file mode 100644 index 0000000000..0e728b37ff --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts @@ -0,0 +1,304 @@ +import { createStore } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; +import type { Session } from "@src/store/session"; + +import type { CloudPushAccess } from "./org2CloudAccessSettings"; +import type { Org2CloudAuthState } from "./org2CloudAuthAtom"; +import { Org2CloudSessionSync } from "./org2CloudSessionSync"; +import type { Org2CloudSyncClientDeps } from "./org2CloudSessionSync.types"; +import type { CollabSessionPushCursor } from "./org2CloudSyncAtoms"; +import { org2CloudPushCursorsAtom } from "./org2CloudSyncAtoms"; + +const mocks = vi.hoisted(() => ({ + childRevision: vi.fn(), + canonicalSnapshot: vi.fn(), + persistedRevision: vi.fn(), + persistedEvents: vi.fn(), +})); + +vi.mock( + "@src/engines/SessionCore/conversations/localConversationExecutionTail", + () => ({ + loadLocalExecutionChildrenRevision: mocks.childRevision, + loadLocalCanonicalConversationSnapshot: mocks.canonicalSnapshot, + }) +); + +vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ + eventStoreProxy: { + getPersistedEventRevision: mocks.persistedRevision, + getPersistedEvents: mocks.persistedEvents, + }, +})); + +const AUTH: Org2CloudAuthState = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_000_000_000, +}; + +const SESSION: Session = { + session_id: "cliagent-root", + status: "completed", + created_at: "2026-09-05T00:00:00.000Z", + updated_at: "2026-09-05T00:00:00.000Z", + name: "Native root", + orgId: "cloud:org-1", + category: "cli_agent", +}; + +const ACCESS: CloudPushAccess = { + accessMode: COLLAB_SESSION_ACCESS_MODE.FULL_REPLAY, + visibility: "org", +}; + +function event(id: string, text: string): SessionEvent { + return { + id, + chunk_id: id, + sessionId: SESSION.session_id, + createdAt: "2026-09-05T00:00:00.000Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant", + args: {}, + result: {}, + source: "assistant", + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function client() { + return { + upsertSessionMetadata: vi.fn(async () => undefined), + appendSessionEvents: vi.fn(async () => undefined), + rewriteSessionEvents: vi.fn(async () => undefined), + getSessionEvents: vi.fn(async () => ({ events: [], epoch: 0 })), + getOrgRepoScopes: vi.fn(async () => ({ repoScopes: [] })), + listOrgSessions: vi.fn(async () => ({ sessions: [] })), + deleteSession: vi.fn(async () => undefined), + } as unknown as Org2CloudSyncClientDeps & { + appendSessionEvents: ReturnType; + rewriteSessionEvents: ReturnType; + }; +} + +describe("Org2CloudSessionSync local continuation replay", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.childRevision.mockResolvedValue("[]"); + mocks.persistedRevision.mockResolvedValue(null); + mocks.persistedEvents.mockResolvedValue([]); + }); + + async function pushPass( + sync: Org2CloudSessionSync, + session: Session = SESSION + ): Promise { + sync.beginPass(); + try { + await sync.pushSession(AUTH, "org-1", session, null, ACCESS); + } finally { + sync.endPass(); + } + } + + it("publishes the verified root-plus-child snapshot through the full replay owner", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const combined = [event("root", "root"), event("child", "child")]; + mocks.childRevision.mockResolvedValue( + '[["cliagent-child","2026-09-05","2026-09-05"]]' + ); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined, + childRevision: '[["cliagent-child","2026-09-05","2026-09-05"]]', + }); + + await pushPass(sync); + + expect(mocks.canonicalSnapshot).toHaveBeenCalledWith({ + authority: "local-session", + authorityScope: [], + conversationId: SESSION.session_id, + }); + expect(mocks.persistedEvents).not.toHaveBeenCalled(); + expect( + store.get(org2CloudPushCursorsAtom)[`org-1:${SESSION.session_id}`] + ?.pushedCount + ).toBe(combined.length); + }); + + it("uses the imported source identity when publishing its continuation children", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const imported = { + ...SESSION, + session_id: "codexapp-native-root", + category: "external_history" as const, + }; + const combined = [event("imported", "imported"), event("child", "child")]; + mocks.childRevision.mockResolvedValue( + '[["cliagent-child","2026-09-05","2026-09-05"]]' + ); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined, + childRevision: '[["cliagent-child","2026-09-05","2026-09-05"]]', + }); + const source = getImportedHistorySourceBySessionId(imported.session_id); + expect(source?.loadCloudTurnIds).toBeDefined(); + const incrementalReader = vi.spyOn(source!, "loadCloudTurnIds"); + // This checkpoint is intentionally incomplete: the child frontier must + // choose the full canonical snapshot before inspecting imported replay. + const cursor = { + importedReplay: { version: 1 }, + } as CollabSessionPushCursor; + + const prepared = await ( + sync as unknown as { + preparePushEventsForPass( + sessionId: string, + cursor: CollabSessionPushCursor + ): Promise<{ mode: string; events: SessionEvent[] }>; + } + ).preparePushEventsForPass(imported.session_id, cursor); + + expect(mocks.canonicalSnapshot).toHaveBeenCalledWith({ + authority: "imported-history", + authorityScope: ["codex_app"], + conversationId: imported.session_id, + }); + expect(mocks.persistedEvents).not.toHaveBeenCalled(); + expect(prepared).toMatchObject({ mode: "full", events: combined }); + expect(incrementalReader).not.toHaveBeenCalled(); + }); + + it("publishes an SDE Agent root with its native continuation children", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const agent = { + ...SESSION, + session_id: "sdeagent-native-root", + category: "rust_agent" as const, + agentDefinitionId: "builtin:sde", + }; + const combined = [event("agent", "agent"), event("child", "child")]; + mocks.childRevision.mockResolvedValue( + '[["cliagent-child","2026-09-05","2026-09-05"]]' + ); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined, + childRevision: '[["cliagent-child","2026-09-05","2026-09-05"]]', + }); + + await pushPass(sync, agent); + + expect(mocks.canonicalSnapshot).toHaveBeenCalledWith({ + authority: "local-session", + authorityScope: [], + conversationId: agent.session_id, + }); + expect(mocks.persistedEvents).not.toHaveBeenCalled(); + }); + + it("keeps the existing EventStore reader when a local root has no children", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const agent = { + ...SESSION, + session_id: "sdeagent-without-child", + category: "rust_agent" as const, + agentDefinitionId: "builtin:sde", + }; + const persisted = [event("agent", "agent")]; + mocks.persistedEvents.mockResolvedValue(persisted); + + await pushPass(sync, agent); + + expect(mocks.canonicalSnapshot).not.toHaveBeenCalled(); + expect(mocks.persistedEvents).toHaveBeenCalledWith(agent.session_id); + expect( + store.get(org2CloudPushCursorsAtom)[`org-1:${agent.session_id}`] + ?.pushedCount + ).toBe(persisted.length); + }); + + it("keeps the existing reader without probing execution children for a non-conversation session", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const persisted = [event("plain", "plain")]; + mocks.persistedEvents.mockResolvedValue(persisted); + + const events = await ( + sync as unknown as { + loadPushEvents(sessionId: string): Promise; + } + ).loadPushEvents("plain-session"); + + expect(events).toEqual(persisted); + expect(mocks.childRevision).not.toHaveBeenCalled(); + expect(mocks.canonicalSnapshot).not.toHaveBeenCalled(); + }); + + it("invalidates a clean root when only its child frontier changes", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + let revision = "revision-1"; + let combined = [event("root", "root"), event("child-1", "one")]; + mocks.childRevision.mockImplementation(async () => revision); + mocks.canonicalSnapshot.mockImplementation(async () => ({ + events: combined, + childRevision: revision, + })); + + await pushPass(sync); + await pushPass(sync); + expect(mocks.canonicalSnapshot).toHaveBeenCalledTimes(1); + + revision = "revision-2"; + combined = [...combined, event("child-2", "two")]; + await pushPass(sync); + + expect(mocks.canonicalSnapshot).toHaveBeenCalledTimes(2); + expect( + cloud.appendSessionEvents.mock.calls.length + + cloud.rewriteSessionEvents.mock.calls.length + ).toBeGreaterThan(1); + }); + + it("never marks a canonical snapshot clean when a native child revision is unstable", async () => { + const store = createStore(); + const cloud = client(); + const sync = new Org2CloudSessionSync(() => store, cloud); + const combined = [event("root", "root"), event("child", "child")]; + mocks.childRevision.mockResolvedValue(null); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined, + childRevision: null, + }); + + await pushPass(sync); + await pushPass(sync); + + expect(mocks.canonicalSnapshot).toHaveBeenCalledTimes(2); + expect(mocks.persistedEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts b/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts index 202607c58f..92872fb478 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts @@ -16,6 +16,7 @@ import type { RemoteTeammateSessionMetadata, } from "@src/store/collaboration/types"; import type { Session } from "@src/store/session/sessionAtom/types"; +import { isManagedNativeHistoryMirror } from "@src/util/session/sessionVisibility"; import { createDefaultAccessSettings, @@ -86,9 +87,10 @@ export function buildCloudSessionMetadata( /** True for local sessions that may ever be pushed to the cloud. */ export function isCloudPushCandidate( - session: Pick + session: Pick ): boolean { // Imported teammate copies must never round-trip under the local user. - // The user's own external history has no importedFrom and remains shareable. - return !session.importedFrom; + // Ordinary external history remains shareable. A managed native mirror is + // readable by ID, but only its owning conversation may publish it. + return !session.importedFrom && !isManagedNativeHistoryMirror(session); } diff --git a/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts b/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts index 5be2490931..f2f5de42f3 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts @@ -10,12 +10,18 @@ */ import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import { rpc } from "@src/api/tauri/rpc"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + loadLocalCanonicalConversationSnapshot, + loadLocalExecutionChildrenRevision, +} from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { createLogger } from "@src/hooks/logger"; import type { ActivityChunk } from "@src/types/session/session"; import { + isAgentSession, isCliSession, isImportedHistorySession, } from "@src/util/session/sessionDispatch"; @@ -71,6 +77,7 @@ interface ImportedReplayAnchorDraft { interface LoadedPushEvents { events: SessionEvent[]; localContentRevision?: number; + localExecutionRevision?: string | null; anchorDraft?: ImportedReplayAnchorDraft; precomputedEventHashes?: string[]; precomputedLocalFrozenEventCount?: number; @@ -100,19 +107,66 @@ function lastUserChunkIndex(chunks: readonly ActivityChunk[]): number { } export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { - private async loadFullPushEvents( + private localConversationRoot( + sessionId: string + ): ConversationRootLocator | null { + const importedSource = getImportedHistorySourceBySessionId(sessionId); + if (importedSource) { + return { + authority: "imported-history", + authorityScope: [importedSource.sourceId], + conversationId: sessionId, + }; + } + // A recognized imported id without a mounted source cannot be assigned a + // stable execution-parent identity. Keep its existing source-reader + // fallback rather than accidentally probing the local-session namespace. + if (isImportedHistorySession(sessionId)) return null; + if (!isCliSession(sessionId) && !isAgentSession(sessionId)) return null; + return { + authority: "local-session", + authorityScope: [], + conversationId: sessionId, + }; + } + + protected async loadLocalExecutionRevision( sessionId: string + ): Promise { + const root = this.localConversationRoot(sessionId); + return root ? loadLocalExecutionChildrenRevision(root) : undefined; + } + + private async loadFullPushEvents( + sessionId: string, + knownLocalExecutionRevision?: string | null ): Promise { + const localExecutionRevision = + knownLocalExecutionRevision ?? + (await this.loadLocalExecutionRevision(sessionId)); + if ( + localExecutionRevision !== undefined && + localExecutionRevision !== "[]" + ) { + const root = this.localConversationRoot(sessionId); + if (root) { + const snapshot = await loadLocalCanonicalConversationSnapshot(root); + return { + events: snapshot.events, + localExecutionRevision: snapshot.childRevision, + }; + } + } if (isImportedHistorySession(sessionId)) { const source = getImportedHistorySourceBySessionId(sessionId); - if (!source) return { events: [] }; + if (!source) return { events: [], localExecutionRevision }; const chunks = await source.loadFullTranscriptChunks(sessionId); if (!Array.isArray(chunks) || chunks.length === 0) { - return { events: [] }; + return { events: [], localExecutionRevision }; } const events = await processChunksRust(chunks, sessionId); if (!source.loadCloudTurnIds || !source.loadCloudTurnWindows) { - return { events }; + return { events, localExecutionRevision }; } try { // Source turn ids are provider-native seek cursors. They intentionally @@ -154,6 +208,7 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { ) { return { events, + localExecutionRevision, anchorDraft: { turnIds, lastTurnStartEventIndex, @@ -170,7 +225,7 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { error ); } - return { events }; + return { events, localExecutionRevision }; } const revisionBefore = await eventStoreProxy.getPersistedEventRevision(sessionId); @@ -185,7 +240,11 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { ? revisionAfter.revision : undefined; if (persisted.length > 0 || !isCliSession(sessionId)) { - return { events: persisted, localContentRevision }; + return { + events: persisted, + localContentRevision, + localExecutionRevision, + }; } // Live CLI sessions keep their transcript of record in the CLI's native // store (account-profile aware) and never write the events cache, so a @@ -193,8 +252,13 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { // and the pass then stamps the event plane clean. Load the full native // transcript through the same command the session-resume path uses. const chunks = (await rpc.cli.chunks({ sessionId })) as ActivityChunk[]; - if (!Array.isArray(chunks) || chunks.length === 0) return { events: [] }; - return { events: await processChunksRust(chunks, sessionId) }; + if (!Array.isArray(chunks) || chunks.length === 0) { + return { events: [], localExecutionRevision }; + } + return { + events: await processChunksRust(chunks, sessionId), + localExecutionRevision, + }; } /** Authoritative complete loader retained for first anchor and recovery. */ @@ -472,6 +536,7 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { mode, baseEventCount, localContentRevision: loaded.localContentRevision, + localExecutionRevision: loaded.localExecutionRevision, events, plan, }; @@ -536,7 +601,15 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { if (cached) return cached; const prepared = (async (): Promise => { const stampAtRead = this.eventActivityStamps.get(sessionId) ?? 0; - if (!forceFull && cursor && isImportedHistorySession(sessionId)) { + const localExecutionRevision = + await this.loadLocalExecutionRevision(sessionId); + if ( + !forceFull && + cursor && + isImportedHistorySession(sessionId) && + (localExecutionRevision === undefined || + localExecutionRevision === "[]") + ) { try { const incremental = await this.tryLoadIncrementalImportedPushEvents( sessionId, @@ -547,7 +620,7 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { stampAtRead, "incremental", incremental.baseEventCount, - incremental, + { ...incremental, localExecutionRevision }, cursor ); } @@ -563,7 +636,7 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { stampAtRead, "full", 0, - await this.loadFullPushEvents(sessionId) + await this.loadFullPushEvents(sessionId, localExecutionRevision) ); })(); this.cachePreparedPushEvents(prepareKey, prepared); diff --git a/src/features/Org2Cloud/org2CloudSessionSync.state.ts b/src/features/Org2Cloud/org2CloudSessionSync.state.ts index f0a068e4ef..3feebdb379 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.state.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.state.ts @@ -195,14 +195,21 @@ export class Org2CloudSessionSyncState { return now - changedAt >= EXTERNAL_HISTORY_ACTIVITY_DEBOUNCE_MS; } - protected isEventPlaneClean(orgId: string, session: Session): boolean { + protected isEventPlaneClean( + orgId: string, + session: Session, + localExecutionRevision?: string | null + ): boolean { const clean = this.cleanEventPlanes.get(session.session_id)?.get(orgId); if (!clean) return false; // EventStore notifications clear this stamp immediately; the durable // session version is the backstop for writes missed while the renderer // was suspended. A verified unchanged version stays clean for the app // lifetime instead of forcing a full-history reread every ten minutes. - return clean.sourceUpdatedAt === session.updated_at; + return ( + clean.sourceUpdatedAt === session.updated_at && + clean.localExecutionRevision === localExecutionRevision + ); } protected markEventPlaneClean( @@ -210,10 +217,15 @@ export class Org2CloudSessionSyncState { session: Session, stampAtRead: number, verifiedAt = Date.now(), - localContentRevision?: number + localContentRevision?: number, + localExecutionRevision?: string | null ): void { const sessionId = session.session_id; if ((this.eventActivityStamps.get(sessionId) ?? 0) !== stampAtRead) return; + // A child was created or updated while the canonical replay was being + // read. The upload is still safe, but it is not a clean-plane proof; the + // next activity pass must take another authoritative snapshot. + if (localExecutionRevision === null) return; let byOrg = this.cleanEventPlanes.get(sessionId); if (!byOrg) { byOrg = new Map(); @@ -222,6 +234,9 @@ export class Org2CloudSessionSyncState { byOrg.set(orgId, { verifiedAt, sourceUpdatedAt: session.updated_at, + ...(localExecutionRevision !== undefined + ? { localExecutionRevision } + : {}), }); const cursor = this.getCursor(orgId, sessionId); if (!cursor) return; diff --git a/src/features/Org2Cloud/org2CloudSessionSync.ts b/src/features/Org2Cloud/org2CloudSessionSync.ts index 39fde3da44..1f399e2628 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.ts @@ -184,6 +184,18 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncUpload { ) { return; } + const localExecutionRevision = await this.loadLocalExecutionRevision( + session.session_id + ); + // The durable cursor predates continuation-child revision stamps. A root + // with children therefore needs one authoritative combined replay after + // every app start before it can be marked clean in memory. + if ( + localExecutionRevision !== undefined && + localExecutionRevision !== "[]" + ) { + return; + } let localContentRevision: number | undefined; if (!isImportedHistorySession(session.session_id)) { const durable = await eventStoreProxy.getPersistedEventRevision( @@ -216,7 +228,8 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncUpload { session, this.eventActivityStamps.get(session.session_id) ?? 0, Date.now(), - localContentRevision + localContentRevision, + localExecutionRevision ); } @@ -311,7 +324,9 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncUpload { // event-store stamp, and defer metadata together with replay so a live CLI // turn does not produce one cloud upsert per scanner refresh. if (!this.isExternalHistorySettled(session)) return; - if (this.isEventPlaneClean(orgId, session)) { + const currentLocalExecutionRevision = + await this.loadLocalExecutionRevision(sessionId); + if (this.isEventPlaneClean(orgId, session, currentLocalExecutionRevision)) { await this.upsertMetadataIfChanged( auth, orgId, @@ -323,15 +338,22 @@ export class Org2CloudSessionSync extends Org2CloudSessionSyncUpload { } const cursor = this.getCursor(orgId, sessionId); const prepared = await this.preparePushEventsForPass(sessionId, cursor); - const { stampAtRead, mode, baseEventCount, localContentRevision, events } = - prepared; + const { + stampAtRead, + mode, + baseEventCount, + localContentRevision, + localExecutionRevision, + events, + } = prepared; const markPreparedClean = () => this.markEventPlaneClean( orgId, session, stampAtRead, Date.now(), - localContentRevision + localContentRevision, + localExecutionRevision ); if (!cursor && events.length === 0) { await this.upsertMetadataIfChanged( diff --git a/src/features/Org2Cloud/org2CloudSessionSync.types.ts b/src/features/Org2Cloud/org2CloudSessionSync.types.ts index 5e7ffb64dc..3ec487ade2 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.types.ts @@ -46,6 +46,8 @@ export interface PreparedPushEvents { baseEventCount: number; /** Durable native-cache revision covered by this materialization. */ localContentRevision?: number; + /** Stable local continuation-child catalog covered by this materialization. */ + localExecutionRevision?: string | null; events: SessionEvent[]; plan(): Promise; } @@ -54,6 +56,8 @@ export interface CleanEventPlaneStamp { verifiedAt: number; /** Imported transcript version used for this proof. */ sourceUpdatedAt?: string; + /** Local continuation-child catalog covered by this proof. */ + localExecutionRevision?: string; } export interface ExternalHistoryVersionObservation { diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index 1acb3c7786..8d42b180a6 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -70,6 +70,8 @@ beforeEach(() => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -276,6 +278,8 @@ describe("storage segment offload (0006)", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); }); @@ -364,6 +368,8 @@ describe("storage segment offload (0006)", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, + conversationTurnCoordination: false, }); await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null)); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/src/features/Org2Cloud/org2CloudSyncCoverage.ts b/src/features/Org2Cloud/org2CloudSyncCoverage.ts index dd0e9381a6..ac02c09ed5 100644 --- a/src/features/Org2Cloud/org2CloudSyncCoverage.ts +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.ts @@ -64,6 +64,7 @@ export type SyncCoverageSession = Pick< | "orgMemberId" | "agentOrgId" | "importedFrom" + | "clientOrigin" | "repoPath" | "repoRemoteUrls" | "forkedFrom" diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts index 4dc4b69028..cd6f997e9f 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from "vitest"; +import { toFrontendSession } from "@src/api/tauri/session"; +import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import type { Session } from "@src/store/session/sessionAtom/types"; import { buildCloudSessionMetadata, isCloudPushCandidate, -} from "./org2CloudSyncEngine"; -import { SCOPE_KEY, SESSION } from "./org2CloudSyncEngine.testUtils"; +} from "./org2CloudSessionSync.metadata"; +import { + SCOPE_KEY, + SESSION, + cleanupEngineFixture, + createEngineFixture, +} from "./org2CloudSyncEngine.testUtils"; describe("buildCloudSessionMetadata", () => { it("mirrors the toRemoteMetadata shape with the cloud user as owner", () => { @@ -45,7 +52,44 @@ describe("buildCloudSessionMetadata", () => { }); describe("isCloudPushCandidate", () => { - it("excludes only imported teammate copies; the user's own external history is shareable", () => { + it("never publishes a hydrated managed mirror in the sync loop", async () => { + const { store, client, engine } = createEngineFixture(); + try { + const hydrated = toFrontendSession({ + sessionId: SESSION.session_id, + name: SESSION.name ?? "Managed native mirror", + status: "completed", + createdAt: SESSION.created_at, + updatedAt: SESSION.updated_at, + category: "cli_agent", + keySource: "own_key", + totalTokens: 0, + background: false, + isActive: false, + pinned: false, + clientOrigin: "org2", + }); + store.set(sessionsAtom, [{ ...SESSION, ...hydrated }]); + await engine.runSyncPass(); + await engine.runSyncPass(); + expect(client.upsertSessionMetadata).not.toHaveBeenCalled(); + // Prove the loop is live, not merely disabled by the fixture. + store.set(sessionsAtom, [SESSION]); + await engine.runSyncPass(); + expect(client.upsertSessionMetadata).toHaveBeenCalledTimes(1); + } finally { + cleanupEngineFixture(engine); + } + }); + it("excludes managed native mirrors even after exact-ID hydration", () => { + expect(isCloudPushCandidate({ ...SESSION, clientOrigin: "org2" })).toBe( + false + ); + expect(isCloudPushCandidate({ ...SESSION, clientOrigin: "cli" })).toBe( + true + ); + }); + it("excludes imported teammate copies; ordinary external history is shareable", () => { expect(isCloudPushCandidate(SESSION)).toBe(true); // Imported teammate copy (pulled from the cloud) — excluded (echo-loop). expect( @@ -56,7 +100,7 @@ describe("isCloudPushCandidate", () => { ).toBe(false); // The user's OWN external history (no importedFrom) is now shareable. // Annotated rather than passed inline: the predicate only reads - // `importedFrom`, so an inline literal trips the excess-property check on + // provenance fields, so an inline literal trips the excess-property check on // the narrowed parameter — `category` is the case under test, not noise. const externalHistory: Session = { ...SESSION, diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts index 53e2ac7359..0fef5c25aa 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.testUtils.ts @@ -3,6 +3,10 @@ import { vi } from "vitest"; import type { CollabOutboxPushItem } from "@src/api/http/project"; import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import Message from "@src/components/Message"; +import { + loadLocalCanonicalConversationSnapshot, + loadLocalExecutionChildrenRevision, +} from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { EventDisplayStatus, @@ -104,6 +108,14 @@ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ }, })); +vi.mock( + "@src/engines/SessionCore/conversations/localConversationExecutionTail", + () => ({ + loadLocalExecutionChildrenRevision: vi.fn(), + loadLocalCanonicalConversationSnapshot: vi.fn(), + }) +); + vi.mock("@src/engines/SessionCore/ingestion/rustBridge", () => ({ processChunksRust: vi.fn(), })); @@ -152,6 +164,12 @@ vi.mock("./org2CloudProjectOrgAlias", () => ({ })); export const eventStoreMock = vi.mocked(eventStoreProxy); +export const localExecutionRevisionMock = vi.mocked( + loadLocalExecutionChildrenRevision +); +export const localCanonicalSnapshotMock = vi.mocked( + loadLocalCanonicalConversationSnapshot +); export const processChunksRustMock = vi.mocked(processChunksRust); export const peekMock = vi.mocked(peekShareableScopeKeys); export const primeMock = vi.mocked(primeShareableScopeKey); @@ -366,6 +384,13 @@ export function createEngineFixture() { eventCount: 2, revision: 1, }); + localExecutionRevisionMock.mockResolvedValue("[]"); + localCanonicalSnapshotMock.mockResolvedValue({ + events: [], + rootEvents: [], + segments: [], + childRevision: "[]", + }); processChunksRustMock.mockResolvedValue([]); vi.useFakeTimers(); engine.start(store); diff --git a/src/features/Org2Cloud/sessionCommentTarget.test.ts b/src/features/Org2Cloud/sessionCommentTarget.test.ts index db494d54c2..c285743932 100644 --- a/src/features/Org2Cloud/sessionCommentTarget.test.ts +++ b/src/features/Org2Cloud/sessionCommentTarget.test.ts @@ -4,7 +4,10 @@ import { cloudOrgToken } from "@src/features/TeamCollaboration/sessionOrgTagsAto import { rerootSessionCommentTarget, + resolvePendingCloudConversationTarget, resolveSessionCommentTarget, + retireExpiredOwnerCommentTarget, + sessionCommentTargetForConversationRoot, } from "./sessionCommentTarget"; const CLOUD_ORGS = [ @@ -21,6 +24,50 @@ const IMPORTED = { count: 10, }; +describe("pending Cloud conversation authority", () => { + it("retains a tagged Cloud root before the membership roster loads", () => { + expect( + resolvePendingCloudConversationTarget({ + session: { session_id: "sess-1" }, + tags: { "sess-1": [cloudOrgToken("org-a")] }, + preferredOrgId: null, + }) + ).toEqual({ orgId: "org-a", sessionId: "sess-1" }); + }); + + it("does not manufacture Cloud authority for a plain local session", () => { + expect( + resolvePendingCloudConversationTarget({ + session: { session_id: "sess-1" }, + tags: {}, + preferredOrgId: null, + }) + ).toBeNull(); + }); +}); + +describe("sessionCommentTargetForConversationRoot", () => { + it("keeps Team Chat on the Cloud root while a native child executes", () => { + expect( + sessionCommentTargetForConversationRoot({ + authority: "org2-cloud", + authorityScope: ["org-a"], + conversationId: "root-1", + }) + ).toEqual({ orgId: "org-a", sessionId: "root-1" }); + }); + + it("does not manufacture Team Chat for local conversations", () => { + expect( + sessionCommentTargetForConversationRoot({ + authority: "local-session", + authorityScope: [], + conversationId: "local-1", + }) + ).toBeNull(); + }); +}); + describe("resolveSessionCommentTarget", () => { it("imported teammate session targets the SOURCE coordinates", () => { expect( @@ -368,3 +415,67 @@ describe("rerootSessionCommentTarget", () => { }); }); }); + +describe("retireExpiredOwnerCommentTarget", () => { + const target = { orgId: "org-a", sessionId: "local-1" }; + const listedRow = { sourceSessionId: "local-1" } as never; + + it("keeps an owner-local target while its row is listed or the listing is loading", () => { + expect( + retireExpiredOwnerCommentTarget( + target, + { importedFrom: undefined }, + { state: "ready", rows: [listedRow] } + ) + ).toEqual(target); + expect( + retireExpiredOwnerCommentTarget( + target, + { importedFrom: undefined }, + { state: "loading", rows: [] } + ) + ).toEqual(target); + expect( + retireExpiredOwnerCommentTarget( + target, + { importedFrom: undefined }, + undefined + ) + ).toEqual(target); + }); + + it("retires an owner-local target whose row left a ready listing", () => { + expect( + retireExpiredOwnerCommentTarget( + target, + { importedFrom: undefined }, + { state: "ready", rows: [] } + ) + ).toBeNull(); + }); + + it("never retires a replay viewer's target", () => { + expect( + retireExpiredOwnerCommentTarget( + target, + { + importedFrom: { + orgId: "org-a", + sourceSessionId: "local-1", + } as never, + }, + { state: "ready", rows: [] } + ) + ).toEqual(target); + expect( + retireExpiredOwnerCommentTarget( + null, + { importedFrom: undefined }, + { + state: "ready", + rows: [], + } + ) + ).toBeNull(); + }); +}); diff --git a/src/features/Org2Cloud/sessionCommentTarget.ts b/src/features/Org2Cloud/sessionCommentTarget.ts index b7c647e9b7..ea5aaffc0b 100644 --- a/src/features/Org2Cloud/sessionCommentTarget.ts +++ b/src/features/Org2Cloud/sessionCommentTarget.ts @@ -8,6 +8,7 @@ import { useAtomValue } from "jotai"; import { useMemo } from "react"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; import { collectScopeMatchedImportedSessionIds } from "@src/features/TeamCollaboration/importedSessionScopeMatch"; import { @@ -19,12 +20,16 @@ import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/typ import type { Session } from "@src/store/session/sessionAtom/types"; import { chatPanelSelectedCloudOrgAtom } from "@src/store/ui/chatPanel/selectionAtoms"; +import { cloudConversationAuthorityIsLive } from "./SessionConversation/cloudConversationAuthority"; import type { Org2CloudOrg } from "./org2CloudOrgsAtom"; import { org2CloudOrgsAtom, parseCloudOrgSelectorValue, } from "./org2CloudOrgsAtom"; -import { org2CloudRemoteSessionsAtom } from "./org2CloudRemoteSessionsAtom"; +import { + type CloudOrgRemoteSessionsEntry, + org2CloudRemoteSessionsAtom, +} from "./org2CloudRemoteSessionsAtom"; import { org2CloudPushCursorsAtom, org2CloudPushedMetadataAtom, @@ -37,6 +42,77 @@ export interface SessionCommentTarget { sessionId: string; } +export function pushedCloudOrgIdsForSession( + sessionId: string, + pushCursors: Readonly>, + pushedMetadata: Readonly> +): string[] { + const suffix = `:${sessionId}`; + return [...Object.keys(pushCursors), ...Object.keys(pushedMetadata)].flatMap( + (key) => (key.endsWith(suffix) ? [key.slice(0, -suffix.length)] : []) + ); +} + +/** + * Preserve a durable Cloud authority while the membership roster hydrates. + * This does not grant access or enable Team Chat; it only prevents an Agent + * send from being misclassified as a local continuation during cold start. + */ +export function resolvePendingCloudConversationTarget(params: { + session: CommentTargetSession | null | undefined; + tags: SessionOrgTags; + preferredOrgId: string | null; + pushedOrgIds?: readonly string[]; +}): SessionCommentTarget | null { + const { session, tags, preferredOrgId, pushedOrgIds = [] } = params; + if (!session) return null; + if (session.importedFrom) { + return { + orgId: session.importedFrom.orgId, + sessionId: session.importedFrom.sourceSessionId, + }; + } + if (session.forkedFrom) { + return { + orgId: session.forkedFrom.orgId, + sessionId: session.forkedFrom.sourceSessionId, + }; + } + const ownedCloudOrgId = session.orgId + ? parseCloudOrgSelectorValue(session.orgId) + : null; + const candidates = [ + ...(ownedCloudOrgId ? [ownedCloudOrgId] : []), + ...cloudOrgIdsForSession(tags, session.session_id), + ...pushedOrgIds, + ].filter( + (orgId, index, all) => Boolean(orgId) && all.indexOf(orgId) === index + ); + const orgId = + preferredOrgId && candidates.includes(preferredOrgId) + ? preferredOrgId + : candidates[0]; + return orgId ? { orgId, sessionId: session.session_id } : null; +} + +/** Bridge a canonical Cloud root into the existing Team Chat target. */ +export function sessionCommentTargetForConversationRoot( + root: ConversationRootLocator | null | undefined +): SessionCommentTarget | null { + if ( + root?.authority !== "org2-cloud" || + (root.authorityScope.length !== 1 && root.authorityScope.length !== 2) + ) { + return null; + } + const orgId = root.authorityScope.at(-1); + if (!orgId) return null; + return { + orgId, + sessionId: root.conversationId, + }; +} + type CommentTargetSession = { session_id: string; /** Canonical launch ownership (`cloud:` for managed-cloud runs). */ @@ -170,7 +246,8 @@ export function resolveSessionCommentTarget(params: { * non-cloud session — consumers render nothing in that case. */ export function useSessionCommentTarget( - session: Session | null | undefined + session: Session | null | undefined, + canonicalTarget?: SessionCommentTarget | null ): SessionCommentTarget | null { const cloudOrgs = useAtomValue(org2CloudOrgsAtom); const tags = useAtomValue(sessionOrgTagsAtom); @@ -181,12 +258,10 @@ export function useSessionCommentTarget( const pushedOrgIds = useMemo(() => { if (!session) return []; - const suffix = `:${session.session_id}`; - return [ - ...Object.keys(pushCursors), - ...Object.keys(pushedMetadata), - ].flatMap((key) => - key.endsWith(suffix) ? [key.slice(0, -suffix.length)] : [] + return pushedCloudOrgIdsForSession( + session.session_id, + pushCursors, + pushedMetadata ); }, [session, pushCursors, pushedMetadata]); @@ -194,17 +269,23 @@ export function useSessionCommentTarget( return useMemo(() => { const lineage = session ? getSessionForkedFrom(session) : undefined; - const target = resolveSessionCommentTarget({ - session: session ? { ...session, forkedFrom: lineage } : null, - cloudOrgs, - tags, - preferredOrgId: selectedCloudOrg?.orgId ?? null, - orgRepoScopes, - pushedOrgIds, - }); + const target = + canonicalTarget ?? + resolveSessionCommentTarget({ + session: session ? { ...session, forkedFrom: lineage } : null, + cloudOrgs, + tags, + preferredOrgId: selectedCloudOrg?.orgId ?? null, + orgRepoScopes, + pushedOrgIds, + }); const rows = target ? remoteEntries[target.orgId]?.rows : undefined; const rerooted = rerootSessionCommentTarget(target, rows); - return rerooted; + return retireExpiredOwnerCommentTarget( + rerooted, + session, + rerooted ? remoteEntries[rerooted.orgId] : undefined + ); }, [ session, cloudOrgs, @@ -213,9 +294,33 @@ export function useSessionCommentTarget( orgRepoScopes, pushedOrgIds, remoteEntries, + canonicalTarget, ]); } +/** + * An owner-local session whose Cloud row (and every live family member) has + * left a ready listing has no Cloud plane left to read or write: comments, + * mentions, and canonical turns would all fail with retention errors. Retire + * the target so every surface treats the session as local again. Replay + * viewers keep their target; their rows are the import input itself. + */ +export function retireExpiredOwnerCommentTarget( + target: SessionCommentTarget | null, + session: Pick | null | undefined, + entry: Pick | undefined +): SessionCommentTarget | null { + if (!target) return null; + return cloudConversationAuthorityIsLive({ + session: session ?? undefined, + target, + entry, + loadingSource: undefined, + }) + ? target + : null; +} + /** * One conversation, one discussion plane: comments on any fork-family member * belong to the family ROOT session, so every viewpoint — root owner, fork diff --git a/src/features/Org2Cloud/useCloudSessionActions.ts b/src/features/Org2Cloud/useCloudSessionActions.ts index 027a91951b..0c029b19dc 100644 --- a/src/features/Org2Cloud/useCloudSessionActions.ts +++ b/src/features/Org2Cloud/useCloudSessionActions.ts @@ -216,6 +216,9 @@ export function useCloudSessionActions( const sessionEnvironment = resolveCloudSessionEnvironmentIdentity(remoteSession); const sessionOwner = resolveCloudSessionOwnerIdentity(remoteSession); + const requestAuth = authRef.current; + if (!requestAuth) return "noop"; + const requestAuthIdentityKey = org2CloudAuthIdentityKey(requestAuth); // Store read at call time: the render-captured map can be stale, and // both sidebar connectors plus Kanban share this registry. Only the // clicked row's own in-flight action blocks it. @@ -265,6 +268,7 @@ export function useCloudSessionActions( localSessionId: pendingLocalId, entry: buildCloudPendingPlayEntry({ remoteSession, + authIdentityKey: requestAuthIdentityKey, orgId, pendingEvents, etaMs: decision.etaMs, @@ -366,8 +370,10 @@ export function useCloudSessionActions( reporter.report({ localSessionId: importSessionId, progress: { + authIdentityKey: requestAuthIdentityKey, rowId: remoteSession.id, orgId, + sourceSession: remoteSession, sessionEnvironment, sessionOwner, loadedEvents: maxLoadedEvents, @@ -549,8 +555,10 @@ export function useCloudSessionActions( upsertDownloadProgress({ localSessionId, progress: { + authIdentityKey: requestAuthIdentityKey, rowId: remoteSession.id, orgId, + sourceSession: remoteSession, sessionEnvironment, sessionOwner, loadedEvents: heldLoaded, @@ -632,6 +640,9 @@ export function useCloudSessionActions( const sessionEnvironment = resolveCloudSessionEnvironmentIdentity(remoteSession); const sessionOwner = resolveCloudSessionOwnerIdentity(remoteSession); + const requestAuth = authRef.current; + if (!requestAuth) return "noop"; + const requestAuthIdentityKey = org2CloudAuthIdentityKey(requestAuth); if (store.get(cloudSessionBusyRowsAtom).has(remoteSession.id)) { return "noop"; } @@ -674,6 +685,7 @@ export function useCloudSessionActions( localSessionId: pendingLocalId, entry: buildCloudPendingPlayEntry({ remoteSession, + authIdentityKey: requestAuthIdentityKey, orgId, pendingEvents, etaMs: decision.etaMs, @@ -762,8 +774,10 @@ export function useCloudSessionActions( reporter.report({ localSessionId: importSessionId, progress: { + authIdentityKey: requestAuthIdentityKey, rowId: remoteSession.id, orgId, + sourceSession: remoteSession, sessionEnvironment, sessionOwner, loadedEvents: maxLoadedEvents, @@ -838,8 +852,10 @@ export function useCloudSessionActions( upsertDownloadProgress({ localSessionId: importSessionId, progress: { + authIdentityKey: requestAuthIdentityKey, rowId: remoteSession.id, orgId, + sourceSession: remoteSession, sessionEnvironment, sessionOwner, loadedEvents: heldLoaded, diff --git a/src/features/Org2Cloud/useCloudSessionDownloadSurface.test.ts b/src/features/Org2Cloud/useCloudSessionDownloadSurface.test.ts new file mode 100644 index 0000000000..7c901e1208 --- /dev/null +++ b/src/features/Org2Cloud/useCloudSessionDownloadSurface.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { createElement } from "react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import { + type SmokeRoot, + createSmokeRoot, + dispatch, +} from "@src/test/reactSmokeHarness"; + +import { cloudDownloadPendingPlayAtom } from "./cloudSessionDownloadControlAtoms"; +import { cloudSessionDownloadProgressAtom } from "./cloudSessionDownloadProgressAtom"; +import { + type Org2CloudAuthState, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "./org2CloudAuthAtom"; +import { + useCloudSessionDownloadProgressEntry, + useCloudSessionHasDownloadSurface, + useCloudSessionLoadingSource, + useCloudSessionPendingPlayEntry, +} from "./useCloudSessionDownloadSurface"; + +const AUTH_A: Org2CloudAuthState = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-a", + accessToken: "jwt-a", + refreshToken: "refresh-a", + expiresAt: 4_000_000_000, +}; + +const AUTH_B: Org2CloudAuthState = { + ...AUTH_A, + userId: "user-b", + accessToken: "jwt-b", + refreshToken: "refresh-b", +}; + +function source(ownerUserId: string): RemoteTeammateSessionMetadata { + return { + id: `row-${ownerUserId}`, + orgId: "org-1", + ownerMemberId: `member-${ownerUserId}`, + ownerUserId, + ownerDisplayName: ownerUserId, + ownerIdentityKind: "human", + sourceSessionId: "source-1", + title: "Shared session", + eventsEpoch: 1, + eventsFrozenSeq: 2, + eventsCount: 3, + eventsTailHash: "tail", + }; +} + +describe("Cloud download surface auth identity", () => { + let root: SmokeRoot | null = null; + + afterEach(async () => { + await root?.unmount(); + root = null; + }); + + it("hides pending/progress source data immediately after an account switch", async () => { + const store = createStore(); + const identityA = org2CloudAuthIdentityKey(AUTH_A); + const sourceA = source("user-a"); + store.set(org2CloudAuthAtom, AUTH_A); + store.set( + cloudDownloadPendingPlayAtom, + new Map([ + [ + "imported-session-1", + { + authIdentityKey: identityA, + rowId: sourceA.id, + orgId: "org-1", + sourceSession: sourceA, + iconId: "codex", + pendingEvents: 3, + etaMs: 1_000, + kind: "replay" as const, + }, + ], + ]) + ); + store.set( + cloudSessionDownloadProgressAtom, + new Map([ + [ + "imported-session-1", + { + authIdentityKey: identityA, + rowId: sourceA.id, + orgId: "org-1", + sourceSession: sourceA, + loadedEvents: 1, + totalEvents: 3, + startedAtMs: 1, + updatedAtMs: 2, + phase: "downloading" as const, + }, + ], + ]) + ); + + const Harness = () => { + const loadingSource = useCloudSessionLoadingSource("imported-session-1"); + const progress = + useCloudSessionDownloadProgressEntry("imported-session-1"); + const pending = useCloudSessionPendingPlayEntry("imported-session-1"); + const hasSurface = + useCloudSessionHasDownloadSurface("imported-session-1"); + return createElement("output", { + "data-has-surface": String(hasSurface), + "data-pending-user-id": pending?.sourceSession.ownerUserId ?? "", + "data-progress-user-id": progress?.sourceSession?.ownerUserId ?? "", + "data-source-user-id": loadingSource?.ownerUserId ?? "", + }); + }; + const readSurface = () => { + const output = root?.container.querySelector("output"); + return { + hasSurface: output?.getAttribute("data-has-surface") === "true", + pendingUserId: + output?.getAttribute("data-pending-user-id") || undefined, + progressUserId: + output?.getAttribute("data-progress-user-id") || undefined, + sourceUserId: output?.getAttribute("data-source-user-id") || undefined, + }; + }; + + root = createSmokeRoot(); + await root.render( + createElement(Provider, { store }, createElement(Harness)) + ); + expect(readSurface()).toEqual({ + sourceUserId: "user-a", + progressUserId: "user-a", + pendingUserId: "user-a", + hasSurface: true, + }); + + await dispatch(() => store.set(org2CloudAuthAtom, AUTH_B)); + expect(readSurface()).toEqual({ + sourceUserId: undefined, + progressUserId: undefined, + pendingUserId: undefined, + hasSurface: false, + }); + }); +}); diff --git a/src/features/Org2Cloud/useCloudSessionDownloadSurface.ts b/src/features/Org2Cloud/useCloudSessionDownloadSurface.ts index 189f391bb5..fc0b9fb295 100644 --- a/src/features/Org2Cloud/useCloudSessionDownloadSurface.ts +++ b/src/features/Org2Cloud/useCloudSessionDownloadSurface.ts @@ -11,6 +11,8 @@ import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import { useMemo } from "react"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + import { type CloudPendingPlay, cloudDownloadPendingPlayAtom, @@ -19,16 +21,27 @@ import { type CloudSessionDownloadProgress, cloudSessionDownloadProgressAtom, } from "./cloudSessionDownloadProgressAtom"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "./org2CloudAuthAtom"; + +function useCurrentCloudAuthIdentityKey(): string | null { + const auth = useAtomValue(org2CloudAuthAtom); + return auth ? org2CloudAuthIdentityKey(auth) : null; +} export function useCloudSessionDownloadProgressEntry( sessionId: string | null | undefined ): CloudSessionDownloadProgress | undefined { + const authIdentityKey = useCurrentCloudAuthIdentityKey(); const entryAtom = useMemo( () => - selectAtom(cloudSessionDownloadProgressAtom, (map) => - sessionId ? map.get(sessionId) : undefined - ), - [sessionId] + selectAtom(cloudSessionDownloadProgressAtom, (map) => { + const entry = sessionId ? map.get(sessionId) : undefined; + return entry?.authIdentityKey === authIdentityKey ? entry : undefined; + }), + [authIdentityKey, sessionId] ); return useAtomValue(entryAtom); } @@ -36,16 +49,27 @@ export function useCloudSessionDownloadProgressEntry( export function useCloudSessionPendingPlayEntry( sessionId: string | null | undefined ): CloudPendingPlay | undefined { + const authIdentityKey = useCurrentCloudAuthIdentityKey(); const entryAtom = useMemo( () => - selectAtom(cloudDownloadPendingPlayAtom, (map) => - sessionId ? map.get(sessionId) : undefined - ), - [sessionId] + selectAtom(cloudDownloadPendingPlayAtom, (map) => { + const entry = sessionId ? map.get(sessionId) : undefined; + return entry?.authIdentityKey === authIdentityKey ? entry : undefined; + }), + [authIdentityKey, sessionId] ); return useAtomValue(entryAtom); } +/** Source metadata visible before a local imported Session row exists. */ +export function useCloudSessionLoadingSource( + sessionId: string | null | undefined +): RemoteTeammateSessionMetadata | undefined { + const progress = useCloudSessionDownloadProgressEntry(sessionId); + const pending = useCloudSessionPendingPlayEntry(sessionId); + return progress?.sourceSession ?? pending?.sourceSession; +} + /** * True while the session owns a download surface — pending play, live * transfer, paused, or the completed linger. The chat pane's empty/loading @@ -56,19 +80,22 @@ export function useCloudSessionPendingPlayEntry( export function useCloudSessionHasDownloadSurface( sessionId: string | null | undefined ): boolean { + const authIdentityKey = useCurrentCloudAuthIdentityKey(); const hasAtom = useMemo( () => - selectAtom(cloudSessionDownloadProgressAtom, (map) => - sessionId ? map.has(sessionId) : false - ), - [sessionId] + selectAtom(cloudSessionDownloadProgressAtom, (map) => { + const entry = sessionId ? map.get(sessionId) : undefined; + return entry?.authIdentityKey === authIdentityKey; + }), + [authIdentityKey, sessionId] ); const hasPendingAtom = useMemo( () => - selectAtom(cloudDownloadPendingPlayAtom, (map) => - sessionId ? map.has(sessionId) : false - ), - [sessionId] + selectAtom(cloudDownloadPendingPlayAtom, (map) => { + const entry = sessionId ? map.get(sessionId) : undefined; + return entry?.authIdentityKey === authIdentityKey; + }), + [authIdentityKey, sessionId] ); const hasProgress = useAtomValue(hasAtom); const hasPending = useAtomValue(hasPendingAtom); diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts index 259ea7cb25..0b10d4e92f 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts @@ -8,7 +8,13 @@ import { activeSessionIdAtom } from "@src/store/session/viewAtom"; import { chatPanelSelectedCloudOrgAtom } from "@src/store/ui/chatPanel/selectionAtoms"; import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; +import { conversationPlaneSignalAtom } from "./SessionConversation/conversationPlaneAtom"; import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; +import { + org2CloudCommentsSignalAtom, + sessionCommentsKey, +} from "./org2CloudCommentsBus"; +import { ORG_DB_CHANGED_EVENT } from "./org2CloudControlBus"; import { type Org2CloudOrg, org2CloudOrgsAtom, @@ -20,6 +26,7 @@ import type { Org2CloudRealtimeConnection, Org2CloudSubscribeOptions, } from "./org2CloudRealtimeClient"; +import { REALTIME_SIGNAL_COALESCE_MS } from "./org2CloudRealtimeSignalCoalescer"; import { useOrg2CloudRealtime } from "./useOrg2CloudRealtime"; const mocks = vi.hoisted(() => ({ @@ -378,4 +385,130 @@ describe("useOrg2CloudRealtime lifecycle", () => { expect(connection.presences[0]?.handle.leave).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(baselineTimerCount); }); + + it("invalidates the canonical conversation plane on every visible subscribed edge", async () => { + await mount(); + const connection = connections[0]!; + const signalSubscription = subscription( + connection, + "org_change_signals", + "org_id=eq.org-a" + ); + const before = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + + act(() => signalSubscription.options.onStatus?.(true)); + const afterFull = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + expect(afterFull).toBe(before + 1); + + act(() => signalSubscription.options.onStatus?.(true)); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(afterFull + 1); + }); + + it("delivers the provider tail after a nearby admission signal without waiting for coarse recovery", async () => { + mocks.getCloudCapabilities.mockResolvedValue({ broadcastSignals: true }); + await mount(); + const presence = connections[0]!.presences.at(-1)!; + const before = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + const signal = () => + presence.options.onBroadcast?.(ORG_DB_CHANGED_EVENT, { + kind: "conversationEvents", + }); + + act(signal); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 1); + act(() => { + vi.advanceTimersByTime(100); + signal(); + }); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 1); + act(() => vi.advanceTimersByTime(REALTIME_SIGNAL_COALESCE_MS - 100)); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 2); + + // A pending trailing refresh must not outlive its org/socket owner. + act(signal); + await root.unmount(); + act(() => vi.advanceTimersByTime(REALTIME_SIGNAL_COALESCE_MS)); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 2); + }); + + it("owns short foreground recovery once and coalesces duplicate browser edges", async () => { + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true); + await mount(); + act(() => { + store.set(sessionsAtom, [ + { + session_id: "active-chat", + status: "idle", + created_at: "2026-09-08T00:00:00Z", + updated_at: "2026-09-08T00:00:00Z", + importedFrom: { + orgId: "org-a", + sourceSessionId: "source-chat", + ownerMemberId: "peer", + epoch: 1, + seq: 1, + count: 1, + }, + }, + ]); + store.set(activeSessionIdAtom, "active-chat"); + }); + const commentsKey = sessionCommentsKey("org-a", "source-chat"); + const commentsBefore = + store.get(org2CloudCommentsSignalAtom)[commentsKey] ?? 0; + const before = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + + act(() => { + window.dispatchEvent(new Event("focus")); + document.dispatchEvent(new Event("visibilitychange")); + window.dispatchEvent(new Event("online")); + }); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 1); + expect(store.get(org2CloudCommentsSignalAtom)[commentsKey]).toBe( + commentsBefore + 1 + ); + expect( + store.get(org2CloudCommentsSignalAtom)[ + sessionCommentsKey("org-a", "active-chat") + ] + ).toBeUndefined(); + + act(() => { + vi.advanceTimersByTime(REALTIME_SIGNAL_COALESCE_MS); + window.dispatchEvent(new Event("focus")); + }); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(before + 2); + expect(store.get(org2CloudCommentsSignalAtom)[commentsKey]).toBe( + commentsBefore + 2 + ); + const visibility = vi.spyOn(document, "visibilityState", "get"); + visibility.mockReturnValue("hidden"); + act(() => { + vi.advanceTimersByTime(REALTIME_SIGNAL_COALESCE_MS); + window.dispatchEvent(new Event("focus")); + }); + expect(store.get(org2CloudCommentsSignalAtom)[commentsKey]).toBe( + commentsBefore + 2 + ); + visibility.mockReturnValue("visible"); + act(() => { + store.set(sessionsAtom, (current) => + current.map((session) => ({ + ...session, + importedFrom: { ...session.importedFrom!, orgId: "org-b" }, + })) + ); + }); + act(() => window.dispatchEvent(new Event("focus"))); + expect(store.get(org2CloudCommentsSignalAtom)[commentsKey]).toBe( + commentsBefore + 2 + ); + expect( + store.get(org2CloudCommentsSignalAtom)[ + sessionCommentsKey("org-b", "source-chat") + ] + ).toBeUndefined(); + visibility.mockRestore(); + hasFocus.mockRestore(); + }); }); diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index 479b715215..bbde80d513 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -122,6 +122,7 @@ import { decideSubscribedEdgeRecovery } from "./org2CloudRealtimeRecovery"; import { resolveActiveRealtimeOrgId } from "./org2CloudRealtimeScope"; import { Org2CloudRealtimeSignalCoalescer, + REALTIME_SIGNAL_COALESCE_MS, STORM_SIGNAL_COALESCE_MS, } from "./org2CloudRealtimeSignalCoalescer"; import { @@ -132,6 +133,7 @@ import { } from "./org2CloudRemoteSessionsAtom"; import { org2CloudSessionCommentsAtom } from "./org2CloudSessionCommentsAtom"; import { org2CloudSyncEngine } from "./org2CloudSyncEngine"; +import { useSessionCommentTarget } from "./sessionCommentTarget"; const log = createLogger("Org2CloudRealtime"); @@ -201,6 +203,15 @@ function isDocumentHidden(): boolean { export function useOrg2CloudRealtime(): void { const auth = useAtomValue(org2CloudAuthAtom); const store = useStore(); + const activeSessionId = useAtomValue(activeSessionIdAtom) ?? ""; + const sessions = useAtomValue(sessionsAtom) as Session[]; + const activeCommentTarget = useSessionCommentTarget( + sessions.find((session) => session.session_id === activeSessionId) + ); + const activeCommentTargetRef = useRef(activeCommentTarget); + useEffect(() => { + activeCommentTargetRef.current = activeCommentTarget; + }, [activeCommentTarget]); const setAuth = useSetAtom(org2CloudAuthAtom); const cloudOrgs = useAtomValue(org2CloudOrgsAtom); const requestedActiveCloudOrgId = useAtomValue(sidebarActiveCloudOrgIdAtom); @@ -278,16 +289,16 @@ export function useOrg2CloudRealtime(): void { ); const bumpActiveSessionCommentsSignal = useCallback( (orgId: string) => { - const activeSessionId = store.get(activeSessionIdAtom); - if (!activeSessionId) return; + const target = activeCommentTargetRef.current; + if (!target || target.orgId !== orgId) return; setCommentsSignal((current) => bumpCommentsSignalKey( current, - sessionCommentsKey(orgId, activeSessionId) + sessionCommentsKey(orgId, target.sessionId) ) ); }, - [setCommentsSignal, store] + [setCommentsSignal] ); const bumpRemoteSessionsVersion = useCallback( (orgId: string, options: { full?: boolean } = {}) => { @@ -351,6 +362,7 @@ export function useOrg2CloudRealtime(): void { const orgFullRecoveryAtRef = useRef(new Map()); const connectionTeardownAtRef = useRef(undefined); const rosterEdgeRefetchAtRef = useRef(undefined); + const conversationForegroundRecoveryAtRef = useRef(0); useEffect(() => { refetchRef.current = refetchOrgs; }, [refetchOrgs]); @@ -422,6 +434,55 @@ export function useOrg2CloudRealtime(): void { // socket after the blur grace. useEffect(() => startCrossWindowFocusPublisher(), []); + // A short app switch stays inside the Realtime lease's blur grace, so no + // reconnect edge exists to recover an at-most-once conversation signal. + // Own this once at the Cloud realtime boundary and invalidate the org's + // mounted conversation planes; each plane's existing after-seq loader owns + // the actual bounded pull. This avoids one browser listener and direct RPC + // path per mounted transcript surface. + useEffect(() => { + if ( + !activeRealtimeOrgId || + typeof window === "undefined" || + typeof document === "undefined" + ) { + return undefined; + } + const recoverConversationPlanes = () => { + if (document.visibilityState === "hidden") return; + if (typeof document.hasFocus === "function" && !document.hasFocus()) { + return; + } + const now = Date.now(); + if ( + now - conversationForegroundRecoveryAtRef.current < + REALTIME_SIGNAL_COALESCE_MS + ) { + return; + } + conversationForegroundRecoveryAtRef.current = now; + bumpConversationPlaneVersion(activeRealtimeOrgId); + // Team Chat is owned by the comments loader, not the agent plane. + // Force the active thread past its TTL after a missed foreground signal. + bumpActiveSessionCommentsSignal(activeRealtimeOrgId); + }; + window.addEventListener("focus", recoverConversationPlanes); + window.addEventListener("online", recoverConversationPlanes); + document.addEventListener("visibilitychange", recoverConversationPlanes); + return () => { + window.removeEventListener("focus", recoverConversationPlanes); + window.removeEventListener("online", recoverConversationPlanes); + document.removeEventListener( + "visibilitychange", + recoverConversationPlanes + ); + }; + }, [ + activeRealtimeOrgId, + bumpConversationPlaneVersion, + bumpActiveSessionCommentsSignal, + ]); + // --- Connection + Slice A (roster). Rebuilds on user / endpoint / active // org. A fresh connection on scope switch avoids supabase-js reusing a // presence topic whose asynchronous leave has not finished yet. @@ -721,6 +782,7 @@ export function useOrg2CloudRealtime(): void { bumpOrgCommentsSignal(orgId); bumpChannelsVersion(orgId); bumpChannelMessagesVersion(orgId); + bumpConversationPlaneVersion(orgId); return; } orgFullRecoveryAtRef.current.set(orgId, Date.now()); @@ -746,6 +808,7 @@ export function useOrg2CloudRealtime(): void { // Messages posted/edited/deleted during the gap arrive through the // channel's own `p_since` delta, which already carries tombstones. bumpChannelMessagesVersion(orgId); + bumpConversationPlaneVersion(orgId); }, [ armCoarseSignalSafetyNet, @@ -754,6 +817,7 @@ export function useOrg2CloudRealtime(): void { bumpActiveSessionCommentsSignal, bumpChannelsVersion, bumpChannelMessagesVersion, + bumpConversationPlaneVersion, refreshEntitlementForOrg, ] ); @@ -846,8 +910,6 @@ export function useOrg2CloudRealtime(): void { // rendering. Secondary/imported tabs intentionally diverge from the // WorkStation's remembered selection, so publishing that remembered id // makes two users viewing the same cloud replay advertise different rows. - const activeSessionId = useAtomValue(activeSessionIdAtom) ?? ""; - const sessions = useAtomValue(sessionsAtom) as Session[]; const sessionOrgTags = useAtomValue(sessionOrgTagsAtom); const remoteSessions = useAtomValue(org2CloudRemoteSessionsAtom); const displayName = auth?.profile?.displayName ?? ""; diff --git a/src/features/SessionCreator/agentRuntimeConfig.test.ts b/src/features/SessionCreator/agentRuntimeConfig.test.ts new file mode 100644 index 0000000000..10241218ef --- /dev/null +++ b/src/features/SessionCreator/agentRuntimeConfig.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; + +import { resolveAgentRuntimeSelection } from "./agentRuntimeConfig"; + +function account( + id: string, + modelType: "claude_code" | "codex", + model: string +): KeyVaultAccount { + return { + id, + hasLocalKey: true, + isListed: false, + modelType, + name: id, + status: "ready", + hasKey: true, + hasApiKey: false, + hasSessionToken: true, + canLaunchCli: true, + enabled: true, + availableModels: [model], + enabledModels: [model], + }; +} + +const registry = { + agents: [ + { + name: "claude_code", + compatibleApiProviders: ["anthropic_api"], + supportsRustAgents: false, + }, + { + name: "codex", + compatibleApiProviders: ["openai_compatible"], + supportsRustAgents: true, + }, + ], + apiProviders: [], +} as unknown as AgentRegistry; + +describe("agent runtime selection coordinator", () => { + it("requires an explicit Codex pair instead of choosing the first account", () => { + const resolution = resolveAgentRuntimeSelection({ + selection: { category: "cli_agent", cliAgentType: "codex" }, + candidates: [ + { + keySource: "own_key", + cliAgentType: "claude_code", + selectedAccountId: "anthropic-1", + model: "opus", + }, + ], + accounts: [ + account("anthropic-1", "claude_code", "opus"), + account("openai-1", "codex", "gpt-5.6-sol"), + ], + registry, + allowedCliAgentTypes: ["claude_code", "codex"], + allowHosted: false, + allowAmbientClaude: true, + }); + + expect(resolution).toEqual({ status: "needs_model_picker" }); + }); + + it("keeps Claude accountless auto-detect only when the caller allows it", () => { + const input = { + selection: { + category: "cli_agent" as const, + cliAgentType: "claude_code" as const, + }, + candidates: [], + accounts: [] as KeyVaultAccount[], + registry, + allowedCliAgentTypes: ["claude_code", "codex"], + allowHosted: false, + }; + + expect( + resolveAgentRuntimeSelection({ ...input, allowAmbientClaude: true }) + ).toEqual({ + status: "ready", + config: { + keySource: "own_key", + cliAgentType: "claude_code", + model: undefined, + selectedSourceModelType: "claude_code", + }, + }); + expect( + resolveAgentRuntimeSelection({ ...input, allowAmbientClaude: false }) + ).toEqual({ status: "needs_model_picker" }); + }); + + it("reuses one explicit pair across Codex and a Rust Agent", () => { + const candidate = { + keySource: "own_key" as const, + selectedAccountId: "openai-1", + model: "gpt-5.6-sol", + }; + const accounts = [account("openai-1", "codex", "gpt-5.6-sol")]; + + const agent = resolveAgentRuntimeSelection({ + selection: { category: "rust_agent" }, + candidates: [candidate], + accounts, + registry, + allowHosted: false, + allowAmbientClaude: true, + }); + expect(agent).toMatchObject({ + status: "ready", + config: { + cliAgentType: undefined, + selectedAccountId: "openai-1", + model: "gpt-5.6-sol", + }, + }); + + const codex = resolveAgentRuntimeSelection({ + selection: { category: "cli_agent", cliAgentType: "codex" }, + candidates: [ + agent.status === "ready" ? agent.config : { keySource: "own_key" }, + ], + accounts, + registry, + allowedCliAgentTypes: ["claude_code", "codex"], + allowHosted: false, + allowAmbientClaude: true, + }); + expect(codex).toMatchObject({ + status: "ready", + config: { + cliAgentType: "codex", + selectedAccountId: "openai-1", + model: "gpt-5.6-sol", + }, + }); + }); + + it("uses the same compatibility decision for New Session without inventing a pair", () => { + expect( + resolveAgentRuntimeSelection({ + selection: { category: "cli_agent", cliAgentType: "codex" }, + candidates: [ + { + keySource: "own_key", + selectedAccountId: "openai-1", + model: "gpt-5.6-sol", + selectedSourceModelType: "codex", + }, + ], + registry, + allowHosted: true, + allowAmbientClaude: false, + }) + ).toMatchObject({ status: "ready" }); + + expect( + resolveAgentRuntimeSelection({ + selection: { category: "cli_agent", cliAgentType: "codex" }, + candidates: [{ keySource: "own_key" }], + registry, + allowHosted: true, + allowAmbientClaude: false, + }) + ).toEqual({ status: "needs_model_picker" }); + }); +}); diff --git a/src/features/SessionCreator/agentRuntimeConfig.ts b/src/features/SessionCreator/agentRuntimeConfig.ts index 36b6bd8b02..8e3e2b15f0 100644 --- a/src/features/SessionCreator/agentRuntimeConfig.ts +++ b/src/features/SessionCreator/agentRuntimeConfig.ts @@ -11,7 +11,17 @@ * but structurally just "the launch-relevant fields of a model selection". * Both surfaces share it rather than maintaining structurally identical twins. */ +import type { DispatchCategory } from "@src/api/tauri/session"; +import { KEY_SOURCE, isHostedKey } from "@src/api/tauri/session"; +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import { + getCliCompatibleAccounts, + getRustCompatibleAccounts, + isSourceCompatibleWithAgent, +} from "@src/hooks/models/useAgentCompatibility"; +import { accountHasModel } from "@src/hooks/models/useModelAccountLookup"; import type { OrgMemberRuntimeConfig } from "@src/modules/MainApp/AgentOrgs/types"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; import type { AdvancedConfig } from "./types"; @@ -67,3 +77,183 @@ export function applyAgentRuntimeConfig( export function hasResolvedModel(config: AdvancedConfig): boolean { return Boolean(cleanValue(config.model) || cleanValue(config.listingModel)); } + +export interface AgentRuntimeSelection { + category: DispatchCategory; + cliAgentType?: AdvancedConfig["cliAgentType"]; +} + +export type AgentRuntimeSelectionResolution = + | { status: "ready"; config: AdvancedConfig } + | { status: "needs_model_picker" }; + +interface ResolveAgentRuntimeSelectionInput { + selection: AgentRuntimeSelection; + /** Explicit, already-selected pairs in preference order. */ + candidates: readonly AdvancedConfig[]; + registry: AgentRegistry; + /** When present, this inventory is authoritative for account validity. */ + accounts?: readonly KeyVaultAccount[]; + allowedCliAgentTypes?: readonly string[]; + allowHosted: boolean; + allowAmbientClaude: boolean; +} + +function selectedAccountForRuntime( + selection: AgentRuntimeSelection, + config: AdvancedConfig, + accounts: readonly KeyVaultAccount[], + registry: AgentRegistry +): KeyVaultAccount | null { + const accountId = cleanValue(config.selectedAccountId); + const model = cleanValue(config.model); + if (!accountId || !model) return null; + const compatible = + selection.category === "cli_agent" && selection.cliAgentType + ? getCliCompatibleAccounts(registry, selection.cliAgentType, [ + ...accounts, + ]) + : selection.category === "rust_agent" + ? getRustCompatibleAccounts(registry, [...accounts]) + : []; + return ( + compatible.find( + (account) => + account.id === accountId && + account.enabled && + account.hasKey && + accountHasModel(account, model) + ) ?? null + ); +} + +/** + * Resolve an agent/runtime change from explicit model+source pairs only. + * + * This is the shared New Session and continuation selection boundary. It + * never guesses an account from list order: an existing exact pair either + * remains executable for the selected runtime, or the existing model/source + * palette must complete the choice. Claude's signed-in CLI is the sole + * accountless runtime and is enabled only by callers that already support it. + */ +export function resolveAgentRuntimeSelection({ + selection, + candidates, + registry, + accounts, + allowedCliAgentTypes, + allowHosted, + allowAmbientClaude, +}: ResolveAgentRuntimeSelectionInput): AgentRuntimeSelectionResolution { + if ( + selection.category === "cli_agent" && + (!selection.cliAgentType || + (allowedCliAgentTypes && + !allowedCliAgentTypes.includes(selection.cliAgentType))) + ) { + return { status: "needs_model_picker" }; + } + if ( + selection.category !== "cli_agent" && + selection.category !== "rust_agent" + ) { + return { status: "needs_model_picker" }; + } + + for (const candidate of candidates) { + if (isHostedKey(candidate.keySource)) { + if ( + allowHosted && + selection.category === "rust_agent" && + hasResolvedModel(candidate) + ) { + return { + status: "ready", + config: { ...candidate, cliAgentType: undefined }, + }; + } + continue; + } + + const model = cleanValue(candidate.model); + const accountId = cleanValue(candidate.selectedAccountId); + if (!model || !accountId) continue; + + if (accounts) { + const account = selectedAccountForRuntime( + selection, + candidate, + accounts, + registry + ); + if (!account) continue; + return { + status: "ready", + config: { + ...candidate, + keySource: KEY_SOURCE.OWN, + cliAgentType: + selection.category === "cli_agent" + ? selection.cliAgentType + : undefined, + selectedAccountId: account.id, + model, + agent: account.modelType, + provider: account.modelType, + nativeHarnessType: account.nativeHarnessType, + selectedSourceLabel: account.name, + selectedSourceModelType: account.modelType, + }, + }; + } + + const sourceType = candidate.selectedSourceModelType; + if ( + !sourceType || + !isSourceCompatibleWithAgent( + registry, + selection.category, + selection.cliAgentType, + sourceType + ) + ) { + continue; + } + return { + status: "ready", + config: { + ...candidate, + keySource: KEY_SOURCE.OWN, + cliAgentType: + selection.category === "cli_agent" + ? selection.cliAgentType + : undefined, + selectedAccountId: accountId, + model, + }, + }; + } + + if ( + allowAmbientClaude && + selection.category === "cli_agent" && + selection.cliAgentType === "claude_code" + ) { + const ambientCandidate = candidates.find( + (candidate) => + !isHostedKey(candidate.keySource) && !candidate.selectedAccountId + ); + const ambientModel = cleanValue(ambientCandidate?.model); + return { + status: "ready", + config: { + keySource: KEY_SOURCE.OWN, + cliAgentType: "claude_code", + model: ambientModel === "default" ? undefined : ambientModel, + selectedSourceModelType: "claude_code", + }, + }; + } + + return { status: "needs_model_picker" }; +} diff --git a/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx b/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx index a7c29a1683..3ad445b7da 100644 --- a/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx +++ b/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx @@ -32,13 +32,11 @@ import { CREATOR_BOTTOM_DOCK_PADDING_CLASS, CREATOR_MIDDLE_POSITION_STYLE, } from "@src/modules/shared/layouts/blocks"; -import { - type AgentSelection, - DispatchCategoryPalette, -} from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; -import { DispatchCategoryDropdown } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown"; +import type { AgentSelection } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; +import { DispatchCategoryPicker } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker"; import { PresenceMenuButton } from "@src/scaffold/NavigationSidebar/blocks/SidebarBottomBar"; import type { CreatorRepoChromePosition } from "@src/store/session"; +import type { ModelPickerStyle } from "@src/store/ui/chatPanel/displayPrefsAtoms"; import { EditorArea, SessionInfoLine } from "../../components"; import RepoChromeRow from "./RepoChromeRow"; @@ -63,7 +61,7 @@ interface CategoryPickerProps { currentCategory: DispatchCategory; currentCliAgentType?: CliAgentType; includeHumanSession: boolean; - modelPickerStyle: string; + modelPickerStyle: ModelPickerStyle; onClose: () => void; onSelect: (selection: AgentSelection) => void; } @@ -646,34 +644,18 @@ const SessionCreatorChatPanelView: React.FC< /> )} - {categoryPickerProps.modelPickerStyle === "dropdown" ? ( - - ) : ( - - )} + {screenPickerProps && }
diff --git a/src/features/SessionCreator/variants/ChatPanel/useSessionCreatorChatPanelHandlers.ts b/src/features/SessionCreator/variants/ChatPanel/useSessionCreatorChatPanelHandlers.ts index 4ec17e2b63..f9df057285 100644 --- a/src/features/SessionCreator/variants/ChatPanel/useSessionCreatorChatPanelHandlers.ts +++ b/src/features/SessionCreator/variants/ChatPanel/useSessionCreatorChatPanelHandlers.ts @@ -14,7 +14,7 @@ import { showDesktopOperationVisibilityTest, wingmanListMonitors, } from "@src/api/tauri/agent"; -import { KEY_SOURCE } from "@src/api/tauri/session"; +import { resolveAgentRuntimeSelection } from "@src/features/SessionCreator/agentRuntimeConfig"; import type { AdvancedConfig } from "@src/features/SessionCreator/types"; import { createSystemPathSessionSource, @@ -22,10 +22,7 @@ import { getSystemPathSourcePath, isSystemPathSourceId, } from "@src/features/SessionCreator/utils/systemPathSource"; -import { - isSourceCompatibleWithAgent, - useAgentCompatibility, -} from "@src/hooks/models/useAgentCompatibility"; +import { useAgentCompatibility } from "@src/hooks/models/useAgentCompatibility"; import { useWorkingDirectoryForm } from "@src/scaffold/GlobalSpotlight/hooks/forms"; import type { AgentSelection } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; import type { RepoItem } from "@src/scaffold/GlobalSpotlight/types"; @@ -223,42 +220,18 @@ export function useSessionCreatorChatPanelHandlers({ })); if (selection.category === "human_session") return; - - const newCliType = selection.cliAgentType; - const hasModel = Boolean( - advancedConfig.model || advancedConfig.listingModel - ); - const hasSource = Boolean(advancedConfig.selectedSourceModelType); - const isHosted = advancedConfig.keySource === KEY_SOURCE.HOSTED; - - const isSourceCompatible = - !hasSource || - isHosted || - !newCliType || - isSourceCompatibleWithAgent( - registry, - selection.category, - newCliType, - advancedConfig.selectedSourceModelType! - ); - - if (!isSourceCompatible) { - setAdvancedConfig({ - ...advancedConfig, - keySource: advancedConfig.keySource, - cliAgentType: newCliType, - }); - setRequestModelOpen(true); - } else if (!hasModel || !hasSource) { - if (newCliType) { - setAdvancedConfig({ ...advancedConfig, cliAgentType: newCliType }); - } - setRequestModelOpen(true); - } else { - if (newCliType) { - setAdvancedConfig({ ...advancedConfig, cliAgentType: newCliType }); - } + const resolution = resolveAgentRuntimeSelection({ + selection, + candidates: [advancedConfig], + registry, + allowHosted: true, + allowAmbientClaude: false, + }); + if (resolution.status === "ready") { + setAdvancedConfig(resolution.config); + return; } + setRequestModelOpen(true); }, [setCreatorState, setAdvancedConfig, advancedConfig, registry] ); diff --git a/src/features/TeamCollaboration/engine/collabImportIdentity.test.ts b/src/features/TeamCollaboration/engine/collabImportIdentity.test.ts new file mode 100644 index 0000000000..d3370a2440 --- /dev/null +++ b/src/features/TeamCollaboration/engine/collabImportIdentity.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { rewriteEventsForImportedSnapshot } from "./collabImportIdentity"; + +function event(overrides: Partial = {}): SessionEvent { + return { + id: "source-event", + chunk_id: "source-chunk", + sessionId: "source-session", + createdAt: "2026-08-30T00:00:00.000Z", + functionName: "assistant_message", + uiCanonical: "assistant_message", + actionType: "assistant_message", + args: {}, + result: {}, + source: "assistant", + displayText: "hello", + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + ...overrides, + }; +} + +describe("rewriteEventsForImportedSnapshot", () => { + it("namespaces event identity while preserving valid canonical status", () => { + const [rewritten] = rewriteEventsForImportedSnapshot( + [event({ activityStatus: "pending" })], + "local-session" + ); + + expect(rewritten).toMatchObject({ + id: "local-session~source-event", + chunk_id: "local-session~source-chunk", + sessionId: "local-session", + activityStatus: "pending", + }); + }); + + it("normalizes legacy missing renderer fields before durable import", () => { + const legacyUser = event({ + source: "user", + chunk_id: undefined, + activityStatus: undefined, + } as Partial); + const legacyAssistant = event({ + chunk_id: undefined, + activityStatus: "unknown", + } as unknown as Partial); + + const rewritten = rewriteEventsForImportedSnapshot( + [legacyUser, legacyAssistant], + "local-session" + ); + + expect( + rewritten.map(({ chunk_id, activityStatus }) => ({ + chunk_id, + activityStatus, + })) + ).toEqual([ + { chunk_id: null, activityStatus: "processed" }, + { chunk_id: null, activityStatus: "agent" }, + ]); + }); +}); diff --git a/src/features/TeamCollaboration/engine/collabImportIdentity.ts b/src/features/TeamCollaboration/engine/collabImportIdentity.ts index c3f58ddc7c..8e2580edb7 100644 --- a/src/features/TeamCollaboration/engine/collabImportIdentity.ts +++ b/src/features/TeamCollaboration/engine/collabImportIdentity.ts @@ -12,6 +12,12 @@ import type { Session } from "@src/store/session/sessionAtom/types"; import { sha256Hex } from "../collabSyncUtils"; import { namespaceCopyEventId } from "../copyEventId"; +const IMPORTED_SESSION_ID_PREFIX = "imported-session-"; + +export function isImportedSessionId(sessionId: string): boolean { + return sessionId.startsWith(IMPORTED_SESSION_ID_PREFIX); +} + /** * Deterministic local session id for a teammate-session import, derived from * (endpoint, orgId, sourceSessionId). A FAILED import (durable cache write returned 0) @@ -27,7 +33,7 @@ export async function deriveImportedSessionId( const digest = await sha256Hex( `${normalizeSourceEndpointUrl(sourceEndpointUrl)}:${orgId}:${sourceSessionId}` ); - return `imported-session-${digest.slice(0, 32)}`; + return `${IMPORTED_SESSION_ID_PREFIX}${digest.slice(0, 32)}`; } export function normalizeSourceEndpointUrl(value: string): string { @@ -46,15 +52,32 @@ export function rewriteEventsForImportedSnapshot( events: SessionEvent[], localSessionId: string ): SessionEvent[] { - return events.map((event) => ({ - ...event, - id: namespaceCopyEventId(localSessionId, event.id), - chunk_id: - event.chunk_id == null - ? event.chunk_id - : namespaceCopyEventId(localSessionId, event.chunk_id), - sessionId: localSessionId, - })); + return events.map((event) => { + // Older cloud snapshots and lightweight exporters did not always emit + // the two renderer-only fields below. Normalize them at the shared import + // boundary so every Cloud plane (Team Session, personal sync, a future + // provider import) reaches the same durable canonical schema before the + // SQLite RPC validates it. + const activityStatus = + event.activityStatus === "agent" || + event.activityStatus === "pending" || + event.activityStatus === "processed" + ? event.activityStatus + : event.source === "user" + ? "processed" + : "agent"; + + return { + ...event, + id: namespaceCopyEventId(localSessionId, event.id), + chunk_id: + event.chunk_id == null + ? null + : namespaceCopyEventId(localSessionId, event.chunk_id), + sessionId: localSessionId, + activityStatus, + }; + }); } /** Legacy (pre-M3) shape: import provenance JSON-encoded in error_message. */ diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts index 0fa954738d..764c796034 100644 --- a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts @@ -3,7 +3,6 @@ import { getTurnIntentDispatch } from "@src/engines/SessionCore/control/turnInte import { beginTurnDispatch, clearTurnLifecycleSession, - getTurnGeneration, getTurnPhase, markTurnTerminal, } from "@src/engines/SessionCore/control/turnLifecycle"; @@ -17,6 +16,7 @@ import { toCliSessionStatus, toSessionListStatus, } from "@src/engines/SessionCore/sync/sessionSyncUtils"; +import { createLogger } from "@src/hooks/logger"; import { sessionsAtom, updateSessionStatus } from "@src/store/session"; import { getInstrumentedStore, @@ -29,6 +29,7 @@ export interface CliRunReceipt { turnIntentId: string; status: string; } +const log = createLogger("CliTurnLifecycle"); export interface CliLifecycleStatus { sessionId: string; @@ -112,8 +113,7 @@ export class CliTurnLifecycleCoordinator { const generation = dispatch?.generation ?? beginTurnDispatch(event.sessionId); - markCliRuntimeRunning(event.sessionId, generation); - if (getTurnGeneration(event.sessionId) !== generation) return false; + if (!markCliRuntimeRunning(event.sessionId, generation)) return false; this.setActive(event.sessionId, { turnIntentId, generation }); return true; } @@ -131,10 +131,21 @@ export class CliTurnLifecycleCoordinator { : undefined; if (dispatch && dispatch.sessionId !== event.sessionId) return false; const generation = existing?.generation ?? dispatch?.generation; - markTurnTerminal(event.sessionId, cliTerminalStatus(status), { - generation, - }); - markObservedCliTerminalStatus(event.sessionId, status); + if ( + !markTurnTerminal(event.sessionId, cliTerminalStatus(status), { + generation, + }) + ) { + return false; + } + void markObservedCliTerminalStatus(event.sessionId, status).catch( + (error: unknown) => { + log.error("CLI terminal reconciliation failed", { + sessionId: event.sessionId, + error, + }); + } + ); if (isStoreInitialized()) { updateSessionStatus(event.sessionId, toSessionListStatus(status)); } diff --git a/src/hooks/models/useAgentCompatibility.test.ts b/src/hooks/models/useAgentCompatibility.test.ts new file mode 100644 index 0000000000..b0e0b9d7b1 --- /dev/null +++ b/src/hooks/models/useAgentCompatibility.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import type { AgentRegistry } from "@src/store/session/agentRegistryAtom"; + +import { getCliCompatibleAccounts } from "./useAgentCompatibility"; + +function account( + id: string, + modelType: string, + overrides: Partial = {} +): KeyVaultAccount { + return { + id, + name: id, + modelType: modelType as KeyVaultAccount["modelType"], + hasLocalKey: true, + isListed: false, + status: "ready", + hasKey: true, + hasApiKey: false, + hasSessionToken: true, + enabled: true, + ...overrides, + }; +} + +const registry = { + agents: [ + { + name: "claude_code", + compatibleApiProviders: ["anthropic_api", "atlascloud_api"], + }, + ], + apiProviders: [], +} as unknown as AgentRegistry; + +describe("CLI account compatibility", () => { + it("keeps runnable Claude OAuth and supported providers, not Codex or invalid OAuth", () => { + const compatible = getCliCompatibleAccounts(registry, "claude_code", [ + account("anthropic-1", "claude_code", { + authMethod: "oauth", + canLaunchCli: true, + }), + account("atlas-1", "atlascloud_api", { hasApiKey: true }), + account("openai-1", "codex", { + authMethod: "oauth", + canLaunchCli: true, + }), + account("expired-anthropic", "claude_code", { + authMethod: "oauth", + status: "error", + canLaunchCli: true, + }), + ]); + + expect(compatible.map((entry) => entry.id)).toEqual([ + "anthropic-1", + "atlas-1", + ]); + }); +}); diff --git a/src/hooks/models/useAgentCompatibility.ts b/src/hooks/models/useAgentCompatibility.ts index 5006ad1f7d..380167a63c 100644 --- a/src/hooks/models/useAgentCompatibility.ts +++ b/src/hooks/models/useAgentCompatibility.ts @@ -12,6 +12,7 @@ import { CLI_AGENT } from "@src/api/types/keys"; import { type AgentRegistry, agentRegistryAtom, + agentRegistryDiscoveryStateAtom, } from "@src/store/session/agentRegistryAtom"; // ============ PURE FUNCTIONS ============ @@ -158,5 +159,6 @@ export function isSourceCompatibleWithAgent( */ export function useAgentCompatibility() { const registry = useAtomValue(agentRegistryAtom); - return { registry }; + const discoveryState = useAtomValue(agentRegistryDiscoveryStateAtom); + return { registry, discoveryState }; } diff --git a/src/hooks/models/useModelAccountLookup.ts b/src/hooks/models/useModelAccountLookup.ts index 13b3f1e662..451009f865 100644 --- a/src/hooks/models/useModelAccountLookup.ts +++ b/src/hooks/models/useModelAccountLookup.ts @@ -80,9 +80,11 @@ export function buildAccountLookup( * useKeyVault call. */ export function useModelAccountLookup() { - const { accounts } = useKeyVault({ autoLoad: true }); + const { accounts, loading, hasLoaded, error } = useKeyVault({ + autoLoad: true, + }); const accountLookup = useMemo(() => buildAccountLookup(accounts), [accounts]); - return { accountLookup, accounts }; + return { accountLookup, accounts, loading, hasLoaded, error }; } diff --git a/src/hooks/session/__tests__/useNativeSessionStatusMonitor.test.ts b/src/hooks/session/__tests__/useNativeSessionStatusMonitor.test.ts index 473b7e4309..10b0723586 100644 --- a/src/hooks/session/__tests__/useNativeSessionStatusMonitor.test.ts +++ b/src/hooks/session/__tests__/useNativeSessionStatusMonitor.test.ts @@ -26,7 +26,11 @@ import { vi, } from "vitest"; -import { resetTurnLifecycleForTests } from "@src/engines/SessionCore/control/turnLifecycle"; +import { + beginTurnDispatch, + getTurnPhase, + resetTurnLifecycleForTests, +} from "@src/engines/SessionCore/control/turnLifecycle"; import { deliverSessionTerminalNotification, shouldDeliverSessionTerminalNotification, @@ -174,6 +178,16 @@ describe("useNativeSessionStatusMonitor session-list status", () => { expectRowStatus("completed"); }); + it("does not let an unattributed old terminal close a newly dispatching turn", () => { + beginTurnDispatch(SESSION_ID); + + emitStatus("completed"); + + expect(getTurnPhase(SESSION_ID)).toBe("dispatching"); + expectRowStatus("running"); + expect(deliverSessionTerminalNotification).not.toHaveBeenCalled(); + }); + it("passes a non-terminal active status through to the session list", () => { emitStatus("waiting_for_user"); diff --git a/src/hooks/session/useNativeSessionStatusMonitor.ts b/src/hooks/session/useNativeSessionStatusMonitor.ts index 3a94283c29..011da1b538 100644 --- a/src/hooks/session/useNativeSessionStatusMonitor.ts +++ b/src/hooks/session/useNativeSessionStatusMonitor.ts @@ -49,6 +49,7 @@ import { import { activeSessionIdAtom, sessionByIdAtom, + setSessionRuntimeStatusAtom, updateSessionStatus, } from "@src/store/session"; import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; @@ -105,16 +106,41 @@ export function useNativeSessionStatusMonitor(options?: { "session-status-changed", (event) => { const { sessionId, status } = event.payload; + const cliStatus = toCliSessionStatus(status); const completedTurn = isSuccessfulNotificationTurnStatus(status); const session = isStoreInitialized() ? getInstrumentedStore().get(sessionByIdAtom(sessionId)) : undefined; + let lifecycleAccepted = true; if (completedTurn) { - markTurnTerminal(sessionId, "completed"); + lifecycleAccepted = markTurnTerminal(sessionId, "completed"); } else if (isTerminalStatus(status)) { - markTurnTerminal(sessionId, toTurnTerminalStatus(status)); + lifecycleAccepted = markTurnTerminal( + sessionId, + toTurnTerminalStatus(status) + ); } else if (isSessionRuntimeExecuting(status)) { - markTurnRunning(sessionId); + lifecycleAccepted = markTurnRunning(sessionId); + } + + // Finality and every presentation mirror move together. A late + // terminal/running event rejected by the generation-aware lifecycle + // must not still flip the footer, sidebar row, or notification state. + if (!lifecycleAccepted) return; + + // This Tauri event is the durable, process-wide status edge emitted + // after Rust commits the session row. The per-session Channel normally + // updates the foreground runtime mirror through agent:turn_completed, + // but an IPC frame can be lost while the global event still arrives. + // Keep the composer/Stop-button mirror convergent as well; the scoped + // write atom drops background-session updates when another Session is + // visible, so this cannot bleed a terminal into the wrong tab. + if (isStoreInitialized()) { + getInstrumentedStore().set(setSessionRuntimeStatusAtom, { + sessionId, + status: cliStatus, + source: "sync", + }); } const completedBoundary = @@ -147,10 +173,7 @@ export function useNativeSessionStatusMonitor(options?: { // grouping, Kanban lanes and every terminal-status predicate. Narrow // it against the Rust enum mirror, then map it onto `SessionStatus`, // instead of laundering it through `as SessionStatus`. - updateSessionStatus( - sessionId, - toSessionListStatus(toCliSessionStatus(status)) - ); + updateSessionStatus(sessionId, toSessionListStatus(cliStatus)); } ); diff --git a/src/modules/SessionWindow/index.tsx b/src/modules/SessionWindow/index.tsx index 3a114d1011..5aaaf5cc63 100644 --- a/src/modules/SessionWindow/index.tsx +++ b/src/modules/SessionWindow/index.tsx @@ -41,6 +41,7 @@ import { CHAT_PANEL_HEADER_NO_DRAG_STYLE, } from "@src/engines/ChatPanel/header"; import { shouldStartHeaderDragFromTarget } from "@src/engines/ChatPanel/header/chatPanelHeaderLayout"; +import { useConversationTargetBinding } from "@src/engines/ChatPanel/hooks/useConversationTargetBinding"; import { useSessionActionModals } from "@src/engines/ChatPanel/hooks/useSessionActionModals"; import { useSessionHeaderActions } from "@src/engines/ChatPanel/hooks/useSessionHeaderActions"; import { useSessionViewMode } from "@src/engines/ChatPanel/hooks/useSessionViewMode"; @@ -48,6 +49,7 @@ import { useEventStoreBridge } from "@src/engines/SessionCore/core/store/useEven import GlobalPlanningIndicatorBridgeSync from "@src/engines/SessionCore/hooks/replay/GlobalPlanningIndicatorBridgeSync"; import { useQueueDispatch } from "@src/engines/SessionCore/hooks/session/useQueueDispatch"; import SessionSyncProvider from "@src/engines/SessionCore/sync/SessionSyncProvider"; +import { dispatchQueuedCanonicalConversation } from "@src/features/ConversationContinuation/canonicalConversationDispatcher"; import SessionViewersIndicator from "@src/features/Org2Cloud/SessionViewersIndicator"; import { useNativeSessionStatusMonitor } from "@src/hooks/session/useNativeSessionStatusMonitor"; import { getPrimaryPaneBackgroundStyle } from "@src/modules/shared/layouts/viewContainerTokens"; @@ -76,7 +78,7 @@ const MACOS_TRAFFIC_LIGHTS_INSET_PX = 84; * while native notification delivery stays main-window-owned. */ const SessionWindowBridges: React.FC = () => { useEventStoreBridge(); - useQueueDispatch(); + useQueueDispatch(dispatchQueuedCanonicalConversation); useNativeSessionStatusMonitor({ notifications: false }); return ; }; @@ -91,6 +93,7 @@ const SessionWindowContent: React.FC<{ sessionId: string }> = memo( ]); const navigate = useNavigate(); const session = useAtomValue(sessionByIdAtom(sessionId)); + const conversationTargetBinding = useConversationTargetBinding(sessionId); const backgroundConfig = useAtomValue(resolvedBackgroundConfigAtom); const primaryPaneSurfaceStyle = useMemo( () => getPrimaryPaneBackgroundStyle(backgroundConfig.pageOpacity), @@ -209,6 +212,9 @@ const SessionWindowContent: React.FC<{ sessionId: string }> = memo( activeSessionExists={Boolean(session)} copyEventJsonLabel={headerActions.copyEventJsonLabel} currentSessionId={sessionId || null} + appOpenSessionId={ + conversationTargetBinding?.appOpenSessionId ?? null + } displayMode={headerActions.displayMode} eventsLength={headerActions.eventCount} handleCompactDisplayModeToggle={ @@ -261,8 +267,8 @@ const SessionWindowContent: React.FC<{ sessionId: string }> = memo( >
diff --git a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx index 8f7b696881..60a689ea80 100644 --- a/src/modules/WorkStation/TabContent/renderers/chatSession.tsx +++ b/src/modules/WorkStation/TabContent/renderers/chatSession.tsx @@ -18,6 +18,7 @@ import { SessionHeaderViewControls, SessionRawToolbarActions, } from "@src/engines/ChatPanel/components/SessionViewSwitcher"; +import { useConversationTargetBinding } from "@src/engines/ChatPanel/hooks/useConversationTargetBinding"; import { useSessionActionModals } from "@src/engines/ChatPanel/hooks/useSessionActionModals"; import { useSessionHeaderActions } from "@src/engines/ChatPanel/hooks/useSessionHeaderActions"; import { useSessionViewMode } from "@src/engines/ChatPanel/hooks/useSessionViewMode"; @@ -45,6 +46,7 @@ const ChatSessionTabRenderer: React.FC = memo( ]); const sessionId = String(tab.data.sessionId ?? ""); const session = useAtomValue(sessionByIdAtom(sessionId)); + const conversationTargetBinding = useConversationTargetBinding(sessionId); const backgroundConfig = useAtomValue(resolvedBackgroundConfigAtom); const primaryPaneSurfaceStyle = useMemo( () => getPrimaryPaneBackgroundStyle(backgroundConfig.pageOpacity), @@ -124,6 +126,7 @@ const ChatSessionTabRenderer: React.FC = memo( activeSessionExists={Boolean(session)} copyEventJsonLabel={headerActions.copyEventJsonLabel} currentSessionId={sessionId || null} + appOpenSessionId={conversationTargetBinding?.appOpenSessionId ?? null} displayMode={headerActions.displayMode} eventsLength={headerActions.eventCount} handleCompactDisplayModeToggle={ @@ -189,9 +192,9 @@ const ChatSessionTabRenderer: React.FC = memo( > diff --git a/src/scaffold/GlobalSpotlight/components/SpotlightAccountFooter.tsx b/src/scaffold/GlobalSpotlight/components/SpotlightAccountFooter.tsx index 8e5056ebca..9efb753d35 100644 --- a/src/scaffold/GlobalSpotlight/components/SpotlightAccountFooter.tsx +++ b/src/scaffold/GlobalSpotlight/components/SpotlightAccountFooter.tsx @@ -19,6 +19,7 @@ import { getCliCompatibleAccounts, useAgentCompatibility, } from "@src/hooks/models/useAgentCompatibility"; +import { credentialedAccounts } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts"; // ============ TYPES ============ @@ -87,9 +88,12 @@ export const SpotlightAccountFooter: React.FC = ( showIncompatible = false, incompatibleAccounts = [], } = props; - const keyCount = accounts.length; + const availableAccounts = credentialedAccounts(accounts); + const keyCount = availableAccounts.length; const hasKeys = keyCount > 0; - const incompatibleCount = incompatibleAccounts.length; + const availableIncompatibleAccounts = + credentialedAccounts(incompatibleAccounts); + const incompatibleCount = availableIncompatibleAccounts.length; const showIncompatibleRow = showIncompatible && incompatibleCount > 0; if (!hasKeys && !showIncompatibleRow) return null; @@ -104,7 +108,7 @@ export const SpotlightAccountFooter: React.FC = ( {keyCount} {hasKeys && (
- {accounts.map((acc) => ( + {availableAccounts.map((acc) => ( ))}
@@ -120,7 +124,7 @@ export const SpotlightAccountFooter: React.FC = ( {incompatibleCount}
- {incompatibleAccounts.map((acc) => ( + {availableIncompatibleAccounts.map((acc) => ( ))}
@@ -131,7 +135,9 @@ export const SpotlightAccountFooter: React.FC = ( } const { agentType, accounts, showIncompatible = false } = props; - const readyAccounts = getCliCompatibleAccounts(registry, agentType, accounts); + const readyAccounts = credentialedAccounts( + getCliCompatibleAccounts(registry, agentType, accounts) + ); const planAccounts = readyAccounts.filter( (acc) => !isApiKeyProvider(acc.modelType) ); @@ -145,7 +151,10 @@ export const SpotlightAccountFooter: React.FC = ( const incompatibleAccounts = showIncompatible ? accounts.filter( (acc) => - acc.status === "ready" && acc.hasKey && !compatibleSet.has(acc.id) + acc.status === "ready" && + acc.enabled && + acc.hasKey && + !compatibleSet.has(acc.id) ) : []; const incompatibleCount = incompatibleAccounts.length; diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx index 09f8931efa..ba8c40e756 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx @@ -106,6 +106,8 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { const rightContent = data.rightContent as React.ReactNode | undefined; const availableKeys = data.availableKeys as KeyVaultAccount[] | undefined; const isCurrent = data.isCurrentSelection === true; + const isDisabled = data.disabled === true; + const tagLabel = typeof data.tagLabel === "string" ? data.tagLabel : null; const testId = typeof data.testId === "string" ? data.testId : undefined; const renderedIcon = useMemo(() => { @@ -128,9 +130,10 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { type="button" data-testid={testId} {...keyboardProps} + disabled={isDisabled} className={`${DROPDOWN_CLASSES.item} ${DROPDOWN_CLASSES.itemHover} w-full justify-start ${ isCurrent ? DROPDOWN_CLASSES.itemSelected : "" - }`} + } ${isDisabled ? "cursor-not-allowed opacity-50" : ""}`} > {renderedIcon && ( @@ -147,6 +150,9 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { {item.desc} )} + {tagLabel && ( + {tagLabel} + )} {availableKeys ? ( ) : ( @@ -159,6 +165,7 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { interface DispatchCategoryDropdownProps extends DispatchCategoryPaletteProps { /** Element the dropdown is anchored to. */ anchorRef: React.RefObject; + placement?: "top" | "bottom"; } export const DispatchCategoryDropdown: React.FC< @@ -173,9 +180,11 @@ export const DispatchCategoryDropdown: React.FC< currentCliAgentType, hideOrgs = false, hideCliAgents = false, + allowedCliAgentTypes, cliOnly = false, includeHumanSession = false, anchorRef, + placement = "bottom", }) => { const { t: tCommon } = useTranslation("common"); const inputRef = useRef(null); @@ -184,6 +193,7 @@ export const DispatchCategoryDropdown: React.FC< isOpen, hideOrgs, hideCliAgents, + allowedCliAgentTypes, cliOnly, includeHumanSession, currentCategory, @@ -239,7 +249,7 @@ export const DispatchCategoryDropdown: React.FC< const handleSelect = useCallback((item: SpotlightItem) => { const data = getItemData(item); - if (data.isHeader === true) return; + if (data.isHeader === true || data.disabled === true) return; item.action?.(); }, []); @@ -252,12 +262,15 @@ export const DispatchCategoryDropdown: React.FC< if (!open) onClose(); }, anchorRef, - placement: "bottom", + placement, gap: DROPDOWN_PANEL.triggerGap, listNavigation: { items, onSelect: handleSelect, - isItemSelectable: (item) => getItemData(item).isHeader !== true, + isItemSelectable: (item) => { + const data = getItemData(item); + return data.isHeader !== true && data.disabled !== true; + }, initialSelectedIndex: -1, }, }); diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker.tsx new file mode 100644 index 0000000000..8d50b73b35 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +import type { ModelPickerStyle } from "@src/store/ui/chatPanel/displayPrefsAtoms"; + +import { DispatchCategoryDropdown } from "./DispatchCategoryDropdown"; +import { DispatchCategoryPalette } from "./index"; +import type { DispatchCategoryPaletteProps } from "./types"; + +export interface DispatchCategoryPickerProps extends DispatchCategoryPaletteProps { + style: ModelPickerStyle; + anchorRef: React.RefObject; + placement?: "top" | "bottom"; +} + +/** + * Shared presentation switch for every Agent/runtime picker. + * + * New Session and an existing conversation must honor the same configured + * dropdown/Spotlight choice. Keeping this switch beside the two canonical + * picker implementations prevents composers from growing their own palette. + */ +export const DispatchCategoryPicker: React.FC = ({ + style, + anchorRef, + placement, + ...props +}) => + style === "dropdown" ? ( + + ) : ( + + ); diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.test.ts b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.test.ts new file mode 100644 index 0000000000..4e238e47c1 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { cliAgentCapabilityDisabled } from "./cliAgentCapability"; + +describe("cliAgentCapabilityDisabled", () => { + it("leaves the complete New Session runtime list selectable", () => { + for (const runtime of [ + "claude_code", + "codex", + "cursor_cli", + "copilot", + "kiro", + ] as const) { + expect(cliAgentCapabilityDisabled(runtime)).toBe(false); + } + }); + + it("keeps installed runtimes visible while disabling lossy continuation targets", () => { + const nativeTargets = ["claude_code", "codex", "cursor_cli"] as const; + + expect(cliAgentCapabilityDisabled("claude_code", nativeTargets)).toBe( + false + ); + expect(cliAgentCapabilityDisabled("codex", nativeTargets)).toBe(false); + expect(cliAgentCapabilityDisabled("cursor_cli", nativeTargets)).toBe(false); + expect(cliAgentCapabilityDisabled("copilot", nativeTargets)).toBe(true); + expect(cliAgentCapabilityDisabled("kiro", nativeTargets)).toBe(true); + }); +}); diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.ts b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.ts new file mode 100644 index 0000000000..2328eec16d --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.ts @@ -0,0 +1,15 @@ +import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; + +/** + * A contextual allowlist gates execution capability, not discovery. New + * Session passes no allowlist; continuation surfaces pass their lossless + * native-writer targets and keep every other installed runtime visible. + */ +export function cliAgentCapabilityDisabled( + agentType: CliAgentType, + allowedCliAgentTypes?: readonly CliAgentType[] +): boolean { + return Boolean( + allowedCliAgentTypes && !allowedCliAgentTypes.includes(agentType) + ); +} diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.test.ts b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.test.ts new file mode 100644 index 0000000000..9500277d89 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { credentialedAccounts } from "./credentialedAccounts"; + +describe("credentialedAccounts", () => { + it("does not count ambient, disabled, or empty rows as saved credentials", () => { + const enabled = { id: "enabled", enabled: true, hasKey: true }; + + expect( + credentialedAccounts([ + enabled, + { id: "disabled", enabled: false, hasKey: true }, + { id: "empty", enabled: true, hasKey: false }, + ]) + ).toEqual([enabled]); + }); +}); diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.ts b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.ts new file mode 100644 index 0000000000..54f7c97c70 --- /dev/null +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/credentialedAccounts.ts @@ -0,0 +1,6 @@ +/** Accounts that represent an enabled, executable local credential. */ +export function credentialedAccounts< + T extends { enabled: boolean; hasKey: boolean }, +>(accounts: readonly T[]): T[] { + return accounts.filter((account) => account.enabled && account.hasKey); +} diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx index 995db604b4..c1a1879578 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx @@ -51,6 +51,8 @@ import { useAccountFooterForHovered } from "../../hooks"; import { PaletteBody, ShellFooterAction, SpotlightShell } from "../../shell"; import type { PathSegment, SpotlightItem } from "../../types"; import { CliAgentListFilterSwitch } from "./CliAgentListFilterSwitch"; +import { cliAgentCapabilityDisabled } from "./cliAgentCapability"; +import { credentialedAccounts } from "./credentialedAccounts"; import { createHumanSessionOption } from "./humanSessionOption"; import type { AgentOption, DispatchCategoryPaletteProps } from "./types"; @@ -61,20 +63,21 @@ export type { AgentSelection, DispatchCategoryPaletteProps } from "./types"; function buildCredentialBadge( compatibleAccounts: KeyVaultAccount[] ): React.ReactNode { - const totalCount = compatibleAccounts.length; + const availableAccounts = credentialedAccounts(compatibleAccounts); + const totalCount = availableAccounts.length; const dotColor = totalCount > 0 ? "bg-success-6" : "bg-danger-6"; const textColor = totalCount > 0 ? "text-text-2" : "text-text-3"; const uniquePlanTypes = [ ...new Set( - compatibleAccounts + availableAccounts .filter((acc) => !isApiKeyProvider(acc.modelType)) .map((acc) => acc.modelType) ), ]; const uniqueKeyTypes = [ ...new Set( - compatibleAccounts + availableAccounts .filter((acc) => isApiKeyProvider(acc.modelType)) .map((acc) => acc.modelType) ), @@ -118,6 +121,7 @@ export const DispatchCategoryPalette: React.FC< currentCliAgentType, hideOrgs = false, hideCliAgents = false, + allowedCliAgentTypes, cliOnly = false, includeHumanSession = false, titleLabel, @@ -201,7 +205,7 @@ export const DispatchCategoryPalette: React.FC< }, [cliAgentList, setAgentRegistry]); const rustCompatibleAccounts = useMemo( - () => getRustCompatibleAccounts(registry, accounts), + () => credentialedAccounts(getRustCompatibleAccounts(registry, accounts)), [registry, accounts] ); @@ -210,7 +214,8 @@ export const DispatchCategoryPalette: React.FC< return accounts.filter( (acc) => acc.status === "ready" && - (acc.hasKey ?? true) && + acc.enabled && + acc.hasKey && !compatibleSet.has(acc.id) ); }, [accounts, rustCompatibleAccounts]); @@ -246,7 +251,6 @@ export const DispatchCategoryPalette: React.FC< const cliOptions = useMemo((): AgentOption[] => { return installedCliAgents.flatMap((agent) => { - if (shouldFilterCliToGuiSupport && agent.supportsGui !== true) return []; // `agent.name` is a wire-format string; reject any value that isn't // in the canonical CLI agent set rather than smuggling it through // a `as CliAgentType` cast (which used to crash downstream consumers @@ -254,13 +258,25 @@ export const DispatchCategoryPalette: React.FC< const parsed = CliAgentTypeSchema.safeParse(agent.name); if (!parsed.success) return []; const agentType = parsed.data; + // Existing-conversation continuation passes an explicit shell-out + // allowlist. That runtime path does not require the optional GUI launch + // capability used by New Session's GUI/TUI filter. + if ( + shouldFilterCliToGuiSupport && + agent.supportsGui !== true && + !allowedCliAgentTypes?.includes(agentType) + ) { + return []; + } + const disabled = cliAgentCapabilityDisabled( + agentType, + allowedCliAgentTypes + ); // CLI agents only show plan (subscription) accounts in the badge — // API key accounts are not relevant for the session-launch decision. - const compatibleAccounts = getCliCompatibleAccounts( - registry, - agentType, - accounts - ).filter((acc) => !isApiKeyProvider(acc.modelType)); + const compatibleAccounts = credentialedAccounts( + getCliCompatibleAccounts(registry, agentType, accounts) + ).filter((account) => !isApiKeyProvider(account.modelType)); return [ { id: `cli:${agent.name}`, @@ -272,11 +288,20 @@ export const DispatchCategoryPalette: React.FC< isBuiltIn: true, isCli: true, isOrg: false, + disabled, + disabledLabel: disabled ? tCommon("status.notSupported") : undefined, rightContent: buildCredentialBadge(compatibleAccounts), }, ]; }); - }, [installedCliAgents, shouldFilterCliToGuiSupport, accounts, registry]); + }, [ + allowedCliAgentTypes, + installedCliAgents, + shouldFilterCliToGuiSupport, + accounts, + registry, + tCommon, + ]); const customAgentOptions = useMemo((): AgentOption[] => { const rustBadge = buildCredentialBadge(rustCompatibleAccounts); @@ -444,6 +469,8 @@ export const DispatchCategoryPalette: React.FC< option.isCli && option.cliAgentType ? getCliTransportLabel(option.cliAgentType) : undefined, + disabled: option.disabled, + tagLabel: option.disabledLabel, rightContent: option.rightContent, testId: option.isOrg ? `session-creator-agent-option-org-${option.agentOrgId}` @@ -456,6 +483,7 @@ export const DispatchCategoryPalette: React.FC< : undefined, }, action: () => { + if (option.disabled) return; recordRecentAgentSelection({ category: option.category, targetKind: option.targetKind, @@ -569,7 +597,7 @@ export const DispatchCategoryPalette: React.FC< const isItemSelectable = useCallback((item: SpotlightItem) => { const data = item.data as Record | undefined; - return !data?.isHeader; + return !data?.isHeader && !data?.disabled; }, []); const handleExternalKeyDown = useCallback( diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts index 78b2a74aa4..dd39ef7a05 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts @@ -33,6 +33,9 @@ export interface AgentOption { isOrg: boolean; /** Credential accounts represented by the selector's availability count. */ availableKeys?: KeyVaultAccount[]; + /** Keep capability-gated runtimes visible without allowing a lossy launch. */ + disabled?: boolean; + disabledLabel?: string; rightContent?: React.ReactNode; } @@ -49,6 +52,11 @@ export interface DispatchCategoryPaletteProps extends BasePaletteProps { hideOrgs?: boolean; /** Omit CLI agents from contexts that only support Rust-native sessions. */ hideCliAgents?: boolean; + /** + * Capability gate for contextual execution paths. Installed CLI rows remain + * visible, but runtimes outside this set are disabled instead of disappearing. + */ + allowedCliAgentTypes?: readonly CliAgentType[]; /** * When true only CLI agent entries are shown. Used by CLI-only picker surfaces. */ diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx index ae32f236b6..0eaace515e 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx @@ -45,6 +45,8 @@ import { SESSION_TARGET_KIND } from "@src/store/session/creatorStateAtom"; import { invokeTauri } from "@src/util/platform/tauri/init"; import type { SpotlightItem } from "../../types"; +import { cliAgentCapabilityDisabled } from "./cliAgentCapability"; +import { credentialedAccounts } from "./credentialedAccounts"; import { createHumanSessionOption } from "./humanSessionOption"; import type { AgentOption, AgentSelection } from "./types"; @@ -58,6 +60,7 @@ interface UseDispatchCategoryOptionsArgs { isOpen: boolean; hideOrgs: boolean; hideCliAgents?: boolean; + allowedCliAgentTypes?: readonly CliAgentType[]; /** When true, only CLI agent entries are included (Rust-native agents and orgs are hidden). */ cliOnly?: boolean; includeHumanSession?: boolean; @@ -82,20 +85,21 @@ interface UseDispatchCategoryOptionsResult { function buildCredentialBadge( compatibleAccounts: KeyVaultAccount[] ): React.ReactNode { - const totalCount = compatibleAccounts.length; + const availableAccounts = credentialedAccounts(compatibleAccounts); + const totalCount = availableAccounts.length; const dotColor = totalCount > 0 ? "bg-success-6" : "bg-danger-6"; const textColor = totalCount > 0 ? "text-text-2" : "text-text-3"; const uniquePlanTypes = [ ...new Set( - compatibleAccounts + availableAccounts .filter((acc) => !isApiKeyProvider(acc.modelType)) .map((acc) => acc.modelType) ), ]; const uniqueKeyTypes = [ ...new Set( - compatibleAccounts + availableAccounts .filter((acc) => isApiKeyProvider(acc.modelType)) .map((acc) => acc.modelType) ), @@ -131,6 +135,7 @@ export function useDispatchCategoryOptions( isOpen, hideOrgs, hideCliAgents = false, + allowedCliAgentTypes, cliOnly = false, includeHumanSession = false, currentCategory, @@ -208,7 +213,7 @@ export function useDispatchCategoryOptions( }, [cliAgentList, setAgentRegistry]); const rustCompatibleAccounts = useMemo( - () => getRustCompatibleAccounts(registry, accounts), + () => credentialedAccounts(getRustCompatibleAccounts(registry, accounts)), [registry, accounts] ); @@ -217,7 +222,8 @@ export function useDispatchCategoryOptions( return accounts.filter( (acc) => acc.status === "ready" && - (acc.hasKey ?? true) && + acc.enabled && + acc.hasKey && !compatibleSet.has(acc.id) ); }, [accounts, rustCompatibleAccounts]); @@ -255,12 +261,14 @@ export function useDispatchCategoryOptions( const parsed = CliAgentTypeSchema.safeParse(agent.name); if (!parsed.success) return []; const agentType = parsed.data; - // CLI agents only show plan (subscription) accounts in the badge. - const compatibleAccounts = getCliCompatibleAccounts( - registry, + const disabled = cliAgentCapabilityDisabled( agentType, - accounts - ).filter((acc) => !isApiKeyProvider(acc.modelType)); + allowedCliAgentTypes + ); + // CLI agents only show plan (subscription) accounts in the badge. + const compatibleAccounts = credentialedAccounts( + getCliCompatibleAccounts(registry, agentType, accounts) + ).filter((account) => !isApiKeyProvider(account.modelType)); return [ { id: `cli:${agent.name}`, @@ -273,11 +281,13 @@ export function useDispatchCategoryOptions( isCli: true, isOrg: false, availableKeys: compatibleAccounts, + disabled, + disabledLabel: disabled ? tCommon("status.notSupported") : undefined, rightContent: buildCredentialBadge(compatibleAccounts), }, ]; }); - }, [installedCliAgents, accounts, registry]); + }, [allowedCliAgentTypes, installedCliAgents, accounts, registry, tCommon]); const customAgentOptions = useMemo((): AgentOption[] => { const rustBadge = buildCredentialBadge(rustCompatibleAccounts); @@ -472,6 +482,8 @@ export function useDispatchCategoryOptions( ? getCliTransportLabel(option.cliAgentType) : undefined, availableKeys: option.availableKeys, + disabled: option.disabled, + tagLabel: option.disabledLabel, rightContent: option.rightContent, testId: option.isOrg ? `session-creator-agent-option-org-${option.agentOrgId}` @@ -484,6 +496,7 @@ export function useDispatchCategoryOptions( : undefined, }, action: () => { + if (option.disabled) return; recordRecentAgentSelection({ category: option.category, targetKind: option.targetKind, diff --git a/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx b/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx index 0c16cab527..f3fe91b71f 100644 --- a/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx @@ -55,10 +55,12 @@ export const UnifiedModelPalette: React.FC = ({ onClose, advancedConfig, onConfigChange, + agentNameOverride, dispatchCategoryOverride, cliAgentTypeOverride, }) => { - const agentName = useAtomValue(agentNameAtom); + const creatorAgentName = useAtomValue(agentNameAtom); + const agentName = agentNameOverride ?? creatorAgentName; const [keyFirst, setKeyFirst] = useAtom(spotlightModelKeyFirstAtom); const { diff --git a/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/types.ts b/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/types.ts index ae75ebf51c..a76a64d177 100644 --- a/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/types.ts +++ b/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/types.ts @@ -20,6 +20,11 @@ export interface SourceOption { export interface UnifiedModelPaletteProps extends BasePaletteProps { advancedConfig: AdvancedConfig; onConfigChange: (config: AdvancedConfig) => void; + /** + * Display-name override for an already-running conversation's runtime. + * Creator surfaces omit this and keep using the SessionCreator selection. + */ + agentNameOverride?: string; /** * Override the dispatch category used for account filtering. When provided * (e.g. by ModelPill in an active session), this value takes precedence over diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.test.ts index 4fcfb852ff..e87ac8e27d 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.test.ts @@ -234,6 +234,18 @@ describe("decideCloudAutoReplay", () => { ).toEqual({ kind: "reveal-local", requestId: 7, sessionId: SOURCE }); }); + it("keeps a canonical-root request on the Cloud replay even for the viewer's own local row", () => { + expect( + decideCloudAutoReplay( + input({ + request: request({ sidebarItemId: undefined }), + selfUserId: OWNER, + localOwnSessionIds: new Set([SOURCE]), + }) + ) + ).toEqual({ kind: "replay", requestId: 7, row }); + }); + it("still replays an own-owned row that has no local session here", () => { expect( decideCloudAutoReplay( diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.ts index 1c89d22627..5f8f144ec0 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.autoReplayReveal.ts @@ -173,8 +173,14 @@ export function decideCloudAutoReplay({ return null; } - // The viewer's own row, with the session already writable on this device. + // An exact row reference to the viewer's own session can open the writable + // local original. A canonical-root request (Team Inbox) must keep its Cloud + // authority, though: the raw external-history row carries no org/root + // provenance, so revealing it would drop the plane and show only the stale + // provider transcript. Replaying through the existing deterministic cache + // preserves importedFrom and therefore the canonical conversation binding. if ( + parsed && selfUserId && row.ownerUserId === selfUserId && localOwnSessionIds.has(row.sourceSessionId) diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx index 7162e9ec6e..28bd9f052c 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx @@ -34,7 +34,6 @@ import { useTranslation } from "react-i18next"; import { deleteSession as deleteLocalSession } from "@src/api/tauri/agent"; import { deleteOrgtrackCollaborationSession } from "@src/api/tauri/lineage"; import Message from "@src/components/Message"; -import { collectConversationRunnerSessionIds } from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; import { hiddenRemoteSessionKey, readHiddenRemoteSessionIds, @@ -252,10 +251,6 @@ export function useCloudSessionsSection({ )) { excluded.add(sessionId); } - // One-shot conversation runners are execution plumbing, never sessions. - for (const sessionId of collectConversationRunnerSessionIds()) { - excluded.add(sessionId); - } return excluded; }, [orgId, sessions, rows, selfUserId]); diff --git a/src/store/session/agentRegistryAtom.ts b/src/store/session/agentRegistryAtom.ts index e804fdce9d..d7dd5588de 100644 --- a/src/store/session/agentRegistryAtom.ts +++ b/src/store/session/agentRegistryAtom.ts @@ -17,7 +17,13 @@ export interface AgentRegistry { apiProviders: AvailableApiProvider[]; } +/** Initial agent discovery state. A loaded empty registry is distinct from boot. */ +type AgentRegistryDiscoveryState = "idle" | "loading" | "ready" | "error"; + export const agentRegistryAtom = atom({ agents: [], apiProviders: [], }); + +export const agentRegistryDiscoveryStateAtom = + atom("idle"); diff --git a/src/store/ui/__tests__/canonicalConversationTurnLock.test.ts b/src/store/ui/__tests__/canonicalConversationTurnLock.test.ts new file mode 100644 index 0000000000..dc111b3411 --- /dev/null +++ b/src/store/ui/__tests__/canonicalConversationTurnLock.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { QueuedConversationBusyError } from "@src/engines/SessionCore/conversations/queuedConversationContract"; + +import { withCanonicalConversationTurnLock } from "../messageQueueRepository"; + +function installSerialWebLocks(): string[] { + const requested: string[] = []; + const held = new Set(); + vi.stubGlobal("navigator", { + locks: { + request: ( + name: string, + options: LockOptions, + callback: (lock: { name: string } | null) => Promise + ): Promise => { + requested.push(name); + if (options.ifAvailable && held.has(name)) return callback(null); + held.add(name); + return callback({ name }).finally(() => held.delete(name)); + }, + }, + }); + return requested; +} + +function root(conversationId: string): ConversationRootLocator { + return { + authority: "local-session", + authorityScope: [], + conversationId, + }; +} + +describe("canonical conversation cross-window turn lock", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("rejects a second webview queue while the same root is owned", async () => { + const requested = installSerialWebLocks(); + const order: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withCanonicalConversationTurnLock( + root("root-1"), + async () => { + order.push("first:start"); + await firstGate; + order.push("first:end"); + return 1; + } + ); + const second = withCanonicalConversationTurnLock( + root("root-1"), + async () => { + order.push("second:start"); + return 2; + } + ); + + await vi.waitFor(() => expect(order).toEqual(["first:start"])); + await expect(second).rejects.toBeInstanceOf(QueuedConversationBusyError); + expect(order).toEqual(["first:start"]); + releaseFirst(); + await expect(first).resolves.toBe(1); + + await expect( + withCanonicalConversationTurnLock(root("root-1"), async () => { + order.push("second:retry"); + return 2; + }) + ).resolves.toBe(2); + expect(order).toEqual(["first:start", "first:end", "second:retry"]); + expect(new Set(requested)).toHaveLength(1); + }); + + it("does not serialize independent canonical roots", async () => { + installSerialWebLocks(); + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let secondStarted = false; + + const first = withCanonicalConversationTurnLock( + root("root-a"), + async () => { + await firstGate; + } + ); + const second = withCanonicalConversationTurnLock( + root("root-b"), + async () => { + secondStarted = true; + } + ); + + await vi.waitFor(() => expect(secondStarted).toBe(true)); + releaseFirst(); + await Promise.all([first, second]); + }); + + it("fails closed when the web lock manager rejects acquisition", async () => { + vi.stubGlobal("navigator", { + locks: { + request: vi.fn().mockRejectedValue(new Error("locks unavailable")), + }, + }); + const run = vi.fn().mockResolvedValue("ok"); + + await expect( + withCanonicalConversationTurnLock(root("root-fallback"), run) + ).rejects.toThrow("canonical conversation lock acquisition failed"); + expect(run).not.toHaveBeenCalled(); + }); + + it("fails closed when Web Locks are unavailable", async () => { + vi.stubGlobal("navigator", {}); + const run = vi.fn().mockResolvedValue("ok"); + + await expect( + withCanonicalConversationTurnLock(root("root-missing-locks"), run) + ).rejects.toThrow("canonical conversation lock is unavailable"); + expect(run).not.toHaveBeenCalled(); + }); + + it("never replays a provider failure outside the acquired lock", async () => { + installSerialWebLocks(); + const failure = new Error("provider failed"); + const run = vi.fn().mockRejectedValue(failure); + + await expect( + withCanonicalConversationTurnLock(root("root-failure"), run) + ).rejects.toBe(failure); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/ui/__tests__/conversationTargetAtom.test.ts b/src/store/ui/__tests__/conversationTargetAtom.test.ts new file mode 100644 index 0000000000..0f4fa0e4bc --- /dev/null +++ b/src/store/ui/__tests__/conversationTargetAtom.test.ts @@ -0,0 +1,54 @@ +import { createStore } from "jotai"; +import { describe, expect, it } from "vitest"; + +import { + conversationTargetOverridesAtom, + reconcileConversationTargetOverrideAtom, + setConversationTargetOverrideAtom, +} from "../conversationTargetAtom"; + +describe("conversation target override lifecycle", () => { + it("rejects incomplete cross-runtime targets at the persistence boundary", () => { + const store = createStore(); + store.set(setConversationTargetOverrideAtom, { + rootKey: "root-1", + target: { + cliAgentType: "codex", + workspaceRepoPath: "/repo", + }, + }); + expect(store.get(conversationTargetOverridesAtom).has("root-1")).toBe( + false + ); + }); + + it("clears a draft only after the same target is persisted", () => { + const store = createStore(); + const target = { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + } as const; + store.set(setConversationTargetOverrideAtom, { + rootKey: "root-1", + target, + }); + + store.set(reconcileConversationTargetOverrideAtom, { + rootKey: "root-1", + persistedTarget: { ...target, model: "gpt-5.5" }, + }); + expect(store.get(conversationTargetOverridesAtom).get("root-1")).toEqual( + target + ); + + store.set(reconcileConversationTargetOverrideAtom, { + rootKey: "root-1", + persistedTarget: target, + }); + expect(store.get(conversationTargetOverridesAtom).has("root-1")).toBe( + false + ); + }); +}); diff --git a/src/store/ui/__tests__/messageQueueAtom.test.ts b/src/store/ui/__tests__/messageQueueAtom.test.ts index bc8ccc728e..3f785a3967 100644 --- a/src/store/ui/__tests__/messageQueueAtom.test.ts +++ b/src/store/ui/__tests__/messageQueueAtom.test.ts @@ -13,8 +13,9 @@ import { editMessageAtom, enqueueMessageAtom, forceSendMessageAtom, - holdSessionQueueForStopAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, + parkSessionQueuedMessagesAfterStopAtom, queueEditTargetAtom, queueEditingAtom, reorderQueueAtom, @@ -99,8 +100,8 @@ describe("messageQueueAtom", () => { content: "same", displayContent: "same display", }); - store.set(enqueueMessageAtom, msg1); - store.set(enqueueMessageAtom, msg2); + expect(store.set(enqueueMessageAtom, msg1)).toBe("enqueued"); + expect(store.set(enqueueMessageAtom, msg2)).toBe("duplicate"); expect(store.get(messageQueueAtom)).toEqual([msg1]); }); @@ -120,8 +121,8 @@ describe("messageQueueAtom", () => { content: "same", displayContent: "same display", }); - store.set(enqueueMessageAtom, msg1); - store.set(enqueueMessageAtom, msg2); + expect(store.set(enqueueMessageAtom, msg1)).toBe("enqueued"); + expect(store.set(enqueueMessageAtom, msg2)).toBe("enqueued"); expect(store.get(messageQueueAtom)).toEqual([msg1, msg2]); }); @@ -165,6 +166,24 @@ describe("messageQueueAtom", () => { // ============================================= describe("dequeueMessageAtom", () => { + it("freezes queue mutations while ownership is being handed off", () => { + const message = makeMessage({ id: "m1" }); + store.set(enqueueMessageAtom, message); + store.set(messageQueueHandoffIdsAtom, new Set([message.id])); + + store.set(forceSendMessageAtom, message.id); + expect( + store.set(editMessageAtom, { + messageId: message.id, + content: "edited too late", + }) + ).toBe(false); + store.set(dequeueMessageAtom, message.id); + store.set(clearQueuedMessagesAtom, [message.id]); + + expect(store.get(messageQueueAtom)).toEqual([message]); + }); + it("removes message by ID", () => { store.set(enqueueMessageAtom, makeMessage({ id: "m1" })); store.set(enqueueMessageAtom, makeMessage({ id: "m2" })); @@ -214,6 +233,53 @@ describe("messageQueueAtom", () => { }); }); + it("clears a held delivery failure when the user explicitly retries", () => { + store.set( + enqueueMessageAtom, + makeMessage({ + id: "m1", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + }) + ); + + store.set(forceSendMessageAtom, "m1"); + + expect(store.get(messageQueueAtom)[0]).toMatchObject({ + priority: "now", + requiresExplicitDispatch: false, + }); + expect(store.get(messageQueueAtom)[0].deliveryError).toBeUndefined(); + }); + + it("keeps an edited retry intent instead of minting it twice", () => { + store.set( + enqueueMessageAtom, + makeMessage({ + id: "m1", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + }) + ); + store.set(editMessageAtom, { + messageId: "m1", + content: "@VantaNode retry with attachment", + imageDataUrls: ["data:image/png;base64,retry"], + turnIntentId: "terminal-retry-intent", + }); + + store.set(forceSendMessageAtom, "m1"); + + expect(store.get(messageQueueAtom)[0]).toMatchObject({ + id: "m1", + turnIntentId: "terminal-retry-intent", + displayContent: "@VantaNode retry with attachment", + imageDataUrls: ["data:image/png;base64,retry"], + priority: "now", + requiresExplicitDispatch: false, + }); + }); + it("is idempotent", () => { store.set(enqueueMessageAtom, makeMessage({ id: "m1" })); @@ -254,10 +320,10 @@ describe("messageQueueAtom", () => { }); // ============================================= - // holdSessionQueueForStopAtom + // parkSessionQueuedMessagesAfterStopAtom // ============================================= - describe("holdSessionQueueForStopAtom", () => { + describe("parkSessionQueuedMessagesAfterStopAtom", () => { it("parks every queued message of the session", () => { store.set(enqueueMessageAtom, makeMessage({ id: "m1" })); store.set(enqueueMessageAtom, makeMessage({ id: "m2" })); @@ -266,7 +332,7 @@ describe("messageQueueAtom", () => { makeMessage({ id: "m3", sessionId: "session-2" }) ); - store.set(holdSessionQueueForStopAtom, "session-1"); + store.set(parkSessionQueuedMessagesAfterStopAtom, "session-1"); const queue = store.get(messageQueueAtom); expect(queue.find((m) => m.id === "m1")?.requiresExplicitDispatch).toBe( @@ -282,7 +348,7 @@ describe("messageQueueAtom", () => { it("Send Now lifts the hold afterwards", () => { store.set(enqueueMessageAtom, makeMessage({ id: "m1" })); - store.set(holdSessionQueueForStopAtom, "session-1"); + store.set(parkSessionQueuedMessagesAfterStopAtom, "session-1"); store.set(forceSendMessageAtom, "m1"); diff --git a/src/store/ui/__tests__/messageQueueRepository.test.ts b/src/store/ui/__tests__/messageQueueRepository.test.ts new file mode 100644 index 0000000000..e5b9221ee2 --- /dev/null +++ b/src/store/ui/__tests__/messageQueueRepository.test.ts @@ -0,0 +1,491 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ActiveMessageDelivery, QueuedMessage } from "../messageQueueAtom"; +import { + assertDurableActiveDeliveryIsRootHead, + findDurableMessageDeliveryOwnerIds, + handoffDurableMessageDelivery, + loadDurableMessageDeliveries, + loadDurableMessageQueue, + persistDurableMessageQueue, + removeDurableActiveMessageDelivery, + removeDurableQueuedMessageDeliveries, + resetMessageQueueRepositoryForTests, + returnDurableMessageDeliveryToQueue, + updateDurableActiveMessageDelivery, +} from "../messageQueueRepository"; + +const mocks = vi.hoisted(() => ({ + values: new Map(), + delete: vi.fn(), + keys: vi.fn(), + reload: vi.fn(), + save: vi.fn(), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ label: "main" }), +})); + +vi.mock("@tauri-apps/plugin-store", () => ({ + load: async () => ({ + reload: mocks.reload, + get: async (key: string) => mocks.values.get(key), + set: async (key: string, value: unknown) => mocks.values.set(key, value), + delete: mocks.delete, + keys: mocks.keys, + save: mocks.save, + }), +})); + +function message(id: string): QueuedMessage { + return { + id, + turnIntentId: `turn-${id}`, + sessionId: "session-1", + content: id, + displayContent: id, + priority: "next", + status: "queued", + createdAt: "2026-09-02T00:00:00.000Z", + }; +} + +function canonicalMessage(id: string): QueuedMessage { + return { + ...message(id), + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "root-1", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + }; +} + +function activeDelivery( + id: string, + overrides: Partial = {} +): ActiveMessageDelivery { + const queued = canonicalMessage(id); + return { + ...queued, + conversationDispatch: queued.conversationDispatch!, + status: "accepted", + runnerSessionId: `runner-${id}`, + originQueueKey: "queue:main", + ...overrides, + }; +} + +function durableMessage( + id: string +): QueuedMessage & { originQueueKey: string } { + return { ...message(id), originQueueKey: "queue:main" }; +} + +function durableCanonicalMessage( + id: string +): QueuedMessage & { originQueueKey: string } { + return { ...canonicalMessage(id), originQueueKey: "queue:main" }; +} + +describe("message queue repository", () => { + beforeEach(() => { + mocks.values.clear(); + mocks.delete + .mockReset() + .mockImplementation(async (key: string) => mocks.values.delete(key)); + mocks.keys + .mockReset() + .mockImplementation(async () => [...mocks.values.keys()]); + mocks.reload.mockReset(); + mocks.save.mockReset(); + resetMessageQueueRepositoryForTests(); + }); + + it("initializes a missing first-run store before reading deliveries", async () => { + mocks.reload.mockRejectedValueOnce( + new Error("No such file or directory (os error 2)") + ); + + await expect(loadDurableMessageDeliveries()).resolves.toEqual({ + queue: [], + active: [], + }); + + expect(mocks.save).toHaveBeenCalledTimes(1); + }); + + it("does not overwrite a store when reload fails for another reason", async () => { + mocks.reload.mockRejectedValueOnce(new Error("permission denied")); + + await expect(loadDurableMessageDeliveries()).rejects.toThrow( + "permission denied" + ); + + expect(mocks.save).not.toHaveBeenCalled(); + }); + + it("migrates every legacy window queue into the unified registry", async () => { + mocks.values.set("queue:main", [message("main")]); + mocks.values.set("queue:aux", [message("aux")]); + + const snapshot = await loadDurableMessageDeliveries(); + + expect(snapshot.queue).toEqual([message("main")]); + expect(mocks.values.get("deliveries")).toEqual([ + { ...message("aux"), originQueueKey: "queue:aux" }, + durableMessage("main"), + ]); + expect(mocks.values.has("queue:main")).toBe(false); + expect(mocks.values.has("queue:aux")).toBe(false); + }); + + it("runs legacy migration only once after cleanup", async () => { + mocks.values.set("queue:main", [message("first")]); + + await loadDurableMessageDeliveries(); + const firstRegistry = mocks.values.get("deliveries"); + await loadDurableMessageDeliveries(); + + expect(mocks.values.get("deliveries")).toEqual(firstRegistry); + expect(mocks.values.get("deliveries")).toEqual([durableMessage("first")]); + expect(mocks.save).toHaveBeenCalledTimes(2); + expect(mocks.delete).toHaveBeenCalledTimes(1); + expect(mocks.keys).toHaveBeenCalledTimes(1); + }); + + it("does not delete legacy queues when the unified registry save fails", async () => { + const legacy = [message("first")]; + mocks.values.set("queue:main", legacy); + mocks.save.mockRejectedValueOnce(new Error("disk full")); + + await expect(loadDurableMessageDeliveries()).rejects.toThrow("disk full"); + + expect(mocks.delete).not.toHaveBeenCalled(); + expect(mocks.values.get("queue:main")).toEqual(legacy); + }); + + it("restores legacy queues when their cleanup save fails", async () => { + const legacy = [message("first")]; + mocks.values.set("queue:main", legacy); + mocks.save + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("cleanup failed")); + + await expect(loadDurableMessageDeliveries()).rejects.toThrow( + "cleanup failed" + ); + + expect(mocks.delete).toHaveBeenCalledWith("queue:main"); + expect(mocks.values.get("queue:main")).toEqual(legacy); + expect(mocks.values.get("deliveries")).toEqual([durableMessage("first")]); + }); + + it("deduplicates legacy conflicts without replacing unified owners", async () => { + const current = durableMessage("current"); + const sameIntent = message("same-intent"); + sameIntent.turnIntentId = current.turnIntentId; + const sameId = message("current"); + sameId.turnIntentId = "turn-other"; + mocks.values.set("deliveries", [current]); + mocks.values.set("queue:aux", [sameIntent, message("unique")]); + mocks.values.set("queue:main", [sameId, message("unique")]); + + await loadDurableMessageDeliveries(); + + expect(mocks.values.get("deliveries")).toEqual([ + current, + { ...message("unique"), originQueueKey: "queue:aux" }, + ]); + }); + + it("does not let a stale queue snapshot resurrect an execution twin", async () => { + mocks.values.set("deliveries", [activeDelivery("first")]); + + await persistDurableMessageQueue([message("first"), message("second")]); + + expect(mocks.values.get("deliveries")).toEqual([ + activeDelivery("first"), + durableMessage("second"), + ]); + }); + + it("replaces only this window's queued partition in the single registry", async () => { + mocks.values.set("deliveries", [ + { ...message("other"), originQueueKey: "queue:aux" }, + durableMessage("stale-main"), + activeDelivery("running"), + ]); + + await persistDurableMessageQueue([message("fresh-main")]); + + expect(mocks.values.get("deliveries")).toEqual([ + { ...message("other"), originQueueKey: "queue:aux" }, + activeDelivery("running"), + durableMessage("fresh-main"), + ]); + }); + + it("cancels only exact queued owners from this window", async () => { + const otherWindow = { + ...message("other-window"), + originQueueKey: "queue:aux", + }; + const active = activeDelivery("already-preparing", { + status: "preparing", + runnerSessionId: undefined, + }); + mocks.values.set("deliveries", [ + durableMessage("cancel"), + durableMessage("keep"), + otherWindow, + active, + ]); + + await expect( + removeDurableQueuedMessageDeliveries([ + { id: "cancel", turnIntentId: "turn-cancel" }, + { id: "keep", turnIntentId: "wrong-intent" }, + { + id: "already-preparing", + turnIntentId: "turn-already-preparing", + }, + { id: "other-window", turnIntentId: "turn-other-window" }, + ]) + ).resolves.toEqual([message("cancel")]); + + expect(mocks.values.get("deliveries")).toEqual([ + durableMessage("keep"), + otherWindow, + active, + ]); + }); + + it("finds queued and active owners across every window partition", async () => { + mocks.values.set("deliveries", [ + durableMessage("main"), + { ...message("other-window"), originQueueKey: "queue:aux" }, + activeDelivery("active"), + ]); + + await expect( + findDurableMessageDeliveryOwnerIds([ + "main", + "other-window", + "active", + "orphan", + ]) + ).resolves.toEqual(new Set(["main", "other-window", "active"])); + }); + + it("atomically hands off, updates, and removes one delivery record", async () => { + mocks.values.set("deliveries", [durableCanonicalMessage("first")]); + const preparing = activeDelivery("first", { + status: "preparing", + runnerSessionId: undefined, + }); + + const handedOff = await handoffDurableMessageDelivery(preparing); + expect(handedOff.queue).toEqual([]); + expect(handedOff.active).toEqual([preparing]); + + const accepted = await updateDurableActiveMessageDelivery("first", { + status: "accepted", + runnerSessionId: "runner-first", + }); + expect(accepted).toMatchObject({ + id: "first", + status: "accepted", + runnerSessionId: "runner-first", + }); + expect(await assertDurableActiveDeliveryIsRootHead("first")).toEqual( + accepted + ); + + await removeDurableActiveMessageDelivery("first"); + expect(mocks.values.get("deliveries")).toEqual([]); + }); + + it("uses the durable queued payload as authority during handoff", async () => { + const queued = canonicalMessage("first"); + mocks.values.set("deliveries", [durableCanonicalMessage("first")]); + const staleCaller = activeDelivery("first", { + content: "stale in-memory content", + displayContent: "stale in-memory content", + status: "preparing", + runnerSessionId: undefined, + }); + + const result = await handoffDurableMessageDelivery(staleCaller); + + expect(result.delivery.content).toBe(queued.content); + expect(result.delivery.displayContent).toBe(queued.displayContent); + expect(result.delivery.status).toBe("preparing"); + expect(mocks.values.get("deliveries")).toEqual([result.delivery]); + }); + + it("keeps an accepted owner when a stale queued twin is handed off", async () => { + const accepted = activeDelivery("first"); + mocks.values.set("deliveries", [accepted]); + + const result = await handoffDurableMessageDelivery( + activeDelivery("first", { + status: "preparing", + runnerSessionId: undefined, + }) + ); + + expect(result.delivery).toEqual(accepted); + expect(result.active).toEqual([accepted]); + expect(result.queue).toEqual([]); + }); + + it("does not hand an edited same-id intent to the existing owner", async () => { + const owner = activeDelivery("first"); + const edited = canonicalMessage("first"); + edited.turnIntentId = "turn-edited"; + edited.content = "edited body"; + edited.displayContent = "edited body"; + mocks.values.set("deliveries", [ + owner, + { ...edited, originQueueKey: "queue:main" }, + ]); + + await expect( + handoffDurableMessageDelivery({ + ...edited, + conversationDispatch: edited.conversationDispatch!, + status: "preparing", + }) + ).rejects.toThrow(); + + expect(mocks.values.get("deliveries")).toEqual([ + owner, + { ...edited, originQueueKey: "queue:main" }, + ]); + }); + + it("returns a pre-accept owner to the claimant queue without losing snapshots", async () => { + const queued = canonicalMessage("first"); + const preparing = activeDelivery("first", { + status: "preparing", + runnerSessionId: undefined, + modelSelection: { + model: "gpt-5.6-sol", + selectedAccountId: "openai-1", + }, + agentExecMode: "build", + }); + mocks.values.set("deliveries", [preparing]); + const restored: QueuedMessage = { + ...queued, + modelSelection: preparing.modelSelection, + agentExecMode: preparing.agentExecMode, + requiresExplicitDispatch: true, + }; + + const result = await returnDurableMessageDeliveryToQueue("first", restored); + + expect(result.message).toEqual(restored); + expect(mocks.values.get("deliveries")).toEqual([ + { ...restored, originQueueKey: "queue:main" }, + ]); + }); + + it("keeps a newer same-id queued intent when an older owner returns", async () => { + const owner = activeDelivery("first"); + const newer = canonicalMessage("first"); + newer.turnIntentId = "turn-newer"; + newer.content = "newer body"; + newer.displayContent = "newer body"; + mocks.values.set("deliveries", [ + owner, + { ...newer, originQueueKey: "queue:main" }, + ]); + + const result = await returnDurableMessageDeliveryToQueue( + owner.id, + canonicalMessage("first") + ); + + expect(result.message).toEqual(newer); + expect(mocks.values.get("deliveries")).toEqual([ + { ...newer, originQueueKey: "queue:main" }, + ]); + }); + + it("fails closed on an invalid durable row", async () => { + mocks.values.set("deliveries", [{ id: "truncated" }]); + + await expect(loadDurableMessageQueue()).rejects.toThrow("invalid row"); + }); + + it("fails closed on duplicate message or intent identity", async () => { + mocks.values.set("deliveries", [ + durableMessage("first"), + { ...durableMessage("second"), id: "first" }, + ]); + await expect(loadDurableMessageQueue()).rejects.toThrow( + "duplicate identity" + ); + + mocks.values.set("deliveries", [ + durableMessage("first"), + { ...durableMessage("second"), turnIntentId: "turn-first" }, + ]); + await expect(loadDurableMessageQueue()).rejects.toThrow( + "duplicate identity" + ); + }); + + it("rejects accepted recovery metadata without a native runner", async () => { + const invalid = activeDelivery("first", { + runnerSessionId: undefined, + }); + mocks.values.set("deliveries", [invalid]); + + await expect(loadDurableMessageDeliveries()).rejects.toThrow("invalid row"); + }); + + it("rejects active-owner overflow instead of evicting accepted work", async () => { + mocks.values.set( + "deliveries", + Array.from({ length: 100 }, (_, index) => + activeDelivery(`active-${index}`, { + conversationDispatch: { + ...activeDelivery("template").conversationDispatch, + root: { + authority: "local-session", + authorityScope: [], + conversationId: `root-${index}`, + }, + }, + }) + ) + ); + const overflow = canonicalMessage("overflow"); + mocks.values.set("deliveries", [ + ...(mocks.values.get("deliveries") as ActiveMessageDelivery[]), + { ...overflow, originQueueKey: "queue:main" }, + ]); + + await expect( + handoffDurableMessageDelivery({ + ...overflow, + conversationDispatch: overflow.conversationDispatch!, + status: "preparing", + }) + ).rejects.toThrow("row limit"); + expect(mocks.values.get("deliveries")).toHaveLength(101); + }); +}); diff --git a/src/store/ui/conversationTargetAtom.ts b/src/store/ui/conversationTargetAtom.ts new file mode 100644 index 0000000000..19f8b55e63 --- /dev/null +++ b/src/store/ui/conversationTargetAtom.ts @@ -0,0 +1,67 @@ +import { atom } from "jotai"; + +import { + type LocalConversationTarget, + isLocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; + +const MAX_CONVERSATION_TARGET_OVERRIDES = 32; + +/** Unsaved picker choices, keyed by canonical root until an episode persists. */ +export const conversationTargetOverridesAtom = atom< + ReadonlyMap +>(new Map()); +conversationTargetOverridesAtom.debugLabel = "conversationTargetOverridesAtom"; + +export const setConversationTargetOverrideAtom = atom( + null, + (get, set, update: { rootKey: string; target: LocalConversationTarget }) => { + if (!isLocalConversationTarget(update.target)) return; + const current = get(conversationTargetOverridesAtom); + const next = new Map(current); + next.delete(update.rootKey); + next.set(update.rootKey, update.target); + while (next.size > MAX_CONVERSATION_TARGET_OVERRIDES) { + const oldest = next.keys().next().value as string | undefined; + if (!oldest) break; + next.delete(oldest); + } + set(conversationTargetOverridesAtom, next); + } +); + +function sameConversationTarget( + left: LocalConversationTarget, + right: LocalConversationTarget +): boolean { + return ( + left.cliAgentType === right.cliAgentType && + left.agentDefinitionId === right.agentDefinitionId && + left.accountId === right.accountId && + left.model === right.model && + (left.workspaceRepoPath ?? null) === (right.workspaceRepoPath ?? null) + ); +} + +/** Drop a picker draft only after the same target is durable on an episode. */ +export const reconcileConversationTargetOverrideAtom = atom( + null, + ( + get, + set, + update: { + rootKey: string; + persistedTarget: LocalConversationTarget | null; + } + ) => { + if (!update.persistedTarget) return; + const current = get(conversationTargetOverridesAtom); + const draft = current.get(update.rootKey); + if (!draft || !sameConversationTarget(draft, update.persistedTarget)) { + return; + } + const next = new Map(current); + next.delete(update.rootKey); + set(conversationTargetOverridesAtom, next); + } +); diff --git a/src/store/ui/messageQueueAtom.ts b/src/store/ui/messageQueueAtom.ts index b3a7b04f3c..32a75c86fc 100644 --- a/src/store/ui/messageQueueAtom.ts +++ b/src/store/ui/messageQueueAtom.ts @@ -2,6 +2,14 @@ import { atom } from "jotai"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { projectOutgoingUserMessage } from "@src/engines/ChatPanel/hooks/useInputArea/projectOutgoingUserMessage"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS, + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL, + queuedConversationMessageCharSize, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import type { LastModelSelection } from "@src/store/session/creatorDefaultModelAtom"; import { isCliSession } from "@src/util/session/sessionDispatch"; @@ -10,6 +18,8 @@ import { isCliSession } from "@src/util/session/sessionDispatch"; // ============================================ export type QueuedMessagePriority = "now" | "next"; +export type QueuedMessageDeliveryState = "queued"; +export type ActiveMessageDeliveryState = "preparing" | "accepted"; export interface QueuedMessage { id: string; @@ -36,6 +46,7 @@ export interface QueuedMessage { content: string; displayContent: string; imageDataUrls?: string[]; + conversationDispatch?: QueuedConversationDispatch; /** * Snapshot of model/account selection at enqueue time. Frozen here * so a model swap done while the queue is draining cannot retroactively @@ -68,14 +79,59 @@ export interface QueuedMessage { * dispatch them. */ requiresExplicitDispatch?: boolean; - status: "queued"; + /** UI queue rows are pending sends. Canonical work keeps this identity and + * advances the same durable record to `preparing`/`accepted`. */ + status: QueuedMessageDeliveryState; + /** + * Last pre-acceptance delivery failure when the failed EventStore + * projection could not be durably committed. The queue remains the + * recovery owner, but transcript projection renders this held row as + * failed instead of pending. An explicit retry/edit clears it. + */ + deliveryError?: string; createdAt: string; } +/** + * The same durable delivery after its canonical-conversation queue claim. + * + * This is deliberately a flat extension of the queued row. Keeping the + * original payload (including model/account/mode snapshots) makes a failed + * pre-accept handoff reversible without reconstructing a lossy second job. + */ +export interface ActiveMessageDelivery extends Omit< + QueuedMessage, + "conversationDispatch" | "requiresExplicitDispatch" | "status" +> { + conversationDispatch: QueuedConversationDispatch; + /** Window-local queue partition that originally admitted this delivery. */ + originQueueKey?: string; + status: ActiveMessageDeliveryState; + runnerSessionId?: string; + runnerEventStartIndex?: number; + retryAt?: string; + retryAttempt?: number; +} + +export type MessageDeliveryRecord = QueuedMessage | ActiveMessageDelivery; + +export function isQueuedMessageDelivery( + record: MessageDeliveryRecord +): record is QueuedMessage { + return record.status === "queued"; +} + +export function isActiveMessageDelivery( + record: MessageDeliveryRecord +): record is ActiveMessageDelivery { + return record.status === "preparing" || record.status === "accepted"; +} + export const MAX_QUEUED_MESSAGES = 100; export const MAX_QUEUED_MESSAGES_PER_SESSION = 25; -export const MAX_QUEUED_MESSAGE_CHARS = 8 * 1024 * 1024; -export const MAX_QUEUED_MESSAGE_CHARS_TOTAL = 32 * 1024 * 1024; +export const MAX_QUEUED_MESSAGE_CHARS = MAX_QUEUED_CONVERSATION_MESSAGE_CHARS; +export const MAX_QUEUED_MESSAGE_CHARS_TOTAL = + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL; export type QueueAdmissionResult = | "enqueued" @@ -85,14 +141,13 @@ export type QueueAdmissionResult = | "queue_limit"; export function queuedMessageCharSize(message: QueuedMessage): number { - return ( - message.content.length + - message.displayContent.length + - (message.imageDataUrls ?? []).reduce( - (total, image) => total + image.length, - 0 - ) - ); + return queuedConversationMessageCharSize(message); +} + +export function queuedMessageScopeKey(message: QueuedMessage): string { + return message.conversationDispatch + ? `conversation:${conversationRootKey(message.conversationDispatch.root)}` + : message.sessionId; } export function queueAdmissionResult( @@ -102,8 +157,9 @@ export function queueAdmissionResult( const messageSize = queuedMessageCharSize(message); if (messageSize > MAX_QUEUED_MESSAGE_CHARS) return "message_too_large"; if ( - current.filter((item) => item.sessionId === message.sessionId).length >= - MAX_QUEUED_MESSAGES_PER_SESSION + current.filter( + (item) => queuedMessageScopeKey(item) === queuedMessageScopeKey(message) + ).length >= MAX_QUEUED_MESSAGES_PER_SESSION ) { return "session_limit"; } @@ -127,12 +183,52 @@ export function boundQueuedMessages( } // ============================================ -// Core Atom — THE single queue +// Core Atom — THE single durable delivery registry // ============================================ -export const messageQueueAtom = atom([]); +export const messageDeliveryRecordsAtom = atom([]); +messageDeliveryRecordsAtom.debugLabel = "messageDeliveryRecordsAtom"; + +type MessageQueueUpdate = + | QueuedMessage[] + | ((current: QueuedMessage[]) => QueuedMessage[]); + +/** + * Writable UI projection of the one delivery registry. + * + * Existing queue controls continue to see only pending rows, while writes + * preserve accepted/preparing owners held by the same backing atom. + */ +export const messageQueueAtom = atom( + (get) => get(messageDeliveryRecordsAtom).filter(isQueuedMessageDelivery), + (get, set, update: MessageQueueUpdate) => { + const records = get(messageDeliveryRecordsAtom); + const queue = records.filter(isQueuedMessageDelivery); + const nextQueue = typeof update === "function" ? update(queue) : update; + set(messageDeliveryRecordsAtom, [ + ...nextQueue, + ...records.filter(isActiveMessageDelivery), + ]); + } +); messageQueueAtom.debugLabel = "messageQueueAtom"; +/** Active canonical turns used by recovery and the live runner overlay. */ +export const activeMessageDeliveriesAtom = atom((get) => + get(messageDeliveryRecordsAtom).filter(isActiveMessageDelivery) +); +activeMessageDeliveriesAtom.debugLabel = "activeMessageDeliveriesAtom"; + +/** + * Short-lived UI freeze while one durable delivery record changes from + * `queued` to `preparing`. It prevents edit/delete races during the async + * store transaction; it is not another delivery owner. + */ +export const messageQueueHandoffIdsAtom = atom>( + new Set() +); +messageQueueHandoffIdsAtom.debugLabel = "messageQueueHandoffIdsAtom"; + /** True once the durable queue snapshot has been merged into this Jotai store. */ export const messageQueueHydratedAtom = atom(false); messageQueueHydratedAtom.debugLabel = "messageQueueHydratedAtom"; @@ -156,39 +252,33 @@ queueEditingAtom.debugLabel = "queueEditingAtom"; // Write Atoms // ============================================ -/** - * Incremented each time a message is enqueued. - * Components can watch this to react to new enqueues without using effects. - */ -export const enqueueCountAtom = atom(0); -enqueueCountAtom.debugLabel = "enqueueCountAtom"; - export const enqueueMessageAtom = atom( null, (get, set, message: QueuedMessage): QueueAdmissionResult => { const current = get(messageQueueAtom); - // Dedupe by canonical user-intent id. Falls back to content-equality only - // when the caller hasn't minted an id yet (legacy migration entries). - const duplicate = current.some((existing) => - message.turnIntentId - ? existing.turnIntentId === message.turnIntentId - : existing.sessionId === message.sessionId && - existing.content === message.content && - existing.displayContent === message.displayContent + // The submit boundary always mints this canonical identity, including for + // hydrated durable rows. Text is not identity: the user may intentionally + // send the same content more than once. + const duplicate = current.some( + (existing) => + existing.id === message.id || + existing.turnIntentId === message.turnIntentId ); if (duplicate) return "duplicate"; const rejected = queueAdmissionResult(current, message); if (rejected) return rejected; set(messageQueueAtom, [...current, message]); - set(enqueueCountAtom, (count) => count + 1); return "enqueued"; } ); enqueueMessageAtom.debugLabel = "enqueueMessageAtom"; -export const dequeueMessageAtom = atom(null, (_get, set, messageId: string) => { - set(messageQueueAtom, (prev) => prev.filter((msg) => msg.id !== messageId)); +export const dequeueMessageAtom = atom(null, (get, set, messageId: string) => { + if (get(messageQueueHandoffIdsAtom).has(messageId)) return; + set(messageQueueAtom, (prev) => + prev.filter((msg) => msg.id !== messageId || msg.status !== "queued") + ); }); dequeueMessageAtom.debugLabel = "dequeueMessageAtom"; @@ -202,11 +292,31 @@ dequeueMessageAtom.debugLabel = "dequeueMessageAtom"; export const forceSendMessageAtom = atom( null, (get, set, messageId: string) => { - if (!get(messageQueueAtom).some((msg) => msg.id === messageId)) return; + if (get(messageQueueHandoffIdsAtom).has(messageId)) return; + if ( + !get(messageQueueAtom).some( + (msg) => msg.id === messageId && msg.status === "queued" + ) + ) { + return; + } set(messageQueueAtom, (prev) => prev.map((msg) => - msg.id === messageId - ? { ...msg, priority: "now", requiresExplicitDispatch: false } + msg.id === messageId && msg.status === "queued" + ? { + ...msg, + // Send Now is an explicit new dispatch attempt. A recovered + // queued row may point at an immutable stale/coalesced/rejected + // backend intent; mint once for that failed row. An edit has + // already minted and persisted its replacement intent, while an + // ordinary parked unsent row must retain its original intent. + turnIntentId: msg.deliveryError + ? mintTurnIntentId() + : msg.turnIntentId, + priority: "now", + requiresExplicitDispatch: false, + deliveryError: undefined, + } : msg ) ); @@ -219,25 +329,59 @@ forceSendMessageAtom.debugLabel = "forceSendMessageAtom"; * permanently skipped by the natural drain — only Send Now (or queue edit * actions) can dispatch them afterwards. */ -export const holdSessionQueueForStopAtom = atom( +export const parkSessionQueuedMessagesAfterStopAtom = atom( null, - (_get, set, sessionId: string) => { + (get, set, sessionId: string) => { + const handoffIds = get(messageQueueHandoffIdsAtom); + const current = get(messageQueueAtom); + const conversationKeys = new Set( + current.flatMap((message) => + message.sessionId === sessionId && message.conversationDispatch + ? [conversationRootKey(message.conversationDispatch.root)] + : [] + ) + ); set(messageQueueAtom, (prev) => prev.map((msg) => - msg.sessionId === sessionId && !msg.requiresExplicitDispatch + !handoffIds.has(msg.id) && + (msg.sessionId === sessionId || + (msg.conversationDispatch !== undefined && + conversationKeys.has( + conversationRootKey(msg.conversationDispatch.root) + ))) && + msg.status === "queued" && + !msg.requiresExplicitDispatch ? { ...msg, requiresExplicitDispatch: true } : msg ) ); } ); -holdSessionQueueForStopAtom.debugLabel = "holdSessionQueueForStopAtom"; +parkSessionQueuedMessagesAfterStopAtom.debugLabel = + "parkSessionQueuedMessagesAfterStopAtom"; export const clearSessionQueueAtom = atom( null, - (_get, set, sessionId: string) => { + (get, set, sessionId: string) => { + const current = get(messageQueueAtom); + const conversationKeys = new Set( + current.flatMap((message) => + message.sessionId === sessionId && message.conversationDispatch + ? [conversationRootKey(message.conversationDispatch.root)] + : [] + ) + ); set(messageQueueAtom, (prev) => - prev.filter((msg) => msg.sessionId !== sessionId) + prev.filter( + (msg) => + get(messageQueueHandoffIdsAtom).has(msg.id) || + msg.status !== "queued" || + (msg.sessionId !== sessionId && + (msg.conversationDispatch === undefined || + !conversationKeys.has( + conversationRootKey(msg.conversationDispatch.root) + ))) + ) ); } ); @@ -246,11 +390,17 @@ clearSessionQueueAtom.debugLabel = "clearSessionQueueAtom"; /** Remove an exact visible queue projection without touching other Sessions. */ export const clearQueuedMessagesAtom = atom( null, - (_get, set, messageIds: readonly string[]) => { + (get, set, messageIds: readonly string[]) => { if (messageIds.length === 0) return; const ids = new Set(messageIds); + const handoffIds = get(messageQueueHandoffIdsAtom); set(messageQueueAtom, (prev) => - prev.filter((message) => !ids.has(message.id)) + prev.filter( + (message) => + handoffIds.has(message.id) || + message.status !== "queued" || + !ids.has(message.id) + ) ); } ); @@ -259,7 +409,7 @@ clearQueuedMessagesAtom.debugLabel = "clearQueuedMessagesAtom"; export const editMessageAtom = atom( null, ( - _get, + get, set, update: { messageId: string; @@ -268,12 +418,17 @@ export const editMessageAtom = atom( imageDataUrls?: string[]; modelSelection?: LastModelSelection; agentExecMode?: AgentExecMode; + /** Caller-owned retry intent when its EventStore projection must match. */ + turnIntentId?: string; + /** Re-resolved canonical runtime for a retry of a held canonical row. */ + conversationDispatch?: QueuedConversationDispatch; } ) => { + if (get(messageQueueHandoffIdsAtom).has(update.messageId)) return false; let updated = false; set(messageQueueAtom, (prev) => prev.map((msg) => { - if (msg.id !== update.messageId) return msg; + if (msg.id !== update.messageId || msg.status !== "queued") return msg; const nextImageDataUrls = update.imageDataUrls !== undefined ? update.imageDataUrls @@ -299,6 +454,11 @@ export const editMessageAtom = atom( }); const next: QueuedMessage = { ...msg, + // Saving an edit is a new logical user intent. The previous id may + // already be a durable stale/rejected pre-run terminal after a + // crash; terminal intent ids are immutable and cannot be safely + // resurrected with different content. + turnIntentId: update.turnIntentId ?? mintTurnIntentId(), content: projection.agentContent ?? projection.displayContent, displayContent: projection.displayContent, ...(update.imageDataUrls !== undefined && { @@ -310,6 +470,10 @@ export const editMessageAtom = atom( ...(update.agentExecMode !== undefined && { agentExecMode: update.agentExecMode, }), + ...(update.conversationDispatch !== undefined && { + conversationDispatch: update.conversationDispatch, + }), + deliveryError: undefined, }; const siblings = prev.filter((item) => item.id !== msg.id); if (queueAdmissionResult(siblings, next)) return msg; @@ -322,28 +486,25 @@ export const editMessageAtom = atom( ); editMessageAtom.debugLabel = "editMessageAtom"; -/** - * Bumped to request an immediate queue dispatch pass (e.g. "Send Now" - * clicked, or a post-Stop explicit submit was enqueued). Watched by - * useQueueDispatch. - */ -export const queueFlushRequestAtom = atom(0); -queueFlushRequestAtom.debugLabel = "queueFlushRequest"; - export const reorderQueueAtom = atom( null, ( - _get, + get, set, { fromIndex, toIndex }: { fromIndex: number; toIndex: number } ) => { + const handoffIds = get(messageQueueHandoffIdsAtom); set(messageQueueAtom, (prev) => { if ( fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || - toIndex >= prev.length + toIndex >= prev.length || + prev[fromIndex]?.status !== "queued" || + prev[toIndex]?.status !== "queued" || + handoffIds.has(prev[fromIndex].id) || + handoffIds.has(prev[toIndex].id) ) { return prev; } diff --git a/src/store/ui/messageQueueRepository.ts b/src/store/ui/messageQueueRepository.ts index 92fcea1c94..8ae5ec9841 100644 --- a/src/store/ui/messageQueueRepository.ts +++ b/src/store/ui/messageQueueRepository.ts @@ -1,34 +1,78 @@ import { type Store, load } from "@tauri-apps/plugin-store"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL, + QueuedConversationBusyError, + QueuedConversationRecoveryPendingError, + isQueuedConversationMessagePayload, + queuedConversationMessageCharSize, +} from "@src/engines/SessionCore/conversations/queuedConversationContract"; import { createLogger } from "@src/hooks/logger"; import { + type ActiveMessageDelivery, MAX_QUEUED_MESSAGE_CHARS, type QueuedMessage, boundQueuedMessages, + queueAdmissionResult, queuedMessageCharSize, } from "./messageQueueAtom"; const log = createLogger("messageQueueRepository"); const STORE_PATH = "chat-message-queue.json"; const STORE_KEY_PREFIX = "queue"; +const DELIVERY_RECORDS_KEY = "deliveries"; +const STORE_LOCK_NAME = "orgii:chat-message-queue-store"; +const CONVERSATION_TURN_LOCK_PREFIX = "orgii:canonical-conversation:"; +const MAX_ACTIVE_DELIVERIES = 100; + +function isMissingStoreFileError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("No such file or directory") || + message.includes("os error 2") + ); +} let storePromise: Promise | null = null; let queueKeyPromise: Promise | null = null; -let writeChain: Promise = Promise.resolve(); +let mutationChain: Promise = Promise.resolve(); +let fallbackStoreLock: Promise = Promise.resolve(); +let legacyQueueMigrationComplete = false; + +async function withStoreLock(operation: () => Promise): Promise { + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + if (locks?.request) { + return await locks.request( + STORE_LOCK_NAME, + { mode: "exclusive" }, + operation + ); + } + const next = fallbackStoreLock.catch(() => undefined).then(operation); + fallbackStoreLock = next; + return await next; +} function isQueuedMessage(value: unknown): value is QueuedMessage { if (!value || typeof value !== "object") return false; const item = value as Partial; + const validConversationDispatch = item.conversationDispatch + ? isQueuedConversationMessagePayload(item) + : true; return ( typeof item.id === "string" && typeof item.turnIntentId === "string" && typeof item.sessionId === "string" && typeof item.content === "string" && typeof item.displayContent === "string" && + validConversationDispatch && (item.imageDataUrls === undefined || (Array.isArray(item.imageDataUrls) && item.imageDataUrls.every((image) => typeof image === "string"))) && + (item.deliveryError === undefined || + typeof item.deliveryError === "string") && (item.priority === "now" || item.priority === "next") && item.status === "queued" && typeof item.createdAt === "string" && @@ -36,6 +80,171 @@ function isQueuedMessage(value: unknown): value is QueuedMessage { ); } +type DurableQueuedMessage = QueuedMessage & { originQueueKey: string }; +type DurableMessageDelivery = DurableQueuedMessage | ActiveMessageDelivery; + +function hasQueueOwner(value: unknown): value is { originQueueKey: string } { + return Boolean( + value && + typeof value === "object" && + typeof (value as { originQueueKey?: unknown }).originQueueKey === + "string" && + (value as { originQueueKey: string }).originQueueKey.startsWith("queue:") + ); +} + +function isDurableQueuedMessage(value: unknown): value is DurableQueuedMessage { + return isQueuedMessage(value) && hasQueueOwner(value); +} + +function toQueuedMessage(record: DurableQueuedMessage): QueuedMessage { + const { originQueueKey: _originQueueKey, ...message } = record; + return message; +} + +export function validatedDurableMessageQueue(value: unknown): QueuedMessage[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || !value.every(isQueuedMessage)) { + throw new Error("durable message queue contains an invalid row"); + } + const ids = new Set(); + const intents = new Set(); + for (const message of value) { + if (ids.has(message.id) || intents.has(message.turnIntentId)) { + throw new Error("durable message queue contains duplicate identity"); + } + ids.add(message.id); + intents.add(message.turnIntentId); + } + const bounded = boundQueuedMessages(value); + if (bounded.length !== value.length) { + throw new Error("durable message queue exceeds its safety limits"); + } + return value; +} + +function isActiveDelivery(value: unknown): value is ActiveMessageDelivery { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + const retryAt = candidate.retryAt; + const retryAttempt = candidate.retryAttempt; + const validMetadata = Boolean( + typeof candidate.id === "string" && + (candidate.status === "preparing" || candidate.status === "accepted") && + (candidate.priority === "now" || candidate.priority === "next") && + (candidate.runnerSessionId === undefined || + (typeof candidate.runnerSessionId === "string" && + candidate.runnerSessionId.length > 0)) && + (candidate.status !== "accepted" || + typeof candidate.runnerSessionId === "string") && + (candidate.runnerEventStartIndex === undefined || + (typeof candidate.runnerEventStartIndex === "number" && + Number.isSafeInteger(candidate.runnerEventStartIndex) && + candidate.runnerEventStartIndex >= 0)) && + typeof candidate.createdAt === "string" && + (candidate.deliveryError === undefined || + typeof candidate.deliveryError === "string") && + (retryAt === undefined || typeof retryAt === "string") && + (retryAttempt === undefined || + (typeof retryAttempt === "number" && + Number.isSafeInteger(retryAttempt) && + retryAttempt >= 0)) + ); + return ( + validMetadata && + hasQueueOwner(value) && + isQueuedConversationMessagePayload(value) + ); +} + +export function validatedActiveMessageDeliveries( + value: unknown +): ActiveMessageDelivery[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + throw new Error("active message delivery store is not an array"); + } + if (!value.every(isActiveDelivery)) { + throw new Error("active message delivery store contains an invalid row"); + } + const rows = value as ActiveMessageDelivery[]; + if (rows.length > MAX_ACTIVE_DELIVERIES) { + throw new Error("active message delivery store exceeds its row limit"); + } + const ids = new Set(); + const intentIds = new Set(); + for (const delivery of rows) { + if (ids.has(delivery.id) || intentIds.has(delivery.turnIntentId)) { + throw new Error( + "active message delivery store contains duplicate ownership" + ); + } + ids.add(delivery.id); + intentIds.add(delivery.turnIntentId); + } + const totalChars = rows.reduce( + (total, delivery) => total + queuedConversationMessageCharSize(delivery), + 0 + ); + if (totalChars > MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL) { + throw new Error("active message delivery store exceeds its payload limit"); + } + return rows; +} + +function validatedDurableMessageDeliveries( + value: unknown +): DurableMessageDelivery[] { + if (value === undefined || value === null) return []; + if ( + !Array.isArray(value) || + !value.every( + (record) => isDurableQueuedMessage(record) || isActiveDelivery(record) + ) + ) { + throw new Error("durable message delivery store contains an invalid row"); + } + const records = value as DurableMessageDelivery[]; + const intentIds = new Set(); + const queuedIds = new Set(); + for (const record of records) { + if (intentIds.has(record.turnIntentId)) { + throw new Error( + "durable message delivery store contains duplicate identity" + ); + } + intentIds.add(record.turnIntentId); + if (record.status === "queued") { + if (queuedIds.has(record.id)) { + throw new Error( + "durable message delivery store contains duplicate identity" + ); + } + queuedIds.add(record.id); + } + } + const active = records.filter(isActiveDelivery); + validatedActiveMessageDeliveries(active); + const queuedByOwner = new Map(); + for (const record of records) { + if (record.status !== "queued") continue; + const ownerQueue = queuedByOwner.get(record.originQueueKey) ?? []; + ownerQueue.push(record); + queuedByOwner.set(record.originQueueKey, ownerQueue); + } + for (const queue of queuedByOwner.values()) { + validatedDurableMessageQueue(queue); + } + const totalChars = records.reduce( + (total, record) => total + queuedConversationMessageCharSize(record), + 0 + ); + if (totalChars > MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL) { + throw new Error("durable message delivery store exceeds its payload limit"); + } + return records; +} + async function durableStore(): Promise { if (storePromise) return storePromise; storePromise = load(STORE_PATH, { @@ -43,8 +252,6 @@ async function durableStore(): Promise { autoSave: false, }).catch((error) => { log.warn("[messageQueueRepository] durable store unavailable", error); - // Do not memoize a transient startup/plugin failure forever. Queue writes - // remain serialized, and the next mutation gets one fresh load attempt. storePromise = null; return null; }); @@ -62,45 +269,481 @@ async function queueKey(): Promise { return queueKeyPromise; } -/** Load this window's durable queue. Invalid rows are ignored, never dispatched. */ -export async function loadDurableMessageQueue(): Promise { +export async function getMessageQueueOwnerKey(): Promise { + return await queueKey(); +} + +export function isPrimaryMessageQueueOwnerKey(key: string): boolean { + return ( + key === `${STORE_KEY_PREFIX}:main` || key === `${STORE_KEY_PREFIX}:browser` + ); +} + +export async function withMessageQueueStoreTransaction( + operation: (store: Store, windowQueueKey: string) => Promise +): Promise { const store = await durableStore(); - if (!store) return []; + if (!store) { + throw new Error("durable message queue store is unavailable"); + } + return await withStoreLock(async () => { + try { + await store.reload(); + } catch (error) { + if (!isMissingStoreFileError(error)) throw error; + // Store.load() supplies the empty in-memory defaults when its file does + // not exist, but reload() reports ENOENT. Persist that initial snapshot + // once so the normal reload-before-transaction contract can begin. + await store.save(); + } + return await operation(store, await queueKey()); + }); +} + +export function serializeMessageQueueStoreMutation( + operation: (store: Store, windowQueueKey: string) => Promise +): Promise { + const next = mutationChain + .catch((error) => { + log.warn("[messageQueueRepository] previous mutation failed", error); + }) + .then(() => withMessageQueueStoreTransaction(operation)); + mutationChain = next; + return next; +} + +async function readDeliveriesLocked( + store: Store +): Promise { + const deliveries = validatedDurableMessageDeliveries( + await store.get(DELIVERY_RECORDS_KEY) + ); + if (legacyQueueMigrationComplete) return deliveries; + const legacyKeys = (await store.keys()) + .filter((key) => key.startsWith(`${STORE_KEY_PREFIX}:`)) + .sort(); + if (legacyKeys.length === 0) { + legacyQueueMigrationComplete = true; + return deliveries; + } + + const legacyEntries = await Promise.all( + legacyKeys.map(async (key) => [key, await store.get(key)] as const) + ); + const seenIds = new Set(deliveries.map((record) => record.id)); + const seenIntentIds = new Set( + deliveries.map((record) => record.turnIntentId) + ); + const migrated = [...deliveries]; + for (const [key, stored] of legacyEntries) { + if (!Array.isArray(stored)) continue; + const recovered = boundQueuedMessages(stored.filter(isQueuedMessage)); + for (const message of recovered) { + if (seenIds.has(message.id) || seenIntentIds.has(message.turnIntentId)) { + continue; + } + seenIds.add(message.id); + seenIntentIds.add(message.turnIntentId); + migrated.push({ ...message, originQueueKey: key }); + } + } + const validated = validatedDurableMessageDeliveries(migrated); + + // Commit the unified registry before removing any legacy owner. If this + // save fails, every queue: key remains available for a later retry. + await store.set(DELIVERY_RECORDS_KEY, validated); + await store.save(); + try { - const stored = await store.get(await queueKey()); - if (!Array.isArray(stored)) return []; - return boundQueuedMessages(stored.filter(isQueuedMessage)); + for (const key of legacyKeys) await store.delete(key); + await store.save(); } catch (error) { - log.warn("[messageQueueRepository] failed to load queue", error); - return []; + // Cleanup is best-effort but must not leave an in-memory Store instance + // pretending the legacy rows were removed when its save failed. Restore + // them so the next hydrate can retry the same idempotent migration. + for (const [key, stored] of legacyEntries) await store.set(key, stored); + try { + await store.save(); + } catch (restoreError) { + log.warn( + "[messageQueueRepository] failed to restore legacy queue keys after cleanup failure", + restoreError + ); + } + throw error; } + legacyQueueMigrationComplete = true; + return validated; +} + +interface DurableMessageDeliverySnapshot { + queue: QueuedMessage[]; + active: ActiveMessageDelivery[]; +} + +async function readDeliverySnapshotLocked( + store: Store, + windowQueueKey: string +): Promise { + const records = await readDeliveriesLocked(store); + return { + queue: records + .filter( + (record): record is DurableQueuedMessage => + record.status === "queued" && record.originQueueKey === windowQueueKey + ) + .map(toQueuedMessage), + active: records.filter(isActiveDelivery), + }; +} + +export async function loadDurableMessageDeliveries(): Promise { + return await withMessageQueueStoreTransaction(readDeliverySnapshotLocked); +} + +export async function loadDurableMessageQueue(): Promise { + return (await loadDurableMessageDeliveries()).queue; } -/** - * Serialize writes so a rapid enqueue/reorder/dequeue burst cannot let an older - * async save overwrite a newer snapshot. Writes are explicitly saved before - * the mutation promise resolves, so app shutdown cannot race a deferred - * autosave after the in-memory queue has already changed. - */ export function persistDurableMessageQueue( messages: readonly QueuedMessage[] ): Promise { const snapshot = boundQueuedMessages(messages).map((message) => ({ ...message, })); - writeChain = writeChain - .catch((error) => { - // A transient failure must not poison the serialization chain. The next - // queue mutation gets a fresh save attempt with its complete snapshot. - log.warn("[messageQueueRepository] previous queue save failed", error); - }) - .then(async () => { - const store = await durableStore(); - if (!store) return; - await store.set(await queueKey(), snapshot); - await store.save(); + return serializeMessageQueueStoreMutation(async (store, key) => { + const records = await readDeliveriesLocked(store); + const active = records.filter(isActiveDelivery); + const activeIntentIds = new Set(active.map((row) => row.turnIntentId)); + const otherRecords = records.filter( + (record) => record.status !== "queued" || record.originQueueKey !== key + ); + const currentQueue = snapshot + .filter((message) => !activeIntentIds.has(message.turnIntentId)) + .map((message) => ({ ...message, originQueueKey: key })); + await store.set( + DELIVERY_RECORDS_KEY, + validatedDurableMessageDeliveries([...otherRecords, ...currentQueue]) + ); + await store.save(); + }); +} + +export interface QueuedMessageCancellationIdentity { + id: string; + turnIntentId: string; +} + +/** Return which queue ids still have any durable queued/active owner. */ +export function findDurableMessageDeliveryOwnerIds( + messageIds: readonly string[] +): Promise> { + const requested = new Set(messageIds); + if (requested.size === 0) return Promise.resolve(new Set()); + return withMessageQueueStoreTransaction(async (store) => { + const records = await readDeliveriesLocked(store); + return new Set( + records + .filter((record) => requested.has(record.id)) + .map((record) => record.id) + ); + }); +} + +/** + * Remove exact queued owners from this window's durable partition. + * + * This is intentionally narrower than replacing the queue snapshot: a row + * that has already moved to `preparing`/`accepted`, or belongs to another + * window, is no longer cancellable by a stale queue card and must survive. + */ +export function removeDurableQueuedMessageDeliveries( + identities: readonly QueuedMessageCancellationIdentity[] +): Promise { + const keys = new Set( + identities.map(({ id, turnIntentId }) => `${id}\0${turnIntentId}`) + ); + if (keys.size === 0) return Promise.resolve([]); + + return serializeMessageQueueStoreMutation(async (store, windowQueueKey) => { + const records = await readDeliveriesLocked(store); + const removed: QueuedMessage[] = []; + const next = records.filter((record) => { + const matches = + record.status === "queued" && + record.originQueueKey === windowQueueKey && + keys.has(`${record.id}\0${record.turnIntentId}`); + if (matches) removed.push(toQueuedMessage(record)); + return !matches; }); - return writeChain.catch((error) => { - log.warn("[messageQueueRepository] failed to persist queue", error); + if (removed.length === 0) return removed; + await store.set( + DELIVERY_RECORDS_KEY, + validatedDurableMessageDeliveries(next) + ); + await store.save(); + return removed; + }); +} + +export function handoffDurableMessageDelivery( + delivery: ActiveMessageDelivery +): Promise<{ + delivery: ActiveMessageDelivery; + queue: QueuedMessage[]; + active: ActiveMessageDelivery[]; +}> { + return serializeMessageQueueStoreMutation(async (store, windowQueueKey) => { + const records = await readDeliveriesLocked(store); + const active = records.filter(isActiveDelivery); + const queue = records + .filter( + (record): record is DurableQueuedMessage => + record.status === "queued" && record.originQueueKey === windowQueueKey + ) + .map(toQueuedMessage); + const existingOwner = active.find( + (candidate) => + candidate.id === delivery.id && + candidate.turnIntentId === delivery.turnIntentId + ); + const conflictingOwner = active.some( + (candidate) => + candidate.id === delivery.id || + candidate.turnIntentId === delivery.turnIntentId + ); + const sourceRow = queue.find( + (message) => + message.id === delivery.id && + message.turnIntentId === delivery.turnIntentId + ); + if ( + (!existingOwner && !sourceRow) || + (!existingOwner && conflictingOwner) + ) { + throw new QueuedConversationBusyError(); + } + const persisted = + existingOwner ?? + ({ + ...sourceRow, + status: delivery.status, + originQueueKey: windowQueueKey, + runnerSessionId: delivery.runnerSessionId, + runnerEventStartIndex: delivery.runnerEventStartIndex, + retryAt: delivery.retryAt, + retryAttempt: delivery.retryAttempt, + } as ActiveMessageDelivery); + const nextQueue = queue.filter( + (message) => + message.id !== persisted.id || + message.turnIntentId !== persisted.turnIntentId + ); + const nextActive = existingOwner + ? active + : validatedActiveMessageDeliveries([...active, persisted]); + const nextRecords = existingOwner + ? records.filter( + (record) => + record.status !== "queued" || + record.originQueueKey !== windowQueueKey || + record.id !== persisted.id || + record.turnIntentId !== persisted.turnIntentId + ) + : records.map((record) => + record.status === "queued" && + record.originQueueKey === windowQueueKey && + record.id === persisted.id && + record.turnIntentId === persisted.turnIntentId + ? persisted + : record + ); + await store.set( + DELIVERY_RECORDS_KEY, + validatedDurableMessageDeliveries(nextRecords) + ); + await store.save(); + return { delivery: persisted, queue: nextQueue, active: nextActive }; + }); +} + +export function returnDurableMessageDeliveryToQueue( + deliveryId: string, + message: QueuedMessage +): Promise<{ + message: QueuedMessage; + active: ActiveMessageDelivery[]; +}> { + return serializeMessageQueueStoreMutation(async (store, claimantQueueKey) => { + const records = await readDeliveriesLocked(store); + const active = records.filter(isActiveDelivery); + if (!active.some((candidate) => candidate.id === deliveryId)) { + throw new QueuedConversationRecoveryPendingError( + "active message delivery owner is temporarily unavailable" + ); + } + const queue = records + .filter( + (record): record is DurableQueuedMessage => + record.status === "queued" && + record.originQueueKey === claimantQueueKey + ) + .map(toQueuedMessage); + const supersedingMessage = queue.find( + (candidate) => + candidate.id === message.id && + candidate.turnIntentId !== message.turnIntentId + ); + const baseQueue = queue.filter( + (candidate) => + candidate.id !== message.id && + candidate.turnIntentId !== message.turnIntentId + ); + const restoredMessage = supersedingMessage ?? message; + const rejection = queueAdmissionResult(baseQueue, restoredMessage); + if (rejection) { + throw new QueuedConversationRecoveryPendingError( + `message queue cannot restore this turn yet (${rejection})` + ); + } + const nextActive = active.filter( + (candidate) => candidate.id !== deliveryId + ); + const queuedRecord: DurableQueuedMessage = { + ...restoredMessage, + originQueueKey: claimantQueueKey, + }; + const nextRecords = records + .filter( + (record) => + record.id !== deliveryId && + !( + record.status === "queued" && + record.originQueueKey === claimantQueueKey && + (record.id === queuedRecord.id || + record.turnIntentId === queuedRecord.turnIntentId) + ) + ) + .concat(queuedRecord); + await store.set( + DELIVERY_RECORDS_KEY, + validatedDurableMessageDeliveries(nextRecords) + ); + await store.save(); + return { message: restoredMessage, active: nextActive }; + }); +} + +export type ActiveMessageDeliveryUpdate = Partial< + Pick< + ActiveMessageDelivery, + | "status" + | "runnerSessionId" + | "runnerEventStartIndex" + | "retryAt" + | "retryAttempt" + > +>; + +export async function updateDurableActiveMessageDelivery( + deliveryId: string, + update: ActiveMessageDeliveryUpdate +): Promise { + let updated: ActiveMessageDelivery | null = null; + await serializeMessageQueueStoreMutation(async (store) => { + const records = await readDeliveriesLocked(store); + const next = validatedDurableMessageDeliveries( + records.map((candidate) => { + if (candidate.status === "queued" || candidate.id !== deliveryId) { + return candidate; + } + updated = { ...candidate, ...update } as ActiveMessageDelivery; + return updated; + }) + ); + await store.set(DELIVERY_RECORDS_KEY, next); + await store.save(); }); + return updated; +} + +export function removeDurableActiveMessageDelivery( + deliveryId: string +): Promise { + return serializeMessageQueueStoreMutation(async (store) => { + const records = await readDeliveriesLocked(store); + await store.set( + DELIVERY_RECORDS_KEY, + records.filter( + (candidate) => + candidate.status === "queued" || candidate.id !== deliveryId + ) + ); + await store.save(); + }); +} + +export function assertDurableActiveDeliveryIsRootHead( + deliveryId: string +): Promise { + return withMessageQueueStoreTransaction(async (store) => { + const active = (await readDeliveriesLocked(store)).filter(isActiveDelivery); + const owner = active.find((candidate) => candidate.id === deliveryId); + if (!owner) throw new QueuedConversationBusyError(); + const rootKey = conversationRootKey(owner.conversationDispatch.root); + const head = active.find( + (candidate) => + conversationRootKey(candidate.conversationDispatch.root) === rootKey + ); + if (head?.id !== deliveryId) throw new QueuedConversationBusyError(); + return owner; + }); +} + +export async function withCanonicalConversationTurnLock( + root: import("@src/engines/SessionCore/conversations/conversationTypes").ConversationRootLocator, + run: () => Promise +): Promise { + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + if (!locks?.request) { + throw new Error("canonical conversation lock is unavailable"); + } + const name = `${CONVERSATION_TURN_LOCK_PREFIX}${conversationRootKey(root)}`; + let result: + | { ok: true; value: T } + | { ok: false; error: unknown } + | undefined; + try { + result = (await locks.request( + name, + { mode: "exclusive", ifAvailable: true }, + async (lock) => { + if (!lock) { + return { + ok: false as const, + error: new QueuedConversationBusyError(), + }; + } + try { + return { ok: true as const, value: await run() }; + } catch (error) { + return { ok: false as const, error }; + } + } + )) as typeof result; + } catch { + throw new Error("canonical conversation lock acquisition failed"); + } + if (!result) + throw new Error("canonical conversation lock returned no result"); + if (!result.ok) throw result.error; + return result.value; +} + +export function resetMessageQueueRepositoryForTests(): void { + storePromise = null; + queueKeyPromise = null; + mutationChain = Promise.resolve(); + fallbackStoreLock = Promise.resolve(); + legacyQueueMigrationComplete = false; } diff --git a/src/util/session/__tests__/sessionDisplayMetadata.test.ts b/src/util/session/__tests__/sessionDisplayMetadata.test.ts index f3abb3184f..71e3be2f72 100644 --- a/src/util/session/__tests__/sessionDisplayMetadata.test.ts +++ b/src/util/session/__tests__/sessionDisplayMetadata.test.ts @@ -177,6 +177,31 @@ describe("resolveSessionDisplayMetadata", () => { expect(display.cliAgentType).toBeUndefined(); }); + it("uses the current episode provider instead of its canonical root provider", () => { + const display = resolveSessionDisplayMetadata({ + kind: "remote", + session: { + sourceSessionId: "cliagent-claude-continuation", + forkedFrom: { + sourceSessionId: "codexapp-parent", + rootSessionId: "codexapp-root", + }, + cliAgentType: "claude_code", + agentDisplayName: "Claude Code", + model: "claude-sonnet-5", + origin: { kind: "orgii" }, + }, + }); + + expect(display).toMatchObject({ + agentLabel: "Claude Code", + agentIconId: "claude_code", + cliAgentType: "claude_code", + modelName: "claude-sonnet-5", + }); + expect(display.externalSource).toBeUndefined(); + }); + it("drives the sidebar adapter from the same final icon used by Kanban", () => { const session = { session_id: "cliagent-org-coordinator", diff --git a/src/util/session/__tests__/sessionVisibility.test.ts b/src/util/session/__tests__/sessionVisibility.test.ts index 51e658ae29..2e358a7a87 100644 --- a/src/util/session/__tests__/sessionVisibility.test.ts +++ b/src/util/session/__tests__/sessionVisibility.test.ts @@ -3,6 +3,20 @@ import { describe, expect, it } from "vitest"; import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; describe("isPrimarySessionListSession", () => { + it("keeps an exact-ID hydrated managed native mirror out of primary lists", () => { + expect( + isPrimarySessionListSession({ + session_id: "codexapp-rollout-native-id", + clientOrigin: "org2", + }) + ).toBe(false); + expect( + isPrimarySessionListSession({ + session_id: "codexapp-rollout-native-id", + clientOrigin: "cli", + }) + ).toBe(true); + }); it("keeps Agent Team coordinator root sessions visible", () => { expect( isPrimarySessionListSession({ diff --git a/src/util/session/sessionDispatch.ts b/src/util/session/sessionDispatch.ts index 8dbceca37e..d3582fb667 100644 --- a/src/util/session/sessionDispatch.ts +++ b/src/util/session/sessionDispatch.ts @@ -215,6 +215,21 @@ export function getExternalHistorySourceId( return config?.externalHistorySourceId; } +/** + * Runnable native-CLI provider owned by an external-history session. + * Sources without a native resume contract deliberately return undefined. + */ +export function getExternalHistoryCliAgentType( + sessionId: string | null | undefined +): string | undefined { + const sourceId = getExternalHistorySourceId(sessionId); + return sourceId + ? IMPORTED_HISTORY_SOURCE_DESCRIPTORS.find( + (descriptor) => descriptor.sourceId === sourceId + )?.cliResume?.agentType + : undefined; +} + export function isCodexAppSession( sessionId: string | null | undefined ): boolean { diff --git a/src/util/session/sessionDisplayMetadata.ts b/src/util/session/sessionDisplayMetadata.ts index f6c7645096..eb0465acbe 100644 --- a/src/util/session/sessionDisplayMetadata.ts +++ b/src/util/session/sessionDisplayMetadata.ts @@ -49,6 +49,7 @@ export interface LocalSessionDisplayInput { type RemoteSessionDisplayInput = Pick< RemoteTeammateSessionMetadata, | "sourceSessionId" + | "forkedFrom" | "cliAgentType" | "agentDisplayName" | "agentDefinitionId" @@ -105,6 +106,9 @@ function normalizeSessionDisplayInput( const { session } = source; return { kind: source.kind, + // Provider identity belongs to the visible execution episode. The + // canonical root id is grouping lineage, not display metadata: using it + // here would relabel a Claude continuation under a Codex root as Codex. sessionId: session.sourceSessionId, cliAgentType: session.cliAgentType, agentDisplayName: session.agentDisplayName, diff --git a/src/util/session/sessionVisibility.ts b/src/util/session/sessionVisibility.ts index 589326570a..cdcb5bd8f4 100644 --- a/src/util/session/sessionVisibility.ts +++ b/src/util/session/sessionVisibility.ts @@ -13,11 +13,21 @@ interface SessionVisibilityInput { * stripping it first. */ readOnly?: boolean; + clientOrigin?: string; +} + +/** Imported provider mirrors remain readable by ID, but their managed session + * owns listing and publication. Mirrors can be hydrated without a parent ID. */ +export function isManagedNativeHistoryMirror(session: { + clientOrigin?: string; +}): boolean { + return session.clientOrigin === "org2"; } export function isPrimarySessionListSession( session: SessionVisibilityInput ): boolean { + if (isManagedNativeHistoryMirror(session)) return false; const hasParentSessionId = Boolean( session.parentSessionId ?? session.parent_session_id ); diff --git a/tests/e2e/specs/core/chat-rendering-ui.spec.mjs b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs index e931818c1c..1ca8ec1df9 100644 --- a/tests/e2e/specs/core/chat-rendering-ui.spec.mjs +++ b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs @@ -3294,6 +3294,98 @@ describe("Core chat rendering UI", () => { await assertOneHundredRoundSkeletonRemainsNavigable(); }); + it("keeps manual scroll position while the active assistant event streams", async function () { + if (!shouldRunScenario("streaming-manual-scroll-pin")) { + this.skip(); + return; + } + + const sessionId = `sdeagent-e2e-stream-scroll-${RUN_ID}`; + const events = Array.from({ length: 48 }, (_, index) => [ + makeUserEvent(sessionId, 10_000 + index), + makeAssistantEvent(sessionId, 10_000 + index), + ]).flat(); + const last = events.at(-1); + last.displayStatus = "running"; + last.result = { ...last.result, status: "running" }; + const seeded = await invokeE2E("seedChatEvents", sessionId, events, { + runtimeStatus: "running", + }); + if (!seeded?.ok) { + throw new Error( + `stream-scroll initial seed failed: ${seeded?.error ?? "unknown"}` + ); + } + + await browser.waitUntil( + async () => + execJS(` + const scroller = document.querySelector('[data-testid="chat-history-scroll-container"]'); + if (!scroller || scroller.scrollHeight <= scroller.clientHeight * 2) return false; + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + scroller.scrollTop = 0; + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + return scroller.scrollTop === 0; + `), + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: "stream-scroll transcript never exposed a scrollable history", + } + ); + await browser.pause(250); + + const streamedText = `STREAM_SCROLL_DELTA_${RUN_ID}`; + const streamedEvents = events.map((event, index) => + index === events.length - 1 + ? { + ...event, + displayText: `${event.displayText}\n${streamedText}`, + result: { + ...event.result, + content: `${event.displayText}\n${streamedText}`, + status: "running", + }, + } + : event + ); + const updated = await invokeE2E( + "seedChatEvents", + sessionId, + streamedEvents, + { runtimeStatus: "running" } + ); + if (!updated?.ok) { + throw new Error( + `stream-scroll delta seed failed: ${updated?.error ?? "unknown"}` + ); + } + + await browser.waitUntil( + async () => + execJS(` + const scroller = document.querySelector('[data-testid="chat-history-scroll-container"]'); + const scrollButton = Array.from(document.querySelectorAll('button')) + .find((button) => /scroll to bottom/i.test(button.getAttribute('aria-label') || '')); + return Boolean( + scroller && + scroller.scrollTop <= 10 && + scrollButton + ); + `), + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: + "streaming output forced the manually-scrolled history back to the bottom", + } + ); + const finalState = await invokeE2E("inspectChatState"); + if (!finalState?.ok || !JSON.stringify(finalState).includes(streamedText)) { + throw new Error("stream-scroll delta never entered canonical chat state"); + } + }); + it("lazily loads an imported Claude Code round body and auto-refetches it after a replace reload", async function () { if (!shouldRunScenario("claude-imported-lazy-replay")) { this.skip(); diff --git a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs index ec8e9dd206..b8d0ac683c 100644 --- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs @@ -3,6 +3,7 @@ import { join } from "node:path"; import { getApiAccount, + js, selectPreferredModel, } from "../../support/core/agentOrgUiDriver.mjs"; import { @@ -31,6 +32,7 @@ import { selectCloudOrgScopeFromSidebar, setCloudSessionModeViaDialog, setCloudSessionVisibilityViaDialog, + startDelayedCloudFailureEndpoint, typeRendered, unwrap, waitForApp, @@ -67,6 +69,7 @@ const EDITED_COMMENT_BODY = `@agent dual-instance edited task ${RUN_ID}`; const EDITED_COMMENT_BRIEF = EDITED_COMMENT_BODY.slice("@agent ".length); const REPLY_BODY = `Owner reply from the other instance ${RUN_ID}`; const TEAM_INBOX_MENTION_BODY = `Team Inbox mention ${RUN_ID}`; +const TEAM_CHAT_MENTION_BODY = `Team Chat mention ${RUN_ID}`; const SEND_BODY = `Continue this work from the matching workspace ${RUN_ID}`; const PROJECT_NAME = `Dual cloud project ${RUN_ID}`; const PROJECT_SLUG = PROJECT_NAME.toLowerCase() @@ -1292,7 +1295,7 @@ describe("Cloud collaboration with two independent rendered app instances", func await browser.waitUntil( async () => execJS( - `return document.querySelectorAll('[data-testid="cloud-org-member-row"]').length >= 2;` + `return Boolean(document.querySelector('[data-testid="cloud-org-member-row"][data-member-id=${JSON.stringify(teammate.userId)}]'));` ), { timeout: CLOUD_FETCH_TIMEOUT_MS, @@ -1729,6 +1732,16 @@ describe("Cloud collaboration with two independent rendered app instances", func ); } + // Fresh streamed imports intentionally defer the derived Session Blame + // index so the first open does not synchronously reload a large replay. + // Reopening the same production Cloud row is the supported no-op refresh: + // it sees the durable cursor/history and fills the deferred local index. + await clickRenderedOn( + second.client, + remoteRowSelector, + "secondary refresh imported replay for Session Blame" + ); + // Full-replay authorization is also the authorization boundary for Team // Session Blame. The imported transcript must be projected locally with // the owner's identity; no second cloud provenance database is involved. @@ -1898,6 +1911,28 @@ describe("Cloud collaboration with two independent rendered app instances", func // Presence is a separate, ephemeral plane: the teammate must see the // owner viewing the same cloud session, lose the chip when the owner // leaves, and regain it when the owner re-opens the session. + // Realtime presence is intentionally scoped to the actively selected + // Cloud org. Make that product precondition explicit for the owner before + // asserting that the teammate can see the owner's viewing lease. + await selectCloudOrgScopeFromSidebar(teamOrgId); + await browser.waitUntil( + async () => { + const presence = unwrap( + await invokeE2E("cloudInspectPresence"), + "inspect owner presence after selecting the team scope" + ); + return ( + presence.activeSessionId === sessionId && + presence.outbound?.[teamOrgId]?.viewingSessionId === sessionId + ); + }, + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: + "owner never advertised the source session after entering the team scope", + } + ); const ownerViewerChip = '[data-testid="session-viewers-indicator"]'; try { await waitForRenderedOn( @@ -2454,6 +2489,316 @@ describe("Cloud collaboration with two independent rendered app instances", func } }); + it("C3. sends a Team Chat @mention with pending/failed/retry delivery and reaches the teammate Inbox", async function () { + this.timeout(240_000); + + unwrap( + await invokeE2E("openSession", sessionId), + "primary reopen source session for Team Chat mention" + ); + await clickRendered( + '[data-testid="conversation-mode-pill"] button[aria-label="Team chat"]', + "primary select Team Chat composer mode" + ); + await browser.waitUntil( + async () => + execJS(` + const button = document.querySelector('[data-testid="conversation-mode-pill"] button[aria-label="Team chat"]'); + return button?.getAttribute('aria-pressed') === 'true'; + `), + { + timeout: 15_000, + interval: 100, + timeoutMsg: "Team Chat composer mode did not become active", + } + ); + + const editorSelector = + '[data-testid="chat-input"] [contenteditable="true"]'; + await waitForRendered(editorSelector, "primary Team Chat editor"); + const typedAt = await execJS(` + const editors = Array.from(document.querySelectorAll(${JSON.stringify(editorSelector)})) + .filter((element) => element.isContentEditable && element.getClientRects().length > 0); + const editor = editors.at(-1); + if (!editor) return false; + editor.focus(); + document.execCommand('selectAll', false, null); + document.execCommand('insertText', false, '@'); + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: '@' })); + return true; + `); + if (!typedAt) throw new Error("primary Team Chat editor rejected @"); + const mentionOptionSelector = `[data-testid="agent-org-mention-option"][data-mention-id="${teammate.userId}"]`; + await waitForRendered( + mentionOptionSelector, + "primary teammate mention option" + ); + const mentionPicked = await execJS(js.visibleClick(mentionOptionSelector)); + if (mentionPicked !== "clicked") { + throw new Error( + `primary choose teammate mention pill failed: ${mentionPicked}` + ); + } + const appended = await execJS(` + const editor = Array.from(document.querySelectorAll(${JSON.stringify(editorSelector)})) + .filter((element) => element.isContentEditable && element.getClientRects().length > 0) + .at(-1); + if (!editor || !editor.querySelector('[data-composer-pill="true"][data-pill-id]')) return false; + editor.focus(); + document.execCommand('insertText', false, ${JSON.stringify(` ${TEAM_CHAT_MENTION_BODY}`)}); + editor.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: ${JSON.stringify(` ${TEAM_CHAT_MENTION_BODY}`)} })); + return true; + `); + if (!appended) { + throw new Error( + "Team Chat mention pill was not preserved while appending body" + ); + } + + await execJS(` + window.__e2eTeamChatDeliveryStates = []; + window.__e2eTeamChatDeliveryObserver?.disconnect?.(); + const record = () => { + for (const status of ['pending', 'failed']) { + if (document.querySelector('[data-testid="chat-message-delivery-' + status + '"]')) { + window.__e2eTeamChatDeliveryStates.push(status); + } + } + }; + const observer = new MutationObserver(record); + observer.observe(document.body, { childList: true, subtree: true, attributes: true }); + window.__e2eTeamChatDeliveryObserver = observer; + record(); + return true; + `); + let failedState = null; + const delayedFailure = await startDelayedCloudFailureEndpoint(); + await applyCloudEndpointOverride(delayedFailure.endpoint); + try { + await clickRendered( + '[data-testid="chat-send-button"]', + "primary send offline Team Chat mention" + ); + await browser.waitUntil( + async () => + execJS(` + const group = Array.from(document.querySelectorAll('[data-chat-group-index]')) + .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)})); + return Boolean(group?.querySelector('[data-testid="chat-message-delivery-failed"]')); + `), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: "the failed Team Chat delivery row never rendered", + } + ); + failedState = await execJS(` + const editor = Array.from(document.querySelectorAll(${JSON.stringify(editorSelector)})) + .filter((element) => element.getClientRects().length > 0) + .at(-1); + const group = Array.from(document.querySelectorAll('[data-chat-group-index]')) + .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)})); + return { + observed: window.__e2eTeamChatDeliveryStates ?? [], + composerText: editor?.textContent ?? '', + failedText: group?.textContent ?? '', + retryPresent: Boolean(group?.querySelector('[data-testid="chat-message-delivery-retry"]')), + }; + `); + } finally { + await applyCloudEndpointOverride(env); + await delayedFailure.close(); + } + if ( + !failedState || + !failedState.observed.includes("pending") || + !failedState.observed.includes("failed") || + failedState.composerText.includes(TEAM_CHAT_MENTION_BODY) || + !failedState.failedText.includes(TEAM_CHAT_MENTION_BODY) || + !failedState.retryPresent + ) { + throw new Error( + `Team Chat delivery did not follow pending -> failed with a durable retry row: ${JSON.stringify(failedState)}` + ); + } + + await browser.waitUntil( + async () => + execJS(` + const group = Array.from(document.querySelectorAll('[data-chat-group-index]')) + .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)})); + const button = group?.querySelector('[data-testid="chat-message-delivery-retry"]'); + if (!button || button.getClientRects().length === 0) return false; + button.click(); + return true; + `), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 100, + timeoutMsg: "failed Team Chat retry action was not clickable", + } + ); + try { + await browser.waitUntil( + async () => + execJS(` + const group = Array.from(document.querySelectorAll('[data-chat-group-index]')) + .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)})); + return Boolean( + group && + !group.querySelector('[data-testid="chat-message-delivery-pending"]') && + !group.querySelector('[data-testid="chat-message-delivery-failed"]') + ); + `), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: "retried Team Chat message never became sent", + } + ); + } catch (error) { + const [row, debug] = await Promise.all([ + execJS(` + const group = Array.from(document.querySelectorAll('[data-chat-group-index]')) + .find((candidate) => (candidate.textContent ?? '').includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)})); + const failed = group?.querySelector('[data-testid="chat-message-delivery-failed"]'); + return { + text: group?.textContent ?? '', + failedTitle: failed?.getAttribute('title') ?? null, + failedLabel: failed?.getAttribute('aria-label') ?? null, + pending: Boolean(group?.querySelector('[data-testid="chat-message-delivery-pending"]')), + }; + `), + invokeE2E("cloudInspectDebugState", { sessionId }), + ]); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n` + + `target row: ${JSON.stringify(row)}\n` + + `comment debug: ${JSON.stringify(debug?.debug ?? debug)}` + ); + } + await execJS(` + window.__e2eTeamChatDeliveryObserver?.disconnect?.(); + delete window.__e2eTeamChatDeliveryObserver; + return true; + `); + + await clickRenderedOn( + second.client, + '[data-testid="sidebar-team-inbox"]', + "secondary Team Inbox for Team Chat mention" + ); + await second.client.waitUntil( + async () => + executeOn( + second.client, + ` + const row = Array.from(document.querySelectorAll('[data-testid="team-inbox-row"]')) + .find((candidate) => (candidate.textContent ?? '').includes(arguments[0])); + if (!row) return false; + row.setAttribute('data-e2e-team-chat-inbox', 'true'); + return true; + `, + [TEAM_CHAT_MENTION_BODY] + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: + "Team Chat @mention never reached the teammate's rendered Inbox", + } + ); + + // A row in the Inbox is not sufficient evidence: open its production + // detail surface, then follow the real "Open in New Tab" navigation into + // the locally materialized replay. This catches source-id/local-id routing + // mistakes that otherwise leave a bright Inbox row pointing at a missing + // provider-native history. + await clickRenderedOn( + second.client, + '[data-e2e-team-chat-inbox="true"]', + "secondary open Team Chat mention detail" + ); + await waitForRenderedOn( + second.client, + '[data-testid="team-inbox-mention-thread"]', + "secondary Team Chat mention detail", + CLOUD_FETCH_TIMEOUT_MS + ); + const renderedMentionDetail = await executeOn( + second.client, + ` + const detail = document.querySelector('[data-testid="team-inbox-mention-thread"]'); + const open = document.querySelector('[data-testid="team-inbox-open-source"]'); + return { + body: detail?.textContent ?? '', + openLabel: open?.getAttribute('aria-label') ?? open?.getAttribute('title') ?? '', + }; + ` + ); + if ( + !renderedMentionDetail.body.includes(TEAM_CHAT_MENTION_BODY) || + !/open in new tab/i.test(renderedMentionDetail.openLabel) + ) { + throw new Error( + `Team Chat Inbox detail did not expose the received comment and Open in New Tab action: ${JSON.stringify(renderedMentionDetail)}` + ); + } + + await clickRenderedOn( + second.client, + '[data-testid="team-inbox-open-source"]', + "secondary open Team Chat source in new tab" + ); + await second.client.waitUntil( + async () => { + const state = unwrapOn( + await invokeOn(second.client, "inspectChatState"), + "secondary source opened from Team Chat Inbox" + ); + const transcript = await executeOn( + second.client, + `return document.querySelector('[data-testid="chat-message-list"]')?.textContent ?? '';` + ); + return ( + state.activeSessionId === secondaryImportedSessionId && + transcript.includes("Inherited answer 2") && + transcript.includes(TEAM_CHAT_MENTION_BODY) + ); + }, + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: + "Open in New Tab did not restore the imported replay with its source history and Team Chat comment", + } + ); + const openedSource = await executeOn( + second.client, + ` + const body = document.body?.innerText ?? ''; + const transcript = document.querySelector('[data-testid="chat-message-list"]'); + return { + activeSessionTab: Boolean(document.querySelector( + '[data-session-tab-drop-target="chat-panel"] [role="tab"][aria-selected="true"]' + )), + transcript: transcript?.textContent ?? '', + nativeHistoryError: /provider-native transcript[^\\n]*not found|native history[^\\n]*not found|history file[^\\n]*not found/i.exec(body)?.[0] ?? null, + }; + ` + ); + if ( + !openedSource.activeSessionTab || + !openedSource.transcript.includes("Inherited answer 2") || + !openedSource.transcript.includes(TEAM_CHAT_MENTION_BODY) || + openedSource.nativeHistoryError + ) { + throw new Error( + `Team Chat Inbox source did not open as a complete local replay: ${JSON.stringify(openedSource)}` + ); + } + }); + it("D. syncs comment CRUD/status, intercepts send into a same-remote fork, and revokes directed access live", async function () { this.timeout(360_000); diff --git a/tests/e2e/specs/core/cloud-org-ui.spec.mjs b/tests/e2e/specs/core/cloud-org-ui.spec.mjs index 90530b1422..eabbb710b8 100644 --- a/tests/e2e/specs/core/cloud-org-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-org-ui.spec.mjs @@ -1140,7 +1140,7 @@ describe("Cloud org rendered UI (managed ORG2 Cloud)", function () { } catch (error) { const diagnostic = await execJS(` const panel = document.querySelector('[data-testid="cloud-org-panel"]'); - const form = document.querySelector('[data-testid="create-collab-org-body"]'); + const form = document.querySelector('[data-testid="collab-org-form"]'); return { pathname: location.pathname, panel: panel?.textContent?.trim().slice(0, 800) ?? null, diff --git a/tests/e2e/specs/core/session-account-switch.spec.mjs b/tests/e2e/specs/core/session-account-switch.spec.mjs index b27e54031a..f14faa0298 100644 --- a/tests/e2e/specs/core/session-account-switch.spec.mjs +++ b/tests/e2e/specs/core/session-account-switch.spec.mjs @@ -1,4 +1,6 @@ /* global describe, before, it, expect */ +import { execFileSync } from "node:child_process"; + import { CLAUDE_CODE_AGENT_TYPE, CODEX_AGENT_TYPE, @@ -28,12 +30,16 @@ import { logScenarioScope, runRenderedAccountSwitch, runRenderedMidStreamAccountSwitch, + sendFromRenderedComposer, sharedModelsFromChain, shouldRunScenario, skipCursorProviderBlockedIfApplicable, skipOrFailMissingCoverage, + switchAccountThroughRenderedPicker, + switchRuntimeThroughRenderedPicker, unwrap, waitForApp, + waitForComposerIdle, } from "../../support/core/session/accountSwitchDriver.mjs"; describe("Claude Code CLI multi-account switching", () => { @@ -394,3 +400,357 @@ describe("Claude Code CLI multi-account switching", () => { } }); }); + +const nativeLiveIt = + process.env.E2E_NATIVE_CONTINUATION_LIVE === "1" ? it : it.skip; +const nativeAppLiveIt = + process.env.E2E_NATIVE_APP_UI_LIVE === "1" ? it : it.skip; + +function requiredEnv(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required for live native coverage`); + return value; +} + +function liveCliAccount(accounts, type, model, requested) { + return accounts.find( + (row) => + row.agent_type === type && + row.enabled && + row.health_status !== "invalid" && + (row.enabled_models ?? []).includes(model) && + (!requested || row.id === requested || row.name === requested) + ); +} + +const NATIVE_LARGE_MIN_EVENTS_FLOOR = 40; +const NATIVE_ASSISTANT_PLACEHOLDER_TEXT = "AI Processing..."; +const NATIVE_TOOL_RESULT_TEXT_KEYS = [ + "output", + "observation", + "content", + "stdout", + "text", + "message", + "summary", +]; +const NATIVE_UNFINISHED_RESULT_STATUS = new Set([ + "running", + "pending", + "in_progress", + "awaiting_user", +]); + +function nativeLargeMinimumEvents() { + const configured = process.env.E2E_NATIVE_LARGE_MIN_EVENTS?.trim(); + if (!configured) return NATIVE_LARGE_MIN_EVENTS_FLOOR; + const parsed = Number.parseInt(configured, 10); + if (!Number.isFinite(parsed)) { + throw new Error( + `E2E_NATIVE_LARGE_MIN_EVENTS=${JSON.stringify(configured)} is not an integer` + ); + } + if (parsed < NATIVE_LARGE_MIN_EVENTS_FLOOR) { + throw new Error( + `E2E_NATIVE_LARGE_MIN_EVENTS=${parsed} is below this acceptance's floor of ${NATIVE_LARGE_MIN_EVENTS_FLOOR}. ` + + `This test exists to prove large provider-native histories survive a cross-runtime round trip, so the operator must supply a session that genuinely projects at least ${NATIVE_LARGE_MIN_EVENTS_FLOOR} events instead of lowering the bar.` + ); + } + return parsed; +} + +function collectStrings(value, sink) { + if (typeof value === "string") { + sink.push(value); + return sink; + } + if (Array.isArray(value)) { + for (const entry of value) collectStrings(entry, sink); + return sink; + } + if (value && typeof value === "object") { + for (const entry of Object.values(value)) collectStrings(entry, sink); + return sink; + } + return sink; +} + +function anchorText(value) { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + return Array.from(trimmed).slice(0, 80).join("").trimEnd(); +} + +function actionTypeHistogram(events) { + const counts = {}; + for (const event of events) { + const key = String(event?.actionType ?? "unknown"); + counts[key] = (counts[key] ?? 0) + 1; + } + return JSON.stringify(counts); +} + +function toolResultText(event) { + const result = event?.result; + if (!result || typeof result !== "object" || Array.isArray(result)) return ""; + for (const key of NATIVE_TOOL_RESULT_TEXT_KEYS) { + const value = result[key]; + if (typeof value === "string" && value.trim()) return value; + } + const status = + typeof event.resultStatus === "string" ? event.resultStatus.trim() : ""; + const nested = collectStrings(result, []).find( + (entry) => entry.trim().length >= 8 && entry.trim() !== status + ); + return nested ?? ""; +} + +function isCompletedToolCall(event) { + if ( + event?.actionType !== "tool_call" && + event?.actionType !== "tool_result" + ) { + return false; + } + const status = + typeof event.resultStatus === "string" + ? event.resultStatus.trim().toLowerCase() + : ""; + if (status && NATIVE_UNFINISHED_RESULT_STATUS.has(status)) return false; + return toolResultText(event).trim().length > 0; +} + +async function openLargeSession(sessionId, label) { + unwrap(await invokeE2E("openSession", sessionId), `${label} open`); + const state = unwrap(await invokeE2E("inspectChatState"), `${label} inspect`); + const rawEvents = state.rawEvents ?? []; + const chatEvents = state.chatEvents ?? []; + const minimum = nativeLargeMinimumEvents(); + const shape = `raw=${rawEvents.length} chat=${chatEvents.length} actionTypes=${actionTypeHistogram(rawEvents)}`; + if (rawEvents.length < minimum) { + throw new Error( + `${label}: session ${sessionId} projects only ${rawEvents.length} events (${shape}), below the ${minimum}-event bar. ` + + `Supply a session id that genuinely projects at least ${minimum} events; lowering E2E_NATIVE_LARGE_MIN_EVENTS is not an accepted remedy.` + ); + } + const anchor = (events, predicate, extract, kind, remedy) => { + for (const event of events) { + if (!predicate(event)) continue; + const text = anchorText(extract(event)); + if (text) return text; + } + throw new Error( + `${label}: session ${sessionId} has no ${kind} anchor (${shape}). ${remedy}` + ); + }; + return [ + anchor( + rawEvents, + (event) => event.source === "user", + (event) => event.displayText, + "user turn", + "A source=user event carrying display text is required; image-only user turns do not qualify." + ), + anchor( + chatEvents, + (event) => + event.source === "assistant" && + event.displayVariant === "message" && + event.displayText !== NATIVE_ASSISTANT_PLACEHOLDER_TEXT, + (event) => event.displayText, + "assistant message", + "A chat event with source=assistant and displayVariant=message is required." + ), + anchor( + rawEvents, + isCompletedToolCall, + (event) => toolResultText(event), + "completed tool call with result", + "A raw event with actionType=tool_call/tool_result, a non-running resultStatus and non-empty result text is required. " + + "If the projection drops this provider's tool calls entirely, that is the defect under test - fix the projection, do not retarget or skip this anchor." + ), + ]; +} + +async function continueWith(target, marker, label) { + await switchRuntimeThroughRenderedPicker(target.type, label); + await switchAccountThroughRenderedPicker(target.account, target.model, label); + await sendFromRenderedComposer( + `Reply with exactly ${marker} and no other words.`, + label + ); + await waitForComposerIdle(label, marker); + return unwrap(await invokeE2E("inspectChatState"), `${label} final state`); +} + +function assertHistory(state, expected, label) { + const transcript = collectStrings(state.rawEvents ?? [], []); + collectStrings(state.chatEvents ?? [], transcript); + for (const text of expected) { + if (!transcript.some((entry) => entry.includes(text))) { + throw new Error( + `${label} lost canonical history ${JSON.stringify(text)}` + ); + } + } +} + +describe("provider-native continuation acceptance (live, opt-in)", () => { + nativeLiveIt( + "round-trips large Codex/Claude histories in both directions", + async function () { + this.timeout(1_200_000); + await waitForApp(); + const accounts = unwrap( + await invokeE2E("listAccounts"), + "native listAccounts" + ).accounts; + const claudeModel = + process.env.E2E_CLAUDE_CODE_MODEL ?? "claude-sonnet-4-6"; + const codexModel = process.env.E2E_CODEX_MODEL ?? "gpt-5.5"; + const claude = liveCliAccount( + accounts, + CLAUDE_CODE_AGENT_TYPE, + claudeModel, + process.env.E2E_CLAUDE_CODE_ACCOUNT + ); + const codex = liveCliAccount( + accounts, + CODEX_AGENT_TYPE, + codexModel, + process.env.E2E_CODEX_ACCOUNT + ); + if (!claude || !codex) + throw new Error("live Codex/Claude account missing"); + const targets = { + claude: { + account: claude, + model: claudeModel, + type: CLAUDE_CODE_AGENT_TYPE, + }, + codex: { account: codex, model: codexModel, type: CODEX_AGENT_TYPE }, + }; + + const scenarios = [ + { + source: requiredEnv("E2E_NATIVE_LARGE_CODEX_SESSION_ID"), + label: "Codex-Claude-Codex", + first: targets.claude, + second: targets.codex, + }, + { + source: requiredEnv("E2E_NATIVE_LARGE_CLAUDE_SESSION_ID"), + label: "Claude-Codex-Claude", + first: targets.codex, + second: targets.claude, + }, + ]; + const prepared = []; + const unusable = []; + for (const scenario of scenarios) { + try { + prepared.push({ + scenario, + anchors: await openLargeSession(scenario.source, scenario.label), + }); + } catch (error) { + unusable.push(String(error?.message ?? error)); + } + } + if (unusable.length > 0) { + throw new Error( + `provider-native continuation acceptance has no usable input:\n${unusable.join("\n")}` + ); + } + + for (const { scenario, anchors } of prepared) { + unwrap( + await invokeE2E("openSession", scenario.source), + `${scenario.label} reopen` + ); + const firstMarker = `NATIVE_FIRST_${Date.now()}`; + const first = await continueWith( + scenario.first, + firstMarker, + `${scenario.label} first` + ); + assertHistory(first, anchors, `${scenario.label} first`); + const second = await continueWith( + scenario.second, + `NATIVE_RETURN_${Date.now()}`, + `${scenario.label} return` + ); + assertHistory( + second, + [...anchors, firstMarker], + `${scenario.label} return` + ); + } + } + ); +}); + +function nativeProcessWindows(processName) { + const script = `tell application "System Events" + set matches to every application process whose name contains "${processName}" + if (count of matches) is 0 then return "" + set target to item 1 of matches + if (count of windows of target) is 0 then return (name of target) + set uiText to "" + repeat with uiElement in entire contents of front window of target + try + if role of uiElement is "AXStaticText" then + set uiText to uiText & linefeed & (value of uiElement as text) + end if + end try + end repeat + return (name of target) & linefeed & ((name of every window of target) as text) & uiText + end tell`; + return execFileSync("osascript", ["-e", script], { encoding: "utf8" }); +} + +describe("native App catalog visibility (live, ignored by default)", () => { + nativeAppLiveIt( + "opens cataloged UUID/title/cwd rows in both native Apps", + async function () { + this.timeout(240_000); + if (process.platform !== "darwin") throw new Error("macOS only"); + await waitForApp(); + for (const target of [ + { prefix: "CODEX", process: "Codex" }, + { prefix: "CLAUDE", process: "Claude" }, + ]) { + const sessionId = requiredEnv( + `E2E_NATIVE_${target.prefix}_APP_SESSION_ID` + ); + const uuid = requiredEnv(`E2E_NATIVE_${target.prefix}_APP_UUID`); + const title = requiredEnv(`E2E_NATIVE_${target.prefix}_APP_TITLE`); + const cwd = requiredEnv(`E2E_NATIVE_${target.prefix}_APP_CWD`); + unwrap( + await invokeE2E("openSession", sessionId), + `${target.process} open` + ); + const state = unwrap( + await invokeE2E("inspectChatState"), + `${target.process} catalog` + ); + expect(sessionId).toContain(uuid); + expect(state.activeSession?.name ?? "").toContain(title); + expect(state.activeSession?.repoPath).toBe(cwd); + await ( + await browser.$('[data-testid="chat-panel-header-more-button"]') + ).click(); + const open = await browser.$( + '[data-testid="session-open-in-app-menu-item"]' + ); + await open.waitForExist({ timeout: 30_000 }); + await open.click(); + await browser.pause(3_000); + const nativeUi = nativeProcessWindows(target.process); + expect(nativeUi).toContain(title); + expect(nativeUi).toContain(cwd.split("/").filter(Boolean).at(-1)); + } + } + ); +}); diff --git a/tests/e2e/support/core/cloudOrgUiDriver.mjs b/tests/e2e/support/core/cloudOrgUiDriver.mjs index 7b7ddab0b3..a499cb0772 100644 --- a/tests/e2e/support/core/cloudOrgUiDriver.mjs +++ b/tests/e2e/support/core/cloudOrgUiDriver.mjs @@ -28,6 +28,7 @@ * not be imported here. */ import { createHash } from "node:crypto"; +import { createServer } from "node:http"; import { gzipSync } from "node:zlib"; import { @@ -323,6 +324,59 @@ export async function clearCloudEndpointOverride() { `); } +/** + * Starts a loopback Cloud endpoint that accepts the browser request, keeps it + * in flight long enough for the rendered optimistic row to be observable, + * then returns a deterministic failure. A closed port rejects before the + * browser can paint/observe `pending`, making a strict pending -> failed E2E + * assertion scheduler-dependent rather than testing the production UI. + */ +export async function startDelayedCloudFailureEndpoint(delayMs = 750) { + const server = createServer((request, response) => { + response.setHeader("access-control-allow-origin", "*"); + response.setHeader("access-control-allow-headers", "*"); + response.setHeader( + "access-control-allow-methods", + "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS" + ); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + setTimeout(() => { + response.writeHead(503, { "content-type": "application/json" }); + response.end( + JSON.stringify({ message: "forced delayed Cloud delivery failure" }) + ); + }, delayMs); + }); + await new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(0, "127.0.0.1", () => { + server.off("error", rejectListen); + resolveListen(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("delayed Cloud failure endpoint has no TCP address"); + } + const origin = `http://127.0.0.1:${address.port}`; + return { + endpoint: { + webOrigin: origin, + supabaseUrl: origin, + anonKey: "delayed-failure-anon-key", + }, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }), + }; +} + // ============================================================================ // Low-level rendered helpers // ============================================================================ @@ -434,7 +488,7 @@ export async function openCreateOrgFormFromSidebar() { "sidebar add-org action" ); await waitForRendered( - '[data-testid="create-collab-org-body"]', + '[data-testid="collab-org-form"]', "create org form" ); } @@ -795,7 +849,7 @@ export async function setCloudSessionVisibilityViaDialog( } // ============================================================================ -// Session comments + owner-local in-place agent follow-up +// Session comments + local native continuation // ============================================================================ // // Same contract as everything above: assertions and clicks stay on the diff --git a/tests/e2e/support/core/dualCloudHarness.mjs b/tests/e2e/support/core/dualCloudHarness.mjs index a35447036e..bf051bdca3 100644 --- a/tests/e2e/support/core/dualCloudHarness.mjs +++ b/tests/e2e/support/core/dualCloudHarness.mjs @@ -9,6 +9,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import net from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -19,20 +20,38 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "..", "..", "..", ".."); const tauriConfigPath = resolve(repoRoot, "src-tauri/tauri.conf.json"); const compiledBinaryPath = resolve(repoRoot, "src-tauri/target/debug/org2"); +const require = createRequire(import.meta.url); +const { createInstanceProfileFromIdeServerPort } = require( + resolve(repoRoot, "scripts/tauri/instance-profile.cjs") +); const SECONDARY_WEBDRIVER_PORT = Number.parseInt( process.env.E2E_SECONDARY_WEBDRIVER_PORT ?? "4455", 10 ); +const PRIMARY_IDE_PORT = Number.parseInt( + process.env.E2E_IDE_SERVER_PORT ?? "13847", + 10 +); const SECONDARY_IDE_PORT = Number.parseInt( - process.env.E2E_SECONDARY_IDE_SERVER_PORT ?? "24847", + process.env.E2E_SECONDARY_IDE_SERVER_PORT ?? String(PRIMARY_IDE_PORT + 1), 10 ); +const SECONDARY_INSTANCE_PROFILE = + createInstanceProfileFromIdeServerPort(SECONDARY_IDE_PORT); const SECONDARY_CLI_PROXY_PORT = Number.parseInt( - process.env.E2E_SECONDARY_CLI_PROXY_PORT ?? "28889", + process.env.E2E_SECONDARY_CLI_PROXY_PORT ?? + String(SECONDARY_INSTANCE_PROFILE.cliProxyPort), 10 ); +if (SECONDARY_CLI_PROXY_PORT !== SECONDARY_INSTANCE_PROFILE.cliProxyPort) { + throw new Error( + `E2E_SECONDARY_CLI_PROXY_PORT=${SECONDARY_CLI_PROXY_PORT} does not match the embedded ` + + `instance${SECONDARY_INSTANCE_PROFILE.id} runtime profile (${SECONDARY_INSTANCE_PROFILE.cliProxyPort}).` + ); +} + function writeJsonAtomically(path, value) { mkdirSync(dirname(path), { recursive: true }); const temporaryPath = `${path}.${process.pid}.tmp`; @@ -158,8 +177,8 @@ function secondaryTauriConfig(originalConfig) { return `${JSON.stringify( { ...config, - productName: "ORG2 E2E Instance 2", - identifier: "org2ai.org2.e2e.instance2", + productName: SECONDARY_INSTANCE_PROFILE.productName, + identifier: SECONDARY_INSTANCE_PROFILE.identifier, build: { ...config.build, devUrl: `http://localhost:${frontendPort}`, @@ -167,7 +186,9 @@ function secondaryTauriConfig(originalConfig) { plugins: { ...config.plugins, "deep-link": { - desktop: { schemes: ["yorgai-e2e-instance2", "orgii-e2e-instance2"] }, + desktop: { + schemes: [...SECONDARY_INSTANCE_PROFILE.deepLinkSchemes], + }, }, updater: { ...config.plugins?.updater, active: false }, }, diff --git a/tests/e2e/support/core/session/accountSwitchDriver.mjs b/tests/e2e/support/core/session/accountSwitchDriver.mjs index da03e3b4cf..0ae41b5c05 100644 --- a/tests/e2e/support/core/session/accountSwitchDriver.mjs +++ b/tests/e2e/support/core/session/accountSwitchDriver.mjs @@ -714,7 +714,7 @@ async function configureRenderedCreator({ ); } -async function sendFromRenderedComposer(prompt, label) { +export async function sendFromRenderedComposer(prompt, label) { const inputSelector = '[data-testid="chat-input"] [contenteditable="true"]'; await browser.waitUntil(async () => execJS(js.exists(inputSelector)), { timeout: MOUNT_TIMEOUT_MS, @@ -763,7 +763,7 @@ async function waitForActiveSession(label) { ).sessionId; } -async function waitForComposerIdle(label, expectedAssistantText = null) { +export async function waitForComposerIdle(label, expectedAssistantText = null) { await browser.waitUntil( async () => { const state = await execJS(js.sendState); @@ -844,11 +844,34 @@ function sessionModelMatchesAny(actualModel, expectedModels) { ); } +async function readConversationTargetPill() { + return execJS(` + const target = document.querySelector('[data-testid="chat-model-target"]'); + return target + ? { + accountId: target.getAttribute("data-account-id"), + modelId: target.getAttribute("data-model-id"), + text: target.textContent, + } + : null; + `); +} + async function isSessionPatchedTo(accountId, expectedModels, label) { const state = unwrap( await invokeE2E("inspectChatState"), `${label}-inspectChatState` ); + if ( + !state.activeSession || + state.activeSession.category === "external_history" + ) { + const pill = await readConversationTargetPill(); + return ( + pill?.accountId === accountId && + sessionModelMatchesAny(pill?.modelId, expectedModels) + ); + } return ( state.activeSession?.accountId === accountId && sessionModelMatchesAny(state.activeSession?.model, expectedModels) @@ -919,7 +942,7 @@ async function assertCliPersistedAccount(sessionId, expectedAccountId, label) { ); } -async function switchAccountThroughRenderedPicker( +export async function switchAccountThroughRenderedPicker( followupAccount, model, label @@ -960,7 +983,10 @@ async function switchAccountThroughRenderedPicker( ); } - const modelClicked = await clickLastVisibleNative(modelSelector); + const exactModelSelector = `[data-spotlight-model-section="all"][data-spotlight-model-id="${model}"]`; + const modelClicked = (await execJS(js.exists(exactModelSelector))) + ? await clickLastVisibleNative(exactModelSelector) + : await clickLastVisibleNative(modelSelector); if (modelClicked?.status !== "clicked") { throw new Error( `${label} model option click failed for model=${model}: ${JSON.stringify(modelClicked)} dump=${JSON.stringify(await execJS(js.pageDump))}` @@ -971,11 +997,12 @@ async function switchAccountThroughRenderedPicker( ); const modelGroupIds = parseModelIdList(modelClicked.groupModelIds); - const allowedSwitchModels = getAllowedSwitchModels( - followupAccount, - model, - modelGroupIds - ); + const allowedSwitchModels = [ + ...new Set([ + ...getAllowedSwitchModels(followupAccount, model, modelGroupIds), + ...(modelClicked.modelId ? [String(modelClicked.modelId)] : []), + ]), + ]; if ( await isSessionPatchedTo(followupAccount.id, allowedSwitchModels, label) @@ -993,6 +1020,7 @@ async function switchAccountThroughRenderedPicker( }); let sourceClicked = null; + let firstClickedSource = null; const sourceClickStrategies = [ clickLastVisibleNative, clickLastVisibleReactPath, @@ -1011,6 +1039,7 @@ async function switchAccountThroughRenderedPicker( continue; } if (sourceClicked?.status !== "clicked") continue; + firstClickedSource ??= sourceClicked; if ( await isSessionPatchedTo(followupAccount.id, allowedSwitchModels, label) ) { @@ -1023,7 +1052,43 @@ async function switchAccountThroughRenderedPicker( await browser.pause(500); } throw new Error( - `${label} source option click did not patch session; sourceClicked=${JSON.stringify(sourceClicked)} state=${JSON.stringify(await invokeE2E("inspectChatState"))}; dump=${JSON.stringify(await execJS(js.pageDump))}` + `${label} source option click did not patch session; sourceClicked=${JSON.stringify(firstClickedSource ?? sourceClicked)} pill=${JSON.stringify(await readConversationTargetPill())} state=${JSON.stringify(await invokeE2E("inspectChatState"))}; dump=${JSON.stringify(await execJS(js.pageDump))}` + ); +} + +/** Select a continuation runtime through the rendered New Session palette. */ +export async function switchRuntimeThroughRenderedPicker(cliAgentType, label) { + const trigger = '[data-testid="chat-runtime-pill"]'; + const option = `[data-testid="session-creator-agent-option-cli-${cliAgentType}"]`; + await browser.waitUntil(async () => execJS(js.exists(trigger)), { + timeout: MOUNT_TIMEOUT_MS, + timeoutMsg: `${label} runtime pill never mounted`, + }); + if ((await clickLastVisibleNative(trigger))?.status !== "clicked") { + throw new Error(`${label} runtime pill was not clickable`); + } + await browser.waitUntil(async () => execJS(js.exists(option)), { + timeout: MOUNT_TIMEOUT_MS, + timeoutMsg: `${label} runtime option ${cliAgentType} never appeared`, + }); + if ((await clickLastVisibleNative(option))?.status !== "clicked") { + throw new Error( + `${label} runtime option ${cliAgentType} was not clickable` + ); + } + const expected = + cliAgentType === CLAUDE_CODE_AGENT_TYPE ? "Claude Code" : "Codex"; + await browser.waitUntil( + async () => + execJS(` + return Array.from(document.querySelectorAll('[data-testid="chat-runtime-pill"]')) + .filter((node) => node.getClientRects().length > 0) + .some((node) => ((node.textContent || '') + (node.getAttribute('aria-label') || '')).includes(${JSON.stringify(expected)})); + `), + { + timeout: 20_000, + timeoutMsg: `${label} runtime did not become ${expected}`, + } ); } diff --git a/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs b/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs index a08f28a44c..b839a740ec 100644 --- a/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs +++ b/tests/e2e/support/core/session/agentQueuedControlScenarios.mjs @@ -716,6 +716,33 @@ async function waitForQueuedFollowup(marker) { timeoutMsg: `follow-up marker ${marker} never appeared in queued messages; state=${JSON.stringify(summarizeChatState(await invokeE2E("inspectChatState")))} dump=${JSON.stringify(summarizePageDump(await execJS(js.pageDump)))}`, } ); + + await browser.waitUntil( + async () => { + const clearAll = await execJS(` + const button = document.querySelector('[data-testid="queued-messages-clear-all"]'); + return button + ? { + text: (button.textContent || "").trim(), + title: (button.getAttribute("title") || "").trim(), + } + : null; + `); + return ( + clearAll !== null && + clearAll.text.length > 0 && + clearAll.title.length > 0 && + clearAll.text !== "actions.clearAll" && + clearAll.title !== "actions.clearAll" + ); + }, + { + timeout: 10_000, + interval: 100, + timeoutMsg: + "queued-message clear-all control did not render translated text and title", + } + ); } async function clickSendNowForQueuedMarker(marker) { @@ -732,8 +759,6 @@ async function clickSendNowForQueuedMarker(marker) { `Queued state did not contain marker ${marker}: markerUserEvents=${markerUserEvents.length} markerPreviewEvents=${markerPreviewEvents.length} state=${JSON.stringify(summarizeChatState(state))}` ); } - const previousFlushRequest = state.queueFlushRequest; - let clicked = null; await browser.waitUntil( async () => { @@ -796,17 +821,6 @@ async function clickSendNowForQueuedMarker(marker) { } ); - await browser.waitUntil( - async () => { - const nextState = await inspectChatState(`${marker}-flush`); - return nextState.queueFlushRequest > previousFlushRequest; - }, - { - timeout: 5_000, - timeoutMsg: `Send Now did not invoke queue flush for ${marker}; before=${previousFlushRequest} state=${JSON.stringify(summarizeChatState(await invokeE2E("inspectChatState")))} dump=${JSON.stringify(summarizePageDump(await execJS(js.pageDump)))}`, - } - ); - await browser.waitUntil( async () => { const nextState = await inspectChatState(marker); diff --git a/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs b/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs index 8b2ed2420c..01977e7ca9 100644 --- a/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs +++ b/tests/e2e/support/core/session/agentQueuedFollowupDriver.mjs @@ -640,7 +640,6 @@ function summarizeChatState(state) { ), turnPhase: state.turnPhase, turnGeneration: state.turnGeneration, - queueFlushRequest: state.queueFlushRequest, isPendingCancel: state.isPendingCancel, userInitiatedCancel: state.userInitiatedCancel, isQueueEditing: state.isQueueEditing, diff --git a/tests/e2e/wdio.conf.mjs b/tests/e2e/wdio.conf.mjs index 476f9500ac..3ed0438354 100644 --- a/tests/e2e/wdio.conf.mjs +++ b/tests/e2e/wdio.conf.mjs @@ -9,6 +9,7 @@ import { rmSync, writeFileSync, } from "node:fs"; +import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +17,10 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "..", ".."); const appBinary = resolve(repoRoot, "src-tauri/target/debug/org2"); +const require = createRequire(import.meta.url); +const { createInstanceProfileFromIdeServerPort } = require( + resolve(repoRoot, "scripts/tauri/instance-profile.cjs") +); // Load tests/e2e/.env so specs can read OPENAI_API_KEY etc. via process.env. // Quiet failure is fine — the .env is optional; without it, tests fall back to @@ -76,6 +81,20 @@ const ideServerPort = Number.parseInt( process.env.E2E_IDE_SERVER_PORT ?? "13847", 10 ); +const isolatedInstanceProfile = isolatedRun + ? createInstanceProfileFromIdeServerPort(ideServerPort) + : null; +const isolatedCliProxyPort = isolatedInstanceProfile?.cliProxyPort ?? null; +if ( + isolatedRun && + process.env.ORGII_CLI_PROXY_PORT && + Number.parseInt(process.env.ORGII_CLI_PROXY_PORT, 10) !== isolatedCliProxyPort +) { + throw new Error( + `ORGII_CLI_PROXY_PORT=${process.env.ORGII_CLI_PROXY_PORT} does not match the isolated ` + + `instance${isolatedInstanceProfile.id} profile (${isolatedCliProxyPort}).` + ); +} const TAURI_DEV_URL_PORT = 1998; const frontendPort = Number.parseInt( process.env.E2E_FRONTEND_PORT ?? String(TAURI_DEV_URL_PORT), @@ -287,6 +306,30 @@ const externalHistoryHome = mkdtempSync(join(tmpdir(), "orgii-e2e-external-history-")); process.env.ORGII_EXTERNAL_HISTORY_HOME = externalHistoryHome; +// A native-App visibility run is allowed to publish into the real provider +// profile, but it must opt in explicitly. Without this guard the materializer +// inherits the isolated discovery root; Claude Desktop can still retain a +// catalog row for that UUID after the temp root is deleted, leaving a visible +// session that opens as "Session not found on disk". +if (process.env.E2E_NATIVE_PROVIDER_SWITCH_LIVE === "1") { + const configuredNativeHome = process.env.ORGII_NATIVE_TRANSCRIPT_HOME?.trim(); + const officialNativeHome = resolve( + process.env.E2E_NATIVE_PROVIDER_SWITCH_OFFICIAL_HOME?.trim() ?? homedir() + ); + if (!configuredNativeHome) { + throw new Error( + "E2E_NATIVE_PROVIDER_SWITCH_LIVE=1 requires ORGII_NATIVE_TRANSCRIPT_HOME " + + `to point at the official provider home (${officialNativeHome}).` + ); + } + if (resolve(configuredNativeHome) !== officialNativeHome) { + throw new Error( + "Native provider App proof cannot use an isolated publication root: " + + `expected ${officialNativeHome}, got ${resolve(configuredNativeHome)}.` + ); + } +} + // Claude Code imported-history fixture consumed by the // "claude-imported-lazy-replay" scenario in chat-rendering-ui.spec.mjs. It // must exist on disk before the app process launches so the app's own @@ -378,12 +421,20 @@ function ensureClaudeCodeImportFixtureTranscript() { } process.env.ORGII_IDE_SERVER_PORT = String(ideServerPort); +if (isolatedCliProxyPort !== null) { + process.env.ORGII_CLI_PROXY_PORT = String(isolatedCliProxyPort); +} process.env.E2E_BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${ideServerPort}`; ensureE2EWorkspaceRepo(); ensureClaudeCodeImportFixtureTranscript(); -const WDIO_PRE_FLIGHT_PORTS = [webDriverPort, frontendPort, ideServerPort]; +const WDIO_PRE_FLIGHT_PORTS = [ + webDriverPort, + frontendPort, + ideServerPort, + ...(isolatedCliProxyPort === null ? [] : [isolatedCliProxyPort]), +]; const WDIO_PRE_FLIGHT_PROCESS_PATTERNS = [ "tauri-wd", "src-tauri/target/debug/org2", @@ -684,17 +735,36 @@ function startFrontendServer() { waitForPort(frontendPort, 60_000); } -function withTauriDevUrlForFrontendPort(callback) { - if (frontendPort === TAURI_DEV_URL_PORT) return callback(); +function withManagedTauriConfig(callback) { + if (frontendPort === TAURI_DEV_URL_PORT && !isolatedRun) return callback(); const originalConfig = readFileSync(tauriConfigPath, "utf8"); const config = JSON.parse(originalConfig); const patchedConfig = JSON.stringify( { ...config, + ...(isolatedRun + ? { + productName: isolatedInstanceProfile.productName, + identifier: isolatedInstanceProfile.identifier, + } + : {}), build: { ...config.build, devUrl: `http://localhost:${frontendPort}`, }, + ...(isolatedRun + ? { + plugins: { + ...config.plugins, + "deep-link": { + desktop: { + schemes: [...isolatedInstanceProfile.deepLinkSchemes], + }, + }, + updater: { ...config.plugins?.updater, active: false }, + }, + } + : {}), }, null, 2 @@ -708,7 +778,7 @@ function withTauriDevUrlForFrontendPort(callback) { } function buildWebDriverApp() { - withTauriDevUrlForFrontendPort(() => { + withManagedTauriConfig(() => { execFileSync( "cargo", [ From ea1ce85d6fc62861830fa8ce12b4e6f731447441 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:56:21 -0700 Subject: [PATCH 2/7] fix(conversations): preserve explicit account across shared continuation --- .../conversationTargetSelection.test.ts | 27 ++++-- .../ChatPanel/conversationTargetSelection.ts | 3 + .../localConversationContinuation.test.ts | 90 +++++++++++++++++++ .../localConversationContinuation.ts | 48 ++++++++-- .../SessionCreator/agentRuntimeConfig.test.ts | 21 +++++ .../SessionCreator/agentRuntimeConfig.ts | 11 +++ 6 files changed, 187 insertions(+), 13 deletions(-) diff --git a/src/engines/ChatPanel/conversationTargetSelection.test.ts b/src/engines/ChatPanel/conversationTargetSelection.test.ts index d21ec8c1e4..0f812f8f80 100644 --- a/src/engines/ChatPanel/conversationTargetSelection.test.ts +++ b/src/engines/ChatPanel/conversationTargetSelection.test.ts @@ -51,6 +51,26 @@ const registry = { } as unknown as AgentRegistry; describe("canonical conversation target selection", () => { + it("does not replace an unavailable explicit Claude account with ambient auth", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "opus", + workspaceRepoPath: "/repo", + }, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: "opus", + workspaceRepoPath: "/repo", + accounts: [], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toBeNull(); + }); + it("uses the selected Rust agent's existing preferred account and model", () => { expect( resolveConversationRuntimeTarget({ @@ -280,7 +300,7 @@ describe("canonical conversation target selection", () => { }); }); - it("uses Claude ambient only when the conversation has no valid explicit pair", () => { + it("requires an account choice when the prior Claude account was disabled", () => { const selection = { category: "cli_agent", targetKind: "cli_agent", @@ -313,10 +333,7 @@ describe("canonical conversation target selection", () => { registry, nativeCliTargets: ["claude_code", "codex"], }) - ).toEqual({ - cliAgentType: "claude_code", - workspaceRepoPath: "/repo", - }); + ).toBeNull(); }); it("keeps an explicit composer provider switch", () => { diff --git a/src/engines/ChatPanel/conversationTargetSelection.ts b/src/engines/ChatPanel/conversationTargetSelection.ts index ee4590bd16..77b0f90a1f 100644 --- a/src/engines/ChatPanel/conversationTargetSelection.ts +++ b/src/engines/ChatPanel/conversationTargetSelection.ts @@ -223,6 +223,9 @@ export function resolveDefaultConversationTarget({ nativeCliTargets, }); if (resolved) return resolved; + // A persisted/explicit account must not fall through to source-only + // defaults when its credentials or model inventory are unavailable. + if (candidate.accountId) return null; } const parsedSource = CliAgentTypeSchema.safeParse(sourceCliAgentType); diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts index 4276f64598..ccdc31e0bc 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -343,6 +343,96 @@ function mockCompatibleCliEpisode( } describe("durable execution target hydration", () => { + it("retains the owner's explicit account when its native session becomes shared", async () => { + const sharedRoot = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "cliagent-owner-root", + } as const; + mocks.invokeTauri.mockResolvedValue([]); + mocks.cliStatus.mockResolvedValue({ + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "claude-opus-5-high", + repoPath: "/repo", + updatedAt: "2026-09-08T12:00:00.000Z", + }); + + await expect( + loadLocalConversationExecutionTargets(sharedRoot) + ).resolves.toEqual([ + { + sessionId: sharedRoot.conversationId, + updatedAt: "2026-09-08T12:00:00.000Z", + target: { + cliAgentType: "claude_code", + accountId: "anthropic-1", + model: "claude-opus-5-high", + workspaceRepoPath: "/repo", + }, + }, + ]); + }); + + it("restores the owner's pre-sharing runtime child through the cloud root", async () => { + const sharedRoot = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "sdeagent-owner-root", + } as const; + const oldParent = conversationExecutionParentId({ + authority: "local-session", + authorityScope: [], + conversationId: sharedRoot.conversationId, + }); + mocks.invokeTauri.mockImplementation(async (_command, args) => + args?.parentSessionId === oldParent + ? [ + { + sessionId: "cliagent-before-share", + updatedAt: "2026-09-08T14:00:00Z", + }, + ] + : [] + ); + mocks.getAgentSession.mockResolvedValue({ + agentDefinitionId: "builtin:sde", + accountId: "agent-account", + model: "agent-model", + workspacePath: "/repo", + updatedAt: "2026-09-08T13:00:00Z", + }); + mocks.cliStatus.mockResolvedValue({ + cliAgentType: "claude_code", + accountId: "anthropic-original", + model: "claude-opus-5-high", + repoPath: "/repo", + updatedAt: "2026-09-08T14:00:00Z", + }); + const targets = await loadLocalConversationExecutionTargets(sharedRoot); + expect(targets.map((entry) => entry.sessionId)).toEqual([ + "cliagent-before-share", + sharedRoot.conversationId, + ]); + expect(targets[0]?.target).toMatchObject({ + cliAgentType: "claude_code", + accountId: "anthropic-original", + }); + }); + + it("does not invent the remote owner's account on a receiving device", async () => { + mocks.invokeTauri.mockResolvedValue([]); + mocks.cliStatus.mockResolvedValue(null); + await expect( + loadLocalConversationExecutionTargets({ + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "cliagent-remote-root", + }) + ).resolves.toEqual([]); + expect(mocks.invokeTauri).toHaveBeenCalledTimes(1); + }); + it("restores the newest hidden continuation child without an in-memory roster", async () => { const localRoot = { authority: "local-session", diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.ts b/src/engines/SessionCore/conversations/localConversationContinuation.ts index 2211c69036..00f8c80fb3 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -38,7 +38,10 @@ import { import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; import { invokeTauri } from "@src/util/platform/tauri/init"; -import { isCliSession } from "@src/util/session/sessionDispatch"; +import { + isAgentSession, + isCliSession, +} from "@src/util/session/sessionDispatch"; import type { ConversationRootLocator, @@ -324,11 +327,19 @@ async function listExecutionCandidates( const children = await listExecutionChildren( conversationExecutionParentId(locator) ); - if (locator.authority !== "local-session") return children; + const canOwnLocalExecution = + locator.authority === "local-session" || + (locator.authority === "org2-cloud" && + (isCliSession(locator.conversationId) || + isAgentSession(locator.conversationId))); + if (!canOwnLocalExecution) return children; // The ordinary source Session is already a fully native execution episode. // Include it next to provider-switch children so returning to the source // provider reuses its native UUID instead of creating a duplicate copy. + // Sharing changes the conversation authority, not the owner's execution + // identity. On a receiving device this source has no local execution row; + // only its own children can contribute account identities there. let root: ExecutionRow | null; try { root = await readExecutionRow(locator.conversationId); @@ -340,13 +351,34 @@ async function listExecutionCandidates( ); } if (!root?.updatedAt) return children; - return [ - { - sessionId: locator.conversationId, - updatedAt: root.updatedAt, - }, + // Sharing can promote the locator after an owner already switched runtime. + // Those durable children still belong to its local root. Consult that + // namespace only after the owner's actual execution row was found above; + // a receiving device must never infer the remote owner's account history. + const beforeSharingChildren = + locator.authority === "org2-cloud" + ? await listExecutionChildren( + conversationExecutionParentId({ + authority: "local-session", + authorityScope: [], + conversationId: locator.conversationId, + }) + ) + : []; + const candidates = new Map(); + for (const candidate of [ + { sessionId: locator.conversationId, updatedAt: root.updatedAt }, ...children, - ].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + ...beforeSharingChildren, + ]) { + const previous = candidates.get(candidate.sessionId); + if (!previous || candidate.updatedAt > previous.updatedAt) { + candidates.set(candidate.sessionId, candidate); + } + } + return [...candidates.values()].sort((left, right) => + right.updatedAt.localeCompare(left.updatedAt) + ); } function sameOptional(left: unknown, right: string | undefined): boolean { diff --git a/src/features/SessionCreator/agentRuntimeConfig.test.ts b/src/features/SessionCreator/agentRuntimeConfig.test.ts index 10241218ef..cdecd2392d 100644 --- a/src/features/SessionCreator/agentRuntimeConfig.test.ts +++ b/src/features/SessionCreator/agentRuntimeConfig.test.ts @@ -44,6 +44,27 @@ const registry = { } as unknown as AgentRegistry; describe("agent runtime selection coordinator", () => { + it("requires a new choice when an explicit Claude account is unavailable", () => { + expect( + resolveAgentRuntimeSelection({ + selection: { category: "cli_agent", cliAgentType: "claude_code" }, + candidates: [ + { + keySource: "own_key", + cliAgentType: "claude_code", + selectedAccountId: "anthropic-1", + model: "opus", + }, + ], + accounts: [], + registry, + allowedCliAgentTypes: ["claude_code", "codex"], + allowHosted: false, + allowAmbientClaude: true, + }) + ).toEqual({ status: "needs_model_picker" }); + }); + it("requires an explicit Codex pair instead of choosing the first account", () => { const resolution = resolveAgentRuntimeSelection({ selection: { category: "cli_agent", cliAgentType: "codex" }, diff --git a/src/features/SessionCreator/agentRuntimeConfig.ts b/src/features/SessionCreator/agentRuntimeConfig.ts index 8e3e2b15f0..17320dbc3e 100644 --- a/src/features/SessionCreator/agentRuntimeConfig.ts +++ b/src/features/SessionCreator/agentRuntimeConfig.ts @@ -243,6 +243,17 @@ export function resolveAgentRuntimeSelection({ (candidate) => !isHostedKey(candidate.keySource) && !candidate.selectedAccountId ); + const hasExplicitClaudeAccount = candidates.some( + (candidate) => + !isHostedKey(candidate.keySource) && + Boolean(cleanValue(candidate.selectedAccountId)) && + (!candidate.cliAgentType || candidate.cliAgentType === "claude_code") + ); + // An unavailable saved account is not consent to use the CLI's ambient + // credentials. Preserve the choice boundary rather than silently rebinding. + if (hasExplicitClaudeAccount && !ambientCandidate) { + return { status: "needs_model_picker" }; + } const ambientModel = cleanValue(ambientCandidate?.model); return { status: "ready", From 8e704eabc08eaaca6859a075acef7f95a0e21072 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:16:11 -0700 Subject: [PATCH 3/7] fix(conversations): preserve stable replay and managed publication ownership --- .../sources/claude_code/history/cache_sync.rs | 9 +-- .../src/sources/codex/app/index.rs | 13 ++- .../sources/imported_history/cache_tests.rs | 32 ++++++++ .../imported_history/managed_mirror.rs | 47 +++++++++++ .../sync/nativeTranscriptReconcile.ts | 10 ++- .../cloudConversationQueueAdapter.test.ts | 47 +++++++++++ .../cloudConversationQueueAdapter.ts | 16 +++- .../org2CloudSessionSync.continuation.test.ts | 81 +++++++++++++++++-- .../org2CloudSessionSync.pushEvents.ts | 8 ++ .../Org2Cloud/org2CloudSessionSync.state.ts | 5 +- 10 files changed, 241 insertions(+), 27 deletions(-) diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs index 9e9f8d94d6..02ee07cd44 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs @@ -53,7 +53,7 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { SOURCE_CLAUDE_CODE, )?; for record in &mut discovered { - managed_mirror::append_managed_fingerprint( + managed_mirror::append_managed_origin_fingerprint( &mut record.source_fingerprint, managed_ids.contains(&record.source_session_id), ); @@ -98,12 +98,7 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); - let is_managed_history_mirror = managed_mirror::is_managed_history_mirror( - &managed_ids, - &input.source_session_id, - input.client_origin, - ); - input.listable = input.listable && !is_managed_history_mirror; + managed_mirror::apply_managed_history_mirror(&mut input, &managed_ids); inputs.push(input); } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs index b376a5a0a9..adce4803fe 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs @@ -318,7 +318,7 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { SOURCE_CODEX_APP, )?; for record in &mut discovered { - crate::sources::imported_history::managed_mirror::append_managed_fingerprint( + crate::sources::imported_history::managed_mirror::append_managed_origin_fingerprint( &mut record.source_fingerprint, // Suffix match: the imported key is the rollout stem while the // runner binds the bare thread uuid. @@ -371,13 +371,10 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); - let is_managed_history_mirror = - crate::sources::imported_history::managed_mirror::is_managed_history_mirror( - &managed_ids, - &input.source_session_id, - input.client_origin, - ); - input.listable = input.listable && !is_managed_history_mirror; + crate::sources::imported_history::managed_mirror::apply_managed_history_mirror( + &mut input, + &managed_ids, + ); inputs.push(input); } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 912052058a..1d2387c752 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -1101,3 +1101,35 @@ fn a_source_wide_prune_does_not_erase_pins() { "a prune of the rebuildable projection must not take user pin state with it" ); } + +#[test] +fn managed_native_origin_survives_exact_id_cache_hydration() { + use crate::sources::imported_history::client_origin::ImportedClientOrigin; + use crate::sources::imported_history::managed_mirror::apply_managed_history_mirror; + use std::collections::HashSet; + + let mut conn = fixture_conn(); + let mut managed = input(SOURCE_CODEX_APP, "rollout-date-native-uuid", 300); + managed.client_origin = Some(ImportedClientOrigin::OfficialApp); + managed.client_origin_raw = Some("Codex Desktop".to_string()); + let mut ordinary = input(SOURCE_CODEX_APP, "ordinary-native-app", 200); + ordinary.client_origin = Some(ImportedClientOrigin::OfficialApp); + let ids = HashSet::from(["native-uuid".to_string()]); + apply_managed_history_mirror(&mut managed, &ids); + apply_managed_history_mirror(&mut ordinary, &ids); + let managed_id = managed.session_id.clone(); + let ordinary_id = ordinary.session_id.clone(); + upsert_imported_session_cache_from_conn(&mut conn, &[managed, ordinary]).expect("persist"); + let (_, hydrated) = query_cached_session_by_session_id_from_conn(&conn, &managed_id) + .expect("exact id read").expect("mirror remains readable"); + assert_eq!(hydrated.client_origin, Some(ImportedClientOrigin::Org2)); + let raw: String = conn.query_row( + "SELECT client_origin_raw FROM imported_history_session_cache WHERE session_id = ?1", + [&managed_id], |row| row.get(0), + ).expect("retain native header provenance"); + assert_eq!(raw, "Codex Desktop"); + let page = query_imported_session_page_from_conn(&conn, SOURCE_CODEX_APP, 10, 0).expect("list"); + assert_eq!(page.sessions.len(), 1); + assert_eq!(page.sessions[0].session_id, ordinary_id); + assert_eq!(page.sessions[0].client_origin, Some(ImportedClientOrigin::OfficialApp)); +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs index c5a17fa643..2e2be344fb 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs @@ -18,6 +18,7 @@ use std::collections::HashSet; use rusqlite::Connection; use super::client_origin::ImportedClientOrigin; +use super::metadata::ImportedHistoryCacheInput; fn table_exists(conn: &Connection, name: &str) -> bool { conn.query_row( @@ -123,6 +124,35 @@ pub fn is_managed_history_mirror( || client_origin == Some(ImportedClientOrigin::Org2) } +/// Apply durable managed ownership before persisting an imported projection. +pub fn apply_managed_history_mirror( + input: &mut ImportedHistoryCacheInput, + managed_ids: &HashSet, +) { + let managed = is_managed_history_mirror( + managed_ids, + &input.source_session_id, + input.client_origin, + ); + if managed { + input.listable = false; + // Provider apps can rewrite their header after a managed launch. The + // durable binding still owns the conversation. Preserve the raw header + // for diagnostics, but exact-ID hydration must carry the same ownership + // as the primary-list decision or it becomes independently publishable. + input.client_origin = Some(ImportedClientOrigin::Org2); + } +} + +/// Invalidate only ledger-managed cached rows from before origin propagation. +/// Ordinary native histories keep their existing fingerprint and parse budget. +pub fn append_managed_origin_fingerprint(fingerprint: &mut String, is_managed: bool) { + append_managed_fingerprint(fingerprint, is_managed); + if is_managed { + fingerprint.push_str("|managed-origin=org2-v1"); + } +} + /// Repair already-cached ORGII mirrors without requiring their native file to /// change and trigger a reparse. New parses are hidden by /// [`is_managed_history_mirror`]; this closes the same invariant for cache rows @@ -241,6 +271,23 @@ mod tests { ); } + #[test] + fn origin_upgrade_invalidates_only_managed_cache_fingerprints() { + let mut old_managed = "native-file-unchanged".to_string(); + append_managed_fingerprint(&mut old_managed, true); + let mut upgraded = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut upgraded, true); + assert_ne!(old_managed, upgraded); + let mut next_scan = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut next_scan, true); + assert_eq!(upgraded, next_scan); + let mut ordinary_old = "native-file-unchanged".to_string(); + append_managed_fingerprint(&mut ordinary_old, false); + let mut ordinary_new = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut ordinary_new, false); + assert_eq!(ordinary_old, ordinary_new); + } + #[test] fn unions_current_binding_and_ledger() { let conn = Connection::open_in_memory().expect("open"); diff --git a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts index 94d235fa8d..74dc31cd01 100644 --- a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts +++ b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts @@ -23,11 +23,13 @@ import { closeObservedCliTerminalEvents, isCliTerminalStatus, } from "@src/engines/SessionCore/sync/adapters/cli/cliLifecycle"; +import { createLogger } from "@src/hooks/logger"; import { loadAuthoritativeSessionEvents } from "./authoritativeSessionEvents"; import { mergeFailedUserDeliveryProjection } from "./sessionSyncUtils"; const MISMATCH_RECOVERY_DELAYS_MS = [250, 750] as const; +const log = createLogger("NativeTranscriptReconcile"); async function hasDurableNativeTranscript(sessionId: string): Promise { const session = await rpc.cli.status({ sessionId }); @@ -267,9 +269,15 @@ export function scheduleNativeTranscriptReconcile( .then((isNative) => isNative ? reconcileNativeTranscript(sessionId, options) : undefined ) - .catch(() => { + .catch((error: unknown) => { // The ephemeral projection stays visible and a later open/recovery can // retry from the provider transcript. Scheduling must never throw into a // status event handler. + log.rateLimited( + `native-reconcile-${sessionId}`, + 60_000, + `Native transcript reconciliation deferred for ${sessionId}`, + error + ); }); } diff --git a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts index d067937716..529d1c6111 100644 --- a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts +++ b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ runConversationTurn: vi.fn(), listComments: vi.fn(), loadCanonical: vi.fn(), + loadLocalTimeline: vi.fn(), importRemote: vi.fn(), buildFetchClient: vi.fn(), cloudDeviceIdentity: vi.fn(), @@ -40,6 +41,13 @@ vi.mock( () => ({ loadCanonicalConversationEvents: mocks.loadCanonical }) ); +vi.mock( + "@src/engines/SessionCore/conversations/localConversationExecutionTail", + () => ({ + loadLocalCanonicalConversationTimeline: mocks.loadLocalTimeline, + }) +); + vi.mock("@src/features/Org2Cloud/org2CloudCommentsClient", () => ({ listSessionComments: mocks.listComments, })); @@ -287,6 +295,45 @@ const ASSISTANT_TAIL_EVENT = { } as const; describe("dispatchQueuedCloudConversation coordination", () => { + it("loads the owner's verified child history before overlaying Cloud turns", async () => { + const store = readyStore(); + store.set(sessionsAtom, [ + { + session_id: "shared-root", + name: "Root", + status: "completed", + created_at: "2026-08-20T09:00:00Z", + updated_at: "2026-08-20T09:00:00Z", + agentDefinitionId: "builtin:sde", + }, + ]); + const nativeOnlyReply = { + ...ASSISTANT_TAIL_EVENT, + id: "native-before-first-plane-turn", + chunk_id: "native-before-first-plane-turn", + displayText: "No response requested.", + }; + mocks.loadLocalTimeline.mockResolvedValueOnce([nativeOnlyReply]); + mocks.runConversationTurn.mockResolvedValueOnce({ + runnerSessionId: "runner", + terminalStatus: "completed", + }); + await dispatchQueuedCloudConversation( + store, + { ...MESSAGE, sessionId: "shared-root" }, + ROOT, + { onAccepted: vi.fn() } + ); + expect(mocks.loadLocalTimeline).toHaveBeenCalledWith({ + authority: "local-session", + authorityScope: [], + conversationId: "shared-root", + }); + expect(mocks.runConversationTurn.mock.calls[0]?.[0].timeline).toEqual( + expect.arrayContaining([nativeOnlyReply]) + ); + }); + it("refreshes the execution timeline after acquiring the Cloud FIFO head", async () => { enableTurnCoordination(); mocks.runConversationTurn.mockResolvedValueOnce({ diff --git a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts index 351df8ab7f..bd73bef493 100644 --- a/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts +++ b/src/features/Org2Cloud/SessionConversation/cloudConversationQueueAdapter.ts @@ -7,6 +7,7 @@ import { conversationTurnIdOf, localConversationRootForSession, } from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { loadLocalCanonicalConversationTimeline } from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; import type { QueuedConversationDispatchCallbacks, QueuedConversationExecutionMessage, @@ -475,7 +476,20 @@ export async function dispatchQueuedCloudConversation( localSessionId = imported?.localSessionId; } if (!localSessionId) return null; - return (await loadCanonicalConversationEvents(localSessionId)).events; + // The owner may already have native execution children from before + // sharing. The plane contains new turns, not every provider-native row + // in those children; reading only the root would drop that history and + // make its existing native UUID fail prefix verification on continuation. + const localRoot = !local?.importedFrom + ? localConversationRootForSession( + localSessionId, + local?.cliAgentType, + local?.agentDefinitionId + ) + : null; + return localRoot + ? loadLocalCanonicalConversationTimeline(localRoot) + : (await loadCanonicalConversationEvents(localSessionId)).events; }; try { return { diff --git a/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts b/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts index 0e728b37ff..39289c927c 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.continuation.test.ts @@ -1,5 +1,5 @@ import { createStore } from "jotai"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; @@ -96,6 +96,7 @@ function client() { } describe("Org2CloudSessionSync local continuation replay", () => { + afterEach(() => vi.restoreAllMocks()); beforeEach(() => { vi.clearAllMocks(); mocks.childRevision.mockResolvedValue("[]"); @@ -284,21 +285,87 @@ describe("Org2CloudSessionSync local continuation replay", () => { ).toBeGreaterThan(1); }); - it("never marks a canonical snapshot clean when a native child revision is unstable", async () => { + it("revalidates a persisted continuation cursor across two cold engines without rewriting", async () => { + const store = createStore(); + const cloud = client(); + const combined = [event("root", "root"), event("child", "child")]; + mocks.childRevision.mockResolvedValue("stable-1"); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined, + childRevision: "stable-1", + }); + await pushPass(new Org2CloudSessionSync(() => store, cloud)); + const cursorBefore = store.get(org2CloudPushCursorsAtom)[ + `org-1:${SESSION.session_id}` + ]; + cloud.rewriteSessionEvents.mockClear(); + cloud.appendSessionEvents.mockClear(); + mocks.canonicalSnapshot.mockClear(); + + for (let boot = 0; boot < 2; boot += 1) { + const sync = new Org2CloudSessionSync(() => store, cloud); + for (let pass = 0; pass < 3; pass += 1) await pushPass(sync); + } + // Each cold owner really reads the snapshot once; clean later passes are + // bounded. Zero mutations are paired with this positive liveness proof. + expect(mocks.canonicalSnapshot).toHaveBeenCalledTimes(2); + expect(cloud.rewriteSessionEvents).not.toHaveBeenCalled(); + expect(cloud.appendSessionEvents).not.toHaveBeenCalled(); + expect( + store.get(org2CloudPushCursorsAtom)[`org-1:${SESSION.session_id}`] + ).toEqual(cursorBefore); + }); + + it("refuses unstable child snapshots before any cloud mutation and recovers after stabilization", async () => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); const store = createStore(); const cloud = client(); const sync = new Org2CloudSessionSync(() => store, cloud); const combined = [event("root", "root"), event("child", "child")]; - mocks.childRevision.mockResolvedValue(null); + mocks.childRevision.mockResolvedValue("stable-1"); mocks.canonicalSnapshot.mockResolvedValue({ events: combined, + childRevision: "stable-1", + }); + await pushPass(sync); + const cursorBefore = store.get(org2CloudPushCursorsAtom)[ + `org-1:${SESSION.session_id}` + ]; + cloud.rewriteSessionEvents.mockClear(); + cloud.appendSessionEvents.mockClear(); + + // A provider transcript is replaced while it is read. Repeated partial + // reads are still not authoritative truncation, even with a nonzero count. + mocks.childRevision.mockResolvedValue(null); + mocks.canonicalSnapshot.mockResolvedValue({ + events: combined.slice(0, 1), childRevision: null, }); + for (let pass = 0; pass < 3; pass += 1) { + now += 600_000; + await expect(pushPass(sync)).rejects.toThrow( + "changed while preparing cloud replay" + ); + } + expect(cloud.rewriteSessionEvents).not.toHaveBeenCalled(); + expect(cloud.appendSessionEvents).not.toHaveBeenCalled(); + expect( + store.get(org2CloudPushCursorsAtom)[`org-1:${SESSION.session_id}`] + ).toEqual(cursorBefore); + mocks.childRevision.mockResolvedValue("stable-2"); + now += 600_000; + mocks.canonicalSnapshot.mockResolvedValue({ + events: [...combined, event("next", "next")], + childRevision: "stable-2", + }); await pushPass(sync); - await pushPass(sync); - - expect(mocks.canonicalSnapshot).toHaveBeenCalledTimes(2); - expect(mocks.persistedEvents).not.toHaveBeenCalled(); + expect(cloud.rewriteSessionEvents).not.toHaveBeenCalled(); + expect(cloud.appendSessionEvents).toHaveBeenCalledTimes(1); + expect( + store.get(org2CloudPushCursorsAtom)[`org-1:${SESSION.session_id}`] + ?.pushedCount + ).toBe(3); }); }); diff --git a/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts b/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts index f2f5de42f3..0fc8365535 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.pushEvents.ts @@ -151,6 +151,14 @@ export class Org2CloudSessionSyncPushEvents extends Org2CloudSessionSyncState { const root = this.localConversationRoot(sessionId); if (root) { const snapshot = await loadLocalCanonicalConversationSnapshot(root); + if (snapshot.childRevision === null) { + // A repeated partial read is not evidence of an intentional shrink. + // Refuse it before the planner can replace any cloud segments; the + // sync engine's existing retry gate handles the transient failure. + throw new Error( + `Native conversation ${sessionId} changed while preparing cloud replay` + ); + } return { events: snapshot.events, localExecutionRevision: snapshot.childRevision, diff --git a/src/features/Org2Cloud/org2CloudSessionSync.state.ts b/src/features/Org2Cloud/org2CloudSessionSync.state.ts index 3feebdb379..813f19b4a5 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.state.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.state.ts @@ -222,9 +222,8 @@ export class Org2CloudSessionSyncState { ): void { const sessionId = session.session_id; if ((this.eventActivityStamps.get(sessionId) ?? 0) !== stampAtRead) return; - // A child was created or updated while the canonical replay was being - // read. The upload is still safe, but it is not a clean-plane proof; the - // next activity pass must take another authoritative snapshot. + // Defense in depth: the push loader rejects unstable child snapshots + // before upload. Other callers must not stamp an unstable read clean. if (localExecutionRevision === null) return; let byOrg = this.cleanEventPlanes.get(sessionId); if (!byOrg) { From c549dea35e813afab495320d29d14b74ffec3857 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:16:11 -0700 Subject: [PATCH 4/7] fix(conversations): keep manual compaction in its owning runtime --- .../src/state/commands/session/compaction.rs | 29 +++++ .../components/ContextInfoButton.tsx | 15 ++- .../ChatPanel/hooks/useManualCompact.test.ts | 102 ++++++++++++++++++ .../ChatPanel/hooks/useManualCompact.ts | 28 ++++- 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/engines/ChatPanel/hooks/useManualCompact.test.ts diff --git a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs index df6af7c8e3..987dbe1de4 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs @@ -91,6 +91,12 @@ pub async fn prepare_session_for_scheduler_maintenance( state: &AgentAppState, session_id: &str, ) -> Result, String> { + // CLI transcripts belong to their provider. They must never initialize + // an unrelated Agent runtime merely because the shared composer invoked + // this maintenance entry point with a CLI conversation root. + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + return Err("Native CLI compaction is owned by the provider runtime".to_string()); + } let needs_init = match state.get_session(session_id).await { Some(session) => session.get_runtime().await.is_none(), None => true, @@ -579,3 +585,26 @@ fn already_compact_result( boundary: None, } } + +#[cfg(test)] +mod runtime_ownership_tests { + use super::*; + + #[tokio::test] + async fn cli_compaction_cannot_initialize_an_agent_runtime() { + let _sandbox = test_helpers::test_env::sandbox(); + let conn = database::db::get_connection().expect("sandbox database"); + crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); + unified_persistence::init(&conn).expect("session schema"); + let state = AgentAppState::new(); + let session_id = "cliagent-manual-compact-owner"; + let result = prepare_session_for_scheduler_maintenance(&state, session_id).await; + assert!( + matches!(result, Err(ref error) if error.contains("owned by the provider runtime")) + ); + assert!(state.get_session(session_id).await.is_none()); + assert!(unified_persistence::get_session(session_id) + .unwrap() + .is_none()); + } +} diff --git a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx index 8ca0f3d492..7c1beb8f16 100644 --- a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx +++ b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx @@ -20,8 +20,10 @@ import { PILL_CONTROL_HOVER_CLASS, } from "@src/components/CompoundPill/config"; import Textarea from "@src/components/Textarea"; +import { useConversationExecutionBinding } from "@src/engines/ChatPanel/ConversationExecutionBindingContext"; import { manualCompactInFlightSessionAtom, + resolveManualCompactSessionId, useManualCompact, } from "@src/engines/ChatPanel/hooks/useManualCompact"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; @@ -162,6 +164,7 @@ const ContextInfoButton: React.FC = memo( ({ variant = "toolbar", compact = false }) => { const { t } = useTranslation(); const { sessionId } = useSessionId(); + const executionBinding = useConversationExecutionBinding(); const [housekeeperEnabled] = useSetting("housekeeper.enabled"); const [contextCompactEnabled] = useSetting( "housekeeper.features.contextCompact" @@ -305,7 +308,9 @@ const ContextInfoButton: React.FC = memo( [runManualCompact] ); - const compactDisabled = manualCompacting; + const manualCompactSupported = + resolveManualCompactSessionId(sessionId, executionBinding) !== null; + const compactDisabled = manualCompacting || !manualCompactSupported; const triggerSurfaceClass = panelPos !== null ? PILL_CONTROL_ACTIVE_SURFACE_CLASS @@ -463,6 +468,14 @@ const ContextInfoButton: React.FC = memo( {manualCompactOpen && (
+ {!manualCompactSupported && ( +

+ {t("contextInfo.manualCompactNativeProvider", { + defaultValue: + "Manual compaction here supports built-in Agent sessions. Compact native CLI history in its provider app.", + })} +

+ )}