From 55998f916d3179b40e63f5d78ee2756ee209bb33 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:31:15 +0800 Subject: [PATCH 1/5] feat(conversations): add canonical event plane and durable continuation Define canonical conversation roots, typed events, turn intents, local continuation bindings, Cloud-plane synchronization, incremental timelines, durable publication outbox, and provider-independent sender metadata. --- .../agent-cli/src/session_provenance/mod.rs | 56 +- .../agent-cli/src/session_provenance/tests.rs | 67 + .../src/core/session/persistence/messages.rs | 500 ++++- .../src/core/session/persistence/mod.rs | 4 +- .../session-persistence/src/turn_index.rs | 34 +- .../event_pipeline/commands/batch_update.rs | 22 +- .../agent_sessions/event_pipeline/derived.rs | 18 +- .../event_pipeline/ingestion/normalizer.rs | 45 +- .../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 +- .../agent_sessions/session_directory/patch.rs | 17 +- src-tauri/src/agent_sessions/turn_intents.rs | 99 + src/api/tauri/rpc/procedures/sessionCore.ts | 12 + .../__tests__/agentSessionMessages.test.ts | 41 + src/api/tauri/rpc/schemas/sessionCore.ts | 27 + .../root/services/GlobalSessionSync/index.tsx | 3 +- .../__tests__/sessionTimelineBoundary.test.ts | 22 + .../control/__tests__/turnLifecycle.test.ts | 28 + .../control/sessionTimelineBoundary.ts | 37 +- .../SessionCore/control/turnLifecycle.ts | 31 +- .../canonicalConversationEvents.ts | 44 + .../conversationSenderMetadata.test.ts | 94 + .../conversationSenderMetadata.ts | 102 + .../conversations/conversationTypes.test.ts | 87 + .../conversations/conversationTypes.ts | 116 + .../localConversationContinuation.test.ts | 1934 +++++++++++++++++ .../localConversationContinuation.ts | 1539 +++++++++++++ .../queuedConversationExecutor.ts | 68 + .../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 | 167 +- .../queueDispatchSyncInputsAtom.test.ts | 7 +- .../sessionScopedChatEvents.stability.test.ts | 42 + src/engines/SessionCore/derived/chatEvents.ts | 145 +- .../derived/queueDispatchSyncInputsAtom.ts | 3 - .../derived/sessionScopedChatEvents.ts | 24 +- .../__tests__/usePlanningIndicator.test.ts | 21 + .../hooks/replay/usePlanningIndicator.ts | 102 +- .../session/__tests__/launchPayload.test.ts | 21 + .../useSessionLaunch/index.tsx | 1 + .../useSessionLaunch/launchPayload.ts | 5 + .../hooks/session/useSessionDiscovery.ts | 18 +- .../ingestion/visibilityFilters.ts | 25 +- .../SessionCore/services/SessionService.ts | 40 +- src/engines/SessionCore/services/types.ts | 8 + .../authoritativeSessionEvents.test.ts | 123 ++ .../nativeTranscriptReconcile.test.ts | 21 + .../sessionSwitchOrchestrator.test.ts | 6 + .../__tests__/sessionSyncReconcile.test.ts | 28 +- ...SyncStateHelpers.sessionListStatus.test.ts | 26 +- .../__tests__/sessionSyncStateHelpers.test.ts | 28 + .../externalHistoryAdapter.loading.test.ts | 33 + .../__tests__/createCliEventHandler.test.ts | 54 + .../sync/adapters/cli/cliLifecycle.ts | 29 +- .../sync/adapters/cli/cliTransport.ts | 4 + .../adapters/cli/createCliEventHandler.ts | 12 +- .../sync/adapters/externalHistoryAdapter.ts | 25 + .../sync/adapters/shared/eventFactories.ts | 25 +- .../sync/authoritativeSessionEvents.ts | 78 + .../sync/nativeTranscriptReconcile.ts | 52 +- .../sync/sessionSwitchOrchestrator.ts | 28 +- .../SessionCore/sync/sessionSyncReconcile.ts | 30 +- .../sync/sessionSyncStateHelpers.ts | 83 +- src/engines/SessionCore/sync/types.ts | 15 + .../SessionCore/sync/useSessionSync.ts | 32 +- .../enqueueCanonicalConversation.ts | 88 + .../externalHistoryContinuation.test.ts | 162 ++ .../externalHistoryContinuation.ts | 97 + .../queuedConversationExecutor.test.ts | 140 ++ .../queuedConversationExecutor.ts | 212 ++ .../CloudSessionDownloadProgressCard.test.ts | 11 + .../SessionCommentsContext.test.ts | 291 ++- .../SessionCommentsContext.tsx | 237 +- ...ConversationSenderMetadataProvider.test.ts | 113 + ...Org2ConversationSenderMetadataProvider.tsx | 258 +++ .../activeConversationRunnersAtom.test.ts | 137 +- .../activeConversationRunnersAtom.ts | 87 +- .../continuationEvents.test.ts | 2 +- .../SessionConversation/continuationEvents.ts | 92 +- .../conversationOwnerPublisher.test.ts | 93 - .../conversationOwnerPublisher.ts | 237 -- .../conversationPlaneAtom.ts | 530 ++++- .../conversationPlaneEvents.ts | 13 +- .../conversationRunnerScope.tsx | 13 +- .../conversationTailOutbox.ts | 279 +++ .../conversationTimeline.test.ts | 175 +- .../conversationTimeline.ts | 78 +- .../conversationTurnRunner.test.ts | 203 ++ .../conversationTurnRunner.ts | 528 +++-- .../discussionEvents.test.ts | 37 +- .../SessionConversation/discussionEvents.ts | 50 +- .../queuedConversationExecutor.ts | 268 +++ .../teamChatMentions.test.ts | 85 + .../SessionConversation/teamChatMentions.ts | 158 +- .../useCloudConversationSource.test.ts | 43 + .../useCloudConversationSource.ts | 174 ++ .../useConversationComposer.ts | 49 +- .../useConversationSetupPillBinding.ts | 111 - .../useEnsureFamilyLoaded.ts | 88 +- .../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 | 4 + .../Org2Cloud/org2CloudCapabilities.test.ts | 18 + .../Org2Cloud/org2CloudCapabilities.ts | 6 + .../Org2Cloud/org2CloudCommentsClient.test.ts | 67 +- .../Org2Cloud/org2CloudCommentsClient.ts | 110 +- .../org2CloudConversationEventsClient.test.ts | 46 + .../org2CloudConversationEventsClient.ts | 201 +- .../Org2Cloud/org2CloudSessionCommentsAtom.ts | 96 +- .../org2CloudSessionCommentsAtom.types.ts | 16 +- .../Org2Cloud/org2CloudSyncClient.test.ts | 3 + .../Org2Cloud/sessionCommentTarget.test.ts | 23 + .../Org2Cloud/sessionCommentTarget.ts | 40 +- .../Org2Cloud/useCloudSessionActions.ts | 16 + .../useCloudSessionDownloadSurface.test.ts | 157 ++ .../useCloudSessionDownloadSurface.ts | 59 +- .../Org2Cloud/useOrg2CloudRealtime.test.ts | 19 + .../Org2Cloud/useOrg2CloudRealtime.ts | 3 + .../engine/collabImportIdentity.test.ts | 68 + .../engine/collabImportIdentity.ts | 43 +- 133 files changed, 11857 insertions(+), 1478 deletions(-) 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/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/queuedConversationExecutor.ts create mode 100644 src/engines/SessionCore/sync/__tests__/authoritativeSessionEvents.test.ts create mode 100644 src/engines/SessionCore/sync/authoritativeSessionEvents.ts create mode 100644 src/features/ConversationContinuation/enqueueCanonicalConversation.ts create mode 100644 src/features/ConversationContinuation/externalHistoryContinuation.test.ts create mode 100644 src/features/ConversationContinuation/externalHistoryContinuation.ts create mode 100644 src/features/ConversationContinuation/queuedConversationExecutor.test.ts create mode 100644 src/features/ConversationContinuation/queuedConversationExecutor.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/conversationOwnerPublisher.test.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.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 delete mode 100644 src/features/Org2Cloud/SessionConversation/usePinnedSession.ts create mode 100644 src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts create mode 100644 src/features/Org2Cloud/useCloudSessionDownloadSurface.test.ts create mode 100644 src/features/TeamCollaboration/engine/collabImportIdentity.test.ts diff --git a/src-tauri/crates/agent-cli/src/session_provenance/mod.rs b/src-tauri/crates/agent-cli/src/session_provenance/mod.rs index aeeacbf212..10d3faac88 100644 --- a/src-tauri/crates/agent-cli/src/session_provenance/mod.rs +++ b/src-tauri/crates/agent-cli/src/session_provenance/mod.rs @@ -260,10 +260,20 @@ fn update_platform( _ => {} } let path = platform.config_path(); + update_json_platform_at_path(platform, &path, enabled, live_status, executable) +} + +fn update_json_platform_at_path( + platform: SessionProvenanceHookPlatform, + path: &Path, + enabled: bool, + live_status: bool, + executable: &Path, +) -> Result<(), String> { if !enabled && !path.exists() { return Ok(()); } - let mut config = read_config(&path)?; + let mut config = read_config(path)?; let original_config = config.clone(); let (unix_command, windows_command) = hook_commands(executable, platform.source_arg()); match platform { @@ -358,7 +368,49 @@ fn update_platform( if config == original_config { Ok(()) } else { - write_config(&path, &config) + write_config(path, &config) + } +} + +/// Materialize only ORGII-managed provenance hooks inside an isolated CLI +/// profile. Managed Cursor/Claude/Codex sessions override their normal config +/// roots; without this projection the globally enabled hooks are invisible to +/// the child. +/// +/// `config_path` is the provider's config JSON. Existing user settings are +/// preserved by the same merge functions used by the global installer. +pub fn materialize_hooks_for_isolated_profile( + platform: SessionProvenanceHookPlatform, + config_path: &Path, +) -> Result<(), String> { + let _guard = operation_guard()?; + let preferences = read_preferences().unwrap_or_else(|err| { + tracing::warn!( + error = %err, + "[SessionProvenance] Unreadable preferences while projecting isolated profile; using defaults" + ); + HookPreferences::default() + }); + let enabled = preferences.effective_enabled(platform); + let executable = std::env::current_exe() + .map_err(|err| format!("Failed to locate ORG2 executable: {err}"))?; + match platform { + SessionProvenanceHookPlatform::ClaudeCode + | SessionProvenanceHookPlatform::Codex + | SessionProvenanceHookPlatform::Cursor + | SessionProvenanceHookPlatform::QwenCode + | SessionProvenanceHookPlatform::FactoryDroid + | SessionProvenanceHookPlatform::Trae + | SessionProvenanceHookPlatform::Windsurf => update_json_platform_at_path( + platform, + config_path, + enabled, + preferences.live_status_enabled, + &executable, + ), + _ => Err(format!( + "{platform:?} isolated-profile projection is not a supported JSON target" + )), } } diff --git a/src-tauri/crates/agent-cli/src/session_provenance/tests.rs b/src-tauri/crates/agent-cli/src/session_provenance/tests.rs index 1ad9d9a8c3..549bf1e20c 100644 --- a/src-tauri/crates/agent-cli/src/session_provenance/tests.rs +++ b/src-tauri/crates/agent-cli/src/session_provenance/tests.rs @@ -883,6 +883,73 @@ fn atomic_write_replaces_an_existing_config() { assert_eq!(std::fs::read(&path).unwrap(), b"new"); } +#[test] +fn isolated_json_profile_gets_managed_hooks_without_losing_user_settings() { + let temp = tempfile::tempdir().expect("temporary profile"); + let path = temp.path().join("settings.json"); + std::fs::write( + &path, + serde_json::to_vec(&json!({ + "theme": "dark", + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": "user-stop-hook"}]}] + } + })) + .expect("user settings"), + ) + .expect("write user settings"); + + update_json_platform_at_path( + SessionProvenanceHookPlatform::ClaudeCode, + &path, + true, + true, + Path::new("/opt/orgii/bin/orgii"), + ) + .expect("project isolated hooks"); + + let projected: Value = + serde_json::from_slice(&std::fs::read(&path).expect("projected settings")) + .expect("projected json"); + assert_eq!(projected["theme"], "dark"); + assert_eq!( + projected["hooks"]["Stop"][0]["hooks"][0]["command"], + "user-stop-hook" + ); + assert!(config_has_complete_managed_hooks( + &projected, + SessionProvenanceHookPlatform::ClaudeCode, + true + )); +} + +#[test] +fn isolated_cursor_and_codex_hook_files_use_their_native_json_shapes() { + for platform in [ + SessionProvenanceHookPlatform::Cursor, + SessionProvenanceHookPlatform::Codex, + ] { + let temp = tempfile::tempdir().expect("temporary profile"); + let path = temp.path().join("hooks.json"); + update_json_platform_at_path( + platform, + &path, + true, + true, + Path::new("/opt/orgii/bin/orgii"), + ) + .expect("project isolated hooks"); + + let projected: Value = + serde_json::from_slice(&std::fs::read(&path).expect("read isolated hooks")) + .expect("isolated hooks json"); + assert!( + config_has_complete_managed_hooks(&projected, platform, true), + "{platform:?} must receive its complete native managed-hook shape" + ); + } +} + #[cfg(unix)] #[test] fn atomic_write_leaves_an_unchanged_config_in_place() { 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..8b9beee275 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 @@ -444,6 +444,7 @@ fn compacted_history_rows( let mut rows = Vec::new(); for msg in compacted_messages { + let first_row = rows.len(); let role = msg .get("role") .and_then(|value| value.as_str()) @@ -452,12 +453,19 @@ fn compacted_history_rows( "system" => { let content = text_content_from_llm_message(msg); if !content.trim().is_empty() { - rows.push(message_row( - session_id, - shared::message_role::SYSTEM, - content, - None, - )); + let mut row = + message_row(session_id, shared::message_role::SYSTEM, content, None); + if msg + .get("__orgiiNativeCompactBoundary") + .and_then(|value| value.as_bool()) + == Some(true) + { + // Sentinel resolved to the first row after this + // boundary by the seed/append transaction, where the + // final durable sequence is known. + row.compact_from_sequence = Some(-1); + } + rows.push(row); } } "user" => { @@ -540,6 +548,22 @@ fn compacted_history_rows( } _ => {} } + for row in &mut rows[first_row..] { + if let Some(id) = msg + .get("__orgiiNativeMessageId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + row.id = id.to_string(); + } + if let Some(created_at) = msg + .get("__orgiiNativeCreatedAt") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + row.created_at = created_at.to_string(); + } + } } rows @@ -570,52 +594,160 @@ 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, ) -> 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) + { + return Err(history_append_constraint(format!( + "append_session_with_messages conflict: native row {} already exists with different content, ownership, or sequence", + expected.id + ))); + } } + 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!( + "append_session_with_messages 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" - )), - )); - } - 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,30 +758,58 @@ 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); - } - - conn.execute_batch("COMMIT")?; - Ok(()) + )?; + tx.commit() }) } +/// 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) +} + +/// Atomically append structured LLM-history rows to an existing session. +/// +/// This is the native counterpart of provider transcript growth: existing +/// rows remain immutable and the supplied role/tool records receive the next +/// contiguous sequence numbers. Callers must verify the semantic prefix +/// before invoking it; this function only owns the durable append boundary. +pub fn append_session_with_messages( + session_id: &str, + messages: &[serde_json::Value], +) -> SqliteResult<()> { + if messages.is_empty() { + return Ok(()); + } + let rows = compacted_history_rows(session_id, messages); + persist_history_rows(session_id, &rows, false) +} + /// Append a compact-boundary row to a session's transcript. /// /// The boundary row is a `system` message whose `compact_from_sequence` @@ -1163,6 +1323,228 @@ 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_messages( + session_id, + &[serde_json::json!({ + "role": "user", + "content": "continue", + "__orgiiNativeMessageId": "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce", + "__orgiiNativeCreatedAt": "2026-08-29T00:00:00Z", + })], + ) + .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 = [serde_json::json!({ + "role": "user", + "content": "continue", + "__orgiiNativeMessageId": "org2-native-v1.c291cmNlLTE.target", + "__orgiiNativeCreatedAt": "2026-08-29T00:00:00Z", + })]; + + seed_session_with_messages(session_id, &transcript).expect("seed native transcript"); + seed_session_with_messages(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 native_materialization_keeps_full_rows_but_resumes_from_latest_compact_boundary() { + let _sandbox = test_env::sandbox(); + let session_id = "seed-native-compact-window-test"; + seed_session_for_message_tests(session_id); + seed_session_with_messages( + session_id, + &[ + serde_json::json!({"role": "user", "content": "old user"}), + serde_json::json!({"role": "assistant", "content": "old answer"}), + serde_json::json!({ + "role": "system", + "content": "[Conversation summary — earlier messages compacted]\n\nsummary", + "__orgiiNativeCompactBoundary": true, + }), + serde_json::json!({"role": "user", "content": "recent user"}), + ], + ) + .expect("seed native compact window"); + + let rows = load_messages(session_id).expect("load immutable native rows"); + assert_eq!(rows.len(), 4, "full transcript remains durable"); + assert_eq!(rows[2].compact_from_sequence, Some(3)); + + let history = load_llm_history(session_id).expect("load native compact window"); + assert_eq!(history.len(), 2); + assert_eq!(history[0]["role"], "user"); + assert_eq!( + history[0]["content"], + "[Conversation summary — earlier messages compacted]\n\nsummary" + ); + assert_eq!(history[1]["content"], "recent user"); + } + + #[test] + fn append_session_with_messages_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_messages( + session_id, + &[serde_json::json!({"role": "user", "content": "first"})], + ) + .expect("seed prefix"); + + append_session_with_messages( + session_id, + &[ + serde_json::json!({"role": "assistant", "content": "answer"}), + serde_json::json!({ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "call-1", + "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"README.md\"}"} + }] + }), + serde_json::json!({ + "role": "tool", + "tool_call_id": "call-1", + "name": "read_file", + "content": "contents" + }), + ], + ) + .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 append_session_with_messages_accepts_a_fully_applied_native_suffix_once() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-idempotent-suffix-test"; + seed_session_for_message_tests(session_id); + let suffix = [serde_json::json!({ + "role": "assistant", + "content": "answer", + "__orgiiNativeMessageId": "org2-native-v1.c291cmNlLTE.target", + "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", + })]; + + append_session_with_messages(session_id, &suffix).expect("append native suffix"); + append_session_with_messages(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 append_session_with_messages_rejects_mixed_or_conflicting_native_suffixes() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-conflicting-suffix-test"; + seed_session_for_message_tests(session_id); + let first = serde_json::json!({ + "role": "user", + "content": "first", + "__orgiiNativeMessageId": "org2-native-v1.Zmlyc3Q.target", + "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", + }); + append_session_with_messages(session_id, std::slice::from_ref(&first)) + .expect("append first native row"); + + let mixed = [ + first.clone(), + serde_json::json!({ + "role": "assistant", + "content": "second", + "__orgiiNativeMessageId": "org2-native-v1.c2Vjb25k.target", + "__orgiiNativeCreatedAt": "2026-08-30T00:00:01Z", + }), + ]; + assert!(append_session_with_messages(session_id, &mixed).is_err()); + + let conflict = [serde_json::json!({ + "role": "user", + "content": "different", + "__orgiiNativeMessageId": "org2-native-v1.Zmlyc3Q.target", + "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", + })]; + assert!(append_session_with_messages(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 append_native_materialization_advances_to_compact_window_without_deleting_prefix() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-compact-window-test"; + seed_session_for_message_tests(session_id); + seed_session_with_messages( + session_id, + &[ + serde_json::json!({"role": "user", "content": "old user"}), + serde_json::json!({"role": "assistant", "content": "old answer"}), + ], + ) + .expect("seed native prefix"); + + append_session_with_messages( + session_id, + &[ + serde_json::json!({ + "role": "system", + "content": "[Conversation summary — earlier messages compacted]\n\nsummary", + "__orgiiNativeCompactBoundary": true, + }), + serde_json::json!({"role": "user", "content": "recent user"}), + ], + ) + .expect("append native compact window"); + + let rows = load_messages(session_id).expect("load immutable native rows"); + assert_eq!(rows.len(), 4, "appending a compact window keeps the prefix"); + assert_eq!(rows[2].compact_from_sequence, Some(3)); + + let history = load_llm_history(session_id).expect("load appended compact window"); + assert_eq!(history.len(), 2); + assert_eq!( + history[0]["content"], + "[Conversation summary — earlier messages compacted]\n\nsummary" + ); + assert_eq!(history[1]["content"], "recent user"); + } + #[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..8b7124e428 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,8 +43,8 @@ pub use sidebar::{ }; pub use messages::{ - anchor_at_or_after_created_at, append_compact_boundary, clear_messages, - clear_session_memory_state, compact_cutoff_sequence, + anchor_at_or_after_created_at, append_compact_boundary, append_session_with_messages, + 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, diff --git a/src-tauri/crates/session-persistence/src/turn_index.rs b/src-tauri/crates/session-persistence/src/turn_index.rs index 51732f734f..7c6cca1a05 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"; @@ -29,7 +30,10 @@ const TURN_STATUS_FAILED: &str = "failed"; /// Orgtrack instead of interpreting ORG2 tool names in this host crate. /// v11: treat the normalized imported-history `user` function as the same /// turn boundary as the native `user_message` function. -const TURN_INDEX_VERSION: i64 = 11; +/// v12: 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 = 12; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -154,7 +158,11 @@ 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) } @@ -248,7 +256,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(); @@ -278,6 +286,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 @@ -1007,6 +1016,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/event_pipeline/commands/batch_update.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs index cccc56d0a7..b342e5554c 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 @@ -61,24 +61,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/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/normalizer.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs index 741c0649b3..182def85f2 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 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") +} + 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") { + 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/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/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/api/tauri/rpc/procedures/sessionCore.ts b/src/api/tauri/rpc/procedures/sessionCore.ts index d52a767f2c..e68c3115c7 100644 --- a/src/api/tauri/rpc/procedures/sessionCore.ts +++ b/src/api/tauri/rpc/procedures/sessionCore.ts @@ -257,8 +257,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/sessionCore.ts b/src/api/tauri/rpc/schemas/sessionCore.ts index c8ec64be3d..526a4a228a 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/app/root/services/GlobalSessionSync/index.tsx b/src/app/root/services/GlobalSessionSync/index.tsx index 7c6bdd3b3c..1f57bb80cb 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/queuedConversationExecutor"; 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/engines/SessionCore/control/__tests__/sessionTimelineBoundary.test.ts b/src/engines/SessionCore/control/__tests__/sessionTimelineBoundary.test.ts index 5906c98050..751def64d0 100644 --- a/src/engines/SessionCore/control/__tests__/sessionTimelineBoundary.test.ts +++ b/src/engines/SessionCore/control/__tests__/sessionTimelineBoundary.test.ts @@ -73,6 +73,28 @@ describe("sessionTimelineBoundary", () => { 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..be767f1d2f 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") { @@ -224,6 +231,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. diff --git a/src/engines/SessionCore/conversations/canonicalConversationEvents.ts b/src/engines/SessionCore/conversations/canonicalConversationEvents.ts new file mode 100644 index 0000000000..54dc2f2fd9 --- /dev/null +++ b/src/engines/SessionCore/conversations/canonicalConversationEvents.ts @@ -0,0 +1,44 @@ +/** + * 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 { + 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 { + const authoritative = await loadAuthoritativeSessionEvents(sessionId, signal); + 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 (status?.status !== "cancelled" && status?.status !== "failed") { + 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..1a64827596 --- /dev/null +++ b/src/engines/SessionCore/conversations/conversationTypes.ts @@ -0,0 +1,116 @@ +/** 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 + ); + } + return ( + target.agentDefinitionId === undefined && + typeof target.cliAgentType === "string" && + NATIVE_CONVERSATION_CLI_TARGETS.includes( + target.cliAgentType as NativeConversationCliTarget + ) && + (target.accountId === undefined || + (typeof target.accountId === "string" && + target.accountId.trim().length > 0)) && + (target.model === undefined || + (typeof target.model === "string" && target.model.trim().length > 0)) + ); +} + +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; + sourceTitle: string; + 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..a8db9519a9 --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -0,0 +1,1934 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CONVERSATION_TURN_ID_ARG, + continueLocalConversation, + continueLocalConversationAfterTimelineLoad, + conversationExecutionParentId, + localConversationRootForSession, + parseConversationExecutionParentId, +} 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(), + getStoredEvents: vi.fn(), + getLatestSnapshot: vi.fn(), + subscribeSession: vi.fn(), + loadEvents: vi.fn(), + materialize: vi.fn(), + synchronize: 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, + getEvents: mocks.getStoredEvents, + getLatestSessionSnapshot: mocks.getLatestSnapshot, + subscribeSession: mocks.subscribeSession, + }, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadEvents, +})); +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), + 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/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 } = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: options.sessionId ?? "root", + 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", +}; + +let childEvents: SessionEvent[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + childEvents = []; + mocks.invokeTauri.mockResolvedValue([]); + mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "native_store", + })); + 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, existingEvents }) => { + childEvents = [ + ...(existingEvents as SessionEvent[]), + ...(timeline as SessionEvent[]) + .slice((existingEvents as SessionEvent[]).length) + .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.getStoredEvents.mockImplementation(async () => childEvents); + mocks.getLatestSnapshot.mockReturnValue(null); + mocks.subscribeSession.mockReturnValue(() => undefined); + mocks.storeGet.mockReturnValue(null); + mocks.sendMessage.mockImplementation( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + 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); +}); + +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( + "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("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(mocks.storeSet).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sessionId: "agentsession-child", + displayText: "new request", + }) + ); + expect(result).toMatchObject({ + sessionId: "agentsession-child", + created: true, + agentTail: [expect.objectContaining({ displayText: "native answer" })], + }); + }); + + 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("rebuilds one replay-safe overflow and reuses it on the next small turn", 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; + }); + let exhaustedStatusReads = 0; + mocks.cliStatus.mockImplementation(async ({ sessionId }) => { + if (sessionId === "cliagent-exhausted") { + exhaustedStatusReads += 1; + return exhaustedStatusReads === 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", + contextExhausted: true, + }; + } + return { + status: "completed", + updatedAt: "2026-08-29T00:02:00.000Z", + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + repoPath: "/repo", + }; + }); + mocks.loadEvents.mockImplementation(async (sessionId) => ({ + events: sessionId === "cliagent-exhausted" ? timeline : childEvents, + source: "native_store", + })); + mocks.create.mockImplementation(async () => { + children.push({ + sessionId: "cliagent-rebuilt", + updatedAt: "2026-08-29T00:02:00.000Z", + }); + return { sessionId: "cliagent-rebuilt" }; + }); + mocks.getTerminal.mockImplementation((sessionId) => ({ + generation: 3, + status: sessionId === "cliagent-exhausted" ? "failed" : "completed", + at: Date.now() + 1_000, + })); + mocks.cliWaitForTurnTerminal.mockImplementation( + async ({ sessionId, turnIntentId }) => ({ + sessionId, + turnIntentId, + status: sessionId === "cliagent-exhausted" ? "failed" : "completed", + updatedAt: "2026-08-29T00:03:00.000Z", + }) + ); + mocks.sendMessage.mockImplementation( + async ({ + sessionId, + displayText, + turnIntentId, + }: { + sessionId: string; + displayText: string; + turnIntentId: string; + }) => { + if (sessionId === "cliagent-exhausted") return; + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "rebuilt answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); + + const result = await 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", + }); + + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "cliagent-rebuilt", + timeline, + }); + expect(mocks.sendMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sessionId: "cliagent-exhausted", + allowNativeContextRecovery: true, + }) + ); + expect(mocks.sendMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + sessionId: "cliagent-rebuilt", + allowNativeContextRecovery: true, + }) + ); + expect(result).toMatchObject({ + sessionId: "cliagent-rebuilt", + created: true, + terminalStatus: "completed", + agentTail: [expect.objectContaining({ displayText: "rebuilt answer" })], + }); + + const nextTimeline = [ + ...timeline, + event("user-turn-context-rollover", "user", "retry me once", { + sessionId: "root", + turnId: "turn-context-rollover", + }), + event("answer-turn-context-rollover", "assistant", "rebuilt answer", { + sessionId: "root", + turnId: "turn-context-rollover", + }), + ]; + const next = await continueLocalConversation({ + root, + title: "Canonical rollover", + timeline: nextTimeline, + displayText: "small follow-up", + target: { + cliAgentType: "codex", + accountId: "codex-account", + model: "codex-model", + workspaceRepoPath: "/repo", + }, + turnIntentId: "turn-after-context-rollover", + }); + + expect(next).toMatchObject({ + sessionId: "cliagent-rebuilt", + created: false, + terminalStatus: "completed", + }); + expect(mocks.create).toHaveBeenCalledTimes(1); + expect(mocks.sendMessage).toHaveBeenCalledTimes(3); + expect(mocks.sendMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: "cliagent-rebuilt", + displayText: "small follow-up", + allowNativeContextRecovery: true, + }) + ); + }); + + it.each([ + { kind: "assistant", contextExhausted: true }, + { kind: "thinking", contextExhausted: true }, + { kind: "tool", contextExhausted: true }, + { kind: "plan", contextExhausted: true }, + // allowNativeContextRecovery is permission, not a trigger: an unrelated + // provider failure never enters the canonical rebuild path. + { kind: "failure", contextExhausted: false }, + ] as const)( + "does not rebuild a failed attempt after $kind output", + async ({ kind, contextExhausted }) => { + 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", + contextExhausted, + }; + }); + 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", + created: false, + 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: "provider-answer-turn-result-anchor", + displayText: "native answer", + }), + ]); + }); + + it("publishes a provider-native suffix when the runtime strips the ORG2 turn id", async () => { + let nativeEvents: SessionEvent[] = []; + 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, displayText }) => { + nativeEvents = [ + ...childEvents, + event("native-user", "user", `runtime bridge\n\n${displayText}`, { + sessionId, + }), + event("native-answer", "assistant", "native suffix 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: "new request", + target, + turnIntentId: "turn-provider-native-suffix", + }); + + expect(result.agentTail).toEqual([ + expect.objectContaining({ + id: "native-answer", + displayText: "native suffix answer", + }), + ]); + expect(mocks.mergeEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + id: "native-user", + displayText: expect.stringContaining("new request"), + result: expect.objectContaining({ + turnIntentId: "turn-provider-native-suffix", + }), + }), + expect.objectContaining({ + id: "native-answer", + displayText: "native suffix answer", + }), + ], + "agentsession-child" + ); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "completed", + { generation: 3 } + ); + expect(mocks.setStreaming).toHaveBeenCalledWith( + false, + "agentsession-child" + ); + expect(mocks.loadEvents).toHaveBeenCalledTimes(1); + }); + + it("closes the current generation when only durable status observes terminal", 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).toHaveBeenCalledWith( + "agentsession-child", + "completed", + { generation: 3 } + ); + expect(mocks.storeSet).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sessionId: "agentsession-child", + status: "completed", + source: "sync", + }) + ); + }); + + 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("uses the resident turn window before reading one final native transcript", 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.getLatestSnapshot).toHaveBeenCalledWith("agentsession-child"); + expect(mocks.getStoredEvents).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).toHaveBeenCalledWith( + "agentsession-child", + "cancelled", + { generation: 3 } + ); + }); + + 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("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", + }); + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "resume natively", + target, + turnIntentId: "turn-2", + }); + + expect(result.created).toBe(false); + 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", + created: false, + }); + 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", + created: false, + }); + 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", + created: false, + }); + expect(mocks.create).not.toHaveBeenCalled(); + }); + + it("creates a new native episode 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")]; + + const result = await continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue", + target, + turnIntentId: "turn-3", + }); + expect(result.created).toBe(true); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline, + }); + }); + + 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")]; + + const result = 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(result.created).toBe(false); + expect(mocks.synchronize).toHaveBeenCalledWith({ + sessionId: "cliagent-existing", + timeline, + existingEvents: [existing], + }); + 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("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 root across 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-28T02: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", + })); + mocks.create.mockImplementation(async () => { + children.push({ + sessionId: "cliagent-codex-child", + updatedAt: "2026-08-28T02:00:00.000Z", + }); + return { sessionId: "cliagent-codex-child" }; + }); + 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, existingEvents }) => { + const synchronized = [ + ...(existingEvents as SessionEvent[]), + ...(timeline as SessionEvent[]) + .slice((existingEvents as SessionEvent[]).length) + .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, + created: false, + }); + + 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", + created: true, + }); + + 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, + created: false, + }); + 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..c3b6c6099f --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -0,0 +1,1539 @@ +/** + * 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 { + beginOptimisticTurn, + failOptimisticTurn, +} from "@src/engines/SessionCore/control/optimisticTurnStatus"; +import { + type TurnTerminalStatus, + beginTurnDispatch, + confirmTurnRunning, + markTurnTerminal, + 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, + clearParkedUserIntentEvent, + confirmUserIntentPreparation, + dispatchUserIntent, + failUserIntentPreparation, + isUserIntentSendError, + prepareUserIntent, +} from "@src/engines/SessionCore/services/userIntentDispatch"; +import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; +import { createLogger } from "@src/hooks/logger"; +import { setSessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import type { + ConversationRootLocator, + LocalConversationTarget, +} from "./conversationTypes"; +import { + materializeNativeConversation, + nativeConversationItemsArePrefix, + projectNativeConversationItems, + supportsNativeConversationTarget, + synchronizeNativeConversation, +} from "./nativeConversationMaterializer"; + +export type { + ConversationRootLocator, + LocalConversationTarget, +} from "./conversationTypes"; + +const TRANSCRIPT_SETTLE_MS = 5_000; +const INTERRUPTED_TRANSCRIPT_SETTLE_MS = 800; +const TRANSCRIPT_SETTLE_INITIAL_POLL_MS = 100; +const TRANSCRIPT_SETTLE_MAX_POLL_MS = 1_000; +const TURN_WAIT_WINDOW_MS = 60_000; +const log = createLogger("localConversationContinuation"); + +async function notifyConversationTurnAccepted( + callback: ContinueLocalConversationParams["onTurnAccepted"], + sessionId: string, + turnIntentId: string +): Promise { + if (!callback) return; + try { + await callback(sessionId); + } catch (error) { + // Provider acceptance is already durable. A local receipt/bookkeeping + // failure must not reclassify the send as rejected or skip waiting for the + // real native tail; recovery can reconcile the same turnIntentId later. + log.error( + `[native-continuation] failed to persist acceptance receipt for ${turnIntentId}`, + 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; + /** Runs after the singleton queue grants this conversation its turn. */ + beforeDispatch?: () => void | Promise; + onSessionReady?: ( + sessionId: string, + /** Authoritative native-event prefix that predates this turn. */ + eventStartIndex: number + ) => 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; + created: boolean; + terminalStatus: TurnTerminalStatus; + agentTail: SessionEvent[]; +} + +interface RecoverLocalConversationParams extends Omit< + ContinueLocalConversationParams, + "beforeDispatch" +> { + runnerSessionId: string; + eventStartIndex?: number; +} + +type ConversationTurnPreparation = UserIntentPreparation; + +interface ChildSessionView { + sessionId: string; + updatedAt: string; +} + +interface ExecutionCandidate { + sessionId: string; + updatedAt: string; +} + +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 { + if (isCliSession(sessionId)) { + if (!cliAgentType) return null; + } else if (!agentDefinitionId) { + return null; + } + return { + authority: "local-session", + authorityScope: [], + conversationId: sessionId, + }; +} + +function eventTurnId(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. + const root = await readExecutionRow(locator.conversationId).catch(() => null); + 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, + _options: { allowFailed?: boolean } = {} +): 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, + }; +} + +async function readExecutionTarget( + sessionId: string +): Promise { + return (await readExecutionRow(sessionId))?.target ?? null; +} + +async function candidateMatchesTarget( + sessionId: string, + target: LocalConversationTarget, + options: { allowFailed?: boolean } = {} +): Promise { + const existing = options.allowFailed + ? ((await readExecutionRow(sessionId, options))?.target ?? null) + : await readExecutionTarget(sessionId); + 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; + updatedAt: string; + events: SessionEvent[]; +} | null> { + const canonicalItems = projectNativeConversationItems(timeline); + const availableCandidates = + knownMatchingCandidates ?? (await listExecutionCandidates(locator)); + 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); + if (nativeConversationItemsArePrefix(executionItems, canonicalItems)) { + return { + sessionId: candidate.sessionId, + updatedAt: candidate.updatedAt, + events, + }; + } + log.info( + `[native-continuation] skipping ${candidate.sessionId}: native transcript is not a canonical prefix`, + { + nativeItems: executionItems.length, + canonicalItems: canonicalItems.length, + } + ); + } catch (error) { + // A missing/corrupt native transcript is not resumable. Try an older + // compatible episode before creating a fresh one. + log.warn( + `[native-continuation] skipping ${candidate.sessionId}: native transcript read failed`, + 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" && eventTurnId(event) === turnIntentId + ); + if (anchor < 0) return null; + appended = appended.slice(anchor + 1); + } else { + const anchor = after.findIndex( + (event) => event.source === "user" && eventTurnId(event) === turnIntentId + ); + if (anchor < 0) return null; + appended = after.slice(anchor + 1); + } + return removeKnownNativeEchoes( + before, + appended.filter((event) => event.source !== "user") + ); +} + +/** + * EventStore can briefly contain a provider-native echo of a synchronized + * prefix after the new user anchor. Never republish an item whose portable + * native identity was already present before this turn. + */ +function removeKnownNativeEchoes( + before: readonly SessionEvent[], + candidates: readonly SessionEvent[] +): SessionEvent[] { + const seen = new Set( + projectNativeConversationItems(before).map((item) => item.id) + ); + return candidates.filter((event) => { + const items = projectNativeConversationItems([event]); + if (items.length === 0) return true; + const isKnown = items.every((item) => seen.has(item.id)); + for (const item of items) seen.add(item.id); + return !isKnown; + }); +} + +function nativeItemEventId(id: string): string { + return id.replace(/:(?:call|result)$/, ""); +} + +/** + * 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[], + expectedUserText: string +): SessionEvent[] | null { + const beforeItems = projectNativeConversationItems(before); + const afterItems = projectNativeConversationItems(after); + if (!nativeConversationItemsArePrefix(beforeItems, afterItems)) { + 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.text === expectedUserText || item.text.endsWith(expectedUserText)) + ); + if (userIndex < 0) { + 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((item) => nativeItemEventId(item.id)) + ); + if (tailEventIds.size === 0) return []; + const tail = after.filter( + (event) => event.source !== "user" && tailEventIds.has(event.id) + ); + log.info( + `[native-continuation] recovered provider-native tail: items=${tailEventIds.size}, events=${tail.length}` + ); + return tail; +} + +async function loadSettledTail( + sessionId: string, + before: readonly SessionEvent[], + turnIntentId: string, + expectedUserText: string, + emptyTerminalSettleMs: number | null = null +): Promise<{ agentTail: SessionEvent[]; events: SessionEvent[] }> { + const settleDeadline = Date.now() + TRANSCRIPT_SETTLE_MS; + const emptyTerminalDeadline = + emptyTerminalSettleMs === null + ? null + : Math.min(settleDeadline, Date.now() + emptyTerminalSettleMs); + let checkedNativeAfterTerminal = false; + let fallbackDelayMs = TRANSCRIPT_SETTLE_INITIAL_POLL_MS; + for (;;) { + // The target runtime's native transcript is the source for this episode's + // newly produced output, but it is never the cross-runtime conversation + // authority. EventStore reconciles the optimistic user row with the + // provider user row and transfers that exact identity one-to-one + // (including repeated equal text). Once this tail is published it becomes + // part of the canonical SessionEvent log used by every later runtime. + // The open Session already owns a windowed JS snapshot, so inspect that + // reference instead of cloning the entire Rust store and reparsing the + // provider transcript every 100ms. A cold/non-rendered caller keeps the + // compatibility path. + const snapshot = eventStoreProxy.getLatestSessionSnapshot(sessionId); + const identifiedEvents = snapshot + ? snapshot.chatEvents + : await eventStoreProxy.getEvents(sessionId).catch(() => []); + const tail = sliceTurnTail(before, identifiedEvents, turnIntentId); + if (tail && tail.length > 0) { + // Read the target-native transcript exactly once after the enriched + // anchor settles. This captures the episode tail and refreshes native + // app metadata; publishing that tail promotes it into canonical events. + const { events } = await loadAuthoritativeSessionEvents(sessionId); + return { agentTail: tail, events }; + } + if (snapshot && !checkedNativeAfterTerminal) { + checkedNativeAfterTerminal = true; + log.info( + `[native-continuation] reading terminal provider transcript for ${sessionId}` + ); + const { events } = await loadAuthoritativeSessionEvents(sessionId); + const nativeTail = sliceProviderNativeTail( + before, + events, + expectedUserText + ); + if (nativeTail && nativeTail.length > 0) { + return { agentTail: nativeTail, events }; + } + } + if (!snapshot) { + // Background/non-rendered continuations may have no JS snapshot. Their + // provider reader can carry the intent marker itself, so retain the + // established full-read fallback for that uncommon path. + const { events } = await loadAuthoritativeSessionEvents(sessionId); + const authoritativeTail = sliceTurnTail(before, events, turnIntentId); + if (authoritativeTail && authoritativeTail.length > 0) { + return { agentTail: authoritativeTail, events }; + } + const nativeTail = sliceProviderNativeTail( + before, + events, + expectedUserText + ); + if (nativeTail && nativeTail.length > 0) { + return { agentTail: nativeTail, events }; + } + } + if (emptyTerminalDeadline !== null && Date.now() >= emptyTerminalDeadline) { + // A cancelled or failed durable terminal is a valid conversation + // boundary even when the provider produced no portable assistant/tool + // suffix. The accepted user row remains canonical; callers publish the + // terminal status/error without retrying the provider turn. + const { events } = await loadAuthoritativeSessionEvents(sessionId); + return { agentTail: [], events }; + } + if (Date.now() >= settleDeadline) { + // Some non-rendered adapters can carry ORG2 identity themselves even + // when EventStore has no resident snapshot. Preserve that fail-safe + // without putting a full provider parse in the active polling loop. + const { events } = await loadAuthoritativeSessionEvents(sessionId); + const authoritativeTail = sliceTurnTail(before, events, turnIntentId); + if (authoritativeTail && authoritativeTail.length > 0) { + return { agentTail: authoritativeTail, events }; + } + const nativeTail = sliceProviderNativeTail( + before, + events, + expectedUserText + ); + if (nativeTail && nativeTail.length > 0) { + return { agentTail: nativeTail, events }; + } + if (emptyTerminalDeadline !== null) return { agentTail: [], events }; + throw new Error( + `conversation turn ${turnIntentId} is missing its native transcript anchor` + ); + } + // The EventStore already owns the session change channel. Wake as soon as + // it publishes the terminal suffix; the exponentially backed-off timer is + // only for providers whose native file flush is not accompanied by a + // snapshot push. This keeps a hidden large Session from cloning/parsing + // its complete transcript fifty times during the five-second settle + // window. + await new Promise((resolve) => { + let settled = false; + const subscription: { dispose?: () => void } = {}; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + subscription.dispose?.(); + resolve(); + }; + const timer = setTimeout(finish, fallbackDelayMs); + subscription.dispose = eventStoreProxy.subscribeSession( + sessionId, + finish + ); + if (settled) subscription.dispose(); + }); + fallbackDelayMs = Math.min( + fallbackDelayMs * 2, + TRANSCRIPT_SETTLE_MAX_POLL_MS + ); + } +} + +function eventContent(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 findAttemptUserIndex( + events: readonly SessionEvent[], + turnIntentId: string, + expectedUserText: string +): number { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.source !== "user") continue; + if (eventTurnId(event) === turnIntentId) return index; + const text = eventContent(event); + if ( + expectedUserText.length > 0 && + (text === expectedUserText || text.endsWith(expectedUserText)) + ) { + return index; + } + } + return -1; +} + +/** + * Recover the provider-owned user row for the completed attempt. + * + * The optimistic row makes submission immediate, but a native transcript + * reconcile is free to replace that projection while the provider is still + * flushing. Publishing only the assistant/tool suffix then leaves a + * completed turn with no user row once the optimistic overlay is released. + * Carry the logical turn identity onto the native user echo and merge it with + * the suffix as one EventStore update; EventStore's transcript reconciler + * atomically replaces the matching optimistic placeholder. + */ +function providerUserEchoForAttempt( + events: readonly SessionEvent[], + turnIntentId: string, + expectedUserText: string +): SessionEvent | null { + const userIndex = findAttemptUserIndex( + events, + turnIntentId, + expectedUserText + ); + if (userIndex < 0) return null; + const user = events[userIndex]; + if (!user || user.source !== "user") return null; + return { + ...user, + result: { + ...(user.result ?? {}), + turnIntentId, + }, + }; +} + +/** + * Isolate events emitted after this exact user attempt. A provider may omit + * ORG2's private turn id, so the fallback anchors on the last matching native + * user message. Returning null means the boundary cannot be proven and must + * fail closed: an unproven attempt is never replayed into another episode. + */ +function sliceAttemptEvents( + before: readonly SessionEvent[], + after: readonly SessionEvent[], + turnIntentId: string, + expectedUserText: string +): SessionEvent[] | null { + if (sameEventPrefix(before, after)) { + const appended = after.slice(before.length); + const anchor = findAttemptUserIndex( + appended, + turnIntentId, + expectedUserText + ); + if (anchor >= 0) return appended.slice(anchor + 1); + // Some providers reject an oversized request before persisting its user + // row. The unchanged prefix (plus possible lifecycle/error rows) is still + // an exact attempt boundary, and the safety classifier below inspects all + // appended rows before allowing a rebuild. + if (!appended.some((event) => event.source === "user")) return appended; + return null; + } + + // Native compact/rollover may replace the provider file with a new thread, + // so its historical ids are no longer an EventStore prefix. The retried + // native user row remains the only safe boundary in that representation. + const anchor = findAttemptUserIndex(after, turnIntentId, expectedUserText); + return anchor >= 0 ? after.slice(anchor + 1) : null; +} + +function isReplayUnsafeAttemptEvent(event: SessionEvent): boolean { + // Match the runtime's replay-safety contract at the canonical EventStore + // boundary. Deltas count: once any assistant/reasoning/tool/plan output was + // visible or a tool began, replaying the user request could duplicate work. + if (event.source !== "assistant") return false; + return ( + Boolean(event.callId) || + event.displayVariant === "message" || + event.displayVariant === "thinking" || + event.displayVariant === "tool_call" || + event.displayVariant === "plan" || + event.displayVariant === "approval" || + event.displayVariant === "summary" + ); +} + +async function isReplaySafeContextExhaustion(params: { + sessionId: string; + terminalStatus: TurnTerminalStatus; + before: readonly SessionEvent[]; + turnIntentId: string; + displayText: string; +}): Promise { + if (params.terminalStatus !== "failed" || !isCliSession(params.sessionId)) { + return false; + } + const row = await rpc.cli + .status({ sessionId: params.sessionId }) + .catch(() => null); + // This durable bit is produced only by the shared runtime error classifier; + // frontend prose matching is deliberately not a recovery authority. + if (row?.contextExhausted !== true) return false; + + const snapshot = eventStoreProxy.getLatestSessionSnapshot(params.sessionId); + const projectedEvents = snapshot + ? snapshot.chatEvents + : await eventStoreProxy.getEvents(params.sessionId).catch(() => []); + if (projectedEvents.length > 0) { + const projectedAttempt = sliceAttemptEvents( + params.before, + projectedEvents, + params.turnIntentId, + params.displayText + ); + if ( + projectedAttempt === null || + projectedAttempt.some(isReplayUnsafeAttemptEvent) + ) { + return false; + } + } + + // EventStore can be absent in a hidden/background continuation. The target + // provider's native transcript is therefore required as the second, + // authoritative replay-safety proof. If it cannot be read or bounded, do + // not create another episode. + const nativeEvents = await loadAuthoritativeSessionEvents(params.sessionId) + .then(({ events }) => events) + .catch(() => null); + if (!nativeEvents) return false; + const nativeAttempt = sliceAttemptEvents( + params.before, + nativeEvents, + params.turnIntentId, + params.displayText + ); + return ( + nativeAttempt !== null && !nativeAttempt.some(isReplayUnsafeAttemptEvent) + ); +} + +async function finishConversationTurn(params: { + sessionId: string; + target: LocalConversationTarget; + before: readonly SessionEvent[]; + turnIntentId: string; + userEventId?: string; + displayText: string; + generation: number; +}): Promise< + Pick & { + replaySafeContextExhaustion: boolean; + } +> { + const terminalStatus = await waitForTurnTerminal( + params.sessionId, + params.turnIntentId + ); + // The durable provider terminal is also a hard EventStore streaming fence. + // CLI adapters normally clear streaming first, but their event callback is + // intentionally fire-and-forget; a fast terminal poll can therefore finish + // this continuation while the last StreamingSnapshot still advertises an + // active stream. That leaves a zero-width live-assistant row in the + // canonical transcript and makes the completed composer look half-running. + // Await the idempotent fence here before reading/publishing the native tail. + await eventStoreProxy + .setStreaming(false, params.sessionId) + .catch((error) => + log.warn( + `[native-continuation] failed to close EventStore streaming for ${params.sessionId}`, + error + ) + ); + const replaySafeContextExhaustion = await isReplaySafeContextExhaustion({ + sessionId: params.sessionId, + terminalStatus, + before: params.before, + turnIntentId: params.turnIntentId, + displayText: params.displayText, + }); + // The failed provider episode is not the retry authority. Its accepted user + // row remains visible in that episode, while the canonical pre-turn log is + // rematerialized into a fresh native episode below. + if (replaySafeContextExhaustion) { + markTurnTerminal(params.sessionId, terminalStatus, { + generation: params.generation, + }); + return { + terminalStatus, + agentTail: [], + replaySafeContextExhaustion: true, + }; + } + // Durable provider completion is the user-facing turn boundary. Publishing + // it must not wait for a second full parse of a very large native history: + // that reconciliation can take tens of seconds even though Codex/Claude + // already wrote task_complete and the final assistant row is resident in + // EventStore. Keeping the optimistic runtime mirror at `running` during + // that read leaves Stop/planning chrome active, prevents the completed tail + // from collapsing, and makes the final answer look missing. + // + // The existing durable message queue and canonical-root lock still own + // reconciliation, so a follow-up submitted now is parked behind this exact + // tail read rather than racing it. + markTurnTerminal(params.sessionId, terminalStatus, { + generation: params.generation, + }); + getInstrumentedStore().set(setSessionRuntimeStatusAtom, { + sessionId: params.sessionId, + status: + terminalStatus === "completed" + ? "completed" + : terminalStatus === "cancelled" + ? "cancelled" + : "failed", + source: "sync", + }); + const settled = await loadSettledTail( + params.sessionId, + params.before, + params.turnIntentId, + params.displayText, + terminalStatus === "cancelled" + ? INTERRUPTED_TRANSCRIPT_SETTLE_MS + : terminalStatus === "failed" + ? TRANSCRIPT_SETTLE_MS + : null + ); + // Native CLI history is the episode's execution record, but reading it is + // side-effect free. Publish the verified provider user echo together with + // its assistant/tool suffix so the visible Session advances immediately. + // EventStore transfers the optimistic row's durable identity onto the + // native user event and removes exactly that placeholder. This also closes + // the race where native reconcile replaces the projection before terminal: + // publishing only the suffix used to render the answer without its prompt. + const providerUserEcho = providerUserEchoForAttempt( + settled.events, + params.turnIntentId, + params.displayText + ); + const completedTurnEvents = providerUserEcho + ? [providerUserEcho, ...settled.agentTail] + : settled.agentTail; + if (completedTurnEvents.length > 0) { + await eventStoreProxy.mergeEvents(completedTurnEvents, params.sessionId); + } + // A fire-and-forget adapter callback that was already queued when the first + // fence ran may publish one last streaming snapshot while native history is + // being verified. Converge again after the authoritative tail merge. + await eventStoreProxy + .setStreaming(false, params.sessionId) + .catch((error) => + log.warn( + `[native-continuation] failed to converge EventStore terminal state for ${params.sessionId}`, + error + ) + ); + // Release the cross-session overlay only after the authoritative user row + // has been merged. If the provider file is still one flush behind, keep the + // optimistic row parked; the normal transcript reconciliation will settle + // it when the user echo arrives instead of making the message disappear. + if (params.userEventId && providerUserEcho) { + clearParkedUserIntentEvent(params.userEventId); + } + return { + terminalStatus, + agentTail: settled.agentTail, + replaySafeContextExhaustion: false, + }; +} + +async function prepareConversationTurn( + sessionId: string, + params: Pick< + ContinueLocalConversationParams, + "displayText" | "imageDataUrls" | "turnIntentId" + >, + runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"], + pendingPolicy: UserIntentPreparation["pendingPolicy"] +): Promise { + return prepareUserIntent({ + sessionId, + visibleText: params.displayText, + imageDataUrls: params.imageDataUrls, + turnIntentId: params.turnIntentId, + runtimeStatusSource, + pendingPolicy, + }); +} + +async function dispatchConversationMessage( + sessionId: string, + params: Omit, + options: { + allowNativeContextRecovery: boolean; + runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"]; + pendingPolicy: UserIntentPreparation["pendingPolicy"]; + preparation?: ConversationTurnPreparation; + } +): ReturnType { + return dispatchUserIntent({ + sessionId, + visibleText: params.displayText, + imageDataUrls: params.imageDataUrls, + runtimeStatusSource: options.runtimeStatusSource, + pendingPolicy: options.pendingPolicy, + 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; + pendingPolicy: UserIntentPreparation["pendingPolicy"]; + 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", + options.pendingPolicy + ); + // 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) => eventTurnId(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 + ); + 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", + pendingPolicy: options.pendingPolicy, + preparation, + } + ); + preparation = dispatched.preparation; + } catch (error) { + if (preparation) { + 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, + target: params.target, + before: materialized.events, + turnIntentId: params.turnIntentId, + userEventId: preparation.userEvent.id, + displayText: params.displayText, + generation: preparation.generation, + }); + return { + sessionId: created.sessionId, + created: true, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; +} + +async function continueLocalConversationAtQueueHead( + params: ContinueLocalConversationParams, + knownCandidates?: readonly ExecutionCandidate[] +): 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) => eventTurnId(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. + await effectiveParams.beforeDispatch?.(); + const compatible = await findCompatibleExecution( + effectiveParams.root, + effectiveParams.target, + effectiveParams.timeline, + knownCandidates + ); + if (compatible) { + const preparation = await prepareConversationTurn( + compatible.sessionId, + effectiveParams, + "dispatch", + "visible" + ); + // 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, + existingEvents: compatible.events, + }); + 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 + ); + 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", + pendingPolicy: "visible", + preparation, + } + ); + } catch (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, + target: effectiveParams.target, + before: compatible.events, + turnIntentId: effectiveParams.turnIntentId, + userEventId: dispatched.userEvent.id, + displayText: effectiveParams.displayText, + generation: dispatched.preparation.generation, + }); + if (finished.replaySafeContextExhaustion) { + return runCreatedConversationTurn(effectiveParams, { + loadTimeline: async () => effectiveParams.timeline, + pendingPolicy: "visible", + onSessionCreated: effectiveParams.onSessionPreparing, + }); + } + return { + sessionId: compatible.sessionId, + created: false, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; + } + + return runCreatedConversationTurn(effectiveParams, { + loadTimeline: async () => effectiveParams.timeline, + pendingPolicy: "visible", + 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 Error( + `conversation turn was retired before provider execution (${durableIntent.status}); edit or retry it as a new intent` + ); + } + + 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, { + allowFailed: true, + })) + ) { + throw new Error( + "durable conversation runner no longer belongs to this root/target" + ); + } + + const timeline = params.timeline.filter( + (event) => eventTurnId(event) !== params.turnIntentId + ); + const { events } = await loadAuthoritativeSessionEvents( + params.runnerSessionId + ); + const canonicalItems = projectNativeConversationItems(timeline); + const executionItems = projectNativeConversationItems(events); + if (!nativeConversationItemsArePrefix(canonicalItems, executionItems)) { + throw new Error( + "accepted conversation runner diverged from the canonical transcript" + ); + } + + const generation = beginTurnDispatch(params.runnerSessionId); + confirmTurnRunning(params.runnerSessionId); + beginOptimisticTurn(params.runnerSessionId, "dispatch"); + try { + await params.onSessionPreparing?.(params.runnerSessionId); + await params.onSessionReady?.( + params.runnerSessionId, + params.eventStartIndex ?? timeline.length + ); + await notifyConversationTurnAccepted( + params.onTurnAccepted, + params.runnerSessionId, + params.turnIntentId + ); + const finished = await finishConversationTurn({ + sessionId: params.runnerSessionId, + target: params.target, + before: timeline, + turnIntentId: params.turnIntentId, + displayText: params.displayText, + generation, + }); + return { + sessionId: params.runnerSessionId, + created: false, + terminalStatus: finished.terminalStatus, + agentTail: finished.agentTail, + }; + } catch (error) { + failOptimisticTurn(params.runnerSessionId, "dispatch"); + markTurnTerminal(params.runnerSessionId, "failed", { generation }); + throw 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); + // Publish the canonical user intent before native-history I/O. Cloud roots + // can render it immediately; local/imported roots retain their durable queue + // card until the concrete execution accepts it. + await params.beforeDispatch?.(); + 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( + { ...params, beforeDispatch: undefined }, + { + loadTimeline: params.loadTimeline, + pendingPolicy: "across_session_switch", + onSessionCreated: params.onSessionPreparing, + } + ); + } + const timeline = await params.loadTimeline(); + return continueLocalConversationAtQueueHead( + { ...params, beforeDispatch: undefined, timeline }, + matchingCandidates + ); +} diff --git a/src/engines/SessionCore/conversations/queuedConversationExecutor.ts b/src/engines/SessionCore/conversations/queuedConversationExecutor.ts new file mode 100644 index 0000000000..d87a893744 --- /dev/null +++ b/src/engines/SessionCore/conversations/queuedConversationExecutor.ts @@ -0,0 +1,68 @@ +import type { Store } from "jotai/vanilla/store"; + +import type { TurnTerminalStatus } from "@src/engines/SessionCore/control/turnLifecycle"; + +import type { + ConversationRootLocator, + LocalConversationTarget, +} 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[]; + status: "queued" | "preparing" | "accepted"; + runnerSessionId?: string; + runnerEventStartIndex?: number; + conversationDispatch?: QueuedConversationDispatch; +} + +/** 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; +} + +export interface QueuedConversationExecutionResult { + terminalStatus: TurnTerminalStatus; +} + +/** 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"; + } +} + +/** + * Dependency-inversion seam for canonical-conversation delivery. + * + * SessionCore continues to own the only durable queue. Feature composition + * supplies the provider/cloud adapter without making the queue depend on UI + * or Cloud modules. + */ +export type QueuedConversationExecutor = ( + store: Store, + message: QueuedConversationMessage, + 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 23ee603046..7dd2a7e48c 100644 --- a/src/engines/SessionCore/core/store/EventStoreProxy.ts +++ b/src/engines/SessionCore/core/store/EventStoreProxy.ts @@ -520,6 +520,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..ef8ef43fbf 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,167 @@ afterEach(() => { }); describe("chatEventsAtom live streaming overlay", () => { + 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("keeps the pending user row visible across a native snapshot replace", () => { + 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", + } + ); + store.set(sessionIdAtom, "session-1"); + store.set(pendingSyntheticEventAtom, pending); + store.set(derivedSnapshotAtom, makeSnapshot([], false)); + + expect(store.get(chatEventsAtom)).toEqual([pending]); + + // A delayed native-history replacement remains visually lossless. + store.set( + derivedSnapshotAtom, + makeSnapshot([makeChatEvent("older", "2026-06-06T19:59:59.000Z")], false) + ); + expect(store.get(chatEventsAtom).map((event) => event.id)).toEqual([ + "older", + "user-input-pending", + ]); + }); + + 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("keeps a new intent visible when an older native turn is replayed with a newer timestamp", () => { + const store = createStore(); + const pending = makeChatEvent( + "user-input-pending", + "2026-06-06T20:00:01.000Z", + { + source: "user", + functionName: "user_message", + uiCanonical: "", + actionType: "raw", + displayText: "continue exploring", + result: { + syntheticUserInput: true, + turnIntentId: "turn-next", + message: { content: "continue exploring", role: "user" }, + }, + displayVariant: "message", + } + ); + const replayedOldTurn = makeChatEvent( + "provider-user-old", + "2026-06-06T20:00:02.000Z", + { + source: "user", + functionName: "user", + uiCanonical: "user", + actionType: "user_message", + displayText: "old request", + result: { + turnIntentId: "turn-old", + message: { content: "old request", role: "user" }, + }, + displayVariant: "message", + } + ); + store.set(sessionIdAtom, "session-1"); + store.set(pendingSyntheticEventAtom, pending); + store.set(derivedSnapshotAtom, makeSnapshot([replayedOldTurn], true)); + + expect(store.get(chatEventsAtom).map((event) => event.id)).toEqual([ + "provider-user-old", + "user-input-pending", + "live-assistant-session-1", + ]); + }); + 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..4ff4935c6f 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, turnLifecycleSignal: 7, - flushRequest: 2, editing: false, }); }); diff --git a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts index b458173365..b354749d37 100644 --- a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts +++ b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts @@ -1,6 +1,7 @@ import { createStore } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { pendingSyntheticEventAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; @@ -120,4 +121,45 @@ describe("chatEventsForSessionAtomFamily streaming stability", () => { ); unsub(); }); + + it("projects the foreground pending user row outside a stale native snapshot", async () => { + const sessionId = "pending-visible"; + const chatAtom = chatEventsForSessionAtomFamily(sessionId); + const unsub = store.sub(chatAtom, () => {}); + await Promise.resolve(); + + const listener = subscribers.get(sessionId); + listener?.( + streamingSnapshot(1, [ + chatEvent("assistant-old", "previous answer", { + sessionId, + displayStatus: "completed", + isDelta: false, + }), + ]) + ); + const pending = chatEvent("user-input-next", "continue exploring", { + sessionId, + source: "user", + functionName: "user_message", + uiCanonical: "", + actionType: "raw", + result: { + syntheticUserInput: true, + turnIntentId: "turn-next", + message: { content: "continue exploring", role: "user" }, + }, + displayStatus: "completed", + displayVariant: "message", + isDelta: false, + }); + store.set(pendingSyntheticEventAtom, pending); + + expect(store.get(chatAtom).map((event) => event.id)).toEqual([ + "assistant-old", + "user-input-next", + `live-assistant-${sessionId}`, + ]); + unsub(); + }); }); diff --git a/src/engines/SessionCore/derived/chatEvents.ts b/src/engines/SessionCore/derived/chatEvents.ts index 1b80032baa..23f31e9462 100644 --- a/src/engines/SessionCore/derived/chatEvents.ts +++ b/src/engines/SessionCore/derived/chatEvents.ts @@ -6,15 +6,24 @@ */ 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, } from "@src/store/ui/messageQueueAtom"; +import { syntheticSettledByScope } from "../core/atoms/actions.userMessageSync"; import { derivedSnapshotAtom, eventsAtom } from "../core/atoms/events"; -import { sessionIdAtom } from "../core/atoms/metadata"; +import { + pendingSyntheticEventAtom, + sessionIdAtom, +} from "../core/atoms/metadata"; import type { Snapshot } from "../core/store/EventStoreProxy"; +import { syntheticEvictionScopeForRealUserEvents } from "../core/store/eventStoreEvents"; import type { SessionEvent } from "../core/types"; import { isVisibleInChat } from "../ingestion/visibilityFilters"; import { @@ -82,18 +91,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 +98,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,9 +203,78 @@ export function appendLiveAssistantEvent( return [...withoutLive, liveEvent]; } +/** + * Render the single foreground optimistic user row independently of the Rust + * snapshot. Native transcript synchronization is allowed to replace the + * EventStore wholesale; without this overlay the just-submitted row vanishes + * until the replacement finishes and the provider echoes it back. The real + * echo (same event ID or durable turn-intent ID; legacy rows fall back to + * content/time reconciliation) suppresses the overlay, so it cannot create a + * second visible message. + */ +export function appendPendingSyntheticUserEvent( + events: SessionEvent[], + sessionId: string | null, + pending: SessionEvent | null +): SessionEvent[] { + if (!sessionId || !pending || pending.sessionId !== sessionId) return events; + if (events.some((event) => event.id === pending.id)) return events; + const scope = syntheticEvictionScopeForRealUserEvents(events); + if (syntheticSettledByScope(pending, scope)) return events; + return [...events, pending]; +} + +/** + * 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 Set( + events + .map((event) => turnIntentIdOf(event)) + .filter((id): id is string => Boolean(id)) + ); + let next = events; + for (const message of queuedMessages) { + if ( + message.sessionId !== sessionId || + representedTurnIntents.has(message.turnIntentId) + ) { + continue; + } + const pending = createSyntheticUserEvent( + sessionId, + message.displayContent, + { + id: `queued-user-${message.turnIntentId}`, + createdAt: message.createdAt, + imageDataUrls: message.imageDataUrls, + turnIntentId: message.turnIntentId, + deliveryStatus: "pending", + queueMessageId: message.id, + } + ); + if (next === events) next = [...events]; + next.push(pending); + representedTurnIntents.add(message.turnIntentId); + } + return next; +} + export const chatEventsAtom = atom((get) => { const snap = get(derivedSnapshotAtom); const sessionId = get(sessionIdAtom); + const pendingSyntheticEvent = get(pendingSyntheticEventAtom); // Reset prev cache when the active session changes so the stability // comparison never runs across two different sessions' event arrays. @@ -216,7 +295,15 @@ export const chatEventsAtom = atom((get) => { const queuedMessages = get(messageQueueAtom); if (snap && "chatEvents" in snap) { - const rawChatEvents = snap.chatEvents; + const rawChatEvents = appendQueuedUserEvents( + appendPendingSyntheticUserEvent( + snap.chatEvents, + sessionId, + pendingSyntheticEvent + ), + sessionId, + queuedMessages + ); // Fast path — skip the expensive derivation on unchanged frames. // @@ -279,7 +366,15 @@ 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( + appendPendingSyntheticUserEvent( + get(eventsAtom), + sessionId, + pendingSyntheticEvent + ), + sessionId, + queuedMessages + ); return appendLiveAssistantEvent( derivePlanDisplayEvents( filterQueuedSyntheticUserEvents( diff --git a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts index 7ec4fe7b8f..3e85f7f7f7 100644 --- a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts +++ b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts @@ -5,7 +5,6 @@ import { messageQueueAtom, messageQueueHydratedAtom, queueEditingAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { turnLifecycleSignalAtom } from "../control/turnLifecycle"; @@ -20,7 +19,6 @@ export interface QueueDispatchSyncInputs { queue: QueuedMessage[]; hydrated: boolean; turnLifecycleSignal: number; - flushRequest: number; editing: boolean; } @@ -29,7 +27,6 @@ export const queueDispatchSyncInputsAtom = atom( queue: get(messageQueueAtom), hydrated: 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..8762879733 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -35,6 +35,7 @@ import { } from "@src/store/ui/messageQueueAtom"; import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; +import { pendingSyntheticEventAtom } from "../core/atoms/metadata"; import { isInteractiveTool } from "../core/interactiveTools"; import { hasLiveRuntimeResourceInLatestTurn, @@ -50,6 +51,8 @@ import type { SessionEvent } from "../core/types"; import { ensureCursorIdeEventsInStore } from "../sync/adapters/cursorIdeAdapter"; import { appendLiveAssistantEvent, + appendPendingSyntheticUserEvent, + appendQueuedUserEvents, filterQueuedSyntheticUserEvents, } from "./chatEvents"; import { areChatTranscriptsStructurallyEqual } from "./chatTranscriptStructure"; @@ -180,13 +183,22 @@ export function extractSessionChatEvents( function deriveFamilyChatEvents( snapshot: Snapshot | null, sessionId: string, - queuedMessages: readonly QueuedMessage[] + queuedMessages: readonly QueuedMessage[], + pendingSyntheticEvent: SessionEvent | null ): SessionEvent[] { const streaming = snapshot ? isSnapshotActivelyStreaming(snapshot) : false; return appendLiveAssistantEvent( derivePlanDisplayEvents( filterQueuedSyntheticUserEvents( - extractSessionChatEvents(snapshot), + appendQueuedUserEvents( + appendPendingSyntheticUserEvent( + extractSessionChatEvents(snapshot), + sessionId, + pendingSyntheticEvent + ), + sessionId, + queuedMessages + ), queuedMessages as QueuedMessage[] ) ), @@ -208,7 +220,13 @@ export const chatEventsForSessionAtomFamily = atomFamily( const a = atom((get) => { const { snapshot } = get(sessionSnapshotAtomFamily(sessionId)); const queuedMessages = get(messageQueueAtom); - const next = deriveFamilyChatEvents(snapshot, sessionId, queuedMessages); + const pendingSyntheticEvent = get(pendingSyntheticEventAtom); + const next = deriveFamilyChatEvents( + snapshot, + sessionId, + queuedMessages, + pendingSyntheticEvent + ); const streaming = snapshot ? isSnapshotActivelyStreaming(snapshot) : false; diff --git a/src/engines/SessionCore/hooks/replay/__tests__/usePlanningIndicator.test.ts b/src/engines/SessionCore/hooks/replay/__tests__/usePlanningIndicator.test.ts index b660ff6af6..eb26b8dd92 100644 --- a/src/engines/SessionCore/hooks/replay/__tests__/usePlanningIndicator.test.ts +++ b/src/engines/SessionCore/hooks/replay/__tests__/usePlanningIndicator.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { planningWatchdogDelayMs, + planningWatchdogTerminalStatus, shouldShowPlanningIndicator, } from "../usePlanningIndicator"; @@ -134,3 +135,23 @@ describe("planningWatchdogDelayMs", () => { expect(planningWatchdogDelayMs(0, WATCHDOG)).toBe(WATCHDOG); }); }); + +describe("planningWatchdogTerminalStatus", () => { + it("never closes a provider that is still doing silent work", () => { + expect(planningWatchdogTerminalStatus("running")).toBeNull(); + expect(planningWatchdogTerminalStatus("installing")).toBeNull(); + expect(planningWatchdogTerminalStatus("waiting_for_user")).toBeNull(); + expect(planningWatchdogTerminalStatus("pending")).toBeNull(); + expect(planningWatchdogTerminalStatus("unknown-new-status")).toBeNull(); + }); + + it("preserves failure/cancellation and normalizes other terminal states", () => { + expect(planningWatchdogTerminalStatus("failed")).toBe("failed"); + expect(planningWatchdogTerminalStatus("cancelled")).toBe("cancelled"); + expect(planningWatchdogTerminalStatus("timeout")).toBe("timeout"); + expect(planningWatchdogTerminalStatus("killed")).toBe("failed"); + expect(planningWatchdogTerminalStatus("completed")).toBe("completed"); + expect(planningWatchdogTerminalStatus("idle")).toBe("completed"); + expect(planningWatchdogTerminalStatus("paused")).toBe("completed"); + }); +}); diff --git a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts index 51f4120e05..b05b912e0e 100644 --- a/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts +++ b/src/engines/SessionCore/hooks/replay/usePlanningIndicator.ts @@ -8,10 +8,9 @@ * The indicator stays visible until new events arrive or the session ends. * * Watchdog: if the indicator stays visible for PLANNING_WATCHDOG_MS (60s), - * we assume Rust dropped `agent:complete` (or `agent:queue_status` idle) - * and force `sessionRuntimeStatusAtom` to `completed` so the UI cannot stay - * stuck on "Planning next step..." forever. Logged as a warning because - * this should only fire on genuine event-loss bugs. + * query the backend-authoritative session status. A live provider may spend + * minutes inside one silent shell tool; channel silence alone is not proof + * that the turn ended. Only a terminal backend status may settle the UI. * * Reads directly from derivedSnapshotAtom (NOT eventsAtom). During streaming, * Rust pushes StreamingSnapshot which has no `events` field, causing eventsAtom @@ -50,9 +49,11 @@ import { sessionScopedPlanningMetaAtomFamily, } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import { usePlanningIdleTiming } from "@src/engines/SessionCore/hooks/replay/planningIndicatorIdleTiming"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { msSinceSessionChannelActivity } from "@src/engines/SessionCore/sync/sessionChannelActivity"; import { createLogger } from "@src/hooks/logger"; import { + type CliSessionStatus, isPendingCancelAtom, isSessionActiveAtom, sessionRuntimeStatusAtom, @@ -63,6 +64,7 @@ import { hasLiveSubagentJobs, subagentJobMapAtom, } from "@src/store/session/subagentJobAtom"; +import { isActiveStatus, isTerminalStatus } from "@src/types/session/session"; const log = createLogger("usePlanningIndicator"); @@ -99,6 +101,38 @@ export function planningWatchdogDelayMs( return watchdogMs - msSinceChannelActivity; } +/** + * Map a backend-authoritative status to the UI action the watchdog may take. + * `null` means the provider is still alive (or waiting for the user), so the + * watchdog must re-arm instead of manufacturing a terminal event. + */ +export function planningWatchdogTerminalStatus( + status: string +): CliSessionStatus | null { + // `idle` means the backend has released this turn; `paused` likewise has no + // executing work for the planning footer to represent. Other active states + // must remain open even when their channel is temporarily silent. + if (status === "idle" || status === "paused") return "completed"; + if (isActiveStatus(status)) return null; + // Unknown/new backend statuses are not proof of completion. Only the shared + // terminal vocabulary may settle the UI. + if (!isTerminalStatus(status)) return null; + if (status === "completed") return "completed"; + if (status === "cancelled") return "cancelled"; + if ( + status === "failed" || + status === "error" || + status === "abandoned" || + status === "timeout" || + status === "archived" + ) { + return status; + } + // Market-only terminal values (for example `killed`) collapse to the + // closest local-session presentation status. + return "failed"; +} + export interface PlanningIndicatorVisibilityInput { runtimeStatus: string; isSessionActive: boolean; @@ -282,29 +316,55 @@ export function usePlanningIndicator( ) return; let timerId: number | null = null; + let disposed = false; const arm = (delayMs: number) => { timerId = window.setTimeout(() => { - const rearmDelay = planningWatchdogDelayMs( - msSinceSessionChannelActivity(effectiveSessionId) - ); - if (rearmDelay !== null) { - arm(rearmDelay); - return; - } - log.warn( - `[usePlanningIndicator] watchdog: planning indicator stuck for ${PLANNING_WATCHDOG_MS}ms ` + - "with no channel activity — forcing session status to 'completed'. This usually means " + - "Rust dropped agent:complete or the idle agent:queue_status frame." - ); - setSessionRuntimeStatus({ - sessionId: effectiveSessionId, - status: "completed", - source: "planning", - }); + void (async () => { + const rearmDelay = planningWatchdogDelayMs( + msSinceSessionChannelActivity(effectiveSessionId) + ); + if (rearmDelay !== null) { + if (!disposed) arm(rearmDelay); + return; + } + try { + const backend = await SessionService.getStatus({ + sessionId: effectiveSessionId, + }); + if (disposed) return; + const terminal = planningWatchdogTerminalStatus(backend.status); + if (terminal === null) { + log.info( + `[usePlanningIndicator] watchdog: channel silent for ${PLANNING_WATCHDOG_MS}ms, ` + + `but backend is ${backend.status}; keeping the turn active` + ); + arm(PLANNING_WATCHDOG_MS); + return; + } + log.warn( + `[usePlanningIndicator] watchdog: channel missed terminal status; ` + + `backend is ${backend.status}, settling UI as ${terminal}` + ); + setSessionRuntimeStatus({ + sessionId: effectiveSessionId, + status: terminal, + source: "planning", + }); + } catch (error) { + // A failed probe cannot prove the provider stopped. Preserve the + // active footer and retry instead of recreating the original lie. + log.warn( + "[usePlanningIndicator] watchdog backend probe failed; keeping the turn active", + error + ); + if (!disposed) arm(PLANNING_WATCHDOG_MS); + } + })(); }, delayMs); }; arm(PLANNING_WATCHDOG_MS); return () => { + disposed = true; if (timerId !== null) window.clearTimeout(timerId); }; }, [ 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/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 618bd66ab4..4db88eaa8b 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); +} + // ============================================ // Utility Functions // ============================================ @@ -71,12 +89,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 763a16260d..87c2ebd176 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 { collectAdeContext } from "@src/services/context/collectors"; import { @@ -302,6 +298,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 @@ -320,38 +317,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, @@ -361,12 +331,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/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/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..1925448470 100644 --- a/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts +++ b/src/engines/SessionCore/sync/__tests__/nativeTranscriptReconcile.test.ts @@ -11,6 +11,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mergeInterruptedConversationProjection } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { @@ -71,6 +72,7 @@ function makeHarness( harness.loads.push(next); return next; }, + mergeInterruptedProjection: mergeInterruptedConversationProjection, dispatchLoadSession: (payload) => { harness.dispatches.push(payload); }, @@ -139,6 +141,25 @@ describe("scheduleNativeTranscriptReconcile", () => { expect(harness.dispatches).toEqual([{ sessionId, events, replace: true }]); }); + it("does not erase a durable interrupted suffix when the newest native fork was not flushed", async () => { + const sessionId = "s-interrupted-fallback"; + registerSessionTranscriptSource(sessionId, "native"); + const native = [makeEvent("a1", sessionId)]; + const partial = makeEvent("a-partial", sessionId); + const projected = [...native, partial]; + const harness = makeHarness(sessionId, [native, native]); + harness.deps.loadProjectedHistory = async () => projected; + + scheduleNativeTranscriptReconcile(sessionId, harness.deps, { + preserveInterruptedSuffix: true, + }); + await vi.advanceTimersByTimeAsync(SETTLE_MS + RETRY_MS); + + expect(harness.dispatches).toEqual([ + { sessionId, events: projected, replace: true }, + ]); + }); + it("re-dispatches on retry only when the parse grew", async () => { const sessionId = "s-grew"; registerSessionTranscriptSource(sessionId, "native"); diff --git a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts index 810d2cfa03..ef6c2031da 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSwitchOrchestrator.test.ts @@ -5,6 +5,10 @@ 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(), @@ -52,6 +56,8 @@ vi.mock("../sessionSyncReconcile", () => ({ vi.mock("../sessionSyncStateHelpers", () => ({ applyPostLoadResult: mocks.applyPostLoadResult, + capturePostLoadLifecycleSnapshot: mocks.capturePostLoadLifecycleSnapshot, + isPostLoadRunStatusSuperseded: vi.fn(() => false), })); vi.mock("../sessionSyncUtils", () => ({ diff --git a/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts b/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts index ed144c8375..1e8239aa1e 100644 --- a/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts +++ b/src/engines/SessionCore/sync/__tests__/sessionSyncReconcile.test.ts @@ -13,6 +13,12 @@ */ 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"; @@ -188,6 +194,7 @@ function settle(): Promise { describe("reconcileInFlightHistory", () => { beforeEach(() => { vi.clearAllMocks(); + resetTurnLifecycleForTests(); store.reset(); timer.delays.length = 0; createInstrumentedStore(); @@ -450,8 +457,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 +480,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(); 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..725b10aed2 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,6 +42,8 @@ vi.mock("@src/store/session", () => ({ })); vi.mock("@src/engines/SessionCore/control/turnLifecycle", () => ({ + getLastTurnTerminal: vi.fn(() => null), + getTurnGeneration: mocks.getTurnGeneration, markTurnRunning: vi.fn(), markTurnTerminal: vi.fn(), toTurnTerminalStatus: (status: string) => @@ -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,29 @@ 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("rejects a terminal intent attributed to another session", () => { mocks.getTurnIntentDispatch.mockReturnValue({ sessionId: "session-other", 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__/createCliEventHandler.test.ts b/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts index 870fb7072f..438b39fa71 100644 --- a/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts +++ b/src/engines/SessionCore/sync/adapters/cli/__tests__/createCliEventHandler.test.ts @@ -1308,6 +1308,60 @@ 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("force-closes still-running events when the session ends", async () => { await store.api.upsert( { diff --git a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts index f2f5bd377e..d2eb1faa11 100644 --- a/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts +++ b/src/engines/SessionCore/sync/adapters/cli/cliLifecycle.ts @@ -43,18 +43,29 @@ 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); + 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 so native projection drops it until a real result + // arrives and replaces this row. + result: unresolvedToolCall + ? { ...event.result, status: "pending", interrupted: true } + : { ...event.result, status: displayStatus }, isDelta: false, }, sessionId - ) - ) + ); + }) ); } @@ -74,14 +85,16 @@ export function markCliRuntimeRunning( export function markObservedCliTerminalStatus( sessionId: string, status: CliSessionStatus | undefined -): void { - if (!isCliTerminalStatus(status) || !isStoreInitialized()) return; +): Promise { + if (!isCliTerminalStatus(status) || !isStoreInitialized()) { + return Promise.resolve(); + } getInstrumentedStore().set(setSessionRuntimeStatusAtom, { sessionId, status, source: "sync", }); - void closeObservedCliTerminalEvents(sessionId, status).catch((error) => { + return closeObservedCliTerminalEvents(sessionId, status).catch((error) => { log.warn("[cliAdapter] failed to close terminal CLI events:", error); }); } 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 a864567e4e..a6640d6b4a 100644 --- a/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts +++ b/src/engines/SessionCore/sync/adapters/cli/createCliEventHandler.ts @@ -108,7 +108,7 @@ export function createCliEventHandler( function reconcileTerminalEventsIfNeeded(): void { if (!observedTerminalStatus) return; - markObservedCliTerminalStatus(sessionId, observedTerminalStatus); + void markObservedCliTerminalStatus(sessionId, observedTerminalStatus); } function asString(value: unknown): string | undefined { @@ -458,9 +458,15 @@ 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?.()); } if (isSessionRuntimeExecuting(status)) { 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..d74bd66a7d 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,21 @@ export function createSyntheticUserEvent( * Send Now). */ turnIntentId?: string; + /** Frontend delivery state for an optimistic user turn. */ + deliveryStatus?: "pending" | "sent" | "failed"; + deliveryError?: string; + queueMessageId?: string; } ): 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 +321,21 @@ export function createSyntheticUserEvent( syntheticUserInput: true, ...(images && images.length > 0 ? { images } : {}), ...(turnIntentId ? { turnIntentId } : {}), + ...(deliveryStatus ? { deliveryStatus } : {}), + ...(options?.deliveryError + ? { deliveryError: options.deliveryError } + : {}), + ...(options?.queueMessageId + ? { queueMessageId: options.queueMessageId } + : {}), }, 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..db8580d894 100644 --- a/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts +++ b/src/engines/SessionCore/sync/nativeTranscriptReconcile.ts @@ -33,6 +33,13 @@ export function isNativeTranscriptSession(sessionId: string): boolean { interface ReconcileDeps { loadHistory: (sessionId: string) => Promise; + /** Durable in-app projection captured before native replay replaces it. */ + loadProjectedHistory?: (sessionId: string) => Promise; + /** Provider-portable suffix merge supplied by the conversation layer. */ + mergeInterruptedProjection?: ( + nativeEvents: readonly SessionEvent[], + projectedEvents: readonly SessionEvent[] + ) => SessionEvent[]; dispatchLoadSession: (payload: { sessionId: string; events: SessionEvent[]; @@ -48,20 +55,55 @@ interface ReconcileDeps { isSessionLive: (sessionId: string) => boolean; } +interface ReconcileOptions { + /** Preserve an accepted safe suffix after cancellation/failure. */ + preserveInterruptedSuffix?: boolean; +} + const pendingReconciles = new Set(); +function mergeReconcileEvents( + deps: ReconcileDeps, + nativeEvents: SessionEvent[], + projectedEvents: SessionEvent[] +): SessionEvent[] { + if (projectedEvents.length === 0 || !deps.mergeInterruptedProjection) { + return nativeEvents; + } + return deps.mergeInterruptedProjection(nativeEvents, projectedEvents); +} + export function scheduleNativeTranscriptReconcile( sessionId: string, - deps: ReconcileDeps + deps: ReconcileDeps, + options: ReconcileOptions = {} ): void { if (!isNativeTranscriptSession(sessionId)) return; if (pendingReconciles.has(sessionId)) return; pendingReconciles.add(sessionId); + // Capture the durable pre-reconcile projection at most once. Completed + // turns need no fallback read at all; cancellation/failure is the only path + // where a killed CLI may not have flushed its newest native fork. + let projectedHistoryPromise: Promise | null = null; + const loadProjectedHistory = (): Promise => { + if (!options.preserveInterruptedSuffix || !deps.loadProjectedHistory) { + return Promise.resolve([]); + } + projectedHistoryPromise ??= deps + .loadProjectedHistory(sessionId) + .catch(() => []); + return projectedHistoryPromise; + }; + const runOnce = async (): Promise => { if (!deps.isSessionLive(sessionId)) return -1; - const events = await deps.loadHistory(sessionId); + const [nativeEvents, projectedEvents] = await Promise.all([ + deps.loadHistory(sessionId), + loadProjectedHistory(), + ]); if (!deps.isSessionLive(sessionId)) return -1; + const events = mergeReconcileEvents(deps, nativeEvents, projectedEvents); if (events.length > 0) { deps.dispatchLoadSession({ sessionId, events, replace: true }); } @@ -77,7 +119,11 @@ export function scheduleNativeTranscriptReconcile( // 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); + const [nativeEvents, projectedEvents] = await Promise.all([ + deps.loadHistory(sessionId), + loadProjectedHistory(), + ]); + const events = mergeReconcileEvents(deps, nativeEvents, projectedEvents); if ( events.length > Math.max(firstCount, 0) && deps.isSessionLive(sessionId) diff --git a/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts b/src/engines/SessionCore/sync/sessionSwitchOrchestrator.ts index f729d9f454..f59e2254e6 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,7 +136,13 @@ 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; @@ -197,7 +206,9 @@ async function handleCacheHit( ) { reconcileInFlightHistory(sessionId, adapter, refs, actions); } - applyPostLoadResult(sessionId, postResult, actions); + applyPostLoadResult(sessionId, postResult, actions, { + lifecycleSnapshot: postLoadLifecycle, + }); } async function handleCursorIdeCacheHit( @@ -253,13 +264,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 +298,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..652085d50c 100644 --- a/src/engines/SessionCore/sync/sessionSyncReconcile.ts +++ b/src/engines/SessionCore/sync/sessionSyncReconcile.ts @@ -1,10 +1,10 @@ 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 +12,6 @@ import { hydrateSessionStoreBeforeDisplay, isTerminalRunStatus, loadPersistedHistory, - toCliSessionStatus, - toSessionListStatus, waitForReconcileDelay, } from "./sessionSyncUtils"; import type { SessionAdapter } from "./types"; @@ -44,6 +42,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; @@ -95,26 +94,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..93e9a33ff6 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 @@ -258,6 +322,17 @@ 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. @@ -277,7 +352,7 @@ export function createSessionEventHandlerCallbacks( actions.setPendingCancel(false); eventStoreProxy.unpinSession(sessionId); updateSessionStatus(sessionId, toSessionListStatus(cliStatus)); - actions.scheduleNativeTranscriptReconcile?.(sessionId); + actions.scheduleNativeTranscriptReconcile?.(sessionId, status); } if (isSessionRuntimeExecuting(status)) { markTurnRunning(sessionId); diff --git a/src/engines/SessionCore/sync/types.ts b/src/engines/SessionCore/sync/types.ts index 8ad38714ce..52af718bec 100644 --- a/src/engines/SessionCore/sync/types.ts +++ b/src/engines/SessionCore/sync/types.ts @@ -166,6 +166,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 +195,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/useSessionSync.ts b/src/engines/SessionCore/sync/useSessionSync.ts index 75ed1538f0..cf480cf94d 100644 --- a/src/engines/SessionCore/sync/useSessionSync.ts +++ b/src/engines/SessionCore/sync/useSessionSync.ts @@ -15,6 +15,8 @@ import { loadStatusAtom, streamingDeltaContentAtom, } from "@src/engines/SessionCore"; +import { mergeInterruptedConversationProjection } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { createLogger } from "@src/hooks/logger"; import { canvasPreviewAtom, @@ -191,17 +193,27 @@ export function useSessionSync( ); const scheduleReconcile = useCallback( - (sid: string) => { - scheduleNativeTranscriptReconcile(sid, { - loadHistory: async (target) => { - const adapter = getAdapterForSession(target); - if (!adapter) return []; - const controller = new AbortController(); - return adapter.loadHistory(target, controller.signal); + (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); + }, + loadProjectedHistory: (target) => + eventStoreProxy.getPersistedEvents(target), + mergeInterruptedProjection: mergeInterruptedConversationProjection, + dispatchLoadSession, + isSessionLive: (target) => liveSessionIdRef.current === target, }, - dispatchLoadSession, - isSessionLive: (target) => liveSessionIdRef.current === target, - }); + { + preserveInterruptedSuffix: + terminalStatus === "cancelled" || terminalStatus === "failed", + } + ); }, [dispatchLoadSession] ); diff --git a/src/features/ConversationContinuation/enqueueCanonicalConversation.ts b/src/features/ConversationContinuation/enqueueCanonicalConversation.ts new file mode 100644 index 0000000000..e6a55de693 --- /dev/null +++ b/src/features/ConversationContinuation/enqueueCanonicalConversation.ts @@ -0,0 +1,88 @@ +import type { Store } from "jotai/vanilla/store"; + +import { + admitUserIntentToMessageQueue, + isExplicitPostStopSubmit, +} from "@src/engines/SessionCore/control/messageQueueAdmission"; +import type { + ConversationRootLocator, + LocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; + +interface CanonicalConversationQueueInput { + displayText: string; + agentContent?: string; + imageDataUrls?: string[]; + /** Preserve the durable intent identity when retrying a failed delivery. */ + turnIntentId?: string; +} + +export class CanonicalConversationQueueAdmissionError extends Error { + constructor(message: string) { + super(message); + this.name = "CanonicalConversationQueueAdmissionError"; + } +} + +/** + * Compose a canonical turn into the application's one durable UI queue. + * The queue remains the only component allowed to drain it; the neutral + * SessionCore conversation layer never imports this client-state adapter. + */ +export async function enqueueCanonicalConversation(params: { + store: Store; + root: ConversationRootLocator; + sessionId: string; + input: CanonicalConversationQueueInput; + target: LocalConversationTarget; +}): Promise { + const { store, root, sessionId, input, target } = params; + const id = `queued-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const turnIntentId = input.turnIntentId ?? mintTurnIntentId(); + const dispatchIdentityKey = + root.authority === "org2-cloud" + ? (() => { + const auth = store.get(org2CloudAuthAtom); + if (!auth) { + throw new CanonicalConversationQueueAdmissionError( + "Cloud sign-in is required before queuing this turn" + ); + } + return org2CloudAuthIdentityKey(auth); + })() + : undefined; + const result = admitUserIntentToMessageQueue({ + store, + explicitPostStopSubmit: isExplicitPostStopSubmit(store, sessionId), + message: { + id, + turnIntentId, + sessionId, + content: input.agentContent ?? input.displayText, + displayContent: input.displayText, + imageDataUrls: input.imageDataUrls, + conversationDispatch: { + kind: "canonical_conversation", + root, + target, + ...(dispatchIdentityKey ? { dispatchIdentityKey } : {}), + }, + status: "queued", + createdAt: new Date().toISOString(), + }, + }); + if (result === "duplicate") return true; + if (result !== "enqueued") { + throw new CanonicalConversationQueueAdmissionError( + result === "message_too_large" + ? "Queued message is too large" + : "Message queue is full; send or remove a queued message first" + ); + } + return true; +} 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..7df984af43 --- /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"; + +export 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/ConversationContinuation/queuedConversationExecutor.test.ts b/src/features/ConversationContinuation/queuedConversationExecutor.test.ts new file mode 100644 index 0000000000..d7f58fc02b --- /dev/null +++ b/src/features/ConversationContinuation/queuedConversationExecutor.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/queuedConversationExecutor"; + +import { withCanonicalConversationTurnLock } from "./queuedConversationExecutor"; + +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/features/ConversationContinuation/queuedConversationExecutor.ts b/src/features/ConversationContinuation/queuedConversationExecutor.ts new file mode 100644 index 0000000000..7733107c4d --- /dev/null +++ b/src/features/ConversationContinuation/queuedConversationExecutor.ts @@ -0,0 +1,212 @@ +import type { Store } from "jotai/vanilla/store"; + +import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + continueLocalConversationAfterTimelineLoad, + recoverLocalConversationTurn, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import type { + QueuedConversationExecutionResult, + QueuedConversationExecutor, + QueuedConversationMessage, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { QueuedConversationBusyError } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { dispatchQueuedCloudConversation } from "@src/features/Org2Cloud/SessionConversation/queuedConversationExecutor"; +import type { Session } from "@src/store/session"; +import { loadSessions, sessionsAtom } from "@src/store/session"; +import { publishSessionContinuationAtom } from "@src/store/session/sessionTabPlacementAtom"; + +import { resolveExternalHistoryContinuation } from "./externalHistoryContinuation"; + +const CANONICAL_CONVERSATION_LOCK_PREFIX = "orgii:canonical-conversation:"; + +/** + * Serialize one canonical root across the main and detached Tauri webviews. + * + * Each webview intentionally owns its existing durable message queue, but a + * canonical root can be visible in more than one window. Web Locks are already + * the app's cross-webview mutex primitive (the Cloud auth refresh path uses the + * same API). Holding this lock for the provider turn prevents two independent + * queue realms from materializing and running divergent native episodes at + * once. The queue remains the sole dispatcher; this is only its process-wide + * root boundary, and a closed/crashed webview releases the lock automatically. + */ +export async function withCanonicalConversationTurnLock( + root: 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 = `${CANONICAL_CONVERSATION_LOCK_PREFIX}${conversationRootKey(root)}`; + let result: + | { ok: true; value: T } + | { ok: false; error: unknown } + | undefined; + try { + // Keep callback failures inside a fulfilled lock request. Otherwise a + // broad acquisition fallback cannot distinguish "Web Locks unavailable" + // from "the provider turn failed" and may execute the same user turn a + // second time outside the lock. + 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 { + // Executing unlocked is not safe: another window may already own this + // canonical root and materialize a divergent native episode. Let the + // existing queue surface a retryable failed message instead of risking a + // duplicate provider turn. + 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; +} + +function sessionById(store: Store, sessionId: string): Session | undefined { + return store + .get(sessionsAtom) + .find((candidate) => candidate.session_id === sessionId); +} + +/** Notify the mounted surface; it owns how its current tab/window retargets. */ +async function revealRunnerIfSourceIsVisible( + store: Store, + sourceSessionId: string, + runnerSessionId: string, + title: string, + repoPath?: string +): Promise { + await loadSessions({ forceRefresh: true }); + store.set(publishSessionContinuationAtom, { + sourceSessionId, + sessionId: runnerSessionId, + sessionName: title, + repoPath, + }); +} + +async function dispatchQueuedLocalConversation( + store: Store, + message: QueuedConversationMessage, + 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 revealedRunnerSessionId: string | null = null; + const revealRunner = async (sessionId: string) => { + if ( + sessionId === message.sessionId || + revealedRunnerSessionId === sessionId + ) { + return; + } + revealedRunnerSessionId = sessionId; + await revealRunnerIfSourceIsVisible( + store, + message.sessionId, + sessionId, + title, + target.workspaceRepoPath ?? undefined + ); + }; + const continuationParams = { + root, + title, + loadTimeline: async () => + (await loadCanonicalConversationEvents(message.sessionId)).events, + displayText: message.displayContent, + agentContent: message.content, + imageDataUrls: message.imageDataUrls, + target, + turnIntentId: message.turnIntentId, + onSessionPreparing: async (sessionId: string) => { + await callbacks.onRunnerReady?.(sessionId, Number.MAX_SAFE_INTEGER); + await revealRunner(sessionId); + }, + onSessionReady: async (sessionId: string, eventStartIndex: number) => { + await callbacks.onRunnerReady?.(sessionId, eventStartIndex); + await revealRunner(sessionId); + }, + onTurnAccepted: callbacks.onAccepted, + }; + if (message.status !== "queued" && message.runnerSessionId) { + const recovered = await recoverLocalConversationTurn({ + ...continuationParams, + timeline: await continuationParams.loadTimeline(), + runnerSessionId: message.runnerSessionId, + eventStartIndex: message.runnerEventStartIndex, + }); + if (recovered) return { terminalStatus: recovered.terminalStatus }; + } + const result = + await continueLocalConversationAfterTimelineLoad(continuationParams); + return { terminalStatus: result.terminalStatus }; +} + +/** The sole canonical executor injected into SessionCore's existing queue. */ +export const dispatchQueuedCanonicalConversation: QueuedConversationExecutor = + 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"); + } + return await withCanonicalConversationTurnLock( + descriptor.root, + async () => { + if (descriptor.root.authority === "org2-cloud") { + return await dispatchQueuedCloudConversation( + store, + message, + descriptor.root, + callbacks + ); + } + return await dispatchQueuedLocalConversation(store, message, callbacks); + } + ); + }; 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 eb3fca5fa7..0ebea2db59 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.test.ts @@ -1,11 +1,64 @@ -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 { 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 { + 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", () => { @@ -86,3 +139,237 @@ describe("addCommentWithSessionAdmissionRecovery", () => { expect(add).toHaveBeenCalledOnce(); }); }); + +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) + ) + ) + ); + 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; + }; + + const first = getContext().retryComment( + failedComment.id, + "@Bob edited body" + ); + 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: "@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 5bf1a7bac9..5097ad4714 100644 --- a/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx +++ b/src/features/Org2Cloud/SessionComments/SessionCommentsContext.tsx @@ -32,6 +32,11 @@ 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 +46,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 +66,7 @@ import { type AddCommentInput, type CloudSessionCommentsFetchState, type GroupedCommentThreads, + OPTIMISTIC_SESSION_COMMENT_ID_PREFIX, groupCommentThreads, useSessionComments, } from "../org2CloudSessionCommentsAtom"; @@ -73,6 +81,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"); +} + +export 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 }; @@ -167,6 +290,8 @@ 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) => 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 @@ -304,6 +429,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 @@ -319,13 +446,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. @@ -374,6 +511,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 && @@ -382,7 +525,7 @@ export const SessionCommentsProvider: React.FC< !getSessionForkedFrom(session) ); return addCommentWithSessionAdmissionRecovery( - () => addComment(input), + () => addComment(stableInput), locallyOwnedTarget && target ? async () => { org2CloudSyncEngine.invalidatePushedMetadataHash( @@ -396,8 +539,82 @@ export const SessionCommentsProvider: React.FC< }, [addComment, session, target] ); - const viewer = useSessionCommentViewer(target); const mentionableMembers = useSessionCommentMentionableMembers(target); + const retryComment = useCallback( + async (commentId: string, editedBody?: string): 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, + undefined, + 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 — @@ -493,6 +710,7 @@ export const SessionCommentsProvider: React.FC< mentionableMembers, refresh, addComment: addCommentWithRecovery, + retryComment, editComment, deleteComment, resolveComment, @@ -513,6 +731,7 @@ export const SessionCommentsProvider: React.FC< mentionableMembers, refresh, addCommentWithRecovery, + retryComment, editComment, deleteComment, resolveComment, 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..69f3b27112 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx @@ -0,0 +1,258 @@ +import { useAtomValue } from "jotai"; +import React, { useMemo } from "react"; + +import { + ConversationSenderMetadataProvider, + useConversationViewerState, +} from "@src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext"; +import type { + ConversationSenderIdentity, + ConversationSenderStamp, +} 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; +} + +export 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 = useConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null + ); + 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 index 216304f9dd..a63ca97cbe 100644 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts +++ b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it } from "vitest"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + import { + type ActiveConversationRunner, + activeConversationRunnerKey, + buildConversationRunnerOverlay, collectLandedTurnIds, + removeConversationRunnerByTurn, selectActiveRunners, + selectConversationRunnerTail, + upsertConversationRunner, } from "./activeConversationRunnersAtom"; const row = (turnId: string, source: "user" | "assistant" | "system") => ({ @@ -31,8 +39,8 @@ describe("collectLandedTurnIds", () => { describe("selectActiveRunners", () => { const runners = [ - { runnerSessionId: "r1", turnId: "t1" }, - { runnerSessionId: "r2", turnId: "t2" }, + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 8 }, + { runnerSessionId: "r2", turnId: "t2", eventStartIndex: 0 }, ]; it("keeps a runner while only its user row is on the plane", () => { @@ -49,3 +57,128 @@ describe("selectActiveRunners", () => { expect(selectActiveRunners(runners, landed)).toEqual([runners[1]]); }); }); + +describe("selectConversationRunnerTail", () => { + it("windows a reused native session to the current non-user tail", () => { + const events = [ + { id: "old-agent", source: "assistant" }, + { id: "current-user", source: "user" }, + { id: "current-tool", source: "system" }, + { id: "current-agent", source: "assistant" }, + ] as unknown as SessionEvent[]; + expect( + selectConversationRunnerTail( + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, + events + ).map((event) => event.id) + ).toEqual(["current-tool", "current-agent"]); + }); + + it("builds the production overlay from only that windowed tail", () => { + const events = [ + { id: "old-agent", chunk_id: "old-agent", source: "assistant" }, + { id: "current-user", chunk_id: "current-user", source: "user" }, + { id: "current-agent", chunk_id: "current-agent", source: "assistant" }, + ] as unknown as SessionEvent[]; + expect( + buildConversationRunnerOverlay( + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, + events, + "canonical-root" + ) + ).toEqual([ + expect.objectContaining({ + id: "runlive-current-agent", + chunk_id: "runlive-current-agent", + sessionId: "canonical-root", + }), + ]); + }); +}); + +describe("removeConversationRunnerByTurn", () => { + it("drops only the empty terminal turn and removes an empty root bucket", () => { + const registry = { + root: [ + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, + { runnerSessionId: "r2", turnId: "t2", eventStartIndex: 2 }, + ], + }; + expect(removeConversationRunnerByTurn(registry, "root", "t1")).toEqual({ + root: [{ runnerSessionId: "r2", turnId: "t2", eventStartIndex: 2 }], + }); + expect( + removeConversationRunnerByTurn({ root: [registry.root[0]] }, "root", "t1") + ).toEqual({}); + }); +}); + +describe("active conversation runner registry identity and bounds", () => { + const root = (conversationId: string) => ({ + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId, + }); + + it("partitions runners by the exact auth identity and canonical root", () => { + const authARoot1 = activeConversationRunnerKey("auth-a", root("root-1")); + const authBRoot1 = activeConversationRunnerKey("auth-b", root("root-1")); + const authARoot2 = activeConversationRunnerKey("auth-a", root("root-2")); + + expect(authARoot1).not.toBe(authBRoot1); + expect(authARoot1).not.toBe(authARoot2); + + const first = { + runnerSessionId: "runner-a", + turnId: "turn-a", + eventStartIndex: 0, + }; + const second = { + runnerSessionId: "runner-b", + turnId: "turn-b", + eventStartIndex: 0, + }; + const registry = upsertConversationRunner( + upsertConversationRunner({}, authARoot1, first), + authBRoot1, + second + ); + + expect(registry[authARoot1]).toEqual([first]); + expect(registry[authBRoot1]).toEqual([second]); + expect(registry[authARoot2]).toBeUndefined(); + }); + + it("bounds both runners per root and remembered root buckets", () => { + const key = activeConversationRunnerKey("auth-a", root("busy-root")); + let registry: Record = {}; + for (let index = 0; index < 9; index += 1) { + registry = upsertConversationRunner(registry, key, { + runnerSessionId: `runner-${index}`, + turnId: `turn-${index}`, + eventStartIndex: index, + }); + } + expect(registry[key]).toHaveLength(8); + expect(registry[key]?.[0]?.runnerSessionId).toBe("runner-1"); + + for (let index = 0; index < 33; index += 1) { + const rootKey = activeConversationRunnerKey( + "auth-a", + root(`root-${index}`) + ); + registry = upsertConversationRunner(registry, rootKey, { + runnerSessionId: `root-runner-${index}`, + turnId: `root-turn-${index}`, + eventStartIndex: 0, + }); + } + expect(Object.keys(registry)).toHaveLength(32); + expect( + registry[activeConversationRunnerKey("auth-a", root("root-0"))] + ).toBeUndefined(); + expect( + registry[activeConversationRunnerKey("auth-a", root("root-32"))] + ).toHaveLength(1); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts index 50cd3eb86c..b0d1b141dd 100644 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts @@ -1,7 +1,8 @@ /** * Live overlay registry for in-flight member turns. * - * A member's send runs the turn in an invisible one-shot local runner and + * A member's send runs the turn in an invisible durable local execution + * Session 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. @@ -19,20 +20,63 @@ */ import { atom } from "jotai"; +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; 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; + /** Native-event prefix from earlier turns; never overlay it again. */ + eventStartIndex: number; } -/** plane rootSessionId → this device's in-flight member runners. */ +const MAX_ACTIVE_CONVERSATION_ROOTS = 32; +const MAX_ACTIVE_RUNNERS_PER_ROOT = 8; + +/** + * The overlay is local UI state, but the plane it shadows is Cloud state. + * Include the endpoint/account identity as well as the canonical root so an + * account or endpoint switch can never expose a runner from the previous + * identity merely because the org/session ids happen to match. + */ +export function activeConversationRunnerKey( + authIdentityKey: string, + root: ConversationRootLocator +): string { + return JSON.stringify([authIdentityKey, conversationRootKey(root)]); +} + +/** `(auth identity, canonical root)` → this device's in-flight runners. */ export const activeConversationRunnersAtom = atom< Record >({}); activeConversationRunnersAtom.debugLabel = "activeConversationRunnersAtom"; +/** Insert one runner while bounding both a busy root and the registry itself. */ +export function upsertConversationRunner( + registry: Readonly>, + key: string, + runner: ActiveConversationRunner +): Record { + const runners = [ + ...(registry[key] ?? []).filter( + (candidate) => candidate.runnerSessionId !== runner.runnerSessionId + ), + runner, + ].slice(-MAX_ACTIVE_RUNNERS_PER_ROOT); + const entries = Object.entries(registry).filter( + ([candidateKey]) => candidateKey !== key + ); + return Object.fromEntries([ + ...entries.slice(-(MAX_ACTIVE_CONVERSATION_ROOTS - 1)), + [key, runners], + ]); +} + /** Plane turnIds whose agent tail has landed (a non-user row is present). */ export function collectLandedTurnIds( rows: readonly { turnId: string; event: Pick }[] @@ -51,3 +95,42 @@ export function selectActiveRunners( ): ActiveConversationRunner[] { return runners.filter((runner) => !landedTurnIds.has(runner.turnId)); } + +/** Current-turn native tail only; prior turns and the injected user row stay hidden. */ +export function selectConversationRunnerTail( + runner: ActiveConversationRunner, + events: readonly SessionEvent[] +): SessionEvent[] { + return events + .slice(Math.max(0, runner.eventStartIndex)) + .filter((event) => event.source !== "user"); +} + +/** Namespace the exact current-turn tail for the canonical live overlay. */ +export function buildConversationRunnerOverlay( + runner: ActiveConversationRunner, + events: readonly SessionEvent[], + canonicalSessionId: string +): SessionEvent[] { + return selectConversationRunnerTail(runner, events).map((event) => ({ + ...event, + id: `runlive-${event.id}`, + chunk_id: `runlive-${event.id}`, + sessionId: canonicalSessionId, + })); +} + +/** Remove one terminal runner when no plane tail can perform normal cleanup. */ +export function removeConversationRunnerByTurn( + registry: Readonly>, + key: string, + turnId: string +): Record { + const current = registry[key] ?? []; + const kept = current.filter((runner) => runner.turnId !== turnId); + if (kept.length === current.length) return registry; + const next = { ...registry }; + if (kept.length === 0) delete next[key]; + else next[key] = kept; + return next; +} diff --git a/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts b/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts index 1405d4cd94..175f5f320b 100644 --- a/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts +++ b/src/features/Org2Cloud/SessionConversation/continuationEvents.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; 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"; diff --git a/src/features/Org2Cloud/SessionConversation/continuationEvents.ts b/src/features/Org2Cloud/SessionConversation/continuationEvents.ts index 63f341fad7..4b30a4b619 100644 --- a/src/features/Org2Cloud/SessionConversation/continuationEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/continuationEvents.ts @@ -1,3 +1,7 @@ +import { + CONVERSATION_SENDER_ARG, + type ConversationSenderStamp, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; 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 +11,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 +117,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 +140,16 @@ 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); - } + const id = peelCopyEventNamespaces(event); + return materializedEventIdentity(id)?.sourceEventId ?? id; +} + +/** 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 +163,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) 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.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts index a161e8487e..bf57979dcb 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts @@ -1,24 +1,45 @@ /** * 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, useRef } from "react"; +import { useCallback } 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 { REALTIME_SIGNAL_COALESCE_MS } from "../org2CloudRealtimeSignalCoalescer"; import type { SessionCommentTarget } from "../sessionCommentTarget"; +import { drainConversationTailOutbox } from "./conversationTailOutbox"; const log = createLogger("ConversationPlane"); +const MAX_CONVERSATION_PLANE_ENTRIES = 64; +const MAX_CONVERSATION_PLANE_SIGNALS = 64; export type ConversationPlaneState = | "idle" @@ -28,23 +49,39 @@ 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[]; lastSeq: number; } -const EMPTY_ENTRY: ConversationPlaneEntry = { - state: "idle", - events: [], - lastSeq: 0, -}; +export interface ConversationPlaneLocator { + authIdentityKey: string; + orgId: string; + rootSessionId: string; +} + +const KEY_SEPARATOR = "\u001f"; + +function emptyEntry(locator: ConversationPlaneLocator): ConversationPlaneEntry { + return { + ...locator, + state: "idle", + events: [], + lastSeq: 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< @@ -54,7 +91,132 @@ export const conversationPlaneAtom = atom< /** orgId → monotonically increasing signal counter (realtime bump). */ export const conversationPlaneSignalAtom = atom>({}); -const inFlightByKey = new Set(); +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; +} + +interface ConversationPlaneRequestState { + activeIdentityKey: string | null; + epoch: number; + inFlightByKey: 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(), + }; + requestStateByStore.set(store, state); + } + return state; +} + +function boundedRecordWrite( + current: Record, + key: string, + value: T, + maxSize: number +): Record { + const bounded = new BoundedMap({ maxSize }); + for (const [existingKey, existingValue] of Object.entries(current)) { + bounded.set(existingKey, existingValue); + } + bounded.set(key, value); + return Object.fromEntries(bounded.entries()); +} + +function writeConversationPlaneEntry( + current: ConversationPlaneEntries, + key: string, + entry: ConversationPlaneEntry +): ConversationPlaneEntries { + return boundedRecordWrite( + current, + key, + entry, + MAX_CONVERSATION_PLANE_ENTRIES + ); +} + +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(); + 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 { + 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 + ); +} function mergePlaneEvents( previous: ConversationPlaneEntry, @@ -69,12 +231,154 @@ function mergePlaneEvents( (left, right) => left.seq - right.seq ); return { + ...previous, state: "ready", events, lastSeq: events.length > 0 ? events[events.length - 1].seq : 0, }; } +/** + * 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) return existing; + + 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 probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); + 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(); + let resolved = entryMatchesLocator(stored, locator) ? stored : before; + let afterSeq = resolved.lastSeq; + for (;;) { + const page = await listConversationEvents(fresh.accessToken, { + orgId: params.orgId, + rootSessionId: params.rootSessionId, + afterSeq, + }); + if (!isCurrentRequest()) { + throw new Error("cloud auth identity changed during plane refresh"); + } + params.setEntries((current) => { + if (!isCurrentRequest()) return current; + const storedCurrent = current[key]; + const previous = entryMatchesLocator(storedCurrent, locator) + ? storedCurrent + : emptyEntry(locator); + resolved = mergePlaneEvents(previous, page.events); + return writeConversationPlaneEntry(current, key, resolved); + }); + if (!page.hasMore || page.events.length === 0) break; + afterSeq = page.events[page.events.length - 1].seq; + } + const wireLastSeq = resolved.lastSeq; + const decodedEvents = await decodeConversationEventChunks( + resolved.events + ); + resolved = { + ...resolved, + events: decodedEvents, + // Chunk envelopes collapse to one logical event whose row carries the + // last chunk seq. Preserve the raw cursor even when no logical event + // was added by this refresh. + lastSeq: wireLastSeq, + }; + 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); + } + }; + void load.then(clearInFlight, clearInFlight); + return load; +} + /** * Keeps the plane entry for the given conversation target fetched and * incrementally fresh. Refetches whenever the org's signal counter bumps @@ -85,79 +389,160 @@ 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 lastForegroundRecoverAtRef = useRef(0); 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); - void (async () => { - try { + const drainTailOutbox = useCallback(async () => { + if (!auth || !authIdentityKey) return; + await drainConversationTailOutbox({ + authIdentityKey, + getAccessToken: async () => { const fresh = await ensureFreshSession(auth); - if (!fresh) return; + if (!fresh) throw new Error("cloud auth refresh failed"); commitRefreshedAuth(setAuth, auth, fresh); - const probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); - if (!probe.capabilities.conversationEvents) { - if (probe.confirmed) { - setEntries((current) => ({ - ...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) => { - const previous = current[key] ?? EMPTY_ENTRY; - return { - ...current, - [key]: mergePlaneEvents(previous, 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 { ...current, [key]: { ...previous, state: "error" } }; - }); - } finally { - inFlightByKey.delete(key); - } - })(); + return fresh.accessToken; + }, + onPushed: (orgId) => bumpConversationPlaneSignal(setSignals, orgId), + }); + }, [auth, authIdentityKey, setAuth, setSignals]); + + useEffect(() => { + 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 () => { + await drainTailOutbox().catch((error: unknown) => { + log.warn("conversation tail outbox recovery failed", error); + }); + await refreshConversationPlaneEntry({ + store, + auth, + orgId: targetOrgId, + rootSessionId: targetSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries, + setAuth, + }); + })().catch((error: unknown) => { + log.warn(`conversation plane fetch failed for ${key}`, error); + }); }, [ targetOrgId, targetSessionId, key, + locator, auth, setAuth, setEntries, signal, store, + drainTailOutbox, + ]); + + // A short foreground switch does not release the shared Realtime socket + // (the lease intentionally has a blur grace), so it cannot rely on a new + // SUBSCRIBED edge to recover an at-most-once broadcast. Match the other + // Cloud planes: on actual foreground regain, run one cooldown-bounded + // incremental pull from this conversation's durable seq cursor. + useEffect(() => { + if ( + !targetOrgId || + !targetSessionId || + !key || + !locator || + !auth || + typeof window === "undefined" || + typeof document === "undefined" + ) { + return undefined; + } + const recover = () => { + if (document.visibilityState === "hidden") return; + if (typeof document.hasFocus === "function" && !document.hasFocus()) { + return; + } + if ( + Date.now() - lastForegroundRecoverAtRef.current < + REALTIME_SIGNAL_COALESCE_MS + ) { + return; + } + // A native foreground transition can emit both `focus` and + // `visibilitychange`; collapse only that duplicate pair. Unlike the + // 30-second full-list cooldown used by heavier Cloud planes, each + // distinct app switch must advance this cheap `after_seq` cursor. + lastForegroundRecoverAtRef.current = Date.now(); + void (async () => { + await drainTailOutbox(); + await refreshConversationPlaneEntry({ + store, + auth, + orgId: targetOrgId, + rootSessionId: targetSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries, + setAuth, + }); + })().catch((error: unknown) => { + log.warn( + `conversation plane foreground recovery failed for ${key}`, + error + ); + }); + }; + window.addEventListener("focus", recover); + window.addEventListener("online", recover); + document.addEventListener("visibilitychange", recover); + return () => { + window.removeEventListener("focus", recover); + window.removeEventListener("online", recover); + document.removeEventListener("visibilitychange", recover); + }; + }, [ + targetOrgId, + targetSessionId, + key, + locator, + auth, + setAuth, + setEntries, + store, + drainTailOutbox, ]); return entry; @@ -170,5 +555,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.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts index 77da3fec8b..f0b48045f8 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts @@ -1,10 +1,10 @@ -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 type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; const PLANE_ID_PREFIX = "convplane-"; @@ -23,7 +23,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, diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx b/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx index a68844a0b6..9b4d67fe00 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx +++ b/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx @@ -1,12 +1,13 @@ /** * 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. + * A member's turn runs in an invisible durable local execution episode, so + * the mounted canonical 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 and + * composer controls scope themselves to that 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 diff --git a/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts b/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts new file mode 100644 index 0000000000..809530c6a1 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts @@ -0,0 +1,279 @@ +import { type Store, load } from "@tauri-apps/plugin-store"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { createLogger } from "@src/hooks/logger"; + +import { + CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH, + Org2CloudConversationError, + pushConversationEvents, +} from "../org2CloudConversationEventsClient"; + +const log = createLogger("ConversationTailOutbox"); +const STORE_PATH = "cloud-conversation-tail-outbox.json"; +const STORE_KEY = "pendingChunks"; +const OUTBOX_LOCK_NAME = "orgii:cloud-conversation-tail-outbox"; +const MAX_PENDING_CONVERSATION_TAIL_CHUNKS = 512; +const MAX_DRAIN_CHUNKS_PER_PASS = 64; + +interface PendingConversationTailChunk { + id: string; + authIdentityKey: string; + orgId: string; + rootSessionId: string; + turnId: string; + chunkIndex: number; + events: SessionEvent[]; + createdAt: string; + failedError?: string; +} + +export interface ConversationTailDrainResult { + pushedChunks: Array<{ id: string; eventCount: number }>; + failedChunkIds: string[]; + pendingChunkIds: string[]; +} + +let storePromise: Promise | null = null; +let fallbackChain: Promise = Promise.resolve(); + +function durableStore(): Promise { + storePromise ??= load(STORE_PATH, { defaults: {}, autoSave: false }); + return storePromise; +} + +function validRow(value: unknown): value is PendingConversationTailChunk { + if (!value || typeof value !== "object") return false; + const row = value as Partial; + return ( + typeof row.id === "string" && + typeof row.authIdentityKey === "string" && + typeof row.orgId === "string" && + typeof row.rootSessionId === "string" && + typeof row.turnId === "string" && + Number.isSafeInteger(row.chunkIndex) && + Array.isArray(row.events) && + row.events.length > 0 && + row.events.length <= CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH && + typeof row.createdAt === "string" + ); +} + +function isSameStagedRevision( + current: PendingConversationTailChunk, + snapshot: PendingConversationTailChunk +): boolean { + return current.id === snapshot.id && current.createdAt === snapshot.createdAt; +} + +async function loadRows(store: Store): Promise { + // Each webview owns a plugin-store handle. The Web Lock serializes writers, + // but it does not refresh another webview's cached document; always reload + // inside the lock before read-modify-write or one window can erase another + // window's newly staged tail. + await store.reload(); + const stored = await store.get(STORE_KEY); + return Array.isArray(stored) ? stored.filter(validRow) : []; +} + +async function saveRows( + store: Store, + rows: readonly PendingConversationTailChunk[] +): Promise { + await store.set(STORE_KEY, rows); + await store.save(); +} + +async function withOutboxLock(operation: () => Promise): Promise { + const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; + if (locks?.request) { + return await locks.request( + OUTBOX_LOCK_NAME, + { mode: "exclusive" }, + operation + ); + } + const next = fallbackChain.catch(() => undefined).then(operation); + fallbackChain = next; + return await next; +} + +/** + * Persist the normalized tail before its first network attempt. The Cloud RPC + * is idempotent by event id, so a crash after the RPC but before the local + * delete safely replays the same chunk after restart. + */ +export async function stageConversationTail(params: { + authIdentityKey: string; + orgId: string; + rootSessionId: string; + turnId: string; + batchId: string; + events: readonly SessionEvent[]; +}): Promise { + if (params.events.length === 0) return []; + const chunkCount = Math.ceil( + params.events.length / CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH + ); + if (chunkCount > MAX_PENDING_CONVERSATION_TAIL_CHUNKS) { + throw new Error( + `Cloud conversation tail is too large (${chunkCount}/${MAX_PENDING_CONVERSATION_TAIL_CHUNKS} chunks)` + ); + } + const chunks: PendingConversationTailChunk[] = []; + for ( + let offset = 0, chunkIndex = 0; + offset < params.events.length; + offset += CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH, chunkIndex += 1 + ) { + chunks.push({ + id: [ + params.authIdentityKey, + params.orgId, + params.rootSessionId, + params.turnId, + params.batchId, + chunkIndex, + ].join("\u001f"), + authIdentityKey: params.authIdentityKey, + orgId: params.orgId, + rootSessionId: params.rootSessionId, + turnId: params.turnId, + chunkIndex, + events: params.events.slice( + offset, + offset + CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH + ), + createdAt: new Date().toISOString(), + }); + } + await withOutboxLock(async () => { + const store = await durableStore(); + const rows = await loadRows(store); + const byId = new Map(rows.map((row) => [row.id, row] as const)); + for (const chunk of chunks) byId.set(chunk.id, chunk); + const merged = [...byId.values()]; + if (merged.length > MAX_PENDING_CONVERSATION_TAIL_CHUNKS) { + throw new Error( + `Cloud conversation tail outbox is full (${merged.length}/${MAX_PENDING_CONVERSATION_TAIL_CHUNKS} chunks)` + ); + } + await saveRows(store, merged); + }); + return chunks.map((chunk) => chunk.id); +} + +/** + * Drain only the signed-in account's rows. Network I/O happens outside the + * store lock so an offline request cannot block a new provider turn from + * durably staging its tail. Duplicate concurrent pushes are harmless because + * Cloud event ids are idempotent. + */ +export async function drainConversationTailOutbox(params: { + authIdentityKey: string; + getAccessToken: () => Promise; + onPushed?: (orgId: string) => void; +}): Promise { + const store = await durableStore(); + const pushedChunks: ConversationTailDrainResult["pushedChunks"] = []; + const attempted = new Set(); + for (;;) { + const snapshot = await withOutboxLock(async () => + (await loadRows(store)) + .filter( + (candidate) => + candidate.authIdentityKey === params.authIdentityKey && + !candidate.failedError && + !attempted.has(candidate.id) + ) + .slice(0, MAX_DRAIN_CHUNKS_PER_PASS) + ); + if (snapshot.length === 0) break; + + const successful: PendingConversationTailChunk[] = []; + const terminalFailures = new Map< + string, + { row: PendingConversationTailChunk; failedError: string } + >(); + let transportError: unknown = null; + const accessToken = await params.getAccessToken(); + for (const row of snapshot) { + attempted.add(row.id); + try { + await pushConversationEvents(accessToken, { + orgId: row.orgId, + rootSessionId: row.rootSessionId, + turnId: row.turnId, + events: row.events, + }); + successful.push(row); + } catch (error) { + const rowTerminal = + error instanceof Org2CloudConversationError && + (error.code === "ORG2_VALIDATION" || + error.code === "ORG2_ORG_NOT_FOUND" || + error.code === "ORG2_FORBIDDEN" || + error.code === "ORG2_MEMBER_REQUIRED" || + error.code === "ORG2_CONVERSATION_BATCH_TOO_LARGE" || + error.code === "ORG2_CONVERSATION_EVENT_TOO_LARGE"); + if (!rowTerminal) { + transportError = error; + break; + } + const failedError = + error instanceof Error ? error.message : "Cloud publication failed"; + terminalFailures.set(row.id, { row, failedError }); + log.error( + `conversation tail ${row.id} requires manual recovery`, + error + ); + } + } + + // One CAS-style commit per bounded network pass. A concurrent restage of + // the same id is a new revision and must never be removed or marked failed + // by this older attempt. + await withOutboxLock(async () => { + const current = await loadRows(store); + const successfulById = new Map( + successful.map((row) => [row.id, row] as const) + ); + const next = current.flatMap((candidate) => { + const pushed = successfulById.get(candidate.id); + if (pushed && isSameStagedRevision(candidate, pushed)) return []; + const failed = terminalFailures.get(candidate.id); + if (failed && isSameStagedRevision(candidate, failed.row)) { + return [{ ...candidate, failedError: failed.failedError }]; + } + return [candidate]; + }); + await saveRows(store, next); + }); + for (const row of successful) { + pushedChunks.push({ id: row.id, eventCount: row.events.length }); + params.onPushed?.(row.orgId); + } + if (transportError) throw transportError; + } + const remaining = await withOutboxLock(async () => + (await loadRows(store)).filter( + (row) => row.authIdentityKey === params.authIdentityKey + ) + ); + const pushed = pushedChunks.reduce( + (total, chunk) => total + chunk.eventCount, + 0 + ); + if (pushed > 0) { + log.info(`published ${pushed} durable conversation tail event(s)`); + } + return { + pushedChunks, + failedChunkIds: remaining + .filter((row) => Boolean(row.failedError)) + .map((row) => row.id), + pendingChunkIds: remaining + .filter((row) => !row.failedError) + .map((row) => row.id), + }; +} diff --git a/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts index 01cb92e4a5..30427204d4 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTimeline.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; +import { CONVERSATION_SENDER_ARG } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; 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 +59,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,6 +89,29 @@ 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", () => { @@ -118,12 +144,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 +164,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 +175,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 +247,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 +278,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 +292,64 @@ 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("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..b6e8896281 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts @@ -9,12 +9,19 @@ * 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 type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; import { - CONVERSATION_SENDER_ARG, - type ConversationSenderStamp, + materializedConversationTurnIdOf, sourceEventIdOf, } from "./continuationEvents"; import { buildConversationPlaneStreamEvents } from "./conversationPlaneEvents"; @@ -32,6 +39,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 +50,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 +87,63 @@ 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 seenBaseKeys = new Set(); for (const event of base) { + const key = conversationEventKey(event); + if (seenBaseKeys.has(key)) continue; + seenBaseKeys.add(key); + 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 +153,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) { diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts new file mode 100644 index 0000000000..a70a7af667 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; + +import { + buildPushedUserEvent, + runConversationTurn, +} from "./conversationTurnRunner"; + +const mocks = vi.hoisted(() => ({ + continueLocalConversation: vi.fn(), + pushConversationEvents: vi.fn(), + pushConversationEventsChunked: vi.fn(), + stageConversationTail: vi.fn(), + drainConversationTailOutbox: vi.fn(), +})); + +vi.mock( + "@src/engines/SessionCore/conversations/localConversationContinuation", + async (importOriginal) => ({ + ...(await importOriginal()), + continueLocalConversation: mocks.continueLocalConversation, + }) +); + +vi.mock("../org2CloudConversationEventsClient", async (importOriginal) => ({ + ...(await importOriginal()), + boundConversationEventForPush: (event: unknown) => event, + pushConversationEvents: mocks.pushConversationEvents, + pushConversationEventsChunked: mocks.pushConversationEventsChunked, +})); + +vi.mock("./conversationTailOutbox", () => ({ + stageConversationTail: mocks.stageConversationTail, + drainConversationTailOutbox: mocks.drainConversationTailOutbox, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.pushConversationEvents.mockResolvedValue({ firstSeq: 1, lastSeq: 1 }); + mocks.pushConversationEventsChunked.mockResolvedValue({ + firstSeq: 2, + lastSeq: 2, + }); + mocks.stageConversationTail.mockResolvedValue(["staged-tail"]); + mocks.drainConversationTailOutbox.mockResolvedValue({ + pushedChunks: [{ id: "staged-tail", eventCount: 1 }], + failedChunkIds: [], + pendingChunkIds: [], + }); + mocks.continueLocalConversation.mockImplementation(async (params) => { + await params.beforeDispatch?.(); + params.onSessionReady?.("cliagent-owner", 3); + return { + sessionId: "cliagent-owner", + created: false, + 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(); + mocks.continueLocalConversation.mockImplementationOnce(async (params) => { + await params.beforeDispatch?.(); + await params.onSessionPreparing?.("cliagent-fresh"); + await params.onSessionReady?.("cliagent-fresh", 7); + return { + sessionId: "cliagent-fresh", + created: true, + terminalStatus: "completed", + agentTail: [], + }; + }); + + const result = await runConversationTurn({ + getAccessToken: async () => "token", + authIdentityKey: "user-1", + orgId: "org-1", + rootSessionId: "shared-root", + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-fresh", + onRunnerReady, + }); + + 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", + pushedAgentEventCount: 0, + }) + ); + }); + + 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({ + getAccessToken: async () => "token", + authIdentityKey: "user-1", + orgId: "org-1", + rootSessionId: "shared-root", + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + executionRoot, + turnIntentId: "turn-owner", + }); + + expect(mocks.continueLocalConversation).toHaveBeenCalledWith( + expect.objectContaining({ root: executionRoot }) + ); + expect(mocks.stageConversationTail).not.toHaveBeenCalled(); + }); + + it("publishes a non-portable transcript error when execution fails after the user row", async () => { + const failure = new Error("native materialization failed"); + mocks.continueLocalConversation.mockImplementationOnce(async (params) => { + await params.beforeDispatch?.(); + throw failure; + }); + + await expect( + runConversationTurn({ + getAccessToken: async () => "token", + authIdentityKey: "user-1", + orgId: "org-1", + rootSessionId: "shared-root", + conversationTitle: "Shared conversation", + displayText: "continue", + timeline: [], + target: { + cliAgentType: "codex", + accountId: "acct-codex", + model: "gpt-5.6-sol", + }, + turnIntentId: "turn-failed", + }) + ).rejects.toBe(failure); + + expect(mocks.stageConversationTail).toHaveBeenCalledOnce(); + const failurePush = mocks.stageConversationTail.mock.calls[0]?.[0]; + expect(failurePush).toEqual( + expect.objectContaining({ + turnId: "turn-failed", + events: [ + expect.objectContaining({ + source: "system", + displayVariant: "error", + displayStatus: "failed", + result: expect.objectContaining({ + error: "native materialization failed", + }), + }), + ], + }) + ); + expect(projectNativeConversationItems(failurePush.events)).toEqual([]); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index 02f341a319..66ba67347a 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -1,193 +1,56 @@ /** - * 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"; + CONVERSATION_TURN_ID_ARG, + type ConversationRootLocator, + type LocalConversationTarget, + continueLocalConversation, + recoverLocalConversationTurn, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; 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"; -import { - clearForkSetupMemory, - loadForkSetupMemory, - saveForkSetupMemory, -} from "@src/features/TeamCollaboration/forkSetupMemory"; import { createLogger } from "@src/hooks/logger"; -import i18n from "@src/i18n"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { conversationEventsForPush } from "../org2CloudConversationEventsClient"; import { - boundConversationEventForPush, - pushConversationEvents, - pushConversationEventsChunked, -} from "../org2CloudConversationEventsClient"; + drainConversationTailOutbox, + stageConversationTail, +} from "./conversationTailOutbox"; 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); - } - 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(); - }); -} - -/** - * 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 +60,247 @@ 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. - */ +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 { + /** Resolved separately for every push; long turns may outlive a JWT. */ getAccessToken: () => Promise; + authIdentityKey: string; orgId: string; rootSessionId: string; 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; + /** Composer-selected local runtime/account/model. Never resolved by a modal. */ + target: LocalConversationTarget; /** - * 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. + * A compatible local native root can be reused directly. Otherwise this + * device keeps its own durable execution episode for the Cloud root. */ - onUserMessagePublished?: () => void; - /** Fires after each successful push (signal-bump hook). */ + executionRoot?: ConversationRootLocator; + turnIntentId?: string; + recovery?: { runnerSessionId: string; eventStartIndex?: number }; + onRunnerReady?: ( + sessionId: string, + turnId: string, + eventStartIndex: number + ) => void | Promise; + /** Local provider accepted the turn; distinct from Cloud user publication. */ + onTurnAccepted?: (sessionId: string) => void | Promise; onPushed?: () => void; } -export interface RunConversationTurnResult { +interface RunConversationTurnResult { runnerSessionId: string; pushedEventCount: number; + pushedAgentEventCount: number; + tailPublicationPending: boolean; + terminalStatus: TurnTerminalStatus; + turnIntentId: string; } 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 ?? mintTurnIntentId(); + const root = + params.executionRoot ?? + ({ + authority: "org2-cloud", + authorityScope: [params.orgId], + conversationId: params.rootSessionId, + } as const); + log.info( + `resolved execution for ${params.orgId}:${params.rootSessionId}; ` + + `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; + // The idempotent conversation-plane push already published the user event. + // Native materialization may proceed without a second wire path. + const beforeDispatch = async () => undefined; + let result: Awaited>; 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, + beforeDispatch, + // 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), + onTurnAccepted: params.onTurnAccepted, + }; + const recovered = params.recovery + ? await recoverLocalConversationTurn({ + ...continuationParams, + runnerSessionId: params.recovery.runnerSessionId, + eventStartIndex: params.recovery.eventStartIndex, + }) + : null; + 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, - }); - 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, - }) - ); + // 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. + try { + const failureEvents = await conversationEventsForPush( + buildPushedDispatchFailureEvent( + error, + new Date().toISOString(), + turnIntentId + ) + ); + const stagedIds = await stageConversationTail({ + authIdentityKey: params.authIdentityKey, + orgId: params.orgId, + rootSessionId: params.rootSessionId, + turnId: turnIntentId, + batchId: "failure", + events: failureEvents, + }); + const drained = await drainConversationTailOutbox({ + authIdentityKey: params.authIdentityKey, + getAccessToken: params.getAccessToken, + onPushed: () => params.onPushed?.(), + }); + const unresolved = new Set([ + ...drained.failedChunkIds, + ...drained.pendingChunkIds, + ]); + if (stagedIds.some((id) => unresolved.has(id))) { + throw new Error("Cloud did not durably publish the turn failure"); + } + } catch (publishError) { + log.warn( + `failed to publish execution error for ${params.orgId}:${params.rootSessionId}`, + publishError + ); + throw publishError; + } + throw error; } - 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(conversationEventsForPush)) + ).flat(); + let pushedAgentEventCount = 0; + let tailPublicationPending = false; if (agentTail.length > 0) { - await pushConversationEventsChunked(await params.getAccessToken(), { + // Staging is the crash-consistency boundary. If local durable storage + // fails, propagate the error so the accepted canonical queue row + // remains and restart recovery can re-read this exact native tail. + const stagedIds = await stageConversationTail({ + authIdentityKey: params.authIdentityKey, orgId: params.orgId, rootSessionId: params.rootSessionId, - turnId, + turnId: turnIntentId, + batchId: "agent", events: agentTail, }); - params.onPushed?.(); + try { + const drained = await drainConversationTailOutbox({ + authIdentityKey: params.authIdentityKey, + getAccessToken: params.getAccessToken, + onPushed: () => params.onPushed?.(), + }); + const staged = new Set(stagedIds); + pushedAgentEventCount = drained.pushedChunks + .filter((chunk) => staged.has(chunk.id)) + .reduce((total, chunk) => total + chunk.eventCount, 0); + const unresolved = new Set([ + ...drained.failedChunkIds, + ...drained.pendingChunkIds, + ]); + tailPublicationPending = stagedIds.some((id) => unresolved.has(id)); + if (stagedIds.some((id) => drained.failedChunkIds.includes(id))) { + throw new Error( + "Cloud permanently rejected a staged provider tail; keeping its accepted queue row for visible recovery" + ); + } + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Cloud permanently rejected") + ) { + throw error; + } + // The provider turn and outbox row are both durable. Keep the episode + // overlaid and let ordinary outbox drain retry after connectivity or + // auth recovers; no provider replay is needed. + tailPublicationPending = true; + log.warn( + `network drain deferred for ${agentTail.length} durably staged tail event(s) for ${params.orgId}:${params.rootSessionId}`, + error + ); + } } log.info( - `pushed conversation turn ${turnId}: 1 + ${agentTail.length} event(s) to ${key}` + `continued ${params.orgId}:${params.rootSessionId} in ${result.sessionId}; ` + + `pushed 1 + ${pushedAgentEventCount} event(s)` + + (tailPublicationPending ? "; tail pending durable retry" : "") ); - return { runnerSessionId, pushedEventCount: 1 + agentTail.length }; + return { + runnerSessionId: result.sessionId, + pushedEventCount: 1 + pushedAgentEventCount, + pushedAgentEventCount, + tailPublicationPending, + terminalStatus: result.terminalStatus, + turnIntentId, + }; } diff --git a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts index 39a4298aef..fe6b6aeb3c 100644 --- a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts +++ b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts @@ -1,10 +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 { CloudSessionComment } from "../org2CloudCommentsClient"; import type { GroupedCommentThreads } from "../org2CloudSessionCommentsAtom.types"; -import { CONVERSATION_SENDER_ARG } from "./continuationEvents"; import { SESSION_DISCUSSION_EVENT, buildDiscussionEvents, @@ -89,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"); @@ -136,6 +137,38 @@ describe("buildDiscussionEvents", () => { userId: "user-1", displayName: "Alice", }); + expect(projectNativeConversationItems(rows)).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: '{"userId":"user-1","displayName":"Alice"}\nlooks good', + }), + ]); + }); + + 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: "network unavailable", + mentionedUserIds: ["user-2"], + }), + replies: [], + }, + ], + }), + "session-1", + new Map() + ); + + expect(rows[0].displayStatus).toBe("failed"); + 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 827fdc89a5..a33b789e59 100644 --- a/src/features/Org2Cloud/SessionConversation/discussionEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/discussionEvents.ts @@ -1,3 +1,7 @@ +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"; @@ -5,10 +9,6 @@ import type { CommentThread, GroupedCommentThreads, } from "../org2CloudSessionCommentsAtom.types"; -import { - CONVERSATION_SENDER_ARG, - type ConversationSenderStamp, -} from "./continuationEvents"; export const SESSION_DISCUSSION_EVENT = "session_discussion"; @@ -75,37 +75,53 @@ function commentToDiscussionEvent( sessionId, createdAt: comment.createdAt, displayText: body, - displayStatus: "completed", + displayStatus: + comment.clientDeliveryStatus === "pending" + ? "pending" + : 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/queuedConversationExecutor.ts b/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts new file mode 100644 index 0000000000..6545503728 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts @@ -0,0 +1,268 @@ +import type { Store } from "jotai/vanilla/store"; + +import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + CONVERSATION_TURN_ID_ARG, + localConversationRootForSession, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import type { + QueuedConversationDispatchCallbacks, + QueuedConversationExecutionResult, + QueuedConversationMessage, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { + commitRefreshedAuth, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { getCloudCapabilitiesConfirmed } from "@src/features/Org2Cloud/org2CloudCapabilities"; +import { ensureFreshSession } from "@src/features/Org2Cloud/org2CloudClient"; +import { listSessionComments } from "@src/features/Org2Cloud/org2CloudCommentsClient"; +import { + conversationEventsForPush, + pushConversationEventsChunked, +} from "@src/features/Org2Cloud/org2CloudConversationEventsClient"; +import { groupCommentThreads } from "@src/features/Org2Cloud/org2CloudSessionCommentsAtom"; +import type { Session } from "@src/store/session"; +import { sessionsAtom } from "@src/store/session"; + +import { + activeConversationRunnerKey, + activeConversationRunnersAtom, + removeConversationRunnerByTurn, + upsertConversationRunner, +} from "./activeConversationRunnersAtom"; +import { + bumpConversationPlaneSignal, + conversationPlaneAtom, + conversationPlaneKey, + conversationPlaneSignalAtom, + refreshConversationPlaneEntry, +} from "./conversationPlaneAtom"; +import { mergePlaneIntoTranscript } from "./conversationTimeline"; +import { + buildPushedUserEvent, + runConversationTurn, +} from "./conversationTurnRunner"; +import { + buildDiscussionEvents, + mergeConversationEvents, +} from "./discussionEvents"; + +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; +} { + const [orgId, ...extraScope] = root.authorityScope; + if (root.authority !== "org2-cloud" || !orgId || extraScope.length > 0) { + throw new Error("invalid Cloud conversation identity"); + } + return { orgId, rootSessionId: root.conversationId }; +} + +/** Cloud authority adapter for the application's existing durable queue. */ +export async function dispatchQueuedCloudConversation( + store: Store, + message: QueuedConversationMessage, + root: ConversationRootLocator, + callbacks: QueuedConversationDispatchCallbacks +): Promise { + const descriptor = message.conversationDispatch; + if (!descriptor) throw new Error("canonical conversation target is missing"); + const { orgId, rootSessionId } = cloudLocator(root); + + const getAccessToken = async (): Promise => { + const current = store.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( + (update) => store.set(org2CloudAuthAtom, update), + current, + fresh + ); + return fresh.accessToken; + }; + + const auth = store.get(org2CloudAuthAtom); + if (!auth) throw new Error("cloud sign-in required"); + const authIdentityKey = org2CloudAuthIdentityKey(auth); + if (descriptor.dispatchIdentityKey !== authIdentityKey) { + throw new Error( + descriptor.dispatchIdentityKey + ? "This queued turn belongs to a different Cloud account; switch back to its author account to send it" + : "This restored Cloud turn predates sender binding; edit and send it again under the current account" + ); + } + const capabilityProbe = await getCloudCapabilitiesConfirmed( + await getAccessToken() + ); + if ( + !capabilityProbe.confirmed || + !capabilityProbe.capabilities.conversationEventsIdempotency + ) { + throw new Error( + "Cloud conversation idempotency is unavailable; refusing an unsafe retry" + ); + } + const runnerRegistryKey = activeConversationRunnerKey(authIdentityKey, root); + 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), + }); + if (plane.state !== "ready") { + throw new Error("canonical conversation plane is unavailable"); + } + + const sourceSession = sessionById(store, message.sessionId); + const rootLocal = sessionById(store, rootSessionId) ?? sourceSession ?? null; + const rootEvents = rootLocal + ? (await loadCanonicalConversationEvents(rootLocal.session_id)).events + : []; + const planeTimeline = mergePlaneIntoTranscript( + rootEvents, + plane.events, + message.sessionId, + { status: "known", userId: auth.userId } + ); + const listing = await listSessionComments( + await getAccessToken(), + orgId, + rootSessionId + ); + const sourceIds = new Set(planeTimeline.map((event) => event.id)); + const grouped = groupCommentThreads(listing.comments, sourceIds); + const bySourceId = new Map( + planeTimeline.map((event) => [event.id, event] as const) + ); + const timeline = mergeConversationEvents( + planeTimeline, + buildDiscussionEvents(grouped, message.sessionId, bySourceId) + ); + // A crash after the user-event push but before native-runner persistence leaves + // this same durable queue row retryable. Its user event is now in the plane, + // but it must not be materialized into the prefix AND sent again. Exclude the + // current turn from the canonical prefix on every attempt; the native send + // remains the one user-message append for this provider episode. + const executionTimeline = timeline.filter( + (event) => event.args?.[CONVERSATION_TURN_ID_ARG] !== message.turnIntentId + ); + const executionRoot = + sourceSession && + !sourceSession.importedFrom && + sourceSession.session_id === rootSessionId + ? (localConversationRootForSession( + sourceSession.session_id, + sourceSession.cliAgentType, + sourceSession.agentDefinitionId + ) ?? undefined) + : undefined; + + await pushConversationEventsChunked(await getAccessToken(), { + orgId, + rootSessionId, + turnId: message.turnIntentId, + events: await conversationEventsForPush( + buildPushedUserEvent( + message.displayContent, + message.content, + message.imageDataUrls, + new Date().toISOString(), + message.turnIntentId + ) + ), + }); + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + + let accepted = false; + const accept = async (sessionId: string) => { + if (accepted) return; + accepted = true; + await callbacks.onAccepted(sessionId); + }; + let result: Awaited> | null = null; + try { + result = await runConversationTurn({ + getAccessToken, + authIdentityKey, + orgId, + rootSessionId, + conversationTitle: + sourceSession?.name ?? rootLocal?.name ?? "Conversation", + displayText: message.displayContent, + agentContent: message.content, + imageDataUrls: message.imageDataUrls, + timeline: executionTimeline, + target: descriptor.target, + turnIntentId: message.turnIntentId, + ...(message.status !== "queued" && message.runnerSessionId + ? { + recovery: { + runnerSessionId: message.runnerSessionId, + eventStartIndex: message.runnerEventStartIndex, + }, + } + : {}), + ...(executionRoot ? { executionRoot } : {}), + onRunnerReady: async (runnerSessionId, turnId, eventStartIndex) => { + store.set(activeConversationRunnersAtom, (current) => + upsertConversationRunner(current, runnerRegistryKey, { + runnerSessionId, + turnId, + eventStartIndex, + }) + ); + await callbacks.onRunnerReady?.(runnerSessionId, eventStartIndex); + }, + onTurnAccepted: accept, + onPushed: () => + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ), + }); + if (result.tailPublicationPending) { + throw new Error( + "Provider tail is durably queued for Cloud publication; keeping the accepted turn for recovery" + ); + } + await accept(result.runnerSessionId); + return { terminalStatus: result.terminalStatus }; + } finally { + // A successful non-empty tail remains overlaid until the refreshed plane + // contains it. Empty cancel/failure tails (and thrown publication errors) + // have no plane row that could ever trigger that normal cleanup. + if ( + !result || + (result.pushedAgentEventCount === 0 && !result.tailPublicationPending) + ) { + store.set(activeConversationRunnersAtom, (current) => + removeConversationRunnerByTurn( + current, + runnerRegistryKey, + message.turnIntentId + ) + ); + } + } +} diff --git a/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts b/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts index be9f9ce718..b6d7907228 100644 --- a/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts +++ b/src/features/Org2Cloud/SessionConversation/teamChatMentions.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { buildTeamChatMentionOptions, + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatAudienceTargets, + resolveTeamChatMentionedUserIds, resolveTeamChatMentions, } from "./teamChatMentions"; @@ -16,17 +20,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 +83,63 @@ 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); + }); +}); + +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..72a725409a 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,114 @@ 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|agent|agent_org):\/\/(.+)$/ + ); + if (!match) return null; + try { + const id = decodeURIComponent(match[2]).trim(); + if (!id) return null; + if (match[1] === "member") return { kind: "member", id }; + if (match[1] === "agent") return { kind: "agent", id }; + return { kind: "agent_org", id }; + } catch { + return null; + } +} + +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) + ), + ]; + } + return audience.human.scope === "members" ? audience.human.memberIds : []; +} diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts new file mode 100644 index 0000000000..2a7676fc8e --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +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", + }, + sourceTitle: "Runtime migration", + cliAgentType: "claude_code", + agentDefinitionId: undefined, + agentDisplayName: undefined, + model: "claude-opus-5", + initialTarget: null, + workspaceRepoPath: null, + }); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts new file mode 100644 index 0000000000..7442ca5be9 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts @@ -0,0 +1,174 @@ +import { atom, useAtomValue } from "jotai"; +import { useEffect, useMemo, useState } from "react"; + +import type { ConversationSource } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { resolveForkWorkspacePath } from "@src/features/TeamCollaboration/forkWorkspaceResolution"; +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 { + type CloudOrgRemoteSessionsEntry, + org2CloudRemoteSessionsAtom, +} from "../org2CloudRemoteSessionsAtom"; +import { useCloudSessionLoadingSource } from "../useCloudSessionDownloadSurface"; + +const detachedRemoteSessionsAtom = atom< + Record +>({}); + +export function conversationSourceFromCloudReplay(params: { + importedFrom?: Session["importedFrom"]; + orgId?: string; + remoteSession?: RemoteTeammateSessionMetadata; + sessionName?: string; + workspaceRepoPath: string | null; +}): ConversationSource | undefined { + const orgId = params.importedFrom?.orgId ?? params.orgId; + const sourceSessionId = + params.importedFrom?.sourceSessionId ?? + params.remoteSession?.sourceSessionId; + if (!orgId || !sourceSessionId) return undefined; + const rootId = + params.remoteSession?.forkedFrom?.rootSessionId ?? sourceSessionId; + return { + root: { + authority: "org2-cloud", + authorityScope: [orgId], + conversationId: rootId, + }, + sourceTitle: + params.sessionName ?? params.remoteSession?.title ?? "Conversation", + 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; + 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, + sessions, + repos, +}: CloudConversationSourceInput): CloudConversationSourceResolution { + const loadingSource = useCloudSessionLoadingSource(sessionId); + const importedFrom = session?.importedFrom; + const remoteEntries = useAtomValue( + importedFrom || loadingSource + ? org2CloudRemoteSessionsAtom + : detachedRemoteSessionsAtom + ); + const importedRemoteRow = useMemo(() => { + if (importedFrom) { + return ( + remoteEntries[importedFrom.orgId]?.rows.find( + (candidate) => + candidate.sourceSessionId === importedFrom.sourceSessionId + ) ?? loadingSource + ); + } + return loadingSource; + }, [importedFrom, loadingSource, remoteEntries]); + const importedOrgId = 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 }); + } + }); + 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( + () => + conversationSourceFromCloudReplay({ + importedFrom, + orgId: importedOrgId, + remoteSession: importedRemoteRow, + sessionName: session?.name, + // Imported rows may carry the owner's absolute path. Only the shared + // repo-scope resolver may produce a workspace for this device. + workspaceRepoPath: importedWorkspacePath, + }), + [ + importedFrom, + importedOrgId, + importedRemoteRow, + importedWorkspacePath, + session?.name, + ] + ); + + return useMemo( + () => ({ source, workspacePending }), + [source, workspacePending] + ); +} diff --git a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts index ec739084cd..f2e5862074 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts @@ -2,15 +2,25 @@ import { useAtom } from "jotai"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import type { SubmitOverrideInput } from "@src/engines/ChatPanel/hooks/useInputArea/types"; -import { resolveMessageAudience } from "@src/features/TeamCollaboration/messageAudienceRouting"; +import { + type SubmitOverrideInput, + SubmitValidationError, +} from "@src/engines/ChatPanel/hooks/useInputArea/types"; import { useSessionCommentsContext } from "../SessionComments/SessionCommentsContext"; +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, +} from "../org2CloudCommentsClient"; import { type ConversationComposerMode, conversationComposerModeAtomFamily, } from "./conversationComposerMode"; -import { resolveTeamChatMentions } from "./teamChatMentions"; +import { + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatMentionedUserIds, +} from "./teamChatMentions"; export function useConversationComposerMode( sessionId: string | null @@ -31,8 +41,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, @@ -48,24 +57,32 @@ export function useConversationSubmitOverride( return fallback ? fallback(input) : false; } if (input.imageDataUrls?.length) { - throw new Error(t("conversation.imagesUnsupported")); + throw new SubmitValidationError(t("conversation.imagesUnsupported")); } 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("conversation.messageTooLong", { + max: CLOUD_COMMENT_MAX_BODY_LENGTH, + defaultValue: `Team Chat messages must be ${CLOUD_COMMENT_MAX_BODY_LENGTH} characters or fewer`, }) - ) + ); + } + 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` + ); + } await comments.addComment({ body, - ...(audience.human.scope === "members" - ? { mentionedUserIds: audience.human.memberIds } - : {}), + ...(mentionedUserIds.length > 0 ? { mentionedUserIds } : {}), }); return true; }, 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.ts b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts index c89e3a18d6..63c581f0c5 100644 --- a/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts +++ b/src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded.ts @@ -1,23 +1,20 @@ 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 { commitRefreshedAuth, org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import { + commitRefreshedAuth, + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "../org2CloudAuthAtom"; import { ensureFreshSession } from "../org2CloudClient"; import type { ConversationFamilyMember } from "./continuationEvents"; const log = createLogger("ConversationFamilyLoader"); -/** - * Keyed by org, session AND replay position — a member whose owner pushes - * more events gets a fresh (incremental) import, so open conversations keep - * following the family without re-downloading unchanged transcripts. - */ -const attemptedImports = new Set(); - /** * Silently import family members the viewer has no local copy of, so their * segments stream into the conversation like any other message — no @@ -32,9 +29,52 @@ export function useEnsureFamilyLoaded( ): void { const auth = useAtomValue(org2CloudAuthAtom); const setAuth = useSetAtom(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const failedImportRef = useRef(false); + const [foregroundRetryVersion, setForegroundRetryVersion] = useState(0); + + // A background failure must remain retryable, but retry only at an + // explicit foreground boundary. `importRemoteSession` owns the actual + // per-source serialization and durable cursor/no-op decision. + useEffect(() => { + if ( + !family || + !authIdentityKey || + typeof window === "undefined" || + typeof document === "undefined" + ) { + return undefined; + } + let wasAway = false; + const markAway = () => { + wasAway = true; + }; + const retryFailedImports = () => { + if ( + document.visibilityState === "hidden" || + (typeof document.hasFocus === "function" && !document.hasFocus()) + ) { + markAway(); + return; + } + if (!wasAway || !failedImportRef.current) return; + wasAway = false; + failedImportRef.current = false; + setForegroundRetryVersion((version) => version + 1); + }; + window.addEventListener("blur", markAway); + window.addEventListener("focus", retryFailedImports); + document.addEventListener("visibilitychange", retryFailedImports); + return () => { + window.removeEventListener("blur", markAway); + window.removeEventListener("focus", retryFailedImports); + document.removeEventListener("visibilitychange", retryFailedImports); + }; + }, [authIdentityKey, family]); useEffect(() => { - if (!family || !auth) return; + const requestAuth = auth; + if (!family || !requestAuth || !authIdentityKey) return; for (const member of family) { const bareSessionId = member.bareSessionId; if ( @@ -50,23 +90,23 @@ export function useEnsureFamilyLoaded( continue; } if (row.id === `local-${bareSessionId}`) continue; - const key = `${row.orgId}:${bareSessionId}:${row.eventsEpoch}:${row.eventsCount}`; - if (attemptedImports.has(key)) continue; - attemptedImports.add(key); void (async () => { try { - const fresh = await ensureFreshSession(auth); - if (!fresh) return; - commitRefreshedAuth(setAuth, auth, fresh); + const fresh = await ensureFreshSession(requestAuth); + if (!fresh) { + failedImportRef.current = true; + return; + } + if (org2CloudAuthIdentityKey(fresh) !== authIdentityKey) return; + commitRefreshedAuth(setAuth, requestAuth, fresh); await importRemoteSession({ client: buildCloudSessionFetchClient(fresh.accessToken), orgId: row.orgId, remoteSession: row, - sourceEndpointUrl: auth.supabaseUrl, + sourceEndpointUrl: requestAuth.supabaseUrl, }); } catch (error) { - // Leave the attempt marker: a broken member should not retry in a - // loop on every render. The next push (new epoch/count) re-keys. + failedImportRef.current = true; log.warn( `background family import failed for ${bareSessionId}`, error @@ -74,5 +114,13 @@ export function useEnsureFamilyLoaded( } })(); } - }, [family, loadedBareSessionIds, anchorBareSessionId, auth, setAuth]); + }, [ + family, + loadedBareSessionIds, + anchorBareSessionId, + auth, + authIdentityKey, + foregroundRetryVersion, + 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..a70e548203 100644 --- a/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts +++ b/src/features/Org2Cloud/memberRuntime/memberRuntimePushScheduler.test.ts @@ -105,6 +105,7 @@ const CONFIRMED_MEMBER_RUNTIME_TRUE: CloudCapabilitiesProbeResult = { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }, confirmed: true, }; @@ -364,6 +365,7 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }, confirmed: true, } satisfies CloudCapabilitiesProbeResult); @@ -399,6 +401,7 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }, confirmed: false, } satisfies CloudCapabilitiesProbeResult); @@ -447,6 +450,7 @@ describe("capability blackout: confirmed vs. unconfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }, confirmed: false, } satisfies CloudCapabilitiesProbeResult) diff --git a/src/features/Org2Cloud/org2CloudCapabilities.test.ts b/src/features/Org2Cloud/org2CloudCapabilities.test.ts index 55b82b6c77..5d8165e92d 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.test.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.test.ts @@ -36,6 +36,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, @@ -49,6 +50,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -70,6 +72,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -84,6 +87,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: true, orgChannelMessagesIdempotency: true, conversationEvents: false, + conversationEventsIdempotency: false, }); const capabilities = await getCloudCapabilities("jwt-1"); expect(capabilities.orgChannelMessagesIdempotency).toBe(true); @@ -107,6 +111,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -129,6 +134,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -145,6 +151,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: true, @@ -158,6 +165,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -175,6 +183,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); rawMock.mockResolvedValueOnce({ broadcastSignals: true }); expect(await getCloudCapabilities("jwt-1")).toEqual({ @@ -189,6 +198,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(rawMock).toHaveBeenCalledTimes(2); }); @@ -211,6 +221,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(await getCloudCapabilities("jwt-1")).toEqual({ broadcastSignals: false, @@ -224,6 +235,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -250,6 +262,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(await second).toEqual({ broadcastSignals: true, @@ -263,6 +276,7 @@ describe("getCloudCapabilities", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); expect(rawMock).toHaveBeenCalledTimes(1); }); @@ -285,6 +299,7 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -301,6 +316,7 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); const result = await getCloudCapabilitiesConfirmed("jwt-1"); expect(result.confirmed).toBe(true); @@ -323,6 +339,7 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -357,6 +374,7 @@ describe("getCloudCapabilitiesConfirmed", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: 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..9ca1447fa5 100644 --- a/src/features/Org2Cloud/org2CloudCapabilities.ts +++ b/src/features/Org2Cloud/org2CloudCapabilities.ts @@ -28,6 +28,7 @@ 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), }); export interface CloudCapabilities { @@ -49,6 +50,8 @@ 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; } const LEGACY_CAPABILITIES: CloudCapabilities = { @@ -63,6 +66,7 @@ const LEGACY_CAPABILITIES: CloudCapabilities = { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }; export interface CloudCapabilitiesProbeResult { @@ -116,6 +120,8 @@ async function probeCloudCapabilities( orgChannelMessagesIdempotency: parsed.data.orgChannelMessagesIdempotency ?? false, conversationEvents: parsed.data.conversationEvents ?? false, + conversationEventsIdempotency: + parsed.data.conversationEventsIdempotency ?? 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 39c5a0b347..a0a6ea4516 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -32,6 +32,8 @@ 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 @@ -194,7 +196,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, @@ -288,14 +302,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. @@ -322,7 +385,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) { @@ -330,9 +393,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, @@ -345,36 +409,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; } diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts new file mode 100644 index 0000000000..e8505ed802 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CLOUD_CONVERSATION_MAX_EVENT_BYTES, + Org2CloudConversationError, + boundConversationEventForPush, +} 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" + ); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts index 2e377f075a..d6c56ce5f2 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 @@ -22,12 +22,15 @@ import { createLogger } from "@src/hooks/logger"; import { ORG2_CLOUD_POSTGREST_SCHEMA, getCloudEndpoint } from "./config"; import { fetchWithTransportRetry } from "./org2CloudFetchRetry"; +import { sha256Hex } from "./org2CloudOrgManagement"; const log = createLogger("Org2CloudConversationEvents"); /** 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; +const CONVERSATION_EVENT_CHUNK_FUNCTION = "conversation_event_chunk"; export const ORG2_CONVERSATION_ERROR_CODES = [ "ORG2_VALIDATION", @@ -241,23 +244,191 @@ 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 { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function chunkMetadataOf( + event: SessionEvent +): ConversationEventChunkMetadata | null { + if (event.functionName !== CONVERSATION_EVENT_CHUNK_FUNCTION) return null; + const value = event.args?.conversationEventChunk; + if (!value || typeof value !== "object") return null; + const metadata = value as Partial; + if ( + metadata.version !== 1 || + typeof metadata.sourceEventId !== "string" || + !Number.isSafeInteger(metadata.chunkIndex) || + !Number.isSafeInteger(metadata.chunkCount) || + !Number.isSafeInteger(metadata.byteLength) || + typeof metadata.sha256 !== "string" + ) { + return null; + } + return 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); + 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(); + for (const row of rows) { + const metadata = chunkMetadataOf(row.event); + if (!metadata) { + ordinary.push(row); + continue; + } + const key = `${row.turnId}\u001f${metadata.sha256}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + for (const group of groups.values()) { + const firstMetadata = chunkMetadataOf(group[0].event)!; + if (group.length !== firstMetadata.chunkCount) { + throw new Org2CloudConversationError( + `incomplete conversation event ${firstMetadata.sourceEventId}: ${group.length}/${firstMetadata.chunkCount} chunks` + ); + } + const ordered = [...group].sort( + (left, right) => + chunkMetadataOf(left.event)!.chunkIndex - + chunkMetadataOf(right.event)!.chunkIndex + ); + 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" + ); + } + return base64ToBytes(data); + }); + 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` + ); + } + const event = JSON.parse(serialized) as SessionEvent; + if (event.id !== firstMetadata.sourceEventId) { + throw new Org2CloudConversationError( + "conversation event chunk source identity mismatch" + ); + } + const last = ordered[ordered.length - 1]; + ordinary.push({ ...last, id: event.id, event }); + } + return ordinary.sort((left, right) => left.seq - right.seq); } diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.ts index c310a021b1..ea1184877f 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 { @@ -500,7 +501,9 @@ export function useSessionComments( throw new Error("no cloud comment target"); } const optimistic: CloudSessionComment = { - id: `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, + id: + input.optimisticId ?? + `${OPTIMISTIC_SESSION_COMMENT_ID_PREFIX}${crypto.randomUUID()}`, eventId: input.eventId, parentId: input.parentId, authorUserId: authRef.current?.userId ?? "", @@ -509,40 +512,67 @@ export function useSessionComments( createdAt: new Date().toISOString(), kind: "user", mentionedUserIds: input.mentionedUserIds ?? [], + clientDeliveryStatus: "pending", + ...(input.replaceExisting + ? { + clientRetryExpectedBody: input.expectedBody, + clientRetryExpectedMentionedUserIds: + input.expectedMentionedUserIds ?? [], + } + : {}), }; patchEntry(key, (comments) => insertComment(comments, optimistic)); - 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; - // 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); - return comment; - } catch (error) { - patchEntry(key, (comments) => - comments.filter((candidate) => candidate.id !== optimistic.id) - ); - // Rejection is observed by useSubmitMessage, which restores the - // exact editor snapshot (including structured mention pills). - throw error; - } + 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, + 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) => + 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 + ); + }, + }); + // Failed rows remain visible with structured mentions; retry reuses the + // same optimistic id rather than restoring the old composer draft. + return delivered.comment; }, [ orgId, diff --git a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts index 6252dc2e53..e5e1b7ba03 100644 --- a/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts +++ b/src/features/Org2Cloud/org2CloudSessionCommentsAtom.types.ts @@ -63,6 +63,18 @@ 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[]; } export interface UseSessionCommentsResult { @@ -78,8 +90,8 @@ export interface UseSessionCommentsResult { * refetch. No RPC fires; the next TTL refetch reconciles regardless. */ insertLocalComment: (comment: CloudSessionComment) => void; - /** Resolves with the created comment (already inserted); rejects on - * failure so composers can keep the draft (design §4 non-goals). */ + /** Resolves with the created comment (already inserted). On failure the + * optimistic row remains visible as failed and the promise rejects. */ addComment: (input: AddCommentInput) => Promise; editComment: (commentId: string, body: string) => Promise; deleteComment: (commentId: string) => Promise; diff --git a/src/features/Org2Cloud/org2CloudSyncClient.test.ts b/src/features/Org2Cloud/org2CloudSyncClient.test.ts index 1acb3c7786..ae4760ebee 100644 --- a/src/features/Org2Cloud/org2CloudSyncClient.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncClient.test.ts @@ -70,6 +70,7 @@ beforeEach(() => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -276,6 +277,7 @@ describe("storage segment offload (0006)", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); }); @@ -364,6 +366,7 @@ describe("storage segment offload (0006)", () => { orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }); await appendSessionEvents("jwt-1", appendInput([makeEvent("f1")], null)); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/src/features/Org2Cloud/sessionCommentTarget.test.ts b/src/features/Org2Cloud/sessionCommentTarget.test.ts index db494d54c2..f69fcd2e0f 100644 --- a/src/features/Org2Cloud/sessionCommentTarget.test.ts +++ b/src/features/Org2Cloud/sessionCommentTarget.test.ts @@ -5,6 +5,7 @@ import { cloudOrgToken } from "@src/features/TeamCollaboration/sessionOrgTagsAto import { rerootSessionCommentTarget, resolveSessionCommentTarget, + sessionCommentTargetForConversationRoot, } from "./sessionCommentTarget"; const CLOUD_ORGS = [ @@ -21,6 +22,28 @@ const IMPORTED = { count: 10, }; +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( diff --git a/src/features/Org2Cloud/sessionCommentTarget.ts b/src/features/Org2Cloud/sessionCommentTarget.ts index cd6cb1df1f..99364c16f8 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 { @@ -37,6 +38,23 @@ export interface SessionCommentTarget { sessionId: string; } +/** 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[0] + ) { + return null; + } + return { + orgId: root.authorityScope[0], + sessionId: root.conversationId, + }; +} + type CommentTargetSession = { session_id: string; /** Canonical launch ownership (`cloud:` for managed-cloud runs). */ @@ -170,7 +188,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); @@ -194,14 +213,16 @@ 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; @@ -213,6 +234,7 @@ export function useSessionCommentTarget( orgRepoScopes, pushedOrgIds, remoteEntries, + canonicalTarget, ]); } 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 1cc87b04f8..5c102036b5 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts @@ -8,6 +8,7 @@ import { activeSessionIdAtom } from "@src/store/session/viewAtom"; import { chatPanelSelectedCloudOrgAtom } from "@src/store/ui/chatPanelAtom"; import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; +import { conversationPlaneSignalAtom } from "./SessionConversation/conversationPlaneAtom"; import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; import { type Org2CloudOrg, @@ -378,4 +379,22 @@ 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); + }); }); diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index 889eecfba0..5daf110409 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -721,6 +721,7 @@ export function useOrg2CloudRealtime(): void { bumpOrgCommentsSignal(orgId); bumpChannelsVersion(orgId); bumpChannelMessagesVersion(orgId); + bumpConversationPlaneVersion(orgId); return; } orgFullRecoveryAtRef.current.set(orgId, Date.now()); @@ -746,6 +747,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 +756,7 @@ export function useOrg2CloudRealtime(): void { bumpActiveSessionCommentsSignal, bumpChannelsVersion, bumpChannelMessagesVersion, + bumpConversationPlaneVersion, refreshEntitlementForOrg, ] ); 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. */ From 9fe5e91713dca29a6a062d56fed286e54b6171ba Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:31:49 +0800 Subject: [PATCH 2/5] feat(chat): route composer delivery through canonical conversations Reuse the normal composer surface for runtime switching, target binding, optimistic pending/sent/failed messages, queued follow-ups, sender identity, retry/edit actions, and canonical execution overlays. --- src/components/ModelSelectorPill/index.tsx | 10 +- .../ChatHistory/ChatHistory.types.ts | 7 + .../__tests__/useEditUserMessage.test.ts | 56 +- .../hooks/useChatHistoryItemActions.ts | 5 +- .../ChatHistory/hooks/useEditUserMessage.ts | 67 ++- src/engines/ChatPanel/ChatHistory/index.tsx | 130 ++-- .../ConversationSenderMetadataContext.tsx | 83 +++ .../ChatItems/ParentAgentSenderContext.tsx | 4 +- .../SharedConversationSenderContext.tsx | 16 - .../ChatPanel/ChatItems/UserChatItem.tsx | 311 +++++++--- .../ChatItems/__tests__/UserChatItem.test.ts | 221 ++++++- src/engines/ChatPanel/ChatView.tsx | 201 +++---- .../ChatPanel/ChatViewHistorySurface.tsx | 134 ++--- src/engines/ChatPanel/ChatViewLiveRegion.tsx | 37 +- .../ChatPanel/ChatViewPostHistoryOverlays.tsx | 87 +-- src/engines/ChatPanel/ChatViewTypes.ts | 6 +- .../ConversationExecutionBindingContext.ts | 18 + .../ChatPanel/ConversationStreamProvider.tsx | 142 +++-- .../ConversationRuntimePill.test.tsx | 110 ++++ .../components/ConversationRuntimePill.tsx | 111 ++++ .../InputArea/components/ModelPill.tsx | 110 +++- .../components/QueuedMessageItem.tsx | 16 +- .../components/QueuedMessages.test.ts | 23 +- .../InputArea/components/QueuedMessages.tsx | 10 +- .../InputArea/hooks/useComposerSections.ts | 19 +- src/engines/ChatPanel/InputArea/index.tsx | 17 +- src/engines/ChatPanel/SideChat/index.tsx | 27 +- .../chatViewComposerVisibility.test.ts | 8 +- .../ChatPanel/chatViewComposerVisibility.ts | 8 +- .../conversationTargetSelection.test.ts | 488 +++++++++++++++ .../ChatPanel/conversationTargetSelection.ts | 404 +++++++++++++ .../ChatPanel/externalHistoryFork.test.ts | 223 ------- src/engines/ChatPanel/externalHistoryFork.ts | 166 ------ .../useConversationSubmitRouter.test.ts | 34 ++ .../useConversationSubmitRouter.ts | 119 ++++ .../importedSessionSubmitReadiness.test.ts | 92 +++ .../hooks/importedSessionSubmitReadiness.ts | 18 + .../hooks/useChatViewAgentOrgSurface.tsx | 24 +- .../hooks/useChatViewMessageQueue.test.ts | 74 +++ .../hooks/useChatViewMessageQueue.ts | 50 +- .../useConversationTargetBinding.test.ts | 131 ++++ .../hooks/useConversationTargetBinding.ts | 449 ++++++++++++++ .../hooks/useImportedSessionSubmitOverride.ts | 558 ----------------- .../__tests__/inputAreaEventSelectors.test.ts | 59 +- .../__tests__/useSubmitMessage.test.ts | 4 +- .../ChatPanel/hooks/useInputArea/index.ts | 25 +- .../useInputArea/inputAreaEventSelectors.ts | 25 + .../ChatPanel/hooks/useInputArea/types.ts | 16 +- .../hooks/useInputArea/useAtMention.ts | 8 +- .../hooks/useInputArea/useSubmitMessage.ts | 21 +- .../useWorkspaceChat/useMessageDispatch.ts | 146 ++--- .../useWorkspaceChat/useSessionActions.ts | 47 +- .../useUserIntentSubmit.intervention.test.ts | 44 +- .../useWorkspaceChat/useUserIntentSubmit.ts | 131 ++-- .../useWorkspaceChat/useWorkspaceChat.test.ts | 35 ++ .../useWorkspaceChat/useWorkspaceChat.ts | 36 +- .../CloudOrgSyncSection.test.ts | 1 + .../control/messageQueueAdmission.ts | 41 ++ .../useQueueDispatch.intervention.test.ts | 390 +++++++++++- .../hooks/session/messageQueuePersistence.ts | 27 +- .../hooks/session/useQueueDispatch.ts | 563 +++++++++++++----- .../optimisticOutgoingDelivery.test.ts | 45 ++ .../services/optimisticOutgoingDelivery.ts | 49 ++ .../services/userIntentDispatch.test.ts | 363 +++++++++++ .../services/userIntentDispatch.ts | 374 ++++++++++++ src/hooks/models/useAgentCompatibility.ts | 4 +- src/hooks/models/useModelAccountLookup.ts | 6 +- src/modules/SessionWindow/index.tsx | 3 +- .../DispatchCategoryDropdown.tsx | 21 +- .../cliAgentCapability.test.ts | 29 + .../cliAgentCapability.ts | 15 + .../DispatchCategoryPalette/index.tsx | 22 +- .../palettes/DispatchCategoryPalette/types.ts | 8 + .../useDispatchCategoryOptions.tsx | 14 +- .../palettes/UnifiedModelPalette/index.tsx | 4 +- .../palettes/UnifiedModelPalette/types.ts | 5 + .../cloudSessionsSection.tsx | 5 - .../__tests__/sessionTabPlacementAtom.test.ts | 33 + src/store/session/agentRegistryAtom.ts | 10 + .../session/sessionAtom/mergeSessions.ts | 2 +- src/store/session/sessionTabPlacementAtom.ts | 43 ++ .../ui/__tests__/messageQueueAtom.test.ts | 18 +- src/store/ui/messageQueueAtom.ts | 146 +++-- src/store/ui/messageQueueRepository.ts | 89 ++- src/util/session/sessionDispatch.ts | 15 + src/util/session/sessionDisplayMetadata.ts | 6 +- 86 files changed, 5679 insertions(+), 2093 deletions(-) create mode 100644 src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx delete mode 100644 src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx create mode 100644 src/engines/ChatPanel/ConversationExecutionBindingContext.ts create mode 100644 src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx create mode 100644 src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx 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/control/messageQueueAdmission.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/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.test.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/cliAgentCapability.ts diff --git a/src/components/ModelSelectorPill/index.tsx b/src/components/ModelSelectorPill/index.tsx index e379042f7b..4ace54ee19 100644 --- a/src/components/ModelSelectorPill/index.tsx +++ b/src/components/ModelSelectorPill/index.tsx @@ -51,6 +51,8 @@ interface ModelSelectorPillProps { /** When false (browsing a historical session), skip variant resolution * so the pill shows the session's original model, not a remapped variant. */ isActiveSession?: boolean; + /** Prevent opening a picker while its execution inventory is unresolved. */ + disabled?: boolean; } const ModelSelectorPill = forwardRef( @@ -67,6 +69,7 @@ const ModelSelectorPill = forwardRef( ariaLabel, iconSize = PILL_SM_ICON_SIZE, isActiveSession = false, + disabled = false, }, ref ) => { @@ -158,14 +161,15 @@ const ModelSelectorPill = forwardRef( tooltipFramedWide: true, ariaLabel: ariaLabel ?? defaultLabel, active, - danger: !hasModelSelection, + danger: !disabled && !hasModelSelection, + disabled, onClick, dataTestId: dataTestId, buttonRef: modelSegmentRef, maxLabelWidth: 220, }; - if (!effortEditable || !effortModelId) { + if (disabled || !effortEditable || !effortModelId) { return [modelSegment]; } @@ -223,6 +227,7 @@ const ModelSelectorPill = forwardRef( ariaLabel, dataTestId, defaultLabel, + disabled, displayParts.label, displayParts.rawValue, displayParts.thinking, @@ -249,6 +254,7 @@ const ModelSelectorPill = forwardRef( ? variantOptions.parseSelection(effortModelId) : undefined; if ( + !disabled && effortEditable && effortModelId && variant && diff --git a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts index 4362eb4798..15a25f28d2 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts @@ -91,6 +91,13 @@ 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; /** * 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/hooks/__tests__/useEditUserMessage.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts index 2d10781257..dfd05d9eb2 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts @@ -15,9 +15,18 @@ import { import type { OptimizedChatItem } from "../../chatItemPipeline/types"; import { useEditUserMessage } from "../useEditUserMessage"; -const { submitUserIntentSpy, storeSessionId } = vi.hoisted(() => ({ +const { + checkSnapshotChangesSpy, + removeByIdPrefixSpy, + submitUserIntentSpy, + storeSessionId, + truncateBeforeIdSpy, +} = vi.hoisted(() => ({ + checkSnapshotChangesSpy: vi.fn(async () => false), + removeByIdPrefixSpy: vi.fn(async () => 1), submitUserIntentSpy: vi.fn(async (..._args: unknown[]) => undefined), storeSessionId: { current: "osagent-session-1" }, + truncateBeforeIdSpy: vi.fn(async () => undefined), })); vi.mock("jotai", async (importOriginal) => ({ @@ -33,7 +42,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@src/api/tauri/agent", () => ({ - checkSnapshotChanges: vi.fn(async () => false), + checkSnapshotChanges: checkSnapshotChangesSpy, truncateAfterMessage: vi.fn(async () => undefined), })); @@ -67,7 +76,8 @@ vi.mock("@src/engines/SessionCore/core/atoms", () => ({ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { - truncateBeforeId: vi.fn(async () => undefined), + removeByIdPrefix: removeByIdPrefixSpy, + truncateBeforeId: truncateBeforeIdSpy, evictSession: vi.fn(async () => undefined), }, })); @@ -143,7 +153,10 @@ describe("useEditUserMessage resend projection", () => { }); beforeEach(() => { + checkSnapshotChangesSpy.mockClear(); + removeByIdPrefixSpy.mockClear(); submitUserIntentSpy.mockClear(); + truncateBeforeIdSpy.mockClear(); storeSessionId.current = "osagent-session-1"; container = document.createElement("div"); document.body.appendChild(container); @@ -215,4 +228,41 @@ 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(); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts index fdf4f1697d..780d37d326 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,7 @@ interface UseChatHistoryItemActionsOptions { groupHeaders: (OptimizedChatItem | null)[]; handleIgnoreQuestionRef: UseChatHistoryStateReturn["handleIgnoreQuestionRef"]; handleReplyQuestionRef: UseChatHistoryStateReturn["handleReplyQuestionRef"]; + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"]; } /** Stabilizes history mutation callbacks passed into virtualized row renderers. */ @@ -18,8 +20,9 @@ export function useChatHistoryItemActions({ groupHeaders, handleIgnoreQuestionRef, handleReplyQuestionRef, + onFailedUserIntentRetry, }: UseChatHistoryItemActionsOptions) { - const handleEditUserMessage = useEditUserMessage(); + const handleEditUserMessage = useEditUserMessage(onFailedUserIntentRetry); const handleRestoreCheckpoint = useRestoreCheckpoint(); const pinnedEditSubmitRef = useRef(handleEditUserMessage); useEffect(() => { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts index 75f846f9b8..9bb03831d5 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts @@ -30,7 +30,9 @@ 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 { isUserIntentSendError } from "@src/engines/SessionCore/services/userIntentDispatch"; import { deleteSession as deleteCachedSession } from "@src/engines/SessionCore/storage/cacheAdapter"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; import { clearPendingPlanApproval, @@ -44,6 +46,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 +61,9 @@ function agentMessageIdFromUserEventId(eventId: string): string | undefined { : undefined; } -export function useEditUserMessage(): ( +export function useEditUserMessage( + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"] +): ( chatItem: OptimizedChatItem, newText: string, imageDataUrls?: string[] @@ -106,6 +111,65 @@ 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 resendImages = + imageDataUrls && imageDataUrls.length > 0 ? imageDataUrls : undefined; + const projection = projectOutgoingUserMessage({ + displayText: newText, + allowCanvasInterception: + !resendImages && !isCliSession(initiatedSessionId), + }); + try { + 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 ( @@ -249,6 +313,7 @@ export function useEditUserMessage(): ( submitUserIntent, t, store, + onFailedUserIntentRetry, ] ); } diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index 5c39d2d79a..c06670ff6b 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 { @@ -48,45 +43,6 @@ export type { 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", @@ -113,25 +69,20 @@ const ChatHistory: React.FC = ({ groupChatViewActive = false, onGroupChatViewToggle, mutationActionsDisabled = false, + onFailedUserIntentRetry, 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 @@ -260,47 +211,46 @@ const ChatHistory: React.FC = ({ groupHeaders: projection.groupHeaders, handleIgnoreQuestionRef: historyState.handleIgnoreQuestionRef, handleReplyQuestionRef: historyState.handleReplyQuestionRef, + onFailedUserIntentRetry, }); return ( - - - - - + + + ); }; diff --git a/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx new file mode 100644 index 0000000000..5f27f4ae41 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx @@ -0,0 +1,83 @@ +import { + createContext, + useCallback, + useContext, + useRef, + useSyncExternalStore, +} from "react"; + +import { + CONVERSATION_VIEWER_SIGNED_OUT, + type ConversationSenderIdentity, + type ConversationSenderRelationship, + type ConversationSenderStamp, + type ConversationViewerState, + conversationSenderStampOf, + resolveConversationSenderRelationship, + resolveConversationViewerState, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +export 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; +} + +/** + * Keep the provider's first null distinct from a confirmed logout. A known + * identity wins immediately (including the synchronous atomWithStorage path), + * while a genuinely empty first paint settles to `signed_out` after mount. + */ +export function useConversationViewerState( + viewerUserId: string | null | undefined +): ConversationViewerState { + const hydrationCompleteRef = useRef(false); + const subscribe = useCallback((onStoreChange: () => void) => { + hydrationCompleteRef.current = true; + onStoreChange(); + return () => {}; + }, []); + const hydrationComplete = useSyncExternalStore( + subscribe, + () => hydrationCompleteRef.current, + () => false + ); + return resolveConversationViewerState(viewerUserId, hydrationComplete); +} + +/** 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 0e3c22d624..28ee9eb033 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, @@ -14,14 +13,22 @@ import { useTranslation } from "react-i18next"; import { CHAT_BUBBLE_TOOLBAR_BUTTON_CLASS } from "@src/components/ChatBubble"; import ClampedContent from "@src/components/ClampedContent"; 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 { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatMentionedUserIds, +} from "@src/features/Org2Cloud/SessionConversation/teamChatMentions"; +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, +} from "@src/features/Org2Cloud/org2CloudCommentsClient"; import { ClipboardCheckIcon, File01Icon, @@ -33,32 +40,21 @@ import { } from "@src/icons"; import { imageRefToRustPath } from "@src/util/file/imageRefs"; +import { useGroupChatContext } from "../ChatHistory/GroupChatView/GroupChatContext"; import UserMessageContent, { type UserMessageMention, } from "../ChatHistory/components/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 { resolveRawUserPrompt } from "./rawUserPrompt"; 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. @@ -66,6 +62,18 @@ const USER_MSG_CONTINUOUS_PREVIEW_HEIGHT = 10 * 24; const AGENT_ORG_INBOX_TRANSCRIPT_PREFIX = "Acknowledged inbox batch"; const PLAN_APPROVED_PREFIX = "[Plan approved"; +export function isViewerOwnedFailedDiscussion(input: { + deliveryStatus: "pending" | "sent" | "failed" | null; + authorUserId: string | null | undefined; + viewerUserId: string | null | undefined; +}): boolean { + return Boolean( + input.deliveryStatus === "failed" && + input.viewerUserId && + input.authorUserId === input.viewerUserId + ); +} + // ============================================ // Types // ============================================ @@ -196,8 +204,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); @@ -211,6 +217,8 @@ const UserChatItem = ({ const messageContentRef = useRef(null); const event = chatItem.event; + const groupChat = useGroupChatContext(); + 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. @@ -220,10 +228,10 @@ const UserChatItem = ({ // the org roster so the `@name` text renders as a member pill. const comments = useSessionCommentsContext(); const mentionableMembers = comments?.mentionableMembers; - const mentionedUserIds = event - ? discussionPayloadOf(event)?.mentionedUserIds - : undefined; - const mentions = useMemo((): UserMessageMention[] | undefined => { + const discussionPayload = event ? discussionPayloadOf(event) : null; + const mentionedUserIds = discussionPayload?.mentionedUserIds; + const discussionCommentId = discussionPayload?.commentId ?? null; + const mentions: UserMessageMention[] | undefined = (() => { if (!mentionedUserIds?.length) return undefined; const resolved: UserMessageMention[] = []; for (const userId of mentionedUserIds) { @@ -234,7 +242,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)) : ""; @@ -254,6 +262,28 @@ 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 groupChatInboxId = + typeof event?.args?.groupChatInboxId === "number" + ? event.args.groupChatInboxId + : null; + const viewerOwnsFailedDiscussion = isViewerOwnedFailedDiscussion({ + deliveryStatus, + authorUserId: discussionPayload?.authorUserId, + viewerUserId: comments?.viewerUserId, + }); const fullContent = useMemo(() => { // When display_text is present on the event it is the pill-format string @@ -286,6 +316,22 @@ const UserChatItem = ({ // Extract images from activity result for display in chat history. const messageImages = isAgentOrgInboxTranscript ? undefined : activityImages; + const retryDelivery = + viewerOwnsFailedDiscussion && comments && discussionCommentId + ? () => { + void comments + .retryComment(discussionCommentId) + .catch((error) => + Message.error( + error instanceof Error ? error.message : String(error) + ) + ); + } + : deliveryStatus === "failed" && groupChat && groupChatInboxId !== null + ? () => groupChat.retryFailedMessage(groupChatInboxId) + : onEditSubmit + ? () => onEditSubmit(editedText || fullContent, messageImages) + : null; const needsTruncation = useMemo(() => { if (!compactPreview) return false; @@ -340,7 +386,44 @@ const UserChatItem = ({ const handleEditSubmitInternal = useCallback( (newText: string, addedImageDataUrls?: string[]) => { + if (viewerOwnsFailedDiscussion && comments && discussionCommentId) { + if (!isTeamChatBodyWithinLimit(newText)) { + Message.warning( + `Team Chat messages must be ${CLOUD_COMMENT_MAX_BODY_LENGTH} characters or fewer` + ); + return; + } + const mentionedUserIds = resolveTeamChatMentionedUserIds( + newText, + comments.mentionableMembers, + undefined, + comments.viewerUserId + ); + if (!isTeamChatMentionAudienceWithinLimit(mentionedUserIds)) { + Message.warning( + `@all is unavailable when it would notify more than ${CLOUD_COMMENT_MAX_MENTIONED_USER_IDS} people` + ); + return; + } + setIsEditing(false); + void comments + .retryComment(discussionCommentId, newText) + .catch((error) => + Message.error( + error instanceof Error ? error.message : String(error) + ) + ); + return; + } setIsEditing(false); + if ( + deliveryStatus === "failed" && + groupChat && + groupChatInboxId !== null + ) { + groupChat.retryFailedMessage(groupChatInboxId, newText); + return; + } const rustImages = [ ...((editImageList && editImageList.length > 0 ? editImageList.map(imageRefToRustPath) @@ -349,7 +432,16 @@ const UserChatItem = ({ ]; onEditSubmit?.(newText, rustImages.length > 0 ? rustImages : undefined); }, - [onEditSubmit, editImageList] + [ + comments, + deliveryStatus, + discussionCommentId, + editImageList, + groupChat, + groupChatInboxId, + onEditSubmit, + viewerOwnsFailedDiscussion, + ] ); // Edit mode @@ -374,12 +466,13 @@ const UserChatItem = ({ const planApprovedEdited = isPlanApproved && fullContent.startsWith("[Plan approved (edited)"); const isEditableDisplay = Boolean( - onEditSubmit && + (onEditSubmit || viewerOwnsFailedDiscussion) && + deliveryStatus !== "pending" && !isRepoSetup && !isAgentOrgInboxTranscript && !isPlanApproved && - !event?.args?.["sessionDiscussion"] && - !readConversationSenderStamp(event) + (!event?.args?.["sessionDiscussion"] || deliveryStatus === "failed") && + (!conversationSenderStampOf(event) || viewerOwnsFailedDiscussion) ); const hasDisplayContent = Boolean( fullContent.trim() || @@ -391,15 +484,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 @@ -414,9 +504,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 = ( @@ -533,59 +621,98 @@ const UserChatItem = ({ )} - {(rawPrompt.trim() || isEditableDisplay || toolbarActions) && ( + {(rawPrompt.trim() || + isEditableDisplay || + toolbarActions || + deliveryStatus === "pending" || + deliveryStatus === "failed") && (
-
- {rawPrompt.trim() && event?.sessionId && ( - - )} - {isEditableDisplay && onRestoreCheckpoint && ( - - )} - {isEditableDisplay && ( - - )} - {toolbarActions} -
+ )} + {isEditableDisplay && onRestoreCheckpoint && ( + + )} + {isEditableDisplay && ( + + )} + {toolbarActions} +
+ )} + {(deliveryStatus === "pending" || deliveryStatus === "failed") && ( + + {deliveryStatus === "pending" && ( + + {t("chat.messageSending", "Sending…")} + + )} + {deliveryStatus === "failed" && ( + <> + + {t("chat.failedToSendMessage")} + + {retryDelivery && ( + + )} + + )} + + )} )} @@ -598,7 +725,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 008f5ed153..5b6af10ab3 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,9 +13,35 @@ 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"; +import UserChatItem, { isViewerOwnedFailedDiscussion } from "../UserChatItem"; + +describe("failed Team Chat edit ownership", () => { + it("allows only the viewer's failed discussion row", () => { + expect( + isViewerOwnedFailedDiscussion({ + deliveryStatus: "failed", + authorUserId: "viewer-user", + viewerUserId: "viewer-user", + }) + ).toBe(true); + expect( + isViewerOwnedFailedDiscussion({ + deliveryStatus: "failed", + authorUserId: "teammate-user", + viewerUserId: "viewer-user", + }) + ).toBe(false); + expect( + isViewerOwnedFailedDiscussion({ + deliveryStatus: "sent", + authorUserId: "viewer-user", + viewerUserId: "viewer-user", + }) + ).toBe(false); + }); +}); function renderMessage(id: string): string { const sessionId = "agentsession-local"; @@ -27,11 +57,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) }) @@ -63,14 +97,191 @@ 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 message-level copy or timestamp controls", () => { const markup = renderMessage("user-message-without-footer"); expect(markup).not.toContain('data-icon="copy"'); expect(markup).not.toContain(" { + 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", () => { diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index fee78758c9..3e5e82a4ac 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, useSetAtom } from "jotai"; import { selectAtom } from "jotai/utils"; import React, { memo, @@ -32,30 +32,26 @@ 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 { sessionCommentTargetForConversationRoot } from "@src/features/Org2Cloud/sessionCommentTarget"; 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"; +import { + clearSessionContinuationAtom, + sessionContinuationNoticesAtom, +} from "@src/store/session/sessionTabPlacementAtom"; import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanelAtom"; import { STATION_MODE, stationModeAtom } from "@src/store/ui/simulatorAtom"; import { @@ -71,12 +67,14 @@ import { ChatViewHistorySurface } from "./ChatViewHistorySurface"; import { ChatViewLiveRegion } from "./ChatViewLiveRegion"; import { ChatViewPostHistoryOverlays } from "./ChatViewPostHistoryOverlays"; import type { ChatViewProps } from "./ChatViewTypes"; +import { ConversationExecutionBindingContext } from "./ConversationExecutionBindingContext"; import { useComposerSections } from "./InputArea/hooks/useComposerSections"; import { - shouldShowExternalHistoryForkComposer, + shouldShowExternalHistoryContinuationComposer, shouldShowMainChatComposer, } from "./chatViewComposerVisibility"; import { resolveInitialFileChanges } from "./chatViewFileChanges"; +import { useConversationSubmitRouter } from "./hooks/conversationSubmit/useConversationSubmitRouter"; import { useBrowserAddToConversationAction } from "./hooks/useBrowserAddToConversationAction"; import { useChatViewAgentOrgSurface } from "./hooks/useChatViewAgentOrgSurface"; import { useChatViewAgentStationDiff } from "./hooks/useChatViewAgentStationDiff"; @@ -86,15 +84,13 @@ 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( @@ -109,9 +105,6 @@ const ChatView: React.FC = memo( 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 +126,31 @@ const ChatView: React.FC = memo( useTodoSync(isReadOnlySurface ? undefined : sessionId); useFileReviewSync(sessionId, !isReadOnlySurface && !secondary); const currentSession = useAtomValue(sessionByIdAtom(sessionId)); - const pinnedCommentsSession = usePinnedSession(sessionId) ?? null; + const continuationNoticeAtom = useMemo( + () => + selectAtom( + sessionContinuationNoticesAtom, + (notices) => notices[sessionId] ?? null, + Object.is + ), + [sessionId] + ); + const continuationNotice = useAtomValue(continuationNoticeAtom); + const clearSessionContinuation = useSetAtom(clearSessionContinuationAtom); + useEffect(() => { + if (!continuationNotice || !onSessionContinuation) return; + clearSessionContinuation({ + sourceSessionId: sessionId, + sessionId: continuationNotice.sessionId, + }); + onSessionContinuation(continuationNotice); + }, [ + clearSessionContinuation, + continuationNotice, + onSessionContinuation, + sessionId, + ]); + const conversationTargetBinding = useConversationTargetBinding(sessionId); const hydratedSessionIdsRef = useRef(new Set()); useEffect(() => { if ( @@ -185,75 +202,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 +277,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 +323,7 @@ const ChatView: React.FC = memo( handleAgentOrgMemberSessionJump, handleMainComposerSubmitOverride, cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, @@ -386,9 +335,18 @@ const ChatView: React.FC = memo( groupChatHistoryAction, } = useChatViewAgentOrgSurface({ sessionId, - currentSession, - onSessionContinuation, showCurrentPlanSurface, + conversationRoot: conversationTargetBinding?.root ?? null, + }); + const { + submit: handleConversationSubmit, + retry: handleCanonicalConversationRetry, + } = 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 +383,7 @@ const ChatView: React.FC = memo( } = useComposerSections({ sessionId, queueCount: sessionMessageQueue.length, - enqueueCount, + queueTailKey, hasQuestion, hasPermission, hasModeSwitch, @@ -454,8 +412,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 +467,7 @@ const ChatView: React.FC = memo( agentOrgIntervention: agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - onSubmitOverride: handleMainComposerSubmitOverride, + onSubmitOverride: handleConversationSubmit, customMentionOptions: groupChatMentionOptions, queueEditProps, disableStopWhenEmpty: groupChatViewActive, @@ -551,7 +510,7 @@ const ChatView: React.FC = memo( agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - handleMainComposerSubmitOverride, + handleConversationSubmit, groupChatMentionOptions, queueEditProps, followUpSuggestions, @@ -563,21 +522,33 @@ 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 = useMemo( + () => + sessionCommentTargetForConversationRoot( + conversationTargetBinding?.root + ), + [conversationTargetBinding?.root] + ); return (
= memo( = memo( groupChatViewAvailable={groupChatViewAvailable} handleGroupChatViewToggle={handleGroupChatViewToggle} isReadOnlySurface={isReadOnlySurface} + onFailedUserIntentRetry={handleCanonicalConversationRetry} />
} - composer={} + composer={ + + + + } />
); diff --git a/src/engines/ChatPanel/ChatViewHistorySurface.tsx b/src/engines/ChatPanel/ChatViewHistorySurface.tsx index 0e4fe54c70..cb716e25b5 100644 --- a/src/engines/ChatPanel/ChatViewHistorySurface.tsx +++ b/src/engines/ChatPanel/ChatViewHistorySurface.tsx @@ -12,13 +12,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; @@ -48,12 +46,14 @@ interface ChatViewHistorySurfaceProps { ChatHistoryProps["onGroupChatViewToggle"] >; isReadOnlySurface: boolean; + onFailedUserIntentRetry: NonNullable< + ChatHistoryProps["onFailedUserIntentRetry"] + >; } export function ChatViewHistorySurface({ sessionId, groupChatViewActive, - groupChatMergedEvents, groupChatAgents, pipelineSessionId, handleGroupChatTapEvents, @@ -78,75 +78,71 @@ export function ChatViewHistorySurface({ groupChatViewAvailable, handleGroupChatViewToggle, isReadOnlySurface, + onFailedUserIntentRetry, }: ChatViewHistorySurfaceProps) { return ( - { + void retryFailedGroupChatMessage(rowId, editedDisplayText); + }} > - { - void retryFailedGroupChatMessage(rowId, editedDisplayText); - }} - > - {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} + /> + + ); } diff --git a/src/engines/ChatPanel/ChatViewLiveRegion.tsx b/src/engines/ChatPanel/ChatViewLiveRegion.tsx index 9b959ed94f..1b61cd272d 100644 --- a/src/engines/ChatPanel/ChatViewLiveRegion.tsx +++ b/src/engines/ChatPanel/ChatViewLiveRegion.tsx @@ -1,15 +1,22 @@ 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; + conversationSessionId: string; + conversationOverrideEvents: SessionEvent[] | undefined; transcript: ReactNode; composer: ReactNode; } @@ -21,9 +28,12 @@ interface ChatViewLiveRegionProps { */ export const ChatViewLiveRegion = memo(function ChatViewLiveRegion({ commentsSession, + commentsTargetOverride, turnAnchorsVisible, rootRef, dataSessionId, + conversationSessionId, + conversationOverrideEvents, transcript, composer, }: ChatViewLiveRegionProps) { @@ -32,18 +42,29 @@ export const ChatViewLiveRegion = memo(function ChatViewLiveRegion({ return ( -
- {transcript} - {composer} -
+ +
+ {transcript} + {composer} +
+
+
); }); 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 093f19cab2..d4587d14dc 100644 --- a/src/engines/ChatPanel/ChatViewTypes.ts +++ b/src/engines/ChatPanel/ChatViewTypes.ts @@ -41,9 +41,9 @@ export interface ChatViewProps { */ 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. + * Retarget the owning tab after canonical continuation prepares a writable + * native execution episode. 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.tsx b/src/engines/ChatPanel/ConversationStreamProvider.tsx index c22d9a81b6..5a05aeeb0e 100644 --- a/src/engines/ChatPanel/ConversationStreamProvider.tsx +++ b/src/engines/ChatPanel/ConversationStreamProvider.tsx @@ -1,4 +1,5 @@ import { useAtomValue, useSetAtom } from "jotai"; +import { selectAtom } from "jotai/utils"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; @@ -6,7 +7,9 @@ import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; import { + activeConversationRunnerKey, activeConversationRunnersAtom, + buildConversationRunnerOverlay, collectLandedTurnIds, selectActiveRunners, } from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; @@ -24,15 +27,21 @@ import { } 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 { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudRemoteSessionsAtom, + remoteSessionsEntryForIdentity, +} from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; import { findImportedSession } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { sessionsAtom } from "@src/store/session"; +import { sessionByIdAtom, sessionsAtom } from "@src/store/session"; import { ChatHistoryOverrideContext } from "./ChatHistoryOverrideContext"; +import { useConversationViewerState } from "./ChatItems/ConversationSenderMetadataContext"; interface ConversationStreamProviderProps { sessionId: string; @@ -45,18 +54,28 @@ interface MemberEventsTapProps { bareSessionId: string; localSessionId: string; onEvents: (bareSessionId: string, events: SessionEvent[]) => void; + onUnmount?: (bareSessionId: string) => void; } +const EMPTY_ACTIVE_CONVERSATION_RUNNERS = [] as const; + /** Invisible per-family-member subscription; the atom self-hydrates on mount. */ function MemberEventsTap({ bareSessionId, localSessionId, onEvents, + onUnmount, }: MemberEventsTapProps): null { const events = useAtomValue(chatEventsForSessionAtomFamily(localSessionId)); React.useEffect(() => { onEvents(bareSessionId, events); }, [bareSessionId, events, onEvents]); + React.useEffect( + () => () => { + onUnmount?.(bareSessionId); + }, + [bareSessionId, onUnmount] + ); return null; } @@ -76,10 +95,11 @@ 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; @@ -89,7 +109,10 @@ export function ConversationStreamProvider({ 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,6 +165,7 @@ export function ConversationStreamProvider({ target, overrideEvents, remoteEntries, + authIdentityKey, anchorBareSessionId, currentSession, auth?.userId, @@ -197,28 +221,51 @@ export function ConversationStreamProvider({ ); const plane = useConversationPlaneEvents(target); - const viewerUserId = auth?.userId ?? null; + const viewer = useConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null + ); // 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 (!authIdentityKey || !target || !planeRootId) return null; + return activeConversationRunnerKey(authIdentityKey, { + authority: "org2-cloud", + authorityScope: [target.orgId], + conversationId: planeRootId, + }); + }, [authIdentityKey, planeRootId, target]); + const runnerRegistryEntryAtom = useMemo( + () => + selectAtom( + activeConversationRunnersAtom, + (registry) => + runnerRegistryKey + ? (registry[runnerRegistryKey] ?? EMPTY_ACTIVE_CONVERSATION_RUNNERS) + : EMPTY_ACTIVE_CONVERSATION_RUNNERS, + Object.is + ), + [runnerRegistryKey] + ); + const registeredRunners = useAtomValue(runnerRegistryEntryAtom); const landedTurnIds = useMemo( () => collectLandedTurnIds(plane.events), [plane.events] ); const activeRunners = useMemo(() => { - if (!planeRootId) return []; + if (!runnerRegistryKey) 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 selectActiveRunners(registeredRunners, landedTurnIds); + }, [registeredRunners, runnerRegistryKey, landedTurnIds]); + 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 = @@ -226,32 +273,50 @@ export function ConversationStreamProvider({ ? activeRunners[activeRunners.length - 1].runnerSessionId : null; useEffect(() => { - if (!planeRootId) return; - const list = runnerRegistry[planeRootId]; + if (!runnerRegistryKey) return; + const list = registeredRunners; 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; + if (kept.length === 0) delete next[runnerRegistryKey]; + else next[runnerRegistryKey] = kept; return next; }); - }, [planeRootId, runnerRegistry, landedTurnIds, setRunnerRegistry]); - const [runnerEventsById, setRunnerEventsById] = useState< + }, [runnerRegistryKey, registeredRunners, landedTurnIds, setRunnerRegistry]); + 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 (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; @@ -267,28 +332,18 @@ export function ConversationStreamProvider({ // onto the transcript by server seq — local twins keep their identity. const timeline = plane.events.length > 0 - ? mergePlaneIntoTranscript(base, plane.events, sessionId, viewerUserId) + ? mergePlaneIntoTranscript(base, plane.events, sessionId, viewer) : base; // Synthetic rows merged by timestamp: the sender's live runner overlay // and Team chat discussion. 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 && @@ -315,12 +370,12 @@ export function ConversationStreamProvider({ chatEvents, eventsByBareId, sessionId, - viewerUserId, + viewer, grouped, toSourceEventId, plane.events, activeRunners, - runnerEventsById, + runnerOverlayById, ]); return ( @@ -339,6 +394,7 @@ export function ConversationStreamProvider({ bareSessionId={runner.runnerSessionId} localSessionId={runner.runnerSessionId} onEvents={handleRunnerEvents} + onUnmount={handleRunnerUnmount} /> ))} diff --git a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx new file mode 100644 index 0000000000..33eb6ce8c9 --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ConversationRuntimePill from "./ConversationRuntimePill"; + +vi.mock("jotai", () => ({ + useAtomValue: () => "dropdown", +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => + key === "common:actions.loading" ? "Loading..." : "Select an agent", + }), +})); + +vi.mock("@src/components/SelectorPill", () => ({ + default: ({ + label, + disabled, + dataTestId, + }: { + label: string; + disabled?: boolean; + dataTestId?: string; + }) => ( + + ), +})); + +vi.mock("@src/components/AnyIcon", () => ({ default: () => })); +vi.mock("@src/components/ModelIcon", () => ({ default: () => })); +vi.mock("@src/config/agentIcons", () => ({ + resolveAgentIcon: () => undefined, +})); + +vi.mock( + "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette", + () => ({ DispatchCategoryPalette: () => null }) +); +vi.mock( + "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown", + () => ({ DispatchCategoryDropdown: () => null }) +); + +describe("ConversationRuntimePill inventory readiness", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("does not paint a source runtime as selected while inventory loads", () => { + act(() => { + root.render( + + ); + }); + + const button = container.querySelector("button"); + expect(button?.disabled).toBe(true); + expect(button?.textContent).toBe("Loading..."); + expect(container.textContent).not.toContain("Codex"); + }); + + it("keeps an unavailable inventory neutral and disabled", () => { + act(() => { + root.render( + + ); + }); + + const button = container.querySelector("button"); + expect(button?.disabled).toBe(true); + expect(button?.textContent).toBe("Select an agent"); + expect(container.textContent).not.toContain("Codex"); + }); +}); diff --git a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx new file mode 100644 index 0000000000..785f41e58d --- /dev/null +++ b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx @@ -0,0 +1,111 @@ +import { useAtomValue } from "jotai"; +import React, { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; +import AnyIcon from "@src/components/AnyIcon"; +import ModelIcon from "@src/components/ModelIcon"; +import SelectorPill from "@src/components/SelectorPill"; +import { resolveAgentIcon } from "@src/config/agentIcons"; +import type { ConversationTargetReadiness } from "@src/engines/ChatPanel/conversationTargetSelection"; +import { + type AgentSelection, + DispatchCategoryPalette, +} from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; +import { DispatchCategoryDropdown } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown"; +import { modelPickerStyleAtom } from "@src/store/ui/chatPanelAtom"; + +interface ConversationRuntimePillProps { + selection: AgentSelection | null; + readiness: ConversationTargetReadiness; + allowedCliAgentTypes: readonly CliAgentType[]; + onSelect: (selection: AgentSelection) => void; +} + +/** + * The ordinary New Session runtime picker, mounted beside the model picker. + * The conversation layer owns only the selected value; option discovery and + * presentation remain in DispatchCategoryPalette. + */ +const ConversationRuntimePill: React.FC = memo( + ({ selection, readiness, allowedCliAgentTypes, onSelect }) => { + const { t } = useTranslation(); + const modelPickerStyle = useAtomValue(modelPickerStyleAtom); + const [isOpen, setIsOpen] = useState(false); + const triggerRef = useRef(null); + const visibleSelection = readiness === "ready" ? selection : null; + + const icon = useMemo(() => { + if (visibleSelection?.cliAgentType) { + return ( + + ); + } + return ( + + ); + }, [visibleSelection]); + + const handleSelect = useCallback( + (next: AgentSelection) => { + onSelect(next); + setIsOpen(false); + }, + [onSelect] + ); + + const close = useCallback(() => setIsOpen(false), []); + const disabled = readiness !== "ready"; + const effectiveIsOpen = isOpen && !disabled; + const label = + readiness === "loading" + ? t("common:actions.loading") + : (visibleSelection?.agentName ?? t("sessions:creator.selectAgent")); + const sharedProps = { + isOpen: effectiveIsOpen, + onClose: close, + onSelect: handleSelect, + currentCategory: visibleSelection?.category, + currentAgentDefinitionId: visibleSelection?.agentDefinitionId, + currentCliAgentType: visibleSelection?.cliAgentType, + hideOrgs: true, + allowedCliAgentTypes, + } as const; + + return ( + <> + setIsOpen((open) => !open)} + size="sm" + ariaLabel={label} + dataTestId="chat-runtime-pill" + /> + + {modelPickerStyle === "dropdown" ? ( + + ) : ( + + )} + + ); + } +); + +ConversationRuntimePill.displayName = "ConversationRuntimePill"; + +export default ConversationRuntimePill; diff --git a/src/engines/ChatPanel/InputArea/components/ModelPill.tsx b/src/engines/ChatPanel/InputArea/components/ModelPill.tsx index b0b4502a5f..045d4e2026 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`. @@ -28,30 +28,33 @@ import { } from "@src/api/tauri/session"; import { Message } from "@src/components/Message"; import ModelSelectorPill from "@src/components/ModelSelectorPill"; +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 { 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/chatPanelAtom"; import { modelSelectorAtom } from "@src/store/ui/modelSelectorAtom"; import { isActiveStatus } from "@src/types/session/session"; import { getDispatchCategory } from "@src/util/session/sessionDispatch"; +import ConversationRuntimePill from "./ConversationRuntimePill"; + // ============================================ // Component // ============================================ -const ModelPill: React.FC = memo(() => { +const ModelPillComponent: React.FC = () => { const { t } = useTranslation(); const modelPickerStyle = useAtomValue(modelPickerStyleAtom); const modelSegmentRef = useRef(null); @@ -72,7 +75,7 @@ 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(); // 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 +86,14 @@ const ModelPill: React.FC = memo(() => { ? getDispatchCategory(sessionId) : undefined; const paletteCategoryOverride: DispatchCategory | undefined = isInSession - ? (session?.category ?? sessionIdCategory) + ? conversationBinding + ? conversationBinding.runtimeSelection?.category + : (session?.category ?? sessionIdCategory) : undefined; const paletteCliAgentTypeOverride: CliAgentType | undefined = isInSession - ? (session?.cliAgentType ?? undefined) + ? conversationBinding + ? conversationBinding.runtimeSelection?.cliAgentType + : (session?.cliAgentType ?? undefined) : undefined; // The display value `lastModel` is built from the session row when @@ -134,6 +141,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 +152,10 @@ 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)); return; } // In-session: keySource / cliAgentType / tier are session-create @@ -249,26 +256,78 @@ const ModelPill: React.FC = memo(() => { [advancedConfig, handleConfigChange, lastModel] ); + const handleRuntimeSelect = useCallback( + (selection: AgentSelection) => { + if (!conversationBinding?.applyRuntimePick(selection)) { + Message.warning(t("navigation:collaboration.forkImported.agentError")); + } + }, + [conversationBinding, t] + ); + + const pillSelection = useMemo( + () => + conversationBinding && lastModel + ? { ...lastModel, cliAgentLabel: undefined } + : lastModel, + [conversationBinding, lastModel] + ); + + const conversationTargetReady = + !conversationBinding || + (conversationBinding.readiness === "ready" && + Boolean(conversationBinding.target)); + const modelDefaultLabel = + conversationBinding?.readiness === "loading" + ? t("common:actions.loading") + : t("sessions:creator.model"); + const visiblePillSelection = conversationTargetReady ? pillSelection : null; + const effectiveModelOpen = isModelOpen && conversationTargetReady; + 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 && ( + + )} {modelPill} - {isModelOpen && + {effectiveModelOpen && (modelPickerStyle === "dropdown" ? ( { onClose={handleCloseSelector} advancedConfig={advancedConfig} onConfigChange={handleConfigChange} + agentNameOverride={conversationBinding?.runtimeSelection?.agentName} dispatchCategoryOverride={paletteCategoryOverride} cliAgentTypeOverride={paletteCliAgentTypeOverride} /> ))} ); -}); +}; + +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..b6754984cc 100644 --- a/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx +++ b/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx @@ -54,11 +54,11 @@ 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 = 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 +78,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 && ( } 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/index.tsx b/src/engines/ChatPanel/InputArea/index.tsx index ef7b95b0e8..04f92b72b6 100644 --- a/src/engines/ChatPanel/InputArea/index.tsx +++ b/src/engines/ChatPanel/InputArea/index.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import type { SessionFollowUpSuggestion } from "@src/api/services/sessionFollowUpSuggestions"; import type { ComposerInputRef } from "@src/components/ComposerInput"; import ComposerShell from "@src/components/ComposerShell"; +import { useConversationExecutionBinding } from "@src/engines/ChatPanel/ConversationExecutionBindingContext"; import { useInputArea } from "@src/engines/ChatPanel/hooks/useInputArea"; import type { CustomMentionOption, @@ -166,6 +167,7 @@ const InputAreaInteractive: React.FC = memo( slashItemCategories, presentation = "default", }) => { + const conversationExecutionBinding = useConversationExecutionBinding(); const { t } = useTranslation("sessions"); const { sessionId } = useSessionId({ propSessionId }); @@ -267,11 +269,20 @@ const InputAreaInteractive: React.FC = memo( 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); @@ -540,7 +551,7 @@ const InputAreaInteractive: React.FC = memo( hasImages={hasImages} isHosted={isHosted} canStopAgent={canStopAgent} - canResume={canResume} + canResume={genericResumeAvailable} onInterrupt={interruptSession} onResume={resumeSession} isCursorIde={isCursorIde} @@ -577,7 +588,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 68c475a61d..c2795fc4b6 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, @@ -72,6 +73,7 @@ import ChatHistory from "../ChatHistory"; import { ChatSessionContext } from "../ChatSessionContext"; import InputArea from "../InputArea"; import type { SubmitOverrideInput } from "../hooks/useInputArea/types"; +import { useUserIntentSubmit } from "../hooks/useWorkspaceChat/useUserIntentSubmit"; import type { ChatPanelProps } from "../types"; import { shouldShowSideChatLauncher } from "./sideChatLauncherVisibility"; @@ -290,6 +292,8 @@ const SideChatSessionBody: React.FC = ({ isLive, }) => { const turnPaginationEnabled = useAtomValue(chatTurnPaginationEnabledAtom); + const getSessionId = useCallback(() => sessionId, [sessionId]); + const submitUserIntent = useUserIntentSubmit({ getSessionId }); const handleSubmit = useCallback( async ({ @@ -300,21 +304,24 @@ const SideChatSessionBody: React.FC = ({ 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] + [sessionId, submitUserIntent] ); return ( 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/conversationTargetSelection.test.ts b/src/engines/ChatPanel/conversationTargetSelection.test.ts new file mode 100644 index 0000000000..2ef549d8ef --- /dev/null +++ b/src/engines/ChatPanel/conversationTargetSelection.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it } from "vitest"; + +import type { KeyVaultAccount } from "@src/hooks/keyVault"; + +import { + resolveConversationRuntimeSelection, + resolveConversationRuntimeTarget, + resolveConversationTargetPillPresentation, + resolveConversationTargetReadiness, + resolveDefaultConversationTarget, +} from "./conversationTargetSelection"; + +function account( + id: string, + modelType: "claude_code" | "codex" | "cursor_cli", + model: string +): KeyVaultAccount { + return { + id, + hasLocalKey: true, + isListed: false, + modelType, + name: id, + status: "ready", + hasKey: true, + hasApiKey: modelType === "cursor_cli", + hasSessionToken: true, + canLaunchCli: true, + enabled: true, + availableModels: [model], + enabledModels: [model], + }; +} + +const registry = { + agents: [ + { + name: "claude_code", + compatibleApiProviders: [], + }, + { name: "codex", compatibleApiProviders: [] }, + { name: "cursor_cli", compatibleApiProviders: [] }, + ], + apiProviders: [], +} as never; + +describe("canonical conversation target selection", () => { + it("resolves a standard New Session runtime selection without a custom runtime list", () => { + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "codex", + agentName: "Codex", + }, + current: { + workspaceRepoPath: "/repo", + cliAgentType: "claude_code", + model: "opus", + }, + sourceModel: "claude-opus-5", + workspaceRepoPath: "/repo", + accounts: [account("codex-local", "codex", "gpt-5.6-sol")], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "codex", + accountId: "codex-local", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }); + }); + + it("switches to an installed Cursor CLI account without a setup dialog", () => { + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "cursor_cli", + agentName: "Cursor CLI", + }, + current: { + cliAgentType: "codex", + accountId: "codex-local", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + sourceModel: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + accounts: [account("cursor-local", "cursor_cli", "composer-1")], + registry, + nativeCliTargets: ["claude_code", "codex", "cursor_cli"], + }) + ).toEqual({ + cliAgentType: "cursor_cli", + accountId: "cursor-local", + model: "composer-1", + workspaceRepoPath: "/repo", + }); + }); + + it("uses the selected Rust agent's existing preferred account and model", () => { + const rustAccount = account("rust-account", "codex", "gpt-5.6-sol"); + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "rust_agent", + targetKind: "agent", + agentDefinitionId: "builtin:sde", + agentName: "SDE Agent", + }, + current: null, + sourceModel: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + preferredAccountId: "rust-account", + preferredModel: "gpt-5.6-sol", + accounts: [rustAccount], + registry: { + agents: [ + { + name: "claude_code", + compatibleApiProviders: [], + }, + { + name: "codex", + compatibleApiProviders: [], + supportsRustAgents: true, + }, + ], + apiProviders: [], + } as never, + 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", + }, + sourceTitle: "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, + sourceCliAgentType: "claude_code", + sourceModel: "claude-opus-5", + accounts: [account("rust-account", "codex", "gpt-5.6-sol")], + }) + ).toMatchObject({ + selection: { + cliAgentType: undefined, + model: "gpt-5.6-sol", + selectedAccountId: "rust-account", + }, + }); + }); + + it("resolves a same-provider target from healthy local accounts without a modal", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: null, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: "claude-opus-5", + workspaceRepoPath: "/repo", + accounts: [ + account("codex-local", "codex", "gpt-5.6-sol"), + 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: "/repo", + }); + }); + + it("uses the signed-in local Claude CLI for a runtime-only switch", () => { + expect( + resolveConversationRuntimeTarget({ + selection: { + category: "cli_agent", + targetKind: "cli_agent", + cliAgentType: "claude_code", + agentName: "Claude Code", + }, + current: { + cliAgentType: "codex", + accountId: "codex-local", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + sourceModel: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + accounts: [ + { + ...account("stale-oauth", "claude_code", "claude-fable-5"), + status: "error", + healthStatus: "invalid", + }, + account("healthy-claude", "claude_code", "claude-opus-5"), + ], + registry, + nativeCliTargets: ["claude_code", "codex"], + }) + ).toEqual({ + cliAgentType: "claude_code", + 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("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("ignores a stale remembered account and falls back without a dialog", () => { + expect( + resolveDefaultConversationTarget({ + preferredTarget: { + cliAgentType: "claude_code", + accountId: "deleted-account", + model: "claude-opus-5", + workspaceRepoPath: "/old-repo", + }, + initialTarget: null, + sourceCliAgentType: "claude_code", + sourceModel: "claude-opus-5", + workspaceRepoPath: "/repo", + 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: "/repo", + }); + }); + + it("does not present source provenance as a selected execution target", () => { + expect( + resolveConversationTargetPillPresentation({ + target: null, + sourceCliAgentType: "codex", + sourceModel: "gpt-5.6-sol", + }) + ).toEqual({ selection: null }); + + expect( + resolveConversationRuntimeSelection({ + target: null, + source: { + root: { + authority: "local-session", + authorityScope: [], + conversationId: "codex-source", + }, + sourceTitle: "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 the confirmed provider-native continuation target", () => { + expect( + resolveConversationTargetPillPresentation({ + target: { + cliAgentType: "claude_code", + accountId: "claude-local", + model: "claude-opus-5", + workspaceRepoPath: "/repo", + }, + sourceCliAgentType: "codex", + sourceModel: "gpt-5.6-sol", + }) + ).toMatchObject({ + selection: { + model: "claude-opus-5", + selectedAccountId: "claude-local", + cliAgentType: "claude_code", + }, + }); + }); + + it("shows Claude's native Default model for an ambient runtime switch", () => { + expect( + resolveConversationTargetPillPresentation({ + target: { + cliAgentType: "claude_code", + workspaceRepoPath: "/repo", + }, + sourceCliAgentType: "codex", + sourceModel: "gpt-5.5-medium", + }) + ).toEqual({ + selection: { + keySource: "own_key", + model: "default", + selectedAccountId: undefined, + cliAgentType: "claude_code", + selectedSourceLabel: undefined, + selectedSourceModelType: "claude_code", + }, + }); + }); + + it("keeps a persisted local child account and provider visible", () => { + expect( + resolveConversationTargetPillPresentation({ + target: { + cliAgentType: "codex", + accountId: "openai-local", + model: "gpt-5.5", + workspaceRepoPath: "/repo", + }, + sourceCliAgentType: "codex", + }) + ).toMatchObject({ + selection: { + model: "gpt-5.5", + selectedAccountId: "openai-local", + cliAgentType: "codex", + }, + }); + }); + + it("keeps a remembered ORG2 target above a Codex source", () => { + expect( + resolveConversationTargetPillPresentation({ + target: { + agentDefinitionId: "builtin:sde", + accountId: "openai", + model: "gpt-5.6-luna", + workspaceRepoPath: "/repo", + }, + sourceCliAgentType: "codex", + sourceModel: "gpt-5.6-sol", + }) + ).toMatchObject({ + selection: { + model: "gpt-5.6-luna", + cliAgentType: undefined, + }, + }); + }); +}); diff --git a/src/engines/ChatPanel/conversationTargetSelection.ts b/src/engines/ChatPanel/conversationTargetSelection.ts new file mode 100644 index 0000000000..538e411a35 --- /dev/null +++ b/src/engines/ChatPanel/conversationTargetSelection.ts @@ -0,0 +1,404 @@ +import { + type CliAgentType, + CliAgentTypeSchema, +} from "@src/api/tauri/rpc/schemas/validation"; +import { KEY_SOURCE } from "@src/api/tauri/session"; +import { formatAgentType } from "@src/assets/providers"; +import type { + ConversationRootLocator, + ConversationSource, + LocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import type { AdvancedConfig } from "@src/features/SessionCreator/types"; +import type { KeyVaultAccount } from "@src/hooks/keyVault"; +import { + getCliCompatibleAccounts, + getRustCompatibleAccounts, +} from "@src/hooks/models/useAgentCompatibility"; +import { + accountHasModel, + accountModelIds, +} from "@src/hooks/models/useModelAccountLookup"; +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 { + /** Stable typed identity shared by every native execution episode. */ + root: ConversationRootLocator; + selection: LastModelSelection | null; + runtimeSelection: AgentSelection | null; + target: LocalConversationTarget | null; + /** Whether runtime/account discovery can support an executable selection. */ + readiness: ConversationTargetReadiness; + nativeCliTargets: readonly CliAgentType[]; + applyRuntimePick: (selection: AgentSelection) => boolean; + applyModelPick: (config: AdvancedConfig) => boolean; +} + +export 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 ConversationTargetPillPresentationInput { + target: LocalConversationTarget | null; + sourceCliAgentType?: string; + sourceAgentDefinitionId?: string; + sourceModel?: string; + accounts?: readonly KeyVaultAccount[]; +} + +interface DefaultConversationTargetInput { + /** Current picker override or latest persisted native execution target. */ + preferredTarget: LocalConversationTarget | null; + initialTarget: LocalConversationTarget | null; + sourceCliAgentType?: string; + sourceAgentDefinitionId?: string; + sourceModel?: string; + /** Undefined while an imported conversation's local checkout is hydrating. */ + workspaceRepoPath: string | null | undefined; + accounts: readonly KeyVaultAccount[]; + registry: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +} + +interface RuntimeConversationTargetInput { + selection: AgentSelection; + current: LocalConversationTarget | null; + sourceModel?: string; + workspaceRepoPath: string | null; + preferredAccountId?: string; + preferredModel?: string; + accounts: readonly KeyVaultAccount[]; + registry: AgentRegistry; + nativeCliTargets: readonly CliAgentType[]; +} + +function availableAccountModels(account: KeyVaultAccount): string[] { + return accountModelIds(account).filter((model) => + accountHasModel(account, model) + ); +} + +function chooseAccountAndModel( + candidates: readonly KeyVaultAccount[], + currentAccountId: string | undefined, + currentModel: string | undefined, + preferredAccountId: string | undefined, + preferredModel: string | undefined +): { accountId: string; model: string } | null { + const account = + candidates.find((candidate) => candidate.id === currentAccountId) ?? + candidates.find((candidate) => candidate.id === preferredAccountId) ?? + (preferredModel + ? candidates.find((candidate) => + accountHasModel(candidate, preferredModel) + ) + : undefined) ?? + candidates[0]; + if (!account) return null; + const model = + (currentModel && accountHasModel(account, currentModel) + ? currentModel + : undefined) ?? + (preferredModel && accountHasModel(account, preferredModel) + ? preferredModel + : undefined) ?? + availableAccountModels(account)[0]; + return model ? { accountId: account.id, model } : null; +} + +function isUsableTarget( + target: LocalConversationTarget | null, + accounts: readonly KeyVaultAccount[], + registry: AgentRegistry, + nativeCliTargets: readonly CliAgentType[] +): target is LocalConversationTarget { + if (!target) return false; + const parsedCliAgentType = CliAgentTypeSchema.safeParse(target.cliAgentType); + if (parsedCliAgentType.success) { + const cliAgentType = parsedCliAgentType.data; + if (!nativeCliTargets.includes(cliAgentType)) return false; + if (!target.accountId) return cliAgentType === "claude_code"; + const account = getCliCompatibleAccounts(registry, cliAgentType, [ + ...accounts, + ]).find( + (candidate) => + candidate.id === target.accountId && + candidate.enabled && + candidate.hasKey + ); + return Boolean( + account && target.model && accountHasModel(account, target.model) + ); + } + + if (!target.agentDefinitionId || !target.accountId || !target.model) { + return false; + } + const account = getRustCompatibleAccounts(registry, [...accounts]).find( + (candidate) => candidate.id === target.accountId && candidate.enabled + ); + return Boolean(account && accountHasModel(account, target.model)); +} + +function resolveCliTarget(params: { + cliAgentType: CliAgentType; + current: LocalConversationTarget | null; + sourceModel?: string; + workspaceRepoPath: string | null; + accounts: readonly KeyVaultAccount[]; + registry: AgentRegistry; +}): LocalConversationTarget | null { + const accounts = getCliCompatibleAccounts( + params.registry, + params.cliAgentType, + [...params.accounts] + ).filter((account) => account.enabled && account.hasKey); + const sameRuntime = params.current?.cliAgentType === params.cliAgentType; + const resolved = chooseAccountAndModel( + accounts, + sameRuntime ? params.current?.accountId : undefined, + sameRuntime ? params.current?.model : undefined, + undefined, + params.sourceModel + ); + if (!resolved) { + if (params.cliAgentType !== "claude_code") return null; + return { + cliAgentType: params.cliAgentType, + workspaceRepoPath: params.workspaceRepoPath, + model: sameRuntime ? params.current?.model : undefined, + }; + } + return { + cliAgentType: params.cliAgentType, + ...resolved, + workspaceRepoPath: params.workspaceRepoPath, + }; +} + +function resolveAgentTarget(params: { + agentDefinitionId: string; + current: LocalConversationTarget | null; + sourceModel?: string; + workspaceRepoPath: string | null; + preferredAccountId?: string; + preferredModel?: string; + accounts: readonly KeyVaultAccount[]; + registry: AgentRegistry; +}): LocalConversationTarget | null { + const sameRuntime = + params.current?.agentDefinitionId === params.agentDefinitionId; + const resolved = chooseAccountAndModel( + getRustCompatibleAccounts(params.registry, [...params.accounts]).filter( + (account) => account.enabled && account.hasKey + ), + sameRuntime ? params.current?.accountId : undefined, + sameRuntime ? params.current?.model : undefined, + params.preferredAccountId, + params.preferredModel ?? params.sourceModel + ); + if (!resolved) return null; + return { + agentDefinitionId: params.agentDefinitionId, + ...resolved, + workspaceRepoPath: params.workspaceRepoPath, + }; +} + +export function resolveDefaultConversationTarget({ + preferredTarget, + initialTarget, + sourceCliAgentType, + sourceAgentDefinitionId, + sourceModel, + workspaceRepoPath, + accounts, + registry, + nativeCliTargets, +}: DefaultConversationTargetInput): LocalConversationTarget | null { + // Cold boot restores the canonical execution choice before the repository + // inventory finishes hydrating. `undefined` means "not resolved yet", not + // "run without a workspace": retain the last verified local path until the + // shared repo-scope resolver returns a definitive path or null. + const resolvedWorkspaceRepoPath = + workspaceRepoPath === undefined + ? (preferredTarget?.workspaceRepoPath ?? + initialTarget?.workspaceRepoPath ?? + null) + : workspaceRepoPath; + if (isUsableTarget(preferredTarget, accounts, registry, nativeCliTargets)) { + return { + ...preferredTarget, + workspaceRepoPath: resolvedWorkspaceRepoPath, + }; + } + if (isUsableTarget(initialTarget, accounts, registry, nativeCliTargets)) { + return { + ...initialTarget, + workspaceRepoPath: resolvedWorkspaceRepoPath, + }; + } + + const parsedSource = CliAgentTypeSchema.safeParse(sourceCliAgentType); + if (parsedSource.success && nativeCliTargets.includes(parsedSource.data)) { + return resolveCliTarget({ + cliAgentType: parsedSource.data, + current: null, + sourceModel, + workspaceRepoPath: resolvedWorkspaceRepoPath, + accounts, + registry, + }); + } + if (sourceAgentDefinitionId) { + return resolveAgentTarget({ + agentDefinitionId: sourceAgentDefinitionId, + current: null, + sourceModel, + workspaceRepoPath: resolvedWorkspaceRepoPath, + accounts, + registry, + }); + } + return null; +} + +export function resolveConversationRuntimeTarget({ + selection, + current, + sourceModel, + workspaceRepoPath, + preferredAccountId, + preferredModel, + accounts, + registry, + nativeCliTargets, +}: RuntimeConversationTargetInput): LocalConversationTarget | null { + let resolved: LocalConversationTarget | null = null; + if (selection.category === "cli_agent" && selection.cliAgentType) { + if (!nativeCliTargets.includes(selection.cliAgentType)) return null; + // Picking the Claude Code runtime means "use the signed-in local CLI". + // Managed Claude-compatible accounts (including Anthropic-compatible + // gateways such as Atlas) remain explicit model/source choices in the + // model picker; silently choosing the first one here makes a runtime-only + // switch change credentials and endpoint behind the user's back. + if (selection.cliAgentType === "claude_code") { + return { + cliAgentType: "claude_code", + workspaceRepoPath, + }; + } + resolved = resolveCliTarget({ + cliAgentType: selection.cliAgentType, + current, + // A runtime pick is not a source/account pick. In particular, Claude + // Code should auto-detect its signed-in CLI account and its default + // model; carrying a Codex/source model into that runtime is invalid. + sourceModel, + workspaceRepoPath, + accounts, + registry, + }); + } else if ( + selection.category === "rust_agent" && + selection.agentDefinitionId + ) { + resolved = resolveAgentTarget({ + agentDefinitionId: selection.agentDefinitionId, + current, + sourceModel, + workspaceRepoPath, + preferredAccountId, + preferredModel, + accounts, + registry, + }); + } + return resolved; +} + +export function resolveConversationTargetPillPresentation({ + target, + accounts = [], +}: ConversationTargetPillPresentationInput): Pick< + ConversationTargetBinding, + "selection" +> { + // Source provenance is not an execution selection. Until discovery has + // produced a real target, both standard picker pills remain neutral. + if (!target) return { selection: null }; + const selectedCliAgentType = target.cliAgentType; + const parsedCliAgentType = CliAgentTypeSchema.safeParse(selectedCliAgentType); + const cliAgentType = parsedCliAgentType.success + ? parsedCliAgentType.data + : undefined; + // An accountless Claude Code target deliberately delegates model choice to + // the signed-in local CLI. That is still a complete, sendable selection: + // render the existing native `default` model option instead of the setup + // placeholder. Keep the execution target model undefined so the runner + // omits `--model` and Claude performs its normal auto-detection. + const model = + target.model ?? + (target.cliAgentType === "claude_code" && !target.accountId + ? "default" + : undefined); + const selectedAccountId = target?.accountId; + const selectedAccount = accounts.find( + (account) => account.id === selectedAccountId + ); + return { + selection: { + keySource: KEY_SOURCE.OWN, + model, + selectedAccountId, + cliAgentType, + selectedSourceLabel: selectedAccount?.name, + selectedSourceModelType: + selectedAccount?.modelType ?? (cliAgentType || undefined), + }, + }; +} + +export function resolveConversationRuntimeSelection(params: { + target: LocalConversationTarget | null; + source: ConversationSource; + definitions: readonly AgentDefinition[]; +}): AgentSelection | null { + if (!params.target) return null; + const selectedCliAgentType = params.target.cliAgentType; + const parsedCliAgentType = CliAgentTypeSchema.safeParse(selectedCliAgentType); + if (parsedCliAgentType.success) { + return { + category: "cli_agent", + targetKind: SESSION_TARGET_KIND.CLI_AGENT, + cliAgentType: parsedCliAgentType.data, + agentName: formatAgentType(parsedCliAgentType.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..fabd0d196f --- /dev/null +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; + +import { SubmitValidationError } from "../useInputArea/types"; +import { 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); + }); +}); diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts new file mode 100644 index 0000000000..8c9ea54001 --- /dev/null +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts @@ -0,0 +1,119 @@ +import { useStore } from "jotai"; +import { useCallback } from "react"; + +import type { + ConversationRootLocator, + LocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + CanonicalConversationQueueAdmissionError, + enqueueCanonicalConversation, +} from "@src/features/ConversationContinuation/enqueueCanonicalConversation"; +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; +} + +export 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; +} + +/** + * 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" + ); + } + 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 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; + + try { + return await enqueueCanonicalConversation({ + store, + root, + sessionId, + input, + target, + }); + } catch (error) { + if (error instanceof CanonicalConversationQueueAdmissionError) { + throw new SubmitValidationError(error.message); + } + throw error; + } + }, + [currentSession, downloadProgress, root, selectedTarget, sessionId, store] + ); + + const submit = useCallback( + async (input: SubmitOverrideInput) => { + if (await onSurfaceSubmit(input)) return true; + return enqueueCanonical(input); + }, + [enqueueCanonical, onSurfaceSubmit] + ); + + return { submit, retry: enqueueCanonical }; +} 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/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..86a613dfde 100644 --- a/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts +++ b/src/engines/ChatPanel/hooks/useChatViewMessageQueue.ts @@ -1,53 +1,83 @@ import { useAtomValue, useSetAtom } 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 { + type QueuedMessage, clearQueuedMessagesAtom, dequeueMessageAtom, editMessageAtom, - enqueueCountAtom, forceSendMessageAtom, messageQueueAtom, - queueFlushRequestAtom, reorderQueueAtom, } from "@src/store/ui/messageQueueAtom"; import { useQueueEditMode } from "../InputArea/hooks/useQueueEditMode"; /** 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 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" && + 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 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( @@ -84,7 +114,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..4d1b01162c --- /dev/null +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { + conversationRootForSession, + conversationSourceFromImportedHistory, + latestConversationExecution, + writableConversationWorkspacePath, +} from "./useConversationTargetBinding"; + +describe("conversation target binding source", () => { + 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({ + sourceTitle: "Native Claude history", + 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({ + sourceTitle: "Windsurf history", + 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"); + }); +}); diff --git a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts new file mode 100644 index 0000000000..ab6a55464d --- /dev/null +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts @@ -0,0 +1,449 @@ +/** React binding from a canonical conversation to the standard creator controls. */ +import { useAtomValue } from "jotai"; +import { useCallback, 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, +} from "@src/engines/ChatPanel/conversationTargetSelection"; +import { + type ConversationRootLocator, + type ConversationSource, + type LocalConversationTarget, + NATIVE_CONVERSATION_CLI_TARGETS, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + conversationExecutionParentId, + localConversationRootForSession, + parseConversationExecutionParentId, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { useCloudConversationSource } from "@src/features/Org2Cloud/SessionConversation/useCloudConversationSource"; +import type { AdvancedConfig } from "@src/features/SessionCreator/types"; +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 { Session } from "@src/store/session/sessionAtom"; +import { + sessionByIdAtom, + sessionsAtom, +} from "@src/store/session/sessionAtom/atoms"; + +/** + * 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, + sourceTitle: + params.session?.name ?? `${externalSource.displayName} history`, + cliAgentType: compatibleSourceCliAgentType, + model: params.session?.model, + initialTarget: null, + workspaceRepoPath: + params.session?.repoRootPath ?? + params.session?.worktreePath ?? + params.session?.repoPath ?? + null, + }; +} + +/** Recover the target persisted by the newest native execution episode. */ +export function latestConversationExecution( + sessions: readonly Session[], + root: ConversationRootLocator +): Session | undefined { + const parentId = conversationExecutionParentId(root); + return sessions + .filter((candidate) => candidate.parentSessionId === parentId) + .sort((left, right) => + (right.updated_at ?? "").localeCompare(left.updated_at ?? "") + )[0]; +} + +/** 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 { accounts, hasLoaded: accountsLoaded } = useModelAccountLookup(); + const { registry, discoveryState } = useAgentCompatibility(); + const { builtInAgents, agents: customAgents } = useAgentDefinitions(); + const definitions = useMemo( + () => [...builtInAgents, ...customAgents], + [builtInAgents, customAgents] + ); + const cloudSource = useCloudConversationSource({ + sessionId, + session, + sessions, + repos, + }); + const [pickerOverride, setPickerOverride] = useState<{ + rootKey: string; + target: LocalConversationTarget; + } | null>(null); + + const source = useMemo(() => { + const externalSource = conversationSourceFromImportedHistory({ + sessionId, + session, + }); + if (externalSource) return externalSource; + + if (cloudSource.source) { + return cloudSource.source; + } + + 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, + sourceTitle: rootSession.name ?? session.name ?? "Conversation", + 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, session, sessionId, sessions]); + + const sourceRootKey = source ? conversationRootKey(source.root) : null; + const persistedExecution = useMemo( + () => (source ? latestConversationExecution(sessions, source.root) : null), + [sessions, source] + ); + const persistedTarget = useMemo( + () => + persistedExecution + ? localConversationTargetFromSession(persistedExecution) + : null, + [persistedExecution] + ); + const preferredTarget = + pickerOverride?.rootKey === sourceRootKey + ? pickerOverride.target + : 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(() => { + if (!agentDiscoverySettled) return []; + const supported = [...NATIVE_CONVERSATION_CLI_TARGETS] as CliAgentType[]; + return supported.filter((runtime) => + registry.agents.some( + (agent) => + agent.name === runtime && agent.installed && agent.supportsGui + ) + ); + }, [agentDiscoverySettled, registry.agents]); + + const target = useMemo(() => { + if (!source || inventoryLoading) return null; + return resolveDefaultConversationTarget({ + preferredTarget, + initialTarget: source.initialTarget, + sourceCliAgentType: source.cliAgentType, + sourceAgentDefinitionId: source.agentDefinitionId, + sourceModel: source.model, + workspaceRepoPath: cloudSource.workspacePending + ? undefined + : source.workspaceRepoPath, + accounts, + registry, + nativeCliTargets, + }); + }, [ + accounts, + cloudSource.workspacePending, + inventoryLoading, + nativeCliTargets, + registry, + preferredTarget, + source, + ]); + + const hasAvailableRuntime = useMemo( + () => + nativeCliTargets.length > 0 || + (definitions.length > 0 && + getRustCompatibleAccounts(registry, [...accounts]).some( + (account) => account.enabled + )), + [accounts, definitions.length, nativeCliTargets.length, registry] + ); + const readiness = resolveConversationTargetReadiness({ + accountsLoaded, + agentDiscoverySettled, + hasAvailableRuntime, + }); + + const presentation = useMemo(() => { + if (!source || readiness !== "ready" || !target) return null; + return resolveConversationTargetPillPresentation({ + target, + sourceCliAgentType: source.cliAgentType, + sourceAgentDefinitionId: source.agentDefinitionId, + sourceModel: source.model, + 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): boolean => { + if ( + readiness !== "ready" || + isHostedKey(config.keySource) || + !source || + !target + ) { + return false; + } + // A source row always supplies selectedAccountId. An accountless Claude + // selection is therefore an explicit return to the signed-in native CLI + // and must clear the previous Atlas/managed endpoint instead of `??` + // inheriting it. Variant-only changes keep the current source. + const accountId = + config.selectedAccountId ?? + (config.model !== undefined ? target.accountId : undefined); + const model = config.model; + let nextTarget: LocalConversationTarget; + if (target.cliAgentType) { + const ambientClaude = + target.cliAgentType === "claude_code" && !accountId; + if ((!accountId || !model) && !ambientClaude) return false; + nextTarget = { + cliAgentType: target.cliAgentType, + accountId: ambientClaude ? undefined : accountId, + model, + workspaceRepoPath: target.workspaceRepoPath, + }; + } else { + if (!target.agentDefinitionId || !accountId || !model) { + return false; + } + nextTarget = { + agentDefinitionId: target.agentDefinitionId, + accountId, + model, + workspaceRepoPath: target.workspaceRepoPath, + }; + } + setPickerOverride({ + rootKey: conversationRootKey(source.root), + target: nextTarget, + }); + return true; + }, + [readiness, 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, + sourceModel: source.model, + 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; + }, + [ + accounts, + definitions, + nativeCliTargets, + readiness, + registry, + source, + target, + ] + ); + + return useMemo( + () => + source + ? { + root: source.root, + selection: presentation?.selection ?? null, + runtimeSelection, + target, + readiness, + nativeCliTargets, + applyRuntimePick, + applyModelPick, + } + : null, + [ + applyModelPick, + applyRuntimePick, + nativeCliTargets, + presentation, + readiness, + runtimeSelection, + 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 46126c5ceb..0bcedb1785 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/__tests__/useSubmitMessage.test.ts @@ -261,6 +261,7 @@ describe("useSubmitMessage composer boundary", () => { displayText: expected, agentContent: undefined, imageDataUrls: undefined, + composerSnapshot: editorHarness.editor.getSnapshot(), }); expect(handleSessChatSubmit).not.toHaveBeenCalled(); } else { @@ -377,7 +378,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); @@ -400,6 +401,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 83eff683ec..9ea9c4a721 100644 --- a/src/engines/ChatPanel/hooks/useInputArea/index.ts +++ b/src/engines/ChatPanel/hooks/useInputArea/index.ts @@ -35,6 +35,7 @@ import { parseCompactSlashCommand } from "@src/engines/ChatPanel/hooks/useManual import useWorkspaceChat from "@src/engines/ChatPanel/hooks/useWorkspaceChat"; import { useRepositoryInfo } from "@src/engines/SessionCore"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; +import { useConversationRunnerScope } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; import { createLogger } from "@src/hooks/logger"; import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApproval"; import { @@ -66,9 +67,11 @@ import { canvasSlashCommandNeedsInstruction } from "./canvasSlashCommand"; import { resolveDraftRestoreAction } from "./draftRestore"; import { type PlanMentionSourceItem, + resolveInputAreaWorkingState, useInputAreaChatRoundCount, useInputAreaComposerStopBlockingWork, useInputAreaPlanMentionSource, + useInputAreaRunnerTurnActive, } from "./inputAreaEventSelectors"; import type { CustomMentionOption, @@ -166,6 +169,7 @@ export function useInputArea( sessionScope = "active", submitDisabled = false, enableAgentInterceptors = true, + executionControlsEnabled = true, } = options; // ============================================ @@ -186,6 +190,11 @@ export function useInputArea( // Workspace Chat // ============================================ + const conversationRunnerSessionId = useConversationRunnerScope(); + const conversationRunnerTurnActive = useInputAreaRunnerTurnActive( + conversationRunnerSessionId + ); + const { handleSessInputChange, handleSessChatSubmit, @@ -194,7 +203,11 @@ export function useInputArea( isHosted, canStopAgent, canResume, - } = useWorkspaceChat({ sessionId: propSessionId, sessionScope }); + } = useWorkspaceChat({ + sessionId: propSessionId, + sessionScope, + controlSessionId: conversationRunnerSessionId, + }); // ============================================ // Atoms (Global State) @@ -264,8 +277,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: conversationRunnerSessionId, + 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 1a331093f3..50cf88b3c2 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. */ @@ -41,6 +51,8 @@ export interface CustomMentionOption { selectType?: MenuItemId; selectValue?: string; selectDisplayName?: string; + /** Identity-stable collaboration target carried by the inserted pill. */ + audienceTarget?: MessageAudienceTarget; } export interface UseInputAreaOptions { @@ -53,6 +65,8 @@ export interface UseInputAreaOptions { 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 cb60c2827a..1447b008ae 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..85def8c2c4 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,28 @@ 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, + pendingPolicy: "visible", + 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..e53af0ad42 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); @@ -252,7 +261,9 @@ export function useSessionActions(options: UseSessionActionsOptions) { }); })(); }, [ - 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..5937a0d991 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.intervention.test.ts @@ -14,30 +14,18 @@ import { const SESSION_ID = "agent-builtin:sde-worker-intervention"; const mocks = vi.hoisted(() => ({ - addUserMessage: vi.fn(), beginOptimisticTurn: vi.fn(), - beginTurnDispatch: vi.fn(), dispatchMessageBySessionType: vi.fn(), - failOptimisticTurn: vi.fn(), getTurnPhase: vi.fn(), - markTurnTerminal: vi.fn(), mintTurnIntentId: vi.fn(), - removeByIdPrefix: 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/sync/adapters/shared/eventFactories", () => ({ @@ -55,7 +43,6 @@ vi.mock("@src/hooks/logger", () => ({ vi.mock("./useMessageDispatch", () => ({ useMessageDispatch: () => ({ - addUserMessage: mocks.addUserMessage, dispatchMessageBySessionType: mocks.dispatchMessageBySessionType, }), })); @@ -78,31 +65,24 @@ function renderSubmitHook(store: ReturnType) { describe("useUserIntentSubmit Agent Org intervention", () => { beforeEach(() => { - mocks.addUserMessage.mockReset().mockResolvedValue("synthetic-user-1"); mocks.beginOptimisticTurn.mockReset(); - mocks.beginTurnDispatch.mockReset().mockReturnValue(7); mocks.dispatchMessageBySessionType.mockReset().mockResolvedValue(undefined); - mocks.failOptimisticTurn.mockReset(); mocks.getTurnPhase.mockReset().mockReturnValue("idle"); - mocks.markTurnTerminal.mockReset(); mocks.mintTurnIntentId.mockReset().mockReturnValue("turn-intent-1"); - mocks.removeByIdPrefix.mockReset().mockResolvedValue(1); }); - 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 +123,7 @@ describe("useUserIntentSubmit Agent Org intervention", () => { expect(mocks.dispatchMessageBySessionType).not.toHaveBeenCalled(); }); - it("removes the optimistic user event and rejects when backend dispatch fails", async () => { + 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 +133,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..bdeffe3131 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts @@ -12,31 +12,19 @@ import { useCallback, useEffect } 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 { 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"; + admitUserIntentToMessageQueue, + isExplicitPostStopSubmit, +} from "@src/engines/SessionCore/control/messageQueueAdmission"; +import { getTurnPhase } from "@src/engines/SessionCore/control/turnLifecycle"; import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { type SessionRuntimeStatusSource, - closePostStopDispatchEpisodeAtom, isSessionActiveAtom, lastUserMessageAtom, - postStopDispatchSessionsAtom, } from "@src/store/session/cliSessionStatusAtom"; import { creatorDefaultModelSelectionAtom } from "@src/store/session/creatorDefaultModelAtom"; import { sessionMapAtom } from "@src/store/session/sessionAtom"; -import { - enqueueMessageAtom, - messageQueueAtom, - queueFlushRequestAtom, -} from "@src/store/ui/messageQueueAtom"; +import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { @@ -79,7 +67,6 @@ 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. */ @@ -95,13 +82,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,7 +101,6 @@ export function useUserIntentSubmit({ source = "dispatch", applyStopSubmitGuards = false, dedupeDirectSubmit = false, - clearUserInitiatedCancelOnQueue = false, onQueued, onBeforeDirectDispatch, turnIntentId: providedTurnIntentId, @@ -158,9 +139,11 @@ export function useUserIntentSubmit({ imageDataUrls, }) : false; - const explicitPostStopSubmit = - restoredStopDraftSubmit || - store.get(postStopDispatchSessionsAtom)[sessionId] === true; + const explicitPostStopSubmit = isExplicitPostStopSubmit( + store, + sessionId, + restoredStopDraftSubmit + ); if ( dedupeDirectSubmit && @@ -194,18 +177,21 @@ export function useUserIntentSubmit({ session?.agentExecMode ); - const queueResult = enqueueMessage({ - id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - turnIntentId, - sessionId, - content: contentForAgent, - displayContent, - imageDataUrls, - modelSelection: snapshotSelection ?? undefined, - agentExecMode: snapshotMode, - priority: explicitPostStopSubmit ? "now" : "next", - status: "queued", - createdAt: new Date().toISOString(), + const queueResult = admitUserIntentToMessageQueue({ + store, + explicitPostStopSubmit, + message: { + id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + turnIntentId, + sessionId, + content: contentForAgent, + displayContent, + imageDataUrls, + modelSelection: snapshotSelection ?? undefined, + agentExecMode: snapshotMode, + status: "queued", + createdAt: new Date().toISOString(), + }, }); if (queueResult !== "enqueued" && queueResult !== "duplicate") { throw new Error( @@ -214,15 +200,6 @@ export function useUserIntentSubmit({ : "Message queue is full; send or remove a queued message first" ); } - if (clearUserInitiatedCancelOnQueue && explicitPostStopSubmit) { - closePostStopDispatchEpisode(sessionId); - } - if (explicitPostStopSubmit) { - setQueueFlushRequest((requestId) => requestId + 1); - } - if (!explicitPostStopSubmit) { - beginOptimisticTurn(sessionId, "queue"); - } onQueued?.(); return; } @@ -232,71 +209,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..9c2ead10d1 --- /dev/null +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveWorkspaceChatControlSessionId, + 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("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..e8b3b489b2 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useWorkspaceChat.ts @@ -34,10 +34,29 @@ 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; } 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 +111,11 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { activeSessionId, workstationActiveSessionId, ]); + const getControlSessionId = useCallback( + (): string | null => + resolveWorkspaceChatControlSessionId(controlSessionId, getSessionId()), + [controlSessionId, getSessionId] + ); // ============================================ // Sub-hooks @@ -99,7 +123,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 +168,6 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { source: "dispatch", applyStopSubmitGuards: true, dedupeDirectSubmit: true, - clearUserInitiatedCancelOnQueue: true, onQueued: () => setSessChatInput(""), onBeforeDirectDispatch: () => setSessChatInput(""), }); @@ -187,7 +215,7 @@ const useWorkspaceChat = (options: UseWorkspaceChatOptions = {}) => { // ============================================ const effectiveSessionId = isSessionless ? null - : resolvedSessionId || coreSessionId; + : controlSessionId || resolvedSessionId || coreSessionId; const canStopAgent = useMemo( () => diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts index 2f4f9ae4e0..6f3412ffd0 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts @@ -45,6 +45,7 @@ function status( orgChannelMessages: false, orgChannelMessagesIdempotency: false, conversationEvents: false, + conversationEventsIdempotency: false, }, capabilitiesLoading: false, lastSync: { lastPassAtMs: null, lastSuccessAtMs: null }, diff --git a/src/engines/SessionCore/control/messageQueueAdmission.ts b/src/engines/SessionCore/control/messageQueueAdmission.ts new file mode 100644 index 0000000000..f29342d02c --- /dev/null +++ b/src/engines/SessionCore/control/messageQueueAdmission.ts @@ -0,0 +1,41 @@ +import type { Store } from "jotai/vanilla/store"; + +import { postStopDispatchSessionsAtom } from "@src/store/session/cliSessionStatusAtom"; +import { + type QueueAdmissionResult, + type QueuedMessage, + enqueueMessageAtom, +} from "@src/store/ui/messageQueueAtom"; + +/** + * Admit every user-authored queued turn through the same post-Stop policy. + * + * Runtime continuation changes where a queued turn executes, not how Stop, + * Send Now, or explicit queue release behave. Keeping that decision here + * prevents canonical/imported conversations from silently bypassing the + * ordinary composer contract. + */ +export function admitUserIntentToMessageQueue(params: { + store: Store; + message: Omit; + explicitPostStopSubmit: boolean; +}): QueueAdmissionResult { + const { store, message, explicitPostStopSubmit } = params; + const result = store.set(enqueueMessageAtom, { + ...message, + priority: explicitPostStopSubmit ? "now" : "next", + }); + + return result; +} + +export function isExplicitPostStopSubmit( + store: Store, + sessionId: string, + restoredStopDraft = false +): boolean { + return ( + restoredStopDraft || + store.get(postStopDispatchSessionsAtom)[sessionId] === true + ); +} 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..c08c9d7318 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts @@ -3,6 +3,7 @@ import { Provider, createStore } from "jotai"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UserIntentSendError } from "@src/engines/SessionCore/services/userIntentDispatch"; import { type QueuedMessage, messageQueueAtom, @@ -17,17 +18,24 @@ const mocks = vi.hoisted(() => ({ append: vi.fn(), beginOptimisticTurn: vi.fn(), beginTurnDispatch: vi.fn(), + beginTurnStopping: vi.fn(), cancelTurn: vi.fn(), + clearTurnLifecycleSession: vi.fn(), + dispatchCanonicalConversation: vi.fn(), confirmTurnRunning: vi.fn(), failOptimisticTurn: vi.fn(), getSession: vi.fn(), + getTurnGeneration: vi.fn(), getTurnPhase: vi.fn(), markSessionActive: vi.fn(), markTurnTerminal: vi.fn(), messageError: vi.fn(), messageWarning: vi.fn(), - removeByIdPrefix: vi.fn(), + loadDurableMessageQueue: vi.fn(), + persistDurableMessageQueue: vi.fn(), + restoreTurnWorkingAfterInterruptFailure: vi.fn(), sendMessage: vi.fn(), + updateById: vi.fn(), })); vi.mock("@src/api/tauri/agent", () => ({ @@ -54,9 +62,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,7 +77,7 @@ vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { append: mocks.append, - removeByIdPrefix: mocks.removeByIdPrefix, + updateById: mocks.updateById, }, })); @@ -72,8 +85,24 @@ 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) => ({ + 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, deliveryStatus: "pending" }, + displayText: "queued worker follow-up", + displayStatus: "pending", + displayVariant: "message", + activityStatus: "agent", + isDelta: false, + }), })); vi.mock("@src/hooks/logger", () => ({ @@ -89,6 +118,11 @@ vi.mock("@src/store/session", () => ({ markSessionActive: mocks.markSessionActive, })); +vi.mock("@src/store/ui/messageQueueRepository", () => ({ + loadDurableMessageQueue: mocks.loadDurableMessageQueue, + persistDurableMessageQueue: mocks.persistDurableMessageQueue, +})); + vi.mock("@src/util/platform/tauri/init", () => ({ invokeTauri: vi.fn(), })); @@ -128,8 +162,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; } @@ -141,17 +238,29 @@ describe("useQueueDispatch Agent Org intervention", () => { 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.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.loadDurableMessageQueue.mockReset().mockResolvedValue([]); + mocks.persistDurableMessageQueue.mockReset().mockResolvedValue(undefined); + mocks.restoreTurnWorkingAfterInterruptFailure.mockReset(); mocks.sendMessage.mockReset().mockResolvedValue(undefined); + mocks.updateById.mockReset().mockResolvedValue(true); store = createStore(); root = createSmokeRoot(); }); @@ -189,7 +298,7 @@ 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("does not let a blocked Send Now freeze another idle session", async () => { @@ -214,29 +323,280 @@ 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([])); + }); + + 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("drains canonical rows through the singleton headless dispatcher", 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).not.toHaveBeenCalled(); + }); + + it("propagates the provider terminal instead of manufacturing completion", 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(mocks.markTurnTerminal).toHaveBeenCalledWith( + expect.stringContaining("root-1"), + "cancelled", + expect.objectContaining({ generation: 11 }) + ) + ); + }); + + 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(mocks.messageError).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("native launch failed"), + }) + ); + }); + + it("retains a canonical queue card when no optimistic row was stored", async () => { + 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("serializes two canonical turns for one root through turnLifecycle", 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(messageQueueAtom)).toEqual([ expect.objectContaining({ - id: "queued-intervention-1", - requiresExplicitDispatch: true, + id: "canonical-first", + status: "accepted", + runnerSessionId: "runner-canonical-first", }), + 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("admits another canonical root after the first provider accepts", 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(1) + ); + expect(mocks.dispatchCanonicalConversation.mock.calls[0]?.[1].id).toBe( + "root-a" + ); + expect(store.get(messageQueueAtom).map((message) => message.id)).toEqual([ + "root-a", + "root-b", + ]); + acceptFirst(); + await vi.waitFor(() => + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + ); + expect(mocks.dispatchCanonicalConversation.mock.calls[1]?.[1].id).toBe( + "root-b" + ); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).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}`; + await callbacks.onRunnerReady?.(runnerId, 0); + await callbacks.onAccepted(runnerId); + if (message.id === "canonical-running") await firstTerminal; + 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("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..8cdf3a7c94 100644 --- a/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts +++ b/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts @@ -11,6 +11,14 @@ import { persistDurableMessageQueue, } from "@src/store/ui/messageQueueRepository"; +function persistQueueBestEffort(store: Store): void { + void persistDurableMessageQueue(store.get(messageQueueAtom)).catch( + (error) => { + console.warn("[messageQueuePersistence] failed to persist queue", error); + } + ); +} + const hydrationByStore = new WeakMap>(); const unsubscribeByStore = new WeakMap void>(); @@ -19,16 +27,17 @@ function mergeQueues( live: readonly QueuedMessage[] ): QueuedMessage[] { 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. + // Legacy/plain queued rows may have crossed an old backend-ACK/dequeue crash + // window, so keep those parked for an explicit Send Now. Modern canonical + // rows persist preparing/accepted plus their runner Session and reconnect to + // that exact turn automatically; downgrading them would strand a live native + // turn and invite an unsafe replay. for (const message of durable) { byIntent.set(message.turnIntentId, { ...message, - priority: "next", - requiresExplicitDispatch: true, + ...(message.status === "queued" + ? { priority: "next" as const, requiresExplicitDispatch: true } + : {}), }); } // Live mutations made while the async disk read was pending win. @@ -52,10 +61,10 @@ export function hydrateMessageQueue(store: Store): Promise { .then((durable) => { store.set(messageQueueAtom, (live) => mergeQueues(durable, live)); store.set(messageQueueHydratedAtom, true); - void persistDurableMessageQueue(store.get(messageQueueAtom)); + persistQueueBestEffort(store); if (!unsubscribeByStore.has(store)) { const unsubscribe = store.sub(messageQueueAtom, () => { - void persistDurableMessageQueue(store.get(messageQueueAtom)); + persistQueueBestEffort(store); }); unsubscribeByStore.set(store, unsubscribe); } diff --git a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts index 14b94d9abb..ce3028e5ec 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,30 @@ 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, + beginTurnStopping, + clearTurnLifecycleSession, confirmTurnRunning, + getTurnGeneration, getTurnPhase, markTurnTerminal, + restoreTurnWorkingAfterInterruptFailure, } from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { + QueuedConversationBusyError, + type QueuedConversationExecutor, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; 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, +} 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, @@ -63,13 +69,14 @@ import { messageQueueAtom, messageQueueHydratedAtom, queueEditingAtom, + queuedMessageScopeKey, } from "@src/store/ui/messageQueueAtom"; +import { persistDurableMessageQueue } 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 { @@ -83,23 +90,16 @@ import { const log = createLogger("useQueueDispatch"); -const MAX_SENT_QUEUE_ID_CACHE = 200; - -/** - * 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 queuedMessageAgeMs(message: QueuedMessage): number { - const createdAtMs = Date.parse(message.createdAt); - if (!Number.isFinite(createdAtMs)) return MIN_QUEUE_VISIBLE_MS; - return Date.now() - createdAtMs; -} - /** 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; + +function canonicalRecoveryDelayMs(attempt: number): number { + return Math.min( + QUEUE_BACKEND_RECHECK_MS * 2 ** Math.max(0, attempt - 1), + CANONICAL_RECOVERY_RETRY_MAX_MS + ); +} /** * Authoritative pre-dispatch gate for the natural FIFO drain. @@ -132,7 +132,9 @@ async function getBackendDispatchVerdict( } } -export function useQueueDispatch(): void { +export function useQueueDispatch( + executeCanonicalConversation?: QueuedConversationExecutor +): void { const store = useStore(); useEffect(() => { @@ -141,33 +143,130 @@ export function useQueueDispatch(): void { }, [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); + // A canonical root can execute in a different native Session after each + // runtime switch. Keep only the currently running Session id so Send Now + // can address the ordinary interrupt path. Busy/idle ownership remains in + // turnLifecycle; this transient handle is never consulted as a queue gate. + const canonicalRunnerByScopeRef = useRef< + Map + >(new Map()); // 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 persistCanonicalDelivery = useCallback( + async ( + messageId: string, + update: Pick< + QueuedMessage, + | "status" + | "runnerSessionId" + | "runnerEventStartIndex" + | "retryAt" + | "retryAttempt" + > + ) => { + store.set(messageQueueAtom, (current) => + current.map((candidate) => + candidate.id === messageId ? { ...candidate, ...update } : candidate + ) + ); + // This is the crash-recovery boundary: provider dispatch may proceed + // only after the same durable queue row knows its concrete native + // Session. The ordinary queue subscription remains the coalesced writer + // for non-critical reorder/edit mutations. + await persistDurableMessageQueue(store.get(messageQueueAtom)); + }, + [store] + ); + + const settleQueuedMessageFailure = useCallback( + (message: QueuedMessage, error: unknown) => { + // Once dispatchUserIntent has created a durable failed user row, that + // row is the only retry owner. Failures before that boundary keep the + // queue copy parked so the user's payload is never lost. + store.set(messageQueueAtom, (current) => + isUserIntentSendError(error) + ? current.filter((candidate) => candidate.id !== message.id) + : current.map((candidate) => + candidate.id === message.id + ? { + ...candidate, + status: "queued", + runnerSessionId: undefined, + runnerEventStartIndex: undefined, + retryAt: undefined, + retryAttempt: undefined, + priority: "next", + requiresExplicitDispatch: true, + } + : candidate + ) + ); + interruptRequestedByMessageIdRef.current.delete(message.id); + const detail = error instanceof Error ? error.message : String(error); + Message.error({ + content: `Failed to send message: ${detail}`, + duration: 5000, + }); + }, + [store] + ); - // Pending wake-up for MIN_QUEUE_VISIBLE_MS waits. + // Pending wake-up for backend-busy retries. const wakeTimerRef = useRef(null); + const canonicalRecoveryWakeTimerRef = useRef(null); + const canonicalRecoveryWakeAtRef = useRef(null); const tryDispatchNextRef = useRef<() => void>(() => {}); + const armCanonicalRecoveryWake = useCallback( + function armRecoveryWake(retryAt: number) { + if ( + canonicalRecoveryWakeAtRef.current !== null && + canonicalRecoveryWakeAtRef.current <= retryAt + ) { + return; + } + if (canonicalRecoveryWakeTimerRef.current !== null) { + window.clearTimeout(canonicalRecoveryWakeTimerRef.current); + } + canonicalRecoveryWakeAtRef.current = retryAt; + canonicalRecoveryWakeTimerRef.current = window.setTimeout( + () => { + canonicalRecoveryWakeTimerRef.current = null; + canonicalRecoveryWakeAtRef.current = null; + tryDispatchNextRef.current(); + const now = Date.now(); + const nextRetryAt = store + .get(messageQueueAtom) + .reduce((earliest, message) => { + const candidate = Date.parse(message.retryAt ?? ""); + if (candidate <= now || !Number.isFinite(candidate)) + return earliest; + return earliest === undefined || candidate < earliest + ? candidate + : earliest; + }, undefined); + if (nextRetryAt !== undefined) armRecoveryWake(nextRetryAt); + }, + Math.max(0, retryAt - Date.now()) + ); + }, + [store] + ); const dispatchMessage = useCallback( (msg: QueuedMessage, onDone: () => void) => { @@ -189,19 +288,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,97 +295,212 @@ 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) - ); + acceptQueuedMessage(msg.id); + onDone(); + } catch (err) { + log.error("[useQueueDispatch] dispatch failed:", err); + settleQueuedMessageFailure(msg, err); 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", + } + })(); + }, + [acceptQueuedMessage, settleQueuedMessageFailure, store] + ); + + const dispatchCanonicalMessage = useCallback( + (msg: QueuedMessage, onDone: () => void) => { + if (!msg.conversationDispatch) { + onDone(); + return; + } + const scopeKey = queuedMessageScopeKey(msg); + const dispatchGeneration = beginTurnDispatch(scopeKey); + // Loading and materializing a native transcript is already owned work. + // It can legitimately outlive the dispatching dead-man before the + // provider accepts the user turn, so enter the ordinary working phase. + confirmTurnRunning(scopeKey); + let accepted = false; + let releasedDispatchLock = false; + let runnerSessionId: string | null = null; + const releaseDispatchLock = () => { + if (releasedDispatchLock) return; + releasedDispatchLock = true; + onDone(); + }; + const rememberRunner = (sessionId: string) => { + if (getTurnGeneration(scopeKey) !== dispatchGeneration) return; + runnerSessionId = sessionId; + canonicalRunnerByScopeRef.current.set(scopeKey, { + generation: dispatchGeneration, + sessionId, + }); + }; + + const execution = (async () => { + await persistCanonicalDelivery(msg.id, { + status: "preparing", + runnerSessionId: msg.runnerSessionId, + runnerEventStartIndex: msg.runnerEventStartIndex, + retryAt: undefined, + retryAttempt: msg.retryAttempt, + }); + if (!executeCanonicalConversation) { + throw new Error("canonical conversation executor is unavailable"); + } + const persistedMessage = + store + .get(messageQueueAtom) + .find((candidate) => candidate.id === msg.id) ?? msg; + return await executeCanonicalConversation(store, persistedMessage, { + onAccepted: async (sessionId) => { + if (accepted) return; + accepted = true; + rememberRunner(sessionId); + await persistCanonicalDelivery(msg.id, { + status: "accepted", + runnerSessionId: sessionId, + runnerEventStartIndex: + store + .get(messageQueueAtom) + .find((candidate) => candidate.id === msg.id) + ?.runnerEventStartIndex ?? msg.runnerEventStartIndex, + retryAt: undefined, + retryAttempt: msg.retryAttempt, }); - markTurnTerminal(sessionId, "completed", { + releaseDispatchLock(); + }, + onRunnerReady: async (sessionId, eventStartIndex) => { + rememberRunner(sessionId); + await persistCanonicalDelivery(msg.id, { + status: "preparing", + runnerSessionId: sessionId, + runnerEventStartIndex: eventStartIndex, + retryAt: undefined, + retryAttempt: msg.retryAttempt, + }); + }, + }); + })(); + + void execution + .then( + (result) => { + acceptQueuedMessage(msg.id); + markTurnTerminal(scopeKey, result.terminalStatus, { generation: dispatchGeneration, }); - } - } 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 + }, + async (error: unknown) => { + if (error instanceof QueuedConversationBusyError) { + // Another window owns this root. Persist the same bounded + // recovery backoff as any accepted retry; a fixed 250 ms poll + // burned CPU for the complete duration of a long provider turn. + const current = store + .get(messageQueueAtom) + .find((candidate) => candidate.id === msg.id); + if (current) { + const attempt = (current.retryAttempt ?? 0) + 1; + await persistCanonicalDelivery(msg.id, { + status: current.status, + runnerSessionId: current.runnerSessionId, + runnerEventStartIndex: current.runnerEventStartIndex, + retryAttempt: attempt, + retryAt: new Date( + Date.now() + canonicalRecoveryDelayMs(attempt) + ).toISOString(), + }); + } + markTurnTerminal(scopeKey, "cancelled", { + generation: dispatchGeneration, + }); + return; + } + if (!accepted) { + settleQueuedMessageFailure(msg, error); + } else { + log.error( + "[useQueueDispatch] canonical provider turn failed after acceptance:", + error ); + const current = store + .get(messageQueueAtom) + .find((candidate) => candidate.id === msg.id); + if (current) { + const attempt = (current.retryAttempt ?? 0) + 1; + await persistCanonicalDelivery(msg.id, { + status: current.status, + runnerSessionId: current.runnerSessionId, + runnerEventStartIndex: current.runnerEventStartIndex, + retryAttempt: attempt, + retryAt: new Date( + Date.now() + canonicalRecoveryDelayMs(attempt) + ).toISOString(), + }); + } } + markTurnTerminal(scopeKey, "failed", { + generation: dispatchGeneration, + }); } - // 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 - ) + ) + .finally(() => { + const currentRunner = canonicalRunnerByScopeRef.current.get(scopeKey); + if ( + currentRunner?.generation === dispatchGeneration && + currentRunner.sessionId === runnerSessionId + ) { + canonicalRunnerByScopeRef.current.delete(scopeKey); + } + // Canonical scope ids are virtual and do not participate in normal + // Session deletion cleanup. Drop now-idle state eagerly. + if (getTurnPhase(scopeKey) === "idle") { + clearTurnLifecycleSession(scopeKey); + } + releaseDispatchLock(); + tryDispatchNextRef.current(); + const retryAt = Date.parse( + store + .get(messageQueueAtom) + .find((candidate) => candidate.id === msg.id)?.retryAt ?? "" ); - onDone(); - const detail = err instanceof Error ? err.message : String(err); - Message.error({ - content: `Failed to send message: ${detail}`, - duration: 5000, - }); - } - })(); + if (Number.isFinite(retryAt)) { + armCanonicalRecoveryWake(retryAt); + } + }); }, - [rememberSentQueueId, store] + [ + acceptQueuedMessage, + armCanonicalRecoveryWake, + executeCanonicalConversation, + persistCanonicalDelivery, + settleQueuedMessageFailure, + store, + ] ); const tryDispatchNext = useCallback(() => { @@ -314,11 +515,26 @@ export function useQueueDispatch(): void { const queue = store.get(messageQueueAtom); if (queue.length === 0) return; + const now = Date.now(); const candidates = queue.filter( (msg) => msg.id !== inFlightMessageIdRef.current && - !sentQueuedMessageIdsRef.current.has(msg.id) + (Number.isNaN(Date.parse(msg.retryAt ?? "")) || + Date.parse(msg.retryAt ?? "") <= now) + ); + const earliestDeferredRetry = queue.reduce( + (earliest, message) => { + const candidate = Date.parse(message.retryAt ?? ""); + if (!Number.isFinite(candidate) || candidate <= now) return earliest; + return earliest === undefined || candidate < earliest + ? candidate + : earliest; + }, + undefined ); + if (earliestDeferredRetry !== undefined) { + armCanonicalRecoveryWake(earliestDeferredRetry); + } // ── 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 +542,18 @@ 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); + const scopeKey = queuedMessageScopeKey(explicitMsg); + const phase = getTurnPhase(scopeKey); if (phase === "idle") { + // 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 +566,43 @@ export function useQueueDispatch(): void { (phase === "working" || phase === "dispatching") && !interruptRequestedByMessageIdRef.current.has(explicitMsg.id) ) { + const interruptSessionId = explicitMsg.conversationDispatch + ? canonicalRunnerByScopeRef.current.get(scopeKey)?.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); + if (explicitMsg.conversationDispatch) { + beginTurnStopping(scopeKey); + } + const interruptGeneration = getTurnGeneration(interruptSessionId); + const scopeGeneration = getTurnGeneration(scopeKey); + let interruptFailureHandled = false; + const handleInterruptFailure = (detail: string) => { + if (interruptFailureHandled) return; + interruptFailureHandled = true; + restoreTurnWorkingAfterInterruptFailure(interruptSessionId, { + generation: interruptGeneration, + }); + if (scopeKey !== interruptSessionId) { + restoreTurnWorkingAfterInterruptFailure(scopeKey, { + generation: scopeGeneration, + }); + } + settleQueuedMessageFailure(explicitMsg, new Error(detail)); + 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 @@ -364,13 +613,18 @@ export function useQueueDispatch(): void { for (const msg of candidates) { if (msg.priority === "now") 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; + const scopeKey = queuedMessageScopeKey(msg); + if (getTurnPhase(scopeKey) !== "idle") continue; // turn active + if (msg.conversationDispatch) { + dispatchLockRef.current = true; + inFlightMessageIdRef.current = msg.id; + dispatchCanonicalMessage(msg, () => { + if (inFlightMessageIdRef.current === msg.id) { + inFlightMessageIdRef.current = null; + } + dispatchLockRef.current = false; tryDispatchNextRef.current(); - }, remainingVisibleMs); + }); return; } dispatchLockRef.current = true; @@ -433,7 +687,13 @@ export function useQueueDispatch(): void { }); return; } - }, [dispatchMessage, store]); + }, [ + dispatchCanonicalMessage, + dispatchMessage, + armCanonicalRecoveryWake, + settleQueuedMessageFailure, + store, + ]); useEffect(() => { tryDispatchNextRef.current = tryDispatchNext; @@ -448,6 +708,11 @@ export function useQueueDispatch(): void { window.clearTimeout(wakeTimerRef.current); wakeTimerRef.current = null; } + if (canonicalRecoveryWakeTimerRef.current !== null) { + window.clearTimeout(canonicalRecoveryWakeTimerRef.current); + canonicalRecoveryWakeTimerRef.current = null; + canonicalRecoveryWakeAtRef.current = null; + } }; }, [store, tryDispatchNext]); } 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/userIntentDispatch.test.ts b/src/engines/SessionCore/services/userIntentDispatch.test.ts new file mode 100644 index 0000000000..505f33a623 --- /dev/null +++ b/src/engines/SessionCore/services/userIntentDispatch.test.ts @@ -0,0 +1,363 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + pendingSyntheticEventAtom, + sessionIdAtom, +} from "@src/engines/SessionCore/core/atoms/metadata"; + +import { + clearParkedUserIntentEvent, + confirmUserIntentPreparation, + dispatchUserIntent, + prepareUserIntent, +} 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(), + getPersistedEvents: vi.fn(), + updateById: vi.fn(), + sendMessage: vi.fn(), + beginOptimisticTurn: vi.fn(), + failOptimisticTurn: vi.fn(), + beginTurnDispatch: 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, + getPersistedEvents: mocks.getPersistedEvents, + updateById: mocks.updateById, + }, +})); + +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, + 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", () => ({ + 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.append.mockResolvedValue(undefined); + mocks.getPersistedEvents.mockResolvedValue([]); + mocks.updateById.mockResolvedValue(true); + mocks.sendMessage.mockResolvedValue(undefined); + mocks.createSyntheticUserEvent.mockImplementation((sessionId: string) => + syntheticEvent(sessionId, `user-${sessionId}`) + ); + }); + + 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", + pendingPolicy: "across_session_switch", + 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" + ); + expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toMatchObject({ + id: "user-agentsession-1", + displayStatus: "failed", + result: expect.objectContaining({ deliveryStatus: "failed" }), + }); + }); + + 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("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", + pendingPolicy: "across_session_switch", + }); + + 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); + expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toEqual( + expect.objectContaining({ id: "user-cliagent-1" }) + ); + + clearParkedUserIntentEvent(preparation.userEvent.id); + expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toBeNull(); + }); + + it("rejects a preparation from a different concrete session", async () => { + const source = await prepareUserIntent({ + sessionId: "cliagent-source", + visibleText: "hello", + turnIntentId: "intent-transfer", + runtimeStatusSource: "launch", + pendingPolicy: "across_session_switch", + }); + 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..7bef6305d7 --- /dev/null +++ b/src/engines/SessionCore/services/userIntentDispatch.ts @@ -0,0 +1,374 @@ +/** + * 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 { + beginTurnDispatch, + confirmTurnRunning, + markTurnTerminal, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import { + pendingSyntheticEventAtom, + sessionIdAtom, +} from "@src/engines/SessionCore/core/atoms/metadata"; +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 { 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"); + +export type UserIntentPendingPolicy = + | "none" + | "visible" + | "across_session_switch"; + +export interface UserIntentPreparation { + sessionId: string; + userEvent: SessionEvent; + generation: number; + turnIntentId: string; + runtimeStatusSource: SessionRuntimeStatusSource; + pendingPolicy: UserIntentPendingPolicy; +} + +interface PrepareUserIntentParams { + sessionId: string; + visibleText: string; + imageDataUrls?: string[]; + turnIntentId: string; + runtimeStatusSource?: SessionRuntimeStatusSource; + pendingPolicy?: UserIntentPendingPolicy; + /** Preserve the durable queue identity on a newly created optimistic row. */ + queueMessageId?: string; + /** Runs after the synchronous lifecycle reserve and before EventStore I/O. */ + beforeAppend?: () => void | Promise; +} + +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 parkUserIntentEvent( + event: SessionEvent, + policy: UserIntentPendingPolicy +): void { + if (policy === "none") return; + const store = getInstrumentedStore(); + if ( + policy === "across_session_switch" || + store.get(sessionIdAtom) === event.sessionId + ) { + store.set(pendingSyntheticEventAtom, event); + } +} + +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 } : {}), + }, + }; +} + +async function setUserIntentDelivery( + preparation: UserIntentPreparation, + status: "pending" | "sent" | "failed", + error?: unknown +): Promise { + const next = deliveryEvent(preparation.userEvent, status, error); + preparation.userEvent = next; + parkUserIntentEvent(next, preparation.pendingPolicy); + const updated = await eventStoreProxy.updateById( + next.id, + { displayStatus: next.displayStatus, result: next.result }, + preparation.sessionId + ); + if (!updated) { + throw new Error( + `optimistic user event ${next.id} is missing from ${preparation.sessionId}` + ); + } +} + +export function clearParkedUserIntentEvent(userEventId: string): void { + const store = getInstrumentedStore(); + const pending = store.get(pendingSyntheticEventAtom); + if (pending?.id === userEventId) { + store.set(pendingSyntheticEventAtom, null); + } +} + +/** + * 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 pendingPolicy = params.pendingPolicy ?? "none"; + 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, { + imageDataUrls: params.imageDataUrls, + turnIntentId: params.turnIntentId, + deliveryStatus: "pending", + queueMessageId: params.queueMessageId, + }); + parkUserIntentEvent(userEvent, pendingPolicy); + await eventStoreProxy.append([userEvent], params.sessionId); + const preparation = { + sessionId: params.sessionId, + userEvent, + generation, + turnIntentId: params.turnIntentId, + runtimeStatusSource, + pendingPolicy, + }; + 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); + parkUserIntentEvent(failed, pendingPolicy); + await eventStoreProxy + .updateById( + failed.id, + { displayStatus: failed.displayStatus, result: failed.result }, + params.sessionId + ) + .catch(() => false); + } + throw error; + } +} + +/** 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; + preparationStates.set(preparation, "failed"); + failOptimisticTurn(preparation.sessionId, preparation.runtimeStatusSource); + markTurnTerminal(preparation.sessionId, "failed", { + generation: preparation.generation, + }); + await setUserIntentDelivery(preparation, "failed", error); +} + +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, + pendingPolicy: params.pendingPolicy, + beforeAppend: params.beforeAppend, + queueMessageId: params.queueMessageId, + }); + } + 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. + parkUserIntentEvent(existing.userEvent, existing.pendingPolicy); + 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: () => + 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/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/modules/SessionWindow/index.tsx b/src/modules/SessionWindow/index.tsx index 34380d5350..1505277c1e 100644 --- a/src/modules/SessionWindow/index.tsx +++ b/src/modules/SessionWindow/index.tsx @@ -48,6 +48,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/queuedConversationExecutor"; import SessionViewersIndicator from "@src/features/Org2Cloud/SessionViewersIndicator"; import { useNativeSessionStatusMonitor } from "@src/hooks/session/useNativeSessionStatusMonitor"; import { getChatPanelBackgroundStyle } from "@src/modules/shared/layouts/viewContainerTokens"; @@ -76,7 +77,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 ; }; diff --git a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx index 91cb6a4030..13d65f56d7 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown.tsx @@ -56,6 +56,8 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { const data = getItemData(item); const rightContent = data.rightContent as React.ReactNode | 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(() => { @@ -78,9 +80,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 && ( @@ -97,6 +100,9 @@ const DropdownRow: React.FC = ({ item, keyboardProps }) => { {item.desc} )}
+ {tagLabel && ( + {tagLabel} + )} {rightContent &&
{rightContent}
} ); @@ -105,6 +111,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< @@ -119,9 +126,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); @@ -130,6 +139,7 @@ export const DispatchCategoryDropdown: React.FC< isOpen, hideOrgs, hideCliAgents, + allowedCliAgentTypes, cliOnly, includeHumanSession, currentCategory, @@ -185,7 +195,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?.(); }, []); @@ -198,12 +208,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/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/index.tsx b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx index b030f93557..51f34c5ce6 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/index.tsx @@ -51,6 +51,7 @@ import { PaletteBody, ShellFooterAction, SpotlightShell } from "../../shell"; import type { PathSegment, SpotlightItem } from "../../types"; import { useSelectorKernel } from "../core"; import { CliAgentListFilterSwitch } from "./CliAgentListFilterSwitch"; +import { cliAgentCapabilityDisabled } from "./cliAgentCapability"; import { createHumanSessionOption } from "./humanSessionOption"; import type { AgentOption, DispatchCategoryPaletteProps } from "./types"; @@ -118,6 +119,7 @@ export const DispatchCategoryPalette: React.FC< currentCliAgentType, hideOrgs = false, hideCliAgents = false, + allowedCliAgentTypes, cliOnly = false, includeHumanSession = false, titleLabel, @@ -254,6 +256,10 @@ export const DispatchCategoryPalette: React.FC< const parsed = CliAgentTypeSchema.safeParse(agent.name); if (!parsed.success) return []; const agentType = parsed.data; + 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( @@ -272,11 +278,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 +459,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 +473,7 @@ export const DispatchCategoryPalette: React.FC< : undefined, }, action: () => { + if (option.disabled) return; recordRecentAgentSelection({ category: option.category, targetKind: option.targetKind, @@ -569,7 +587,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 2ae76897e7..875b0475dc 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/types.ts @@ -30,6 +30,9 @@ export interface AgentOption { isBuiltIn: boolean; isCli: boolean; isOrg: boolean; + /** Keep capability-gated runtimes visible without allowing a lossy launch. */ + disabled?: boolean; + disabledLabel?: string; rightContent?: React.ReactNode; } @@ -46,6 +49,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 b44cf89fb0..4a76cbf2f6 100644 --- a/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/useDispatchCategoryOptions.tsx @@ -45,6 +45,7 @@ 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 { createHumanSessionOption } from "./humanSessionOption"; import type { AgentOption, AgentSelection } from "./types"; @@ -58,6 +59,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; @@ -131,6 +133,7 @@ export function useDispatchCategoryOptions( isOpen, hideOrgs, hideCliAgents = false, + allowedCliAgentTypes, cliOnly = false, includeHumanSession = false, currentCategory, @@ -254,6 +257,10 @@ export function useDispatchCategoryOptions( const parsed = CliAgentTypeSchema.safeParse(agent.name); if (!parsed.success) return []; const agentType = parsed.data; + const disabled = cliAgentCapabilityDisabled( + agentType, + allowedCliAgentTypes + ); // CLI agents only show plan (subscription) accounts in the badge. const compatibleAccounts = getCliCompatibleAccounts( registry, @@ -271,11 +278,13 @@ export function useDispatchCategoryOptions( isBuiltIn: true, isCli: true, isOrg: false, + 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); @@ -467,6 +476,8 @@ export function useDispatchCategoryOptions( 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}` @@ -479,6 +490,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 5f03112a97..693cf2c61c 100644 --- a/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx +++ b/src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/index.tsx @@ -54,10 +54,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 setDefaultSpotlightOpen = useSetAtom(spotlightOpenAtom); 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.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx index f3e9c4d353..c9ee52f110 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, @@ -251,10 +250,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/__tests__/sessionTabPlacementAtom.test.ts b/src/store/session/__tests__/sessionTabPlacementAtom.test.ts index 2fc111641b..5cb01cca99 100644 --- a/src/store/session/__tests__/sessionTabPlacementAtom.test.ts +++ b/src/store/session/__tests__/sessionTabPlacementAtom.test.ts @@ -23,11 +23,14 @@ import { } from "@src/util/core/state/instrumentedStore"; import { + clearSessionContinuationAtom, moveSessionTabAtom, openSessionInNewWindowAtom, openSessionInWorkstationAtom, + publishSessionContinuationAtom, retargetChatPanelSessionTabAtom, retargetWorkstationSessionTabAtom, + sessionContinuationNoticesAtom, } from "../sessionTabPlacementAtom"; vi.mock("@src/api/tauri/sessionWindow", () => ({ @@ -77,6 +80,36 @@ describe("session tab placement", () => { vi.useRealTimers(); }); + it("publishes and consumes a surface-neutral continuation exactly once", () => { + const store = createInstrumentedStore(); + store.set(publishSessionContinuationAtom, { + sourceSessionId: "codexapp-source", + sessionId: "agentsession-continuation", + sessionName: "Continue imported history", + repoPath: "/repo", + }); + + expect(store.get(sessionContinuationNoticesAtom)).toEqual({ + "codexapp-source": expect.objectContaining({ + sessionId: "agentsession-continuation", + }), + }); + + store.set(clearSessionContinuationAtom, { + sourceSessionId: "codexapp-source", + sessionId: "another-session", + }); + expect(store.get(sessionContinuationNoticesAtom)).toHaveProperty( + "codexapp-source" + ); + + store.set(clearSessionContinuationAtom, { + sourceSessionId: "codexapp-source", + sessionId: "agentsession-continuation", + }); + expect(store.get(sessionContinuationNoticesAtom)).toEqual({}); + }); + it("moves a Chat Panel session into Workstation without copying the tab", () => { const store = createInstrumentedStore(); store.set(sessionsAtom, [session("session-1", "Live session")]); diff --git a/src/store/session/agentRegistryAtom.ts b/src/store/session/agentRegistryAtom.ts index e804fdce9d..035a92b2bf 100644 --- a/src/store/session/agentRegistryAtom.ts +++ b/src/store/session/agentRegistryAtom.ts @@ -17,7 +17,17 @@ export interface AgentRegistry { apiProviders: AvailableApiProvider[]; } +/** Initial agent discovery state. A loaded empty registry is distinct from boot. */ +export type AgentRegistryDiscoveryState = + | "idle" + | "loading" + | "ready" + | "error"; + export const agentRegistryAtom = atom({ agents: [], apiProviders: [], }); + +export const agentRegistryDiscoveryStateAtom = + atom("idle"); diff --git a/src/store/session/sessionAtom/mergeSessions.ts b/src/store/session/sessionAtom/mergeSessions.ts index df0d71a6b4..81f9ae6f9b 100644 --- a/src/store/session/sessionAtom/mergeSessions.ts +++ b/src/store/session/sessionAtom/mergeSessions.ts @@ -43,7 +43,7 @@ export function loadSessionsCacheSignature( * The flat-list roster intentionally excludes imported replay copies (their * display entry is the Team Conversations row), but the LOCAL row still owns * the open surface's identity — importedFrom drives the comments target, - * fork-before-send routing, and sender attribution. A wholesale roster + * canonical continuation routing, and sender attribution. A wholesale roster * replace must therefore carry resident import copies over instead of * evicting them mid-view; explicit removal stays the only way they leave. */ diff --git a/src/store/session/sessionTabPlacementAtom.ts b/src/store/session/sessionTabPlacementAtom.ts index be848c7f3a..b15d2b2e43 100644 --- a/src/store/session/sessionTabPlacementAtom.ts +++ b/src/store/session/sessionTabPlacementAtom.ts @@ -23,6 +23,49 @@ export interface SessionContinuation { repoPath?: string; } +export interface SessionContinuationNotice extends SessionContinuation { + sourceSessionId: string; +} + +/** + * Surface-neutral handoff emitted when an immutable/read-only source acquires + * a writable native episode. The mounted ChatView owns presentation, so the + * same signal works in Chat Panel, Workstation, and detached SessionWindow. + */ +export const sessionContinuationNoticesAtom = atom< + Record +>({}); +sessionContinuationNoticesAtom.debugLabel = "sessionContinuationNotices"; + +export const publishSessionContinuationAtom = atom( + null, + (_get, set, notice: SessionContinuationNotice) => { + set(sessionContinuationNoticesAtom, (current) => ({ + ...current, + [notice.sourceSessionId]: notice, + })); + } +); +publishSessionContinuationAtom.debugLabel = "publishSessionContinuation"; + +export const clearSessionContinuationAtom = atom( + null, + ( + get, + set, + expected: Pick + ) => { + const current = get(sessionContinuationNoticesAtom); + if (current[expected.sourceSessionId]?.sessionId !== expected.sessionId) { + return; + } + const next = { ...current }; + delete next[expected.sourceSessionId]; + set(sessionContinuationNoticesAtom, next); + } +); +clearSessionContinuationAtom.debugLabel = "clearSessionContinuation"; + export interface OpenSessionInWorkstationOptions { sessionId: string; title?: string; diff --git a/src/store/ui/__tests__/messageQueueAtom.test.ts b/src/store/ui/__tests__/messageQueueAtom.test.ts index bc8ccc728e..d62fa9e6a0 100644 --- a/src/store/ui/__tests__/messageQueueAtom.test.ts +++ b/src/store/ui/__tests__/messageQueueAtom.test.ts @@ -13,8 +13,8 @@ import { editMessageAtom, enqueueMessageAtom, forceSendMessageAtom, - holdSessionQueueForStopAtom, messageQueueAtom, + parkSessionQueuedMessagesAfterStopAtom, queueEditTargetAtom, queueEditingAtom, reorderQueueAtom, @@ -99,8 +99,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 +120,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]); }); @@ -254,10 +254,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 +266,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 +282,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/messageQueueAtom.ts b/src/store/ui/messageQueueAtom.ts index b3a7b04f3c..43d8bcd3e8 100644 --- a/src/store/ui/messageQueueAtom.ts +++ b/src/store/ui/messageQueueAtom.ts @@ -2,6 +2,9 @@ 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/queuedConversationExecutor"; +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 +13,7 @@ import { isCliSession } from "@src/util/session/sessionDispatch"; // ============================================ export type QueuedMessagePriority = "now" | "next"; +export type QueuedMessageDeliveryState = "queued" | "preparing" | "accepted"; export interface QueuedMessage { id: string; @@ -36,6 +40,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,7 +73,19 @@ export interface QueuedMessage { * dispatch them. */ requiresExplicitDispatch?: boolean; - status: "queued"; + /** + * Durable delivery state for the same queue row. Canonical continuations + * keep the row through provider completion so a renderer restart can + * reconnect to the exact native turn instead of replaying it. + */ + status: QueuedMessageDeliveryState; + /** Concrete native Session selected before provider dispatch. */ + runnerSessionId?: string; + /** Verified native prefix used by the live overlay once materialized. */ + runnerEventStartIndex?: number; + /** Durable recovery backoff for an accepted canonical turn. */ + retryAt?: string; + retryAttempt?: number; createdAt: string; } @@ -95,6 +112,12 @@ export function queuedMessageCharSize(message: QueuedMessage): number { ); } +export function queuedMessageScopeKey(message: QueuedMessage): string { + return message.conversationDispatch + ? `conversation:${conversationRootKey(message.conversationDispatch.root)}` + : message.sessionId; +} + export function queueAdmissionResult( current: readonly QueuedMessage[], message: QueuedMessage @@ -102,8 +125,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"; } @@ -156,39 +180,30 @@ 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.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)); + set(messageQueueAtom, (prev) => + prev.filter((msg) => msg.id !== messageId || msg.status !== "queued") + ); }); dequeueMessageAtom.debugLabel = "dequeueMessageAtom"; @@ -202,11 +217,28 @@ dequeueMessageAtom.debugLabel = "dequeueMessageAtom"; export const forceSendMessageAtom = atom( null, (get, set, messageId: string) => { - if (!get(messageQueueAtom).some((msg) => msg.id === 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; minting here prevents that terminal id from + // making the visible retry permanently unrunnable. + turnIntentId: mintTurnIntentId(), + priority: "now", + requiresExplicitDispatch: false, + retryAt: undefined, + retryAttempt: undefined, + } : msg ) ); @@ -219,25 +251,56 @@ 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 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 + (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) => + msg.status !== "queued" || + (msg.sessionId !== sessionId && + (msg.conversationDispatch === undefined || + !conversationKeys.has( + conversationRootKey(msg.conversationDispatch.root) + ))) + ) ); } ); @@ -250,7 +313,9 @@ export const clearQueuedMessagesAtom = atom( if (messageIds.length === 0) return; const ids = new Set(messageIds); set(messageQueueAtom, (prev) => - prev.filter((message) => !ids.has(message.id)) + prev.filter( + (message) => message.status !== "queued" || !ids.has(message.id) + ) ); } ); @@ -273,7 +338,7 @@ export const editMessageAtom = atom( 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 +364,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: mintTurnIntentId(), content: projection.agentContent ?? projection.displayContent, displayContent: projection.displayContent, ...(update.imageDataUrls !== undefined && { @@ -310,6 +380,8 @@ export const editMessageAtom = atom( ...(update.agentExecMode !== undefined && { agentExecMode: update.agentExecMode, }), + retryAt: undefined, + retryAttempt: undefined, }; const siblings = prev.filter((item) => item.id !== msg.id); if (queueAdmissionResult(siblings, next)) return msg; @@ -322,14 +394,6 @@ 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, ( @@ -343,7 +407,9 @@ export const reorderQueueAtom = atom( fromIndex < 0 || toIndex < 0 || fromIndex >= prev.length || - toIndex >= prev.length + toIndex >= prev.length || + prev[fromIndex]?.status !== "queued" || + prev[toIndex]?.status !== "queued" ) { return prev; } diff --git a/src/store/ui/messageQueueRepository.ts b/src/store/ui/messageQueueRepository.ts index 058a420499..d9f5ba4a27 100644 --- a/src/store/ui/messageQueueRepository.ts +++ b/src/store/ui/messageQueueRepository.ts @@ -1,5 +1,9 @@ import { type Store, load } from "@tauri-apps/plugin-store"; +import { + isConversationRootLocator, + isLocalConversationTarget, +} from "@src/engines/SessionCore/conversations/conversationTypes"; import { createLogger } from "@src/hooks/logger"; import { @@ -12,25 +16,66 @@ import { const log = createLogger("messageQueueRepository"); const STORE_PATH = "chat-message-queue.json"; const STORE_KEY_PREFIX = "queue"; +const STORE_LOCK_NAME = "orgii:chat-message-queue-store"; let storePromise: Promise | null = null; let queueKeyPromise: Promise | null = null; let writeChain: Promise = Promise.resolve(); +let fallbackStoreLock: Promise = Promise.resolve(); + +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 conversationDispatch = item.conversationDispatch; + const validConversationDispatch = + conversationDispatch === undefined || + (conversationDispatch.kind === "canonical_conversation" && + isConversationRootLocator(conversationDispatch.root) && + isLocalConversationTarget(conversationDispatch.target) && + (conversationDispatch.dispatchIdentityKey === undefined || + typeof conversationDispatch.dispatchIdentityKey === "string")); 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.priority === "now" || item.priority === "next") && - item.status === "queued" && + (item.status === "queued" || + item.status === "preparing" || + item.status === "accepted") && + (item.runnerSessionId === undefined || + typeof item.runnerSessionId === "string") && + (item.runnerEventStartIndex === undefined || + (typeof item.runnerEventStartIndex === "number" && + Number.isSafeInteger(item.runnerEventStartIndex) && + item.runnerEventStartIndex >= 0)) && + (item.retryAt === undefined || + (typeof item.retryAt === "string" && + Number.isFinite(Date.parse(item.retryAt)))) && + (item.retryAttempt === undefined || + (typeof item.retryAttempt === "number" && + Number.isSafeInteger(item.retryAttempt) && + item.retryAttempt >= 0)) && + (item.status !== "accepted" || typeof item.runnerSessionId === "string") && typeof item.createdAt === "string" && queuedMessageCharSize(item as QueuedMessage) <= MAX_QUEUED_MESSAGE_CHARS ); @@ -65,14 +110,22 @@ async function queueKey(): Promise { /** Load this window's durable queue. Invalid rows are ignored, never dispatched. */ export async function loadDurableMessageQueue(): Promise { const store = await durableStore(); - if (!store) return []; + if (!store) { + throw new Error("durable message queue store is unavailable"); + } try { - const stored = await store.get(await queueKey()); - if (!Array.isArray(stored)) return []; - return boundQueuedMessages(stored.filter(isQueuedMessage)); + return await withStoreLock(async () => { + await store.reload(); + const stored = await store.get(await queueKey()); + if (!Array.isArray(stored)) return []; + return boundQueuedMessages(stored.filter(isQueuedMessage)); + }); } catch (error) { log.warn("[messageQueueRepository] failed to load queue", error); - return []; + // An unreadable snapshot is unknown, not an authoritative empty queue. + // Let hydration retain its live in-memory rows and avoid writing [] over + // a transiently unavailable durable document. + throw error; } } @@ -96,17 +149,29 @@ export function persistDurableMessageQueue( }) .then(async () => { const store = await durableStore(); - if (!store) return; - await store.set(await queueKey(), snapshot); - await store.save(); + if (!store) { + throw new Error("durable message queue store is unavailable"); + } + await withStoreLock(async () => { + // Store handles are cached per webview. Reload under the cross-window + // lock before changing only this window's key, otherwise a stale save + // can erase a sibling window's durable queue. + await store.reload(); + await store.set(await queueKey(), snapshot); + await store.save(); + }); }); - return writeChain.catch((error) => { - log.warn("[messageQueueRepository] failed to persist queue", error); - }); + // Deliberately propagate the current write failure. Provider dispatch uses + // this promise as its crash-consistency boundary: starting a native turn + // without the corresponding durable queue row would make renderer restart + // recovery ambiguous and can replay the same user intent. Background + // subscribers attach their own best-effort logging handler. + return writeChain; } export function resetMessageQueueRepositoryForTests(): void { storePromise = null; queueKeyPromise = null; writeChain = Promise.resolve(); + fallbackStoreLock = Promise.resolve(); } diff --git a/src/util/session/sessionDispatch.ts b/src/util/session/sessionDispatch.ts index 2e07db0f97..d8e37e7e57 100644 --- a/src/util/session/sessionDispatch.ts +++ b/src/util/session/sessionDispatch.ts @@ -263,6 +263,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..733a2a0ad5 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,7 +106,10 @@ function normalizeSessionDisplayInput( const { session } = source; return { kind: source.kind, - sessionId: session.sourceSessionId, + // A visible Team Session fork is another episode in the same canonical + // conversation. Keep the root provider mark (Codex/Claude/...) instead + // of replacing it with the local ORG2 runtime that produced the fork. + sessionId: session.forkedFrom?.rootSessionId ?? session.sourceSessionId, cliAgentType: session.cliAgentType, agentDisplayName: session.agentDisplayName, agentDefinitionId: session.agentDefinitionId, From 247a532c11290d5b87db522ab5c51114ec23741d Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:32:11 +0800 Subject: [PATCH 3/5] feat(native): materialize and resume provider-native conversations Convert canonical role/tool events into Codex and Claude Code native histories, bind provider session identities, preserve interrupted tool activity, resume through the selected CLI transport, and recover from provider context exhaustion. --- src-tauri/Cargo.toml | 1 + src-tauri/crates/app-paths/src/home.rs | 99 +- .../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/discovery.rs | 10 +- .../sources/claude_code/history/metadata.rs | 13 +- .../src/sources/claude_code/history/replay.rs | 93 +- .../src/sources/claude_code/history/types.rs | 10 + .../claude_code/history/windows/index.rs | 9 +- .../src/sources/claude_code/history_tests.rs | 178 +- .../sources/codex/app/transcript/messages.rs | 121 + .../sources/codex/app/transcript/parser.rs | 329 +- .../src/sources/codex/app/transcript/tests.rs | 258 + .../codex/app/transcript/tool_calls/mod.rs | 27 +- .../transcript/tool_calls/normalization.rs | 49 +- .../src/sources/codex/app_tests.rs | 95 + .../src/sources/imported_history/mod.rs | 35 +- .../cli/commands/resume_delete.rs | 46 +- .../src/agent_sessions/cli/commands/run.rs | 236 +- .../src/agent_sessions/cli/commands/status.rs | 52 +- .../agent_sessions/cli/commands/transcript.rs | 81 +- src-tauri/src/agent_sessions/cli/mod.rs | 2 + .../agent_sessions/cli/native_materializer.rs | 4776 +++++++++++++++++ .../agent_sessions/cli/parsers/claude_code.rs | 43 +- .../cli/parsers/codex_app_server.rs | 528 +- .../parsers/tests/codex_app_server_tests.rs | 218 +- .../parsers/tests/parser_integration_tests.rs | 65 + .../cli/session_runner/command.rs | 34 +- .../cli/session_runner/env_setup.rs | 52 +- .../cli/session_runner/finalize.rs | 95 +- .../cli/session_runner/helpers.rs | 49 +- .../cli/session_runner/input_assembly.rs | 263 +- .../cli/session_runner/launch_profiles.rs | 31 +- .../cli/session_runner/lifecycle.rs | 134 +- .../agent_sessions/cli/session_runner/mod.rs | 13 +- .../cli/session_runner/session.rs | 154 +- .../cli/session_runner/session/mcp_inject.rs | 50 + .../cli/session_runner/session/tests.rs | 112 +- .../session/transport_app_server.rs | 79 +- src-tauri/src/agent_sessions/cli/tests/mod.rs | 1 - .../cli/tests/runner_command_tests.rs | 96 +- .../agent_sessions/cli/tests/runner_tests.rs | 52 - src-tauri/src/agent_sessions/mod.rs | 1 + src-tauri/src/api/agent/test/cli.rs | 6 +- src-tauri/src/commands/handler_list.inc | 6 + src/api/tauri/rpc/schemas/agentSession.ts | 13 +- src/api/tauri/rpc/schemas/cli.ts | 2 + .../nativeConversationMaterializer.test.ts | 520 ++ .../nativeConversationMaterializer.ts | 576 ++ .../session/useNativeSessionStatusMonitor.ts | 22 +- 51 files changed, 9282 insertions(+), 522 deletions(-) create mode 100644 src-tauri/src/agent_sessions/cli/native_materializer.rs delete mode 100644 src-tauri/src/agent_sessions/cli/tests/runner_tests.rs create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 90f3fced62..cc36fa5819 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -521,6 +521,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/app-paths/src/home.rs b/src-tauri/crates/app-paths/src/home.rs index f1cad7660a..e9f4753dbf 100644 --- a/src-tauri/crates/app-paths/src/home.rs +++ b/src-tauri/crates/app-paths/src/home.rs @@ -3,7 +3,7 @@ //! Owns `home_dir()` plus the `ORGII_EXTERNAL_HISTORY_HOME`-aware //! data/config/state/XDG roots that external-history discovery probes. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// User home directory with a deterministic fallback to the system temp dir. pub fn home_dir() -> PathBuf { @@ -20,6 +20,48 @@ 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) +} + +pub fn native_transcript_data_dir() -> PathBuf { + if native_transcript_home_override().is_none() && external_history_home_override().is_none() { + if let Some(path) = dirs::data_dir() { + return path; + } + } + platform_data_dir(&native_transcript_home_dir()) +} + +pub fn native_transcript_data_local_dir() -> PathBuf { + if native_transcript_home_override().is_none() && external_history_home_override().is_none() { + if let Some(path) = dirs::data_local_dir() { + return path; + } + } + platform_data_local_dir(&native_transcript_home_dir()) +} + +pub fn native_transcript_config_dir() -> PathBuf { + if native_transcript_home_override().is_none() && external_history_home_override().is_none() { + if let Some(path) = dirs::config_dir() { + return path; + } + } + platform_config_dir(&native_transcript_home_dir()) +} + fn external_history_home_override() -> Option { std::env::var_os("ORGII_EXTERNAL_HISTORY_HOME") .filter(|value| !value.is_empty()) @@ -36,7 +78,10 @@ pub fn external_history_data_dir() -> PathBuf { return path; } } - let home = external_history_home_dir(); + platform_data_dir(&external_history_home_dir()) +} + +fn platform_data_dir(home: &Path) -> PathBuf { #[cfg(target_os = "windows")] return home.join("AppData").join("Roaming"); #[cfg(target_os = "macos")] @@ -52,7 +97,10 @@ pub fn external_history_data_local_dir() -> PathBuf { return path; } } - let home = external_history_home_dir(); + platform_data_local_dir(&external_history_home_dir()) +} + +fn platform_data_local_dir(home: &Path) -> PathBuf { #[cfg(target_os = "windows")] return home.join("AppData").join("Local"); #[cfg(target_os = "macos")] @@ -68,7 +116,10 @@ pub fn external_history_config_dir() -> PathBuf { return path; } } - let home = external_history_home_dir(); + platform_config_dir(&external_history_home_dir()) +} + +fn platform_config_dir(home: &Path) -> PathBuf { #[cfg(target_os = "windows")] return home.join("AppData").join("Roaming"); #[cfg(target_os = "macos")] @@ -188,6 +239,46 @@ 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") + ); + #[cfg(target_os = "macos")] + assert_eq!( + native_transcript_data_dir(), + PathBuf::from("/Users/tester/Library/Application Support") + ); + #[cfg(target_os = "windows")] + assert_eq!( + native_transcript_data_dir(), + PathBuf::from("/Users/tester/AppData/Roaming") + ); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + assert_eq!( + native_transcript_data_dir(), + PathBuf::from("/Users/tester/.local/share") + ); + } + #[test] fn xdg_config_dir_is_none_under_isolation_override() { let _lock = env_lock(); 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 4f124790e8..3b4288afc8 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 4119700441..5a153d3126 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 @@ -822,6 +822,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(); @@ -831,6 +846,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] @@ -1467,6 +1486,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(); @@ -1475,6 +1501,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/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..1f577bce0e 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; @@ -152,13 +208,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 +231,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(), 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..2285617012 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,105 @@ fn parses_claude_jsonl_into_replay_chunks() { 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 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 +419,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 +443,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 +468,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 +492,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 +506,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 +557,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 +569,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 +935,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 +1200,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 +1256,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 +1264,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/transcript/messages.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs index 19f482950b..a93a8f2d81 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 @@ -15,6 +15,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 +36,42 @@ pub(crate) fn strip_ignored_embedded_images(line: &mut String) { } } +fn preserves_user_embedded_images(line: &str) -> bool { + let Some(image_offset) = line.find(CODEX_EMBEDDED_IMAGE_MARKER) else { + return false; + }; + let before_image = &line[..image_offset]; + let Some(payload_offset) = before_image.find("\"payload\":{") else { + return false; + }; + let payload = &before_image[payload_offset..]; + (payload.starts_with("\"payload\":{\"type\":\"message\"") + && payload.contains("\"role\":\"user\",\"content\":")) + || payload.starts_with("\"payload\":{\"type\":\"user_message\"") + || (payload.starts_with("\"payload\":{\"type\":\"item_completed\"") + && payload.contains("\"item\":{\"type\":\"UserMessage\"")) +} + +#[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 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 +104,83 @@ 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. +/// ORGII stamps their public passthrough turn id while materializing so they +/// can be projected as real user turns without mistaking Codex's user-role +/// system/context prefix messages for human input. +pub(super) fn materialized_user_message_chunk_from_response_message( + session_id: &str, + sequence: usize, + created_at: &str, + payload: &Value, +) -> Option { + let materialized = payload + .get("internal_chat_message_metadata_passthrough") + .and_then(|metadata| metadata.get("turn_id")) + .and_then(Value::as_str) + .is_some_and(|turn_id| turn_id.starts_with("orgii-materialization-")); + if !materialized + || 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..1677357462 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,13 +13,14 @@ 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, materialized_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, - codex_task_error_message, codex_tool_call_chunk, lifecycle_turn_id, - output_parts_for_tool_calls, pending_custom_tool_calls_from_payload, + codex_task_error_message, codex_tool_call_chunk, is_orgii_materialized_tool_call, + lifecycle_turn_id, output_parts_for_tool_calls, pending_custom_tool_calls_from_payload, pending_tool_calls_from_payload, resolve_codex_tool_outputs, wait_cell_id, web_search_call_from_payload, PendingBackgroundToolCall, }; @@ -59,6 +60,19 @@ 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 a top-level `compacted` checkpoint and a + // nearby `event_msg/context_compacted` UI mirror. Emit one ORGII marker, + // while still accepting older event-only rollouts. + let mut pending_compacted_mirror_at: 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(); + // ORGII materializes a portable compaction summary as a supported + // assistant response item immediately followed by Codex's supported + // `context_compaction` response item. Keep the summary out of the normal + // assistant transcript and fold the pair back into one compact boundary. + let mut pending_materialized_compaction: Option<(String, String)> = None; let mut line = String::new(); let mut next_byte_offset = start_offset; @@ -86,10 +100,112 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( .as_deref() .map(imported_history::normalize_created_at) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + if parsed.line_type == "compacted" { + 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_at + .as_deref() + .is_some_and(|checkpoint_created_at| { + compact_markers_are_same_checkpoint(checkpoint_created_at, &created_at) + }); + 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_at = Some(created_at); + continue; + } let Some(payload_type) = parsed.payload.get("type").and_then(Value::as_str) else { continue; }; + if payload_type == "context_compacted" { + if pending_compacted_mirror_at + .take() + .is_some_and(|checkpoint_created_at| { + compact_markers_are_same_checkpoint(&checkpoint_created_at, &created_at) + }) + { + 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; + } + + if payload_type == "context_compaction" { + let marker = parsed + .payload + .get("internal_chat_message_metadata_passthrough") + .and_then(|metadata| metadata.get("turn_id")) + .and_then(Value::as_str) + .filter(|turn_id| turn_id.starts_with("orgii-materialized-compaction:")); + let summary = marker.and_then(|marker| { + pending_materialized_compaction + .take() + .filter(|(pending_marker, _)| pending_marker == marker) + .map(|(_, summary)| summary) + }); + let marker_id = parsed + .payload + .get("id") + .and_then(Value::as_str) + .or(marker) + .unwrap_or("context-compaction"); + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + marker_id, + &created_at, + summary.as_deref(), + )); + 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 +219,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 +254,69 @@ 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; + // 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. + let duplicate_context_item = collector.current.last().is_some_and(|chunk| { + chunk.function == imported_history::FUNCTION_ASSISTANT + && chunk.created_at == created_at + && chunk + .result + .get("observation") + .or_else(|| chunk.result.get("content")) + .and_then(Value::as_str) + == Some(message) + }); + if !duplicate_context_item { + 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") { + let role = parsed.payload.get("role").and_then(Value::as_str); + if role == Some("user") { + if let Some(user_chunk) = materialized_user_message_chunk_from_response_message( + session_id, + sequence, + &created_at, + &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 let Some(marker) = parsed + .payload + .get("internal_chat_message_metadata_passthrough") + .and_then(|metadata| metadata.get("turn_id")) + .and_then(Value::as_str) + .filter(|turn_id| turn_id.starts_with("orgii-materialized-compaction:")) + { + pending_materialized_compaction = Some((marker.to_string(), text)); + continue; + } collector .current .push(imported_history::assistant_message_chunk( @@ -205,49 +373,53 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( if let Some((file_order, calls)) = pending_tool_calls.take(call_id) { let output_value = parsed.payload.get("output"); let output = codex_tool_output_text(output_value); - if let Some(cell_id) = wait_cell_id(&calls) { - let cell_key = background_cell_key(cell_id); - if let Some((background_order, mut background)) = - background_tool_calls.take(&cell_key) - { - if let Some(next_cell_id) = background_cell_id(&output) { - background.latest_output = output; - background_tool_calls.reinsert( - background_cell_key(&next_cell_id), - background_order, - background, - ); - } else { - let final_output = if output.trim().is_empty() { - background.latest_output + let is_orgii_materialized = + calls.iter().all(is_orgii_materialized_tool_call); + if !is_orgii_materialized { + if let Some(cell_id) = wait_cell_id(&calls) { + let cell_key = background_cell_key(cell_id); + if let Some((background_order, mut background)) = + background_tool_calls.take(&cell_key) + { + if let Some(next_cell_id) = background_cell_id(&output) { + background.latest_output = output; + background_tool_calls.reinsert( + background_cell_key(&next_cell_id), + background_order, + background, + ); } else { - output - }; - resolve_codex_tool_outputs( - session_id, - background.calls, - background_order, - output_value, - &final_output, - &mut collector.current, - &mut sequence, - &mut background_tool_calls, - ); + let final_output = if output.trim().is_empty() { + background.latest_output + } else { + output + }; + resolve_codex_tool_outputs( + session_id, + background.calls, + background_order, + output_value, + &final_output, + &mut collector.current, + &mut sequence, + &mut background_tool_calls, + ); + } + continue; } + } + if let Some(cell_id) = background_cell_id(&output) { + background_tool_calls.reinsert( + background_cell_key(&cell_id), + file_order, + PendingBackgroundToolCall { + calls, + latest_output: output, + }, + ); continue; } } - if let Some(cell_id) = background_cell_id(&output) { - background_tool_calls.reinsert( - background_cell_key(&cell_id), - file_order, - PendingBackgroundToolCall { - calls, - latest_output: output, - }, - ); - continue; - } resolve_codex_tool_outputs( session_id, calls, @@ -323,7 +495,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 +514,48 @@ 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()); + 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 +} + +fn compact_markers_are_same_checkpoint(left: &str, right: &str) -> bool { + let Ok(left) = chrono::DateTime::parse_from_rfc3339(left) else { + return left == right; + }; + let Ok(right) = chrono::DateTime::parse_from_rfc3339(right) else { + return false; + }; + (right - left).num_seconds().abs() <= 5 +} 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 3d68a66df5..4e4b95cfb7 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 @@ -9,6 +9,264 @@ 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"); + let content = r#"{"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"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialization-user-1"}}} +{"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"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialization-user-2"}}}"#; + 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[1].result["message"]["content"], "second"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .count(), + 1 + ); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn preserves_app_server_injected_canonical_tool_arguments_without_renormalizing() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-injected-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-injected-tool.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"function_call","name":"grep","arguments":"{\"action\":\"grep\",\"command\":\"rg needle .\",\"cwd\":\"/repo\",\"pattern\":\"needle\",\"payload\":{\"cmd\":\"rg needle .\"},\"__orgiiMaterializedNative\":true}","call_id":"call-1","internal_chat_message_metadata_passthrough":{"turn_id":"auto-compact-0"}}} +{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"match"}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-injected-tool", &path) + .expect("parse app-server injected transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("tool call"); + assert_eq!(tool.result["call_id"], "call-1"); + assert_eq!( + tool.args, + serde_json::json!({ + "action": "grep", + "command": "rg needle .", + "cwd": "/repo", + "pattern": "needle", + "payload": {"cmd": "rg needle ."} + }) + ); + assert_eq!(tool.result["output"], "match"); + + 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","orgii_materialization":true}} +{"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 materialized_context_compaction_pair_round_trips_as_one_canonical_boundary() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-materialized-compact-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-materialized-compact.jsonl"); + let content = r#"{"timestamp":"2026-08-31T00:00:00Z","type":"response_item","payload":{"type":"message","id":"compact-1-summary","role":"assistant","content":[{"type":"output_text","text":"Portable compact summary"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialized-compaction:compact-1"}}} +{"timestamp":"2026-08-31T00:00:00Z","type":"response_item","payload":{"type":"context_compaction","id":"compact-1","encrypted_content":null,"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialized-compaction:compact-1"}}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-materialized-compact", &path) + .expect("parse materialized compact transcript"); + 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("Portable compact summary") + ); + assert!(!chunks.iter().any(|chunk| { + chunk.function == "assistant" + && chunk.result["content"].as_str() == Some("Portable compact summary") + })); + + 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,"window_id":"window-153","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.040Z","type":"compacted","payload":{"message":"final summary","window_number":154,"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 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/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs index 5ed7175ebf..e74a62f522 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs @@ -11,8 +11,11 @@ mod exec_results; mod normalization; use exec_results::{append_incremental_output, codex_exec_results, CodexExecResult}; +use normalization::original_raw_tool_name; pub(crate) use normalization::pending_custom_tool_calls_from_payload; -pub(super) use normalization::{pending_tool_calls_from_payload, web_search_call_from_payload}; +pub(super) use normalization::{ + is_orgii_materialized_tool_call, pending_tool_calls_from_payload, web_search_call_from_payload, +}; pub(super) struct PendingBackgroundToolCall { pub(super) calls: Vec, @@ -106,6 +109,22 @@ pub(super) fn resolve_codex_tool_outputs( sequence: &mut usize, background_tool_calls: &mut imported_history::PendingCallMap, ) { + // ORGII materializes canonical tool calls into Codex records solely so the + // native runtime can resume them. Their outputs are application data, not + // Codex Desktop exec envelopes. Parsing an arbitrary JSON result that + // happens to contain `session_id` as a background-shell receipt drops the + // call from the reconstructed transcript, so preserve these records as-is. + if calls.iter().all(is_orgii_materialized_tool_call) { + emit_codex_call_group( + transcript_session_id, + calls, + fallback_output, + None, + chunks, + sequence, + ); + return; + } let mut results = codex_exec_results(output_value); if results.len() == calls.len() { for (call, result) in calls.into_iter().zip(results.drain(..)) { @@ -331,6 +350,12 @@ pub(super) fn codex_tool_call_chunk( ) -> ActivityChunk { let mut chunk = imported_history::tool_call_chunk(session_id, CODEX_PROVIDER_SLUG, sequence, call, output); + if let Some(result) = chunk.result.as_object_mut() { + result.insert( + "raw_tool_name".to_string(), + Value::String(original_raw_tool_name(&call.raw_name).to_string()), + ); + } if call.canonical_name == imported_history::FUNCTION_CODE_SEARCH { if let Some(result) = chunk.result.as_object_mut() { result.insert("content".to_string(), Value::String(output.to_string())); 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..6e50641eaf 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 @@ -7,17 +7,64 @@ use super::super::super::normalize::{ normalize_codex_tool_calls, normalize_tool_name_key, normalize_web_search_args, }; +const ORGII_MATERIALIZED_RAW_NAME_PREFIX: &str = "orgii_materialized_native::"; +const ORGII_MATERIALIZED_ARGUMENT_KEY: &str = "__orgiiMaterializedNative"; +const ORGII_CANONICAL_ARGUMENT_KEY: &str = "__orgiiCanonicalArguments"; + +pub(in crate::sources::codex::app::transcript) fn is_orgii_materialized_tool_call( + call: &ImportedToolCall, +) -> bool { + call.raw_name + .starts_with(ORGII_MATERIALIZED_RAW_NAME_PREFIX) +} + +pub(super) fn original_raw_tool_name(raw_name: &str) -> &str { + raw_name + .strip_prefix(ORGII_MATERIALIZED_RAW_NAME_PREFIX) + .unwrap_or(raw_name) +} + pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payload( payload: &Value, created_at: &str, ) -> 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!({})); + let materialized_arguments = arguments + .as_object_mut() + .and_then(|object| object.remove(ORGII_MATERIALIZED_ARGUMENT_KEY)) + .and_then(|value| value.as_bool()) + == Some(true); + if materialized_arguments { + if let Some(canonical) = arguments + .as_object_mut() + .and_then(|object| object.remove(ORGII_CANONICAL_ARGUMENT_KEY)) + { + arguments = canonical; + } + } + if materialized_arguments + || payload + .get("orgii_materialization") + .and_then(Value::as_bool) + == Some(true) + { + return Some(( + call_id.clone(), + vec![ImportedToolCall { + call_id, + raw_name: format!("{ORGII_MATERIALIZED_RAW_NAME_PREFIX}{raw_name}"), + 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..7b652e4d7b 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,39 @@ 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.000Z","type":"event_msg","payload":{"type":"agent_message","message":"one answer","phase":"final_answer","memory_citation":null}} +"#; + 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(), 1); + assert_eq!( + assistant[0] + .result + .get("observation") + .or_else(|| assistant[0].result.get("content")) + .and_then(Value::as_str), + Some("one 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!( @@ -1604,6 +1637,55 @@ fn codex_desktop_exec_unwraps_web_search_query() { assert_eq!(calls[0].args["query"], "Codex app event format"); } +#[test] +fn codex_materialized_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", + "name": "web_search", + "arguments": canonical_args.to_string(), + "call_id": "call_materialized_web", + "orgii_materialization": true, + }); + 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, canonical_args); + 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 +2518,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/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index 7efe0e9911..cb84e6b142 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 @@ -536,12 +536,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)] = &[ + ("", ""), ( "", "", @@ -581,8 +580,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`]. @@ -699,6 +698,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/src/agent_sessions/cli/commands/resume_delete.rs b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs index acac8f10cf..55252e3294 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,41 @@ 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. This keeps catalog work and runtime/account patches away from the + // bound native UUID until terminal publication has completed. + let identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; + tokio::task::spawn_blocking({ + let session_id = session_id.clone(); + move || { + super::super::native_materializer::freeze_cli_native_publication_context( + &session_id, + ) + } + }) + .await + .map_err(|err| format!("native publication snapshot task failed: {err}"))??; + // 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); + if let Err(error) = accept_result { + super::super::native_materializer::clear_cli_native_publication_context(&session_id); + return Err(error); + } let mut running_msg = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, @@ -106,6 +135,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,10 +143,12 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { None, None, Some(&runner_turn_intent_id), + false, ) .await { tracing::error!("[CodeSession] Resume of {} failed: {}", sid, e); + super::super::native_materializer::clear_cli_native_publication_context(&sid); // Same fail-loud principle as the create path above: log the // persistence failure so a stuck Running row is traceable. let failed_sid = sid.clone(); @@ -165,6 +197,9 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { if let Some(existing) = sessions.get(&session_id) { if !existing.is_finished() { handle.abort(); + super::super::native_materializer::clear_cli_native_publication_context( + &session_id, + ); return Err(format!( "Session {} already has a running agent. Cancel it first.", session_id @@ -184,8 +219,15 @@ 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; + super::super::native_materializer::clear_cli_native_publication_context(&session_id); // 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 b6b695ef27..ef56acbeb5 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; @@ -32,6 +32,8 @@ pub struct CliRunRequest { pub images: Option>, pub turn_intent_id: Option, pub client_message_id: Option, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Send a follow-up message on an existing session, optionally switching the @@ -50,6 +52,8 @@ pub struct CliMessageRequest { pub images: Option>, pub turn_intent_id: Option, pub client_message_id: Option, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Identity of a single turn. `turn_intent_id` keys the `turn_intents` row and @@ -75,30 +79,53 @@ fn new_id() -> String { uuid::Uuid::new_v4().to_string() } +/// A forced follow-up owns the old runner from interruption through terminal +/// persistence. If its native partial cannot be proven publishable, fail the +/// old turn before returning so the queue/footer never remains `Running`. +async fn fail_interrupted_turn(session_id: &str, error: &str) -> Result<(), String> { + let persist_session_id = session_id.to_string(); + let persist_error = error.to_string(); + let active_turn_intent_id = tokio::task::spawn_blocking(move || { + let active = session_persistence::turn_intents::latest_for_sessions( + std::slice::from_ref(&persist_session_id), + ) + .map_err(|err| err.to_string())? + .remove(&persist_session_id) + .filter(|intent| { + intent.status == session_persistence::turn_intents::TurnIntentStatus::Running + }) + .map(|intent| intent.turn_intent_id); + persistence::update_cli_turn_lifecycle( + &persist_session_id, + SessionStatus::Failed, + Some(&persist_error), + active.as_deref().map(|turn_intent_id| { + ( + turn_intent_id, + session_persistence::turn_intents::TurnIntentStatus::Failed, + ) + }), + ) + .map_err(|err| err.to_string())?; + Ok::<_, String>(active) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + super::failure_broadcast::broadcast_async_run_failure( + session_id, + error, + active_turn_intent_id.as_deref(), + ) + .await; + Ok(()) +} + /// Mint a turn intent id for a path that has no `TurnIdentity` of its own /// (currently `cli_agent_resume`), so every turn is attributable. 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] @@ -251,6 +278,7 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri images, turn_intent_id: _, client_message_id: _, + allow_native_context_recovery, } = request; let TurnIdentity { turn_intent_id, @@ -302,11 +330,36 @@ 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 catalog refresh 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, including final provider-native publication. `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!( @@ -316,9 +369,20 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri } } + tokio::task::spawn_blocking({ + let session_id = session_id.clone(); + move || { + super::super::native_materializer::freeze_cli_native_publication_context( + &session_id, + ) + } + }) + .await + .map_err(|err| format!("native publication snapshot task failed: {err}"))??; + let persist_session_id = session_id.clone(); let persist_turn_intent_id = turn_intent_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, @@ -327,7 +391,12 @@ 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); + if let Err(error) = accept_result { + super::super::native_materializer::clear_cli_native_publication_context(&session_id); + return Err(error); + } let mut running_msg = serde_json::json!({ "type": "code_session.status_changed", @@ -338,7 +407,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(); @@ -347,17 +415,21 @@ 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 { tracing::error!("[CodeSession] Session {} failed: {}", sid, e); + super::super::native_materializer::clear_cli_native_publication_context(&sid); session_runner::forget_session_context(&sid); session_runner::flush_cli_streams_for_session(&sid).await; // Best-effort: if marking the row as Failed itself fails, log @@ -459,6 +531,7 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result Result {} + // The provider was interrupted before it minted a native UUID. + // The canonical user/tool rows remain authoritative and the next + // episode will materialize them into the selected runtime. + Ok(false) => {} + Err(err) => { + let error = format!("Provider-native partial turn publication failed: {err}"); + fail_interrupted_turn(&session_id, &error).await?; + return Err(error); + } + } + } + tracing::info!(session_id = %session_id, "cli_agent_message: existing runner cleanup complete"); + + // Publish the old account's runner before changing the binding lookup. + // Otherwise a Codex/Claude account switch asks the publisher to resolve + // the old file through the new account profile and either loses the + // interrupted suffix or fails a valid runtime switch. if model.is_some() || account_id.is_some() { let sid = session_id.clone(); let mdl = model.clone(); let acc = account_id.clone(); tokio::task::spawn_blocking(move || { - if let Err(err) = - persistence::update_model_and_account(&sid, mdl.as_deref(), acc.as_deref()) - { - tracing::warn!( - "[CodeSession] Failed to update model/account for follow-up: {}", - err - ); - } + persistence::update_model_and_account(&sid, mdl.as_deref(), acc.as_deref()) + .map_err(|err| format!("update model/account for follow-up: {err}")) }) .await - .map_err(|e| format!("Task error: {}", e))?; + .map_err(|e| format!("Task error: {e}"))??; if let Some(ref new_account_id) = account_id { if session.account_id.as_deref() != Some(new_account_id.as_str()) { @@ -521,20 +659,6 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result Result Result, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliAgentStatusItem { + #[serde(flatten)] + pub session: CodeSession, + /// True only for a failed provider turn whose durable error is classified + /// by the shared runtime classifier as context exhaustion. The frontend + /// must not infer this recovery signal from provider prose independently. + pub context_exhausted: bool, +} + +fn status_item(session: CodeSession) -> CliAgentStatusItem { + let context_exhausted = context_exhausted(session.status, session.error_message.as_deref()); + CliAgentStatusItem { + session, + context_exhausted, + } +} + +fn context_exhausted(status: SessionStatus, error_message: Option<&str>) -> bool { + matches!(status, SessionStatus::Failed) + && error_message.is_some_and(app_utils::runtime_errors::is_context_exhausted_message) +} + + /// Get session status. #[tauri::command] -pub async fn cli_agent_status(session_id: String) -> Result, String> { +pub async fn cli_agent_status(session_id: String) -> Result, String> { tokio::task::spawn_blocking(move || { - persistence::get_session(&session_id).map_err(|e| format!("DB error: {}", e)) + persistence::get_session(&session_id) + .map(|session| session.map(status_item)) + .map_err(|e| format!("DB error: {}", e)) }) .await .map_err(|e| format!("Task error: {}", e))? @@ -82,3 +110,21 @@ pub async fn cli_agent_cancel( ) -> Result { session_runner::cancel_session(&session_id, reason.unwrap_or_default()).await } + +#[cfg(test)] +mod tests { + use super::context_exhausted; + use crate::agent_sessions::cli::types::SessionStatus; + + #[test] + fn context_recovery_signal_requires_failed_status_and_shared_classification() { + let exhausted = Some("Codex ran out of room in the model's context window."); + assert!(context_exhausted(SessionStatus::Failed, exhausted)); + assert!(!context_exhausted(SessionStatus::Completed, exhausted)); + assert!(!context_exhausted( + SessionStatus::Failed, + Some("connection refused") + )); + } + +} diff --git a/src-tauri/src/agent_sessions/cli/commands/transcript.rs b/src-tauri/src/agent_sessions/cli/commands/transcript.rs index d6ab507acd..f16ea40698 100644 --- a/src-tauri/src/agent_sessions/cli/commands/transcript.rs +++ b/src-tauri/src/agent_sessions/cli/commands/transcript.rs @@ -20,21 +20,49 @@ fn load_native_transcript_chunks(session: &CodeSession) -> Option { + for chunk in &mut chunks { + chunk.session_id = session.session_id.clone(); + } + return Some(chunks); + } + Ok(_) => None, + Err(err) => Some(err), + }; + + // Fall back to discovery for legacy/provider files that moved away + // from the bound workspace. Only report errors after both exact and + // discovery readers failed, so a healthy exact transcript does not + // emit a misleading file-not-found warning while the cache catches up. + let mut discovery_failed = false; match orgtrack_core::sources::imported_history::load_activity_chunks_for_session( &conn, &imported_id, @@ -47,12 +75,25 @@ fn load_native_transcript_chunks(session: &CodeSession) -> Option continue, + Ok(_) => {} Err(err) => { + discovery_failed = true; + if let Some(exact_error) = exact_error.as_deref() { + tracing::warn!( + "[cli_agent_chunks] Native transcript load failed for {imported_id}: exact={exact_error}; discovery={err}" + ); + } else { + tracing::warn!( + "[cli_agent_chunks] Native transcript load failed for {imported_id}: {err}" + ); + } + } + } + if !discovery_failed { + if let Some(err) = exact_error { tracing::warn!( - "[cli_agent_chunks] Native transcript load failed for {imported_id}: {err}" + "[cli_agent_chunks] Exact native transcript load failed for {imported_id}: {err}" ); - continue; } } } @@ -195,8 +236,18 @@ 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; + // Truncation intentionally replaces the active native episode instead of + // publishing its interrupted suffix, so its frozen launch snapshot must + // not survive into the next turn. + super::super::native_materializer::clear_cli_native_publication_context(&session_id); // Wipe the Cursor config dir so the agent starts fresh — legacy chunk mode // ONLY. Under `transcript_source = 'native'` that directory IS the diff --git a/src-tauri/src/agent_sessions/cli/mod.rs b/src-tauri/src/agent_sessions/cli/mod.rs index e975925c99..2f6b6e3602 100644 --- a/src-tauri/src/agent_sessions/cli/mod.rs +++ b/src-tauri/src/agent_sessions/cli/mod.rs @@ -12,9 +12,11 @@ //! - `commands` — Tauri commands exposed to the frontend pub mod agent_core_bridge; +mod codex_native_catalog; pub mod commands; pub mod hook_approvals; pub mod launch_profile_store; +pub mod native_materializer; pub mod native_transcript; pub mod parsers; pub mod persistence; 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..a7b9447395 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_materializer.rs @@ -0,0 +1,4776 @@ +//! 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, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use core_types::activity::ActivityChunk; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::codex_native_catalog; +use super::native_transcript::TRANSCRIPT_SOURCE_NATIVE; +use super::persistence; + +const MAX_ITEMS: usize = 100_000; +const MAX_SERIALIZED_BYTES: usize = 64 * 1024 * 1024; +const MAX_PORTABLE_TOOL_CALL_ID_LENGTH: usize = 64; +const NATIVE_CATALOG_REFRESH_BACKOFFS: [Duration; 2] = + [Duration::from_millis(150), Duration::from_millis(400)]; +const CODEX_NATIVE_PATH_CACHE_MAX_ENTRIES: usize = 512; +// Claude's project catalog is one read-modify-write JSON document. Serialize +// those short critical sections so two Sessions completing together cannot +// overwrite each other's index entry. +static CLAUDE_PROJECT_INDEX_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +// 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< + Mutex>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); +// Freeze the provider/account/workspace row that launched each active turn. +// Model/account pills may already show the next queued selection while the +// current provider is still running; terminal publication must resolve the +// runner UUID through this launch snapshot, never through the mutable row. +static ACTIVE_NATIVE_PUBLICATION_SESSIONS: LazyLock< + Mutex>, +> = LazyLock::new(|| Mutex::new(HashMap::new())); +// Catalog publication is deliberately off the turn-critical path. Keep one +// worker per provider and coalesce repeated requests by native conversation so fast +// consecutive turns cannot retain an unbounded list of Tokio tasks behind a +// slow app-server call. Separate lanes keep a blocked Codex app-server from +// delaying Claude metadata (and vice versa). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum NativeCatalogProvider { + ClaudeCode, + Codex, +} + +impl NativeCatalogProvider { + fn from_agent(agent: &str) -> Option { + match agent { + "claude_code" => Some(Self::ClaudeCode), + "codex" => Some(Self::Codex), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ClaudeCode => "claude_code", + Self::Codex => "codex", + } + } +} + +#[derive(Debug, Default)] +struct NativeCatalogRefreshLane { + pending: HashMap, + // Requests whose native conversation is owned by a live turn wait here. + // One async waiter per key re-enqueues the newest coalesced request after + // identity becomes available, while this provider lane keeps advancing. + deferred: HashMap, + worker_running: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct NativeCatalogRefreshKey { + provider: NativeCatalogProvider, + native_id: String, + native_path: PathBuf, +} + +#[derive(Debug, Clone)] +struct NativeCatalogRefreshRequest { + queued_at: Instant, + context: CliNativePublicationContext, + completed_turns_hint: Option, +} + +impl NativeCatalogRefreshLane { + fn key( + provider: NativeCatalogProvider, + context: &CliNativePublicationContext, + ) -> NativeCatalogRefreshKey { + NativeCatalogRefreshKey { + provider, + native_id: context.native_id.clone(), + native_path: context.paths.native_path.clone(), + } + } + + fn merge_request( + request: &mut NativeCatalogRefreshRequest, + queued_at: Instant, + context: CliNativePublicationContext, + completed_turns_hint: Option, + ) { + request.queued_at = queued_at; + // The native id is immutable, but title/model/branch metadata can + // advance while requests are coalesced. Keep the newest snapshot and + // the highest provider progress floor. + request.context = context; + request.completed_turns_hint = + request.completed_turns_hint.max(completed_turns_hint); + } + + fn enqueue( + &mut self, + provider: NativeCatalogProvider, + context: CliNativePublicationContext, + completed_turns_hint: Option, + ) -> bool { + let now = Instant::now(); + let key = Self::key(provider, &context); + if let Some(request) = self.deferred.get_mut(&key) { + Self::merge_request(request, now, context, completed_turns_hint); + return false; + } + self.pending + .entry(key) + .and_modify(|request| { + Self::merge_request(request, now, context.clone(), completed_turns_hint); + }) + .or_insert(NativeCatalogRefreshRequest { + queued_at: now, + context, + completed_turns_hint, + }); + if self.worker_running { + false + } else { + self.worker_running = true; + true + } + } + + fn defer_until_identity_available( + &mut self, + provider: NativeCatalogProvider, + mut request: NativeCatalogRefreshRequest, + ) -> (NativeCatalogRefreshKey, bool) { + let key = Self::key(provider, &request.context); + // A newer request can be enqueued between the worker's try-lock and + // this queue mutation. Fold it into the deferred slot as well. + if let Some(pending) = self.pending.remove(&key) { + Self::merge_request( + &mut request, + pending.queued_at, + pending.context, + pending.completed_turns_hint, + ); + } + if let Some(deferred) = self.deferred.get_mut(&key) { + Self::merge_request( + deferred, + request.queued_at, + request.context, + request.completed_turns_hint, + ); + (key, false) + } else { + self.deferred.insert(key.clone(), request); + (key, true) + } + } + + fn take_deferred( + &mut self, + key: &NativeCatalogRefreshKey, + ) -> Option { + self.deferred.remove(key) + } + + fn take_next(&mut self) -> Option { + let next = self + .pending + .iter() + .min_by_key(|(_, request)| request.queued_at) + .map(|(key, _)| key.clone()); + if let Some(key) = next { + Some( + self.pending + .remove(&key) + .expect("selected catalog refresh request must still exist"), + ) + } else { + self.worker_running = false; + None + } + } +} + +#[derive(Debug, Default)] +struct NativeCatalogRefreshQueue { + lanes: HashMap, +} + +impl NativeCatalogRefreshQueue { + fn lane_mut(&mut self, provider: NativeCatalogProvider) -> &mut NativeCatalogRefreshLane { + self.lanes.entry(provider).or_default() + } +} + +static NATIVE_CATALOG_REFRESH_QUEUE: LazyLock> = + LazyLock::new(|| Mutex::new(NativeCatalogRefreshQueue::default())); + +/// 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 publication, 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, + }) +} + +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'-')) +} + +#[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, + created_at: String, + }, + Compaction { + id: String, + summary: String, + created_at: String, + }, +} + +impl NativeConversationItem { + fn id(&self) -> &str { + match self { + Self::Message { id, .. } + | Self::ToolCall { id, .. } + | Self::ToolResult { id, .. } + | Self::Compaction { id, .. } => id, + } + } + + fn created_at(&self) -> &str { + match self { + Self::Message { created_at, .. } + | Self::ToolCall { created_at, .. } + | Self::ToolResult { created_at, .. } + | Self::Compaction { created_at, .. } => created_at, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeMaterializationReceipt { + native_session_id: String, + item_count: usize, +} + +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::Compaction { .. } => {} + } + } + Ok(()) +} + +fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("native transcript path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; + let tmp = path.with_extension(format!("jsonl.tmp-{}", Uuid::new_v4().simple())); + let result = (|| -> Result<(), String> { + let mut file = fs::File::create(&tmp) + .map_err(|err| format!("create native transcript {}: {err}", tmp.display()))?; + for record in records { + serde_json::to_writer(&mut file, record) + .map_err(|err| format!("write native transcript {}: {err}", tmp.display()))?; + file.write_all(b"\n") + .map_err(|err| format!("write native transcript {}: {err}", tmp.display()))?; + } + file.sync_all() + .map_err(|err| format!("sync native transcript {}: {err}", tmp.display()))?; + atomic_replace_file(&tmp, path, "native transcript")?; + sync_parent_directory(path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(test)] +fn append_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + // Serialize the complete suffix before opening the shared provider file. + // This keeps the append to one payload and, critically, means an error + // never needs a blind set_len rollback that could truncate bytes another + // native App process appended concurrently. + let payload = serialize_jsonl(records)?; + append_jsonl_payload(path, &payload) +} + +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 append_jsonl_payload(path: &Path, payload: &[u8]) -> Result<(), String> { + let mut file = fs::OpenOptions::new() + .append(true) + .open(path) + .map_err(|err| { + format!( + "open native transcript {} for append: {err}", + path.display() + ) + })?; + file.write_all(payload) + .map_err(|err| format!("append native transcript {}: {err}", path.display()))?; + file.sync_all() + .map_err(|err| format!("sync native transcript {}: {err}", path.display())) +} + +fn rollback_jsonl_suffix(path: &Path, original_len: u64, suffix: &[u8]) -> Result<(), String> { + let expected_len = original_len.saturating_add(suffix.len() as u64); + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|error| format!("open native transcript {} for rollback: {error}", path.display()))?; + let actual_len = file + .metadata() + .map_err(|error| format!("inspect native transcript {} for rollback: {error}", path.display()))? + .len(); + if actual_len != expected_len { + return Err(format!( + "native transcript {} advanced concurrently; expected {expected_len} bytes, found {actual_len}", + path.display() + )); + } + file.seek(SeekFrom::Start(original_len)) + .map_err(|error| format!("seek native transcript {} for rollback: {error}", path.display()))?; + let mut actual_suffix = vec![0; suffix.len()]; + file.read_exact(&mut actual_suffix) + .map_err(|error| format!("read native transcript {} for rollback: {error}", path.display()))?; + if actual_suffix != suffix { + return Err(format!( + "native transcript {} suffix changed concurrently; refusing rollback", + path.display() + )); + } + file.set_len(original_len) + .map_err(|error| format!("truncate native transcript {} during rollback: {error}", path.display()))?; + file.sync_all() + .map_err(|error| format!("sync native transcript {} after rollback: {error}", path.display())) +} + +/// Count actual human/user prompts in a Claude transcript without loading the +/// JSONL into memory. Claude represents tool results as `type=user` records as +/// well, so the outer type alone would wildly over-count long tool-heavy turns. +fn claude_completed_turns_from_transcript(path: &Path) -> Result { + let file = fs::File::open(path) + .map_err(|error| format!("open Claude transcript {}: {error}", path.display()))?; + let mut count = 0usize; + for (index, line) in BufReader::new(file).lines().enumerate() { + if index >= MAX_ITEMS { + return Err(format!( + "Claude transcript {} exceeds {MAX_ITEMS} records", + path.display() + )); + } + let line = line.map_err(|error| { + format!("read Claude transcript {}: {error}", path.display()) + })?; + if line.trim().is_empty() { + continue; + } + let record: Value = serde_json::from_str(&line).map_err(|error| { + format!("parse Claude transcript {}: {error}", path.display()) + })?; + if record["type"] != "user" + || record["message"]["role"] != "user" + || record["isMeta"].as_bool() == Some(true) + || record["isCompactSummary"].as_bool() == Some(true) + || !record["toolUseResult"].is_null() + { + continue; + } + let content = &record["message"]["content"]; + let is_tool_result_only = content.as_array().is_some_and(|blocks| { + !blocks.is_empty() + && blocks + .iter() + .all(|block| block["type"] == "tool_result") + }); + if !is_tool_result_only { + count += 1; + } + } + Ok(count) +} + +#[cfg(test)] +fn claude_active_leaf_uuid(path: &Path) -> Option { + fs::read_to_string(path) + .ok()? + .lines() + .rev() + .find_map(|line| { + let record = serde_json::from_str::(line).ok()?; + if record["type"] == "last-prompt" { + return record["leafUuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + } + record["uuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }) +} + +fn atomic_json(path: &Path, value: &Value) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("native metadata path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|err| format!("create native metadata dir {}: {err}", parent.display()))?; + let tmp = path.with_extension(format!("json.tmp-{}", Uuid::new_v4().simple())); + let result = (|| -> Result<(), String> { + let mut file = fs::File::create(&tmp) + .map_err(|err| format!("create native metadata {}: {err}", tmp.display()))?; + serde_json::to_writer_pretty(&mut file, value) + .map_err(|err| format!("write native metadata {}: {err}", tmp.display()))?; + file.write_all(b"\n") + .map_err(|err| format!("write native metadata {}: {err}", tmp.display()))?; + file.sync_all() + .map_err(|err| format!("sync native metadata {}: {err}", tmp.display()))?; + atomic_replace_file(&tmp, path, "native metadata")?; + sync_parent_directory(path) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[derive(Debug, Clone)] +struct NativeTranscriptPaths { + /// Real provider file discovered by the official CLI and desktop app. + native_path: PathBuf, + /// Account-profile alias used by ORGII's isolated provider process. + runner_path: PathBuf, +} + +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() + )), + } +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("path has no parent to sync: {}", path.display()))?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync directory {}: {error}", parent.display())) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[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() + ) + }) +} + +fn replace_runner_link(native_path: &Path, runner_path: &Path) -> Result<(), String> { + // Ambient local CLIs already read the provider's official transcript + // path. There is no isolated profile alias to create in that case. + if native_path == runner_path { + return Ok(()); + } + let parent = runner_path.parent().ok_or_else(|| { + format!( + "native runner transcript path has no parent: {}", + runner_path.display() + ) + })?; + fs::create_dir_all(parent).map_err(|err| { + format!( + "create native runner transcript dir {}: {err}", + parent.display() + ) + })?; + let tmp = runner_path.with_extension(format!("jsonl.link-{}", Uuid::new_v4().simple())); + + #[cfg(unix)] + std::os::unix::fs::symlink(native_path, &tmp).map_err(|err| { + format!( + "link native runner transcript {} -> {}: {err}", + tmp.display(), + native_path.display() + ) + })?; + + // Windows file symlinks commonly require an elevated process. A hard link + // keeps the same append semantics while both stores live on the user's + // home volume. Synchronization replaces it after each atomic rewrite. + #[cfg(windows)] + fs::hard_link(native_path, &tmp).map_err(|err| { + format!( + "link native runner transcript {} -> {}: {err}", + tmp.display(), + native_path.display() + ) + })?; + + #[cfg(not(any(unix, windows)))] + fs::hard_link(native_path, &tmp).map_err(|err| { + format!( + "link native runner transcript {} -> {}: {err}", + tmp.display(), + native_path.display() + ) + })?; + + let result = atomic_replace_file(&tmp, runner_path, "native runner transcript link") + .and_then(|()| sync_parent_directory(runner_path)); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +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() { + 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 publish_runner_transcript( + paths: &NativeTranscriptPaths, + expected_native_id: &str, +) -> Result<(), String> { + validate_provider_jsonl(&paths.runner_path, expected_native_id)?; + if paths.native_path == paths.runner_path { + return fs::File::open(&paths.native_path) + .and_then(|native| native.sync_all()) + .map_err(|error| { + format!( + "sync provider-native transcript {}: {error}", + paths.native_path.display() + ) + }); + } + let runner_metadata = fs::symlink_metadata(&paths.runner_path).map_err(|err| { + format!( + "inspect native runner transcript {}: {err}", + paths.runner_path.display() + ) + })?; + // The normal steady state is a link into the provider App store. Codex + // can replace that link with a regular rollout while resuming inside an + // account-isolated CODEX_HOME; in that case the runner copy contains the + // provider's newest native-only state and must be published before the App + // catalog is refreshed. + if runner_metadata.file_type().is_symlink() { + return fs::File::open(&paths.native_path) + .and_then(|native| native.sync_all()) + .map_err(|error| { + format!( + "sync provider-native transcript {}: {error}", + paths.native_path.display() + ) + }); + } + if !runner_metadata.is_file() { + return Err(format!( + "native runner transcript is not a file: {}", + paths.runner_path.display() + )); + } + if paths.native_path.is_file() { + if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { + // The isolated provider copy is an append-only extension of the + // native App copy; replacing it preserves every native byte. + } else if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { + // The native App advanced while ORGII's isolated copy did not. + // Keep the strictly newer native transcript and converge the + // runner alias without rewriting the official file. + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(()); + } else if preferred_materialized_transcript_path(paths) + == Some(paths.runner_path.as_path()) + { + // Codex may replace the runner symlink with a complete new + // rollout instead of appending bytes. The interrupted-turn read + // rule already proved this generation is newer by mtime (or equal + // mtime plus a larger file), so it is safe to publish. + } else { + // Both sides advanced from the same UUID. There is no safe total + // order for provider-private state, so preserve both artifacts and + // fail closed. A later continuation can materialize the canonical + // portable transcript into a fresh native UUID. + return Err(format!( + "provider-native transcript conflict for {expected_native_id}: native App and isolated runner both advanced" + )); + } + } + let parent = paths.native_path.parent().ok_or_else(|| { + format!( + "native transcript path has no parent: {}", + paths.native_path.display() + ) + })?; + fs::create_dir_all(parent) + .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; + let tmp = paths + .native_path + .with_extension(format!("jsonl.tmp-{}", Uuid::new_v4().simple())); + + // The account profile and native App store normally live on the same home + // volume. Stage a hard link and atomically replace the App copy in O(1). + // Crucially, the runner name remains valid throughout, so a crash between + // staging and publication cannot strand the only current transcript under + // a temporary filename. Cross-filesystem roots use the copy fallback. + match fs::File::open(&paths.runner_path) + .and_then(|source| source.sync_all()) + .and_then(|()| fs::hard_link(&paths.runner_path, &tmp)) + { + Ok(()) => { + if let Err(error) = atomic_replace_file(&tmp, &paths.native_path, "native transcript") { + let _ = fs::remove_file(&tmp); + return Err(error); + } + sync_parent_directory(&paths.native_path)?; + + if let Err(link_error) = replace_runner_link(&paths.native_path, &paths.runner_path) { + // The provider transcript is already durable. Recover a + // regular runner copy so the next native resume still works; + // the following publication will retry converting it to the + // steady-state link. + let recovery = fs::copy(&paths.native_path, &paths.runner_path) + .and_then(|_| fs::File::open(&paths.runner_path)?.sync_all()) + .and_then(|()| sync_parent_directory(&paths.runner_path).map_err(std::io::Error::other)) + .map_err(|error| error.to_string()); + return match recovery { + Ok(_) => { + tracing::warn!( + runner_path = %paths.runner_path.display(), + native_path = %paths.native_path.display(), + error = %link_error, + "native transcript published but runner link recovery fell back to a regular file" + ); + Ok(()) + } + Err(recovery_error) => Err(format!( + "restore native runner transcript {} after link failure ({link_error}): {recovery_error}", + paths.runner_path.display() + )), + }; + } + return Ok(()); + } + Err(error) => { + tracing::debug!( + runner_path = %paths.runner_path.display(), + native_path = %paths.native_path.display(), + error = %error, + "native transcript hard-link staging unavailable; falling back to copy" + ); + } + } + + let result = (|| -> Result<(), String> { + let mut source = fs::File::open(&paths.runner_path).map_err(|err| { + format!( + "open native runner transcript {}: {err}", + paths.runner_path.display() + ) + })?; + let mut destination = fs::File::create(&tmp) + .map_err(|err| format!("create native transcript {}: {err}", tmp.display()))?; + std::io::copy(&mut source, &mut destination) + .map_err(|err| format!("copy native transcript {}: {err}", tmp.display()))?; + destination + .sync_all() + .map_err(|err| format!("sync native transcript {}: {err}", tmp.display()))?; + atomic_replace_file(&tmp, &paths.native_path, "native transcript")?; + sync_parent_directory(&paths.native_path)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + return result; + } + replace_runner_link(&paths.native_path, &paths.runner_path) +} + +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_messages(target_session_id: &str, items: &[NativeConversationItem]) -> Vec { + items + .iter() + .map(|item| match item { + NativeConversationItem::Message { + id, + role, + text, + images, + created_at, + turn_id, + } => { + let row_id = native_agent_row_id(target_session_id, id, turn_id.as_deref()); + let mut message = + if role == "user" && !images.is_empty() { + let mut content = vec![json!({"type": "text", "text": text})]; + content.extend(images.iter().map( + |image| json!({"type": "image_url", "image_url": {"url": image}}), + )); + json!({"role": role, "content": content}) + } else { + json!({"role": role, "content": text}) + }; + message["__orgiiNativeMessageId"] = json!(row_id); + message["__orgiiNativeCreatedAt"] = json!(created_at); + message + } + NativeConversationItem::ToolCall { + id, + call_id, + name, + arguments, + created_at, + } => { + let mut message = json!({ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments} + }] + }); + message["__orgiiNativeMessageId"] = + json!(native_agent_row_id(target_session_id, id, None)); + message["__orgiiNativeCreatedAt"] = json!(created_at); + message + } + NativeConversationItem::ToolResult { + id, + call_id, + name, + output, + created_at, + } => { + let mut message = json!({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": output + }); + message["__orgiiNativeMessageId"] = + json!(native_agent_row_id(target_session_id, id, None)); + message["__orgiiNativeCreatedAt"] = json!(created_at); + message + } + NativeConversationItem::Compaction { + id, + summary, + created_at, + } => { + let mut message = json!({ + "role": "system", + "content": format!( + "[Conversation summary — earlier messages compacted]\n\n{summary}" + ), + "__orgiiNativeCompactBoundary": true, + }); + message["__orgiiNativeMessageId"] = + json!(native_agent_row_id(target_session_id, id, None)); + message["__orgiiNativeCreatedAt"] = json!(created_at); + message + } + }) + .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, + } +} + +fn codex_sessions_root() -> PathBuf { + app_paths::native_transcript_home_dir() + .join(".codex") + .join("sessions") +} + +fn codex_native_paths_for_relative(account_id: &str, relative: &Path) -> NativeTranscriptPaths { + NativeTranscriptPaths { + native_path: codex_sessions_root().join(relative), + runner_path: app_paths::codex_cli_profile_dir(account_id) + .join("sessions") + .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) -> Option { + 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() { + return Some(paths); + } + if let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() { + cache.remove(&cache_key); + } + } + + let runner_root = app_paths::codex_cli_profile_dir(account_id).join("sessions"); + let app_root = codex_sessions_root(); + let (found, root) = find_codex_materialization(&runner_root, native_id) + .map(|path| (path, runner_root)) + .or_else(|| { + find_codex_materialization(&app_root, native_id).map(|path| (path, app_root)) + })?; + let relative = found.strip_prefix(root).ok()?; + let paths = codex_native_paths_for_relative(account_id, relative); + cache_codex_native_paths(account_id, native_id, &paths); + Some(paths) +} + +fn registered_codex_native_paths( + account_id: &str, + native_path: &Path, +) -> Result { + let root = codex_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 profile: path={} root={} ({error})", + native_path.display(), + root.display() + ) + })? + } + }; + Ok(codex_native_paths_for_relative(account_id, &relative)) +} + +/// Read 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. +pub(super) fn load_materialized_cli_transcript( + 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" => claude_native_paths(account_id, &cwd, native_id), + "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), + }; + let Some(path) = preferred_materialized_transcript_path(&paths) else { + return Ok(None); + }; + let chunks = match agent { + "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)) +} + +/// Select the authoritative readable copy after an interrupted provider turn. +/// +/// The steady state is one identity (a symlink on Unix; commonly a hard link +/// on Windows). Some providers atomically replace the isolated runner file, +/// leaving the App copy behind until publication. In that diverged state a +/// strictly newer runner -- or an equal-timestamp append with a larger size -- +/// is the only copy that can contain the just-finished partial/tool suffix. +/// Never prefer a merely different runner: the native App may itself have +/// advanced a conversation, and coarse filesystem timestamps cannot prove the +/// isolated copy is newer. +fn preferred_materialized_transcript_path(paths: &NativeTranscriptPaths) -> Option<&Path> { + 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) => None, + (Some(_), None) => Some(&paths.native_path), + (None, Some(_)) => Some(&paths.runner_path), + (Some(native), Some(runner)) => { + if paths_match(&paths.native_path, &paths.runner_path) { + return Some(&paths.native_path); + } + let runner_is_newer = match (native.modified(), runner.modified()) { + (Ok(native_modified), Ok(runner_modified)) => { + runner_modified > native_modified + || (runner_modified == native_modified && runner.len() > native.len()) + } + _ => false, + }; + if runner_is_newer { + tracing::warn!( + native_path = %paths.native_path.display(), + runner_path = %paths.runner_path.display(), + "reading newer unpublished provider transcript from isolated runner" + ); + Some(&paths.runner_path) + } else { + Some(&paths.native_path) + } + } + } +} + +fn paths_match(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn git_common_dir(path: &Path) -> Option { + let mut directory = fs::canonicalize(path).ok()?; + loop { + let dot_git = directory.join(".git"); + if dot_git.is_dir() { + return fs::canonicalize(dot_git).ok(); + } + if dot_git.is_file() { + let raw = fs::read_to_string(&dot_git).ok()?; + let raw_git_dir = raw.trim().strip_prefix("gitdir:")?.trim(); + let git_dir = PathBuf::from(raw_git_dir); + let git_dir = if git_dir.is_absolute() { + git_dir + } else { + directory.join(git_dir) + }; + let git_dir = fs::canonicalize(git_dir).ok()?; + let common_dir_file = git_dir.join("commondir"); + if !common_dir_file.is_file() { + return Some(git_dir); + } + let raw_common_dir = fs::read_to_string(common_dir_file).ok()?; + let common_dir = PathBuf::from(raw_common_dir.trim()); + let common_dir = if common_dir.is_absolute() { + common_dir + } else { + git_dir.join(common_dir) + }; + return fs::canonicalize(common_dir).ok(); + } + directory = directory.parent()?.to_path_buf(); + } +} + +fn paths_share_git_repository(left: &Path, right: &Path, left_common_dir: Option<&Path>) -> bool { + let left_common_dir = left_common_dir + .map(Path::to_path_buf) + .or_else(|| git_common_dir(left)); + left_common_dir + .zip(git_common_dir(right)) + .is_some_and(|(left, right)| left == right) +} + +fn claude_desktop_sessions_roots() -> Vec { + let mut roots = [ + app_paths::native_transcript_data_dir(), + app_paths::native_transcript_data_local_dir(), + app_paths::native_transcript_config_dir(), + ] + .into_iter() + .map(|root| root.join("Claude").join("claude-code-sessions")) + .collect::>(); + roots.sort(); + roots.dedup(); + roots +} + +fn claude_desktop_active_account_id(sessions_root: &Path) -> Option { + let config_path = sessions_root.parent()?.join("config.json"); + let config = fs::read_to_string(config_path).ok()?; + let config = serde_json::from_str::(&config).ok()?; + let account_id = config["lastKnownAccountUuid"].as_str()?; + Uuid::parse_str(account_id).ok()?; + Some(account_id.to_string()) +} + +fn publish_claude_project_index( + cwd: &Path, + native_id: &str, + items: &[NativeConversationItem], + completed_turns: Option, + git_branch: Option<&str>, +) -> Result { + let _index_guard = CLAUDE_PROJECT_INDEX_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let (index_path, index) = prepare_claude_project_index( + cwd, + native_id, + items, + completed_turns, + git_branch, + )?; + atomic_json(&index_path, &index)?; + Ok(index_path) +} + +fn prepare_claude_project_index( + cwd: &Path, + native_id: &str, + items: &[NativeConversationItem], + completed_turns: Option, + git_branch: Option<&str>, +) -> Result<(PathBuf, Value), 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() + ) + })?; + fs::create_dir_all(project_dir).map_err(|error| { + format!( + "create Claude native project directory {}: {error}", + project_dir.display() + ) + })?; + let index_path = project_dir.join("sessions-index.json"); + let mut index = match fs::read_to_string(&index_path) { + Ok(raw) => serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode existing Claude project index {}: {error}", + index_path.display() + ) + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + json!({"version": 1, "entries": []}) + } + Err(error) => { + return Err(format!( + "read Claude project index {}: {error}", + index_path.display() + )) + } + }; + let object = index.as_object_mut().ok_or_else(|| { + format!( + "Claude project index is not an object: {}", + index_path.display() + ) + })?; + object + .entry("version".to_string()) + .or_insert_with(|| Value::Number(1.into())); + let entries = object + .entry("entries".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .ok_or_else(|| { + format!( + "Claude project index entries are not an array: {}", + index_path.display() + ) + })?; + 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 completed_message_count = completed_turns.unwrap_or_default().saturating_mul(2); + let message_count = projected_message_count + .max(previous_message_count) + .max(completed_message_count); + entries.push(json!({ + "sessionId": native_id, + "fullPath": transcript_path, + "fileMtime": now.timestamp_millis(), + "firstPrompt": first_prompt, + "messageCount": message_count, + "created": created, + "modified": now_iso, + "gitBranch": git_branch.unwrap_or_default(), + "workspacePath": cwd, + })); + Ok((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())?; + let _index_guard = CLAUDE_PROJECT_INDEX_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !index_path.is_file() { + return Ok(()); + } + let mut index = fs::read_to_string(&index_path) + .map_err(|error| { + format!( + "read Claude project index {}: {error}", + index_path.display() + ) + }) + .and_then(|raw| { + serde_json::from_str::(&raw) + .map_err(|error| format!("decode Claude project index: {error}")) + })?; + let Some(entries) = index["entries"].as_array_mut() else { + return Err(format!( + "Claude project index entries are not an array: {}", + index_path.display() + )); + }; + 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(()) +} + +#[derive(Debug, Default)] +struct ClaudeDesktopCatalogResolution { + active_account_root: bool, + existing_session_path: Option, + matching_project_dir: Option, +} + +impl ClaudeDesktopCatalogResolution { + fn priority(&self) -> u8 { + if self.existing_session_path.is_some() { + 0 + } else if self.active_account_root { + 1 + } else if self.matching_project_dir.is_some() { + 2 + } else { + 3 + } + } + + fn target_path(&self, native_id: &str) -> Option { + self.existing_session_path.clone().or_else(|| { + self.matching_project_dir + .as_ref() + .map(|project_dir| project_dir.join(format!("local_{native_id}.json"))) + }) + } +} + +fn resolve_claude_desktop_catalog( + root: &Path, + cwd: &Path, + native_id: &str, +) -> ClaudeDesktopCatalogResolution { + let active_account_id = claude_desktop_active_account_id(root); + let active_account_root = active_account_id.is_some(); + let mut existing_session: Option<(i64, PathBuf)> = None; + let mut matching_project: Option<(i64, PathBuf)> = None; + let mut visited = 0usize; + let cwd_common_dir = git_common_dir(cwd); + let organization_dirs = match active_account_id { + Some(account_id) => vec![root.join(account_id)], + None => match fs::read_dir(root) { + Ok(entries) => entries.flatten().map(|entry| entry.path()).collect(), + Err(_) => Vec::new(), + }, + }; + for organization_dir in organization_dirs { + if !organization_dir.is_dir() { + continue; + } + let Ok(projects) = fs::read_dir(organization_dir) else { + continue; + }; + for project in projects.flatten() { + let project_path = project.path(); + if !project_path.is_dir() { + continue; + } + let Ok(entries) = fs::read_dir(&project_path) else { + continue; + }; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_ITEMS { + return ClaudeDesktopCatalogResolution { + active_account_root, + existing_session_path: existing_session.map(|(_, path)| path), + matching_project_dir: matching_project.map(|(_, path)| path), + }; + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Ok(value) = fs::read_to_string(&path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .ok_or(()) + else { + continue; + }; + let exact_cwd = ["cwd", "originCwd"].into_iter().any(|field| { + value[field] + .as_str() + .is_some_and(|record_cwd| paths_match(Path::new(record_cwd), cwd)) + }); + let matches_project = exact_cwd + || ["cwd", "originCwd"].into_iter().any(|field| { + value[field].as_str().is_some_and(|record_cwd| { + paths_share_git_repository( + cwd, + Path::new(record_cwd), + cwd_common_dir.as_deref(), + ) + }) + }); + let activity = value["lastActivityAt"] + .as_i64() + .or_else(|| value["createdAt"].as_i64()) + .unwrap_or_default(); + if exact_cwd + && value["cliSessionId"].as_str() == Some(native_id) + && existing_session + .as_ref() + .is_none_or(|(best_activity, _)| activity > *best_activity) + { + existing_session = Some((activity, path)); + } + if matches_project + && matching_project + .as_ref() + .is_none_or(|(best_activity, _)| activity > *best_activity) + { + matching_project = Some((activity, project_path.clone())); + } + } + } + } + ClaudeDesktopCatalogResolution { + active_account_root, + existing_session_path: existing_session.map(|(_, path)| path), + matching_project_dir: matching_project.map(|(_, path)| path), + } +} + +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 assistant_turn_count(items: &[NativeConversationItem]) -> usize { + items + .iter() + .filter(|item| { + matches!(item, NativeConversationItem::Message { role, .. } if role == "assistant") + }) + .count() +} + +fn publish_claude_desktop_session( + cwd: &Path, + native_id: &str, + model: Option<&str>, + title: Option<&str>, + items: &[NativeConversationItem], + materialized_by_orgii: bool, + completed_turns: Option, +) -> Result, String> { + let mut catalogs = claude_desktop_sessions_roots() + .into_iter() + .map(|sessions_root| resolve_claude_desktop_catalog(&sessions_root, cwd, native_id)) + .collect::>(); + // Prefer an exact provider-owned row, then the root of Desktop's active + // account, then any existing matching project. Filesystem path ordering + // is not an account-selection policy. + catalogs.sort_by_key(ClaudeDesktopCatalogResolution::priority); + for resolution in catalogs { + let Some(path) = resolution.target_path(native_id) else { + continue; + }; + if let Some(path) = publish_claude_desktop_session_to_path( + path, + cwd, + native_id, + model, + title, + items, + materialized_by_orgii, + completed_turns, + )? { + return Ok(Some(path)); + } + } + Ok(None) +} + +#[cfg(test)] +#[derive(Clone, Copy)] +struct ClaudeDesktopPublicationState { + materialized_by_orgii: bool, + completed_turns: Option, +} + +#[cfg(test)] +fn publish_claude_desktop_session_at( + sessions_root: &Path, + cwd: &Path, + native_id: &str, + model: Option<&str>, + title: Option<&str>, + items: &[NativeConversationItem], + state: ClaudeDesktopPublicationState, +) -> Result, String> { + let resolution = resolve_claude_desktop_catalog(sessions_root, cwd, native_id); + let Some(path) = resolution.target_path(native_id) else { + // Native Claude Code JSONL remains independently valid, but this + // function is specifically the Desktop catalog adapter. Do not + // manufacture account/project UUIDs and call the result App-visible + // when Desktop has never registered them. + return Ok(None); + }; + publish_claude_desktop_session_to_path( + path, + cwd, + native_id, + model, + title, + items, + state.materialized_by_orgii, + state.completed_turns, + ) +} + +#[allow(clippy::too_many_arguments)] +fn publish_claude_desktop_session_to_path( + path: PathBuf, + cwd: &Path, + native_id: &str, + model: Option<&str>, + title: Option<&str>, + items: &[NativeConversationItem], + materialized_by_orgii: bool, + completed_turns: Option, +) -> Result, String> { + let mut metadata = match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode existing Claude Desktop metadata {}: {error}", + path.display() + ) + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}), + Err(error) => { + return Err(format!( + "read Claude Desktop metadata {}: {error}", + path.display() + )) + } + }; + let object = metadata.as_object_mut().ok_or_else(|| { + format!( + "Claude Desktop metadata is not an object: {}", + path.display() + ) + })?; + let now = Utc::now().timestamp_millis(); + let existing_completed_turns = object + .get("completedTurns") + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()); + let projected_completed_turns = (!items.is_empty()).then(|| assistant_turn_count(items)); + let Some(completed_turns) = [ + completed_turns, + existing_completed_turns, + projected_completed_turns, + ] + .into_iter() + .flatten() + .max() else { + // A metadata-only refresh has no safe progress value when neither the + // queue nor an existing provider row carries one. Leave the catalog + // untouched instead of resetting completedTurns to zero. + return Ok(None); + }; + + object + .entry("sessionId".to_string()) + .or_insert_with(|| Value::String(format!("local_{native_id}"))); + object.insert( + "cliSessionId".to_string(), + Value::String(native_id.to_string()), + ); + object.insert( + "cwd".to_string(), + Value::String(cwd.to_string_lossy().into()), + ); + object + .entry("originCwd".to_string()) + .or_insert_with(|| Value::String(cwd.to_string_lossy().into())); + object + .entry("createdAt".to_string()) + .or_insert_with(|| Value::Number(now.into())); + object.insert("lastFocusedAt".to_string(), Value::Number(now.into())); + object.insert("lastActivityAt".to_string(), Value::Number(now.into())); + object.entry("title".to_string()).or_insert_with(|| { + Value::String( + title + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| first_user_title(items)), + ) + }); + object + .entry("titleSource".to_string()) + .or_insert_with(|| Value::String("orgii".to_string())); + object + .entry("permissionMode".to_string()) + .or_insert_with(|| Value::String("auto".to_string())); + object + .entry("isArchived".to_string()) + .or_insert(Value::Bool(false)); + object + .entry("remoteMcpServersConfig".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + object.insert( + "completedTurns".to_string(), + Value::Number(completed_turns.into()), + ); + object + .entry("alwaysAllowedReasons".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + object + .entry("sessionPermissionUpdates".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + object + .entry("classifierSummaryEnabled".to_string()) + .or_insert(Value::Bool(true)); + if materialized_by_orgii { + object.insert("orgiiMaterialization".to_string(), Value::Bool(true)); + } + if let Some(model) = model.filter(|value| !value.trim().is_empty()) { + object + .entry("model".to_string()) + .or_insert_with(|| Value::String(model.to_string())); + } + atomic_json(&path, &metadata)?; + // Read through the same provider-owned metadata boundary before reporting + // success. This is deliberately stronger than trusting our in-memory JSON: + // malformed/redirected writes remain native-format-only and materialize + // fails closed instead of promising an App-visible catalog row. + let published = fs::read_to_string(&path) + .map_err(|error| { + format!( + "read back Claude Desktop session {}: {error}", + path.display() + ) + }) + .and_then(|raw| { + serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode published Claude Desktop session {}: {error}", + path.display() + ) + }) + })?; + let published_cwd_matches = ["cwd", "originCwd"].into_iter().any(|field| { + published[field] + .as_str() + .is_some_and(|value| paths_match(Path::new(value), cwd)) + }); + if published["cliSessionId"].as_str() != Some(native_id) + || !published_cwd_matches + || !published["title"].is_string() + || !published["completedTurns"].is_number() + { + return Err(format!( + "Claude Desktop catalog read-back rejected {}", + path.display() + )); + } + Ok(Some(path)) +} + +fn remove_claude_desktop_session(native_id: &str) -> Result<(), String> { + for root in claude_desktop_sessions_roots() { + remove_claude_desktop_session_at(&root, native_id)?; + } + Ok(()) +} + +fn remove_claude_desktop_session_at(root: &Path, native_id: &str) -> Result<(), String> { + if !root.is_dir() { + return Ok(()); + } + let filename = format!("local_{native_id}.json"); + for organization in fs::read_dir(root) + .map_err(|err| format!("read Claude Desktop sessions {}: {err}", root.display()))? + .flatten() + { + for project in fs::read_dir(organization.path()) + .into_iter() + .flatten() + .flatten() + { + let path = project.path().join(&filename); + if !path.is_file() { + continue; + } + let matches_native_id = fs::read_to_string(&path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|value| value["cliSessionId"].as_str().map(str::to_string)) + .as_deref() + == Some(native_id); + if matches_native_id { + fs::remove_file(&path).map_err(|err| { + format!("remove Claude Desktop session {}: {err}", path.display()) + })?; + } + } + } + Ok(()) +} + +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::Compaction { + id, + summary, + created_at, + } = item + { + let boundary_uuid = stable_uuid("orgii-claude-native-compact-boundary", native_id, id); + records.push(json!({ + "type": "system", + "subtype": "compact_boundary", + "content": "Conversation compacted", + "uuid": boundary_uuid, + "parentUuid": parent_uuid, + "isSidechain": false, + "isMeta": false, + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "entrypoint": "orgii", + "orgiiMaterialization": true, + "compactMetadata": {"trigger": "orgii_native_transfer"}, + })); + let summary_uuid = stable_uuid("orgii-claude-native-compact-summary", native_id, id); + records.push(json!({ + "type": "user", + "uuid": summary_uuid, + "parentUuid": boundary_uuid, + "isSidechain": false, + "isCompactSummary": true, + "userType": "external", + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "message": {"role": "user", "content": summary}, + "entrypoint": "orgii", + "orgiiMaterialization": true, + })); + 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, .. + } => ( + "user".to_string(), + json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": call_id, + "content": output + }] + }), + Some(json!({"toolUseResult": output})), + ), + NativeConversationItem::Compaction { .. } => { + unreachable!("compaction handled before message projection") + } + }; + 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", + "orgiiMaterialization": true + }); + 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, + "orgiiMaterialization": true, + }) +} + +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 codex_response_items(items: &[NativeConversationItem]) -> Vec { + const MATERIALIZED_ARGUMENT_KEY: &str = "__orgiiMaterializedNative"; + const CANONICAL_ARGUMENT_KEY: &str = "__orgiiCanonicalArguments"; + const MATERIALIZED_COMPACTION_TURN_PREFIX: &str = "orgii-materialized-compaction:"; + + let mut projected = Vec::with_capacity(items.len().saturating_add(2)); + 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})), + ); + } + let mut message = + json!({"type": "message", "id": id, "role": role, "content": content}); + if role == "user" { + // `thread/inject_items` persists only response items; it + // does not synthesize the event_msg/UserMessage mirror + // found after an ordinary Codex UI submission. Stamp the + // supported passthrough turn id so our native reader can + // distinguish these canonical user rows from Codex's + // user-role system/context prefix messages. + message["internal_chat_message_metadata_passthrough"] = json!({ + "turn_id": format!("orgii-materialization-{id}") + }); + } + projected.push(message); + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => { + // `thread/inject_items` drops unknown response-item fields, so + // the legacy `orgii_materialization` boolean cannot survive a + // real app-server round trip. Arguments are protocol data and + // survive verbatim. The `__orgii` namespace is already + // excluded from portable user tool arguments; the reader + // removes this marker before publishing canonical history. + let canonical = serde_json::from_str::(arguments) + .expect("validated native tool arguments"); + let marked_arguments = match canonical { + Value::Object(mut object) => { + object.insert(MATERIALIZED_ARGUMENT_KEY.to_string(), Value::Bool(true)); + Value::Object(object) + } + canonical => { + let mut object = serde_json::Map::new(); + object.insert(MATERIALIZED_ARGUMENT_KEY.to_string(), Value::Bool(true)); + object.insert(CANONICAL_ARGUMENT_KEY.to_string(), canonical); + Value::Object(object) + } + }; + projected.push(json!({ + "type": "function_call", + "name": name, + "arguments": marked_arguments.to_string(), + "call_id": call_id + })); + } + NativeConversationItem::ToolResult { + call_id, output, .. + } => projected.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output + })), + NativeConversationItem::Compaction { id, summary, .. } => { + // `thread/inject_items` supports the Responses API's native + // `context_compaction` item. A cross-provider source cannot + // forge Codex's provider-encrypted compact payload, so carry + // the portable summary as an adjacent model-visible assistant + // item and tag both with the supported passthrough turn id. + // The native reader folds this exact pair back into one + // canonical compaction boundary; it is never projected as a + // fake user prompt. + let marker = format!("{MATERIALIZED_COMPACTION_TURN_PREFIX}{id}"); + projected.push(json!({ + "type": "message", + "id": format!("{id}-summary"), + "role": "assistant", + "content": [{"type": "output_text", "text": summary}], + "internal_chat_message_metadata_passthrough": { + "turn_id": marker + } + })); + projected.push(json!({ + "type": "context_compaction", + "id": id, + "encrypted_content": null, + "internal_chat_message_metadata_passthrough": { + "turn_id": marker + } + })); + } + } + } + if let Some(first) = projected.first_mut().and_then(Value::as_object_mut) { + first.insert("orgii_materialization".to_string(), Value::Bool(true)); + } + projected +} + +fn provider_canonical_cwd(cwd: PathBuf) -> PathBuf { + fs::canonicalize(&cwd).unwrap_or(cwd) +} + +fn execution_cwd(session: &persistence::CodeSession) -> Result { + let value = session + .worktree_path + .as_deref() + .or(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 workspace 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) -> Option { + 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 = fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_ITEMS { + return None; + } + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(path); + } + } + } + None +} + +fn has_orgii_materialization_marker(path: &Path, agent: &str) -> bool { + let Ok(file) = fs::File::open(path) else { + return false; + }; + let mut lines = BufReader::new(file).lines().take(MAX_ITEMS); + match agent { + "claude_code" => lines + .next() + .and_then(Result::ok) + .and_then(|line| serde_json::from_str::(&line).ok()) + .is_some_and(|record| record["orgiiMaterialization"] == true), + "codex" => lines.filter_map(Result::ok).any(|line| { + serde_json::from_str::(&line) + .ok() + .is_some_and(|record| codex_record_has_orgii_materialization_marker(&record)) + }), + _ => false, + } +} + +fn codex_record_has_orgii_materialization_marker(record: &Value) -> bool { + if record["type"] == "session_meta" && record["payload"]["originator"] == "orgii" { + return true; + } + if record["type"] != "response_item" { + return false; + } + let payload = &record["payload"]; + if payload["orgii_materialization"] == true { + return true; + } + if payload["internal_chat_message_metadata_passthrough"]["turn_id"] + .as_str() + .is_some_and(|turn_id| { + turn_id.starts_with("orgii-materialization-") + || turn_id.starts_with("orgii-materialized-compaction:") + }) + { + return true; + } + payload["type"] == "function_call" + && payload["arguments"] + .as_str() + .and_then(|arguments| serde_json::from_str::(arguments).ok()) + .is_some_and(|arguments| arguments["__orgiiMaterializedNative"] == true) +} + +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" => 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), + }; + for path in [&paths.native_path, &paths.runner_path] { + if fs::symlink_metadata(path).is_ok() && !has_orgii_materialization_marker(path, agent) { + return Err(format!( + "refusing to remove unmarked provider transcript {}", + path.display() + )); + } + } + let removed = match agent { + "codex" => { + codex_native_catalog::archive_thread(&paths.native_path, native_id, &cwd)?; + remove_file_if_present(&paths.runner_path)?; + // `thread/archive` removes the catalog row, not necessarily the + // rollout file. The marker checks above prove this is ORGII-owned. + remove_file_if_present(&paths.native_path)?; + true + } + "claude_code" => { + let mut removed = false; + for path in [&paths.runner_path, &paths.native_path] { + if fs::symlink_metadata(path).is_ok() { + fs::remove_file(path).map_err(|err| { + format!("remove native materialization {}: {err}", path.display()) + })?; + removed = true; + } + } + remove_claude_desktop_session(native_id)?; + remove_claude_project_index_entry(&cwd, native_id)?; + removed + } + _ => false, + }; + persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") + .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); + if let Err(error) = write_native_store_jsonl( + &paths, + &claude_records_with_resume_checkpoint(&native_id, &cwd, items)?, + ) { + // `atomic_jsonl` may already have committed the provider file + // before creating the account-profile alias fails. Nothing is + // bound yet, so clean both paths here rather than leave an + // unreachable ORGII-marked UUID behind. + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + 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 registered = + codex_native_catalog::register_thread(&cwd, &title, &codex_response_items(items))?; + let paths = match registered_codex_native_paths(account_id, ®istered.path) { + Ok(paths) => paths, + Err(error) => { + let _ = codex_native_catalog::archive_thread( + ®istered.path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(®istered.path); + 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(&paths.native_path, ®istered.id, &cwd); + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + 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" { + // Claude Code owns the executable resume contract: the native JSONL + // and its project session index. Claude Desktop metadata is a separate + // discovery projection and is refreshed best-effort after the binding + // is durable; a machine without Desktop must still run the CLI. + if let Err(error) = + publish_claude_project_index(&cwd, &native_id, items, None, session.branch.as_deref()) + { + let _ = fs::remove_file(&paths.runner_path); + let _ = fs::remove_file(&paths.native_path); + let _ = remove_claude_project_index_entry(&cwd, &native_id); + return Err(error); + } + } + // Bind the provider UUID before the caller round-trips the transcript. + // Both native readers already fall back to resolving the exact provider + // file by UUID when their list cache misses; synchronously rebuilding the + // entire imported-history index here turns a one-file continuation into + // an O(all historical transcripts) operation on the send path. + let register_result = (|| -> Result<(), String> { + let bound = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|err| { + format!("bind native transcript {native_id} to {session_id}: {err}") + })?; + if !bound { + return Err(format!( + "bind native transcript {native_id}: target session {session_id} disappeared" + )); + } + Ok(()) + })(); + if let Err(error) = register_result { + if agent == "codex" { + let _ = codex_native_catalog::archive_thread(&paths.native_path, &native_id, &cwd); + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + } else { + let _ = fs::remove_file(&paths.runner_path); + let _ = fs::remove_file(&paths.native_path); + let _ = remove_claude_project_index_entry(&cwd, &native_id); + } + return Err(error); + } + 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"))?; + agent_core::session::persistence::seed_session_with_messages( + session_id, + &native_agent_messages(session_id, items), + ) + .map_err(|err| format!("seed native Agent transcript {session_id}: {err}"))?; + 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" => 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" + )) + } + }; + let mut found = false; + for path in [&paths.native_path, &paths.runner_path] { + if fs::symlink_metadata(path).is_err() { + continue; + } + found = true; + } + if !found { + return Err(format!( + "materialized {agent} transcript {native_id} was not found" + )); + } + // A provider UUID is append-only after its first materialization. Claude + // and Codex may add compact checkpoints, encrypted context, queue rows, + // usage, or other native-only state between ORGII turns. Rewriting even + // an ORGII-created file from `complete_items` would destroy that state and + // make the provider compact the same conversation again. The TypeScript + // caller already proved the portable transcript is an exact semantic + // prefix, so append only its verified suffix for every existing UUID. + if !paths.native_path.is_file() && paths.runner_path.is_file() { + let parent = paths.native_path.parent().ok_or_else(|| { + format!( + "native transcript path has no parent: {}", + paths.native_path.display() + ) + })?; + fs::create_dir_all(parent) + .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; + fs::copy(&paths.runner_path, &paths.native_path).map_err(|err| { + format!( + "publish provider transcript {} -> {}: {err}", + paths.runner_path.display(), + paths.native_path.display() + ) + })?; + } + if !paths.native_path.is_file() { + return Err(format!( + "provider transcript {} was not found", + paths.native_path.display() + )); + } + replace_runner_link(&paths.native_path, &paths.runner_path)?; + match agent { + "claude_code" => { + // Validate and prepare Claude's index before mutating the JSONL, + // then keep ORGII index writers serialized until both commit. + let _index_guard = CLAUDE_PROJECT_INDEX_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let (index_path, index) = prepare_claude_project_index( + &cwd, + &native_id, + complete_items, + None, + session.branch.as_deref(), + )?; + 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; + let mut appended_suffix = None; + 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 original_len = fs::metadata(&paths.native_path) + .map_err(|error| { + format!( + "inspect native transcript {} before append: {error}", + paths.native_path.display() + ) + })? + .len(); + let payload = serialize_jsonl(&records)?; + append_jsonl_payload(&paths.native_path, &payload)?; + appended_suffix = Some((original_len, payload)); + } + if let Err(index_error) = atomic_json(&index_path, &index) { + if let Some((original_len, payload)) = appended_suffix { + if let Err(rollback_error) = + rollback_jsonl_suffix(&paths.native_path, original_len, &payload) + { + return Err(format!( + "{index_error}; additionally failed to roll back Claude transcript: {rollback_error}" + )); + } + } + return Err(index_error); + } + } + "codex" => { + let title = if session.name.trim().is_empty() { + first_user_title(complete_items) + } else { + session.name.clone() + }; + codex_native_catalog::synchronize_thread( + &paths.native_path, + &native_id, + &cwd, + &title, + &codex_response_items(append_items), + )?; + } + _ => unreachable!("unsupported targets returned above"), + } + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: complete_items.len(), + }) +} + +#[derive(Debug, Clone)] +struct CliNativePublicationContext { + session_id: String, + name: String, + model: Option, + branch: Option, + native_id: String, + cwd: PathBuf, + agent: String, + paths: NativeTranscriptPaths, +} + +fn cli_native_publication_context( + session_id: &str, +) -> Result, String> { + 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"))?; + cli_native_publication_context_from_session(session_id, session) +} + +fn cli_native_publication_context_from_session( + session_id: &str, + session: persistence::CodeSession, +) -> Result, String> { + 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(|err| format!("read native binding for {session_id}: {err}"))? + else { + return Ok(None); + }; + let cwd = execution_cwd(&session)?; + let agent = session.cli_agent_type.clone().unwrap_or_default(); + let paths = match agent.as_str() { + "claude_code" => claude_native_paths(account_id, &cwd, &native_id), + "codex" => { + let account_id = account_id + .ok_or_else(|| "native Codex catalog refresh 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"))? + } + _ => return Ok(None), + }; + Ok(Some(CliNativePublicationContext { + session_id: session.session_id, + name: session.name, + model: session.model, + branch: session.branch, + native_id, + cwd, + agent, + paths, + })) +} + +pub(super) fn freeze_cli_native_publication_context(session_id: &str) -> Result<(), String> { + let session = persistence::get_session(session_id); + let mut snapshots = ACTIVE_NATIVE_PUBLICATION_SESSIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match session { + Ok(Some(session)) + if session.key_source == super::types::KeySource::OwnKey + && matches!( + session.cli_agent_type.as_deref(), + Some("claude_code" | "codex") + ) => + { + snapshots.insert(session_id.to_string(), session); + Ok(()) + } + Ok(Some(_)) => { + snapshots.remove(session_id); + Ok(()) + } + Ok(None) => { + snapshots.remove(session_id); + Err(format!("CLI session {session_id} does not exist")) + } + Err(err) => { + snapshots.remove(session_id); + Err(format!("load CLI session {session_id}: {err}")) + } + } +} + +pub(super) fn clear_cli_native_publication_context(session_id: &str) { + ACTIVE_NATIVE_PUBLICATION_SESSIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id); +} + +fn take_cli_native_publication_context( + session_id: &str, +) -> Option { + ACTIVE_NATIVE_PUBLICATION_SESSIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id) +} + +/// Copy a runner-replaced provider transcript into the real native App store. +/// +/// This is the only operation that must finish before a follow-up may replace +/// the runner. App catalog discovery is metadata and is intentionally kept out +/// of this boundary so Send Now / runtime switches never wait on app-server. +fn publish_cli_native_transcript_after_turn_blocking( + session_id: &str, + frozen: Option, +) -> Result, String> { + let context = match frozen { + Some(session) => cli_native_publication_context_from_session(session_id, session)?, + None => cli_native_publication_context(session_id)?, + }; + let Some(context) = context else { + return Ok(None); + }; + publish_runner_transcript(&context.paths, &context.native_id)?; + tracing::info!( + session_id, + native_session_id = context.native_id, + "published provider-native transcript" + ); + Ok(Some(context)) +} + +pub(super) async fn publish_cli_native_transcript_after_turn( + session_id: &str, +) -> Result { + // Take ownership before spawning blocking work. Context resolution, + // validation, filesystem publication, a panicking worker, or runtime + // shutdown can then fail without retaining a stale active-turn snapshot. + let frozen = take_cli_native_publication_context(session_id); + let session_id = session_id.to_string(); + let context = tokio::task::spawn_blocking(move || { + publish_cli_native_transcript_after_turn_blocking(&session_id, frozen) + }) + .await + .map_err(|error| format!("provider-native transcript snapshot task failed: {error}"))??; + if let Some(context) = context { + schedule_cli_native_catalog_refresh_context(context, None); + Ok(true) + } else { + Ok(false) + } +} + +fn refresh_cli_native_conversation_metadata( + context: &CliNativePublicationContext, + expected_provider: NativeCatalogProvider, + completed_turns_hint: Option, +) -> Result { + if context.agent != expected_provider.as_str() { + return Err(format!( + "native catalog snapshot provider {} does not match queued lane {}", + context.agent, + expected_provider.as_str() + )); + } + let published = match context.agent.as_str() { + "claude_code" => { + // Claude Code's own session index is part of the CLI-native + // transcript contract and must advance even when Claude Desktop + // is not installed, signed in, or able to accept its sidecar. + publish_claude_project_index( + &context.cwd, + &context.native_id, + &[], + completed_turns_hint, + context.branch.as_deref(), + )?; + let materialized_by_orgii = [&context.paths.native_path, &context.paths.runner_path] + .into_iter() + .any(|path| has_orgii_materialization_marker(path, "claude_code")); + publish_claude_desktop_session( + &context.cwd, + &context.native_id, + context.model.as_deref(), + Some(context.name.as_str()), + &[], + materialized_by_orgii, + completed_turns_hint, + )? + .is_some() + } + "codex" => { + let title = if context.name.trim().is_empty() { + "Imported conversation" + } else { + context.name.as_str() + }; + let entry = codex_native_catalog::refresh_catalog( + &context.paths.native_path, + &context.native_id, + &context.cwd, + title, + )?; + entry.id == context.native_id && paths_match(&entry.cwd, &context.cwd) + } + _ => false, + }; + tracing::info!( + session_id = %context.session_id, + native_session_id = %context.native_id, + published, + completed_turns_hint = ?completed_turns_hint, + "refreshed provider-native conversation metadata" + ); + Ok(published) +} + +/// Refresh native App discovery after the runner transcript was safely +/// published by `publish_cli_native_transcript_after_turn`. +fn refresh_cli_native_conversation_after_turn( + context: CliNativePublicationContext, + provider: NativeCatalogProvider, + completed_turns_hint: Option, +) -> Result { + // Ordinary final/cancel paths do not carry the materializer's absolute + // count. Resolve it once per coalesced background refresh, then reuse the + // same value across retries so Claude Desktop and projects.json advance + // after every native turn without repeated full-file reads. + let completed_turns_hint = if provider == NativeCatalogProvider::ClaudeCode + && completed_turns_hint.is_none() + { + let path = preferred_materialized_transcript_path(&context.paths).ok_or_else(|| { + format!( + "Claude transcript {} has no readable native copy", + context.native_id + ) + })?; + Some(claude_completed_turns_from_transcript(path)?) + } else { + completed_turns_hint + }; + let mut last_error = None; + for attempt in 0..=NATIVE_CATALOG_REFRESH_BACKOFFS.len() { + match refresh_cli_native_conversation_metadata(&context, provider, completed_turns_hint) { + Ok(true) => return Ok(true), + Ok(false) => { + if let Some(delay) = NATIVE_CATALOG_REFRESH_BACKOFFS.get(attempt) { + std::thread::sleep(*delay); + continue; + } + return Ok(false); + } + Err(error) => { + last_error = Some(error); + if let Some(delay) = NATIVE_CATALOG_REFRESH_BACKOFFS.get(attempt) { + std::thread::sleep(*delay); + } + } + } + } + Err(last_error.unwrap_or_else(|| { + format!( + "provider-native catalog refresh failed for {}", + context.session_id + ) + })) +} + +fn native_catalog_refresh_is_current(context: &CliNativePublicationContext) -> bool { + matches!( + cli_native_publication_context(&context.session_id), + Ok(Some(current)) + if current.agent == context.agent + && current.native_id == context.native_id + && current.paths.native_path == context.paths.native_path + && current.paths.runner_path == context.paths.runner_path + ) +} + +/// Coalesce slow native App discovery behind a background boundary. Transcript +/// durability is handled synchronously before this is scheduled; catalog +/// availability may catch up without extending the provider turn or blocking +/// the next message. +fn schedule_cli_native_catalog_refresh_with_hint( + session_id: &str, + agent: &str, + completed_turns_hint: Option, +) { + let Some(provider) = NativeCatalogProvider::from_agent(agent) else { + tracing::warn!( + session_id, + agent, + "ignored catalog refresh for unsupported provider" + ); + return; + }; + let context = match cli_native_publication_context(session_id) { + Ok(Some(context)) if context.agent == provider.as_str() => context, + Ok(Some(context)) => { + tracing::warn!( + session_id, + requested_provider = provider.as_str(), + snapshot_provider = context.agent, + snapshot_native_session_id = context.native_id, + "ignored stale native catalog refresh after a runtime switch" + ); + return; + } + Ok(None) => { + tracing::warn!( + session_id, + provider = provider.as_str(), + "ignored native catalog refresh without a native binding" + ); + return; + } + Err(error) => { + tracing::warn!( + session_id, + provider = provider.as_str(), + error = %error, + "failed to capture provider-native catalog snapshot" + ); + return; + } + }; + schedule_cli_native_catalog_refresh_context(context, completed_turns_hint); +} + +fn schedule_cli_native_catalog_refresh_context( + context: CliNativePublicationContext, + completed_turns_hint: Option, +) { + let Some(provider) = NativeCatalogProvider::from_agent(&context.agent) else { + return; + }; + let should_spawn = NATIVE_CATALOG_REFRESH_QUEUE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .lane_mut(provider) + .enqueue(provider, context, completed_turns_hint); + if !should_spawn { + return; + } + tokio::spawn(async move { + loop { + let next = { + let mut queue = NATIVE_CATALOG_REFRESH_QUEUE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.lane_mut(provider).take_next() + }; + let Some(request) = next else { + return; + }; + let session_id = request.context.session_id.clone(); + // The managed session is the live owner of this native UUID. Do + // not await a busy identity inside the one-per-provider worker: + // one long turn would head-of-line block every other session. + let identity_lock = super::session_runner::session_identity_lock(&session_id).await; + let native_identity_guard = match identity_lock.clone().try_lock_owned() { + Ok(guard) => guard, + Err(_) => { + let (key, should_spawn_waiter) = { + let mut queue = NATIVE_CATALOG_REFRESH_QUEUE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + queue + .lane_mut(provider) + .defer_until_identity_available(provider, request) + }; + if should_spawn_waiter { + tokio::spawn(async move { + // Await the lifecycle edge without polling, then + // release immediately so a queued user turn is not + // held behind metadata publication. + let identity_guard = identity_lock.lock_owned().await; + drop(identity_guard); + let deferred = { + let mut queue = NATIVE_CATALOG_REFRESH_QUEUE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + queue.lane_mut(provider).take_deferred(&key) + }; + if let Some(request) = deferred { + schedule_cli_native_catalog_refresh_context( + request.context, + request.completed_turns_hint, + ); + } + }); + } + continue; + } + }; + let result = tokio::task::spawn_blocking(move || { + let _native_identity_guard = native_identity_guard; + // A queued request is only a projection hint. Delete, + // truncate, discard, or a runtime/account switch may replace + // the binding while it waits; never resurrect that stale UUID + // in a provider App catalog. + if !native_catalog_refresh_is_current(&request.context) { + tracing::info!( + session_id, + native_session_id = %request.context.native_id, + "discarded stale provider-native catalog refresh" + ); + return; + } + match refresh_cli_native_conversation_after_turn( + request.context, + provider, + request.completed_turns_hint, + ) { + Ok(true) => {} + Ok(false) => tracing::warn!( + session_id, + "provider-native App catalog is unavailable; CLI transcript remains resumable" + ), + Err(error) => tracing::warn!( + session_id, + error = %error, + "failed to refresh provider-native App catalog" + ), + } + }) + .await; + if let Err(error) = result { + tracing::warn!( + error = %error, + "provider-native App catalog worker failed" + ); + } + } + }); +} + +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"))?; + agent_core::session::persistence::append_session_with_messages( + session_id, + &native_agent_messages(session_id, append_items), + ) + .map_err(|err| format!("append native Agent transcript {session_id}: {err}"))?; + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn materialize_native_conversation( + session_id: String, + items: Vec, +) -> Result { + validate_items(&items)?; + // Move both guards into the blocking mutation. If the IPC future is + // cancelled after spawning, the filesystem/DB work stays serialized until + // it actually finishes instead of racing a follow-up or catalog refresh. + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let receipt = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + materialize_cli(&session_id, &items) + } else { + materialize_native_agent(&session_id, &items) + } + }) + .await + .map_err(|err| format!("native materialization task failed: {err}"))??; + Ok(receipt) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn synchronize_native_conversation( + session_id: String, + complete_items: Vec, + prefix_item_count: usize, +) -> Result { + validate_items(&complete_items)?; + if prefix_item_count >= complete_items.len() { + return Err("native transcript synchronization requires a non-empty suffix".to_string()); + } + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let receipt = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + // The TypeScript caller has already verified semantic prefix growth. + // Derive the append-only suffix from the one complete IPC payload so + // large conversations are not cloned and decoded twice. + let append_items = &complete_items[prefix_item_count..]; + 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) + } + }) + .await + .map_err(|err| format!("native synchronization task failed: {err}"))??; + Ok(receipt) +} + +/// Commit App discovery only after the frontend has round-tripped and +/// semantically verified the newly materialized provider transcript. Keeping +/// this separate from the write IPC prevents a failed verification + discard +/// from racing a background metadata worker that would recreate a ghost +/// catalog entry. +#[tauri::command(rename_all = "camelCase")] +pub async fn commit_native_conversation_materialization( + session_id: String, + native_session_id: String, +) -> Result { + if !session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + return Ok(false); + } + let _mutation_guards = lock_idle_native_mutation(&session_id).await?; + let Some(context) = cli_native_publication_context(&session_id)? else { + return Ok(false); + }; + if context.native_id != native_session_id { + return Err(format!( + "native materialization binding changed before commit: expected {native_session_id}, found {}", + context.native_id + )); + } + if context.agent != "claude_code" { + return Ok(false); + } + // This call freezes the same context while the session control lock is + // still held; later account/model patches cannot retarget the worker. + schedule_cli_native_catalog_refresh_with_hint( + &session_id, + &context.agent, + None, + ); + Ok(true) +} + +#[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; + + fn create_claude_session(session_id: &str, account_id: &str) { + create_claude_session_with_account(session_id, Some(account_id)); + } + + fn create_claude_session_with_account(session_id: &str, account_id: Option<&str>) { + persistence::create_session( + session_id, + &persistence::CreateCodeSessionParams { + name: Some("native synchronization test".to_string()), + flow: None, + runner: None, + cli_agent_type: "claude_code".to_string(), + model: Some("claude-sonnet-4-6".to_string()), + tier: None, + account_id: account_id.map(str::to_string), + repo_path: Some("/repo".to_string()), + branch: None, + worktree_path: None, + worktree_base_ref: None, + proxy_token: None, + proxy_url: None, + hosted_token: None, + proxy_session_id: None, + isolate: Some(false), + 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: Some("build".to_string()), + }, + ) + .expect("create Claude CLI session"); + } + + fn create_codex_session(session_id: &str, account_id: &str, repo_path: &Path) { + persistence::create_session( + session_id, + &persistence::CreateCodeSessionParams { + name: Some("native Codex synchronization test".to_string()), + flow: None, + runner: None, + cli_agent_type: "codex".to_string(), + model: Some("gpt-5.4".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: Some(false), + 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: Some("build".to_string()), + }, + ) + .expect("create Codex CLI session"); + let profile = app_paths::codex_cli_profile_dir(account_id); + fs::create_dir_all(&profile).expect("create Codex test profile"); + fs::write(profile.join("config.toml"), "model_provider = \"openai\"\n") + .expect("write Codex test profile"); + } + + fn message() -> NativeConversationItem { + NativeConversationItem::Message { + id: "u1".to_string(), + role: "user".to_string(), + text: "hello".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:00Z".to_string(), + turn_id: None, + } + } + + fn assistant_message() -> NativeConversationItem { + NativeConversationItem::Message { + id: "a1".to_string(), + role: "assistant".to_string(), + text: "done".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:03Z".to_string(), + turn_id: None, + } + } + + #[test] + fn native_materialization_rejects_duplicate_canonical_item_ids() { + let duplicate = message(); + let error = validate_items(&[duplicate.clone(), duplicate]) + .expect_err("duplicate canonical ids must fail closed"); + assert!(error.contains("duplicate canonical item id")); + } + + #[test] + fn diverged_transcript_prefers_only_the_provably_newer_copy() { + 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, "native").expect("write native transcript"); + fs::write(&paths.runner_path, "runner").expect("write runner transcript"); + let base = std::time::SystemTime::now() - std::time::Duration::from_secs(120); + std::fs::File::options() + .write(true) + .open(&paths.native_path) + .expect("open native transcript") + .set_modified(base) + .expect("set native mtime"); + std::fs::File::options() + .write(true) + .open(&paths.runner_path) + .expect("open runner transcript") + .set_modified(base + std::time::Duration::from_secs(1)) + .expect("set runner mtime"); + assert_eq!( + preferred_materialized_transcript_path(&paths), + Some(paths.runner_path.as_path()) + ); + + std::fs::File::options() + .write(true) + .open(&paths.native_path) + .expect("reopen native transcript") + .set_modified(base + std::time::Duration::from_secs(2)) + .expect("advance native mtime"); + assert_eq!( + preferred_materialized_transcript_path(&paths), + Some(paths.native_path.as_path()) + ); + } + + #[test] + fn claude_materialization_is_native_role_history() { + let records = claude_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + &[message()], + ) + .expect("claude records"); + assert_eq!(records[0]["type"], "user"); + assert_eq!(records[0]["message"]["role"], "user"); + assert_eq!(records[0]["message"]["content"], "hello"); + assert_eq!(records[0]["entrypoint"], "orgii"); + assert_eq!(records[0]["orgiiMaterialization"], true); + let assistant = claude_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + &[assistant_message()], + ) + .expect("claude assistant records"); + assert_eq!(assistant[0]["message"]["content"][0]["type"], "text"); + assert_eq!(assistant[0]["message"]["content"][0]["text"], "done"); + } + + #[test] + fn claude_suffix_inspection_distinguishes_missing_applied_and_mixed() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("claude-suffix.jsonl"); + let expected = claude_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + &[message(), assistant_message()], + ) + .expect("project Claude suffix"); + + atomic_jsonl(&path, &[json!({"type": "last-prompt", "leafUuid": "prior"})]) + .expect("write prefix"); + assert_eq!( + inspect_claude_suffix_application(&path, &expected) + .expect("inspect missing suffix") + .0, + NativeSuffixApplication::Missing + ); + + append_jsonl(&path, std::slice::from_ref(&expected[0])).expect("append mixed suffix"); + assert!(inspect_claude_suffix_application(&path, &expected).is_err()); + + atomic_jsonl(&path, &expected).expect("write complete suffix"); + assert_eq!( + inspect_claude_suffix_application(&path, &expected) + .expect("inspect applied suffix") + .0, + NativeSuffixApplication::AlreadyApplied + ); + } + + #[test] + fn claude_active_leaf_prefers_the_latest_branch_checkpoint_or_newer_partial_record() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-active-leaf-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let path = temp_dir.join("session.jsonl"); + atomic_jsonl( + &path, + &[ + json!({"type": "assistant", "uuid": "native-leaf-1"}), + json!({ + "type": "last-prompt", + "lastPrompt": "first turn", + "leafUuid": "native-leaf-1", + "sessionId": "native-session" + }), + json!({"type": "mode", "mode": "build"}), + ], + ) + .expect("write native checkpoint fixture"); + assert_eq!( + claude_active_leaf_uuid(&path).as_deref(), + Some("native-leaf-1") + ); + + append_jsonl( + &path, + &[json!({ + "type": "assistant", + "uuid": "interrupted-partial-leaf", + "parentUuid": "native-leaf-1" + })], + ) + .expect("append partial native turn"); + assert_eq!( + claude_active_leaf_uuid(&path).as_deref(), + Some("interrupted-partial-leaf"), + "a partial provider record written after the last checkpoint is the active branch" + ); + + fs::remove_dir_all(temp_dir).expect("remove active leaf fixture"); + } + + #[test] + fn claude_materialization_round_trips_through_the_existing_reader() { + let native_id = "00000000-0000-4000-8000-000000000001"; + let items = vec![ + message(), + NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"/repo/README.md"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-1:result".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + output: "contents".to_string(), + created_at: "2026-08-26T00:00:02Z".to_string(), + }, + assistant_message(), + NativeConversationItem::Compaction { + id: "compact-1".to_string(), + summary: "Native compact summary".to_string(), + created_at: "2026-08-26T00:00:04Z".to_string(), + }, + ]; + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-roundtrip-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let path = temp_dir.join(format!("{native_id}.jsonl")); + atomic_jsonl( + &path, + &claude_records(native_id, Path::new("/repo"), &items) + .expect("build native Claude transcript"), + ) + .expect("write native Claude transcript"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + "claudecodeapp-native-roundtrip", + &path, + ) + .expect("read native Claude transcript"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 1 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .count(), + 1 + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("tool call"); + assert_eq!(tool.args["path"], "/repo/README.md"); + assert_eq!(tool.result["output"], "contents"); + let compact = chunks + .iter() + .find(|chunk| chunk.function == "context_compacted") + .expect("native compact boundary"); + assert_eq!(compact.result["observation"], "Native compact summary"); + + std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn codex_app_server_projection_marks_user_rows_for_native_replay() { + let items = codex_response_items(&[ + message(), + NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id: "call-1".to_string(), + name: "grep".to_string(), + arguments: r#"{"pattern":"needle"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-1:result".to_string(), + call_id: "call-1".to_string(), + name: "grep".to_string(), + output: "match".to_string(), + created_at: "2026-08-26T00:00:02Z".to_string(), + }, + ]); + assert_eq!(items.len(), 3); + assert!( + items[0]["internal_chat_message_metadata_passthrough"]["turn_id"] + .as_str() + .is_some_and(|turn_id| turn_id.starts_with("orgii-materialization-")) + ); + assert_eq!(items[1]["call_id"], "call-1"); + assert_eq!(items[2]["call_id"], "call-1"); + let arguments = + serde_json::from_str::(items[1]["arguments"].as_str().expect("arguments")) + .expect("marked arguments"); + assert_eq!(arguments["pattern"], "needle"); + assert_eq!(arguments["__orgiiMaterializedNative"], true); + } + + #[test] + fn codex_materialization_marker_uses_fields_preserved_by_app_server() { + let projected = codex_response_items(&[ + message(), + NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id: "call-1".to_string(), + name: "grep".to_string(), + arguments: r#"{"pattern":"needle"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }, + ]); + let user_record = json!({"type": "response_item", "payload": projected[0]}); + let tool_record = json!({"type": "response_item", "payload": projected[1]}); + + assert!(codex_record_has_orgii_materialization_marker(&user_record)); + assert!(codex_record_has_orgii_materialization_marker(&tool_record)); + assert!(!codex_record_has_orgii_materialization_marker(&json!({ + "type": "response_item", + "payload": { + "type": "function_call", + "arguments": "{\"pattern\":\"needle\"}" + } + }))); + } + + #[test] + fn codex_app_server_projection_uses_supported_native_compaction_items() { + let items = codex_response_items(&[NativeConversationItem::Compaction { + id: "compact-1".to_string(), + summary: "Canonical compact summary".to_string(), + created_at: "2026-08-31T00:00:00Z".to_string(), + }]); + + assert_eq!(items.len(), 2); + assert_eq!(items[0]["type"], "message"); + assert_eq!(items[0]["role"], "assistant"); + assert_eq!(items[1]["type"], "context_compaction"); + assert!(items[1]["encrypted_content"].is_null()); + let summary_turn_id = items[0]["internal_chat_message_metadata_passthrough"]["turn_id"] + .as_str() + .expect("materialized compact summary marker"); + let compact_turn_id = items[1]["internal_chat_message_metadata_passthrough"]["turn_id"] + .as_str() + .expect("materialized compact boundary marker"); + assert_eq!(summary_turn_id, compact_turn_id); + assert!(summary_turn_id.starts_with("orgii-materialized-compaction:")); + assert!(!items.iter().any(|item| item["role"] == "user")); + } + + #[test] + fn claude_synchronization_preserves_native_compact_state_and_uuid() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-sync"; + let account_id = "native-sync-account"; + create_claude_session(session_id, account_id); + let prefix = vec![message(), assistant_message()]; + let first = materialize_cli(session_id, &prefix).expect("materialize Claude prefix"); + let paths = claude_native_paths( + Some(account_id), + Path::new("/repo"), + &first.native_session_id, + ); + append_jsonl( + &paths.native_path, + &[ + json!({ + "type": "system", + "subtype": "compact_boundary", + "uuid": "provider-compact-boundary", + "parentUuid": Value::Null, + "sessionId": first.native_session_id.clone(), + "timestamp": "2026-08-26T00:00:03.500Z", + "compactMetadata": {"trigger": "auto"} + }), + json!({ + "type": "user", + "uuid": "provider-compact-summary", + "parentUuid": "provider-compact-boundary", + "isCompactSummary": true, + "sessionId": first.native_session_id.clone(), + "timestamp": "2026-08-26T00:00:03.500Z", + "message": {"role": "user", "content": "provider-native summary sentinel"} + }), + json!({ + "type": "last-prompt", + "lastPrompt": "hello", + "leafUuid": "provider-compact-summary", + "sessionId": first.native_session_id.clone() + }), + json!({ + "type": "mode", + "mode": "build", + "sessionId": first.native_session_id.clone() + }), + ], + ) + .expect("append provider-native Claude compact state"); + let remote_user = NativeConversationItem::Message { + id: "u2".to_string(), + role: "user".to_string(), + text: "remote canonical delta".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:04Z".to_string(), + turn_id: None, + }; + let complete = vec![message(), assistant_message(), remote_user]; + let second = synchronize_cli(session_id, &complete, &complete[2..]) + .expect("synchronize Claude native history"); + + assert_eq!(second.native_session_id, first.native_session_id); + assert_eq!(second.item_count, complete.len()); + let path = &paths.runner_path; + assert!(paths.native_path.is_file()); + #[cfg(unix)] + assert_eq!( + fs::read_link(path).expect("runner transcript symlink"), + paths.native_path + ); + let records = fs::read_to_string(path) + .expect("read synchronized Claude JSONL") + .lines() + .map(|line| serde_json::from_str::(line).expect("decode Claude record")) + .collect::>(); + let user_messages = records + .iter() + .filter(|record| { + record["type"] == "user" && record["isCompactSummary"] != Value::Bool(true) + }) + .map(|record| record["message"]["content"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(user_messages, vec!["hello", "remote canonical delta"]); + assert!(records.iter().any(|record| { + record["subtype"] == "compact_boundary" && record["uuid"] == "provider-compact-boundary" + })); + assert!(records.iter().any(|record| { + record["isCompactSummary"] == true + && record["message"]["content"] == "provider-native summary sentinel" + })); + let appended_user = records + .iter() + .find(|record| record["message"]["content"] == "remote canonical delta") + .expect("appended canonical suffix"); + assert_eq!(appended_user["parentUuid"], "provider-compact-summary"); + let appended_user_uuid = appended_user["uuid"] + .as_str() + .expect("materialized user uuid"); + let resume_checkpoint = records + .iter() + .rev() + .find(|record| record["type"] == "last-prompt") + .expect("materialized resume checkpoint"); + assert_eq!(resume_checkpoint["leafUuid"], appended_user_uuid); + assert_eq!(resume_checkpoint["lastPrompt"], "remote canonical delta"); + assert_eq!( + claude_active_leaf_uuid(&paths.native_path).as_deref(), + Some(appended_user_uuid), + "the next native --resume must attach to the remote canonical suffix" + ); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + "claudecodeapp-native-sync-roundtrip", + path, + ) + .expect("round-trip synchronized Claude transcript"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 2 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .count(), + 1 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .map(|chunk| chunk.result["observation"].as_str().unwrap_or_default()) + .collect::>(), + vec!["done"] + ); + assert_eq!( + fs::read_to_string(path).expect("read runner transcript"), + fs::read_to_string(&paths.native_path).expect("read provider transcript") + ); + } + + #[test] + fn claude_synchronization_rolls_back_jsonl_when_project_index_is_invalid() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-index-rollback"; + let account_id = "native-index-rollback-account"; + create_claude_session(session_id, account_id); + let prefix = vec![message(), assistant_message()]; + let first = materialize_cli(session_id, &prefix).expect("materialize Claude prefix"); + let paths = claude_native_paths( + Some(account_id), + Path::new("/repo"), + &first.native_session_id, + ); + let before = fs::read(&paths.native_path).expect("read prefix transcript"); + let index_path = claude_native_paths(None, Path::new("/repo"), &first.native_session_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + atomic_json( + &index_path, + &json!({"version": 1, "entries": "not-an-array"}), + ) + .expect("poison project index shape"); + + let suffix = NativeConversationItem::Message { + id: "u2".to_string(), + role: "user".to_string(), + text: "must roll back".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:04Z".to_string(), + turn_id: None, + }; + let complete = vec![message(), assistant_message(), suffix]; + let error = synchronize_cli(session_id, &complete, &complete[2..]) + .expect_err("invalid project index must fail synchronization"); + + assert!(error.contains("entries are not an array")); + assert_eq!( + fs::read(&paths.native_path).expect("read rolled-back transcript"), + before, + "a failed index update must not leave the canonical suffix appended" + ); + } + + #[test] + fn codex_synchronization_preserves_native_compact_state_and_uuid() { + let sandbox = test_env::sandbox(); + let _catalog = codex_native_catalog::use_direct_test_catalog(); + let session_id = "cliagent-native-codex-sync"; + let account_id = "native-codex-sync-account"; + let repo_path = sandbox.path().join("repo"); + fs::create_dir_all(&repo_path).expect("create native Codex test workspace"); + create_codex_session(session_id, account_id, &repo_path); + let prefix = vec![message(), assistant_message()]; + let first = materialize_cli(session_id, &prefix).expect("materialize Codex prefix"); + let paths = existing_codex_native_paths(account_id, &first.native_session_id) + .expect("materialized Codex paths"); + append_jsonl( + &paths.native_path, + &[json!({ + "timestamp": "2026-08-26T00:00:03.500Z", + "type": "compacted", + "payload": { + "message": "", + "replacement_history": [{ + "item": { + "type": "compaction", + "encrypted_content": "provider-native-encrypted-sentinel" + } + }], + "window_number": 2, + "first_window_id": "provider-window-1", + "previous_window_id": "provider-window-1", + "window_id": "provider-window-2" + } + })], + ) + .expect("append provider-native Codex compact state"); + let remote_user = NativeConversationItem::Message { + id: "u2".to_string(), + role: "user".to_string(), + text: "remote canonical delta".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:04Z".to_string(), + turn_id: None, + }; + let complete = vec![message(), assistant_message(), remote_user]; + + let second = synchronize_cli(session_id, &complete, &complete[2..]) + .expect("synchronize Codex native history"); + + assert_eq!(second.native_session_id, first.native_session_id); + assert_eq!(second.item_count, complete.len()); + let raw = fs::read_to_string(&paths.native_path).expect("read synchronized Codex JSONL"); + assert!(raw.contains("provider-native-encrypted-sentinel")); + assert!(raw.contains("remote canonical delta")); + let chunks = orgtrack_core::sources::codex::app::load_codex_app_from_path( + "codexapp-native-sync-roundtrip", + &paths.native_path, + ) + .expect("round-trip synchronized Codex 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!["hello", "remote canonical delta"]); + let assistant_messages = chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .map(|chunk| chunk.result["observation"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(assistant_messages, vec!["done"]); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .count(), + 1 + ); + assert_eq!( + fs::read_to_string(&paths.runner_path).expect("read Codex runner transcript"), + raw + ); + } + + #[test] + fn synchronization_migrates_a_managed_only_transcript_to_the_app_store() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-managed-migration"; + let account_id = "native-managed-migration-account"; + let native_id = "00000000-0000-4000-8000-000000000088"; + create_claude_session(session_id, account_id); + assert!(persistence::update_cli_session_id_for_account( + session_id, + Some(account_id), + native_id, + ) + .expect("bind legacy native transcript")); + let paths = claude_native_paths(Some(account_id), Path::new("/repo"), native_id); + atomic_jsonl( + &paths.runner_path, + &claude_records(native_id, Path::new("/repo"), &[message()]) + .expect("legacy Claude records"), + ) + .expect("write managed-only transcript"); + assert!(!paths.native_path.exists()); + + let complete = [message(), assistant_message()]; + synchronize_cli(session_id, &complete, &complete[1..]) + .expect("migrate and synchronize native transcript"); + + assert!(paths.native_path.is_file()); + #[cfg(unix)] + assert_eq!( + fs::read_link(&paths.runner_path).expect("migrated runner symlink"), + paths.native_path + ); + assert!(fs::read_to_string(&paths.native_path) + .expect("read migrated app transcript") + .contains("done")); + } + + #[test] + fn discard_removes_both_native_paths() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-discard"; + let account_id = "native-discard-account"; + create_claude_session(session_id, account_id); + let receipt = materialize_cli(session_id, &[message()]).expect("materialize transcript"); + let paths = claude_native_paths( + Some(account_id), + Path::new("/repo"), + &receipt.native_session_id, + ); + assert!(paths.native_path.is_file()); + assert!(fs::symlink_metadata(&paths.runner_path).is_ok()); + + assert!( + discard_cli_materialization(session_id, &receipt.native_session_id) + .expect("discard transcript") + ); + assert!(fs::symlink_metadata(&paths.native_path).is_err()); + assert!(fs::symlink_metadata(&paths.runner_path).is_err()); + } + + #[test] + fn provider_store_jsonl_keeps_the_runner_on_the_same_native_file() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-visible-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let paths = NativeTranscriptPaths { + native_path: temp_dir.join("provider/session.jsonl"), + runner_path: temp_dir.join("runner/session.jsonl"), + }; + write_native_store_jsonl(&paths, &[json!({"generation": 1})]) + .expect("write initial app-visible transcript"); + #[cfg(unix)] + assert_eq!( + fs::read_link(&paths.runner_path).expect("runner transcript symlink"), + paths.native_path + ); + assert_eq!( + fs::read_to_string(&paths.native_path).expect("read provider transcript"), + fs::read_to_string(&paths.runner_path).expect("read runner transcript") + ); + + write_native_store_jsonl(&paths, &[json!({"generation": 2})]) + .expect("replace app-visible transcript"); + assert!(fs::read_to_string(&paths.runner_path) + .expect("read replaced runner transcript") + .contains("\"generation\":2")); + #[cfg(unix)] + assert_eq!( + fs::read_link(&paths.runner_path).expect("replaced runner transcript symlink"), + paths.native_path + ); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn provider_refresh_republishes_a_runner_replaced_codex_link() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-runner-publish-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let paths = NativeTranscriptPaths { + native_path: temp_dir.join("provider/session.jsonl"), + runner_path: temp_dir.join("runner/session.jsonl"), + }; + write_native_store_jsonl(&paths, &[json!({"generation": 1})]) + .expect("write initial provider transcript"); + fs::remove_file(&paths.runner_path).expect("remove runner symlink"); + atomic_jsonl( + &paths.runner_path, + &[json!({"generation": 2, "sessionId": "native-publish"})], + ) + .expect("simulate Codex replacing the runner link"); + + publish_runner_transcript(&paths, "native-publish").expect("publish runner transcript"); + + assert!(fs::read_to_string(&paths.native_path) + .expect("read republished provider transcript") + .contains("\"generation\":2")); + #[cfg(unix)] + assert_eq!( + fs::read_link(&paths.runner_path).expect("restored runner symlink"), + paths.native_path + ); + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn ambient_claude_uses_the_official_profile_without_an_alias() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-ambient"; + create_claude_session_with_account(session_id, None); + + let receipt = materialize_cli(session_id, &[message()]) + .expect("materialize through ambient Claude profile"); + let paths = claude_native_paths(None, Path::new("/repo"), &receipt.native_session_id); + + assert_eq!(paths.runner_path, paths.native_path); + assert!(paths.native_path.is_file()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, None) + .expect("read ambient native binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + assert!( + discard_cli_materialization(session_id, &receipt.native_session_id) + .expect("discard ambient transcript") + ); + assert!(!paths.native_path.exists()); + } + + #[test] + fn claude_cli_materialization_does_not_require_a_desktop_catalog() { + let _sandbox = test_env::sandbox(); + let session_id = "cliagent-native-claude-no-desktop"; + let account_id = "native-no-desktop-account"; + create_claude_session(session_id, account_id); + assert!( + claude_desktop_sessions_roots() + .iter() + .all(|root| !root.exists()), + "sandbox must not contain provider-owned Claude Desktop metadata" + ); + + let receipt = materialize_cli(session_id, &[message()]) + .expect("Claude CLI materialization must not depend on Desktop"); + let paths = claude_native_paths( + Some(account_id), + Path::new("/repo"), + &receipt.native_session_id, + ); + assert!(paths.native_path.is_file()); + let project_index = + claude_native_paths(None, Path::new("/repo"), &receipt.native_session_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + assert!( + project_index.is_file(), + "CLI project index is the success boundary" + ); + assert!( + claude_desktop_sessions_roots() + .iter() + .all(|root| !root.exists()), + "CLI materialization must not synthesize a Desktop catalog" + ); + } + + #[test] + fn claude_desktop_sidecar_registers_the_same_cli_session() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-desktop-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let sessions_root = temp_dir.join("claude-code-sessions"); + let project_dir = sessions_root.join("organization").join("project"); + let cwd = temp_dir.join("repo"); + fs::create_dir_all(&cwd).expect("create repo"); + atomic_json( + &project_dir.join("local-existing.json"), + &json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": cwd, + "lastActivityAt": 1 + }), + ) + .expect("seed Claude Desktop project"); + + let native_id = "00000000-0000-4000-8000-000000000099"; + let sidecar = publish_claude_desktop_session_at( + &sessions_root, + &cwd, + native_id, + Some("claude-sonnet-4-6"), + None, + &[message(), assistant_message()], + ClaudeDesktopPublicationState { + materialized_by_orgii: true, + completed_turns: None, + }, + ) + .expect("publish Claude Desktop session") + .expect("matching Claude Desktop project"); + let metadata: Value = serde_json::from_str( + &fs::read_to_string(&sidecar).expect("read Claude Desktop sidecar"), + ) + .expect("decode Claude Desktop sidecar"); + assert_eq!(metadata["sessionId"], format!("local_{native_id}")); + assert_eq!(metadata["cliSessionId"], native_id); + assert_eq!(metadata["title"], "hello"); + assert_eq!(metadata["completedTurns"], 1); + assert_eq!(metadata["orgiiMaterialization"], true); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn native_agent_row_ids_are_stable_per_target_session() { + let first = native_agent_row_id("target-a", "source-a", Some("turn-a")); + assert_eq!( + first, + native_agent_row_id("target-a", "source-a", Some("turn-a")) + ); + assert_ne!( + first, + native_agent_row_id("target-b", "source-a", Some("turn-a")), + "the same source may be materialized into multiple target Sessions" + ); + assert!(first.starts_with("org2-turn-v1.dHVybi1h.c291cmNlLWE.")); + } + + fn catalog_refresh_context( + session_id: &str, + native_id: &str, + provider: NativeCatalogProvider, + ) -> CliNativePublicationContext { + CliNativePublicationContext { + session_id: session_id.to_string(), + name: format!("title-{native_id}"), + model: None, + branch: None, + native_id: native_id.to_string(), + cwd: PathBuf::from(format!("/tmp/{session_id}")), + agent: provider.as_str().to_string(), + paths: NativeTranscriptPaths { + native_path: PathBuf::from(format!("/tmp/{native_id}.jsonl")), + runner_path: PathBuf::from(format!("/tmp/{native_id}.runner.jsonl")), + }, + } + } + + #[test] + fn catalog_refresh_queue_coalesces_native_conversations() { + let mut queue = NativeCatalogRefreshQueue::default(); + let provider = NativeCatalogProvider::Codex; + let lane = queue.lane_mut(provider); + assert!(lane.enqueue( + provider, + catalog_refresh_context("session-a", "native-a", provider), + Some(2) + )); + assert!(!lane.enqueue( + provider, + catalog_refresh_context("session-a", "native-a", provider), + Some(3) + )); + assert!(!lane.enqueue( + provider, + catalog_refresh_context("session-a", "native-b", provider), + None + )); + assert_eq!( + lane.pending.len(), + 2, + "an account switch must retain both immutable native bindings" + ); + + let first = lane.take_next().expect("first pending session"); + let second = lane.take_next().expect("second pending session"); + assert!(matches!( + ( + first.context.native_id.as_str(), + second.context.native_id.as_str() + ), + ("native-a", "native-b") | ("native-b", "native-a") + )); + let native_a = if first.context.native_id == "native-a" { + first + } else { + second + }; + assert_eq!( + native_a.completed_turns_hint, + Some(3), + "coalescing keeps the newest floor" + ); + assert!(lane.worker_running); + assert!(lane.take_next().is_none()); + assert!(!lane.worker_running); + } + + #[test] + fn catalog_refresh_provider_lanes_advance_independently() { + let mut queue = NativeCatalogRefreshQueue::default(); + let claude = NativeCatalogProvider::ClaudeCode; + let codex = NativeCatalogProvider::Codex; + assert!(queue.lane_mut(claude).enqueue( + claude, + catalog_refresh_context("claude-session", "claude-native", claude), + None + )); + assert!( + queue.lane_mut(codex).enqueue( + codex, + catalog_refresh_context("codex-session", "codex-native", codex), + None + ), + "a running Claude worker must not suppress the Codex worker" + ); + + assert_eq!( + queue + .lane_mut(claude) + .take_next() + .as_ref() + .map(|request| request.context.session_id.as_str()), + Some("claude-session") + ); + assert!(queue.lane_mut(codex).worker_running); + assert_eq!( + queue + .lane_mut(codex) + .take_next() + .as_ref() + .map(|request| request.context.session_id.as_str()), + Some("codex-session") + ); + } + + #[test] + fn catalog_refresh_lane_never_silently_evicts_pending_native_bindings() { + let mut lane = NativeCatalogRefreshLane::default(); + let provider = NativeCatalogProvider::Codex; + for index in 0..300 { + let spawn = lane.enqueue( + provider, + catalog_refresh_context( + &format!("session-{index:03}"), + &format!("native-{index:03}"), + provider, + ), + None, + ); + assert_eq!(spawn, index == 0); + } + assert_eq!(lane.pending.len(), 300); + } + + #[test] + fn claude_desktop_sidecar_refuses_an_empty_catalog() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-desktop-empty-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let sessions_root = temp_dir.join("Claude").join("claude-code-sessions"); + let cwd = temp_dir.join("new-repo"); + fs::create_dir_all(&cwd).expect("create repo"); + + let native_id = "00000000-0000-4000-8000-000000000097"; + let sidecar = publish_claude_desktop_session_at( + &sessions_root, + &cwd, + native_id, + Some("claude-opus-5"), + None, + &[message(), assistant_message()], + ClaudeDesktopPublicationState { + materialized_by_orgii: true, + completed_turns: None, + }, + ) + .expect("inspect empty Claude Desktop catalog"); + + assert!(sidecar.is_none()); + assert!(!sessions_root.exists()); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn claude_desktop_sidecar_groups_a_linked_worktree_with_its_repository() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-desktop-worktree-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let sessions_root = temp_dir.join("claude-code-sessions"); + let project_dir = sessions_root.join("organization").join("project"); + let repository = temp_dir.join("repository"); + let worktree = temp_dir.join("worktree"); + let worktree_git_dir = repository.join(".git/worktrees/pr939"); + fs::create_dir_all(&worktree_git_dir).expect("create worktree git dir"); + fs::create_dir_all(&worktree).expect("create linked worktree"); + fs::write(worktree_git_dir.join("commondir"), "../..\n").expect("write common dir pointer"); + fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .expect("write worktree git pointer"); + atomic_json( + &project_dir.join("local-existing.json"), + &json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": repository, + "lastActivityAt": 1 + }), + ) + .expect("seed Claude Desktop project"); + + let native_id = "00000000-0000-4000-8000-000000000098"; + let sidecar = publish_claude_desktop_session_at( + &sessions_root, + &worktree, + native_id, + Some("claude-opus-5"), + None, + &[message(), assistant_message()], + ClaudeDesktopPublicationState { + materialized_by_orgii: true, + completed_turns: None, + }, + ) + .expect("publish linked-worktree Claude Desktop session") + .expect("matching Claude Desktop repository project"); + + assert_eq!(sidecar.parent(), Some(project_dir.as_path())); + let metadata: Value = + serde_json::from_str(&fs::read_to_string(sidecar).expect("read worktree sidecar")) + .expect("decode worktree sidecar"); + assert_eq!(metadata["cliSessionId"], native_id); + assert_eq!(metadata["cwd"], worktree.to_string_lossy().as_ref()); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[test] + fn claude_desktop_refresh_reuses_provider_sidecar_by_cli_session_id() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-desktop-refresh-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let sessions_root = temp_dir.join("claude-code-sessions"); + let project_dir = sessions_root.join("organization").join("project"); + let cwd = temp_dir.join("repo"); + let native_id = "00000000-0000-4000-8000-000000000099"; + let provider_path = project_dir.join("local-provider-owned.json"); + let unrelated_project_dir = sessions_root + .join("newer-organization") + .join("newer-project"); + fs::create_dir_all(&cwd).expect("create repo"); + atomic_json( + &provider_path, + &json!({ + "sessionId": "local-provider-owned", + "cliSessionId": native_id, + "title": "Provider title", + "cwd": cwd, + "lastActivityAt": 1, + "completedTurns": 0 + }), + ) + .expect("seed provider-owned Claude Desktop session"); + atomic_json( + &unrelated_project_dir.join("local-unrelated.json"), + &json!({ + "sessionId": "local-unrelated", + "cliSessionId": "different-native-session", + "title": "Unrelated newer project", + "cwd": cwd, + "lastActivityAt": 999, + "completedTurns": 10 + }), + ) + .expect("seed newer unrelated Claude Desktop project"); + + let sidecar = publish_claude_desktop_session_at( + &sessions_root, + &cwd, + native_id, + Some("claude-opus-5"), + None, + &[message(), assistant_message()], + ClaudeDesktopPublicationState { + materialized_by_orgii: false, + completed_turns: Some(1), + }, + ) + .expect("refresh Claude Desktop session") + .expect("matching Claude Desktop project"); + + assert_eq!(sidecar, provider_path); + assert!(!project_dir.join(format!("local_{native_id}.json")).exists()); + assert!(!unrelated_project_dir + .join(format!("local_{native_id}.json")) + .exists()); + let metadata: Value = + serde_json::from_str(&fs::read_to_string(&sidecar).expect("read refreshed sidecar")) + .expect("decode refreshed sidecar"); + assert_eq!(metadata["sessionId"], "local-provider-owned"); + assert_eq!(metadata["cliSessionId"], native_id); + assert_eq!(metadata["title"], "Provider title"); + assert_eq!(metadata["completedTurns"], 1); + assert!(metadata.get("orgiiMaterialization").is_none()); + + publish_claude_desktop_session_at( + &sessions_root, + &cwd, + native_id, + Some("claude-opus-5"), + None, + &[], + ClaudeDesktopPublicationState { + materialized_by_orgii: false, + completed_turns: None, + }, + ) + .expect("metadata-only refresh") + .expect("existing provider sidecar"); + let metadata: Value = serde_json::from_str( + &fs::read_to_string(&sidecar).expect("read metadata-only refresh"), + ) + .expect("decode metadata-only refresh"); + assert_eq!( + metadata["completedTurns"], 1, + "unknown refreshes must not reset provider progress" + ); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } + + #[cfg(unix)] + #[test] + fn provider_cwd_uses_the_identity_seen_by_the_native_cli() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-canonical-cwd-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let real = temp_dir.join("real"); + let alias = temp_dir.join("alias"); + fs::create_dir_all(&real).expect("create canonical cwd"); + std::os::unix::fs::symlink(&real, &alias).expect("create cwd alias"); + + assert_eq!( + provider_canonical_cwd(alias), + fs::canonicalize(&real).expect("canonicalize fixture") + ); + + fs::remove_dir_all(temp_dir).expect("remove canonical cwd fixture"); + } + + #[test] + fn unsupported_historical_image_fails_closed() { + let mut item = message(); + if let NativeConversationItem::Message { images, .. } = &mut item { + images.push("/tmp/image.png".to_string()); + } + assert!(validate_items(&[item]).is_err()); + } + + #[test] + fn unsupported_assistant_image_fails_closed() { + let mut item = assistant_message(); + if let NativeConversationItem::Message { images, .. } = &mut item { + images.push("data:image/png;base64,AAAA".to_string()); + } + assert!(validate_items(&[item]).is_err()); + } + + #[test] + fn portable_tool_call_ids_accept_64_characters_and_reject_65() { + let tool_call = |call_id: String| NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id, + name: "read_file".to_string(), + arguments: r#"{"path":"/repo/README.md"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }; + let tool_result = |call_id: String| NativeConversationItem::ToolResult { + id: "tool-1:result".to_string(), + call_id, + name: "read_file".to_string(), + output: "contents".to_string(), + created_at: "2026-08-26T00:00:02Z".to_string(), + }; + + assert!(validate_items(&[tool_call("x".repeat(64)), tool_result("x".repeat(64)),]).is_ok()); + assert!(validate_items(&[tool_call("x".repeat(65))]).is_err()); + assert!(validate_items(&[tool_result("x".repeat(65))]).is_err()); + assert!(validate_items(&[tool_call("call:part-0".to_string())]).is_err()); + assert!(validate_items(&[tool_result("call:part-0".to_string())]).is_err()); + } +} 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..339589aa8e 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): @@ -72,6 +73,12 @@ use crate::agent_sessions::cli::session_runner::launch_profiles::CliPermissionMo /// 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 +131,23 @@ 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 +156,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 +170,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 +180,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 +221,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 +302,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 +321,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 +346,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 +413,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 +478,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 +491,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 +505,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 +560,7 @@ impl CodexAppServerEventParser { } vec![] } + "thread/compacted" => self.native_compaction_marker(), "turn/completed" => { let status = params .get("turn") @@ -496,6 +663,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 +795,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![] @@ -833,6 +1010,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 +1225,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 +1263,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 +1328,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/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/session_runner/command.rs b/src-tauri/src/agent_sessions/cli/session_runner/command.rs index 798d9e969b..0c78905a3f 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::claude_code::ClaudeCodeParser; use crate::agent_sessions::cli::parsers::codex::CodexParser; use crate::agent_sessions::cli::parsers::cursor::CursorParser; 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 => { @@ -219,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 @@ -245,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 } 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..efed17c7dd 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 @@ -9,6 +9,9 @@ use std::collections::HashMap; use std::path::Path; +use agent_cli::session_provenance::{ + materialize_hooks_for_isolated_profile, SessionProvenanceHookPlatform, +}; use key_vault::key_store::{ModelKey, ModelType}; use super::super::persistence::CodeSession; @@ -19,7 +22,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] = &[ @@ -43,6 +46,35 @@ const OPENCODE_ZENMUX_MODEL_IDS: &[&str] = &[ "z-ai/glm-4.6", ]; +fn materialize_isolated_profile_hooks( + platform: SessionProvenanceHookPlatform, + config_or_plugin_path: &Path, +) { + if let Err(err) = materialize_hooks_for_isolated_profile(platform, config_or_plugin_path) { + // Provenance is observational. A read-only/malformed provider config + // must stay visible in logs without turning an otherwise valid model + // launch into an outage. + tracing::warn!( + platform = ?platform, + path = %config_or_plugin_path.display(), + error = %err, + "[CodeSession] Failed to materialize isolated-profile hooks" + ); + } +} + +pub(super) fn cursor_isolated_hooks_path(profile_root: &Path) -> std::path::PathBuf { + profile_root.join("hooks.json") +} + +pub(super) fn claude_isolated_hooks_path(profile_root: &Path) -> std::path::PathBuf { + profile_root.join("settings.json") +} + +pub(super) fn codex_isolated_hooks_path(codex_home: &Path) -> std::path::PathBuf { + codex_home.join("hooks.json") +} + pub(super) fn opencode_zenmux_model_id( session_model: Option<&str>, selected_key: &ModelKey, @@ -248,7 +280,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; } @@ -413,6 +445,10 @@ pub(super) fn setup_codex_hosted_profile( let codex_home = app_paths::codex_hosted_cli_profile_dir(session_id); agent_cli::managed_config::write_codex_hosted_profile(&codex_home, proxy_url) .map_err(|err| format!("Failed to setup hosted Codex profile: {err}"))?; + materialize_isolated_profile_hooks( + SessionProvenanceHookPlatform::Codex, + &codex_isolated_hooks_path(&codex_home), + ); env_vars.insert( "CODEX_HOME".to_string(), codex_home.to_string_lossy().to_string(), @@ -473,6 +509,10 @@ pub(super) fn configure_agent_profile( tracing::warn!("[CodeSession] Failed to write cursor config: {}", err); } } + materialize_isolated_profile_hooks( + SessionProvenanceHookPlatform::Cursor, + &cursor_isolated_hooks_path(&orgii_dir), + ); } } } @@ -494,6 +534,10 @@ pub(super) fn configure_agent_profile( let config_path = orgii_dir.to_string_lossy().to_string(); tracing::info!("[CodeSession] CLAUDE_CONFIG_DIR={}", config_path); env_vars.insert("CLAUDE_CONFIG_DIR".to_string(), config_path); + materialize_isolated_profile_hooks( + SessionProvenanceHookPlatform::ClaudeCode, + &claude_isolated_hooks_path(&orgii_dir), + ); } } } @@ -523,6 +567,10 @@ pub(super) fn configure_agent_profile( } else if selected_key.model_type.is_api_key_provider() { clear_codex_compatible_profile(&codex_home)?; } + materialize_isolated_profile_hooks( + SessionProvenanceHookPlatform::Codex, + &codex_isolated_hooks_path(&codex_home), + ); } if matches!(agent, ModelType::Codex) && session.key_source == KeySource::HostedKey { 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 fcd8d170ed..1b7e718ae1 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}; @@ -16,7 +17,7 @@ use key_vault::key_store::{ModelType, KEY_SERVICE}; use super::super::parsers::{canonicalize_cli_error_message, is_codex_fallback_metadata_notice}; use super::super::persistence::{self, CodeSession}; -use super::super::types::SessionStatus; +use super::super::types::{KeySource, SessionStatus}; use super::cursor_usage::fetch_cursor_usage_for_session; use super::helpers::{clear_live_status, flush_and_broadcast}; use super::oauth_setup::is_cli_oauth_failure_message; @@ -240,7 +241,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 @@ -261,28 +262,94 @@ 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 }; + // Provider-native publication is part of the durable turn boundary, not a + // best-effort metadata side effect. Serialize it with follow-ups and finish + // it before any terminal lifecycle, WorkItem receipt, member-availability, + // or terminal broadcast can advertise a result that the native App cannot + // resume. The runner transcript remains in place when publication fails so + // a later recovery can retry the copy. + let publishes_native_conversation = session.key_source == KeySource::OwnKey + && matches!(agent, ModelType::Codex | ModelType::ClaudeCode); + let native_control_lock = if publishes_native_conversation { + Some(super::helpers::session_control_lock(session_id).await) + } else { + None + }; + let native_control_guard = match native_control_lock.as_ref() { + Some(lock) => Some(lock.lock().await), + None => None, + }; + + // Flush pending assistant/tool deltas into the authoritative CLI store + // before materializing that store into the provider-native transcript. + flush_and_broadcast(session_id).await; + let native_publication_error = if publishes_native_conversation { + match super::super::native_materializer::publish_cli_native_transcript_after_turn( + session_id, + ) + .await + { + Ok(true) => None, + Ok(false) if raw_final_status == SessionStatus::Completed => Some( + "Provider-native transcript publication failed: a completed turn has no native transcript" + .to_string(), + ), + Ok(false) => None, + Err(err) => Some(format!( + "Provider-native transcript publication failed: {err}" + )), + } + } else { + None + }; + if let Some(publication_error) = native_publication_error.as_ref() { + tracing::error!( + session_id, + error = %publication_error, + "failed to publish provider-native conversation at terminal boundary" + ); + raw_final_status = SessionStatus::Failed; + final_status = SessionStatus::Failed; + error_message = Some(match error_message.take() { + Some(existing) => format!("{existing}\n{publication_error}"), + None => publication_error.clone(), + }); + } + + if raw_final_status == SessionStatus::Failed { + super::input_assembly::forget_session_context(session_id); + } + super::harness_hooks::finish_turn( session_id, agent, @@ -426,9 +493,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, @@ -445,6 +509,7 @@ pub(super) async fn finalize_session_run( status_msg["turn_intent_id"] = serde_json::Value::String(turn_intent_id.to_string()); } websocket_handler::broadcast(status_msg.to_string()); + drop(native_control_guard); // ── Worktree: commit changes on completion ── if raw_final_status == SessionStatus::Completed { 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 a8f3cdba9c..a9bff012aa 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. 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 855ff7dc49..855ff97968 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] @@ -323,8 +411,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, @@ -339,13 +428,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() ); @@ -359,8 +450,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, @@ -376,11 +468,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] @@ -388,8 +486,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, @@ -405,8 +504,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/launch_profiles.rs b/src-tauri/src/agent_sessions/cli/session_runner/launch_profiles.rs index dc6ca69175..c1fcba1446 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/launch_profiles.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/launch_profiles.rs @@ -44,9 +44,8 @@ pub struct CliLaunchProfileOverride { pub command_override: Option, pub args_override: Option>, pub env_override: Option>, - /// Experimental transport selector. Absent (default) = per-turn shell-out. - /// `"app-server"` on the codex profile switches managed sessions to the - /// long-lived `codex app-server` JSON-RPC transport. + /// Codex transport override. Absent uses the native app-server transport; + /// `"exec"` keeps the legacy per-turn shell-out as a recovery hatch. #[serde(default, skip_serializing_if = "Option::is_none")] pub transport: Option, } @@ -71,7 +70,7 @@ pub struct CliLaunchProfileView { pub env_overridden: bool, pub effective_command: Vec, pub required_args: Vec, - /// Experimental transport selector (see [`CliLaunchProfileOverride::transport`]). + /// Codex transport override (see [`CliLaunchProfileOverride::transport`]). #[serde(skip_serializing_if = "Option::is_none")] pub transport: Option, } @@ -82,20 +81,30 @@ pub struct ResolvedCliLaunchProfile { pub command: String, pub args: Vec, pub env: HashMap, - /// Experimental transport selector (see [`CliLaunchProfileOverride::transport`]). + /// Codex transport override (see [`CliLaunchProfileOverride::transport`]). pub transport: Option, } /// Launch-profile `transport` value selecting the `codex app-server` /// JSON-RPC transport instead of the per-turn `codex exec --json` shell-out. pub const CLI_TRANSPORT_APP_SERVER: &str = "app-server"; +pub const CLI_TRANSPORT_EXEC: &str = "exec"; -/// Gate predicate for the experimental codex app-server transport: only the -/// codex agent honors the flag, and only when the launch profile explicitly -/// opts in. Absent flag (the default) keeps the shell-out path. +/// Codex uses its native JSON-RPC transport by default so resume, compaction, +/// approvals, and provider-owned thread identity share one production path. +/// An explicit `exec` override preserves the old shell-out as a recovery hatch; +/// unknown values fail closed to that legacy path. pub fn uses_codex_app_server(agent: &ModelType, profile: &ResolvedCliLaunchProfile) -> bool { - matches!(agent, ModelType::Codex) - && profile.transport.as_deref() == Some(CLI_TRANSPORT_APP_SERVER) + if !matches!(agent, ModelType::Codex) { + return false; + } + if profile.transport.as_deref() == Some(CLI_TRANSPORT_EXEC) { + return false; + } + matches!( + profile.transport.as_deref(), + None | Some(CLI_TRANSPORT_APP_SERVER) + ) } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -106,7 +115,7 @@ pub struct CliLaunchProfileUpdate { pub command_override: Option, pub args_override: Option>, pub env_override: Option>, - /// Experimental transport selector. `None` (the UI never sends it) + /// Codex transport override. `None` (the UI never sends it) /// preserves any stored value so flipping args/mode via the settings UI /// doesn't silently clear the app-server opt-in. #[serde(default)] 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..e4420a0667 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs @@ -1,7 +1,7 @@ //! Session lifecycle management — kill, cancel, cleanup. use super::super::persistence; -use super::super::types::SessionStatus; +use super::super::types::{KeySource, 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(); @@ -104,7 +117,7 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result None, + Ok(false) => None, + Err(err) => { + tracing::error!( + session_id, + error = %err, + "failed to publish interrupted provider-native conversation" + ); + Some(format!( + "Provider-native transcript publication failed after cancellation: {err}" + )) + } + } + } else { + None + }; + let terminal_status = if publication_error.is_some() { + SessionStatus::Failed + } else { + SessionStatus::Cancelled + }; + let terminal_intent_status = if publication_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + session_persistence::turn_intents::TurnIntentStatus::Cancelled + }; let persist_session_id = session_id.to_string(); let persist_turn_intent_id = active_turn_intent_id.clone(); + let persist_error = publication_error.clone(); tokio::task::spawn_blocking(move || { persistence::update_cli_turn_lifecycle( &persist_session_id, - SessionStatus::Cancelled, - None, + terminal_status, + persist_error.as_deref(), persist_turn_intent_id.as_deref().map(|turn_intent_id| { - ( - turn_intent_id, - session_persistence::turn_intents::TurnIntentStatus::Cancelled, - ) + (turn_intent_id, terminal_intent_status) }), ) }) @@ -181,19 +266,11 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result 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() @@ -257,6 +292,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>, @@ -284,6 +352,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))? @@ -427,7 +525,7 @@ 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)?; @@ -451,8 +549,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(), @@ -468,10 +567,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 @@ -512,7 +611,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 @@ -523,13 +622,18 @@ 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 mut cmd_parts = build_command_with_launch_profile(CliCommandBuildRequest { 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, @@ -585,6 +689,13 @@ pub async fn run_session( KEY_SERVICE.get_env_for_agent(&agent, account_id) }; + apply_claude_cross_type_session_model( + &agent, + key_model_type.as_ref(), + session.model.as_deref(), + &mut env_vars, + ); + env_vars.extend(launch_profile_env(&launch_profile)); // Inherited by the CLI child and, transitively, by its hook subprocesses: @@ -606,8 +717,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( @@ -750,9 +862,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()); @@ -819,11 +936,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(), @@ -832,6 +951,7 @@ pub async fn run_session( &mut sequence, codex_app_server_turn_ok, &mut attempt_stderr, + allow_native_context_recovery, ) .await?; exit_code = outcome.exit_code; @@ -845,7 +965,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(), 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 fb141d2a4d..f2d9d5b2d0 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 @@ -277,6 +277,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. @@ -800,6 +839,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 94b2883b52..5b8a6bc531 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 @@ -1,5 +1,6 @@ use super::super::env_setup::{ - atlascloud_model_id, clear_codex_compatible_profile, codex_needs_compatible_profile, + atlascloud_model_id, claude_isolated_hooks_path, clear_codex_compatible_profile, + codex_isolated_hooks_path, codex_needs_compatible_profile, cursor_isolated_hooks_path, opencode_zenmux_model_id, resolve_orgtrack_product_mode, setup_codex_compatible_profile, setup_codex_hosted_profile, setup_opencode_atlascloud_profile, setup_opencode_zenmux_profile, validate_codex_own_key_provider, @@ -61,6 +62,14 @@ fn command_logging_redacts_short_and_unicode_secrets_without_panicking() { assert!(!environment_key_is_sensitive("HTTP_PROXY")); } +#[test] +fn isolated_provider_hook_paths_match_each_config_root_contract() { + let root = Path::new("/isolated/profile"); + assert_eq!(cursor_isolated_hooks_path(root), root.join("hooks.json")); + assert_eq!(claude_isolated_hooks_path(root), root.join("settings.json")); + assert_eq!(codex_isolated_hooks_path(root), root.join("hooks.json")); +} + #[test] fn project_is_always_build_execution_while_ordinary_modes_stay_distinct() { assert_eq!( @@ -705,6 +714,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] { @@ -819,6 +883,52 @@ 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 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_app_server.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs index f0461580e6..abd08880fb 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`). +//! This is the default Codex transport. A launch-profile `transport="exec"` +//! override keeps the legacy per-turn shell-out available as a recovery hatch. 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,10 @@ 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, ) -> 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 +63,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 +95,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) @@ -119,12 +129,28 @@ pub(super) async fn run_codex_app_server_branch( } }) .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 +173,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 +185,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 +232,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/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 41bd4c9152..2e712f31b8 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,9 +2,10 @@ 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, + CliPermissionMode, ResolvedCliLaunchProfile, CLI_TRANSPORT_EXEC, }; use key_vault::key_store::ModelType; use std::path::Path; @@ -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, @@ -75,14 +78,22 @@ 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!( @@ -535,18 +574,21 @@ fn app_server_profile(agent: &ModelType, transport: Option<&str>) -> ResolvedCli } #[test] -fn uses_codex_app_server_requires_codex_and_explicit_flag() { +fn uses_codex_app_server_defaults_codex_to_native_transport() { use super::launch_profiles::uses_codex_app_server; - // Default (no flag) stays on the shell-out path. + // Codex defaults to its native app-server transport. let default_profile = app_server_profile(&ModelType::Codex, None); - assert!(!uses_codex_app_server(&ModelType::Codex, &default_profile)); + assert!(uses_codex_app_server(&ModelType::Codex, &default_profile)); // Explicit opt-in flips the codex profile only. 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. + // Explicit legacy escape hatch and unknown values stay off app-server. + let exec = app_server_profile(&ModelType::Codex, Some("exec")); + assert!(!uses_codex_app_server(&ModelType::Codex, &exec)); + let unknown = app_server_profile(&ModelType::Codex, Some("websocket")); assert!(!uses_codex_app_server(&ModelType::Codex, &unknown)); @@ -558,11 +600,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, @@ -578,14 +621,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_app_server_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], "app-server"); + 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, @@ -608,13 +678,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, @@ -625,5 +696,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/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/api/agent/test/cli.rs b/src-tauri/src/api/agent/test/cli.rs index 246c2a1be3..8d7b926145 100644 --- a/src-tauri/src/api/agent/test/cli.rs +++ b/src-tauri/src/api/agent/test/cli.rs @@ -98,12 +98,12 @@ async fn wait_for_terminal_session_after_update( while std::time::Instant::now() < deadline { match cli_agent_status(session_id.to_string()).await { Ok(Some(session)) - if terminal_status(session.status) + if terminal_status(session.session.status) && previous_updated_at - .map(|updated_at| session.updated_at != updated_at) + .map(|updated_at| session.session.updated_at != updated_at) .unwrap_or(true) => { - return Ok(session); + return Ok(session.session); } Ok(_) => { tokio::time::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)).await; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 48ce7281b6..10c91a5959 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -382,6 +382,10 @@ api::websocket_handler::subscribe_session_events, api::websocket_handler::unsubscribe_session_events, // 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::commit_native_conversation_materialization, +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, @@ -1036,6 +1040,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/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..27ecfcab0d 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 @@ -40,6 +41,7 @@ export const CliStatusSchema = z status: z.string(), updatedAt: z.string(), errorMessage: z.string().nullable().optional(), + contextExhausted: z.boolean(), totalTokens: z.number().optional(), transcriptSource: z.string().optional(), }) diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts new file mode 100644 index 0000000000..3f8e69348e --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts @@ -0,0 +1,520 @@ +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, + materializeNativeConversation, + mergeInterruptedConversationProjection, + nativeConversationItemsArePrefix, + nativeConversationItemsEqual, + projectNativeConversationItems, + 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]: "source-user-1", + conversationTurnId: "turn-1", + }; + const assistant = message("convplane-row-2", "assistant", "done"); + assistant.args = { + [NATIVE_SOURCE_EVENT_ID_ARG]: "source-assistant-1", + }; + + expect(projectNativeConversationItems([user, assistant])).toEqual([ + expect.objectContaining({ + id: "source-user-1", + role: "user", + turnId: "turn-1", + }), + expect.objectContaining({ + id: "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: "", + }), + 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 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: "sent", 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([ + "u1", + "a-partial", + "tool-completed:call", + "tool-completed: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("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: "user-message-turn-message-id", + 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 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("keeps the full canonical transcript and its native compact windows", () => { + const before = projectNativeConversationItems([ + message("u1", "user", "old question"), + tool(), + message("a1", "assistant", "old answer"), + ]); + 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.slice(0, -1)).toEqual(before); + expect(compacted.at(-1)).toMatchObject({ + kind: "compaction", + id: "compact-1", + summary: "provider summary", + }); + expect(nativeConversationItemsArePrefix(compacted, withDelta)).toBe(true); + expect(withDelta.at(-1)).toMatchObject({ + kind: "message", + id: "u2", + role: "user", + }); + }); + + 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", id: "u1" }), + expect.objectContaining({ kind: "message", id: "a1" }), + ]); + }); + + 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("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 }, + }); + expect(mocks.invokeTauri).not.toHaveBeenCalled(); + expect(mocks.loadEvents).not.toHaveBeenCalled(); + }); + + it("synchronizes one complete transcript plus its verified prefix length", 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, + existingEvents: existing, + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 2 }, + }); + expect(mocks.invokeTauri).toHaveBeenCalledWith( + "synchronize_native_conversation", + { + sessionId: "cliagent-target", + completeItems: projectNativeConversationItems(timeline), + prefixItemCount: 1, + } + ); + }); + + 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..3988f35a65 --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts @@ -0,0 +1,576 @@ +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; + createdAt: string; + } + | { + kind: "compaction"; + id: string; + summary: string; + createdAt: string; + }; + +interface NativeMaterializationReceipt { + nativeSessionId: string; + itemCount: number; +} + +/** 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"; + +/** Original event identity carried by synthesized/replayed projections. */ +export const NATIVE_SOURCE_EVENT_ID_ARG = "__orgiiSourceEventId"; + +function nativeSourceEventId(event: SessionEvent): string { + const sourceId = event.args?.[NATIVE_SOURCE_EVENT_ID_ARG]; + return typeof sourceId === "string" && sourceId.length > 0 + ? sourceId + : event.id; +} + +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 ""; +} + +/** + * Provider message schemas have one undifferentiated `user` role and no + * portable participant/name field. Preserve multi-human Team Chat semantics + * inside each native user message (never as one transcript prompt) while the + * canonical SessionEvent keeps the original body and structured sender stamp. + */ +function providerNativeMessageText(event: SessionEvent, text: string): string { + if (event.source !== "user") return text; + const sender = conversationSenderStampOf(event); + if (!sender) return text; + return `${JSON.stringify(sender)}\n${text}`; +} + +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" + ); +} + +/** + * Lossless portable conversation plane. It preserves roles and tool pairing; + * it never renders history into a prompt. Provider-private reasoning and + * system policy are intentionally outside the portable contract. + */ +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).trim(); + // Some providers emit window/checkpoint markers without a transferable + // summary. They remain useful ORG2 timeline annotations, but projecting + // one as a native compact with an empty replacement history destroys the + // target provider's effective context. In that case rebuild the complete + // structured role/tool list and let the target manage its own context. + if (!summary) continue; + items.push({ + kind: "compaction", + 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)) { + items.push({ + kind: "tool_result", + id: `${nativeSourceEventId(event)}:result`, + callId, + name, + output: eventText(event), + createdAt: event.createdAt, + }); + } + continue; + } + if (event.source !== "user" && event.source !== "assistant") continue; + const text = providerNativeMessageText(event, 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 } : {}), + }); + } + return items; +} + +function sourceEventIdOfNativeItem(item: NativeConversationItem): string { + return 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]; + case "compaction": + 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 "compaction": + return `compaction:summary=${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])) + ) + ); +} + +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 = projectNativeConversationItems(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 }, + }; + } + const receipt = await invokeTauri( + "materialize_native_conversation", + { sessionId: params.sessionId, items } + ); + 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)})` + ); + } + if (isCliSession(params.sessionId) && receipt.nativeSessionId) { + await invokeTauri("commit_native_conversation_materialization", { + sessionId: params.sessionId, + nativeSessionId: receipt.nativeSessionId, + }); + } + 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, so a branch or + * rewrite rolls to a new episode instead of mutating unrelated history. + */ +export async function synchronizeNativeConversation(params: { + sessionId: string; + timeline: readonly SessionEvent[]; + existingEvents: readonly SessionEvent[]; +}): Promise<{ events: SessionEvent[]; receipt: NativeMaterializationReceipt }> { + const complete = projectNativeConversationItems(params.timeline); + const existing = projectNativeConversationItems(params.existingEvents); + if (!nativeConversationItemsArePrefix(existing, complete)) { + throw new Error( + "native transcript is not a semantic prefix of the canonical conversation" + ); + } + if (existing.length === complete.length) { + return { + events: [...params.existingEvents], + receipt: { + nativeSessionId: params.sessionId, + itemCount: complete.length, + }, + }; + } + const receipt = await invokeTauri( + "synchronize_native_conversation", + { + sessionId: params.sessionId, + completeItems: complete, + prefixItemCount: existing.length, + } + ); + if (receipt.itemCount !== complete.length) { + throw new Error( + `native synchronizer wrote ${receipt.itemCount} of ${complete.length} items` + ); + } + const { events } = await loadAuthoritativeSessionEvents(params.sessionId); + if ( + !nativeConversationItemsEqual( + complete, + projectNativeConversationItems(events) + ) + ) { + throw new Error( + `native transcript synchronization round-trip verification failed (${nativeConversationMismatch(complete, projectNativeConversationItems(events))})` + ); + } + if (isCliSession(params.sessionId) && receipt.nativeSessionId) { + await invokeTauri("commit_native_conversation_materialization", { + sessionId: params.sessionId, + nativeSessionId: receipt.nativeSessionId, + }); + } + return { events, receipt }; +} diff --git a/src/hooks/session/useNativeSessionStatusMonitor.ts b/src/hooks/session/useNativeSessionStatusMonitor.ts index 3a94283c29..4bddfbca61 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,6 +106,7 @@ 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)) @@ -117,6 +119,21 @@ export function useNativeSessionStatusMonitor(options?: { markTurnRunning(sessionId); } + // 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 = completedTurn && !isSuccessfulNotificationTurnStatus(session?.status ?? ""); @@ -147,10 +164,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)); } ); From 2311b6dc5f456df5ee88dcbec62b905cca9e9f79 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:32:45 +0800 Subject: [PATCH 4/5] feat(native-apps): publish resumable sessions to provider catalogs Synchronize Codex catalog metadata, expose native transcript roots to isolated instances, document the continuation architecture, and add cross-runtime, dual-instance, queued-control, and native-App acceptance coverage. --- .../managed-cloud-collaboration.md | 46 +- ...ersation-events-plane-design-2026-08-21.md | 166 ++-- scripts/tauri/open-instance.cjs | 13 + .../cli/codex_native_catalog.rs | 851 ++++++++++++++++++ .../sessionHelpers/inspectChatState.ts | 3 - src/app/root/e2e/helpers/sessions.ts | 2 - src/app/root/e2e/types.ts | 1 - .../e2e/specs/core/chat-rendering-ui.spec.mjs | 91 ++ .../core/cloud-dual-instance-ui.spec.mjs | 168 ++++ .../core/session-account-switch.spec.mjs | 202 +++++ tests/e2e/support/core/cloudOrgUiDriver.mjs | 2 +- .../core/session/accountSwitchDriver.mjs | 35 +- .../session/agentQueuedControlScenarios.mjs | 40 +- .../session/agentQueuedFollowupDriver.mjs | 1 - 14 files changed, 1479 insertions(+), 142 deletions(-) create mode 100644 src-tauri/src/agent_sessions/cli/codex_native_catalog.rs diff --git a/docs/architecture/managed-cloud-collaboration.md b/docs/architecture/managed-cloud-collaboration.md index debb88e269..1aa4de2b43 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,22 @@ 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 stores the multi-writer conversation events but has +no provider key, execution host, task lease, or single-run claim. The ordinary +durable queue owns local ordering and restart recovery; Cloud append +idempotency owns duplicate suppression across retries. 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..6476d4e631 100644 --- a/docs/conversation-events-plane-design-2026-08-21.md +++ b/docs/conversation-events-plane-design-2026-08-21.md @@ -1,102 +1,82 @@ -# 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. It is not a Cloud lease and does not prevent + different devices from appending independent turns to a Team Session. +- 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 is deliberately multi-writer. It does not introduce a global + single-provider-turn lease across devices. + +## 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/open-instance.cjs b/scripts/tauri/open-instance.cjs index acfc544ba4..ddfab06dd8 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,14 @@ const appPath = path.resolve( ); const dataHome = path.resolve(optionValue("--data-home") ?? profile.dataHome); const externalHistoryHome = path.join(dataHome, "external-history-home"); +// Keep discovery isolated between ORG2 identities, but publish newly-created +// provider-native conversations to the profile read by the real Codex/Claude +// apps. Tests remain isolated because their launchers do not set this override. +const nativeTranscriptHome = path.resolve( + optionValue("--native-transcript-home") ?? + process.env.ORGII_NATIVE_TRANSCRIPT_HOME ?? + os.homedir() +); if (!fs.existsSync(appPath)) { console.error(`Instance app not found: ${appPath}`); @@ -34,10 +43,12 @@ if (!fs.existsSync(appPath)) { } fs.mkdirSync(dataHome, { recursive: true }); fs.mkdirSync(externalHistoryHome, { recursive: true }); +fs.mkdirSync(nativeTranscriptHome, { recursive: true }); const instanceEnv = { ORGII_HOME: dataHome, ORGII_EXTERNAL_HISTORY_HOME: externalHistoryHome, + 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 +67,7 @@ if (process.platform === "win32") { `[instance ${profile.id}] started ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); process.exit(0); @@ -73,5 +85,6 @@ console.log( `[instance ${profile.id}] opened ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); diff --git a/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs b/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs new file mode 100644 index 0000000000..cdc0c379d7 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs @@ -0,0 +1,851 @@ +//! 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}; +#[cfg(test)] +use std::cell::Cell; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use key_vault::key_store::ModelType; +use serde_json::{json, Value}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CATALOG_LOOKUP_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_CATALOG_PAGES: usize = 50; + +#[cfg(test)] +thread_local! { + static DIRECT_TEST_CATALOG: Cell = const { Cell::new(false) }; +} + +/// Hermetic catalog adapter for materializer tests whose subject is durable +/// JSONL synchronization rather than the external Codex executable. Real +/// app-server protocol coverage remains in this module's dedicated tests. +#[cfg(test)] +pub(super) struct DirectTestCatalogGuard { + previous: bool, +} + +#[cfg(test)] +impl Drop for DirectTestCatalogGuard { + fn drop(&mut self) { + DIRECT_TEST_CATALOG.set(self.previous); + } +} + +#[cfg(test)] +pub(super) fn use_direct_test_catalog() -> DirectTestCatalogGuard { + let previous = DIRECT_TEST_CATALOG.replace(true); + DirectTestCatalogGuard { previous } +} + +#[cfg(test)] +fn direct_test_catalog_enabled() -> bool { + DIRECT_TEST_CATALOG.get() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CodexCatalogEntry { + pub id: String, + pub path: PathBuf, + pub title: String, + pub cwd: PathBuf, + pub model_provider: String, +} + +struct CodexAppServerClient { + child: Child, + stdin: Option, + lines: Receiver>, + reader: Option>, + next_id: u64, +} + +impl CodexAppServerClient { + fn launch(cwd: &Path) -> Result { + let codex_home = app_paths::native_transcript_home_dir().join(".codex"); + // Codex deliberately refuses to start when an explicit CODEX_HOME does + // not already exist. A brand-new ORG2/native profile therefore has to + // create the supported profile root before the app-server can register + // its first thread. Existing user profiles are left untouched. + std::fs::create_dir_all(&codex_home).map_err(|error| { + format!( + "create Codex native profile {}: {error}", + codex_home.display() + ) + })?; + let launch_profile = + super::launch_profile_store::resolve_cli_launch_profile(&ModelType::Codex)?; + let command = launch_profile.command; + let mut child = Command::new(&command) + .arg("app-server") + .envs(launch_profile.env) + // The native catalog always belongs to the real Codex App + // profile, even when ORGII's runner uses an isolated account home. + .env("CODEX_HOME", &codex_home) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // The protocol is stdout-only. Discarding stderr also prevents a + // verbose provider install from filling a pipe while this + // blocking helper waits for a JSON-RPC response. + .stderr(Stdio::null()) + .spawn() + .map_err(|error| { + format!( + "start Codex app-server {} for native catalog {}: {error}", + command, + 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 (sender, lines) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender + .send(line.map_err(|error| error.to_string())) + .is_err() + { + break; + } + } + }); + let mut client = Self { + child, + stdin: Some(stdin), + lines, + reader: Some(reader), + next_id: 0, + }; + client.request( + "initialize", + json!({ + "clientInfo": { + "name": "orgii", + "title": "ORGII", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": {"experimentalApi": true} + }), + )?; + client.notify("initialized", json!({}))?; + Ok(client) + } + + fn write_message(&mut self, value: &Value) -> Result<(), String> { + let stdin = self + .stdin + .as_mut() + .ok_or_else(|| "Codex app-server stdin is closed".to_string())?; + serde_json::to_writer(&mut *stdin, value) + .map_err(|error| format!("encode Codex app-server request: {error}"))?; + stdin + .write_all(b"\n") + .and_then(|_| stdin.flush()) + .map_err(|error| format!("write Codex app-server request: {error}")) + } + + fn notify(&mut self, method: &str, params: Value) -> Result<(), String> { + self.write_message(&json!({"method": method, "params": params})) + } + + fn request(&mut self, method: &str, params: Value) -> Result { + self.request_until(method, params, Instant::now() + REQUEST_TIMEOUT) + } + + fn request_until( + &mut self, + method: &str, + params: Value, + deadline: Instant, + ) -> Result { + self.next_id += 1; + let id = self.next_id; + self.write_message(&json!({"id": id, "method": method, "params": params}))?; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!( + "Codex app-server {method} reached its request deadline" + )); + } + let line = self + .lines + .recv_timeout(remaining) + .map_err(|error| format!("Codex app-server {method} ended: {error}"))??; + let response: Value = serde_json::from_str(&line) + .map_err(|error| format!("decode Codex app-server response: {error}"))?; + if response["id"].as_u64() != Some(id) { + 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")); + } + } +} + +impl Drop for CodexAppServerClient { + fn drop(&mut self) { + self.stdin.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +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( + client: &mut CodexAppServerClient, + cwd: &Path, +) -> Result { + let result = client.request("config/read", json!({"cwd": cwd, "includeLayers": false}))?; + Ok(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("openai") + .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 find_catalog_entry( + client: &mut CodexAppServerClient, + expected_id: &str, +) -> Result, String> { + let mut cursor: Option = None; + let deadline = Instant::now() + CATALOG_LOOKUP_TIMEOUT; + for _ in 0..MAX_CATALOG_PAGES { + let result = client.request_until( + "thread/list", + json!({ + "cursor": cursor, + "limit": 100, + "sortDirection": "desc", + "modelProviders": [], + "archived": false, + "useStateDbOnly": false + }), + deadline, + )?; + let rows = result["data"] + .as_array() + .ok_or_else(|| "Codex app-server thread/list returned no data array".to_string())?; + for row in rows { + let entry = entry_from_thread(row)?; + if entry.id == expected_id { + return Ok(Some(entry)); + } + } + cursor = result["nextCursor"].as_str().map(str::to_string); + if cursor.is_none() { + return Ok(None); + } + } + Err(format!( + "Codex app-server thread/list exceeded {MAX_CATALOG_PAGES} pages within {}s", + CATALOG_LOOKUP_TIMEOUT.as_secs() + )) +} + +fn read_thread( + client: &mut CodexAppServerClient, + thread_id: &str, +) -> Result { + let result = client.request( + "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( + client: &mut CodexAppServerClient, + thread_id: &str, + title: &str, +) -> Result<(), String> { + client.request( + "thread/name/set", + json!({"threadId": thread_id, "name": title}), + )?; + Ok(()) +} + +fn inject_items( + client: &mut CodexAppServerClient, + thread_id: &str, + items: &[Value], +) -> Result<(), String> { + if items.is_empty() { + return Ok(()); + } + client.request( + "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" | "context_compaction" => 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 normalized_injected_item(item: &Value) -> Result { + let mut normalized = item.clone(); + normalized + .as_object_mut() + .ok_or_else(|| "Codex native suffix item is not an object".to_string())? + // `thread/inject_items` accepts this request marker but does not + // persist unknown top-level response-item fields. + .remove("orgii_materialization"); + Ok(normalized) +} + +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(), normalized_injected_item(item)?) + .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) { + let normalized = normalized_injected_item(&record["payload"])?; + if &normalized != 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() + )) + } +} + +#[cfg(test)] +fn append_direct_test_items(path: &Path, items: &[Value]) -> Result<(), String> { + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(path) + .map_err(|error| format!("open direct-test Codex rollout {}: {error}", path.display()))?; + for item in items { + let record = json!({ + "timestamp": "2026-08-26T00:00:00Z", + "type": "response_item", + "payload": normalized_injected_item(item)?, + }); + serde_json::to_writer(&mut file, &record).map_err(|error| { + format!( + "write direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + file.write_all(b"\n").map_err(|error| { + format!( + "write direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + } + file.sync_all().map_err(|error| { + format!( + "sync direct-test Codex rollout {}: {error}", + path.display() + ) + }) +} + +#[cfg(test)] +fn register_direct_test_thread( + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let path = app_paths::native_transcript_home_dir() + .join(".codex") + .join("sessions") + .join("test") + .join(format!("rollout-{id}.jsonl")); + let parent = path + .parent() + .ok_or_else(|| format!("direct-test Codex rollout has no parent: {}", path.display()))?; + std::fs::create_dir_all(parent).map_err(|error| { + format!( + "create direct-test Codex rollout directory {}: {error}", + parent.display() + ) + })?; + let metadata = json!({ + "timestamp": "2026-08-26T00:00:00Z", + "type": "session_meta", + "payload": { + "id": id, + "cwd": cwd, + "originator": "orgii", + "model_provider": "openai", + } + }); + let mut file = std::fs::File::create(&path).map_err(|error| { + format!( + "create direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + serde_json::to_writer(&mut file, &metadata).map_err(|error| { + format!( + "write direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + file.write_all(b"\n").map_err(|error| { + format!( + "write direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + file.sync_all().map_err(|error| { + format!( + "sync direct-test Codex rollout {}: {error}", + path.display() + ) + })?; + append_direct_test_items(&path, items)?; + Ok(CodexCatalogEntry { + id, + path, + title: title.to_string(), + cwd: cwd.to_path_buf(), + model_provider: "openai".to_string(), + }) +} + +pub(super) fn register_thread( + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + #[cfg(test)] + if direct_test_catalog_enabled() { + return register_direct_test_thread(cwd, title, items); + } + let mut client = CodexAppServerClient::launch(cwd)?; + let model_provider = effective_model_provider(&mut client, cwd)?; + let result = client.request( + "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(&mut client, &started_id, title)?; + let registered = read_thread(&mut 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(&mut client, &started_id, items)?; + Ok(registered) + })(); + if registered.is_err() { + let _ = client.request("thread/archive", json!({"threadId": &started_id})); + } + registered +} + +pub(super) fn synchronize_thread( + 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)?; + #[cfg(test)] + if direct_test_catalog_enabled() { + if suffix_application == SuffixApplication::Missing { + append_direct_test_items(path, items)?; + } + return Ok(CodexCatalogEntry { + id: expected_id.to_string(), + path: path.to_path_buf(), + title: title.to_string(), + cwd: cwd.to_path_buf(), + model_provider: "openai".to_string(), + }); + } + let mut client = CodexAppServerClient::launch(cwd)?; + let model_provider = effective_model_provider(&mut client, cwd)?; + let result = client.request( + "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(&mut client, expected_id, title)?; + let synchronized = read_thread(&mut 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(&mut client, expected_id, items)?; + } + Ok(synchronized) +} + +pub(super) fn refresh_catalog( + path: &Path, + expected_id: &str, + cwd: &Path, + title: &str, +) -> Result { + let mut client = CodexAppServerClient::launch(cwd)?; + let model_provider = effective_model_provider(&mut client, cwd)?; + let result = client.request( + "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 catalog refresh resumed {0} instead of {expected_id}", + resumed.id + )); + } + set_thread_name(&mut client, expected_id, title)?; + let listed = find_catalog_entry(&mut client, expected_id)?.ok_or_else(|| { + format!( + "Codex App catalog does not list native thread {expected_id}; the rollout is not user-openable" + ) + })?; + validate_target_profile(listed, expected_id, cwd, title, &model_provider) +} + +pub(super) fn archive_thread(path: &Path, expected_id: &str, cwd: &Path) -> Result<(), String> { + let mut client = CodexAppServerClient::launch(cwd)?; + let model_provider = effective_model_provider(&mut client, cwd)?; + let result = client.request( + "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 + )); + } + client.request("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 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/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts index 76665f28e9..a9246cf68a 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 2fed1c21fc..7ed291bfef 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -67,7 +67,6 @@ import { 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/tests/e2e/specs/core/chat-rendering-ui.spec.mjs b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs index e931818c1c..821736f86c 100644 --- a/tests/e2e/specs/core/chat-rendering-ui.spec.mjs +++ b/tests/e2e/specs/core/chat-rendering-ui.spec.mjs @@ -3294,6 +3294,97 @@ 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.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..c11fa28715 100644 --- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs @@ -67,6 +67,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() @@ -2454,6 +2455,173 @@ 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 @"); + await clickRendered( + `[data-testid="agent-org-mention-option"][data-mention-id="${teammate.userId}"]`, + "primary choose teammate mention pill" + ); + 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; + await applyCloudEndpointOverride(UNREACHABLE_CLOUD_ENDPOINT); + try { + await clickRendered( + '[data-testid="chat-send-button"]', + "primary send offline Team Chat mention" + ); + await waitForRendered( + '[data-testid="chat-message-delivery-failed"]', + "failed Team Chat delivery row", + CLOUD_FETCH_TIMEOUT_MS + ); + failedState = await execJS(` + const editor = Array.from(document.querySelectorAll(${JSON.stringify(editorSelector)})) + .filter((element) => element.getClientRects().length > 0) + .at(-1); + return { + observed: window.__e2eTeamChatDeliveryStates ?? [], + composerText: editor?.textContent ?? '', + failedText: document.querySelector('[data-testid="chat-message-delivery-failed"]') + ?.closest('[data-chat-group-index]')?.textContent ?? document.body.textContent ?? '', + retryPresent: Boolean(document.querySelector('[data-testid="chat-message-delivery-retry"]')), + }; + `); + } finally { + await applyCloudEndpointOverride(env); + } + 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 clickRendered( + '[data-testid="chat-message-delivery-retry"]', + "retry failed Team Chat mention" + ); + await waitForGone( + '[data-testid="chat-message-delivery-failed"]', + "failed Team Chat status after retry", + CLOUD_FETCH_TIMEOUT_MS + ); + await browser.waitUntil( + async () => + execJS(` + const transcript = document.querySelector('[data-testid="chat-message-list"]'); + return Boolean( + transcript?.textContent?.includes(${JSON.stringify(TEAM_CHAT_MENTION_BODY)}) && + !transcript.querySelector('[data-testid="chat-message-delivery-pending"]') && + !transcript.querySelector('[data-testid="chat-message-delivery-failed"]') + ); + `), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: "retried Team Chat message never became sent", + } + ); + 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, + ` + return Array.from(document.querySelectorAll('[data-testid="team-inbox-row"]')) + .some((row) => (row.textContent ?? '').includes(arguments[0])); + `, + [TEAM_CHAT_MENTION_BODY] + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: + "Team Chat @mention never reached the teammate's rendered Inbox", + } + ); + }); + 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/session-account-switch.spec.mjs b/tests/e2e/specs/core/session-account-switch.spec.mjs index b27e54031a..6bd27de733 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,199 @@ 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) + ); +} + +async function openLargeSession(sessionId, label) { + unwrap(await invokeE2E("openSession", sessionId), `${label} open`); + const state = unwrap( + await invokeE2E("inspectChatState"), + `${label} inspect` + ); + const events = state.rawEvents ?? state.chatEvents ?? []; + const minimum = Number.parseInt( + process.env.E2E_NATIVE_LARGE_MIN_EVENTS ?? "40", + 10 + ); + if (events.length < minimum) { + throw new Error(`${label} is not large: ${events.length} < ${minimum}`); + } + const anchor = (predicate, kind) => { + const event = events.find(predicate); + const text = String(event?.displayText ?? event?.result?.output ?? "") + .trim() + .slice(0, 80); + if (!text) throw new Error(`${label} has no ${kind} anchor`); + return text; + }; + return [ + anchor((event) => event.source === "user", "user"), + anchor( + (event) => + event.source === "assistant" && event.displayVariant === "message", + "assistant" + ), + anchor( + (event) => + Boolean(event.callId) && + event.displayStatus !== "running" && + event.displayStatus !== "pending", + "completed tool/result" + ), + ]; +} + +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 = JSON.stringify(state.rawEvents ?? state.chatEvents ?? []); + for (const text of expected) { + if (!transcript.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 }, + }; + + for (const scenario of [ + { + 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 anchors = await openLargeSession(scenario.source, scenario.label); + 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..3532a1059d 100644 --- a/tests/e2e/support/core/cloudOrgUiDriver.mjs +++ b/tests/e2e/support/core/cloudOrgUiDriver.mjs @@ -795,7 +795,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/session/accountSwitchDriver.mjs b/tests/e2e/support/core/session/accountSwitchDriver.mjs index da03e3b4cf..ce4e605444 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); @@ -919,7 +919,7 @@ async function assertCliPersistedAccount(sessionId, expectedAccountId, label) { ); } -async function switchAccountThroughRenderedPicker( +export async function switchAccountThroughRenderedPicker( followupAccount, model, label @@ -1027,6 +1027,35 @@ async function switchAccountThroughRenderedPicker( ); } +/** 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}` } + ); +} + async function runRenderedAccountSwitchImpl({ label, initialAccount, 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, From dbfe046aed2f3a92cff9aeffe5f9c4ee44347e41 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:09:39 +0800 Subject: [PATCH 5/5] refactor(conversations): unify native continuation and delivery owners --- .../src/core/session/persistence/messages.rs | 437 +- .../src/core/session/persistence/mod.rs | 13 +- .../sources/codex/app/transcript/messages.rs | 17 +- .../sources/codex/app/transcript/parser.rs | 118 +- .../src/sources/codex/app/transcript/tests.rs | 74 +- .../codex/app/transcript/tool_calls/mod.rs | 27 +- .../transcript/tool_calls/normalization.rs | 47 +- .../src/sources/codex/app_tests.rs | 4 +- .../cli/codex_native_catalog.rs | 851 --- .../cli/commands/resume_delete.rs | 20 +- .../src/agent_sessions/cli/commands/run.rs | 54 +- .../agent_sessions/cli/commands/transcript.rs | 17 +- src-tauri/src/agent_sessions/cli/mod.rs | 1 - .../agent_sessions/cli/native_materializer.rs | 5343 ++++------------- .../cli/parsers/codex_app_server.rs | 123 +- .../cli/parsers/codex_app_server/catalog.rs | 520 ++ .../cli/session_runner/finalize.rs | 62 +- .../cli/session_runner/lifecycle.rs | 66 +- src-tauri/src/commands/handler_list.inc | 1 - .../ConversationSenderMetadataContext.tsx | 31 +- .../ChatPanel/ChatItems/UserChatItem.tsx | 104 +- .../ChatItems/__tests__/UserChatItem.test.ts | 28 +- .../useUserMessageDeliveryActions.ts | 111 + .../ChatPanel/ConversationStreamProvider.tsx | 98 +- .../ConversationRuntimePill.test.tsx | 110 - .../components/ConversationRuntimePill.tsx | 111 - .../InputArea/components/ModelPill.tsx | 65 +- .../components/QueuedMessageItem.tsx | 5 +- .../InputArea/components/QueuedMessages.tsx | 3 + .../conversationTargetSelection.test.ts | 60 +- .../ChatPanel/conversationTargetSelection.ts | 233 +- .../useConversationSubmitRouter.ts | 9 + .../hooks/useConversationTargetBinding.ts | 29 +- .../useWorkspaceChat/useMessageDispatch.ts | 1 - .../useWorkspaceChat/useUserIntentSubmit.ts | 45 +- .../control/messageQueueAdmission.ts | 41 - .../canonicalConversationExecution.test.ts | 335 ++ .../canonicalConversationExecution.ts | 559 ++ .../localConversationContinuation.test.ts | 155 +- .../localConversationContinuation.ts | 179 +- .../nativeConversationMaterializer.test.ts | 28 +- .../nativeConversationMaterializer.ts | 64 +- .../queuedConversationExecutor.ts | 98 +- .../derived/__tests__/chatEvents.test.ts | 78 - .../queueDispatchSyncInputsAtom.test.ts | 13 +- .../sessionScopedChatEvents.stability.test.ts | 42 - src/engines/SessionCore/derived/chatEvents.ts | 41 +- .../derived/queueDispatchSyncInputsAtom.ts | 18 +- .../derived/sessionScopedChatEvents.ts | 19 +- .../__tests__/messageQueuePersistence.test.ts | 53 +- .../useQueueDispatch.intervention.test.ts | 356 +- .../hooks/session/messageQueuePersistence.ts | 109 +- .../hooks/session/useQueueDispatch.ts | 827 ++- .../services/userIntentDispatch.test.ts | 20 +- .../services/userIntentDispatch.ts | 40 - .../enqueueCanonicalConversation.ts | 41 +- ...ueuedConversationExecutor.recovery.test.ts | 108 + .../queuedConversationExecutor.test.ts | 3 +- .../queuedConversationExecutor.ts | 171 +- ...Org2ConversationSenderMetadataProvider.tsx | 11 +- .../activeConversationRunnersAtom.test.ts | 184 - .../activeConversationRunnersAtom.ts | 136 - .../conversationPlaneAtom.ts | 173 +- .../conversationRunnerOverlay.ts | 39 + .../conversationTailOutbox.ts | 279 - .../conversationTurnRunner.test.ts | 125 +- .../conversationTurnRunner.ts | 165 +- .../queuedConversationExecutor.ts | 358 +- .../useCloudConversationSource.ts | 12 +- .../Org2Cloud/org2CloudCommentsClient.ts | 24 +- .../org2CloudConversationEventsClient.ts | 43 +- .../Org2Cloud/sessionCommentTarget.ts | 7 +- .../ChatPanel/SessionCreatorChatPanelView.tsx | 50 +- .../DispatchCategoryPicker.tsx | 36 + .../ui/__tests__/messageQueueAtom.test.ts | 19 + src/store/ui/conversationTargetAtom.ts | 27 + src/store/ui/messageQueueAtom.ts | 88 +- src/store/ui/messageQueueRepository.test.ts | 81 + src/store/ui/messageQueueRepository.ts | 158 +- 79 files changed, 5846 insertions(+), 8405 deletions(-) delete mode 100644 src-tauri/src/agent_sessions/cli/codex_native_catalog.rs create mode 100644 src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs create mode 100644 src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts delete mode 100644 src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx delete mode 100644 src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx delete mode 100644 src/engines/SessionCore/control/messageQueueAdmission.ts create mode 100644 src/engines/SessionCore/conversations/canonicalConversationExecution.test.ts create mode 100644 src/engines/SessionCore/conversations/canonicalConversationExecution.ts create mode 100644 src/features/ConversationContinuation/queuedConversationExecutor.recovery.test.ts 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/conversationRunnerOverlay.ts delete mode 100644 src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts create mode 100644 src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryPicker.tsx create mode 100644 src/store/ui/conversationTargetAtom.ts create mode 100644 src/store/ui/messageQueueRepository.test.ts 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 8b9beee275..e2d60423ad 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 @@ -25,6 +25,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 @@ -444,7 +486,6 @@ fn compacted_history_rows( let mut rows = Vec::new(); for msg in compacted_messages { - let first_row = rows.len(); let role = msg .get("role") .and_then(|value| value.as_str()) @@ -453,19 +494,12 @@ fn compacted_history_rows( "system" => { let content = text_content_from_llm_message(msg); if !content.trim().is_empty() { - let mut row = - message_row(session_id, shared::message_role::SYSTEM, content, None); - if msg - .get("__orgiiNativeCompactBoundary") - .and_then(|value| value.as_bool()) - == Some(true) - { - // Sentinel resolved to the first row after this - // boundary by the seed/append transaction, where the - // final durable sequence is known. - row.compact_from_sequence = Some(-1); - } - rows.push(row); + rows.push(message_row( + session_id, + shared::message_role::SYSTEM, + content, + None, + )); } } "user" => { @@ -548,27 +582,75 @@ fn compacted_history_rows( } _ => {} } - for row in &mut rows[first_row..] { - if let Some(id) = msg - .get("__orgiiNativeMessageId") - .and_then(|value| value.as_str()) - .filter(|value| !value.is_empty()) - { - row.id = id.to_string(); - } - if let Some(created_at) = msg - .get("__orgiiNativeCreatedAt") - .and_then(|value| value.as_str()) - .filter(|value| !value.is_empty()) - { - row.created_at = created_at.to_string(); - } - } } 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, + } => { + 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, + } => { + 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() +} + fn message_row( session_id: &str, role: &str, @@ -810,6 +892,37 @@ pub fn append_session_with_messages( persist_history_rows(session_id, &rows, false) } +/// 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)?; + 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)?; + Ok(MaterializedHistoryReceipt { + row_count: rows.len(), + }) +} + /// Append a compact-boundary row to a session's transcript. /// /// The boundary row is a `system` message whose `compact_from_sequence` @@ -1104,6 +1217,23 @@ 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 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); @@ -1328,14 +1458,14 @@ mod tests { let _sandbox = test_env::sandbox(); let session_id = "seed-native-identity-test"; seed_session_for_message_tests(session_id); - seed_session_with_messages( + seed_session_with_materialized_history( session_id, - &[serde_json::json!({ - "role": "user", - "content": "continue", - "__orgiiNativeMessageId": "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce", - "__orgiiNativeCreatedAt": "2026-08-29T00:00:00Z", - })], + &[materialized_message( + "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce", + "2026-08-29T00:00:00Z", + MaterializedHistoryRole::User, + "continue", + )], ) .expect("seed native identity"); @@ -1350,15 +1480,17 @@ mod tests { let _sandbox = test_env::sandbox(); let session_id = "seed-native-idempotent-retry-test"; seed_session_for_message_tests(session_id); - let transcript = [serde_json::json!({ - "role": "user", - "content": "continue", - "__orgiiNativeMessageId": "org2-native-v1.c291cmNlLTE.target", - "__orgiiNativeCreatedAt": "2026-08-29T00:00:00Z", - })]; - - seed_session_with_messages(session_id, &transcript).expect("seed native transcript"); - seed_session_with_messages(session_id, &transcript).expect("retry exact native seed"); + 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); @@ -1367,69 +1499,48 @@ mod tests { } #[test] - fn native_materialization_keeps_full_rows_but_resumes_from_latest_compact_boundary() { - let _sandbox = test_env::sandbox(); - let session_id = "seed-native-compact-window-test"; - seed_session_for_message_tests(session_id); - seed_session_with_messages( - session_id, - &[ - serde_json::json!({"role": "user", "content": "old user"}), - serde_json::json!({"role": "assistant", "content": "old answer"}), - serde_json::json!({ - "role": "system", - "content": "[Conversation summary — earlier messages compacted]\n\nsummary", - "__orgiiNativeCompactBoundary": true, - }), - serde_json::json!({"role": "user", "content": "recent user"}), - ], - ) - .expect("seed native compact window"); - - let rows = load_messages(session_id).expect("load immutable native rows"); - assert_eq!(rows.len(), 4, "full transcript remains durable"); - assert_eq!(rows[2].compact_from_sequence, Some(3)); - - let history = load_llm_history(session_id).expect("load native compact window"); - assert_eq!(history.len(), 2); - assert_eq!(history[0]["role"], "user"); - assert_eq!( - history[0]["content"], - "[Conversation summary — earlier messages compacted]\n\nsummary" - ); - assert_eq!(history[1]["content"], "recent user"); - } - - #[test] - fn append_session_with_messages_preserves_native_role_and_tool_order() { + 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_messages( + seed_session_with_materialized_history( session_id, - &[serde_json::json!({"role": "user", "content": "first"})], + &[materialized_message( + "user-1", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + )], ) .expect("seed prefix"); - append_session_with_messages( + append_session_with_materialized_history( session_id, &[ - serde_json::json!({"role": "assistant", "content": "answer"}), - serde_json::json!({ - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call-1", - "type": "function", - "function": {"name": "read_file", "arguments": "{\"path\":\"README.md\"}"} - }] - }), - serde_json::json!({ - "role": "tool", - "tool_call_id": "call-1", - "name": "read_file", - "content": "contents" - }), + 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"); @@ -1448,19 +1559,21 @@ mod tests { } #[test] - fn append_session_with_messages_accepts_a_fully_applied_native_suffix_once() { + 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 = [serde_json::json!({ - "role": "assistant", - "content": "answer", - "__orgiiNativeMessageId": "org2-native-v1.c291cmNlLTE.target", - "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", - })]; - - append_session_with_messages(session_id, &suffix).expect("append native suffix"); - append_session_with_messages(session_id, &suffix).expect("retry committed suffix"); + 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); @@ -1469,82 +1582,60 @@ mod tests { } #[test] - fn append_session_with_messages_rejects_mixed_or_conflicting_native_suffixes() { + 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 = serde_json::json!({ - "role": "user", - "content": "first", - "__orgiiNativeMessageId": "org2-native-v1.Zmlyc3Q.target", - "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", - }); - append_session_with_messages(session_id, std::slice::from_ref(&first)) + 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(), - serde_json::json!({ - "role": "assistant", - "content": "second", - "__orgiiNativeMessageId": "org2-native-v1.c2Vjb25k.target", - "__orgiiNativeCreatedAt": "2026-08-30T00:00:01Z", - }), + materialized_message( + "org2-native-v1.c2Vjb25k.target", + "2026-08-30T00:00:01Z", + MaterializedHistoryRole::Assistant, + "second", + ), ]; - assert!(append_session_with_messages(session_id, &mixed).is_err()); - - let conflict = [serde_json::json!({ - "role": "user", - "content": "different", - "__orgiiNativeMessageId": "org2-native-v1.Zmlyc3Q.target", - "__orgiiNativeCreatedAt": "2026-08-30T00:00:00Z", - })]; - assert!(append_session_with_messages(session_id, &conflict).is_err()); + 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 append_native_materialization_advances_to_compact_window_without_deleting_prefix() { - let _sandbox = test_env::sandbox(); - let session_id = "append-native-compact-window-test"; - seed_session_for_message_tests(session_id); - seed_session_with_messages( - session_id, - &[ - serde_json::json!({"role": "user", "content": "old user"}), - serde_json::json!({"role": "assistant", "content": "old answer"}), - ], - ) - .expect("seed native prefix"); - - append_session_with_messages( - session_id, - &[ - serde_json::json!({ - "role": "system", - "content": "[Conversation summary — earlier messages compacted]\n\nsummary", - "__orgiiNativeCompactBoundary": true, - }), - serde_json::json!({"role": "user", "content": "recent user"}), - ], - ) - .expect("append native compact window"); - - let rows = load_messages(session_id).expect("load immutable native rows"); - assert_eq!(rows.len(), 4, "appending a compact window keeps the prefix"); - assert_eq!(rows[2].compact_from_sequence, Some(3)); - - let history = load_llm_history(session_id).expect("load appended compact window"); - assert_eq!(history.len(), 2); - assert_eq!( - history[0]["content"], - "[Conversation summary — earlier messages compacted]\n\nsummary" - ); - assert_eq!(history[1]["content"], "recent user"); - } - #[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 8b7124e428..97cf91b6fa 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,20 @@ pub use sidebar::{ }; pub use messages::{ - anchor_at_or_after_created_at, append_compact_boundary, append_session_with_messages, - clear_messages, clear_session_memory_state, compact_cutoff_sequence, + anchor_at_or_after_created_at, append_compact_boundary, + append_session_with_materialized_history, append_session_with_messages, 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/orgtrack-core/src/sources/codex/app/transcript/messages.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs index a93a8f2d81..10c863563a 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 @@ -141,21 +141,20 @@ pub(super) fn user_image_data_urls_from_response_message(payload: &Value) -> Vec /// User rows injected through Codex app-server's supported /// `thread/inject_items` API have no later `event_msg/UserMessage` mirror. -/// ORGII stamps their public passthrough turn id while materializing so they -/// can be projected as real user turns without mistaking Codex's user-role -/// system/context prefix messages for human input. -pub(super) fn materialized_user_message_chunk_from_response_message( +/// 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 materialized = payload - .get("internal_chat_message_metadata_passthrough") - .and_then(|metadata| metadata.get("turn_id")) + let has_native_item_id = payload + .get("id") .and_then(Value::as_str) - .is_some_and(|turn_id| turn_id.starts_with("orgii-materialization-")); - if !materialized + .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") { 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 1677357462..f71dc3b6ed 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,14 +13,14 @@ use super::super::CodexJsonlLine; use super::cache::CodexTurnOffset; use super::collector::{CodexTranscriptCollectionMode, CodexTranscriptCollector}; use super::messages::{ - content_text_from_payload, materialized_user_message_chunk_from_response_message, + 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, - codex_task_error_message, codex_tool_call_chunk, is_orgii_materialized_tool_call, - lifecycle_turn_id, output_parts_for_tool_calls, pending_custom_tool_calls_from_payload, + codex_task_error_message, codex_tool_call_chunk, lifecycle_turn_id, + output_parts_for_tool_calls, pending_custom_tool_calls_from_payload, pending_tool_calls_from_payload, resolve_codex_tool_outputs, wait_cell_id, web_search_call_from_payload, PendingBackgroundToolCall, }; @@ -68,11 +68,6 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( // 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(); - // ORGII materializes a portable compaction summary as a supported - // assistant response item immediately followed by Codex's supported - // `context_compaction` response item. Keep the summary out of the normal - // assistant transcript and fold the pair back into one compact boundary. - let mut pending_materialized_compaction: Option<(String, String)> = None; let mut line = String::new(); let mut next_byte_offset = start_offset; @@ -177,30 +172,17 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } if payload_type == "context_compaction" { - let marker = parsed - .payload - .get("internal_chat_message_metadata_passthrough") - .and_then(|metadata| metadata.get("turn_id")) - .and_then(Value::as_str) - .filter(|turn_id| turn_id.starts_with("orgii-materialized-compaction:")); - let summary = marker.and_then(|marker| { - pending_materialized_compaction - .take() - .filter(|(pending_marker, _)| pending_marker == marker) - .map(|(_, summary)| summary) - }); let marker_id = parsed .payload .get("id") .and_then(Value::as_str) - .or(marker) .unwrap_or("context-compaction"); collector.current.push(codex_context_compacted_chunk( session_id, sequence, marker_id, &created_at, - summary.as_deref(), + None, )); sequence += 1; continue; @@ -285,7 +267,7 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( "message" => { let role = parsed.payload.get("role").and_then(Value::as_str); if role == Some("user") { - if let Some(user_chunk) = materialized_user_message_chunk_from_response_message( + if let Some(user_chunk) = injected_user_message_chunk_from_response_message( session_id, sequence, &created_at, @@ -307,16 +289,6 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } } else if role == Some("assistant") { if let Some(text) = content_text_from_payload(&parsed.payload) { - if let Some(marker) = parsed - .payload - .get("internal_chat_message_metadata_passthrough") - .and_then(|metadata| metadata.get("turn_id")) - .and_then(Value::as_str) - .filter(|turn_id| turn_id.starts_with("orgii-materialized-compaction:")) - { - pending_materialized_compaction = Some((marker.to_string(), text)); - continue; - } collector .current .push(imported_history::assistant_message_chunk( @@ -373,53 +345,49 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( if let Some((file_order, calls)) = pending_tool_calls.take(call_id) { let output_value = parsed.payload.get("output"); let output = codex_tool_output_text(output_value); - let is_orgii_materialized = - calls.iter().all(is_orgii_materialized_tool_call); - if !is_orgii_materialized { - if let Some(cell_id) = wait_cell_id(&calls) { - let cell_key = background_cell_key(cell_id); - if let Some((background_order, mut background)) = - background_tool_calls.take(&cell_key) - { - if let Some(next_cell_id) = background_cell_id(&output) { - background.latest_output = output; - background_tool_calls.reinsert( - background_cell_key(&next_cell_id), - background_order, - background, - ); + if let Some(cell_id) = wait_cell_id(&calls) { + let cell_key = background_cell_key(cell_id); + if let Some((background_order, mut background)) = + background_tool_calls.take(&cell_key) + { + if let Some(next_cell_id) = background_cell_id(&output) { + background.latest_output = output; + background_tool_calls.reinsert( + background_cell_key(&next_cell_id), + background_order, + background, + ); + } else { + let final_output = if output.trim().is_empty() { + background.latest_output } else { - let final_output = if output.trim().is_empty() { - background.latest_output - } else { - output - }; - resolve_codex_tool_outputs( - session_id, - background.calls, - background_order, - output_value, - &final_output, - &mut collector.current, - &mut sequence, - &mut background_tool_calls, - ); - } - continue; + output + }; + resolve_codex_tool_outputs( + session_id, + background.calls, + background_order, + output_value, + &final_output, + &mut collector.current, + &mut sequence, + &mut background_tool_calls, + ); } - } - if let Some(cell_id) = background_cell_id(&output) { - background_tool_calls.reinsert( - background_cell_key(&cell_id), - file_order, - PendingBackgroundToolCall { - calls, - latest_output: output, - }, - ); continue; } } + if let Some(cell_id) = background_cell_id(&output) { + background_tool_calls.reinsert( + background_cell_key(&cell_id), + file_order, + PendingBackgroundToolCall { + calls, + latest_output: output, + }, + ); + continue; + } resolve_codex_tool_outputs( session_id, calls, 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 4e4b95cfb7..ee4d878e03 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 @@ -49,9 +49,9 @@ fn preserves_app_server_injected_user_rows_without_ui_mirrors() { )); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("rollout-injected-user.jsonl"); - let content = r#"{"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"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialization-user-1"}}} + let content = r#"{"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"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialization-user-2"}}}"#; +{"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) @@ -76,41 +76,6 @@ fn preserves_app_server_injected_user_rows_without_ui_mirrors() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } -#[test] -fn preserves_app_server_injected_canonical_tool_arguments_without_renormalizing() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-codex-injected-tool-test-{}", - std::process::id() - )); - std::fs::create_dir_all(&temp_dir).expect("create temp dir"); - let path = temp_dir.join("rollout-injected-tool.jsonl"); - let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"function_call","name":"grep","arguments":"{\"action\":\"grep\",\"command\":\"rg needle .\",\"cwd\":\"/repo\",\"pattern\":\"needle\",\"payload\":{\"cmd\":\"rg needle .\"},\"__orgiiMaterializedNative\":true}","call_id":"call-1","internal_chat_message_metadata_passthrough":{"turn_id":"auto-compact-0"}}} -{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"match"}}"#; - std::fs::write(&path, format!("{content}\n")).expect("write fixture"); - - let chunks = load_codex_app_from_path("codexapp-injected-tool", &path) - .expect("parse app-server injected transcript"); - let tool = chunks - .iter() - .find(|chunk| chunk.action_type == "tool_call") - .expect("tool call"); - assert_eq!(tool.result["call_id"], "call-1"); - assert_eq!( - tool.args, - serde_json::json!({ - "action": "grep", - "command": "rg needle .", - "cwd": "/repo", - "pattern": "needle", - "payload": {"cmd": "rg needle ."} - }) - ); - assert_eq!(tool.result["output"], "match"); - - 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!( @@ -151,7 +116,7 @@ fn native_compaction_is_one_system_marker_not_replacement_user_history() { 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","orgii_materialization":true}} +{"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"}} @@ -200,39 +165,6 @@ fn native_compaction_is_one_system_marker_not_replacement_user_history() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } -#[test] -fn materialized_context_compaction_pair_round_trips_as_one_canonical_boundary() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-codex-materialized-compact-test-{}", - std::process::id() - )); - std::fs::create_dir_all(&temp_dir).expect("create temp dir"); - let path = temp_dir.join("rollout-materialized-compact.jsonl"); - let content = r#"{"timestamp":"2026-08-31T00:00:00Z","type":"response_item","payload":{"type":"message","id":"compact-1-summary","role":"assistant","content":[{"type":"output_text","text":"Portable compact summary"}],"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialized-compaction:compact-1"}}} -{"timestamp":"2026-08-31T00:00:00Z","type":"response_item","payload":{"type":"context_compaction","id":"compact-1","encrypted_content":null,"internal_chat_message_metadata_passthrough":{"turn_id":"orgii-materialized-compaction:compact-1"}}} -"#; - std::fs::write(&path, content).expect("write fixture"); - - let chunks = load_codex_app_from_path("codexapp-materialized-compact", &path) - .expect("parse materialized compact transcript"); - 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("Portable compact summary") - ); - assert!(!chunks.iter().any(|chunk| { - chunk.function == "assistant" - && chunk.result["content"].as_str() == Some("Portable compact summary") - })); - - 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!( diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs index e74a62f522..5ed7175ebf 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/mod.rs @@ -11,11 +11,8 @@ mod exec_results; mod normalization; use exec_results::{append_incremental_output, codex_exec_results, CodexExecResult}; -use normalization::original_raw_tool_name; pub(crate) use normalization::pending_custom_tool_calls_from_payload; -pub(super) use normalization::{ - is_orgii_materialized_tool_call, pending_tool_calls_from_payload, web_search_call_from_payload, -}; +pub(super) use normalization::{pending_tool_calls_from_payload, web_search_call_from_payload}; pub(super) struct PendingBackgroundToolCall { pub(super) calls: Vec, @@ -109,22 +106,6 @@ pub(super) fn resolve_codex_tool_outputs( sequence: &mut usize, background_tool_calls: &mut imported_history::PendingCallMap, ) { - // ORGII materializes canonical tool calls into Codex records solely so the - // native runtime can resume them. Their outputs are application data, not - // Codex Desktop exec envelopes. Parsing an arbitrary JSON result that - // happens to contain `session_id` as a background-shell receipt drops the - // call from the reconstructed transcript, so preserve these records as-is. - if calls.iter().all(is_orgii_materialized_tool_call) { - emit_codex_call_group( - transcript_session_id, - calls, - fallback_output, - None, - chunks, - sequence, - ); - return; - } let mut results = codex_exec_results(output_value); if results.len() == calls.len() { for (call, result) in calls.into_iter().zip(results.drain(..)) { @@ -350,12 +331,6 @@ pub(super) fn codex_tool_call_chunk( ) -> ActivityChunk { let mut chunk = imported_history::tool_call_chunk(session_id, CODEX_PROVIDER_SLUG, sequence, call, output); - if let Some(result) = chunk.result.as_object_mut() { - result.insert( - "raw_tool_name".to_string(), - Value::String(original_raw_tool_name(&call.raw_name).to_string()), - ); - } if call.canonical_name == imported_history::FUNCTION_CODE_SEARCH { if let Some(result) = chunk.result.as_object_mut() { result.insert("content".to_string(), Value::String(output.to_string())); 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 6e50641eaf..515e128c98 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 @@ -7,58 +7,31 @@ use super::super::super::normalize::{ normalize_codex_tool_calls, normalize_tool_name_key, normalize_web_search_args, }; -const ORGII_MATERIALIZED_RAW_NAME_PREFIX: &str = "orgii_materialized_native::"; -const ORGII_MATERIALIZED_ARGUMENT_KEY: &str = "__orgiiMaterializedNative"; -const ORGII_CANONICAL_ARGUMENT_KEY: &str = "__orgiiCanonicalArguments"; - -pub(in crate::sources::codex::app::transcript) fn is_orgii_materialized_tool_call( - call: &ImportedToolCall, -) -> bool { - call.raw_name - .starts_with(ORGII_MATERIALIZED_RAW_NAME_PREFIX) -} - -pub(super) fn original_raw_tool_name(raw_name: &str) -> &str { - raw_name - .strip_prefix(ORGII_MATERIALIZED_RAW_NAME_PREFIX) - .unwrap_or(raw_name) -} - pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payload( payload: &Value, created_at: &str, ) -> 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 mut arguments = payload + let arguments = payload .get("arguments") .and_then(Value::as_str) .map(imported_history::parse_inner_json) .unwrap_or_else(|| json!({})); - let materialized_arguments = arguments - .as_object_mut() - .and_then(|object| object.remove(ORGII_MATERIALIZED_ARGUMENT_KEY)) - .and_then(|value| value.as_bool()) - == Some(true); - if materialized_arguments { - if let Some(canonical) = arguments - .as_object_mut() - .and_then(|object| object.remove(ORGII_CANONICAL_ARGUMENT_KEY)) - { - arguments = canonical; - } - } - if materialized_arguments - || payload - .get("orgii_materialization") - .and_then(Value::as_bool) - == Some(true) + // `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 payload + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()) { return Some(( call_id.clone(), vec![ImportedToolCall { call_id, - raw_name: format!("{ORGII_MATERIALIZED_RAW_NAME_PREFIX}{raw_name}"), + raw_name: raw_name.clone(), canonical_name: raw_name, args: arguments, created_at: created_at.to_string(), 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 7b652e4d7b..967a3b6b1d 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 @@ -1638,7 +1638,7 @@ fn codex_desktop_exec_unwraps_web_search_query() { } #[test] -fn codex_materialized_canonical_tool_args_are_not_normalized_twice() { +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() @@ -1655,10 +1655,10 @@ fn codex_materialized_canonical_tool_args_are_not_normalized_twice() { }); let payload = json!({ "type": "function_call", + "id": "tool-item-1", "name": "web_search", "arguments": canonical_args.to_string(), "call_id": "call_materialized_web", - "orgii_materialization": true, }); let output = json!({ "type": "function_call_output", diff --git a/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs b/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs deleted file mode 100644 index cdc0c379d7..0000000000 --- a/src-tauri/src/agent_sessions/cli/codex_native_catalog.rs +++ /dev/null @@ -1,851 +0,0 @@ -//! 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}; -#[cfg(test)] -use std::cell::Cell; -use std::io::{BufRead, BufReader, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{self, Receiver}; -use std::thread::JoinHandle; -use std::time::{Duration, Instant}; - -use key_vault::key_store::ModelType; -use serde_json::{json, Value}; - -const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); -const CATALOG_LOOKUP_TIMEOUT: Duration = Duration::from_secs(30); -const MAX_CATALOG_PAGES: usize = 50; - -#[cfg(test)] -thread_local! { - static DIRECT_TEST_CATALOG: Cell = const { Cell::new(false) }; -} - -/// Hermetic catalog adapter for materializer tests whose subject is durable -/// JSONL synchronization rather than the external Codex executable. Real -/// app-server protocol coverage remains in this module's dedicated tests. -#[cfg(test)] -pub(super) struct DirectTestCatalogGuard { - previous: bool, -} - -#[cfg(test)] -impl Drop for DirectTestCatalogGuard { - fn drop(&mut self) { - DIRECT_TEST_CATALOG.set(self.previous); - } -} - -#[cfg(test)] -pub(super) fn use_direct_test_catalog() -> DirectTestCatalogGuard { - let previous = DIRECT_TEST_CATALOG.replace(true); - DirectTestCatalogGuard { previous } -} - -#[cfg(test)] -fn direct_test_catalog_enabled() -> bool { - DIRECT_TEST_CATALOG.get() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct CodexCatalogEntry { - pub id: String, - pub path: PathBuf, - pub title: String, - pub cwd: PathBuf, - pub model_provider: String, -} - -struct CodexAppServerClient { - child: Child, - stdin: Option, - lines: Receiver>, - reader: Option>, - next_id: u64, -} - -impl CodexAppServerClient { - fn launch(cwd: &Path) -> Result { - let codex_home = app_paths::native_transcript_home_dir().join(".codex"); - // Codex deliberately refuses to start when an explicit CODEX_HOME does - // not already exist. A brand-new ORG2/native profile therefore has to - // create the supported profile root before the app-server can register - // its first thread. Existing user profiles are left untouched. - std::fs::create_dir_all(&codex_home).map_err(|error| { - format!( - "create Codex native profile {}: {error}", - codex_home.display() - ) - })?; - let launch_profile = - super::launch_profile_store::resolve_cli_launch_profile(&ModelType::Codex)?; - let command = launch_profile.command; - let mut child = Command::new(&command) - .arg("app-server") - .envs(launch_profile.env) - // The native catalog always belongs to the real Codex App - // profile, even when ORGII's runner uses an isolated account home. - .env("CODEX_HOME", &codex_home) - .current_dir(cwd) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - // The protocol is stdout-only. Discarding stderr also prevents a - // verbose provider install from filling a pipe while this - // blocking helper waits for a JSON-RPC response. - .stderr(Stdio::null()) - .spawn() - .map_err(|error| { - format!( - "start Codex app-server {} for native catalog {}: {error}", - command, - 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 (sender, lines) = mpsc::channel(); - let reader = std::thread::spawn(move || { - for line in BufReader::new(stdout).lines() { - if sender - .send(line.map_err(|error| error.to_string())) - .is_err() - { - break; - } - } - }); - let mut client = Self { - child, - stdin: Some(stdin), - lines, - reader: Some(reader), - next_id: 0, - }; - client.request( - "initialize", - json!({ - "clientInfo": { - "name": "orgii", - "title": "ORGII", - "version": env!("CARGO_PKG_VERSION") - }, - "capabilities": {"experimentalApi": true} - }), - )?; - client.notify("initialized", json!({}))?; - Ok(client) - } - - fn write_message(&mut self, value: &Value) -> Result<(), String> { - let stdin = self - .stdin - .as_mut() - .ok_or_else(|| "Codex app-server stdin is closed".to_string())?; - serde_json::to_writer(&mut *stdin, value) - .map_err(|error| format!("encode Codex app-server request: {error}"))?; - stdin - .write_all(b"\n") - .and_then(|_| stdin.flush()) - .map_err(|error| format!("write Codex app-server request: {error}")) - } - - fn notify(&mut self, method: &str, params: Value) -> Result<(), String> { - self.write_message(&json!({"method": method, "params": params})) - } - - fn request(&mut self, method: &str, params: Value) -> Result { - self.request_until(method, params, Instant::now() + REQUEST_TIMEOUT) - } - - fn request_until( - &mut self, - method: &str, - params: Value, - deadline: Instant, - ) -> Result { - self.next_id += 1; - let id = self.next_id; - self.write_message(&json!({"id": id, "method": method, "params": params}))?; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(format!( - "Codex app-server {method} reached its request deadline" - )); - } - let line = self - .lines - .recv_timeout(remaining) - .map_err(|error| format!("Codex app-server {method} ended: {error}"))??; - let response: Value = serde_json::from_str(&line) - .map_err(|error| format!("decode Codex app-server response: {error}"))?; - if response["id"].as_u64() != Some(id) { - 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")); - } - } -} - -impl Drop for CodexAppServerClient { - fn drop(&mut self) { - self.stdin.take(); - let _ = self.child.kill(); - let _ = self.child.wait(); - if let Some(reader) = self.reader.take() { - let _ = reader.join(); - } - } -} - -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( - client: &mut CodexAppServerClient, - cwd: &Path, -) -> Result { - let result = client.request("config/read", json!({"cwd": cwd, "includeLayers": false}))?; - Ok(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("openai") - .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 find_catalog_entry( - client: &mut CodexAppServerClient, - expected_id: &str, -) -> Result, String> { - let mut cursor: Option = None; - let deadline = Instant::now() + CATALOG_LOOKUP_TIMEOUT; - for _ in 0..MAX_CATALOG_PAGES { - let result = client.request_until( - "thread/list", - json!({ - "cursor": cursor, - "limit": 100, - "sortDirection": "desc", - "modelProviders": [], - "archived": false, - "useStateDbOnly": false - }), - deadline, - )?; - let rows = result["data"] - .as_array() - .ok_or_else(|| "Codex app-server thread/list returned no data array".to_string())?; - for row in rows { - let entry = entry_from_thread(row)?; - if entry.id == expected_id { - return Ok(Some(entry)); - } - } - cursor = result["nextCursor"].as_str().map(str::to_string); - if cursor.is_none() { - return Ok(None); - } - } - Err(format!( - "Codex app-server thread/list exceeded {MAX_CATALOG_PAGES} pages within {}s", - CATALOG_LOOKUP_TIMEOUT.as_secs() - )) -} - -fn read_thread( - client: &mut CodexAppServerClient, - thread_id: &str, -) -> Result { - let result = client.request( - "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( - client: &mut CodexAppServerClient, - thread_id: &str, - title: &str, -) -> Result<(), String> { - client.request( - "thread/name/set", - json!({"threadId": thread_id, "name": title}), - )?; - Ok(()) -} - -fn inject_items( - client: &mut CodexAppServerClient, - thread_id: &str, - items: &[Value], -) -> Result<(), String> { - if items.is_empty() { - return Ok(()); - } - client.request( - "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" | "context_compaction" => 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 normalized_injected_item(item: &Value) -> Result { - let mut normalized = item.clone(); - normalized - .as_object_mut() - .ok_or_else(|| "Codex native suffix item is not an object".to_string())? - // `thread/inject_items` accepts this request marker but does not - // persist unknown top-level response-item fields. - .remove("orgii_materialization"); - Ok(normalized) -} - -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(), normalized_injected_item(item)?) - .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) { - let normalized = normalized_injected_item(&record["payload"])?; - if &normalized != 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() - )) - } -} - -#[cfg(test)] -fn append_direct_test_items(path: &Path, items: &[Value]) -> Result<(), String> { - let mut file = std::fs::OpenOptions::new() - .append(true) - .open(path) - .map_err(|error| format!("open direct-test Codex rollout {}: {error}", path.display()))?; - for item in items { - let record = json!({ - "timestamp": "2026-08-26T00:00:00Z", - "type": "response_item", - "payload": normalized_injected_item(item)?, - }); - serde_json::to_writer(&mut file, &record).map_err(|error| { - format!( - "write direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - file.write_all(b"\n").map_err(|error| { - format!( - "write direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - } - file.sync_all().map_err(|error| { - format!( - "sync direct-test Codex rollout {}: {error}", - path.display() - ) - }) -} - -#[cfg(test)] -fn register_direct_test_thread( - cwd: &Path, - title: &str, - items: &[Value], -) -> Result { - let id = uuid::Uuid::new_v4().to_string(); - let path = app_paths::native_transcript_home_dir() - .join(".codex") - .join("sessions") - .join("test") - .join(format!("rollout-{id}.jsonl")); - let parent = path - .parent() - .ok_or_else(|| format!("direct-test Codex rollout has no parent: {}", path.display()))?; - std::fs::create_dir_all(parent).map_err(|error| { - format!( - "create direct-test Codex rollout directory {}: {error}", - parent.display() - ) - })?; - let metadata = json!({ - "timestamp": "2026-08-26T00:00:00Z", - "type": "session_meta", - "payload": { - "id": id, - "cwd": cwd, - "originator": "orgii", - "model_provider": "openai", - } - }); - let mut file = std::fs::File::create(&path).map_err(|error| { - format!( - "create direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - serde_json::to_writer(&mut file, &metadata).map_err(|error| { - format!( - "write direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - file.write_all(b"\n").map_err(|error| { - format!( - "write direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - file.sync_all().map_err(|error| { - format!( - "sync direct-test Codex rollout {}: {error}", - path.display() - ) - })?; - append_direct_test_items(&path, items)?; - Ok(CodexCatalogEntry { - id, - path, - title: title.to_string(), - cwd: cwd.to_path_buf(), - model_provider: "openai".to_string(), - }) -} - -pub(super) fn register_thread( - cwd: &Path, - title: &str, - items: &[Value], -) -> Result { - #[cfg(test)] - if direct_test_catalog_enabled() { - return register_direct_test_thread(cwd, title, items); - } - let mut client = CodexAppServerClient::launch(cwd)?; - let model_provider = effective_model_provider(&mut client, cwd)?; - let result = client.request( - "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(&mut client, &started_id, title)?; - let registered = read_thread(&mut 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(&mut client, &started_id, items)?; - Ok(registered) - })(); - if registered.is_err() { - let _ = client.request("thread/archive", json!({"threadId": &started_id})); - } - registered -} - -pub(super) fn synchronize_thread( - 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)?; - #[cfg(test)] - if direct_test_catalog_enabled() { - if suffix_application == SuffixApplication::Missing { - append_direct_test_items(path, items)?; - } - return Ok(CodexCatalogEntry { - id: expected_id.to_string(), - path: path.to_path_buf(), - title: title.to_string(), - cwd: cwd.to_path_buf(), - model_provider: "openai".to_string(), - }); - } - let mut client = CodexAppServerClient::launch(cwd)?; - let model_provider = effective_model_provider(&mut client, cwd)?; - let result = client.request( - "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(&mut client, expected_id, title)?; - let synchronized = read_thread(&mut 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(&mut client, expected_id, items)?; - } - Ok(synchronized) -} - -pub(super) fn refresh_catalog( - path: &Path, - expected_id: &str, - cwd: &Path, - title: &str, -) -> Result { - let mut client = CodexAppServerClient::launch(cwd)?; - let model_provider = effective_model_provider(&mut client, cwd)?; - let result = client.request( - "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 catalog refresh resumed {0} instead of {expected_id}", - resumed.id - )); - } - set_thread_name(&mut client, expected_id, title)?; - let listed = find_catalog_entry(&mut client, expected_id)?.ok_or_else(|| { - format!( - "Codex App catalog does not list native thread {expected_id}; the rollout is not user-openable" - ) - })?; - validate_target_profile(listed, expected_id, cwd, title, &model_provider) -} - -pub(super) fn archive_thread(path: &Path, expected_id: &str, cwd: &Path) -> Result<(), String> { - let mut client = CodexAppServerClient::launch(cwd)?; - let model_provider = effective_model_provider(&mut client, cwd)?; - let result = client.request( - "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 - )); - } - client.request("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 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/commands/resume_delete.rs b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs index 55252e3294..b6c993938e 100644 --- a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs +++ b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs @@ -88,23 +88,11 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { integrations::proxy::server::stop_session_proxy(&session_id).await; // Resume participates in the same provider-identity boundary as a normal - // turn. This keeps catalog work and runtime/account patches away from the - // bound native UUID until terminal publication has completed. + // turn so runtime/account patches cannot retarget the active UUID. let identity_guard = session_runner::session_identity_lock(&session_id) .await .lock_owned() .await; - tokio::task::spawn_blocking({ - let session_id = session_id.clone(); - move || { - super::super::native_materializer::freeze_cli_native_publication_context( - &session_id, - ) - } - }) - .await - .map_err(|err| format!("native publication snapshot task failed: {err}"))??; - // 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. @@ -119,7 +107,6 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { .map_err(|err| format!("Task error: {err}")) .and_then(|result| result); if let Err(error) = accept_result { - super::super::native_materializer::clear_cli_native_publication_context(&session_id); return Err(error); } let mut running_msg = serde_json::json!({ @@ -148,7 +135,6 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { .await { tracing::error!("[CodeSession] Resume of {} failed: {}", sid, e); - super::super::native_materializer::clear_cli_native_publication_context(&sid); // Same fail-loud principle as the create path above: log the // persistence failure so a stuck Running row is traceable. let failed_sid = sid.clone(); @@ -197,9 +183,6 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { if let Some(existing) = sessions.get(&session_id) { if !existing.is_finished() { handle.abort(); - super::super::native_materializer::clear_cli_native_publication_context( - &session_id, - ); return Err(format!( "Session {} already has a running agent. Cancel it first.", session_id @@ -227,7 +210,6 @@ pub async fn cli_agent_delete(session_id: String) -> Result { .await .lock_owned() .await; - super::super::native_materializer::clear_cli_native_publication_context(&session_id); // 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 ef56acbeb5..99a132418a 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -86,9 +86,9 @@ async fn fail_interrupted_turn(session_id: &str, error: &str) -> Result<(), Stri let persist_session_id = session_id.to_string(); let persist_error = error.to_string(); let active_turn_intent_id = tokio::task::spawn_blocking(move || { - let active = session_persistence::turn_intents::latest_for_sessions( - std::slice::from_ref(&persist_session_id), - ) + let active = session_persistence::turn_intents::latest_for_sessions(std::slice::from_ref( + &persist_session_id, + )) .map_err(|err| err.to_string())? .remove(&persist_session_id) .filter(|intent| { @@ -333,7 +333,7 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri // 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 catalog refresh may + // 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; @@ -348,9 +348,8 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri } // Freeze runtime/account/native binding through the complete background - // turn, including final provider-native publication. `session_patch` - // waits on this guard and therefore applies picker changes to the next - // turn instead of retargeting the active runner. + // 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() @@ -369,17 +368,6 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri } } - tokio::task::spawn_blocking({ - let session_id = session_id.clone(); - move || { - super::super::native_materializer::freeze_cli_native_publication_context( - &session_id, - ) - } - }) - .await - .map_err(|err| format!("native publication snapshot task failed: {err}"))??; - let persist_session_id = session_id.clone(); let persist_turn_intent_id = turn_intent_id.clone(); let accept_result = tokio::task::spawn_blocking(move || { @@ -394,7 +382,6 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri .map_err(|err| format!("Task error: {err}")) .and_then(|result| result); if let Err(error) = accept_result { - super::super::native_materializer::clear_cli_native_publication_context(&session_id); return Err(error); } @@ -429,7 +416,6 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri .await { tracing::error!("[CodeSession] Session {} failed: {}", sid, e); - super::super::native_materializer::clear_cli_native_publication_context(&sid); session_runner::forget_session_context(&sid); session_runner::flush_cli_streams_for_session(&sid).await; // Best-effort: if marking the row as Failed itself fails, log @@ -592,8 +578,7 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result Result {} - // The provider was interrupted before it minted a native UUID. - // The canonical user/tool rows remain authoritative and the next - // episode will materialize them into the selected runtime. - Ok(false) => {} - Err(err) => { - let error = format!("Provider-native partial turn publication failed: {err}"); - fail_interrupted_turn(&session_id, &error).await?; - return Err(error); - } - } - } tracing::info!(session_id = %session_id, "cli_agent_message: existing runner cleanup complete"); - // Publish the old account's runner before changing the binding lookup. - // Otherwise a Codex/Claude account switch asks the publisher to resolve - // the old file through the new account profile and either loses the - // interrupted suffix or fails a valid runtime switch. if model.is_some() || account_id.is_some() { let sid = session_id.clone(); let mdl = model.clone(); diff --git a/src-tauri/src/agent_sessions/cli/commands/transcript.rs b/src-tauri/src/agent_sessions/cli/commands/transcript.rs index f16ea40698..608f481e0f 100644 --- a/src-tauri/src/agent_sessions/cli/commands/transcript.rs +++ b/src-tauri/src/agent_sessions/cli/commands/transcript.rs @@ -29,13 +29,11 @@ fn load_native_transcript_chunks(session: &CodeSession) -> Option> = LazyLock::new(|| Mutex::new(())); // 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< - Mutex>, -> = LazyLock::new(|| Mutex::new(HashMap::new())); -// Freeze the provider/account/workspace row that launched each active turn. -// Model/account pills may already show the next queued selection while the -// current provider is still running; terminal publication must resolve the -// runner UUID through this launch snapshot, never through the mutable row. -static ACTIVE_NATIVE_PUBLICATION_SESSIONS: LazyLock< - Mutex>, -> = LazyLock::new(|| Mutex::new(HashMap::new())); -// Catalog publication is deliberately off the turn-critical path. Keep one -// worker per provider and coalesce repeated requests by native conversation so fast -// consecutive turns cannot retain an unbounded list of Tokio tasks behind a -// slow app-server call. Separate lanes keep a blocked Codex app-server from -// delaying Claude metadata (and vice versa). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum NativeCatalogProvider { - ClaudeCode, - Codex, -} - -impl NativeCatalogProvider { - fn from_agent(agent: &str) -> Option { - match agent { - "claude_code" => Some(Self::ClaudeCode), - "codex" => Some(Self::Codex), - _ => None, - } - } - - fn as_str(self) -> &'static str { - match self { - Self::ClaudeCode => "claude_code", - Self::Codex => "codex", - } - } -} - -#[derive(Debug, Default)] -struct NativeCatalogRefreshLane { - pending: HashMap, - // Requests whose native conversation is owned by a live turn wait here. - // One async waiter per key re-enqueues the newest coalesced request after - // identity becomes available, while this provider lane keeps advancing. - deferred: HashMap, - worker_running: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct NativeCatalogRefreshKey { - provider: NativeCatalogProvider, - native_id: String, - native_path: PathBuf, -} +static CODEX_NATIVE_PATH_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); #[derive(Debug, Clone)] -struct NativeCatalogRefreshRequest { - queued_at: Instant, - context: CliNativePublicationContext, - completed_turns_hint: Option, -} - -impl NativeCatalogRefreshLane { - fn key( - provider: NativeCatalogProvider, - context: &CliNativePublicationContext, - ) -> NativeCatalogRefreshKey { - NativeCatalogRefreshKey { - provider, - native_id: context.native_id.clone(), - native_path: context.paths.native_path.clone(), - } - } - - fn merge_request( - request: &mut NativeCatalogRefreshRequest, - queued_at: Instant, - context: CliNativePublicationContext, - completed_turns_hint: Option, - ) { - request.queued_at = queued_at; - // The native id is immutable, but title/model/branch metadata can - // advance while requests are coalesced. Keep the newest snapshot and - // the highest provider progress floor. - request.context = context; - request.completed_turns_hint = - request.completed_turns_hint.max(completed_turns_hint); - } - - fn enqueue( - &mut self, - provider: NativeCatalogProvider, - context: CliNativePublicationContext, - completed_turns_hint: Option, - ) -> bool { - let now = Instant::now(); - let key = Self::key(provider, &context); - if let Some(request) = self.deferred.get_mut(&key) { - Self::merge_request(request, now, context, completed_turns_hint); - return false; - } - self.pending - .entry(key) - .and_modify(|request| { - Self::merge_request(request, now, context.clone(), completed_turns_hint); - }) - .or_insert(NativeCatalogRefreshRequest { - queued_at: now, - context, - completed_turns_hint, - }); - if self.worker_running { - false - } else { - self.worker_running = true; - true - } - } - - fn defer_until_identity_available( - &mut self, - provider: NativeCatalogProvider, - mut request: NativeCatalogRefreshRequest, - ) -> (NativeCatalogRefreshKey, bool) { - let key = Self::key(provider, &request.context); - // A newer request can be enqueued between the worker's try-lock and - // this queue mutation. Fold it into the deferred slot as well. - if let Some(pending) = self.pending.remove(&key) { - Self::merge_request( - &mut request, - pending.queued_at, - pending.context, - pending.completed_turns_hint, - ); - } - if let Some(deferred) = self.deferred.get_mut(&key) { - Self::merge_request( - deferred, - request.queued_at, - request.context, - request.completed_turns_hint, - ); - (key, false) - } else { - self.deferred.insert(key.clone(), request); - (key, true) - } - } - - fn take_deferred( - &mut self, - key: &NativeCatalogRefreshKey, - ) -> Option { - self.deferred.remove(key) - } - - fn take_next(&mut self) -> Option { - let next = self - .pending - .iter() - .min_by_key(|(_, request)| request.queued_at) - .map(|(key, _)| key.clone()); - if let Some(key) = next { - Some( - self.pending - .remove(&key) - .expect("selected catalog refresh request must still exist"), - ) - } else { - self.worker_running = false; - None - } - } -} - -#[derive(Debug, Default)] -struct NativeCatalogRefreshQueue { - lanes: HashMap, -} - -impl NativeCatalogRefreshQueue { - fn lane_mut(&mut self, provider: NativeCatalogProvider) -> &mut NativeCatalogRefreshLane { - self.lanes.entry(provider).or_default() - } +struct NativeTranscriptPaths { + native_path: PathBuf, } -static NATIVE_CATALOG_REFRESH_QUEUE: LazyLock> = - LazyLock::new(|| Mutex::new(NativeCatalogRefreshQueue::default())); - /// 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 publication, so doing so would invert the lock order. +/// 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 { +async fn lock_idle_native_mutation(session_id: &str) -> Result { let control = super::session_runner::session_control_lock(session_id) .await .lock_owned() @@ -303,20 +115,14 @@ pub enum NativeConversationItem { output: String, created_at: String, }, - Compaction { - id: String, - summary: String, - created_at: String, - }, } impl NativeConversationItem { fn id(&self) -> &str { match self { - Self::Message { id, .. } - | Self::ToolCall { id, .. } - | Self::ToolResult { id, .. } - | Self::Compaction { id, .. } => id, + Self::Message { id, .. } | Self::ToolCall { id, .. } | Self::ToolResult { id, .. } => { + id + } } } @@ -324,8 +130,7 @@ impl NativeConversationItem { match self { Self::Message { created_at, .. } | Self::ToolCall { created_at, .. } - | Self::ToolResult { created_at, .. } - | Self::Compaction { created_at, .. } => created_at, + | Self::ToolResult { created_at, .. } => created_at, } } } @@ -431,12 +236,313 @@ fn validate_items(items: &[NativeConversationItem]) -> Result<(), String> { )); } } - NativeConversationItem::Compaction { .. } => {} } } 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 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. +fn native_items_from_chunks(chunks: &[ActivityChunk]) -> Vec { + let mut items = Vec::new(); + for chunk in chunks { + match chunk.function.as_str() { + 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" => { + let pending = chunk + .result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "pending" | "running")) + || chunk.result.get("interrupted").and_then(Value::as_bool) == Some(true); + if pending { + continue; + } + let call_id = chunk + .result + .get("call_id") + .and_then(Value::as_str) + .unwrap_or(&chunk.chunk_id) + .to_string(); + let name = chunk.function.clone(); + items.push(NativeConversationItem::ToolCall { + id: format!("{}:call", chunk.chunk_id), + call_id: call_id.clone(), + name: name.clone(), + arguments: chunk.args.to_string(), + created_at: chunk.created_at.clone(), + }); + items.push(NativeConversationItem::ToolResult { + id: format!("{}:result", chunk.chunk_id), + call_id, + name, + output: chunk_text(chunk), + created_at: chunk.created_at.clone(), + }); + } + _ => {} + } + } + items +} + +fn native_items_from_agent_history(history: &[Value]) -> Vec { + let mut items = Vec::new(); + for (index, message) in history.iter().enumerate() { + 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" { + for (tool_index, tool) in message + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + let call_id = tool.get("id").and_then(Value::as_str).unwrap_or_default(); + let function = tool.get("function").unwrap_or(tool); + let name = function + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool"); + 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: call_id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + created_at: created_at.clone(), + }); + } + } + } + "tool" => { + let call_id = message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default(); + items.push(NativeConversationItem::ToolResult { + id: format!("agent-history-{index}-result"), + call_id: call_id.to_string(), + name: message + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(), + output: message.get("content").map(json_text).unwrap_or_default(), + created_at, + }); + } + _ => {} + } + } + items +} + +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 + && serde_json::from_str::(left_arguments).ok() + == serde_json::from_str::(right_arguments).ok() + } + ( + NativeConversationItem::ToolResult { + call_id: left_id, + name: left_name, + output: left_output, + .. + }, + NativeConversationItem::ToolResult { + call_id: right_id, + name: right_name, + output: right_output, + .. + }, + ) => left_id == right_id && left_name == right_name && left_output == right_output, + _ => false, + } +} + +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_prefix_len( + session_id: &str, + complete: &[NativeConversationItem], +) -> Result { + let authoritative = authoritative_native_items(session_id)?; + if authoritative.len() > complete.len() + || !authoritative + .iter() + .zip(complete) + .all(|(left, right)| native_item_semantically_equal(left, right)) + { + return Err(format!( + "provider-native transcript is not a semantic prefix of the canonical conversation: native={} canonical={}", + authoritative.len(), + complete.len() + )); + } + Ok(authoritative.len()) +} + fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { let parent = path .parent() @@ -465,16 +571,6 @@ fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { result } -#[cfg(test)] -fn append_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { - // Serialize the complete suffix before opening the shared provider file. - // This keeps the append to one payload and, critically, means an error - // never needs a blind set_len rollback that could truncate bytes another - // native App process appended concurrently. - let payload = serialize_jsonl(records)?; - append_jsonl_payload(path, &payload) -} - fn serialize_jsonl(records: &[Value]) -> Result, String> { let mut payload = Vec::new(); for record in records { @@ -501,139 +597,6 @@ fn append_jsonl_payload(path: &Path, payload: &[u8]) -> Result<(), String> { .map_err(|err| format!("sync native transcript {}: {err}", path.display())) } -fn rollback_jsonl_suffix(path: &Path, original_len: u64, suffix: &[u8]) -> Result<(), String> { - let expected_len = original_len.saturating_add(suffix.len() as u64); - let mut file = fs::OpenOptions::new() - .read(true) - .write(true) - .open(path) - .map_err(|error| format!("open native transcript {} for rollback: {error}", path.display()))?; - let actual_len = file - .metadata() - .map_err(|error| format!("inspect native transcript {} for rollback: {error}", path.display()))? - .len(); - if actual_len != expected_len { - return Err(format!( - "native transcript {} advanced concurrently; expected {expected_len} bytes, found {actual_len}", - path.display() - )); - } - file.seek(SeekFrom::Start(original_len)) - .map_err(|error| format!("seek native transcript {} for rollback: {error}", path.display()))?; - let mut actual_suffix = vec![0; suffix.len()]; - file.read_exact(&mut actual_suffix) - .map_err(|error| format!("read native transcript {} for rollback: {error}", path.display()))?; - if actual_suffix != suffix { - return Err(format!( - "native transcript {} suffix changed concurrently; refusing rollback", - path.display() - )); - } - file.set_len(original_len) - .map_err(|error| format!("truncate native transcript {} during rollback: {error}", path.display()))?; - file.sync_all() - .map_err(|error| format!("sync native transcript {} after rollback: {error}", path.display())) -} - -/// Count actual human/user prompts in a Claude transcript without loading the -/// JSONL into memory. Claude represents tool results as `type=user` records as -/// well, so the outer type alone would wildly over-count long tool-heavy turns. -fn claude_completed_turns_from_transcript(path: &Path) -> Result { - let file = fs::File::open(path) - .map_err(|error| format!("open Claude transcript {}: {error}", path.display()))?; - let mut count = 0usize; - for (index, line) in BufReader::new(file).lines().enumerate() { - if index >= MAX_ITEMS { - return Err(format!( - "Claude transcript {} exceeds {MAX_ITEMS} records", - path.display() - )); - } - let line = line.map_err(|error| { - format!("read Claude transcript {}: {error}", path.display()) - })?; - if line.trim().is_empty() { - continue; - } - let record: Value = serde_json::from_str(&line).map_err(|error| { - format!("parse Claude transcript {}: {error}", path.display()) - })?; - if record["type"] != "user" - || record["message"]["role"] != "user" - || record["isMeta"].as_bool() == Some(true) - || record["isCompactSummary"].as_bool() == Some(true) - || !record["toolUseResult"].is_null() - { - continue; - } - let content = &record["message"]["content"]; - let is_tool_result_only = content.as_array().is_some_and(|blocks| { - !blocks.is_empty() - && blocks - .iter() - .all(|block| block["type"] == "tool_result") - }); - if !is_tool_result_only { - count += 1; - } - } - Ok(count) -} - -#[cfg(test)] -fn claude_active_leaf_uuid(path: &Path) -> Option { - fs::read_to_string(path) - .ok()? - .lines() - .rev() - .find_map(|line| { - let record = serde_json::from_str::(line).ok()?; - if record["type"] == "last-prompt" { - return record["leafUuid"] - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string); - } - record["uuid"] - .as_str() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - }) -} - -fn atomic_json(path: &Path, value: &Value) -> Result<(), String> { - let parent = path - .parent() - .ok_or_else(|| format!("native metadata path has no parent: {}", path.display()))?; - fs::create_dir_all(parent) - .map_err(|err| format!("create native metadata dir {}: {err}", parent.display()))?; - let tmp = path.with_extension(format!("json.tmp-{}", Uuid::new_v4().simple())); - let result = (|| -> Result<(), String> { - let mut file = fs::File::create(&tmp) - .map_err(|err| format!("create native metadata {}: {err}", tmp.display()))?; - serde_json::to_writer_pretty(&mut file, value) - .map_err(|err| format!("write native metadata {}: {err}", tmp.display()))?; - file.write_all(b"\n") - .map_err(|err| format!("write native metadata {}: {err}", tmp.display()))?; - file.sync_all() - .map_err(|err| format!("sync native metadata {}: {err}", tmp.display()))?; - atomic_replace_file(&tmp, path, "native metadata")?; - sync_parent_directory(path) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result -} - -#[derive(Debug, Clone)] -struct NativeTranscriptPaths { - /// Real provider file discovered by the official CLI and desktop app. - native_path: PathBuf, - /// Account-profile alias used by ORGII's isolated provider process. - runner_path: PathBuf, -} - fn remove_file_if_present(path: &Path) -> Result { match fs::remove_file(path) { Ok(()) => Ok(true), @@ -705,329 +668,47 @@ fn atomic_replace_file(staged: &Path, destination: &Path, label: &str) -> Result }) } -fn replace_runner_link(native_path: &Path, runner_path: &Path) -> Result<(), String> { - // Ambient local CLIs already read the provider's official transcript - // path. There is no isolated profile alias to create in that case. - if native_path == runner_path { - return Ok(()); - } - let parent = runner_path.parent().ok_or_else(|| { - format!( - "native runner transcript path has no parent: {}", - runner_path.display() - ) - })?; - fs::create_dir_all(parent).map_err(|err| { - format!( - "create native runner transcript dir {}: {err}", - parent.display() - ) - })?; - let tmp = runner_path.with_extension(format!("jsonl.link-{}", Uuid::new_v4().simple())); - - #[cfg(unix)] - std::os::unix::fs::symlink(native_path, &tmp).map_err(|err| { - format!( - "link native runner transcript {} -> {}: {err}", - tmp.display(), - native_path.display() - ) - })?; - - // Windows file symlinks commonly require an elevated process. A hard link - // keeps the same append semantics while both stores live on the user's - // home volume. Synchronization replaces it after each atomic rewrite. - #[cfg(windows)] - fs::hard_link(native_path, &tmp).map_err(|err| { - format!( - "link native runner transcript {} -> {}: {err}", - tmp.display(), - native_path.display() - ) - })?; +fn write_native_store_jsonl( + paths: &NativeTranscriptPaths, + records: &[Value], +) -> Result<(), String> { + atomic_jsonl(&paths.native_path, records) +} - #[cfg(not(any(unix, windows)))] - fs::hard_link(native_path, &tmp).map_err(|err| { - format!( - "link native runner transcript {} -> {}: {err}", - tmp.display(), - native_path.display() - ) - })?; +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() +} - let result = atomic_replace_file(&tmp, runner_path, "native runner transcript link") - .and_then(|()| sync_parent_directory(runner_path)); - if result.is_err() { - let _ = fs::remove_file(&tmp); - } - result +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 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() { - 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 publish_runner_transcript( - paths: &NativeTranscriptPaths, - expected_native_id: &str, -) -> Result<(), String> { - validate_provider_jsonl(&paths.runner_path, expected_native_id)?; - if paths.native_path == paths.runner_path { - return fs::File::open(&paths.native_path) - .and_then(|native| native.sync_all()) - .map_err(|error| { - format!( - "sync provider-native transcript {}: {error}", - paths.native_path.display() - ) - }); - } - let runner_metadata = fs::symlink_metadata(&paths.runner_path).map_err(|err| { - format!( - "inspect native runner transcript {}: {err}", - paths.runner_path.display() - ) - })?; - // The normal steady state is a link into the provider App store. Codex - // can replace that link with a regular rollout while resuming inside an - // account-isolated CODEX_HOME; in that case the runner copy contains the - // provider's newest native-only state and must be published before the App - // catalog is refreshed. - if runner_metadata.file_type().is_symlink() { - return fs::File::open(&paths.native_path) - .and_then(|native| native.sync_all()) - .map_err(|error| { - format!( - "sync provider-native transcript {}: {error}", - paths.native_path.display() - ) - }); - } - if !runner_metadata.is_file() { - return Err(format!( - "native runner transcript is not a file: {}", - paths.runner_path.display() - )); - } - if paths.native_path.is_file() { - if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { - // The isolated provider copy is an append-only extension of the - // native App copy; replacing it preserves every native byte. - } else if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { - // The native App advanced while ORGII's isolated copy did not. - // Keep the strictly newer native transcript and converge the - // runner alias without rewriting the official file. - replace_runner_link(&paths.native_path, &paths.runner_path)?; - return Ok(()); - } else if preferred_materialized_transcript_path(paths) - == Some(paths.runner_path.as_path()) - { - // Codex may replace the runner symlink with a complete new - // rollout instead of appending bytes. The interrupted-turn read - // rule already proved this generation is newer by mtime (or equal - // mtime plus a larger file), so it is safe to publish. - } else { - // Both sides advanced from the same UUID. There is no safe total - // order for provider-private state, so preserve both artifacts and - // fail closed. A later continuation can materialize the canonical - // portable transcript into a fresh native UUID. - return Err(format!( - "provider-native transcript conflict for {expected_native_id}: native App and isolated runner both advanced" - )); - } - } - let parent = paths.native_path.parent().ok_or_else(|| { - format!( - "native transcript path has no parent: {}", - paths.native_path.display() - ) - })?; - fs::create_dir_all(parent) - .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; - let tmp = paths - .native_path - .with_extension(format!("jsonl.tmp-{}", Uuid::new_v4().simple())); - - // The account profile and native App store normally live on the same home - // volume. Stage a hard link and atomically replace the App copy in O(1). - // Crucially, the runner name remains valid throughout, so a crash between - // staging and publication cannot strand the only current transcript under - // a temporary filename. Cross-filesystem roots use the copy fallback. - match fs::File::open(&paths.runner_path) - .and_then(|source| source.sync_all()) - .and_then(|()| fs::hard_link(&paths.runner_path, &tmp)) - { - Ok(()) => { - if let Err(error) = atomic_replace_file(&tmp, &paths.native_path, "native transcript") { - let _ = fs::remove_file(&tmp); - return Err(error); - } - sync_parent_directory(&paths.native_path)?; - - if let Err(link_error) = replace_runner_link(&paths.native_path, &paths.runner_path) { - // The provider transcript is already durable. Recover a - // regular runner copy so the next native resume still works; - // the following publication will retry converting it to the - // steady-state link. - let recovery = fs::copy(&paths.native_path, &paths.runner_path) - .and_then(|_| fs::File::open(&paths.runner_path)?.sync_all()) - .and_then(|()| sync_parent_directory(&paths.runner_path).map_err(std::io::Error::other)) - .map_err(|error| error.to_string()); - return match recovery { - Ok(_) => { - tracing::warn!( - runner_path = %paths.runner_path.display(), - native_path = %paths.native_path.display(), - error = %link_error, - "native transcript published but runner link recovery fell back to a regular file" - ); - Ok(()) - } - Err(recovery_error) => Err(format!( - "restore native runner transcript {} after link failure ({link_error}): {recovery_error}", - paths.runner_path.display() - )), - }; - } - return Ok(()); - } - Err(error) => { - tracing::debug!( - runner_path = %paths.runner_path.display(), - native_path = %paths.native_path.display(), - error = %error, - "native transcript hard-link staging unavailable; falling back to copy" - ); - } - } - - let result = (|| -> Result<(), String> { - let mut source = fs::File::open(&paths.runner_path).map_err(|err| { - format!( - "open native runner transcript {}: {err}", - paths.runner_path.display() - ) - })?; - let mut destination = fs::File::create(&tmp) - .map_err(|err| format!("create native transcript {}: {err}", tmp.display()))?; - std::io::copy(&mut source, &mut destination) - .map_err(|err| format!("copy native transcript {}: {err}", tmp.display()))?; - destination - .sync_all() - .map_err(|err| format!("sync native transcript {}: {err}", tmp.display()))?; - atomic_replace_file(&tmp, &paths.native_path, "native transcript")?; - sync_parent_directory(&paths.native_path)?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&tmp); - return result; - } - replace_runner_link(&paths.native_path, &paths.runner_path) -} - -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_messages(target_session_id: &str, items: &[NativeConversationItem]) -> Vec { +fn native_agent_seeds( + target_session_id: &str, + items: &[NativeConversationItem], +) -> Vec { items .iter() .map(|item| match item { @@ -1038,78 +719,49 @@ fn native_agent_messages(target_session_id: &str, items: &[NativeConversationIte images, created_at, turn_id, - } => { - let row_id = native_agent_row_id(target_session_id, id, turn_id.as_deref()); - let mut message = - if role == "user" && !images.is_empty() { - let mut content = vec![json!({"type": "text", "text": text})]; - content.extend(images.iter().map( - |image| json!({"type": "image_url", "image_url": {"url": image}}), - )); - json!({"role": role, "content": content}) + } => 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 { - json!({"role": role, "content": text}) - }; - message["__orgiiNativeMessageId"] = json!(row_id); - message["__orgiiNativeCreatedAt"] = json!(created_at); - message - } + MaterializedHistoryRole::Assistant + }, + text: text.clone(), + images: images.clone(), + }, + }, NativeConversationItem::ToolCall { id, call_id, name, arguments, created_at, - } => { - let mut message = json!({ - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": call_id, - "type": "function", - "function": {"name": name, "arguments": arguments} - }] - }); - message["__orgiiNativeMessageId"] = - json!(native_agent_row_id(target_session_id, id, None)); - message["__orgiiNativeCreatedAt"] = json!(created_at); - message - } + } => 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, - } => { - let mut message = json!({ - "role": "tool", - "tool_call_id": call_id, - "name": name, - "content": output - }); - message["__orgiiNativeMessageId"] = - json!(native_agent_row_id(target_session_id, id, None)); - message["__orgiiNativeCreatedAt"] = json!(created_at); - message - } - NativeConversationItem::Compaction { - id, - summary, - created_at, - } => { - let mut message = json!({ - "role": "system", - "content": format!( - "[Conversation summary — earlier messages compacted]\n\n{summary}" - ), - "__orgiiNativeCompactBoundary": true, - }); - message["__orgiiNativeMessageId"] = - json!(native_agent_row_id(target_session_id, id, None)); - message["__orgiiNativeCreatedAt"] = json!(created_at); - message - } + } => 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(), + }, + }, }) .collect() } @@ -1153,37 +805,25 @@ fn claude_native_paths( 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); + let profile_root = account_id + .map(app_paths::claude_code_cli_profile_dir) + .unwrap_or_else(|| app_paths::native_transcript_home_dir().join(".claude")); 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, + native_path: profile_root.join(relative), } } -fn codex_sessions_root() -> PathBuf { - app_paths::native_transcript_home_dir() - .join(".codex") - .join("sessions") +fn codex_sessions_root(account_id: &str) -> PathBuf { + app_paths::codex_cli_profile_dir(account_id).join("sessions") } fn codex_native_paths_for_relative(account_id: &str, relative: &Path) -> NativeTranscriptPaths { NativeTranscriptPaths { - native_path: codex_sessions_root().join(relative), - runner_path: app_paths::codex_cli_profile_dir(account_id) - .join("sessions") - .join(relative), + native_path: codex_sessions_root(account_id).join(relative), } } -fn cache_codex_native_paths( - account_id: &str, - native_id: &str, - paths: &NativeTranscriptPaths, -) { +fn cache_codex_native_paths(account_id: &str, native_id: &str, paths: &NativeTranscriptPaths) { let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() else { return; }; @@ -1211,13 +851,8 @@ fn existing_codex_native_paths(account_id: &str, native_id: &str) -> Option Result { - let root = codex_sessions_root(); + let root = codex_sessions_root(account_id); let relative = match native_path.strip_prefix(&root) { Ok(relative) => relative.to_path_buf(), Err(_) => { @@ -1306,1578 +941,649 @@ pub(super) fn load_materialized_cli_transcript( Ok(Some(chunks)) } -/// Select the authoritative readable copy after an interrupted provider turn. -/// -/// The steady state is one identity (a symlink on Unix; commonly a hard link -/// on Windows). Some providers atomically replace the isolated runner file, -/// leaving the App copy behind until publication. In that diverged state a -/// strictly newer runner -- or an equal-timestamp append with a larger size -- -/// is the only copy that can contain the just-finished partial/tool suffix. -/// Never prefer a merely different runner: the native App may itself have -/// advanced a conversation, and coarse filesystem timestamps cannot prove the -/// isolated copy is newer. +/// Return the one authoritative transcript selected by the account binding. fn preferred_materialized_transcript_path(paths: &NativeTranscriptPaths) -> Option<&Path> { - 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) => None, - (Some(_), None) => Some(&paths.native_path), - (None, Some(_)) => Some(&paths.runner_path), - (Some(native), Some(runner)) => { - if paths_match(&paths.native_path, &paths.runner_path) { - return Some(&paths.native_path); - } - let runner_is_newer = match (native.modified(), runner.modified()) { - (Ok(native_modified), Ok(runner_modified)) => { - runner_modified > native_modified - || (runner_modified == native_modified && runner.len() > native.len()) - } - _ => false, - }; - if runner_is_newer { - tracing::warn!( - native_path = %paths.native_path.display(), - runner_path = %paths.runner_path.display(), - "reading newer unpublished provider transcript from isolated runner" - ); - Some(&paths.runner_path) - } else { - Some(&paths.native_path) - } - } - } + paths + .native_path + .is_file() + .then_some(paths.native_path.as_path()) } -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 git_common_dir(path: &Path) -> Option { - let mut directory = fs::canonicalize(path).ok()?; - loop { - let dot_git = directory.join(".git"); - if dot_git.is_dir() { - return fs::canonicalize(dot_git).ok(); - } - if dot_git.is_file() { - let raw = fs::read_to_string(&dot_git).ok()?; - let raw_git_dir = raw.trim().strip_prefix("gitdir:")?.trim(); - let git_dir = PathBuf::from(raw_git_dir); - let git_dir = if git_dir.is_absolute() { - git_dir - } else { - directory.join(git_dir) - }; - let git_dir = fs::canonicalize(git_dir).ok()?; - let common_dir_file = git_dir.join("commondir"); - if !common_dir_file.is_file() { - return Some(git_dir); +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 { + 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, + ) } - let raw_common_dir = fs::read_to_string(common_dir_file).ok()?; - let common_dir = PathBuf::from(raw_common_dir.trim()); - let common_dir = if common_dir.is_absolute() { - common_dir - } else { - git_dir.join(common_dir) - }; - return fs::canonicalize(common_dir).ok(); + 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, .. + } => ( + "user".to_string(), + json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": call_id, + "content": output + }] + }), + Some(json!({"toolUseResult": output})), + ), + }; + 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); } - directory = directory.parent()?.to_path_buf(); + parent_uuid = Some(record_uuid); + records.push(record); } + Ok(records) } -fn paths_share_git_repository(left: &Path, right: &Path, left_common_dir: Option<&Path>) -> bool { - let left_common_dir = left_common_dir - .map(Path::to_path_buf) - .or_else(|| git_common_dir(left)); - left_common_dir - .zip(git_common_dir(right)) - .is_some_and(|(left, right)| left == right) -} - -fn claude_desktop_sessions_roots() -> Vec { - let mut roots = [ - app_paths::native_transcript_data_dir(), - app_paths::native_transcript_data_local_dir(), - app_paths::native_transcript_config_dir(), - ] - .into_iter() - .map(|root| root.join("Claude").join("claude-code-sessions")) - .collect::>(); - roots.sort(); - roots.dedup(); - roots -} - -fn claude_desktop_active_account_id(sessions_root: &Path) -> Option { - let config_path = sessions_root.parent()?.join("config.json"); - let config = fs::read_to_string(config_path).ok()?; - let config = serde_json::from_str::(&config).ok()?; - let account_id = config["lastKnownAccountUuid"].as_str()?; - Uuid::parse_str(account_id).ok()?; - Some(account_id.to_string()) -} - -fn publish_claude_project_index( - cwd: &Path, +fn claude_resume_checkpoint( native_id: &str, + leaf_uuid: &str, items: &[NativeConversationItem], - completed_turns: Option, - git_branch: Option<&str>, -) -> Result { - let _index_guard = CLAUDE_PROJECT_INDEX_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let (index_path, index) = prepare_claude_project_index( - cwd, - native_id, - items, - completed_turns, - git_branch, - )?; - atomic_json(&index_path, &index)?; - Ok(index_path) -} - -fn prepare_claude_project_index( - cwd: &Path, - native_id: &str, - items: &[NativeConversationItem], - completed_turns: Option, - git_branch: Option<&str>, -) -> Result<(PathBuf, Value), 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() - ) - })?; - fs::create_dir_all(project_dir).map_err(|error| { - format!( - "create Claude native project directory {}: {error}", - project_dir.display() - ) - })?; - let index_path = project_dir.join("sessions-index.json"); - let mut index = match fs::read_to_string(&index_path) { - Ok(raw) => serde_json::from_str::(&raw).map_err(|error| { - format!( - "decode existing Claude project index {}: {error}", - index_path.display() - ) - })?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - json!({"version": 1, "entries": []}) - } - Err(error) => { - return Err(format!( - "read Claude project index {}: {error}", - index_path.display() - )) - } - }; - let object = index.as_object_mut().ok_or_else(|| { - format!( - "Claude project index is not an object: {}", - index_path.display() - ) - })?; - object - .entry("version".to_string()) - .or_insert_with(|| Value::Number(1.into())); - let entries = object - .entry("entries".to_string()) - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .ok_or_else(|| { - format!( - "Claude project index entries are not an array: {}", - index_path.display() - ) - })?; - 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 +) -> Value { + let last_prompt = items .iter() + .rev() .find_map(|item| match item { - NativeConversationItem::Message { role, text, .. } if role == "user" => { - Some(text.trim()) + NativeConversationItem::Message { role, text, .. } + if role == "user" && !text.trim().is_empty() => + { + Some(text.as_str()) } _ => 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 completed_message_count = completed_turns.unwrap_or_default().saturating_mul(2); - let message_count = projected_message_count - .max(previous_message_count) - .max(completed_message_count); - entries.push(json!({ + .unwrap_or_default(); + json!({ + "type": "last-prompt", + "lastPrompt": last_prompt, + "leafUuid": leaf_uuid, "sessionId": native_id, - "fullPath": transcript_path, - "fileMtime": now.timestamp_millis(), - "firstPrompt": first_prompt, - "messageCount": message_count, - "created": created, - "modified": now_iso, - "gitBranch": git_branch.unwrap_or_default(), - "workspacePath": cwd, - })); - Ok((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())?; - let _index_guard = CLAUDE_PROJECT_INDEX_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !index_path.is_file() { - return Ok(()); - } - let mut index = fs::read_to_string(&index_path) - .map_err(|error| { - format!( - "read Claude project index {}: {error}", - index_path.display() - ) - }) - .and_then(|raw| { - serde_json::from_str::(&raw) - .map_err(|error| format!("decode Claude project index: {error}")) - })?; - let Some(entries) = index["entries"].as_array_mut() else { - return Err(format!( - "Claude project index entries are not an array: {}", - index_path.display() - )); - }; - 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)?; +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(()) + Ok(records) } -#[derive(Debug, Default)] -struct ClaudeDesktopCatalogResolution { - active_account_root: bool, - existing_session_path: Option, - matching_project_dir: Option, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeSuffixApplication { + Missing, + AlreadyApplied, } -impl ClaudeDesktopCatalogResolution { - fn priority(&self) -> u8 { - if self.existing_session_path.is_some() { - 0 - } else if self.active_account_root { - 1 - } else if self.matching_project_dir.is_some() { - 2 - } else { - 3 +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}" + )); } } - - fn target_path(&self, native_id: &str) -> Option { - self.existing_session_path.clone().or_else(|| { - self.matching_project_dir - .as_ref() - .map(|project_dir| project_dir.join(format!("local_{native_id}.json"))) - }) + if expected_records_by_id.is_empty() { + return Err("projected Claude native suffix is empty".to_string()); } -} -fn resolve_claude_desktop_catalog( - root: &Path, - cwd: &Path, - native_id: &str, -) -> ClaudeDesktopCatalogResolution { - let active_account_id = claude_desktop_active_account_id(root); - let active_account_root = active_account_id.is_some(); - let mut existing_session: Option<(i64, PathBuf)> = None; - let mut matching_project: Option<(i64, PathBuf)> = None; - let mut visited = 0usize; - let cwd_common_dir = git_common_dir(cwd); - let organization_dirs = match active_account_id { - Some(account_id) => vec![root.join(account_id)], - None => match fs::read_dir(root) { - Ok(entries) => entries.flatten().map(|entry| entry.path()).collect(), - Err(_) => Vec::new(), - }, - }; - for organization_dir in organization_dirs { - if !organization_dir.is_dir() { + 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 Ok(projects) = fs::read_dir(organization_dir) else { - continue; - }; - for project in projects.flatten() { - let project_path = project.path(); - if !project_path.is_dir() { - 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()); } - let Ok(entries) = fs::read_dir(&project_path) else { - continue; - }; - for entry in entries.flatten() { - visited += 1; - if visited > MAX_ITEMS { - return ClaudeDesktopCatalogResolution { - active_account_root, - existing_session_path: existing_session.map(|(_, path)| path), - matching_project_dir: matching_project.map(|(_, path)| path), - }; - } - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let Ok(value) = fs::read_to_string(&path) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .ok_or(()) - else { - continue; - }; - let exact_cwd = ["cwd", "originCwd"].into_iter().any(|field| { - value[field] - .as_str() - .is_some_and(|record_cwd| paths_match(Path::new(record_cwd), cwd)) - }); - let matches_project = exact_cwd - || ["cwd", "originCwd"].into_iter().any(|field| { - value[field].as_str().is_some_and(|record_cwd| { - paths_share_git_repository( - cwd, - Path::new(record_cwd), - cwd_common_dir.as_deref(), - ) - }) - }); - let activity = value["lastActivityAt"] - .as_i64() - .or_else(|| value["createdAt"].as_i64()) - .unwrap_or_default(); - if exact_cwd - && value["cliSessionId"].as_str() == Some(native_id) - && existing_session - .as_ref() - .is_none_or(|(best_activity, _)| activity > *best_activity) - { - existing_session = Some((activity, path)); + } 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 matches_project - && matching_project - .as_ref() - .is_none_or(|(best_activity, _)| activity > *best_activity) - { - matching_project = Some((activity, project_path.clone())); + if !found_ids.insert(uuid.to_string()) { + return Err(format!( + "Claude native transcript {} contains duplicate stable suffix uuid {uuid}", + path.display() + )); } } } } - ClaudeDesktopCatalogResolution { - active_account_root, - existing_session_path: existing_session.map(|(_, path)| path), - matching_project_dir: matching_project.map(|(_, path)| path), - } -} - -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 assistant_turn_count(items: &[NativeConversationItem]) -> usize { - items - .iter() - .filter(|item| { - matches!(item, NativeConversationItem::Message { role, .. } if role == "assistant") - }) - .count() + 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 publish_claude_desktop_session( - cwd: &Path, - native_id: &str, - model: Option<&str>, - title: Option<&str>, - items: &[NativeConversationItem], - materialized_by_orgii: bool, - completed_turns: Option, -) -> Result, String> { - let mut catalogs = claude_desktop_sessions_roots() - .into_iter() - .map(|sessions_root| resolve_claude_desktop_catalog(&sessions_root, cwd, native_id)) - .collect::>(); - // Prefer an exact provider-owned row, then the root of Desktop's active - // account, then any existing matching project. Filesystem path ordering - // is not an account-selection policy. - catalogs.sort_by_key(ClaudeDesktopCatalogResolution::priority); - for resolution in catalogs { - let Some(path) = resolution.target_path(native_id) else { - continue; - }; - if let Some(path) = publish_claude_desktop_session_to_path( - path, - cwd, - native_id, - model, - title, - items, - materialized_by_orgii, - completed_turns, - )? { - return Ok(Some(path)); +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, .. + } => projected.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output + })), } } - Ok(None) -} - -#[cfg(test)] -#[derive(Clone, Copy)] -struct ClaudeDesktopPublicationState { - materialized_by_orgii: bool, - completed_turns: Option, + projected } -#[cfg(test)] -fn publish_claude_desktop_session_at( - sessions_root: &Path, - cwd: &Path, - native_id: &str, - model: Option<&str>, - title: Option<&str>, - items: &[NativeConversationItem], - state: ClaudeDesktopPublicationState, -) -> Result, String> { - let resolution = resolve_claude_desktop_catalog(sessions_root, cwd, native_id); - let Some(path) = resolution.target_path(native_id) else { - // Native Claude Code JSONL remains independently valid, but this - // function is specifically the Desktop catalog adapter. Do not - // manufacture account/project UUIDs and call the result App-visible - // when Desktop has never registered them. - return Ok(None); - }; - publish_claude_desktop_session_to_path( - path, - cwd, - native_id, - model, - title, - items, - state.materialized_by_orgii, - state.completed_turns, - ) +fn provider_canonical_cwd(cwd: PathBuf) -> PathBuf { + fs::canonicalize(&cwd).unwrap_or(cwd) } -#[allow(clippy::too_many_arguments)] -fn publish_claude_desktop_session_to_path( - path: PathBuf, - cwd: &Path, - native_id: &str, - model: Option<&str>, - title: Option<&str>, - items: &[NativeConversationItem], - materialized_by_orgii: bool, - completed_turns: Option, -) -> Result, String> { - let mut metadata = match fs::read_to_string(&path) { - Ok(raw) => serde_json::from_str::(&raw).map_err(|error| { - format!( - "decode existing Claude Desktop metadata {}: {error}", - path.display() - ) - })?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => json!({}), - Err(error) => { - return Err(format!( - "read Claude Desktop metadata {}: {error}", - path.display() - )) - } - }; - let object = metadata.as_object_mut().ok_or_else(|| { - format!( - "Claude Desktop metadata is not an object: {}", - path.display() - ) - })?; - let now = Utc::now().timestamp_millis(); - let existing_completed_turns = object - .get("completedTurns") - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()); - let projected_completed_turns = (!items.is_empty()).then(|| assistant_turn_count(items)); - let Some(completed_turns) = [ - completed_turns, - existing_completed_turns, - projected_completed_turns, - ] - .into_iter() - .flatten() - .max() else { - // A metadata-only refresh has no safe progress value when neither the - // queue nor an existing provider row carries one. Leave the catalog - // untouched instead of resetting completedTurns to zero. - return Ok(None); +fn execution_cwd(session: &persistence::CodeSession) -> Result { + let value = session + .worktree_path + .as_deref() + .or(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}"))?, }; - object - .entry("sessionId".to_string()) - .or_insert_with(|| Value::String(format!("local_{native_id}"))); - object.insert( - "cliSessionId".to_string(), - Value::String(native_id.to_string()), - ); - object.insert( - "cwd".to_string(), - Value::String(cwd.to_string_lossy().into()), - ); - object - .entry("originCwd".to_string()) - .or_insert_with(|| Value::String(cwd.to_string_lossy().into())); - object - .entry("createdAt".to_string()) - .or_insert_with(|| Value::Number(now.into())); - object.insert("lastFocusedAt".to_string(), Value::Number(now.into())); - object.insert("lastActivityAt".to_string(), Value::Number(now.into())); - object.entry("title".to_string()).or_insert_with(|| { - Value::String( - title - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| first_user_title(items)), - ) - }); - object - .entry("titleSource".to_string()) - .or_insert_with(|| Value::String("orgii".to_string())); - object - .entry("permissionMode".to_string()) - .or_insert_with(|| Value::String("auto".to_string())); - object - .entry("isArchived".to_string()) - .or_insert(Value::Bool(false)); - object - .entry("remoteMcpServersConfig".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - object.insert( - "completedTurns".to_string(), - Value::Number(completed_turns.into()), - ); - object - .entry("alwaysAllowedReasons".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - object - .entry("sessionPermissionUpdates".to_string()) - .or_insert_with(|| Value::Array(Vec::new())); - object - .entry("classifierSummaryEnabled".to_string()) - .or_insert(Value::Bool(true)); - if materialized_by_orgii { - object.insert("orgiiMaterialization".to_string(), Value::Bool(true)); - } - if let Some(model) = model.filter(|value| !value.trim().is_empty()) { - object - .entry("model".to_string()) - .or_insert_with(|| Value::String(model.to_string())); - } - atomic_json(&path, &metadata)?; - // Read through the same provider-owned metadata boundary before reporting - // success. This is deliberately stronger than trusting our in-memory JSON: - // malformed/redirected writes remain native-format-only and materialize - // fails closed instead of promising an App-visible catalog row. - let published = fs::read_to_string(&path) - .map_err(|error| { - format!( - "read back Claude Desktop session {}: {error}", - path.display() - ) - }) - .and_then(|raw| { - serde_json::from_str::(&raw).map_err(|error| { - format!( - "decode published Claude Desktop session {}: {error}", - path.display() - ) - }) - })?; - let published_cwd_matches = ["cwd", "originCwd"].into_iter().any(|field| { - published[field] - .as_str() - .is_some_and(|value| paths_match(Path::new(value), cwd)) - }); - if published["cliSessionId"].as_str() != Some(native_id) - || !published_cwd_matches - || !published["title"].is_string() - || !published["completedTurns"].is_number() - { - return Err(format!( - "Claude Desktop catalog read-back rejected {}", - path.display() - )); + // 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 workspace 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) -> Option { + 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 = fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_ITEMS { + return None; + } + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(path); + } + } } - Ok(Some(path)) + None } -fn remove_claude_desktop_session(native_id: &str) -> Result<(), String> { - for root in claude_desktop_sessions_roots() { - remove_claude_desktop_session_at(&root, native_id)?; +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(), + ); } - Ok(()) + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let cwd = execution_cwd(&session)?; + let paths = match agent { + "claude_code" => 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" => { + codex_native_catalog::archive_thread( + &app_paths::codex_cli_profile_dir(account_id.expect("Codex account checked")), + &paths.native_path, + native_id, + &cwd, + )?; + remove_file_if_present(&paths.native_path)?; + true + } + "claude_code" => remove_file_if_present(&paths.native_path)?, + _ => false, + }; + persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") + .map_err(|err| format!("clear native materialization binding: {err}"))?; + Ok(removed) } -fn remove_claude_desktop_session_at(root: &Path, native_id: &str) -> Result<(), String> { - if !root.is_dir() { - return Ok(()); +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 + )); } - let filename = format!("local_{native_id}.json"); - for organization in fs::read_dir(root) - .map_err(|err| format!("read Claude Desktop sessions {}: {err}", root.display()))? - .flatten() - { - for project in fs::read_dir(organization.path()) - .into_iter() - .flatten() - .flatten() - { - let path = project.path().join(&filename); - if !path.is_file() { - continue; + 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); + if let Err(error) = write_native_store_jsonl( + &paths, + &claude_records_with_resume_checkpoint(&native_id, &cwd, items)?, + ) { + let _ = remove_file_if_present(&paths.native_path); + return Err(error); } - let matches_native_id = fs::read_to_string(&path) - .ok() - .and_then(|raw| serde_json::from_str::(&raw).ok()) - .and_then(|value| value["cliSessionId"].as_str().map(str::to_string)) - .as_deref() - == Some(native_id); - if matches_native_id { - fs::remove_file(&path).map_err(|err| { - format!("remove Claude Desktop session {}: {err}", path.display()) + (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 = app_paths::codex_cli_profile_dir(account_id); + let registered = codex_native_catalog::register_thread( + &codex_home, + &cwd, + &title, + &codex_response_items(items), + )?; + 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); + return Err(error); + } + }; + cache_codex_native_paths(account_id, ®istered.id, &paths); + (registered.id, paths) + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + // Bind the provider UUID before the caller round-trips the transcript. + // Both native readers already fall back to resolving the exact provider + // file by UUID when their list cache misses; synchronously rebuilding the + // entire imported-history index here turns a one-file continuation into + // an O(all historical transcripts) operation on the send path. + let register_result = (|| -> Result<(), String> { + let bound = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|err| { + format!("bind native transcript {native_id} to {session_id}: {err}") })?; - } + if !bound { + return Err(format!( + "bind native transcript {native_id}: target session {session_id} disappeared" + )); + } + Ok(()) + })(); + if let Err(error) = register_result { + if agent == "codex" { + let _ = codex_native_catalog::archive_thread( + &app_paths::codex_cli_profile_dir(account_id.expect("Codex account checked")), + &paths.native_path, + &native_id, + &cwd, + ); + let _ = remove_file_if_present(&paths.native_path); + } else { + let _ = fs::remove_file(&paths.native_path); } + return Err(error); } - Ok(()) + tracing::info!( + session_id, + native_session_id = native_id, + target = agent, + native_path = %paths.native_path.display(), + item_count = items.len(), + "materialized provider-native conversation transcript" + ); + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: items.len(), + }) } -fn claude_records( - native_id: &str, - cwd: &Path, +fn materialize_native_agent( + session_id: &str, 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::Compaction { - id, - summary, - created_at, - } = item - { - let boundary_uuid = stable_uuid("orgii-claude-native-compact-boundary", native_id, id); - records.push(json!({ - "type": "system", - "subtype": "compact_boundary", - "content": "Conversation compacted", - "uuid": boundary_uuid, - "parentUuid": parent_uuid, - "isSidechain": false, - "isMeta": false, - "sessionId": native_id, - "cwd": cwd, - "timestamp": created_at, - "entrypoint": "orgii", - "orgiiMaterialization": true, - "compactMetadata": {"trigger": "orgii_native_transfer"}, - })); - let summary_uuid = stable_uuid("orgii-claude-native-compact-summary", native_id, id); - records.push(json!({ - "type": "user", - "uuid": summary_uuid, - "parentUuid": boundary_uuid, - "isSidechain": false, - "isCompactSummary": true, - "userType": "external", - "sessionId": native_id, - "cwd": cwd, - "timestamp": created_at, - "message": {"role": "user", "content": summary}, - "entrypoint": "orgii", - "orgiiMaterialization": true, - })); - 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, .. - } => ( - "user".to_string(), - json!({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": call_id, - "content": output - }] - }), - Some(json!({"toolUseResult": output})), - ), - NativeConversationItem::Compaction { .. } => { - unreachable!("compaction handled before message projection") - } - }; - 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", - "orgiiMaterialization": true - }); - if let Some(Value::Object(extra)) = extra { - record.as_object_mut().expect("record object").extend(extra); - } - parent_uuid = Some(record_uuid); - records.push(record); +) -> 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(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, - "orgiiMaterialization": true, + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: items.len(), }) } -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)); +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 + )); } - 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() - { + 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" => 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!( - "projected Claude native suffix contains duplicate uuid {id}" - )); + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) } + }; + if !paths.native_path.is_file() { + return Err(format!( + "materialized {agent} transcript {native_id} was not found" + )); } - 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() - )); + // 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" => { + 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 !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 codex_response_items(items: &[NativeConversationItem]) -> Vec { - const MATERIALIZED_ARGUMENT_KEY: &str = "__orgiiMaterializedNative"; - const CANONICAL_ARGUMENT_KEY: &str = "__orgiiCanonicalArguments"; - const MATERIALIZED_COMPACTION_TURN_PREFIX: &str = "orgii-materialized-compaction:"; - - let mut projected = Vec::with_capacity(items.len().saturating_add(2)); - 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})), - ); - } - let mut message = - json!({"type": "message", "id": id, "role": role, "content": content}); - if role == "user" { - // `thread/inject_items` persists only response items; it - // does not synthesize the event_msg/UserMessage mirror - // found after an ordinary Codex UI submission. Stamp the - // supported passthrough turn id so our native reader can - // distinguish these canonical user rows from Codex's - // user-role system/context prefix messages. - message["internal_chat_message_metadata_passthrough"] = json!({ - "turn_id": format!("orgii-materialization-{id}") - }); - } - projected.push(message); - } - NativeConversationItem::ToolCall { - call_id, - name, - arguments, - .. - } => { - // `thread/inject_items` drops unknown response-item fields, so - // the legacy `orgii_materialization` boolean cannot survive a - // real app-server round trip. Arguments are protocol data and - // survive verbatim. The `__orgii` namespace is already - // excluded from portable user tool arguments; the reader - // removes this marker before publishing canonical history. - let canonical = serde_json::from_str::(arguments) - .expect("validated native tool arguments"); - let marked_arguments = match canonical { - Value::Object(mut object) => { - object.insert(MATERIALIZED_ARGUMENT_KEY.to_string(), Value::Bool(true)); - Value::Object(object) - } - canonical => { - let mut object = serde_json::Map::new(); - object.insert(MATERIALIZED_ARGUMENT_KEY.to_string(), Value::Bool(true)); - object.insert(CANONICAL_ARGUMENT_KEY.to_string(), canonical); - Value::Object(object) - } - }; - projected.push(json!({ - "type": "function_call", - "name": name, - "arguments": marked_arguments.to_string(), - "call_id": call_id - })); - } - NativeConversationItem::ToolResult { - call_id, output, .. - } => projected.push(json!({ - "type": "function_call_output", - "call_id": call_id, - "output": output - })), - NativeConversationItem::Compaction { id, summary, .. } => { - // `thread/inject_items` supports the Responses API's native - // `context_compaction` item. A cross-provider source cannot - // forge Codex's provider-encrypted compact payload, so carry - // the portable summary as an adjacent model-visible assistant - // item and tag both with the supported passthrough turn id. - // The native reader folds this exact pair back into one - // canonical compaction boundary; it is never projected as a - // fake user prompt. - let marker = format!("{MATERIALIZED_COMPACTION_TURN_PREFIX}{id}"); - projected.push(json!({ - "type": "message", - "id": format!("{id}-summary"), - "role": "assistant", - "content": [{"type": "output_text", "text": summary}], - "internal_chat_message_metadata_passthrough": { - "turn_id": marker - } - })); - projected.push(json!({ - "type": "context_compaction", - "id": id, - "encrypted_content": null, - "internal_chat_message_metadata_passthrough": { - "turn_id": marker - } - })); - } - } - } - if let Some(first) = projected.first_mut().and_then(Value::as_object_mut) { - first.insert("orgii_materialization".to_string(), Value::Bool(true)); - } - projected -} - -fn provider_canonical_cwd(cwd: PathBuf) -> PathBuf { - fs::canonicalize(&cwd).unwrap_or(cwd) -} - -fn execution_cwd(session: &persistence::CodeSession) -> Result { - let value = session - .worktree_path - .as_deref() - .or(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 workspace 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) -> Option { - 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 = fs::read_dir(directory).ok()?; - for entry in entries.flatten() { - visited += 1; - if visited > MAX_ITEMS { - return None; - } - let path = entry.path(); - if path.is_dir() { - pending.push(path); - } else if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.ends_with(&suffix)) - { - return Some(path); - } - } - } - None -} - -fn has_orgii_materialization_marker(path: &Path, agent: &str) -> bool { - let Ok(file) = fs::File::open(path) else { - return false; - }; - let mut lines = BufReader::new(file).lines().take(MAX_ITEMS); - match agent { - "claude_code" => lines - .next() - .and_then(Result::ok) - .and_then(|line| serde_json::from_str::(&line).ok()) - .is_some_and(|record| record["orgiiMaterialization"] == true), - "codex" => lines.filter_map(Result::ok).any(|line| { - serde_json::from_str::(&line) - .ok() - .is_some_and(|record| codex_record_has_orgii_materialization_marker(&record)) - }), - _ => false, - } -} - -fn codex_record_has_orgii_materialization_marker(record: &Value) -> bool { - if record["type"] == "session_meta" && record["payload"]["originator"] == "orgii" { - return true; - } - if record["type"] != "response_item" { - return false; - } - let payload = &record["payload"]; - if payload["orgii_materialization"] == true { - return true; - } - if payload["internal_chat_message_metadata_passthrough"]["turn_id"] - .as_str() - .is_some_and(|turn_id| { - turn_id.starts_with("orgii-materialization-") - || turn_id.starts_with("orgii-materialized-compaction:") - }) - { - return true; - } - payload["type"] == "function_call" - && payload["arguments"] - .as_str() - .and_then(|arguments| serde_json::from_str::(arguments).ok()) - .is_some_and(|arguments| arguments["__orgiiMaterializedNative"] == true) -} - -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" => 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), - }; - for path in [&paths.native_path, &paths.runner_path] { - if fs::symlink_metadata(path).is_ok() && !has_orgii_materialization_marker(path, agent) { - return Err(format!( - "refusing to remove unmarked provider transcript {}", - path.display() - )); - } - } - let removed = match agent { - "codex" => { - codex_native_catalog::archive_thread(&paths.native_path, native_id, &cwd)?; - remove_file_if_present(&paths.runner_path)?; - // `thread/archive` removes the catalog row, not necessarily the - // rollout file. The marker checks above prove this is ORGII-owned. - remove_file_if_present(&paths.native_path)?; - true - } - "claude_code" => { - let mut removed = false; - for path in [&paths.runner_path, &paths.native_path] { - if fs::symlink_metadata(path).is_ok() { - fs::remove_file(path).map_err(|err| { - format!("remove native materialization {}: {err}", path.display()) - })?; - removed = true; + 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_jsonl_payload(&paths.native_path, &payload)?; } - remove_claude_desktop_session(native_id)?; - remove_claude_project_index_entry(&cwd, native_id)?; - removed - } - _ => false, - }; - persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") - .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); - if let Err(error) = write_native_store_jsonl( - &paths, - &claude_records_with_resume_checkpoint(&native_id, &cwd, items)?, - ) { - // `atomic_jsonl` may already have committed the provider file - // before creating the account-profile alias fails. Nothing is - // bound yet, so clean both paths here rather than leave an - // unreachable ORGII-marked UUID behind. - let _ = remove_file_if_present(&paths.runner_path); - let _ = remove_file_if_present(&paths.native_path); - 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 registered = - codex_native_catalog::register_thread(&cwd, &title, &codex_response_items(items))?; - let paths = match registered_codex_native_paths(account_id, ®istered.path) { - Ok(paths) => paths, - Err(error) => { - let _ = codex_native_catalog::archive_thread( - ®istered.path, - ®istered.id, - &cwd, - ); - let _ = remove_file_if_present(®istered.path); - 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(&paths.native_path, ®istered.id, &cwd); - let _ = remove_file_if_present(&paths.runner_path); - let _ = remove_file_if_present(&paths.native_path); - 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" { - // Claude Code owns the executable resume contract: the native JSONL - // and its project session index. Claude Desktop metadata is a separate - // discovery projection and is refreshed best-effort after the binding - // is durable; a machine without Desktop must still run the CLI. - if let Err(error) = - publish_claude_project_index(&cwd, &native_id, items, None, session.branch.as_deref()) - { - let _ = fs::remove_file(&paths.runner_path); - let _ = fs::remove_file(&paths.native_path); - let _ = remove_claude_project_index_entry(&cwd, &native_id); - return Err(error); - } - } - // Bind the provider UUID before the caller round-trips the transcript. - // Both native readers already fall back to resolving the exact provider - // file by UUID when their list cache misses; synchronously rebuilding the - // entire imported-history index here turns a one-file continuation into - // an O(all historical transcripts) operation on the send path. - let register_result = (|| -> Result<(), String> { - let bound = - persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) - .map_err(|err| { - format!("bind native transcript {native_id} to {session_id}: {err}") - })?; - if !bound { - return Err(format!( - "bind native transcript {native_id}: target session {session_id} disappeared" - )); - } - Ok(()) - })(); - if let Err(error) = register_result { - if agent == "codex" { - let _ = codex_native_catalog::archive_thread(&paths.native_path, &native_id, &cwd); - let _ = remove_file_if_present(&paths.runner_path); - let _ = remove_file_if_present(&paths.native_path); - } else { - let _ = fs::remove_file(&paths.runner_path); - let _ = fs::remove_file(&paths.native_path); - let _ = remove_claude_project_index_entry(&cwd, &native_id); - } - return Err(error); - } - 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"))?; - agent_core::session::persistence::seed_session_with_messages( - session_id, - &native_agent_messages(session_id, items), - ) - .map_err(|err| format!("seed native Agent transcript {session_id}: {err}"))?; - 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" => 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" - )) - } - }; - let mut found = false; - for path in [&paths.native_path, &paths.runner_path] { - if fs::symlink_metadata(path).is_err() { - continue; - } - found = true; - } - if !found { - return Err(format!( - "materialized {agent} transcript {native_id} was not found" - )); - } - // A provider UUID is append-only after its first materialization. Claude - // and Codex may add compact checkpoints, encrypted context, queue rows, - // usage, or other native-only state between ORGII turns. Rewriting even - // an ORGII-created file from `complete_items` would destroy that state and - // make the provider compact the same conversation again. The TypeScript - // caller already proved the portable transcript is an exact semantic - // prefix, so append only its verified suffix for every existing UUID. - if !paths.native_path.is_file() && paths.runner_path.is_file() { - let parent = paths.native_path.parent().ok_or_else(|| { - format!( - "native transcript path has no parent: {}", - paths.native_path.display() - ) - })?; - fs::create_dir_all(parent) - .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; - fs::copy(&paths.runner_path, &paths.native_path).map_err(|err| { - format!( - "publish provider transcript {} -> {}: {err}", - paths.runner_path.display(), - paths.native_path.display() - ) - })?; - } - if !paths.native_path.is_file() { - return Err(format!( - "provider transcript {} was not found", - paths.native_path.display() - )); - } - replace_runner_link(&paths.native_path, &paths.runner_path)?; - match agent { - "claude_code" => { - // Validate and prepare Claude's index before mutating the JSONL, - // then keep ORGII index writers serialized until both commit. - let _index_guard = CLAUDE_PROJECT_INDEX_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let (index_path, index) = prepare_claude_project_index( - &cwd, - &native_id, - complete_items, - None, - session.branch.as_deref(), - )?; - 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; - let mut appended_suffix = None; - 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 original_len = fs::metadata(&paths.native_path) - .map_err(|error| { - format!( - "inspect native transcript {} before append: {error}", - paths.native_path.display() - ) - })? - .len(); - let payload = serialize_jsonl(&records)?; - append_jsonl_payload(&paths.native_path, &payload)?; - appended_suffix = Some((original_len, payload)); - } - if let Err(index_error) = atomic_json(&index_path, &index) { - if let Some((original_len, payload)) = appended_suffix { - if let Err(rollback_error) = - rollback_jsonl_suffix(&paths.native_path, original_len, &payload) - { - return Err(format!( - "{index_error}; additionally failed to roll back Claude transcript: {rollback_error}" - )); - } - } - return Err(index_error); - } - } - "codex" => { - let title = if session.name.trim().is_empty() { - first_user_title(complete_items) + first_user_title(complete_items) } else { session.name.clone() }; codex_native_catalog::synchronize_thread( + &app_paths::codex_cli_profile_dir(account_id.expect("Codex account checked")), &paths.native_path, &native_id, &cwd, @@ -2885,1892 +1591,325 @@ fn synchronize_cli( &codex_response_items(append_items), )?; } - _ => unreachable!("unsupported targets returned above"), - } - Ok(NativeMaterializationReceipt { - native_session_id: native_id, - item_count: complete_items.len(), - }) -} - -#[derive(Debug, Clone)] -struct CliNativePublicationContext { - session_id: String, - name: String, - model: Option, - branch: Option, - native_id: String, - cwd: PathBuf, - agent: String, - paths: NativeTranscriptPaths, -} - -fn cli_native_publication_context( - session_id: &str, -) -> Result, String> { - 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"))?; - cli_native_publication_context_from_session(session_id, session) -} - -fn cli_native_publication_context_from_session( - session_id: &str, - session: persistence::CodeSession, -) -> Result, String> { - 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(|err| format!("read native binding for {session_id}: {err}"))? - else { - return Ok(None); - }; - let cwd = execution_cwd(&session)?; - let agent = session.cli_agent_type.clone().unwrap_or_default(); - let paths = match agent.as_str() { - "claude_code" => claude_native_paths(account_id, &cwd, &native_id), - "codex" => { - let account_id = account_id - .ok_or_else(|| "native Codex catalog refresh 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"))? - } - _ => return Ok(None), - }; - Ok(Some(CliNativePublicationContext { - session_id: session.session_id, - name: session.name, - model: session.model, - branch: session.branch, - native_id, - cwd, - agent, - paths, - })) -} - -pub(super) fn freeze_cli_native_publication_context(session_id: &str) -> Result<(), String> { - let session = persistence::get_session(session_id); - let mut snapshots = ACTIVE_NATIVE_PUBLICATION_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - match session { - Ok(Some(session)) - if session.key_source == super::types::KeySource::OwnKey - && matches!( - session.cli_agent_type.as_deref(), - Some("claude_code" | "codex") - ) => - { - snapshots.insert(session_id.to_string(), session); - Ok(()) - } - Ok(Some(_)) => { - snapshots.remove(session_id); - Ok(()) - } - Ok(None) => { - snapshots.remove(session_id); - Err(format!("CLI session {session_id} does not exist")) - } - Err(err) => { - snapshots.remove(session_id); - Err(format!("load CLI session {session_id}: {err}")) - } - } -} - -pub(super) fn clear_cli_native_publication_context(session_id: &str) { - ACTIVE_NATIVE_PUBLICATION_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(session_id); -} - -fn take_cli_native_publication_context( - session_id: &str, -) -> Option { - ACTIVE_NATIVE_PUBLICATION_SESSIONS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(session_id) -} - -/// Copy a runner-replaced provider transcript into the real native App store. -/// -/// This is the only operation that must finish before a follow-up may replace -/// the runner. App catalog discovery is metadata and is intentionally kept out -/// of this boundary so Send Now / runtime switches never wait on app-server. -fn publish_cli_native_transcript_after_turn_blocking( - session_id: &str, - frozen: Option, -) -> Result, String> { - let context = match frozen { - Some(session) => cli_native_publication_context_from_session(session_id, session)?, - None => cli_native_publication_context(session_id)?, - }; - let Some(context) = context else { - return Ok(None); - }; - publish_runner_transcript(&context.paths, &context.native_id)?; - tracing::info!( - session_id, - native_session_id = context.native_id, - "published provider-native transcript" - ); - Ok(Some(context)) -} - -pub(super) async fn publish_cli_native_transcript_after_turn( - session_id: &str, -) -> Result { - // Take ownership before spawning blocking work. Context resolution, - // validation, filesystem publication, a panicking worker, or runtime - // shutdown can then fail without retaining a stale active-turn snapshot. - let frozen = take_cli_native_publication_context(session_id); - let session_id = session_id.to_string(); - let context = tokio::task::spawn_blocking(move || { - publish_cli_native_transcript_after_turn_blocking(&session_id, frozen) - }) - .await - .map_err(|error| format!("provider-native transcript snapshot task failed: {error}"))??; - if let Some(context) = context { - schedule_cli_native_catalog_refresh_context(context, None); - Ok(true) - } else { - Ok(false) - } -} - -fn refresh_cli_native_conversation_metadata( - context: &CliNativePublicationContext, - expected_provider: NativeCatalogProvider, - completed_turns_hint: Option, -) -> Result { - if context.agent != expected_provider.as_str() { - return Err(format!( - "native catalog snapshot provider {} does not match queued lane {}", - context.agent, - expected_provider.as_str() - )); - } - let published = match context.agent.as_str() { - "claude_code" => { - // Claude Code's own session index is part of the CLI-native - // transcript contract and must advance even when Claude Desktop - // is not installed, signed in, or able to accept its sidecar. - publish_claude_project_index( - &context.cwd, - &context.native_id, - &[], - completed_turns_hint, - context.branch.as_deref(), - )?; - let materialized_by_orgii = [&context.paths.native_path, &context.paths.runner_path] - .into_iter() - .any(|path| has_orgii_materialization_marker(path, "claude_code")); - publish_claude_desktop_session( - &context.cwd, - &context.native_id, - context.model.as_deref(), - Some(context.name.as_str()), - &[], - materialized_by_orgii, - completed_turns_hint, - )? - .is_some() - } - "codex" => { - let title = if context.name.trim().is_empty() { - "Imported conversation" - } else { - context.name.as_str() - }; - let entry = codex_native_catalog::refresh_catalog( - &context.paths.native_path, - &context.native_id, - &context.cwd, - title, - )?; - entry.id == context.native_id && paths_match(&entry.cwd, &context.cwd) - } - _ => false, - }; - tracing::info!( - session_id = %context.session_id, - native_session_id = %context.native_id, - published, - completed_turns_hint = ?completed_turns_hint, - "refreshed provider-native conversation metadata" - ); - Ok(published) -} - -/// Refresh native App discovery after the runner transcript was safely -/// published by `publish_cli_native_transcript_after_turn`. -fn refresh_cli_native_conversation_after_turn( - context: CliNativePublicationContext, - provider: NativeCatalogProvider, - completed_turns_hint: Option, -) -> Result { - // Ordinary final/cancel paths do not carry the materializer's absolute - // count. Resolve it once per coalesced background refresh, then reuse the - // same value across retries so Claude Desktop and projects.json advance - // after every native turn without repeated full-file reads. - let completed_turns_hint = if provider == NativeCatalogProvider::ClaudeCode - && completed_turns_hint.is_none() - { - let path = preferred_materialized_transcript_path(&context.paths).ok_or_else(|| { - format!( - "Claude transcript {} has no readable native copy", - context.native_id - ) - })?; - Some(claude_completed_turns_from_transcript(path)?) - } else { - completed_turns_hint - }; - let mut last_error = None; - for attempt in 0..=NATIVE_CATALOG_REFRESH_BACKOFFS.len() { - match refresh_cli_native_conversation_metadata(&context, provider, completed_turns_hint) { - Ok(true) => return Ok(true), - Ok(false) => { - if let Some(delay) = NATIVE_CATALOG_REFRESH_BACKOFFS.get(attempt) { - std::thread::sleep(*delay); - continue; - } - return Ok(false); - } - Err(error) => { - last_error = Some(error); - if let Some(delay) = NATIVE_CATALOG_REFRESH_BACKOFFS.get(attempt) { - std::thread::sleep(*delay); - } - } - } - } - Err(last_error.unwrap_or_else(|| { - format!( - "provider-native catalog refresh failed for {}", - context.session_id - ) - })) -} - -fn native_catalog_refresh_is_current(context: &CliNativePublicationContext) -> bool { - matches!( - cli_native_publication_context(&context.session_id), - Ok(Some(current)) - if current.agent == context.agent - && current.native_id == context.native_id - && current.paths.native_path == context.paths.native_path - && current.paths.runner_path == context.paths.runner_path - ) -} - -/// Coalesce slow native App discovery behind a background boundary. Transcript -/// durability is handled synchronously before this is scheduled; catalog -/// availability may catch up without extending the provider turn or blocking -/// the next message. -fn schedule_cli_native_catalog_refresh_with_hint( - session_id: &str, - agent: &str, - completed_turns_hint: Option, -) { - let Some(provider) = NativeCatalogProvider::from_agent(agent) else { - tracing::warn!( - session_id, - agent, - "ignored catalog refresh for unsupported provider" - ); - return; - }; - let context = match cli_native_publication_context(session_id) { - Ok(Some(context)) if context.agent == provider.as_str() => context, - Ok(Some(context)) => { - tracing::warn!( - session_id, - requested_provider = provider.as_str(), - snapshot_provider = context.agent, - snapshot_native_session_id = context.native_id, - "ignored stale native catalog refresh after a runtime switch" - ); - return; - } - Ok(None) => { - tracing::warn!( - session_id, - provider = provider.as_str(), - "ignored native catalog refresh without a native binding" - ); - return; - } - Err(error) => { - tracing::warn!( - session_id, - provider = provider.as_str(), - error = %error, - "failed to capture provider-native catalog snapshot" - ); - return; - } - }; - schedule_cli_native_catalog_refresh_context(context, completed_turns_hint); -} - -fn schedule_cli_native_catalog_refresh_context( - context: CliNativePublicationContext, - completed_turns_hint: Option, -) { - let Some(provider) = NativeCatalogProvider::from_agent(&context.agent) else { - return; - }; - let should_spawn = NATIVE_CATALOG_REFRESH_QUEUE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .lane_mut(provider) - .enqueue(provider, context, completed_turns_hint); - if !should_spawn { - return; - } - tokio::spawn(async move { - loop { - let next = { - let mut queue = NATIVE_CATALOG_REFRESH_QUEUE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - queue.lane_mut(provider).take_next() - }; - let Some(request) = next else { - return; - }; - let session_id = request.context.session_id.clone(); - // The managed session is the live owner of this native UUID. Do - // not await a busy identity inside the one-per-provider worker: - // one long turn would head-of-line block every other session. - let identity_lock = super::session_runner::session_identity_lock(&session_id).await; - let native_identity_guard = match identity_lock.clone().try_lock_owned() { - Ok(guard) => guard, - Err(_) => { - let (key, should_spawn_waiter) = { - let mut queue = NATIVE_CATALOG_REFRESH_QUEUE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - queue - .lane_mut(provider) - .defer_until_identity_available(provider, request) - }; - if should_spawn_waiter { - tokio::spawn(async move { - // Await the lifecycle edge without polling, then - // release immediately so a queued user turn is not - // held behind metadata publication. - let identity_guard = identity_lock.lock_owned().await; - drop(identity_guard); - let deferred = { - let mut queue = NATIVE_CATALOG_REFRESH_QUEUE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - queue.lane_mut(provider).take_deferred(&key) - }; - if let Some(request) = deferred { - schedule_cli_native_catalog_refresh_context( - request.context, - request.completed_turns_hint, - ); - } - }); - } - continue; - } - }; - let result = tokio::task::spawn_blocking(move || { - let _native_identity_guard = native_identity_guard; - // A queued request is only a projection hint. Delete, - // truncate, discard, or a runtime/account switch may replace - // the binding while it waits; never resurrect that stale UUID - // in a provider App catalog. - if !native_catalog_refresh_is_current(&request.context) { - tracing::info!( - session_id, - native_session_id = %request.context.native_id, - "discarded stale provider-native catalog refresh" - ); - return; - } - match refresh_cli_native_conversation_after_turn( - request.context, - provider, - request.completed_turns_hint, - ) { - Ok(true) => {} - Ok(false) => tracing::warn!( - session_id, - "provider-native App catalog is unavailable; CLI transcript remains resumable" - ), - Err(error) => tracing::warn!( - session_id, - error = %error, - "failed to refresh provider-native App catalog" - ), - } - }) - .await; - if let Err(error) = result { - tracing::warn!( - error = %error, - "provider-native App catalog worker failed" - ); - } - } - }); -} - -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"))?; - agent_core::session::persistence::append_session_with_messages( - session_id, - &native_agent_messages(session_id, append_items), - ) - .map_err(|err| format!("append native Agent transcript {session_id}: {err}"))?; - Ok(NativeMaterializationReceipt { - native_session_id: session_id.to_string(), - item_count: complete_items.len(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -pub async fn materialize_native_conversation( - session_id: String, - items: Vec, -) -> Result { - validate_items(&items)?; - // Move both guards into the blocking mutation. If the IPC future is - // cancelled after spawning, the filesystem/DB work stays serialized until - // it actually finishes instead of racing a follow-up or catalog refresh. - let mutation_guards = lock_idle_native_mutation(&session_id).await?; - let receipt = tokio::task::spawn_blocking(move || { - let _mutation_guards = mutation_guards; - if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { - materialize_cli(&session_id, &items) - } else { - materialize_native_agent(&session_id, &items) - } - }) - .await - .map_err(|err| format!("native materialization task failed: {err}"))??; - Ok(receipt) -} - -#[tauri::command(rename_all = "camelCase")] -pub async fn synchronize_native_conversation( - session_id: String, - complete_items: Vec, - prefix_item_count: usize, -) -> Result { - validate_items(&complete_items)?; - if prefix_item_count >= complete_items.len() { - return Err("native transcript synchronization requires a non-empty suffix".to_string()); - } - let mutation_guards = lock_idle_native_mutation(&session_id).await?; - let receipt = tokio::task::spawn_blocking(move || { - let _mutation_guards = mutation_guards; - // The TypeScript caller has already verified semantic prefix growth. - // Derive the append-only suffix from the one complete IPC payload so - // large conversations are not cloned and decoded twice. - let append_items = &complete_items[prefix_item_count..]; - 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) - } - }) - .await - .map_err(|err| format!("native synchronization task failed: {err}"))??; - Ok(receipt) -} - -/// Commit App discovery only after the frontend has round-tripped and -/// semantically verified the newly materialized provider transcript. Keeping -/// this separate from the write IPC prevents a failed verification + discard -/// from racing a background metadata worker that would recreate a ghost -/// catalog entry. -#[tauri::command(rename_all = "camelCase")] -pub async fn commit_native_conversation_materialization( - session_id: String, - native_session_id: String, -) -> Result { - if !session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { - return Ok(false); - } - let _mutation_guards = lock_idle_native_mutation(&session_id).await?; - let Some(context) = cli_native_publication_context(&session_id)? else { - return Ok(false); - }; - if context.native_id != native_session_id { - return Err(format!( - "native materialization binding changed before commit: expected {native_session_id}, found {}", - context.native_id - )); - } - if context.agent != "claude_code" { - return Ok(false); - } - // This call freezes the same context while the session control lock is - // still held; later account/model patches cannot retarget the worker. - schedule_cli_native_catalog_refresh_with_hint( - &session_id, - &context.agent, - None, - ); - Ok(true) -} - -#[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; - - fn create_claude_session(session_id: &str, account_id: &str) { - create_claude_session_with_account(session_id, Some(account_id)); - } - - fn create_claude_session_with_account(session_id: &str, account_id: Option<&str>) { - persistence::create_session( - session_id, - &persistence::CreateCodeSessionParams { - name: Some("native synchronization test".to_string()), - flow: None, - runner: None, - cli_agent_type: "claude_code".to_string(), - model: Some("claude-sonnet-4-6".to_string()), - tier: None, - account_id: account_id.map(str::to_string), - repo_path: Some("/repo".to_string()), - branch: None, - worktree_path: None, - worktree_base_ref: None, - proxy_token: None, - proxy_url: None, - hosted_token: None, - proxy_session_id: None, - isolate: Some(false), - 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: Some("build".to_string()), - }, - ) - .expect("create Claude CLI session"); - } - - fn create_codex_session(session_id: &str, account_id: &str, repo_path: &Path) { - persistence::create_session( - session_id, - &persistence::CreateCodeSessionParams { - name: Some("native Codex synchronization test".to_string()), - flow: None, - runner: None, - cli_agent_type: "codex".to_string(), - model: Some("gpt-5.4".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: Some(false), - 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: Some("build".to_string()), - }, - ) - .expect("create Codex CLI session"); - let profile = app_paths::codex_cli_profile_dir(account_id); - fs::create_dir_all(&profile).expect("create Codex test profile"); - fs::write(profile.join("config.toml"), "model_provider = \"openai\"\n") - .expect("write Codex test profile"); - } - - fn message() -> NativeConversationItem { - NativeConversationItem::Message { - id: "u1".to_string(), - role: "user".to_string(), - text: "hello".to_string(), - images: Vec::new(), - created_at: "2026-08-26T00:00:00Z".to_string(), - turn_id: None, - } - } - - fn assistant_message() -> NativeConversationItem { - NativeConversationItem::Message { - id: "a1".to_string(), - role: "assistant".to_string(), - text: "done".to_string(), - images: Vec::new(), - created_at: "2026-08-26T00:00:03Z".to_string(), - turn_id: None, - } - } - - #[test] - fn native_materialization_rejects_duplicate_canonical_item_ids() { - let duplicate = message(); - let error = validate_items(&[duplicate.clone(), duplicate]) - .expect_err("duplicate canonical ids must fail closed"); - assert!(error.contains("duplicate canonical item id")); - } - - #[test] - fn diverged_transcript_prefers_only_the_provably_newer_copy() { - 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, "native").expect("write native transcript"); - fs::write(&paths.runner_path, "runner").expect("write runner transcript"); - let base = std::time::SystemTime::now() - std::time::Duration::from_secs(120); - std::fs::File::options() - .write(true) - .open(&paths.native_path) - .expect("open native transcript") - .set_modified(base) - .expect("set native mtime"); - std::fs::File::options() - .write(true) - .open(&paths.runner_path) - .expect("open runner transcript") - .set_modified(base + std::time::Duration::from_secs(1)) - .expect("set runner mtime"); - assert_eq!( - preferred_materialized_transcript_path(&paths), - Some(paths.runner_path.as_path()) - ); - - std::fs::File::options() - .write(true) - .open(&paths.native_path) - .expect("reopen native transcript") - .set_modified(base + std::time::Duration::from_secs(2)) - .expect("advance native mtime"); - assert_eq!( - preferred_materialized_transcript_path(&paths), - Some(paths.native_path.as_path()) - ); - } - - #[test] - fn claude_materialization_is_native_role_history() { - let records = claude_records( - "00000000-0000-4000-8000-000000000001", - Path::new("/repo"), - &[message()], - ) - .expect("claude records"); - assert_eq!(records[0]["type"], "user"); - assert_eq!(records[0]["message"]["role"], "user"); - assert_eq!(records[0]["message"]["content"], "hello"); - assert_eq!(records[0]["entrypoint"], "orgii"); - assert_eq!(records[0]["orgiiMaterialization"], true); - let assistant = claude_records( - "00000000-0000-4000-8000-000000000001", - Path::new("/repo"), - &[assistant_message()], - ) - .expect("claude assistant records"); - assert_eq!(assistant[0]["message"]["content"][0]["type"], "text"); - assert_eq!(assistant[0]["message"]["content"][0]["text"], "done"); - } - - #[test] - fn claude_suffix_inspection_distinguishes_missing_applied_and_mixed() { - let sandbox = test_env::sandbox(); - let path = sandbox.path().join("claude-suffix.jsonl"); - let expected = claude_records( - "00000000-0000-4000-8000-000000000001", - Path::new("/repo"), - &[message(), assistant_message()], - ) - .expect("project Claude suffix"); - - atomic_jsonl(&path, &[json!({"type": "last-prompt", "leafUuid": "prior"})]) - .expect("write prefix"); - assert_eq!( - inspect_claude_suffix_application(&path, &expected) - .expect("inspect missing suffix") - .0, - NativeSuffixApplication::Missing - ); - - append_jsonl(&path, std::slice::from_ref(&expected[0])).expect("append mixed suffix"); - assert!(inspect_claude_suffix_application(&path, &expected).is_err()); - - atomic_jsonl(&path, &expected).expect("write complete suffix"); - assert_eq!( - inspect_claude_suffix_application(&path, &expected) - .expect("inspect applied suffix") - .0, - NativeSuffixApplication::AlreadyApplied - ); - } - - #[test] - fn claude_active_leaf_prefers_the_latest_branch_checkpoint_or_newer_partial_record() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-active-leaf-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let path = temp_dir.join("session.jsonl"); - atomic_jsonl( - &path, - &[ - json!({"type": "assistant", "uuid": "native-leaf-1"}), - json!({ - "type": "last-prompt", - "lastPrompt": "first turn", - "leafUuid": "native-leaf-1", - "sessionId": "native-session" - }), - json!({"type": "mode", "mode": "build"}), - ], - ) - .expect("write native checkpoint fixture"); - assert_eq!( - claude_active_leaf_uuid(&path).as_deref(), - Some("native-leaf-1") - ); - - append_jsonl( - &path, - &[json!({ - "type": "assistant", - "uuid": "interrupted-partial-leaf", - "parentUuid": "native-leaf-1" - })], - ) - .expect("append partial native turn"); - assert_eq!( - claude_active_leaf_uuid(&path).as_deref(), - Some("interrupted-partial-leaf"), - "a partial provider record written after the last checkpoint is the active branch" - ); - - fs::remove_dir_all(temp_dir).expect("remove active leaf fixture"); - } - - #[test] - fn claude_materialization_round_trips_through_the_existing_reader() { - let native_id = "00000000-0000-4000-8000-000000000001"; - let items = vec![ - message(), - NativeConversationItem::ToolCall { - id: "tool-1:call".to_string(), - call_id: "call-1".to_string(), - name: "read_file".to_string(), - arguments: r#"{"path":"/repo/README.md"}"#.to_string(), - created_at: "2026-08-26T00:00:01Z".to_string(), - }, - NativeConversationItem::ToolResult { - id: "tool-1:result".to_string(), - call_id: "call-1".to_string(), - name: "read_file".to_string(), - output: "contents".to_string(), - created_at: "2026-08-26T00:00:02Z".to_string(), - }, - assistant_message(), - NativeConversationItem::Compaction { - id: "compact-1".to_string(), - summary: "Native compact summary".to_string(), - created_at: "2026-08-26T00:00:04Z".to_string(), - }, - ]; - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-roundtrip-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let path = temp_dir.join(format!("{native_id}.jsonl")); - atomic_jsonl( - &path, - &claude_records(native_id, Path::new("/repo"), &items) - .expect("build native Claude transcript"), - ) - .expect("write native Claude transcript"); - - let chunks = - orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( - "claudecodeapp-native-roundtrip", - &path, - ) - .expect("read native Claude transcript"); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "user_message") - .count(), - 1 - ); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "assistant") - .count(), - 1 - ); - let tool = chunks - .iter() - .find(|chunk| chunk.action_type == "tool_call") - .expect("tool call"); - assert_eq!(tool.args["path"], "/repo/README.md"); - assert_eq!(tool.result["output"], "contents"); - let compact = chunks - .iter() - .find(|chunk| chunk.function == "context_compacted") - .expect("native compact boundary"); - assert_eq!(compact.result["observation"], "Native compact summary"); - - std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); - } - - #[test] - fn codex_app_server_projection_marks_user_rows_for_native_replay() { - let items = codex_response_items(&[ - message(), - NativeConversationItem::ToolCall { - id: "tool-1:call".to_string(), - call_id: "call-1".to_string(), - name: "grep".to_string(), - arguments: r#"{"pattern":"needle"}"#.to_string(), - created_at: "2026-08-26T00:00:01Z".to_string(), - }, - NativeConversationItem::ToolResult { - id: "tool-1:result".to_string(), - call_id: "call-1".to_string(), - name: "grep".to_string(), - output: "match".to_string(), - created_at: "2026-08-26T00:00:02Z".to_string(), - }, - ]); - assert_eq!(items.len(), 3); - assert!( - items[0]["internal_chat_message_metadata_passthrough"]["turn_id"] - .as_str() - .is_some_and(|turn_id| turn_id.starts_with("orgii-materialization-")) - ); - assert_eq!(items[1]["call_id"], "call-1"); - assert_eq!(items[2]["call_id"], "call-1"); - let arguments = - serde_json::from_str::(items[1]["arguments"].as_str().expect("arguments")) - .expect("marked arguments"); - assert_eq!(arguments["pattern"], "needle"); - assert_eq!(arguments["__orgiiMaterializedNative"], true); - } - - #[test] - fn codex_materialization_marker_uses_fields_preserved_by_app_server() { - let projected = codex_response_items(&[ - message(), - NativeConversationItem::ToolCall { - id: "tool-1:call".to_string(), - call_id: "call-1".to_string(), - name: "grep".to_string(), - arguments: r#"{"pattern":"needle"}"#.to_string(), - created_at: "2026-08-26T00:00:01Z".to_string(), - }, - ]); - let user_record = json!({"type": "response_item", "payload": projected[0]}); - let tool_record = json!({"type": "response_item", "payload": projected[1]}); - - assert!(codex_record_has_orgii_materialization_marker(&user_record)); - assert!(codex_record_has_orgii_materialization_marker(&tool_record)); - assert!(!codex_record_has_orgii_materialization_marker(&json!({ - "type": "response_item", - "payload": { - "type": "function_call", - "arguments": "{\"pattern\":\"needle\"}" - } - }))); - } - - #[test] - fn codex_app_server_projection_uses_supported_native_compaction_items() { - let items = codex_response_items(&[NativeConversationItem::Compaction { - id: "compact-1".to_string(), - summary: "Canonical compact summary".to_string(), - created_at: "2026-08-31T00:00:00Z".to_string(), - }]); - - assert_eq!(items.len(), 2); - assert_eq!(items[0]["type"], "message"); - assert_eq!(items[0]["role"], "assistant"); - assert_eq!(items[1]["type"], "context_compaction"); - assert!(items[1]["encrypted_content"].is_null()); - let summary_turn_id = items[0]["internal_chat_message_metadata_passthrough"]["turn_id"] - .as_str() - .expect("materialized compact summary marker"); - let compact_turn_id = items[1]["internal_chat_message_metadata_passthrough"]["turn_id"] - .as_str() - .expect("materialized compact boundary marker"); - assert_eq!(summary_turn_id, compact_turn_id); - assert!(summary_turn_id.starts_with("orgii-materialized-compaction:")); - assert!(!items.iter().any(|item| item["role"] == "user")); - } - - #[test] - fn claude_synchronization_preserves_native_compact_state_and_uuid() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-sync"; - let account_id = "native-sync-account"; - create_claude_session(session_id, account_id); - let prefix = vec![message(), assistant_message()]; - let first = materialize_cli(session_id, &prefix).expect("materialize Claude prefix"); - let paths = claude_native_paths( - Some(account_id), - Path::new("/repo"), - &first.native_session_id, - ); - append_jsonl( - &paths.native_path, - &[ - json!({ - "type": "system", - "subtype": "compact_boundary", - "uuid": "provider-compact-boundary", - "parentUuid": Value::Null, - "sessionId": first.native_session_id.clone(), - "timestamp": "2026-08-26T00:00:03.500Z", - "compactMetadata": {"trigger": "auto"} - }), - json!({ - "type": "user", - "uuid": "provider-compact-summary", - "parentUuid": "provider-compact-boundary", - "isCompactSummary": true, - "sessionId": first.native_session_id.clone(), - "timestamp": "2026-08-26T00:00:03.500Z", - "message": {"role": "user", "content": "provider-native summary sentinel"} - }), - json!({ - "type": "last-prompt", - "lastPrompt": "hello", - "leafUuid": "provider-compact-summary", - "sessionId": first.native_session_id.clone() - }), - json!({ - "type": "mode", - "mode": "build", - "sessionId": first.native_session_id.clone() - }), - ], - ) - .expect("append provider-native Claude compact state"); - let remote_user = NativeConversationItem::Message { - id: "u2".to_string(), - role: "user".to_string(), - text: "remote canonical delta".to_string(), - images: Vec::new(), - created_at: "2026-08-26T00:00:04Z".to_string(), - turn_id: None, - }; - let complete = vec![message(), assistant_message(), remote_user]; - let second = synchronize_cli(session_id, &complete, &complete[2..]) - .expect("synchronize Claude native history"); - - assert_eq!(second.native_session_id, first.native_session_id); - assert_eq!(second.item_count, complete.len()); - let path = &paths.runner_path; - assert!(paths.native_path.is_file()); - #[cfg(unix)] - assert_eq!( - fs::read_link(path).expect("runner transcript symlink"), - paths.native_path - ); - let records = fs::read_to_string(path) - .expect("read synchronized Claude JSONL") - .lines() - .map(|line| serde_json::from_str::(line).expect("decode Claude record")) - .collect::>(); - let user_messages = records - .iter() - .filter(|record| { - record["type"] == "user" && record["isCompactSummary"] != Value::Bool(true) - }) - .map(|record| record["message"]["content"].as_str().unwrap_or_default()) - .collect::>(); - assert_eq!(user_messages, vec!["hello", "remote canonical delta"]); - assert!(records.iter().any(|record| { - record["subtype"] == "compact_boundary" && record["uuid"] == "provider-compact-boundary" - })); - assert!(records.iter().any(|record| { - record["isCompactSummary"] == true - && record["message"]["content"] == "provider-native summary sentinel" - })); - let appended_user = records - .iter() - .find(|record| record["message"]["content"] == "remote canonical delta") - .expect("appended canonical suffix"); - assert_eq!(appended_user["parentUuid"], "provider-compact-summary"); - let appended_user_uuid = appended_user["uuid"] - .as_str() - .expect("materialized user uuid"); - let resume_checkpoint = records - .iter() - .rev() - .find(|record| record["type"] == "last-prompt") - .expect("materialized resume checkpoint"); - assert_eq!(resume_checkpoint["leafUuid"], appended_user_uuid); - assert_eq!(resume_checkpoint["lastPrompt"], "remote canonical delta"); - assert_eq!( - claude_active_leaf_uuid(&paths.native_path).as_deref(), - Some(appended_user_uuid), - "the next native --resume must attach to the remote canonical suffix" - ); - - let chunks = - orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( - "claudecodeapp-native-sync-roundtrip", - path, - ) - .expect("round-trip synchronized Claude transcript"); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "user_message") - .count(), - 2 - ); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "context_compacted") - .count(), - 1 - ); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "assistant") - .map(|chunk| chunk.result["observation"].as_str().unwrap_or_default()) - .collect::>(), - vec!["done"] - ); - assert_eq!( - fs::read_to_string(path).expect("read runner transcript"), - fs::read_to_string(&paths.native_path).expect("read provider transcript") - ); - } - - #[test] - fn claude_synchronization_rolls_back_jsonl_when_project_index_is_invalid() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-index-rollback"; - let account_id = "native-index-rollback-account"; - create_claude_session(session_id, account_id); - let prefix = vec![message(), assistant_message()]; - let first = materialize_cli(session_id, &prefix).expect("materialize Claude prefix"); - let paths = claude_native_paths( - Some(account_id), - Path::new("/repo"), - &first.native_session_id, - ); - let before = fs::read(&paths.native_path).expect("read prefix transcript"); - let index_path = claude_native_paths(None, Path::new("/repo"), &first.native_session_id) - .native_path - .parent() - .expect("Claude project directory") - .join("sessions-index.json"); - atomic_json( - &index_path, - &json!({"version": 1, "entries": "not-an-array"}), - ) - .expect("poison project index shape"); - - let suffix = NativeConversationItem::Message { - id: "u2".to_string(), - role: "user".to_string(), - text: "must roll back".to_string(), - images: Vec::new(), - created_at: "2026-08-26T00:00:04Z".to_string(), - turn_id: None, - }; - let complete = vec![message(), assistant_message(), suffix]; - let error = synchronize_cli(session_id, &complete, &complete[2..]) - .expect_err("invalid project index must fail synchronization"); - - assert!(error.contains("entries are not an array")); - assert_eq!( - fs::read(&paths.native_path).expect("read rolled-back transcript"), - before, - "a failed index update must not leave the canonical suffix appended" - ); - } - - #[test] - fn codex_synchronization_preserves_native_compact_state_and_uuid() { - let sandbox = test_env::sandbox(); - let _catalog = codex_native_catalog::use_direct_test_catalog(); - let session_id = "cliagent-native-codex-sync"; - let account_id = "native-codex-sync-account"; - let repo_path = sandbox.path().join("repo"); - fs::create_dir_all(&repo_path).expect("create native Codex test workspace"); - create_codex_session(session_id, account_id, &repo_path); - let prefix = vec![message(), assistant_message()]; - let first = materialize_cli(session_id, &prefix).expect("materialize Codex prefix"); - let paths = existing_codex_native_paths(account_id, &first.native_session_id) - .expect("materialized Codex paths"); - append_jsonl( - &paths.native_path, - &[json!({ - "timestamp": "2026-08-26T00:00:03.500Z", - "type": "compacted", - "payload": { - "message": "", - "replacement_history": [{ - "item": { - "type": "compaction", - "encrypted_content": "provider-native-encrypted-sentinel" - } - }], - "window_number": 2, - "first_window_id": "provider-window-1", - "previous_window_id": "provider-window-1", - "window_id": "provider-window-2" - } - })], - ) - .expect("append provider-native Codex compact state"); - let remote_user = NativeConversationItem::Message { - id: "u2".to_string(), - role: "user".to_string(), - text: "remote canonical delta".to_string(), - images: Vec::new(), - created_at: "2026-08-26T00:00:04Z".to_string(), - turn_id: None, - }; - let complete = vec![message(), assistant_message(), remote_user]; - - let second = synchronize_cli(session_id, &complete, &complete[2..]) - .expect("synchronize Codex native history"); - - assert_eq!(second.native_session_id, first.native_session_id); - assert_eq!(second.item_count, complete.len()); - let raw = fs::read_to_string(&paths.native_path).expect("read synchronized Codex JSONL"); - assert!(raw.contains("provider-native-encrypted-sentinel")); - assert!(raw.contains("remote canonical delta")); - let chunks = orgtrack_core::sources::codex::app::load_codex_app_from_path( - "codexapp-native-sync-roundtrip", - &paths.native_path, - ) - .expect("round-trip synchronized Codex 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!["hello", "remote canonical delta"]); - let assistant_messages = chunks - .iter() - .filter(|chunk| chunk.function == "assistant") - .map(|chunk| chunk.result["observation"].as_str().unwrap_or_default()) - .collect::>(); - assert_eq!(assistant_messages, vec!["done"]); - assert_eq!( - chunks - .iter() - .filter(|chunk| chunk.function == "context_compacted") - .count(), - 1 - ); - assert_eq!( - fs::read_to_string(&paths.runner_path).expect("read Codex runner transcript"), - raw - ); - } - - #[test] - fn synchronization_migrates_a_managed_only_transcript_to_the_app_store() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-managed-migration"; - let account_id = "native-managed-migration-account"; - let native_id = "00000000-0000-4000-8000-000000000088"; - create_claude_session(session_id, account_id); - assert!(persistence::update_cli_session_id_for_account( - session_id, - Some(account_id), - native_id, - ) - .expect("bind legacy native transcript")); - let paths = claude_native_paths(Some(account_id), Path::new("/repo"), native_id); - atomic_jsonl( - &paths.runner_path, - &claude_records(native_id, Path::new("/repo"), &[message()]) - .expect("legacy Claude records"), - ) - .expect("write managed-only transcript"); - assert!(!paths.native_path.exists()); - - let complete = [message(), assistant_message()]; - synchronize_cli(session_id, &complete, &complete[1..]) - .expect("migrate and synchronize native transcript"); - - assert!(paths.native_path.is_file()); - #[cfg(unix)] - assert_eq!( - fs::read_link(&paths.runner_path).expect("migrated runner symlink"), - paths.native_path - ); - assert!(fs::read_to_string(&paths.native_path) - .expect("read migrated app transcript") - .contains("done")); - } - - #[test] - fn discard_removes_both_native_paths() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-discard"; - let account_id = "native-discard-account"; - create_claude_session(session_id, account_id); - let receipt = materialize_cli(session_id, &[message()]).expect("materialize transcript"); - let paths = claude_native_paths( - Some(account_id), - Path::new("/repo"), - &receipt.native_session_id, - ); - assert!(paths.native_path.is_file()); - assert!(fs::symlink_metadata(&paths.runner_path).is_ok()); - - assert!( - discard_cli_materialization(session_id, &receipt.native_session_id) - .expect("discard transcript") - ); - assert!(fs::symlink_metadata(&paths.native_path).is_err()); - assert!(fs::symlink_metadata(&paths.runner_path).is_err()); - } - - #[test] - fn provider_store_jsonl_keeps_the_runner_on_the_same_native_file() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-visible-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let paths = NativeTranscriptPaths { - native_path: temp_dir.join("provider/session.jsonl"), - runner_path: temp_dir.join("runner/session.jsonl"), - }; - write_native_store_jsonl(&paths, &[json!({"generation": 1})]) - .expect("write initial app-visible transcript"); - #[cfg(unix)] - assert_eq!( - fs::read_link(&paths.runner_path).expect("runner transcript symlink"), - paths.native_path - ); - assert_eq!( - fs::read_to_string(&paths.native_path).expect("read provider transcript"), - fs::read_to_string(&paths.runner_path).expect("read runner transcript") - ); - - write_native_store_jsonl(&paths, &[json!({"generation": 2})]) - .expect("replace app-visible transcript"); - assert!(fs::read_to_string(&paths.runner_path) - .expect("read replaced runner transcript") - .contains("\"generation\":2")); - #[cfg(unix)] - assert_eq!( - fs::read_link(&paths.runner_path).expect("replaced runner transcript symlink"), - paths.native_path - ); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); + _ => unreachable!("unsupported targets returned above"), } + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: complete_items.len(), + }) +} - #[test] - fn provider_refresh_republishes_a_runner_replaced_codex_link() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-runner-publish-{}-{}", - std::process::id(), - Uuid::new_v4().simple() +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() )); - let paths = NativeTranscriptPaths { - native_path: temp_dir.join("provider/session.jsonl"), - runner_path: temp_dir.join("runner/session.jsonl"), - }; - write_native_store_jsonl(&paths, &[json!({"generation": 1})]) - .expect("write initial provider transcript"); - fs::remove_file(&paths.runner_path).expect("remove runner symlink"); - atomic_jsonl( - &paths.runner_path, - &[json!({"generation": 2, "sessionId": "native-publish"})], - ) - .expect("simulate Codex replacing the runner link"); - - publish_runner_transcript(&paths, "native-publish").expect("publish runner transcript"); - - assert!(fs::read_to_string(&paths.native_path) - .expect("read republished provider transcript") - .contains("\"generation\":2")); - #[cfg(unix)] - assert_eq!( - fs::read_link(&paths.runner_path).expect("restored runner symlink"), - paths.native_path - ); - fs::remove_dir_all(temp_dir).expect("remove temp dir"); } + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }) +} - #[test] - fn ambient_claude_uses_the_official_profile_without_an_alias() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-ambient"; - create_claude_session_with_account(session_id, None); +#[tauri::command(rename_all = "camelCase")] +pub async fn materialize_native_conversation( + session_id: String, + items: Vec, +) -> Result { + validate_items(&items)?; + // Move both guards into the blocking mutation. If the IPC future is + // cancelled after spawning, the filesystem/DB work stays serialized until + // it actually finishes instead of racing a follow-up. + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let receipt = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + materialize_cli(&session_id, &items) + } else { + materialize_native_agent(&session_id, &items) + } + }) + .await + .map_err(|err| format!("native materialization task failed: {err}"))??; + Ok(receipt) +} - let receipt = materialize_cli(session_id, &[message()]) - .expect("materialize through ambient Claude profile"); - let paths = claude_native_paths(None, Path::new("/repo"), &receipt.native_session_id); +#[tauri::command(rename_all = "camelCase")] +pub async fn synchronize_native_conversation( + session_id: String, + complete_items: Vec, +) -> Result { + validate_items(&complete_items)?; + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let receipt = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + 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. Seed + // the provider's real role/tool transcript from the complete + // canonical history while this same native-mutation guard is + // still held. Subsequent synchronizations use the bound UUID + // and the strict prefix/suffix path below. + return materialize_cli(&session_id, &complete_items); + } + } + let prefix_item_count = authoritative_prefix_len(&session_id, &complete_items)?; + if prefix_item_count == complete_items.len() { + let native_session_id = 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"))?; + persistence::get_cli_session_id_for_account( + &session_id, + session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()), + ) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} has no native resume binding"))? + } else { + session_id.clone() + }; + return Ok(NativeMaterializationReceipt { + native_session_id, + item_count: complete_items.len(), + }); + } + // Rust has read the exact bound provider transcript and verified its + // portable role/tool projection. Derive the append-only suffix only + // after that authoritative check; the renderer is never trusted with + // the mutation boundary. + let append_items = &complete_items[prefix_item_count..]; + 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) + } + }) + .await + .map_err(|err| format!("native synchronization task failed: {err}"))??; + Ok(receipt) +} - assert_eq!(paths.runner_path, paths.native_path); - assert!(paths.native_path.is_file()); - assert_eq!( - persistence::get_cli_session_id_for_account(session_id, None) - .expect("read ambient native binding") - .as_deref(), - Some(receipt.native_session_id.as_str()) - ); - assert!( - discard_cli_materialization(session_id, &receipt.native_session_id) - .expect("discard ambient transcript") - ); - assert!(!paths.native_path.exists()); - } +#[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 +} - #[test] - fn claude_cli_materialization_does_not_require_a_desktop_catalog() { - let _sandbox = test_env::sandbox(); - let session_id = "cliagent-native-claude-no-desktop"; - let account_id = "native-no-desktop-account"; - create_claude_session(session_id, account_id); - assert!( - claude_desktop_sessions_roots() - .iter() - .all(|root| !root.exists()), - "sandbox must not contain provider-owned Claude Desktop metadata" - ); +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_env; - let receipt = materialize_cli(session_id, &[message()]) - .expect("Claude CLI materialization must not depend on Desktop"); - let paths = claude_native_paths( - Some(account_id), - Path::new("/repo"), - &receipt.native_session_id, - ); - assert!(paths.native_path.is_file()); - let project_index = - claude_native_paths(None, Path::new("/repo"), &receipt.native_session_id) - .native_path - .parent() - .expect("Claude project directory") - .join("sessions-index.json"); - assert!( - project_index.is_file(), - "CLI project index is the success boundary" - ); - assert!( - claude_desktop_sessions_roots() - .iter() - .all(|root| !root.exists()), - "CLI materialization must not synthesize a Desktop catalog" - ); + 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, + } } - #[test] - fn claude_desktop_sidecar_registers_the_same_cli_session() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-desktop-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let sessions_root = temp_dir.join("claude-code-sessions"); - let project_dir = sessions_root.join("organization").join("project"); - let cwd = temp_dir.join("repo"); - fs::create_dir_all(&cwd).expect("create repo"); - atomic_json( - &project_dir.join("local-existing.json"), - &json!({ - "sessionId": "local-existing", - "cliSessionId": "existing", - "cwd": cwd, - "lastActivityAt": 1 - }), - ) - .expect("seed Claude Desktop project"); - - let native_id = "00000000-0000-4000-8000-000000000099"; - let sidecar = publish_claude_desktop_session_at( - &sessions_root, - &cwd, - native_id, - Some("claude-sonnet-4-6"), - None, - &[message(), assistant_message()], - ClaudeDesktopPublicationState { - materialized_by_orgii: true, - completed_turns: None, + fn create_native_claude_session(session_id: &str, account_id: &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: "claude_code".to_string(), + model: Some("claude-sonnet-4-6".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("publish Claude Desktop session") - .expect("matching Claude Desktop project"); - let metadata: Value = serde_json::from_str( - &fs::read_to_string(&sidecar).expect("read Claude Desktop sidecar"), - ) - .expect("decode Claude Desktop sidecar"); - assert_eq!(metadata["sessionId"], format!("local_{native_id}")); - assert_eq!(metadata["cliSessionId"], native_id); - assert_eq!(metadata["title"], "hello"); - assert_eq!(metadata["completedTurns"], 1); - assert_eq!(metadata["orgiiMaterialization"], true); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); - } - - #[test] - fn native_agent_row_ids_are_stable_per_target_session() { - let first = native_agent_row_id("target-a", "source-a", Some("turn-a")); - assert_eq!( - first, - native_agent_row_id("target-a", "source-a", Some("turn-a")) - ); - assert_ne!( - first, - native_agent_row_id("target-b", "source-a", Some("turn-a")), - "the same source may be materialized into multiple target Sessions" - ); - assert!(first.starts_with("org2-turn-v1.dHVybi1h.c291cmNlLWE.")); - } - - fn catalog_refresh_context( - session_id: &str, - native_id: &str, - provider: NativeCatalogProvider, - ) -> CliNativePublicationContext { - CliNativePublicationContext { - session_id: session_id.to_string(), - name: format!("title-{native_id}"), - model: None, - branch: None, - native_id: native_id.to_string(), - cwd: PathBuf::from(format!("/tmp/{session_id}")), - agent: provider.as_str().to_string(), - paths: NativeTranscriptPaths { - native_path: PathBuf::from(format!("/tmp/{native_id}.jsonl")), - runner_path: PathBuf::from(format!("/tmp/{native_id}.runner.jsonl")), - }, - } + .expect("create fresh native CLI episode"); } #[test] - fn catalog_refresh_queue_coalesces_native_conversations() { - let mut queue = NativeCatalogRefreshQueue::default(); - let provider = NativeCatalogProvider::Codex; - let lane = queue.lane_mut(provider); - assert!(lane.enqueue( - provider, - catalog_refresh_context("session-a", "native-a", provider), - Some(2) - )); - assert!(!lane.enqueue( - provider, - catalog_refresh_context("session-a", "native-a", provider), - Some(3) - )); - assert!(!lane.enqueue( - provider, - catalog_refresh_context("session-a", "native-b", provider), - None - )); - assert_eq!( - lane.pending.len(), - 2, - "an account switch must retain both immutable native bindings" - ); - - let first = lane.take_next().expect("first pending session"); - let second = lane.take_next().expect("second pending session"); - assert!(matches!( - ( - first.context.native_id.as_str(), - second.context.native_id.as_str() - ), - ("native-a", "native-b") | ("native-b", "native-a") - )); - let native_a = if first.context.native_id == "native-a" { - first - } else { - second + 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_eq!( - native_a.completed_turns_hint, - Some(3), - "coalescing keeps the newest floor" - ); - assert!(lane.worker_running); - assert!(lane.take_next().is_none()); - assert!(!lane.worker_running); + assert!(native_item_semantically_equal(&left, &right)); } #[test] - fn catalog_refresh_provider_lanes_advance_independently() { - let mut queue = NativeCatalogRefreshQueue::default(); - let claude = NativeCatalogProvider::ClaudeCode; - let codex = NativeCatalogProvider::Codex; - assert!(queue.lane_mut(claude).enqueue( - claude, - catalog_refresh_context("claude-session", "claude-native", claude), - None + 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"] )); - assert!( - queue.lane_mut(codex).enqueue( - codex, - catalog_refresh_context("codex-session", "codex-native", codex), - None - ), - "a running Claude worker must not suppress the Codex worker" - ); - - assert_eq!( - queue - .lane_mut(claude) - .take_next() - .as_ref() - .map(|request| request.context.session_id.as_str()), - Some("claude-session") - ); - assert!(queue.lane_mut(codex).worker_running); - assert_eq!( - queue - .lane_mut(codex) - .take_next() - .as_ref() - .map(|request| request.context.session_id.as_str()), - Some("codex-session") - ); - } - - #[test] - fn catalog_refresh_lane_never_silently_evicts_pending_native_bindings() { - let mut lane = NativeCatalogRefreshLane::default(); - let provider = NativeCatalogProvider::Codex; - for index in 0..300 { - let spawn = lane.enqueue( - provider, - catalog_refresh_context( - &format!("session-{index:03}"), - &format!("native-{index:03}"), - provider, - ), - None, - ); - assert_eq!(spawn, index == 0); - } - assert_eq!(lane.pending.len(), 300); } #[test] - fn claude_desktop_sidecar_refuses_an_empty_catalog() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-desktop-empty-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let sessions_root = temp_dir.join("Claude").join("claude-code-sessions"); - let cwd = temp_dir.join("new-repo"); - fs::create_dir_all(&cwd).expect("create repo"); - - let native_id = "00000000-0000-4000-8000-000000000097"; - let sidecar = publish_claude_desktop_session_at( - &sessions_root, - &cwd, - native_id, - Some("claude-opus-5"), - None, - &[message(), assistant_message()], - ClaudeDesktopPublicationState { - materialized_by_orgii: true, - completed_turns: None, - }, - ) - .expect("inspect empty Claude Desktop catalog"); - - assert!(sidecar.is_none()); - assert!(!sessions_root.exists()); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); + 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_desktop_sidecar_groups_a_linked_worktree_with_its_repository() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-desktop-worktree-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let sessions_root = temp_dir.join("claude-code-sessions"); - let project_dir = sessions_root.join("organization").join("project"); - let repository = temp_dir.join("repository"); - let worktree = temp_dir.join("worktree"); - let worktree_git_dir = repository.join(".git/worktrees/pr939"); - fs::create_dir_all(&worktree_git_dir).expect("create worktree git dir"); - fs::create_dir_all(&worktree).expect("create linked worktree"); - fs::write(worktree_git_dir.join("commondir"), "../..\n").expect("write common dir pointer"); - fs::write( - worktree.join(".git"), - format!("gitdir: {}\n", worktree_git_dir.display()), - ) - .expect("write worktree git pointer"); - atomic_json( - &project_dir.join("local-existing.json"), - &json!({ - "sessionId": "local-existing", - "cliSessionId": "existing", - "cwd": repository, - "lastActivityAt": 1 - }), - ) - .expect("seed Claude Desktop project"); - - let native_id = "00000000-0000-4000-8000-000000000098"; - let sidecar = publish_claude_desktop_session_at( - &sessions_root, - &worktree, - native_id, - Some("claude-opus-5"), - None, - &[message(), assistant_message()], - ClaudeDesktopPublicationState { - materialized_by_orgii: true, - completed_turns: None, + #[tokio::test(flavor = "current_thread")] + async fn synchronize_materializes_an_unbound_cli_episode_before_prefix_checks() { + 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(), }, - ) - .expect("publish linked-worktree Claude Desktop session") - .expect("matching Claude Desktop repository project"); - - assert_eq!(sidecar.parent(), Some(project_dir.as_path())); - let metadata: Value = - serde_json::from_str(&fs::read_to_string(sidecar).expect("read worktree sidecar")) - .expect("decode worktree sidecar"); - assert_eq!(metadata["cliSessionId"], native_id); - assert_eq!(metadata["cwd"], worktree.to_string_lossy().as_ref()); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); - } - - #[test] - fn claude_desktop_refresh_reuses_provider_sidecar_by_cli_session_id() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-claude-desktop-refresh-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let sessions_root = temp_dir.join("claude-code-sessions"); - let project_dir = sessions_root.join("organization").join("project"); - let cwd = temp_dir.join("repo"); - let native_id = "00000000-0000-4000-8000-000000000099"; - let provider_path = project_dir.join("local-provider-owned.json"); - let unrelated_project_dir = sessions_root - .join("newer-organization") - .join("newer-project"); - fs::create_dir_all(&cwd).expect("create repo"); - atomic_json( - &provider_path, - &json!({ - "sessionId": "local-provider-owned", - "cliSessionId": native_id, - "title": "Provider title", - "cwd": cwd, - "lastActivityAt": 1, - "completedTurns": 0 - }), - ) - .expect("seed provider-owned Claude Desktop session"); - atomic_json( - &unrelated_project_dir.join("local-unrelated.json"), - &json!({ - "sessionId": "local-unrelated", - "cliSessionId": "different-native-session", - "title": "Unrelated newer project", - "cwd": cwd, - "lastActivityAt": 999, - "completedTurns": 10 - }), - ) - .expect("seed newer unrelated Claude Desktop project"); - - let sidecar = publish_claude_desktop_session_at( - &sessions_root, - &cwd, - native_id, - Some("claude-opus-5"), - None, - &[message(), assistant_message()], - ClaudeDesktopPublicationState { - materialized_by_orgii: false, - completed_turns: Some(1), + NativeConversationItem::ToolResult { + id: "tool-result-1".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + output: "repository read".to_string(), + created_at: "2026-09-02T00:00:02Z".to_string(), }, - ) - .expect("refresh Claude Desktop session") - .expect("matching Claude Desktop project"); + message("assistant-1", "assistant", "Inspection complete"), + ]; - assert_eq!(sidecar, provider_path); - assert!(!project_dir.join(format!("local_{native_id}.json")).exists()); - assert!(!unrelated_project_dir - .join(format!("local_{native_id}.json")) - .exists()); - let metadata: Value = - serde_json::from_str(&fs::read_to_string(&sidecar).expect("read refreshed sidecar")) - .expect("decode refreshed sidecar"); - assert_eq!(metadata["sessionId"], "local-provider-owned"); - assert_eq!(metadata["cliSessionId"], native_id); - assert_eq!(metadata["title"], "Provider title"); - assert_eq!(metadata["completedTurns"], 1); - assert!(metadata.get("orgiiMaterialization").is_none()); + let receipt = + synchronize_native_conversation(session_id.to_string(), complete_items.clone()) + .await + .expect("first synchronization should materialize the unbound episode"); - publish_claude_desktop_session_at( - &sessions_root, - &cwd, - native_id, - Some("claude-opus-5"), - None, - &[], - ClaudeDesktopPublicationState { - materialized_by_orgii: false, - completed_turns: None, - }, - ) - .expect("metadata-only refresh") - .expect("existing provider sidecar"); - let metadata: Value = serde_json::from_str( - &fs::read_to_string(&sidecar).expect("read metadata-only refresh"), - ) - .expect("decode metadata-only refresh"); + assert_eq!(receipt.item_count, complete_items.len()); assert_eq!( - metadata["completedTurns"], 1, - "unknown refreshes must not reset provider progress" + 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()) ); - - fs::remove_dir_all(temp_dir).expect("remove temp dir"); + 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))); } - #[cfg(unix)] - #[test] - fn provider_cwd_uses_the_identity_seen_by_the_native_cli() { - let temp_dir = std::env::temp_dir().join(format!( - "orgii-native-canonical-cwd-{}-{}", - std::process::id(), - Uuid::new_v4().simple() - )); - let real = temp_dir.join("real"); - let alias = temp_dir.join("alias"); - fs::create_dir_all(&real).expect("create canonical cwd"); - std::os::unix::fs::symlink(&real, &alias).expect("create cwd alias"); + #[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(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!( - provider_canonical_cwd(alias), - fs::canonicalize(&real).expect("canonicalize fixture") + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read native binding"), + None ); - - fs::remove_dir_all(temp_dir).expect("remove canonical cwd fixture"); - } - - #[test] - fn unsupported_historical_image_fails_closed() { - let mut item = message(); - if let NativeConversationItem::Message { images, .. } = &mut item { - images.push("/tmp/image.png".to_string()); - } - assert!(validate_items(&[item]).is_err()); - } - - #[test] - fn unsupported_assistant_image_fails_closed() { - let mut item = assistant_message(); - if let NativeConversationItem::Message { images, .. } = &mut item { - images.push("data:image/png;base64,AAAA".to_string()); - } - assert!(validate_items(&[item]).is_err()); - } - - #[test] - fn portable_tool_call_ids_accept_64_characters_and_reject_65() { - let tool_call = |call_id: String| NativeConversationItem::ToolCall { - id: "tool-1:call".to_string(), - call_id, - name: "read_file".to_string(), - arguments: r#"{"path":"/repo/README.md"}"#.to_string(), - created_at: "2026-08-26T00:00:01Z".to_string(), - }; - let tool_result = |call_id: String| NativeConversationItem::ToolResult { - id: "tool-1:result".to_string(), - call_id, - name: "read_file".to_string(), - output: "contents".to_string(), - created_at: "2026-08-26T00:00:02Z".to_string(), - }; - - assert!(validate_items(&[tool_call("x".repeat(64)), tool_result("x".repeat(64)),]).is_ok()); - assert!(validate_items(&[tool_call("x".repeat(65))]).is_err()); - assert!(validate_items(&[tool_result("x".repeat(65))]).is_err()); - assert!(validate_items(&[tool_call("call:part-0".to_string())]).is_err()); - assert!(validate_items(&[tool_result("call:part-0".to_string())]).is_err()); + assert!(!app_paths::claude_code_cli_profile_dir(account_id) + .join("projects") + .exists()); } } 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 339589aa8e..0711bb6151 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 @@ -55,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; @@ -69,6 +72,9 @@ 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; @@ -143,9 +149,7 @@ pub enum GracefulInterruptOutcome { /// 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 { +pub async fn interrupt_session_gracefully(session_id: &str) -> GracefulInterruptOutcome { let Some(tx) = interrupt_sender(session_id) else { return GracefulInterruptOutcome::NotRunning; }; @@ -158,8 +162,8 @@ pub async fn interrupt_session_gracefully( } // 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); + 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 GracefulInterruptOutcome::Completed; @@ -895,6 +899,113 @@ 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(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() + ) + })?; + let launch_profile = super::super::launch_profile_store::resolve_cli_launch_profile( + &key_vault::key_store::ModelType::Codex, + )?; + let mut command = Command::new(&launch_profile.command); + command + .arg("app-server") + .envs(launch_profile.env) + .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}", + launch_profile.command, + 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( 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..4106043118 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs @@ -0,0 +1,520 @@ +//! 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); + +#[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 mut client = runtime.block_on(super::CodexAppServerRpcClient::launch(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(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("openai") + .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 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/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index 1b7e718ae1..d6cadb5e01 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -17,7 +17,7 @@ use key_vault::key_store::{ModelType, KEY_SERVICE}; use super::super::parsers::{canonicalize_cli_error_message, is_codex_fallback_metadata_notice}; use super::super::persistence::{self, CodeSession}; -use super::super::types::{KeySource, SessionStatus}; +use super::super::types::SessionStatus; use super::cursor_usage::fetch_cursor_usage_for_session; use super::helpers::{clear_live_status, flush_and_broadcast}; use super::oauth_setup::is_cli_oauth_failure_message; @@ -279,72 +279,21 @@ pub(super) async fn finalize_session_run( // `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 mut final_status = if raw_final_status == SessionStatus::Completed && is_org_member { + let final_status = if raw_final_status == SessionStatus::Completed && is_org_member { SessionStatus::Idle } else { raw_final_status }; - let mut error_message: Option = if final_status == SessionStatus::Failed { + let 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 }; - - // Provider-native publication is part of the durable turn boundary, not a - // best-effort metadata side effect. Serialize it with follow-ups and finish - // it before any terminal lifecycle, WorkItem receipt, member-availability, - // or terminal broadcast can advertise a result that the native App cannot - // resume. The runner transcript remains in place when publication fails so - // a later recovery can retry the copy. - let publishes_native_conversation = session.key_source == KeySource::OwnKey - && matches!(agent, ModelType::Codex | ModelType::ClaudeCode); - let native_control_lock = if publishes_native_conversation { - Some(super::helpers::session_control_lock(session_id).await) - } else { - None - }; - let native_control_guard = match native_control_lock.as_ref() { - Some(lock) => Some(lock.lock().await), - None => None, - }; - - // Flush pending assistant/tool deltas into the authoritative CLI store - // before materializing that store into the provider-native transcript. + // Native providers write the selected profile directly. Flushing final + // deltas is the only terminal persistence boundary. flush_and_broadcast(session_id).await; - let native_publication_error = if publishes_native_conversation { - match super::super::native_materializer::publish_cli_native_transcript_after_turn( - session_id, - ) - .await - { - Ok(true) => None, - Ok(false) if raw_final_status == SessionStatus::Completed => Some( - "Provider-native transcript publication failed: a completed turn has no native transcript" - .to_string(), - ), - Ok(false) => None, - Err(err) => Some(format!( - "Provider-native transcript publication failed: {err}" - )), - } - } else { - None - }; - if let Some(publication_error) = native_publication_error.as_ref() { - tracing::error!( - session_id, - error = %publication_error, - "failed to publish provider-native conversation at terminal boundary" - ); - raw_final_status = SessionStatus::Failed; - final_status = SessionStatus::Failed; - error_message = Some(match error_message.take() { - Some(existing) => format!("{existing}\n{publication_error}"), - None => publication_error.clone(), - }); - } if raw_final_status == SessionStatus::Failed { super::input_assembly::forget_session_context(session_id); @@ -509,7 +458,6 @@ pub(super) async fn finalize_session_run( status_msg["turn_intent_id"] = serde_json::Value::String(turn_intent_id.to_string()); } websocket_handler::broadcast(status_msg.to_string()); - drop(native_control_guard); // ── Worktree: commit changes on completion ── if raw_final_status == SessionStatus::Completed { 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 e4420a0667..67d3944be5 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs @@ -1,7 +1,7 @@ //! Session lifecycle management — kill, cancel, cleanup. use super::super::persistence; -use super::super::types::{KeySource, SessionStatus}; +use super::super::types::SessionStatus; use super::helpers::{flush_cli_streams_for_session, RUNNING_SESSIONS}; use agent_core::state::control_flow::CancelReason; @@ -175,8 +175,7 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result None, - Ok(false) => None, - Err(err) => { - tracing::error!( - session_id, - error = %err, - "failed to publish interrupted provider-native conversation" - ); - Some(format!( - "Provider-native transcript publication failed after cancellation: {err}" - )) - } - } + Some("Codex did not finish its native interrupted turn".to_string()) } else { None }; - let terminal_status = if publication_error.is_some() { + let terminal_status = if interrupt_error.is_some() { SessionStatus::Failed } else { SessionStatus::Cancelled }; - let terminal_intent_status = if publication_error.is_some() { + let terminal_intent_status = if interrupt_error.is_some() { session_persistence::turn_intents::TurnIntentStatus::Failed } else { session_persistence::turn_intents::TurnIntentStatus::Cancelled @@ -247,15 +211,15 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result Result void) => { - hydrationCompleteRef.current = true; - onStoreChange(); - return () => {}; - }, []); - const hydrationComplete = useSyncExternalStore( - subscribe, - () => hydrationCompleteRef.current, - () => false - ); - return resolveConversationViewerState(viewerUserId, hydrationComplete); -} - /** Resolve one row without importing any transport/account implementation. */ export function useConversationSenderResolution( event: SessionEvent | undefined diff --git a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx index 28ee9eb033..b840196267 100644 --- a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx +++ b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx @@ -13,22 +13,12 @@ import { useTranslation } from "react-i18next"; import { CHAT_BUBBLE_TOOLBAR_BUTTON_CLASS } from "@src/components/ChatBubble"; import ClampedContent from "@src/components/ClampedContent"; 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 { discussionPayloadOf } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; -import { - isTeamChatBodyWithinLimit, - isTeamChatMentionAudienceWithinLimit, - resolveTeamChatMentionedUserIds, -} from "@src/features/Org2Cloud/SessionConversation/teamChatMentions"; -import { - CLOUD_COMMENT_MAX_BODY_LENGTH, - CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, -} from "@src/features/Org2Cloud/org2CloudCommentsClient"; import { ClipboardCheckIcon, File01Icon, @@ -40,7 +30,6 @@ import { } from "@src/icons"; import { imageRefToRustPath } from "@src/util/file/imageRefs"; -import { useGroupChatContext } from "../ChatHistory/GroupChatView/GroupChatContext"; import UserMessageContent, { type UserMessageMention, } from "../ChatHistory/components/UserMessageContent"; @@ -53,6 +42,7 @@ import RawPromptToggle from "./RawPromptToggle"; import { normalizeUserMessageText } from "./normalizeUserMessageText"; import { wasSubmittedByViewer } from "./parentAgentSender"; import { resolveRawUserPrompt } from "./rawUserPrompt"; +import { useUserMessageDeliveryActions } from "./useUserMessageDeliveryActions"; import { resolveUserMessageSide } from "./userMessageSide"; const USER_MSG_MAX_LINES = 3; @@ -62,18 +52,6 @@ const USER_MSG_CONTINUOUS_PREVIEW_HEIGHT = 10 * 24; const AGENT_ORG_INBOX_TRANSCRIPT_PREFIX = "Acknowledged inbox batch"; const PLAN_APPROVED_PREFIX = "[Plan approved"; -export function isViewerOwnedFailedDiscussion(input: { - deliveryStatus: "pending" | "sent" | "failed" | null; - authorUserId: string | null | undefined; - viewerUserId: string | null | undefined; -}): boolean { - return Boolean( - input.deliveryStatus === "failed" && - input.viewerUserId && - input.authorUserId === input.viewerUserId - ); -} - // ============================================ // Types // ============================================ @@ -217,7 +195,6 @@ const UserChatItem = ({ const messageContentRef = useRef(null); const event = chatItem.event; - const groupChat = useGroupChatContext(); 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 @@ -230,7 +207,6 @@ const UserChatItem = ({ const mentionableMembers = comments?.mentionableMembers; const discussionPayload = event ? discussionPayloadOf(event) : null; const mentionedUserIds = discussionPayload?.mentionedUserIds; - const discussionCommentId = discussionPayload?.commentId ?? null; const mentions: UserMessageMention[] | undefined = (() => { if (!mentionedUserIds?.length) return undefined; const resolved: UserMessageMention[] = []; @@ -275,14 +251,9 @@ const UserChatItem = ({ typeof activityResult?.result?.deliveryError === "string" ? activityResult.result.deliveryError : null; - const groupChatInboxId = - typeof event?.args?.groupChatInboxId === "number" - ? event.args.groupChatInboxId - : null; - const viewerOwnsFailedDiscussion = isViewerOwnedFailedDiscussion({ + const deliveryActions = useUserMessageDeliveryActions({ + event, deliveryStatus, - authorUserId: discussionPayload?.authorUserId, - viewerUserId: comments?.viewerUserId, }); const fullContent = useMemo(() => { @@ -317,21 +288,10 @@ const UserChatItem = ({ // Extract images from activity result for display in chat history. const messageImages = isAgentOrgInboxTranscript ? undefined : activityImages; const retryDelivery = - viewerOwnsFailedDiscussion && comments && discussionCommentId - ? () => { - void comments - .retryComment(discussionCommentId) - .catch((error) => - Message.error( - error instanceof Error ? error.message : String(error) - ) - ); - } - : deliveryStatus === "failed" && groupChat && groupChatInboxId !== null - ? () => groupChat.retryFailedMessage(groupChatInboxId) - : onEditSubmit - ? () => onEditSubmit(editedText || fullContent, messageImages) - : null; + deliveryActions.retry ?? + (onEditSubmit + ? () => onEditSubmit(editedText || fullContent, messageImages) + : null); const needsTruncation = useMemo(() => { if (!compactPreview) return false; @@ -386,44 +346,11 @@ const UserChatItem = ({ const handleEditSubmitInternal = useCallback( (newText: string, addedImageDataUrls?: string[]) => { - if (viewerOwnsFailedDiscussion && comments && discussionCommentId) { - if (!isTeamChatBodyWithinLimit(newText)) { - Message.warning( - `Team Chat messages must be ${CLOUD_COMMENT_MAX_BODY_LENGTH} characters or fewer` - ); - return; - } - const mentionedUserIds = resolveTeamChatMentionedUserIds( - newText, - comments.mentionableMembers, - undefined, - comments.viewerUserId - ); - if (!isTeamChatMentionAudienceWithinLimit(mentionedUserIds)) { - Message.warning( - `@all is unavailable when it would notify more than ${CLOUD_COMMENT_MAX_MENTIONED_USER_IDS} people` - ); - return; - } + if (deliveryActions.editAndRetry?.(newText)) { setIsEditing(false); - void comments - .retryComment(discussionCommentId, newText) - .catch((error) => - Message.error( - error instanceof Error ? error.message : String(error) - ) - ); return; } setIsEditing(false); - if ( - deliveryStatus === "failed" && - groupChat && - groupChatInboxId !== null - ) { - groupChat.retryFailedMessage(groupChatInboxId, newText); - return; - } const rustImages = [ ...((editImageList && editImageList.length > 0 ? editImageList.map(imageRefToRustPath) @@ -432,16 +359,7 @@ const UserChatItem = ({ ]; onEditSubmit?.(newText, rustImages.length > 0 ? rustImages : undefined); }, - [ - comments, - deliveryStatus, - discussionCommentId, - editImageList, - groupChat, - groupChatInboxId, - onEditSubmit, - viewerOwnsFailedDiscussion, - ] + [deliveryActions, editImageList, onEditSubmit] ); // Edit mode @@ -466,13 +384,13 @@ const UserChatItem = ({ const planApprovedEdited = isPlanApproved && fullContent.startsWith("[Plan approved (edited)"); const isEditableDisplay = Boolean( - (onEditSubmit || viewerOwnsFailedDiscussion) && + (onEditSubmit || deliveryActions.canEditFailed) && deliveryStatus !== "pending" && !isRepoSetup && !isAgentOrgInboxTranscript && !isPlanApproved && (!event?.args?.["sessionDiscussion"] || deliveryStatus === "failed") && - (!conversationSenderStampOf(event) || viewerOwnsFailedDiscussion) + (!conversationSenderStampOf(event) || deliveryActions.canEditFailed) ); const hasDisplayContent = Boolean( fullContent.trim() || diff --git a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts index 5b6af10ab3..5a05673325 100644 --- a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts +++ b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts @@ -15,33 +15,7 @@ import type { Session } from "@src/store/session"; import { ConversationSenderMetadataProvider } from "../ConversationSenderMetadataContext"; import { ParentAgentSenderProvider } from "../ParentAgentSenderContext"; -import UserChatItem, { isViewerOwnedFailedDiscussion } from "../UserChatItem"; - -describe("failed Team Chat edit ownership", () => { - it("allows only the viewer's failed discussion row", () => { - expect( - isViewerOwnedFailedDiscussion({ - deliveryStatus: "failed", - authorUserId: "viewer-user", - viewerUserId: "viewer-user", - }) - ).toBe(true); - expect( - isViewerOwnedFailedDiscussion({ - deliveryStatus: "failed", - authorUserId: "teammate-user", - viewerUserId: "viewer-user", - }) - ).toBe(false); - expect( - isViewerOwnedFailedDiscussion({ - deliveryStatus: "sent", - authorUserId: "viewer-user", - viewerUserId: "viewer-user", - }) - ).toBe(false); - }); -}); +import UserChatItem from "../UserChatItem"; function renderMessage(id: string): string { const sessionId = "agentsession-local"; diff --git a/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts new file mode 100644 index 0000000000..e4c5971b77 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts @@ -0,0 +1,111 @@ +import { useCallback } from "react"; + +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 { + isTeamChatBodyWithinLimit, + isTeamChatMentionAudienceWithinLimit, + resolveTeamChatMentionedUserIds, +} from "@src/features/Org2Cloud/SessionConversation/teamChatMentions"; +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + CLOUD_COMMENT_MAX_MENTIONED_USER_IDS, +} from "@src/features/Org2Cloud/org2CloudCommentsClient"; + +import { useGroupChatContext } from "../ChatHistory/GroupChatView/GroupChatContext"; + +export 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) => boolean) | 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: (text: string) => { + if (!isTeamChatBodyWithinLimit(text)) { + Message.warning( + `Team Chat messages must be ${CLOUD_COMMENT_MAX_BODY_LENGTH} characters or fewer` + ); + return false; + } + const mentionedUserIds = resolveTeamChatMentionedUserIds( + text, + comments.mentionableMembers, + undefined, + comments.viewerUserId + ); + if (!isTeamChatMentionAudienceWithinLimit(mentionedUserIds)) { + Message.warning( + `@all is unavailable when it would notify more than ${CLOUD_COMMENT_MAX_MENTIONED_USER_IDS} people` + ); + return false; + } + void comments + .retryComment(discussion.commentId, text) + .catch(reportFailure); + return true; + }, + }; + } + if ( + params.deliveryStatus === "failed" && + groupChat && + groupChatInboxId !== null + ) { + return { + canEditFailed: true, + retry: () => groupChat.retryFailedMessage(groupChatInboxId), + editAndRetry: (text: string) => { + groupChat.retryFailedMessage(groupChatInboxId, text); + return true; + }, + }; + } + return { + canEditFailed: false, + retry: null, + editAndRetry: null, + }; +} diff --git a/src/engines/ChatPanel/ConversationStreamProvider.tsx b/src/engines/ChatPanel/ConversationStreamProvider.tsx index 5a05aeeb0e..91f5e27022 100644 --- a/src/engines/ChatPanel/ConversationStreamProvider.tsx +++ b/src/engines/ChatPanel/ConversationStreamProvider.tsx @@ -1,24 +1,23 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import { selectAtom } from "jotai/utils"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { canonicalConversationExecutionsAtom } from "@src/engines/SessionCore/conversations/canonicalConversationExecution"; +import { resolveConversationViewerState } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; -import { - activeConversationRunnerKey, - activeConversationRunnersAtom, - buildConversationRunnerOverlay, - collectLandedTurnIds, - selectActiveRunners, -} from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; import { type ConversationFamilyMember, resolveConversationFamily, stitchConversationSegments, } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; import { useConversationPlaneEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; +import { + buildConversationRunnerOverlay, + collectLandedTurnIds, +} from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; import { ConversationRunnerScopeProvider } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; import { @@ -35,13 +34,15 @@ import { org2CloudRemoteSessionsAtom, remoteSessionsEntryForIdentity, } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { findImportedSession } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import { + findImportedSession, + normalizeSourceEndpointUrl, +} from "@src/features/TeamCollaboration/engine/collabImportIdentity"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { sessionByIdAtom, sessionsAtom } from "@src/store/session"; import { ChatHistoryOverrideContext } from "./ChatHistoryOverrideContext"; -import { useConversationViewerState } from "./ChatItems/ConversationSenderMetadataContext"; interface ConversationStreamProviderProps { sessionId: string; @@ -57,8 +58,6 @@ interface MemberEventsTapProps { onUnmount?: (bareSessionId: string) => void; } -const EMPTY_ACTIVE_CONVERSATION_RUNNERS = [] as const; - /** Invisible per-family-member subscription; the atom self-hydrates on mount. */ function MemberEventsTap({ bareSessionId, @@ -221,47 +220,55 @@ export function ConversationStreamProvider({ ); const plane = useConversationPlaneEvents(target); - const viewer = useConversationViewerState( - auth?.userId ?? comments?.viewerUserId ?? null + 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 setRunnerRegistry = useSetAtom(activeConversationRunnersAtom); + const canonicalExecutions = useAtomValue(canonicalConversationExecutionsAtom); const planeRootId = target?.sessionId ?? null; const runnerRegistryKey = useMemo(() => { - if (!authIdentityKey || !target || !planeRootId) return null; - return activeConversationRunnerKey(authIdentityKey, { + if (!auth || !authIdentityKey || !target || !planeRootId) return null; + return conversationRootKey({ authority: "org2-cloud", - authorityScope: [target.orgId], + authorityScope: [ + normalizeSourceEndpointUrl(auth.supabaseUrl), + target.orgId, + ], conversationId: planeRootId, }); - }, [authIdentityKey, planeRootId, target]); - const runnerRegistryEntryAtom = useMemo( - () => - selectAtom( - activeConversationRunnersAtom, - (registry) => - runnerRegistryKey - ? (registry[runnerRegistryKey] ?? EMPTY_ACTIVE_CONVERSATION_RUNNERS) - : EMPTY_ACTIVE_CONVERSATION_RUNNERS, - Object.is - ), - [runnerRegistryKey] - ); - const registeredRunners = useAtomValue(runnerRegistryEntryAtom); + }, [auth, authIdentityKey, planeRootId, target]); const landedTurnIds = useMemo( () => collectLandedTurnIds(plane.events), [plane.events] ); const activeRunners = useMemo(() => { - if (!runnerRegistryKey) 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(registeredRunners, landedTurnIds); - }, [registeredRunners, runnerRegistryKey, landedTurnIds]); + if (!runnerRegistryKey || !authIdentityKey) return []; + return canonicalExecutions.flatMap((execution) => { + const descriptor = execution.message.conversationDispatch; + if ( + !descriptor || + descriptor.dispatchIdentityKey !== authIdentityKey || + conversationRootKey(descriptor.root) !== runnerRegistryKey || + !execution.runnerSessionId || + execution.runnerEventStartIndex === undefined || + landedTurnIds.has(execution.message.turnIntentId) + ) { + return []; + } + return [ + { + runnerSessionId: execution.runnerSessionId, + turnId: execution.message.turnIntentId, + eventStartIndex: execution.runnerEventStartIndex, + }, + ]; + }); + }, [authIdentityKey, canonicalExecutions, landedTurnIds, runnerRegistryKey]); const activeRunnerIds = useMemo( () => new Set(activeRunners.map((runner) => runner.runnerSessionId)), [activeRunners] @@ -272,19 +279,6 @@ export function ConversationStreamProvider({ activeRunners.length > 0 ? activeRunners[activeRunners.length - 1].runnerSessionId : null; - useEffect(() => { - if (!runnerRegistryKey) return; - const list = registeredRunners; - 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[runnerRegistryKey]; - else next[runnerRegistryKey] = kept; - return next; - }); - }, [runnerRegistryKey, registeredRunners, landedTurnIds, setRunnerRegistry]); const [runnerOverlayById, setRunnerOverlayById] = useState< ReadonlyMap >(() => new Map()); diff --git a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx deleted file mode 100644 index 33eb6ce8c9..0000000000 --- a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -// @vitest-environment jsdom -import { act } from "react"; -import { type Root, createRoot } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import ConversationRuntimePill from "./ConversationRuntimePill"; - -vi.mock("jotai", () => ({ - useAtomValue: () => "dropdown", -})); - -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => - key === "common:actions.loading" ? "Loading..." : "Select an agent", - }), -})); - -vi.mock("@src/components/SelectorPill", () => ({ - default: ({ - label, - disabled, - dataTestId, - }: { - label: string; - disabled?: boolean; - dataTestId?: string; - }) => ( - - ), -})); - -vi.mock("@src/components/AnyIcon", () => ({ default: () => })); -vi.mock("@src/components/ModelIcon", () => ({ default: () => })); -vi.mock("@src/config/agentIcons", () => ({ - resolveAgentIcon: () => undefined, -})); - -vi.mock( - "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette", - () => ({ DispatchCategoryPalette: () => null }) -); -vi.mock( - "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown", - () => ({ DispatchCategoryDropdown: () => null }) -); - -describe("ConversationRuntimePill inventory readiness", () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - }); - - it("does not paint a source runtime as selected while inventory loads", () => { - act(() => { - root.render( - - ); - }); - - const button = container.querySelector("button"); - expect(button?.disabled).toBe(true); - expect(button?.textContent).toBe("Loading..."); - expect(container.textContent).not.toContain("Codex"); - }); - - it("keeps an unavailable inventory neutral and disabled", () => { - act(() => { - root.render( - - ); - }); - - const button = container.querySelector("button"); - expect(button?.disabled).toBe(true); - expect(button?.textContent).toBe("Select an agent"); - expect(container.textContent).not.toContain("Codex"); - }); -}); diff --git a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx b/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx deleted file mode 100644 index 785f41e58d..0000000000 --- a/src/engines/ChatPanel/InputArea/components/ConversationRuntimePill.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useAtomValue } from "jotai"; -import React, { memo, useCallback, useMemo, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; - -import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; -import AnyIcon from "@src/components/AnyIcon"; -import ModelIcon from "@src/components/ModelIcon"; -import SelectorPill from "@src/components/SelectorPill"; -import { resolveAgentIcon } from "@src/config/agentIcons"; -import type { ConversationTargetReadiness } from "@src/engines/ChatPanel/conversationTargetSelection"; -import { - type AgentSelection, - DispatchCategoryPalette, -} from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette"; -import { DispatchCategoryDropdown } from "@src/scaffold/GlobalSpotlight/palettes/DispatchCategoryPalette/DispatchCategoryDropdown"; -import { modelPickerStyleAtom } from "@src/store/ui/chatPanelAtom"; - -interface ConversationRuntimePillProps { - selection: AgentSelection | null; - readiness: ConversationTargetReadiness; - allowedCliAgentTypes: readonly CliAgentType[]; - onSelect: (selection: AgentSelection) => void; -} - -/** - * The ordinary New Session runtime picker, mounted beside the model picker. - * The conversation layer owns only the selected value; option discovery and - * presentation remain in DispatchCategoryPalette. - */ -const ConversationRuntimePill: React.FC = memo( - ({ selection, readiness, allowedCliAgentTypes, onSelect }) => { - const { t } = useTranslation(); - const modelPickerStyle = useAtomValue(modelPickerStyleAtom); - const [isOpen, setIsOpen] = useState(false); - const triggerRef = useRef(null); - const visibleSelection = readiness === "ready" ? selection : null; - - const icon = useMemo(() => { - if (visibleSelection?.cliAgentType) { - return ( - - ); - } - return ( - - ); - }, [visibleSelection]); - - const handleSelect = useCallback( - (next: AgentSelection) => { - onSelect(next); - setIsOpen(false); - }, - [onSelect] - ); - - const close = useCallback(() => setIsOpen(false), []); - const disabled = readiness !== "ready"; - const effectiveIsOpen = isOpen && !disabled; - const label = - readiness === "loading" - ? t("common:actions.loading") - : (visibleSelection?.agentName ?? t("sessions:creator.selectAgent")); - const sharedProps = { - isOpen: effectiveIsOpen, - onClose: close, - onSelect: handleSelect, - currentCategory: visibleSelection?.category, - currentAgentDefinitionId: visibleSelection?.agentDefinitionId, - currentCliAgentType: visibleSelection?.cliAgentType, - hideOrgs: true, - allowedCliAgentTypes, - } as const; - - return ( - <> - setIsOpen((open) => !open)} - size="sm" - ariaLabel={label} - dataTestId="chat-runtime-pill" - /> - - {modelPickerStyle === "dropdown" ? ( - - ) : ( - - )} - - ); - } -); - -ConversationRuntimePill.displayName = "ConversationRuntimePill"; - -export default ConversationRuntimePill; diff --git a/src/engines/ChatPanel/InputArea/components/ModelPill.tsx b/src/engines/ChatPanel/InputArea/components/ModelPill.tsx index 045d4e2026..6e68031937 100644 --- a/src/engines/ChatPanel/InputArea/components/ModelPill.tsx +++ b/src/engines/ChatPanel/InputArea/components/ModelPill.tsx @@ -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,14 +26,19 @@ 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 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"; @@ -48,8 +53,6 @@ import { modelSelectorAtom } from "@src/store/ui/modelSelectorAtom"; import { isActiveStatus } from "@src/types/session/session"; import { getDispatchCategory } from "@src/util/session/sessionDispatch"; -import ConversationRuntimePill from "./ConversationRuntimePill"; - // ============================================ // Component // ============================================ @@ -58,6 +61,8 @@ 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 [selectorState, setSelectorState] = useAtom(modelSelectorAtom); const isModelOpen = selectorState.isOpen; // Creator-default selection — also used as the display-only-fields @@ -258,7 +263,9 @@ const ModelPillComponent: React.FC = () => { const handleRuntimeSelect = useCallback( (selection: AgentSelection) => { - if (!conversationBinding?.applyRuntimePick(selection)) { + if (conversationBinding?.applyRuntimePick(selection)) { + setIsRuntimeOpen(false); + } else { Message.warning(t("navigation:collaboration.forkImported.agentError")); } }, @@ -283,6 +290,22 @@ const ModelPillComponent: React.FC = () => { : t("sessions:creator.model"); const visiblePillSelection = conversationTargetReady ? pillSelection : null; const effectiveModelOpen = isModelOpen && conversationTargetReady; + const runtimeSelection = 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 modelPill = (
{ return ( <> {conversationBinding && ( - + <> + setIsRuntimeOpen((open) => !open)} + size="sm" + ariaLabel={runtimeLabel} + dataTestId="chat-runtime-pill" + /> + setIsRuntimeOpen(false)} + onSelect={handleRuntimeSelect} + currentCategory={runtimeSelection?.category} + currentAgentDefinitionId={runtimeSelection?.agentDefinitionId} + currentCliAgentType={runtimeSelection?.cliAgentType} + hideOrgs + allowedCliAgentTypes={conversationBinding.nativeCliTargets} + anchorRef={runtimeSegmentRef} + placement="top" + /> + )} {modelPill} diff --git a/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx b/src/engines/ChatPanel/InputArea/components/QueuedMessageItem.tsx index b6754984cc..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,7 +56,8 @@ 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 isSending = msg.status !== "queued" || msg.priority === "now"; + const isSending = + isHandoff || msg.status !== "queued" || msg.priority === "now"; const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: msg.id, diff --git a/src/engines/ChatPanel/InputArea/components/QueuedMessages.tsx b/src/engines/ChatPanel/InputArea/components/QueuedMessages.tsx index 4c00dbb23b..a210b4ee94 100644 --- a/src/engines/ChatPanel/InputArea/components/QueuedMessages.tsx +++ b/src/engines/ChatPanel/InputArea/components/QueuedMessages.tsx @@ -37,6 +37,7 @@ import { HugeiconsIcon, MessageCircleMoreIcon } from "@src/icons"; import { useWebViewSensors } from "@src/lib/dndKit"; import { type QueuedMessage, + messageQueueHandoffIdsAtom, queueEditTargetAtom, } from "@src/store/ui/messageQueueAtom"; @@ -68,6 +69,7 @@ const QueuedMessages: React.FC = memo( const { t } = useTranslation("common"); const setEditTarget = useSetAtom(queueEditTargetAtom); const editTarget = useAtomValue(queueEditTargetAtom); + const handoffIds = useAtomValue(messageQueueHandoffIdsAtom); // Clear edit target if the message being edited was removed from the queue useEffect(() => { @@ -189,6 +191,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/conversationTargetSelection.test.ts b/src/engines/ChatPanel/conversationTargetSelection.test.ts index 2ef549d8ef..20bd9a2acf 100644 --- a/src/engines/ChatPanel/conversationTargetSelection.test.ts +++ b/src/engines/ChatPanel/conversationTargetSelection.test.ts @@ -32,18 +32,6 @@ function account( }; } -const registry = { - agents: [ - { - name: "claude_code", - compatibleApiProviders: [], - }, - { name: "codex", compatibleApiProviders: [] }, - { name: "cursor_cli", compatibleApiProviders: [] }, - ], - apiProviders: [], -} as never; - describe("canonical conversation target selection", () => { it("resolves a standard New Session runtime selection without a custom runtime list", () => { expect( @@ -59,16 +47,13 @@ describe("canonical conversation target selection", () => { cliAgentType: "claude_code", model: "opus", }, - sourceModel: "claude-opus-5", workspaceRepoPath: "/repo", - accounts: [account("codex-local", "codex", "gpt-5.6-sol")], - registry, nativeCliTargets: ["claude_code", "codex"], }) ).toEqual({ cliAgentType: "codex", - accountId: "codex-local", - model: "gpt-5.6-sol", + accountId: undefined, + model: undefined, workspaceRepoPath: "/repo", }); }); @@ -88,22 +73,18 @@ describe("canonical conversation target selection", () => { model: "gpt-5.6-sol", workspaceRepoPath: "/repo", }, - sourceModel: "gpt-5.6-sol", workspaceRepoPath: "/repo", - accounts: [account("cursor-local", "cursor_cli", "composer-1")], - registry, nativeCliTargets: ["claude_code", "codex", "cursor_cli"], }) ).toEqual({ cliAgentType: "cursor_cli", - accountId: "cursor-local", - model: "composer-1", + accountId: undefined, + model: undefined, workspaceRepoPath: "/repo", }); }); it("uses the selected Rust agent's existing preferred account and model", () => { - const rustAccount = account("rust-account", "codex", "gpt-5.6-sol"); expect( resolveConversationRuntimeTarget({ selection: { @@ -113,25 +94,9 @@ describe("canonical conversation target selection", () => { agentName: "SDE Agent", }, current: null, - sourceModel: "gpt-5.6-sol", workspaceRepoPath: "/repo", preferredAccountId: "rust-account", preferredModel: "gpt-5.6-sol", - accounts: [rustAccount], - registry: { - agents: [ - { - name: "claude_code", - compatibleApiProviders: [], - }, - { - name: "codex", - compatibleApiProviders: [], - supportsRustAgents: true, - }, - ], - apiProviders: [], - } as never, nativeCliTargets: ["claude_code", "codex"], }) ).toEqual({ @@ -206,12 +171,10 @@ describe("canonical conversation target selection", () => { account("codex-local", "codex", "gpt-5.6-sol"), 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: "/repo", }); @@ -232,17 +195,7 @@ describe("canonical conversation target selection", () => { model: "gpt-5.6-sol", workspaceRepoPath: "/repo", }, - sourceModel: "gpt-5.6-sol", workspaceRepoPath: "/repo", - accounts: [ - { - ...account("stale-oauth", "claude_code", "claude-fable-5"), - status: "error", - healthStatus: "invalid", - }, - account("healthy-claude", "claude_code", "claude-opus-5"), - ], - registry, nativeCliTargets: ["claude_code", "codex"], }) ).toEqual({ @@ -260,7 +213,6 @@ describe("canonical conversation target selection", () => { sourceModel: undefined, workspaceRepoPath: "/repo", accounts: [], - registry, nativeCliTargets: ["claude_code", "codex"], }) ).toEqual({ @@ -284,7 +236,6 @@ describe("canonical conversation target selection", () => { sourceModel: "claude-opus-5", workspaceRepoPath: "/repo", accounts: [account("codex-local", "codex", "gpt-5.6-sol")], - registry, nativeCliTargets: ["claude_code", "codex"], }) ).toMatchObject({ @@ -307,7 +258,6 @@ describe("canonical conversation target selection", () => { sourceModel: "claude-opus-5", workspaceRepoPath: undefined, accounts: [account("claude-local", "claude_code", "claude-opus-5")], - registry, nativeCliTargets: ["claude_code", "codex"], }) ).toEqual({ @@ -332,12 +282,10 @@ describe("canonical conversation target selection", () => { sourceModel: "claude-opus-5", workspaceRepoPath: "/repo", 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: "/repo", }); diff --git a/src/engines/ChatPanel/conversationTargetSelection.ts b/src/engines/ChatPanel/conversationTargetSelection.ts index 538e411a35..c4fba7d3db 100644 --- a/src/engines/ChatPanel/conversationTargetSelection.ts +++ b/src/engines/ChatPanel/conversationTargetSelection.ts @@ -11,17 +11,9 @@ import type { } from "@src/engines/SessionCore/conversations/conversationTypes"; import type { AdvancedConfig } from "@src/features/SessionCreator/types"; import type { KeyVaultAccount } from "@src/hooks/keyVault"; -import { - getCliCompatibleAccounts, - getRustCompatibleAccounts, -} from "@src/hooks/models/useAgentCompatibility"; -import { - accountHasModel, - accountModelIds, -} from "@src/hooks/models/useModelAccountLookup"; +import { accountHasModel } from "@src/hooks/models/useModelAccountLookup"; 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"; @@ -68,156 +60,38 @@ interface DefaultConversationTargetInput { sourceModel?: string; /** Undefined while an imported conversation's local checkout is hydrating. */ workspaceRepoPath: string | null | undefined; - accounts: readonly KeyVaultAccount[]; - registry: AgentRegistry; + accounts?: readonly KeyVaultAccount[]; nativeCliTargets: readonly CliAgentType[]; } interface RuntimeConversationTargetInput { selection: AgentSelection; current: LocalConversationTarget | null; - sourceModel?: string; workspaceRepoPath: string | null; preferredAccountId?: string; preferredModel?: string; - accounts: readonly KeyVaultAccount[]; - registry: AgentRegistry; nativeCliTargets: readonly CliAgentType[]; } -function availableAccountModels(account: KeyVaultAccount): string[] { - return accountModelIds(account).filter((model) => - accountHasModel(account, model) - ); -} - -function chooseAccountAndModel( - candidates: readonly KeyVaultAccount[], - currentAccountId: string | undefined, - currentModel: string | undefined, - preferredAccountId: string | undefined, - preferredModel: string | undefined -): { accountId: string; model: string } | null { - const account = - candidates.find((candidate) => candidate.id === currentAccountId) ?? - candidates.find((candidate) => candidate.id === preferredAccountId) ?? - (preferredModel - ? candidates.find((candidate) => - accountHasModel(candidate, preferredModel) - ) - : undefined) ?? - candidates[0]; - if (!account) return null; - const model = - (currentModel && accountHasModel(account, currentModel) - ? currentModel - : undefined) ?? - (preferredModel && accountHasModel(account, preferredModel) - ? preferredModel - : undefined) ?? - availableAccountModels(account)[0]; - return model ? { accountId: account.id, model } : null; -} - -function isUsableTarget( - target: LocalConversationTarget | null, +function targetIsStillSelectable( + target: LocalConversationTarget, accounts: readonly KeyVaultAccount[], - registry: AgentRegistry, nativeCliTargets: readonly CliAgentType[] -): target is LocalConversationTarget { - if (!target) return false; - const parsedCliAgentType = CliAgentTypeSchema.safeParse(target.cliAgentType); - if (parsedCliAgentType.success) { - const cliAgentType = parsedCliAgentType.data; - if (!nativeCliTargets.includes(cliAgentType)) return false; - if (!target.accountId) return cliAgentType === "claude_code"; - const account = getCliCompatibleAccounts(registry, cliAgentType, [ - ...accounts, - ]).find( - (candidate) => - candidate.id === target.accountId && - candidate.enabled && - candidate.hasKey - ); - return Boolean( - account && target.model && accountHasModel(account, target.model) - ); - } - - if (!target.agentDefinitionId || !target.accountId || !target.model) { - return false; +): boolean { + if (target.cliAgentType) { + const parsed = CliAgentTypeSchema.safeParse(target.cliAgentType); + if (!parsed.success || !nativeCliTargets.includes(parsed.data)) + return false; + if (!target.accountId) return parsed.data === "claude_code"; } - const account = getRustCompatibleAccounts(registry, [...accounts]).find( - (candidate) => candidate.id === target.accountId && candidate.enabled + if (!target.accountId || !target.model) return false; + const account = accounts.find( + (candidate) => + candidate.id === target.accountId && candidate.enabled && candidate.hasKey ); return Boolean(account && accountHasModel(account, target.model)); } -function resolveCliTarget(params: { - cliAgentType: CliAgentType; - current: LocalConversationTarget | null; - sourceModel?: string; - workspaceRepoPath: string | null; - accounts: readonly KeyVaultAccount[]; - registry: AgentRegistry; -}): LocalConversationTarget | null { - const accounts = getCliCompatibleAccounts( - params.registry, - params.cliAgentType, - [...params.accounts] - ).filter((account) => account.enabled && account.hasKey); - const sameRuntime = params.current?.cliAgentType === params.cliAgentType; - const resolved = chooseAccountAndModel( - accounts, - sameRuntime ? params.current?.accountId : undefined, - sameRuntime ? params.current?.model : undefined, - undefined, - params.sourceModel - ); - if (!resolved) { - if (params.cliAgentType !== "claude_code") return null; - return { - cliAgentType: params.cliAgentType, - workspaceRepoPath: params.workspaceRepoPath, - model: sameRuntime ? params.current?.model : undefined, - }; - } - return { - cliAgentType: params.cliAgentType, - ...resolved, - workspaceRepoPath: params.workspaceRepoPath, - }; -} - -function resolveAgentTarget(params: { - agentDefinitionId: string; - current: LocalConversationTarget | null; - sourceModel?: string; - workspaceRepoPath: string | null; - preferredAccountId?: string; - preferredModel?: string; - accounts: readonly KeyVaultAccount[]; - registry: AgentRegistry; -}): LocalConversationTarget | null { - const sameRuntime = - params.current?.agentDefinitionId === params.agentDefinitionId; - const resolved = chooseAccountAndModel( - getRustCompatibleAccounts(params.registry, [...params.accounts]).filter( - (account) => account.enabled && account.hasKey - ), - sameRuntime ? params.current?.accountId : undefined, - sameRuntime ? params.current?.model : undefined, - params.preferredAccountId, - params.preferredModel ?? params.sourceModel - ); - if (!resolved) return null; - return { - agentDefinitionId: params.agentDefinitionId, - ...resolved, - workspaceRepoPath: params.workspaceRepoPath, - }; -} - export function resolveDefaultConversationTarget({ preferredTarget, initialTarget, @@ -225,8 +99,7 @@ export function resolveDefaultConversationTarget({ sourceAgentDefinitionId, sourceModel, workspaceRepoPath, - accounts, - registry, + accounts = [], nativeCliTargets, }: DefaultConversationTargetInput): LocalConversationTarget | null { // Cold boot restores the canonical execution choice before the repository @@ -239,13 +112,19 @@ export function resolveDefaultConversationTarget({ initialTarget?.workspaceRepoPath ?? null) : workspaceRepoPath; - if (isUsableTarget(preferredTarget, accounts, registry, nativeCliTargets)) { + if ( + preferredTarget && + targetIsStillSelectable(preferredTarget, accounts, nativeCliTargets) + ) { return { ...preferredTarget, workspaceRepoPath: resolvedWorkspaceRepoPath, }; } - if (isUsableTarget(initialTarget, accounts, registry, nativeCliTargets)) { + if ( + initialTarget && + targetIsStillSelectable(initialTarget, accounts, nativeCliTargets) + ) { return { ...initialTarget, workspaceRepoPath: resolvedWorkspaceRepoPath, @@ -254,24 +133,14 @@ export function resolveDefaultConversationTarget({ const parsedSource = CliAgentTypeSchema.safeParse(sourceCliAgentType); if (parsedSource.success && nativeCliTargets.includes(parsedSource.data)) { - return resolveCliTarget({ + return { cliAgentType: parsedSource.data, - current: null, - sourceModel, + model: sourceModel, workspaceRepoPath: resolvedWorkspaceRepoPath, - accounts, - registry, - }); + }; } if (sourceAgentDefinitionId) { - return resolveAgentTarget({ - agentDefinitionId: sourceAgentDefinitionId, - current: null, - sourceModel, - workspaceRepoPath: resolvedWorkspaceRepoPath, - accounts, - registry, - }); + return null; } return null; } @@ -279,55 +148,37 @@ export function resolveDefaultConversationTarget({ export function resolveConversationRuntimeTarget({ selection, current, - sourceModel, workspaceRepoPath, preferredAccountId, preferredModel, - accounts, - registry, nativeCliTargets, }: RuntimeConversationTargetInput): LocalConversationTarget | null { - let resolved: LocalConversationTarget | null = null; if (selection.category === "cli_agent" && selection.cliAgentType) { if (!nativeCliTargets.includes(selection.cliAgentType)) return null; - // Picking the Claude Code runtime means "use the signed-in local CLI". - // Managed Claude-compatible accounts (including Anthropic-compatible - // gateways such as Atlas) remain explicit model/source choices in the - // model picker; silently choosing the first one here makes a runtime-only - // switch change credentials and endpoint behind the user's back. - if (selection.cliAgentType === "claude_code") { - return { - cliAgentType: "claude_code", - workspaceRepoPath, - }; - } - resolved = resolveCliTarget({ + const sameRuntime = current?.cliAgentType === selection.cliAgentType; + return { cliAgentType: selection.cliAgentType, - current, - // A runtime pick is not a source/account pick. In particular, Claude - // Code should auto-detect its signed-in CLI account and its default - // model; carrying a Codex/source model into that runtime is invalid. - sourceModel, + accountId: sameRuntime ? current.accountId : undefined, + model: sameRuntime ? current.model : undefined, workspaceRepoPath, - accounts, - registry, - }); + }; } else if ( selection.category === "rust_agent" && selection.agentDefinitionId ) { - resolved = resolveAgentTarget({ + const sameRuntime = + current?.agentDefinitionId === selection.agentDefinitionId; + const accountId = sameRuntime ? current.accountId : preferredAccountId; + const model = sameRuntime ? current.model : preferredModel; + if (!accountId || !model) return null; + return { agentDefinitionId: selection.agentDefinitionId, - current, - sourceModel, + accountId, + model, workspaceRepoPath, - preferredAccountId, - preferredModel, - accounts, - registry, - }); + }; } - return resolved; + return null; } export function resolveConversationTargetPillPresentation({ diff --git a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts index 8c9ea54001..d276666c14 100644 --- a/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts +++ b/src/engines/ChatPanel/hooks/conversationSubmit/useConversationSubmitRouter.ts @@ -52,6 +52,15 @@ export function canonicalConversationTargetOrThrow( "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; } diff --git a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts index ab6a55464d..152df17225 100644 --- a/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts +++ b/src/engines/ChatPanel/hooks/useConversationTargetBinding.ts @@ -1,6 +1,6 @@ /** React binding from a canonical conversation to the standard creator controls. */ -import { useAtomValue } from "jotai"; -import { useCallback, useMemo, useState } from "react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useMemo } from "react"; import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import type { CliAgentType } from "@src/api/tauri/rpc/schemas/validation"; @@ -40,6 +40,10 @@ import { sessionByIdAtom, sessionsAtom, } from "@src/store/session/sessionAtom/atoms"; +import { + conversationTargetOverridesAtom, + setConversationTargetOverrideAtom, +} from "@src/store/ui/conversationTargetAtom"; /** * Project any imported provider history onto the same canonical conversation @@ -194,10 +198,8 @@ export function useConversationTargetBinding( sessions, repos, }); - const [pickerOverride, setPickerOverride] = useState<{ - rootKey: string; - target: LocalConversationTarget; - } | null>(null); + const pickerOverrides = useAtomValue(conversationTargetOverridesAtom); + const setPickerOverride = useSetAtom(setConversationTargetOverrideAtom); const source = useMemo(() => { const externalSource = conversationSourceFromImportedHistory({ @@ -248,9 +250,8 @@ export function useConversationTargetBinding( [persistedExecution] ); const preferredTarget = - pickerOverride?.rootKey === sourceRootKey - ? pickerOverride.target - : persistedTarget; + (sourceRootKey ? pickerOverrides.get(sourceRootKey) : undefined) ?? + persistedTarget; const agentDiscoverySettled = discoveryState === "ready" || @@ -283,7 +284,6 @@ export function useConversationTargetBinding( ? undefined : source.workspaceRepoPath, accounts, - registry, nativeCliTargets, }); }, [ @@ -291,7 +291,6 @@ export function useConversationTargetBinding( cloudSource.workspacePending, inventoryLoading, nativeCliTargets, - registry, preferredTarget, source, ]); @@ -380,7 +379,7 @@ export function useConversationTargetBinding( }); return true; }, - [readiness, source, target] + [readiness, setPickerOverride, source, target] ); const applyRuntimePick = useCallback( @@ -394,13 +393,10 @@ export function useConversationTargetBinding( const next = resolveConversationRuntimeTarget({ selection, current: target, - sourceModel: source.model, workspaceRepoPath: target?.workspaceRepoPath ?? source.workspaceRepoPath, preferredAccountId: definition?.selectedAccountId, preferredModel: definition?.selectedModelId, - accounts, - registry, nativeCliTargets, }); if (!next) return false; @@ -411,11 +407,10 @@ export function useConversationTargetBinding( return true; }, [ - accounts, definitions, nativeCliTargets, readiness, - registry, + setPickerOverride, source, target, ] diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts index 85def8c2c4..49a5849127 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useMessageDispatch.ts @@ -76,7 +76,6 @@ export function useMessageDispatch() { visibleText, imageDataUrls, runtimeStatusSource, - pendingPolicy: "visible", beforeAppend, send: { content, diff --git a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts index bdeffe3131..b9cb1e7c7a 100644 --- a/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts +++ b/src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit.ts @@ -11,20 +11,20 @@ import { useCallback, useEffect } from "react"; import type { AgentExecMode } from "@src/config/sessionCreatorConfig"; import { resolveSessionAgentExecMode } from "@src/config/sessionCreatorConfig"; -import { - admitUserIntentToMessageQueue, - isExplicitPostStopSubmit, -} from "@src/engines/SessionCore/control/messageQueueAdmission"; import { getTurnPhase } from "@src/engines/SessionCore/control/turnLifecycle"; import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { type SessionRuntimeStatusSource, isSessionActiveAtom, lastUserMessageAtom, + postStopDispatchSessionsAtom, } from "@src/store/session/cliSessionStatusAtom"; import { creatorDefaultModelSelectionAtom } from "@src/store/session/creatorDefaultModelAtom"; import { sessionMapAtom } from "@src/store/session/sessionAtom"; -import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; +import { + enqueueMessageAtom, + messageQueueAtom, +} from "@src/store/ui/messageQueueAtom"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { @@ -139,11 +139,9 @@ export function useUserIntentSubmit({ imageDataUrls, }) : false; - const explicitPostStopSubmit = isExplicitPostStopSubmit( - store, - sessionId, - restoredStopDraftSubmit - ); + const explicitPostStopSubmit = + restoredStopDraftSubmit || + store.get(postStopDispatchSessionsAtom)[sessionId] === true; if ( dedupeDirectSubmit && @@ -177,21 +175,18 @@ export function useUserIntentSubmit({ session?.agentExecMode ); - const queueResult = admitUserIntentToMessageQueue({ - store, - explicitPostStopSubmit, - message: { - id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, - turnIntentId, - sessionId, - content: contentForAgent, - displayContent, - imageDataUrls, - modelSelection: snapshotSelection ?? undefined, - agentExecMode: snapshotMode, - status: "queued", - createdAt: new Date().toISOString(), - }, + const queueResult = store.set(enqueueMessageAtom, { + id: `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + turnIntentId, + sessionId, + content: contentForAgent, + displayContent, + imageDataUrls, + modelSelection: snapshotSelection ?? undefined, + agentExecMode: snapshotMode, + priority: explicitPostStopSubmit ? "now" : "next", + status: "queued", + createdAt: new Date().toISOString(), }); if (queueResult !== "enqueued" && queueResult !== "duplicate") { throw new Error( diff --git a/src/engines/SessionCore/control/messageQueueAdmission.ts b/src/engines/SessionCore/control/messageQueueAdmission.ts deleted file mode 100644 index f29342d02c..0000000000 --- a/src/engines/SessionCore/control/messageQueueAdmission.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Store } from "jotai/vanilla/store"; - -import { postStopDispatchSessionsAtom } from "@src/store/session/cliSessionStatusAtom"; -import { - type QueueAdmissionResult, - type QueuedMessage, - enqueueMessageAtom, -} from "@src/store/ui/messageQueueAtom"; - -/** - * Admit every user-authored queued turn through the same post-Stop policy. - * - * Runtime continuation changes where a queued turn executes, not how Stop, - * Send Now, or explicit queue release behave. Keeping that decision here - * prevents canonical/imported conversations from silently bypassing the - * ordinary composer contract. - */ -export function admitUserIntentToMessageQueue(params: { - store: Store; - message: Omit; - explicitPostStopSubmit: boolean; -}): QueueAdmissionResult { - const { store, message, explicitPostStopSubmit } = params; - const result = store.set(enqueueMessageAtom, { - ...message, - priority: explicitPostStopSubmit ? "now" : "next", - }); - - return result; -} - -export function isExplicitPostStopSubmit( - store: Store, - sessionId: string, - restoredStopDraft = false -): boolean { - return ( - restoredStopDraft || - store.get(postStopDispatchSessionsAtom)[sessionId] === true - ); -} diff --git a/src/engines/SessionCore/conversations/canonicalConversationExecution.test.ts b/src/engines/SessionCore/conversations/canonicalConversationExecution.test.ts new file mode 100644 index 0000000000..6a3a4285fb --- /dev/null +++ b/src/engines/SessionCore/conversations/canonicalConversationExecution.test.ts @@ -0,0 +1,335 @@ +import { createStore } from "jotai/vanilla"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { messageQueueAtom } from "@src/store/ui/messageQueueAtom"; +import { resetMessageQueueRepositoryForTests } from "@src/store/ui/messageQueueRepository"; + +import { + type CanonicalConversationExecution, + canonicalConversationExecutionsAtom, + handoffQueuedMessageToCanonicalExecution, + hydrateCanonicalConversationExecutions, + resetCanonicalConversationExecutionForTests, + returnCanonicalExecutionToMessageQueue, +} from "./canonicalConversationExecution"; + +const mocks = vi.hoisted(() => ({ + rows: undefined as unknown, + queueRows: [] as unknown[], + queueRowsByKey: {} as Record, + queueReadError: false, + windowLabel: "browser", + save: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-store", () => ({ + load: async () => ({ + reload: async () => undefined, + get: async (key: string) => { + if (key === "executions") return mocks.rows; + if (mocks.queueReadError) throw new Error("queue store starting"); + return mocks.queueRowsByKey[key] ?? mocks.queueRows; + }, + set: async (key: string, value: unknown) => { + if (key === "executions") mocks.rows = value; + else { + mocks.queueRows = value as unknown[]; + mocks.queueRowsByKey[key] = value as unknown[]; + } + }, + save: mocks.save, + }), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ label: mocks.windowLabel }), +})); + +function execution(index: number): CanonicalConversationExecution { + const id = `execution-${index}`; + return { + id, + originQueueKey: "queue:browser", + message: { + id, + turnIntentId: `turn-${index}`, + sessionId: `session-${index}`, + content: "hello", + displayContent: "hello", + conversationDispatch: { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: `root-${index}`, + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + workspaceRepoPath: "/repo", + }, + }, + }, + status: "accepted", + runnerSessionId: `cliagent-${index}`, + createdAt: "2026-09-02T00:00:00.000Z", + }; +} + +describe("canonical conversation execution persistence", () => { + beforeEach(() => { + mocks.rows = undefined; + mocks.queueRows = []; + mocks.queueRowsByKey = {}; + mocks.queueReadError = false; + mocks.windowLabel = "browser"; + mocks.save.mockReset(); + resetMessageQueueRepositoryForTests(); + resetCanonicalConversationExecutionForTests(); + }); + + it("fails closed instead of silently dropping an invalid row", async () => { + mocks.rows = [{ ...execution(1), message: { content: "truncated" } }]; + + await expect( + hydrateCanonicalConversationExecutions(createStore()) + ).rejects.toThrow("invalid row"); + }); + + it("rejects accepted rows without a durable native runner", async () => { + const { runnerSessionId: _runnerSessionId, ...invalid } = execution(1); + mocks.rows = [invalid]; + + await expect( + hydrateCanonicalConversationExecutions(createStore()) + ).rejects.toThrow("invalid row"); + }); + + it("does not publish or overwrite an unknown queue hydration snapshot", async () => { + const queued = { + ...execution(1).message, + priority: "next" as const, + status: "queued" as const, + createdAt: "2026-09-02T00:00:00.000Z", + }; + mocks.rows = []; + mocks.queueRows = [queued]; + mocks.queueReadError = true; + const publish = vi.fn(); + const store = createStore(); + + await expect( + hydrateCanonicalConversationExecutions(store, publish) + ).rejects.toThrow("queue store starting"); + expect(publish).not.toHaveBeenCalled(); + expect(mocks.save).not.toHaveBeenCalled(); + + mocks.queueReadError = false; + await hydrateCanonicalConversationExecutions(store, publish); + expect(publish).toHaveBeenCalledWith([queued], []); + }); + + it("rejects capacity overflow instead of evicting an accepted owner", async () => { + mocks.rows = Array.from({ length: 100 }, (_, index) => execution(index)); + const overflow = execution(100); + mocks.queueRows = [ + { + ...overflow.message, + priority: "next" as const, + status: "queued" as const, + createdAt: overflow.createdAt, + }, + ]; + const store = createStore(); + await hydrateCanonicalConversationExecutions(store); + + await expect( + handoffQueuedMessageToCanonicalExecution(store, overflow) + ).rejects.toThrow("row limit"); + expect(mocks.rows).toHaveLength(100); + expect(mocks.save).not.toHaveBeenCalled(); + }); + + it("keeps an accepted owner when a stale queued twin is handed off", async () => { + const accepted = execution(1); + const staleQueueTwin = { + ...accepted.message, + priority: "next" as const, + status: "queued" as const, + createdAt: accepted.createdAt, + }; + mocks.rows = [accepted]; + mocks.queueRows = [staleQueueTwin]; + const store = createStore(); + store.set(messageQueueAtom, [staleQueueTwin]); + + await handoffQueuedMessageToCanonicalExecution(store, { + ...accepted, + status: "preparing", + runnerSessionId: undefined, + }); + + expect(mocks.rows).toEqual([accepted]); + expect(store.get(messageQueueAtom)).toEqual([]); + }); + + it("does not treat an edited same-id turn as the existing owner", async () => { + const owner = execution(1); + const edited = { + ...owner.message, + turnIntentId: "turn-edited", + content: "edited body", + displayContent: "edited body", + priority: "next" as const, + status: "queued" as const, + createdAt: owner.createdAt, + }; + mocks.rows = [owner]; + mocks.queueRows = [edited]; + const store = createStore(); + store.set(messageQueueAtom, [edited]); + + await expect( + handoffQueuedMessageToCanonicalExecution(store, { + ...owner, + message: edited, + status: "preparing", + runnerSessionId: undefined, + }) + ).rejects.toThrow("another window"); + + expect(mocks.rows).toEqual([owner]); + expect(mocks.queueRows).toEqual([edited]); + expect(store.get(messageQueueAtom)).toEqual([edited]); + }); + + it("preserves a concurrent enqueue while handoff persistence is pending", async () => { + const store = createStore(); + const first = { + ...execution(1).message, + priority: "next" as const, + status: "queued" as const, + createdAt: "2026-09-02T00:00:00.000Z", + }; + const second = { + ...first, + id: "queued-second", + turnIntentId: "turn-second", + }; + mocks.queueRows = [first]; + store.set(messageQueueAtom, [first]); + let releaseSave!: () => void; + mocks.save.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSave = resolve; + }) + ); + + const handoff = handoffQueuedMessageToCanonicalExecution( + store, + execution(1) + ); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledOnce()); + store.set(messageQueueAtom, (current) => [...current, second]); + releaseSave(); + await handoff; + + expect(store.get(messageQueueAtom)).toEqual([second]); + }); + + it("preserves concurrent queue mutations while returning an execution", async () => { + const store = createStore(); + const returned = { + ...execution(1).message, + priority: "next" as const, + requiresExplicitDispatch: true, + status: "queued" as const, + createdAt: "2026-09-02T00:00:00.000Z", + }; + const concurrent = { + ...returned, + id: "queued-concurrent", + turnIntentId: "turn-concurrent", + }; + mocks.rows = [execution(1)]; + let releaseSave!: () => void; + mocks.save.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSave = resolve; + }) + ); + + const restoration = returnCanonicalExecutionToMessageQueue( + store, + returned.id, + returned + ); + await vi.waitFor(() => expect(mocks.save).toHaveBeenCalledOnce()); + store.set(messageQueueAtom, (current) => [concurrent, ...current]); + releaseSave(); + await restoration; + + expect(store.get(messageQueueAtom)).toEqual([concurrent, returned]); + }); + + it("returns a blocked execution to the live claimant when its origin closed", async () => { + const store = createStore(); + const owner = execution(1); + const returned = { + ...owner.message, + priority: "next" as const, + requiresExplicitDispatch: true, + status: "queued" as const, + createdAt: owner.createdAt, + }; + mocks.rows = [owner]; + store.set(canonicalConversationExecutionsAtom, [owner]); + mocks.windowLabel = "detached"; + resetMessageQueueRepositoryForTests(); + + const returnedToClaimant = await returnCanonicalExecutionToMessageQueue( + store, + owner.id, + returned + ); + + expect(returnedToClaimant).toBe(true); + expect(mocks.queueRowsByKey["queue:browser"]).toBeUndefined(); + expect(mocks.queueRowsByKey["queue:detached"]).toEqual([returned]); + expect(store.get(messageQueueAtom)).toEqual([returned]); + expect(mocks.rows).toEqual([]); + }); + + it("keeps a newer same-id intent when an older execution returns", async () => { + const store = createStore(); + const owner = execution(1); + const newer = { + ...owner.message, + turnIntentId: "turn-newer", + content: "newer body", + displayContent: "newer body", + priority: "next" as const, + status: "queued" as const, + createdAt: owner.createdAt, + }; + mocks.rows = [owner]; + mocks.queueRows = [newer]; + store.set(canonicalConversationExecutionsAtom, [owner]); + store.set(messageQueueAtom, [newer]); + + await returnCanonicalExecutionToMessageQueue(store, owner.id, { + ...owner.message, + priority: "next", + requiresExplicitDispatch: true, + status: "queued", + createdAt: owner.createdAt, + }); + + expect(mocks.queueRows).toEqual([newer]); + expect(store.get(messageQueueAtom)).toEqual([newer]); + expect(mocks.rows).toEqual([]); + }); +}); diff --git a/src/engines/SessionCore/conversations/canonicalConversationExecution.ts b/src/engines/SessionCore/conversations/canonicalConversationExecution.ts new file mode 100644 index 0000000000..b94a82e050 --- /dev/null +++ b/src/engines/SessionCore/conversations/canonicalConversationExecution.ts @@ -0,0 +1,559 @@ +import type { Store as TauriStore } from "@tauri-apps/plugin-store"; +import { atom } from "jotai"; +import type { Store } from "jotai/vanilla/store"; + +import { createLogger } from "@src/hooks/logger"; +import { + type QueuedMessage, + messageQueueAtom, + messageQueueHandoffIdsAtom, + queueAdmissionResult, +} from "@src/store/ui/messageQueueAtom"; +import { + serializeMessageQueueStoreMutation, + validatedDurableMessageQueue, + withMessageQueueStoreTransaction, +} from "@src/store/ui/messageQueueRepository"; + +import { conversationRootKey } from "./conversationTypes"; +import { + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL, + QueuedConversationBusyError, + type QueuedConversationMessage, + QueuedConversationRecoveryPendingError, + isQueuedConversationMessagePayload, + queuedConversationMessageCharSize, +} from "./queuedConversationExecutor"; + +const log = createLogger("CanonicalConversationExecution"); +const STORE_KEY = "executions"; +const STORE_LOCK = "orgii:canonical-conversation-executions"; +const CONVERSATION_TURN_LOCK_PREFIX = "orgii:canonical-conversation:"; +const MAX_EXECUTIONS = 100; + +export interface CanonicalConversationExecution { + id: string; + message: QueuedConversationMessage; + /** Window-local queue key that admitted this app-global execution. */ + originQueueKey?: string; + status: "preparing" | "accepted"; + runnerSessionId?: string; + runnerEventStartIndex?: number; + retryAt?: string; + retryAttempt?: number; + createdAt: string; +} + +export const canonicalConversationExecutionsAtom = atom< + CanonicalConversationExecution[] +>([]); +canonicalConversationExecutionsAtom.debugLabel = + "canonicalConversationExecutionsAtom"; + +export const canonicalConversationExecutionsHydratedAtom = atom(false); +canonicalConversationExecutionsHydratedAtom.debugLabel = + "canonicalConversationExecutionsHydratedAtom"; +export const canonicalConversationExternalMutationAtom = atom(0); +canonicalConversationExternalMutationAtom.debugLabel = + "canonicalConversationExternalMutationAtom"; + +const hydrationByStore = new WeakMap>(); +const hydratedStores = new Set(); +const mutationGenerationByStore = new WeakMap(); +const externalRefreshByStore = new WeakMap>(); +let mutationChannel: BroadcastChannel | null = null; + +function mutationGeneration(store: Store): number { + return mutationGenerationByStore.get(store) ?? 0; +} + +function noteLocalMutation(store: Store): void { + mutationGenerationByStore.set(store, mutationGeneration(store) + 1); +} + +/** Serialize one canonical root across all Tauri webviews. */ +export async function withCanonicalConversationTurnLock( + root: import("./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; +} + +function ensureMutationChannel(): BroadcastChannel | null { + if (mutationChannel || typeof BroadcastChannel === "undefined") { + return mutationChannel; + } + mutationChannel = new BroadcastChannel(STORE_LOCK); + mutationChannel.addEventListener("message", () => { + for (const jotaiStore of hydratedStores) { + mutationGenerationByStore.set( + jotaiStore, + mutationGeneration(jotaiStore) + 1 + ); + if (!externalRefreshByStore.has(jotaiStore)) { + const refresh = (async () => { + let observed: number; + do { + observed = mutationGeneration(jotaiStore); + await refreshCanonicalConversationExecutions(jotaiStore); + } while (observed !== mutationGeneration(jotaiStore)); + jotaiStore.set( + canonicalConversationExternalMutationAtom, + (value) => value + 1 + ); + })() + .catch((error) => + log.warn( + "failed to refresh executions after cross-window mutation", + error + ) + ) + .finally(() => externalRefreshByStore.delete(jotaiStore)); + externalRefreshByStore.set(jotaiStore, refresh); + } + } + }); + return mutationChannel; +} + +function isExecution(value: unknown): value is CanonicalConversationExecution { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return Boolean( + typeof candidate.id === "string" && + candidate.message?.id === candidate.id && + (candidate.originQueueKey === undefined || + (typeof candidate.originQueueKey === "string" && + candidate.originQueueKey.startsWith("queue:"))) && + (candidate.status === "preparing" || candidate.status === "accepted") && + (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" && + isQueuedConversationMessagePayload(candidate.message) && + (candidate.retryAt === undefined || + typeof candidate.retryAt === "string") && + (candidate.retryAttempt === undefined || + (typeof candidate.retryAttempt === "number" && + Number.isSafeInteger(candidate.retryAttempt) && + candidate.retryAttempt >= 0)) + ); +} + +function validateExecutionSnapshot( + value: unknown +): CanonicalConversationExecution[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + throw new Error("canonical execution store is not an array"); + } + if (!value.every(isExecution)) { + throw new Error("canonical execution store contains an invalid row"); + } + if (value.length > MAX_EXECUTIONS) { + throw new Error("canonical execution store exceeds its row limit"); + } + const ids = new Set(); + const intentIds = new Set(); + for (const execution of value) { + if ( + ids.has(execution.id) || + intentIds.has(execution.message.turnIntentId) + ) { + throw new Error("canonical execution store contains duplicate ownership"); + } + ids.add(execution.id); + intentIds.add(execution.message.turnIntentId); + } + const totalChars = value.reduce( + (total, execution) => + total + queuedConversationMessageCharSize(execution.message), + 0 + ); + if (totalChars > MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL) { + throw new Error("canonical execution store exceeds its payload limit"); + } + return value; +} + +async function readExecutionsLocked( + store: TauriStore +): Promise { + const value = await store.get(STORE_KEY); + return validateExecutionSnapshot(value); +} + +interface CanonicalDeliverySnapshot { + executions: CanonicalConversationExecution[]; + queue: QueuedMessage[]; +} + +async function readAndReconcileDeliveryLocked( + durable: TauriStore, + windowQueueKey: string +): Promise { + const executions = await readExecutionsLocked(durable); + const queue = validatedDurableMessageQueue( + await durable.get(windowQueueKey) + ); + const executionIntentIds = new Set( + executions.map((execution) => execution.message.turnIntentId) + ); + const reconciledQueue = queue.filter( + (message) => !executionIntentIds.has(message.turnIntentId) + ); + if (reconciledQueue.length !== queue.length) { + await durable.set(windowQueueKey, reconciledQueue); + await durable.save(); + } + return { executions, queue: reconciledQueue }; +} + +export async function hydrateCanonicalConversationExecutions( + store: Store, + publishQueueSnapshot?: ( + queue: readonly QueuedMessage[], + executions: readonly CanonicalConversationExecution[] + ) => void +): Promise { + const existing = hydrationByStore.get(store); + if (existing) return await existing; + const hydration = (async () => { + // Subscribe before reading the snapshot. If another webview commits in + // the read→publish window, its generation bump forces one trailing read + // instead of allowing the stale snapshot to overwrite the newer owner. + hydratedStores.add(store); + ensureMutationChannel(); + let generation = mutationGeneration(store); + let snapshot = await serializeMessageQueueStoreMutation( + (durable, windowQueueKey) => + readAndReconcileDeliveryLocked(durable, windowQueueKey) + ); + while (generation !== mutationGeneration(store)) { + generation = mutationGeneration(store); + snapshot = await serializeMessageQueueStoreMutation( + (durable, windowQueueKey) => + readAndReconcileDeliveryLocked(durable, windowQueueKey) + ); + } + store.set(canonicalConversationExecutionsAtom, snapshot.executions); + publishQueueSnapshot?.(snapshot.queue, snapshot.executions); + store.set(canonicalConversationExecutionsHydratedAtom, true); + })(); + hydrationByStore.set(store, hydration); + try { + await hydration; + } catch (error) { + // `hydrated` means the attempt settled, not that an empty list is + // authoritative. Reject to keep delivery recovery fail-closed, and allow + // a later mount to retry the transient store failure. + hydrationByStore.delete(store); + hydratedStores.delete(store); + store.set(canonicalConversationExecutionsHydratedAtom, false); + log.warn("failed to hydrate executions", error); + throw error; + } +} + +async function mutateDurableExecutions( + mutation: ( + current: CanonicalConversationExecution[] + ) => CanonicalConversationExecution[] +): Promise { + return await serializeMessageQueueStoreMutation(async (store) => { + const current = await readExecutionsLocked(store); + const next = validateExecutionSnapshot(mutation(current)); + await store.set(STORE_KEY, next); + await store.save(); + return next; + }); +} + +/** Atomically replace one window-local queue row with its execution owner. */ +export async function handoffQueuedMessageToCanonicalExecution( + jotaiStore: Store, + execution: CanonicalConversationExecution +): Promise { + const result = await serializeMessageQueueStoreMutation( + async (durable, windowQueueKey) => { + const current = await readExecutionsLocked(durable); + const durableQueue = validatedDurableMessageQueue( + await durable.get(windowQueueKey) + ); + const existingOwner = current.find( + (candidate) => + candidate.id === execution.id && + candidate.message.turnIntentId === execution.message.turnIntentId + ); + const conflictingOwner = current.some( + (candidate) => + candidate.id === execution.id || + candidate.message.turnIntentId === execution.message.turnIntentId + ); + const sourceRow = durableQueue.find( + (message) => + message.id === execution.id && + message.turnIntentId === execution.message.turnIntentId + ); + if ( + (!existingOwner && !sourceRow) || + (!existingOwner && conflictingOwner) + ) { + throw new QueuedConversationBusyError(); + } + const persistedExecution: CanonicalConversationExecution = + existingOwner ?? { ...execution, originQueueKey: windowQueueKey }; + const nextQueue = durableQueue.filter( + (message) => + !( + message.id === persistedExecution.id && + message.turnIntentId === persistedExecution.message.turnIntentId + ) + ); + const next = existingOwner + ? current + : validateExecutionSnapshot([...current, persistedExecution]); + await durable.set(windowQueueKey, nextQueue); + await durable.set(STORE_KEY, next); + await durable.save(); + return { + execution: persistedExecution, + executions: next, + queue: nextQueue, + }; + } + ); + noteLocalMutation(jotaiStore); + jotaiStore.set(messageQueueAtom, (current) => + current.filter( + (message) => message.turnIntentId !== execution.message.turnIntentId + ) + ); + jotaiStore.set(canonicalConversationExecutionsAtom, (current) => [ + ...current.filter( + (candidate) => + candidate.id !== result.execution.id && + candidate.message.turnIntentId !== result.execution.message.turnIntentId + ), + result.execution, + ]); + ensureMutationChannel()?.postMessage({ type: "changed" }); +} + +/** Atomically return a pre-accept execution to the visible UI queue. */ +async function returnCanonicalExecutionToMessageQueueImpl( + jotaiStore: Store, + executionId: string, + message: QueuedMessage +): Promise { + const result = await serializeMessageQueueStoreMutation( + async (durable, claimantQueueKey) => { + const current = await readExecutionsLocked(durable); + const owner = current.find((candidate) => candidate.id === executionId); + if (!owner) { + throw new QueuedConversationRecoveryPendingError( + "canonical execution owner is temporarily unavailable" + ); + } + const durableQueue = validatedDurableMessageQueue( + await durable.get(claimantQueueKey) + ); + const supersedingMessage = durableQueue.find( + (candidate) => + candidate.id === message.id && + candidate.turnIntentId !== message.turnIntentId + ); + const baseQueue = durableQueue.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 restoredQueue = [...baseQueue, restoredMessage]; + const next = current.filter((candidate) => candidate.id !== executionId); + // The claimant is alive and already dispatching this canonical root; + // returning there cannot strand the row in a crashed origin webview. + await durable.set(claimantQueueKey, restoredQueue); + await durable.set(STORE_KEY, next); + await durable.save(); + return { next, restoredMessage }; + } + ); + noteLocalMutation(jotaiStore); + jotaiStore.set(messageQueueAtom, (current) => [ + ...current.filter( + (candidate) => + candidate.id !== result.restoredMessage.id && + candidate.turnIntentId !== result.restoredMessage.turnIntentId + ), + result.restoredMessage, + ]); + jotaiStore.set(canonicalConversationExecutionsAtom, result.next); + ensureMutationChannel()?.postMessage({ type: "changed" }); + return true; +} + +export async function returnCanonicalExecutionToMessageQueue( + jotaiStore: Store, + executionId: string, + message: QueuedMessage +): Promise { + jotaiStore.set(messageQueueHandoffIdsAtom, (current) => { + const next = new Set(current); + next.add(message.id); + return next; + }); + try { + return await returnCanonicalExecutionToMessageQueueImpl( + jotaiStore, + executionId, + message + ); + } finally { + jotaiStore.set(messageQueueHandoffIdsAtom, (current) => { + if (!current.has(message.id)) return current; + const next = new Set(current); + next.delete(message.id); + return next; + }); + } +} + +export async function updateCanonicalConversationExecution( + jotaiStore: Store, + executionId: string, + update: Partial> +): Promise { + let updated: CanonicalConversationExecution | null = null; + await mutateDurableExecutions((current) => + current.map((candidate) => { + if (candidate.id !== executionId) return candidate; + updated = { ...candidate, ...update }; + return updated; + }) + ); + noteLocalMutation(jotaiStore); + if (updated) { + jotaiStore.set(canonicalConversationExecutionsAtom, (current) => + current.map((candidate) => + candidate.id === executionId ? updated! : candidate + ) + ); + } + ensureMutationChannel()?.postMessage({ type: "changed" }); + return updated; +} + +export async function removeCanonicalConversationExecution( + jotaiStore: Store, + executionId: string +): Promise { + await mutateDurableExecutions((current) => + current.filter((candidate) => candidate.id !== executionId) + ); + noteLocalMutation(jotaiStore); + jotaiStore.set(canonicalConversationExecutionsAtom, (current) => + current.filter((candidate) => candidate.id !== executionId) + ); + ensureMutationChannel()?.postMessage({ type: "changed" }); +} + +export async function refreshCanonicalConversationExecutions( + jotaiStore: Store +): Promise { + let generation = mutationGeneration(jotaiStore); + let rows = await withMessageQueueStoreTransaction((durable) => + readExecutionsLocked(durable) + ); + while (generation !== mutationGeneration(jotaiStore)) { + generation = mutationGeneration(jotaiStore); + rows = await withMessageQueueStoreTransaction((durable) => + readExecutionsLocked(durable) + ); + } + jotaiStore.set(canonicalConversationExecutionsAtom, rows); +} + +/** Verify the durable FIFO head again while the caller owns the root lock. */ +export async function assertCanonicalExecutionIsDurableRootHead( + executionId: string +): Promise { + return await withMessageQueueStoreTransaction(async (durable) => { + const current = await readExecutionsLocked(durable); + const owner = current.find((candidate) => candidate.id === executionId); + const descriptor = owner?.message.conversationDispatch; + if (!owner || !descriptor) { + throw new QueuedConversationBusyError(); + } + const rootKey = conversationRootKey(descriptor.root); + const head = current.find((candidate) => { + const candidateDescriptor = candidate.message.conversationDispatch; + return ( + candidateDescriptor !== undefined && + conversationRootKey(candidateDescriptor.root) === rootKey + ); + }); + if (head?.id !== executionId) { + throw new QueuedConversationBusyError(); + } + return owner; + }); +} + +export function resetCanonicalConversationExecutionForTests(): void { + hydratedStores.clear(); + mutationChannel?.close(); + mutationChannel = null; +} + +export function disposeCanonicalConversationExecution(store: Store): void { + hydratedStores.delete(store); + hydrationByStore.delete(store); + mutationGenerationByStore.delete(store); + store.set(canonicalConversationExecutionsHydratedAtom, false); +} diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts index a8db9519a9..75da3ed12c 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueuedConversationRecoveryPendingError } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { @@ -220,20 +221,16 @@ beforeEach(() => { receipt: { nativeSessionId: sessionId, itemCount: childEvents.length }, }; }); - mocks.synchronize.mockImplementation( - async ({ sessionId, timeline, existingEvents }) => { - childEvents = [ - ...(existingEvents as SessionEvent[]), - ...(timeline as SessionEvent[]) - .slice((existingEvents as SessionEvent[]).length) - .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); @@ -386,6 +383,69 @@ describe("local native conversation continuation", () => { 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("does not open a second source lifecycle when target launch fails", async () => { mocks.create.mockRejectedValueOnce(new Error("OAuth refresh rejected")); @@ -469,16 +529,8 @@ describe("local native conversation continuation", () => { ], "agentsession-child" ); - expect(mocks.storeSet).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - sessionId: "agentsession-child", - displayText: "new request", - }) - ); expect(result).toMatchObject({ sessionId: "agentsession-child", - created: true, agentTail: [expect.objectContaining({ displayText: "native answer" })], }); }); @@ -672,7 +724,6 @@ describe("local native conversation continuation", () => { ); expect(result).toMatchObject({ sessionId: "cliagent-rebuilt", - created: true, terminalStatus: "completed", agentTail: [expect.objectContaining({ displayText: "rebuilt answer" })], }); @@ -704,7 +755,6 @@ describe("local native conversation continuation", () => { expect(next).toMatchObject({ sessionId: "cliagent-rebuilt", - created: false, terminalStatus: "completed", }); expect(mocks.create).toHaveBeenCalledTimes(1); @@ -807,7 +857,6 @@ describe("local native conversation continuation", () => { expect(result).toMatchObject({ sessionId: "cliagent-partial", - created: false, terminalStatus: "failed", agentTail: [ expect.objectContaining({ @@ -962,14 +1011,6 @@ describe("local native conversation continuation", () => { "completed", { generation: 3 } ); - expect(mocks.storeSet).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - sessionId: "agentsession-child", - status: "completed", - source: "sync", - }) - ); }); it("waits for an exact CLI turn in Rust when background timers are throttled", async () => { @@ -1327,7 +1368,7 @@ describe("local native conversation continuation", () => { agentDefinitionId: "builtin:sde", }); - const result = await continueLocalConversation({ + await continueLocalConversation({ root, title: "Shared", timeline, @@ -1336,7 +1377,6 @@ describe("local native conversation continuation", () => { turnIntentId: "turn-2", }); - expect(result.created).toBe(false); expect(mocks.create).not.toHaveBeenCalled(); expect(mocks.materialize).not.toHaveBeenCalled(); expect(mocks.sendMessage).toHaveBeenCalledWith( @@ -1515,7 +1555,6 @@ describe("local native conversation continuation", () => { expect(result).toMatchObject({ sessionId: "agentsession-existing", - created: false, }); expect(mocks.create).not.toHaveBeenCalled(); expect(mocks.sendMessage).toHaveBeenCalledWith( @@ -1561,7 +1600,6 @@ describe("local native conversation continuation", () => { expect(result).toMatchObject({ sessionId: "agentsession-existing", - created: false, }); expect(mocks.create).not.toHaveBeenCalled(); }); @@ -1598,7 +1636,6 @@ describe("local native conversation continuation", () => { expect(result).toMatchObject({ sessionId: "agentsession-existing", - created: false, }); expect(mocks.create).not.toHaveBeenCalled(); }); @@ -1620,7 +1657,7 @@ describe("local native conversation continuation", () => { }); const timeline = [event("new", "user", "teammate added context")]; - const result = await continueLocalConversation({ + await continueLocalConversation({ root, title: "Shared", timeline, @@ -1628,7 +1665,6 @@ describe("local native conversation continuation", () => { target, turnIntentId: "turn-3", }); - expect(result.created).toBe(true); expect(mocks.materialize).toHaveBeenCalledWith({ sessionId: "agentsession-child", timeline, @@ -1656,7 +1692,7 @@ describe("local native conversation continuation", () => { }); const timeline = [existing, event("a1", "assistant", "remote answer")]; - const result = await continueLocalConversation({ + await continueLocalConversation({ root, title: "Shared", timeline, @@ -1670,11 +1706,9 @@ describe("local native conversation continuation", () => { turnIntentId: "turn-native-delta", }); - expect(result.created).toBe(false); expect(mocks.synchronize).toHaveBeenCalledWith({ sessionId: "cliagent-existing", timeline, - existingEvents: [existing], }); expect(mocks.mergeEvents).toHaveBeenCalledWith( [expect.objectContaining({ displayText: "remote answer" })], @@ -1833,24 +1867,20 @@ describe("local native conversation continuation", () => { receipt: { nativeSessionId: sessionId, itemCount: materialized.length }, }; }); - mocks.synchronize.mockImplementation( - async ({ sessionId, timeline, existingEvents }) => { - const synchronized = [ - ...(existingEvents as SessionEvent[]), - ...(timeline as SessionEvent[]) - .slice((existingEvents as SessionEvent[]).length) - .map((item) => ({ ...item, sessionId })), - ]; - eventsBySession.set(sessionId, synchronized); - return { - events: synchronized, - receipt: { - nativeSessionId: sessionId, - itemCount: synchronized.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 }) => { @@ -1886,7 +1916,6 @@ describe("local native conversation continuation", () => { }); expect(first).toMatchObject({ sessionId: localRoot.conversationId, - created: false, }); const middle = await continueLocalConversation({ @@ -1904,7 +1933,6 @@ describe("local native conversation continuation", () => { }); expect(middle).toMatchObject({ sessionId: "cliagent-codex-child", - created: true, }); const last = await continueLocalConversation({ @@ -1917,7 +1945,6 @@ describe("local native conversation continuation", () => { }); expect(last).toMatchObject({ sessionId: localRoot.conversationId, - created: false, }); expect(mocks.create).toHaveBeenCalledTimes(1); expect(sentInto).toEqual([ diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.ts b/src/engines/SessionCore/conversations/localConversationContinuation.ts index c3b6c6099f..af78fa7530 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -28,7 +28,6 @@ import { type UserIntentPreparation, UserIntentSendError, activateUserIntentPreparation, - clearParkedUserIntentEvent, confirmUserIntentPreparation, dispatchUserIntent, failUserIntentPreparation, @@ -38,8 +37,6 @@ import { import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; -import { setSessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { invokeTauri } from "@src/util/platform/tauri/init"; import { isCliSession } from "@src/util/session/sessionDispatch"; @@ -51,9 +48,14 @@ import { materializeNativeConversation, nativeConversationItemsArePrefix, projectNativeConversationItems, + sourceEventIdOfNativeItem, supportsNativeConversationTarget, synchronizeNativeConversation, } from "./nativeConversationMaterializer"; +import { + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, +} from "./queuedConversationExecutor"; export type { ConversationRootLocator, @@ -98,8 +100,6 @@ interface ContinueLocalConversationParams { imageDataUrls?: string[]; target: LocalConversationTarget; turnIntentId: string; - /** Runs after the singleton queue grants this conversation its turn. */ - beforeDispatch?: () => void | Promise; onSessionReady?: ( sessionId: string, /** Authoritative native-event prefix that predates this turn. */ @@ -135,15 +135,11 @@ interface ContinueLocalConversationAfterTimelineLoadParams extends Omit< export interface ContinueLocalConversationResult { sessionId: string; - created: boolean; terminalStatus: TurnTerminalStatus; agentTail: SessionEvent[]; } -interface RecoverLocalConversationParams extends Omit< - ContinueLocalConversationParams, - "beforeDispatch" -> { +interface RecoverLocalConversationParams extends ContinueLocalConversationParams { runnerSessionId: string; eventStartIndex?: number; } @@ -275,7 +271,16 @@ async function listExecutionCandidates( // 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. - const root = await readExecutionRow(locator.conversationId).catch(() => null); + 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 [ { @@ -331,8 +336,7 @@ interface ExecutionRow { } async function readExecutionRow( - sessionId: string, - _options: { allowFailed?: boolean } = {} + sessionId: string ): Promise { if (isCliSession(sessionId)) { const row = (await rpc.cli.status({ sessionId })) as Record< @@ -376,20 +380,11 @@ async function readExecutionRow( }; } -async function readExecutionTarget( - sessionId: string -): Promise { - return (await readExecutionRow(sessionId))?.target ?? null; -} - async function candidateMatchesTarget( sessionId: string, - target: LocalConversationTarget, - options: { allowFailed?: boolean } = {} + target: LocalConversationTarget ): Promise { - const existing = options.allowFailed - ? ((await readExecutionRow(sessionId, options))?.target ?? null) - : await readExecutionTarget(sessionId); + const existing = (await readExecutionRow(sessionId))?.target ?? null; if (!existing) { log.info( `[native-continuation] skipping ${sessionId}: execution identity is unavailable` @@ -439,7 +434,6 @@ async function findCompatibleExecution( knownMatchingCandidates?: readonly ExecutionCandidate[] ): Promise<{ sessionId: string; - updatedAt: string; events: SessionEvent[]; } | null> { const canonicalItems = projectNativeConversationItems(timeline); @@ -456,10 +450,13 @@ async function findCompatibleExecution( 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 (nativeConversationItemsArePrefix(executionItems, canonicalItems)) { return { sessionId: candidate.sessionId, - updatedAt: candidate.updatedAt, events, }; } @@ -471,11 +468,14 @@ async function findCompatibleExecution( } ); } catch (error) { - // A missing/corrupt native transcript is not resumable. Try an older - // compatible episode before creating a fresh one. - log.warn( - `[native-continuation] skipping ${candidate.sessionId}: native transcript read failed`, - error + if (error instanceof QueuedConversationRecoveryPendingError) throw error; + // An unknown reader failure cannot prove that this episode is absent or + // divergent. Fail closed and retry instead of silently changing the + // provider-native UUID. + throw new QueuedConversationRecoveryPendingError( + `native transcript for ${candidate.sessionId} is temporarily unavailable: ${ + error instanceof Error ? error.message : String(error) + }` ); } } @@ -594,10 +594,6 @@ function removeKnownNativeEchoes( }); } -function nativeItemEventId(id: string): string { - return id.replace(/:(?:call|result)$/, ""); -} - /** * Provider-native transcripts cannot be required to persist ORG2's private * turn-intent id. After terminal, recover the structured native suffix by @@ -634,7 +630,7 @@ function sliceProviderNativeTail( } const tailEventIds = new Set( - appendedItems.slice(userIndex + 1).map((item) => nativeItemEventId(item.id)) + appendedItems.slice(userIndex + 1).map(sourceEventIdOfNativeItem) ); if (tailEventIds.size === 0) return []; const tail = after.filter( @@ -823,17 +819,34 @@ function findAttemptUserIndex( * atomically replaces the matching optimistic placeholder. */ function providerUserEchoForAttempt( + before: readonly SessionEvent[], events: readonly SessionEvent[], turnIntentId: string, expectedUserText: string ): SessionEvent | null { - const userIndex = findAttemptUserIndex( - events, - turnIntentId, - expectedUserText - ); - if (userIndex < 0) return null; - const user = events[userIndex]; + let user: SessionEvent | undefined; + for (let index = events.length - 1; index >= 0; index -= 1) { + const candidate = events[index]; + if ( + candidate?.source === "user" && + eventTurnId(candidate) === turnIntentId + ) { + user = candidate; + break; + } + } + // Provider-native rows may not preserve ORG2's turn id. Text is only a + // safe fallback inside the proven appended suffix; scanning all history can + // mistake an older identical prompt for a failed attempt that wrote no row. + if (!user && sameEventPrefix(before, events)) { + const appended = events.slice(before.length); + const appendedIndex = findAttemptUserIndex( + appended, + turnIntentId, + expectedUserText + ); + user = appendedIndex >= 0 ? appended[appendedIndex] : undefined; + } if (!user || user.source !== "user") return null; return { ...user, @@ -952,10 +965,8 @@ async function isReplaySafeContextExhaustion(params: { async function finishConversationTurn(params: { sessionId: string; - target: LocalConversationTarget; before: readonly SessionEvent[]; turnIntentId: string; - userEventId?: string; displayText: string; generation: number; }): Promise< @@ -1016,16 +1027,6 @@ async function finishConversationTurn(params: { markTurnTerminal(params.sessionId, terminalStatus, { generation: params.generation, }); - getInstrumentedStore().set(setSessionRuntimeStatusAtom, { - sessionId: params.sessionId, - status: - terminalStatus === "completed" - ? "completed" - : terminalStatus === "cancelled" - ? "cancelled" - : "failed", - source: "sync", - }); const settled = await loadSettledTail( params.sessionId, params.before, @@ -1045,6 +1046,7 @@ async function finishConversationTurn(params: { // the race where native reconcile replaces the projection before terminal: // publishing only the suffix used to render the answer without its prompt. const providerUserEcho = providerUserEchoForAttempt( + params.before, settled.events, params.turnIntentId, params.displayText @@ -1066,13 +1068,6 @@ async function finishConversationTurn(params: { error ) ); - // Release the cross-session overlay only after the authoritative user row - // has been merged. If the provider file is still one flush behind, keep the - // optimistic row parked; the normal transcript reconciliation will settle - // it when the user echo arrives instead of making the message disappear. - if (params.userEventId && providerUserEcho) { - clearParkedUserIntentEvent(params.userEventId); - } return { terminalStatus, agentTail: settled.agentTail, @@ -1086,8 +1081,7 @@ async function prepareConversationTurn( ContinueLocalConversationParams, "displayText" | "imageDataUrls" | "turnIntentId" >, - runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"], - pendingPolicy: UserIntentPreparation["pendingPolicy"] + runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"] ): Promise { return prepareUserIntent({ sessionId, @@ -1095,7 +1089,6 @@ async function prepareConversationTurn( imageDataUrls: params.imageDataUrls, turnIntentId: params.turnIntentId, runtimeStatusSource, - pendingPolicy, }); } @@ -1105,7 +1098,6 @@ async function dispatchConversationMessage( options: { allowNativeContextRecovery: boolean; runtimeStatusSource: UserIntentPreparation["runtimeStatusSource"]; - pendingPolicy: UserIntentPreparation["pendingPolicy"]; preparation?: ConversationTurnPreparation; } ): ReturnType { @@ -1114,7 +1106,6 @@ async function dispatchConversationMessage( visibleText: params.displayText, imageDataUrls: params.imageDataUrls, runtimeStatusSource: options.runtimeStatusSource, - pendingPolicy: options.pendingPolicy, preparation: options.preparation, send: { content: params.agentContent ?? params.displayText, @@ -1165,7 +1156,6 @@ async function materializeCreatedConversation( interface CreatedConversationOptions { loadTimeline: () => Promise; - pendingPolicy: UserIntentPreparation["pendingPolicy"]; onSessionCreated?: (sessionId: string) => void | Promise; } @@ -1186,8 +1176,7 @@ async function runCreatedConversationTurn( preparation = await prepareConversationTurn( created.sessionId, params, - "launch", - options.pendingPolicy + "launch" ); // Native transcript conversion can take materially longer than provider // startup. Promote preparation out of the dispatch dead-man while keeping @@ -1223,13 +1212,13 @@ async function runCreatedConversationTurn( // provider-native compact/rollover may recover a target-window limit. allowNativeContextRecovery: true, runtimeStatusSource: "launch", - pendingPolicy: options.pendingPolicy, preparation, } ); preparation = dispatched.preparation; } catch (error) { if (preparation) { + if (error instanceof QueuedConversationRecoveryPendingError) throw error; await failUserIntentPreparation(preparation, error).catch( () => undefined ); @@ -1255,16 +1244,13 @@ async function runCreatedConversationTurn( const finished = await finishConversationTurn({ sessionId: created.sessionId, - target: params.target, before: materialized.events, turnIntentId: params.turnIntentId, - userEventId: preparation.userEvent.id, displayText: params.displayText, generation: preparation.generation, }); return { sessionId: created.sessionId, - created: true, terminalStatus: finished.terminalStatus, agentTail: finished.agentTail, }; @@ -1286,7 +1272,6 @@ async function continueLocalConversationAtQueueHead( // 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. - await effectiveParams.beforeDispatch?.(); const compatible = await findCompatibleExecution( effectiveParams.root, effectiveParams.target, @@ -1297,8 +1282,7 @@ async function continueLocalConversationAtQueueHead( const preparation = await prepareConversationTurn( compatible.sessionId, effectiveParams, - "dispatch", - "visible" + "dispatch" ); // Synchronizing a large canonical delta is part of the accepted user // intent, not a pre-submit loading screen. Use the same optimistic row, @@ -1313,7 +1297,6 @@ async function continueLocalConversationAtQueueHead( const synchronized = await synchronizeNativeConversation({ sessionId: compatible.sessionId, timeline: effectiveParams.timeline, - existingEvents: compatible.events, }); compatible.events = synchronized.events; if (effectiveParams.target.cliAgentType) { @@ -1343,11 +1326,11 @@ async function continueLocalConversationAtQueueHead( // remains the fallback when native recovery itself fails. allowNativeContextRecovery: true, runtimeStatusSource: "dispatch", - pendingPolicy: "visible", preparation, } ); } catch (error) { + if (error instanceof QueuedConversationRecoveryPendingError) throw error; await failUserIntentPreparation(preparation, error).catch( () => undefined ); @@ -1362,23 +1345,19 @@ async function continueLocalConversationAtQueueHead( ); const finished = await finishConversationTurn({ sessionId: compatible.sessionId, - target: effectiveParams.target, before: compatible.events, turnIntentId: effectiveParams.turnIntentId, - userEventId: dispatched.userEvent.id, displayText: effectiveParams.displayText, generation: dispatched.preparation.generation, }); if (finished.replaySafeContextExhaustion) { return runCreatedConversationTurn(effectiveParams, { loadTimeline: async () => effectiveParams.timeline, - pendingPolicy: "visible", onSessionCreated: effectiveParams.onSessionPreparing, }); } return { sessionId: compatible.sessionId, - created: false, terminalStatus: finished.terminalStatus, agentTail: finished.agentTail, }; @@ -1386,7 +1365,6 @@ async function continueLocalConversationAtQueueHead( return runCreatedConversationTurn(effectiveParams, { loadTimeline: async () => effectiveParams.timeline, - pendingPolicy: "visible", onSessionCreated: effectiveParams.onSessionPreparing, }); } @@ -1418,7 +1396,7 @@ export async function recoverLocalConversationTurn( }); if (!durableIntent || durableIntent.status === "optimistic") return null; if (["stale", "coalesced", "rejected"].includes(durableIntent.status)) { - throw new Error( + throw new QueuedConversationRecoveryBlockedError( `conversation turn was retired before provider execution (${durableIntent.status}); edit or retry it as a new intent` ); } @@ -1432,11 +1410,9 @@ export async function recoverLocalConversationTurn( params.root.conversationId === params.runnerSessionId); if ( !belongsToRoot || - !(await candidateMatchesTarget(params.runnerSessionId, params.target, { - allowFailed: true, - })) + !(await candidateMatchesTarget(params.runnerSessionId, params.target)) ) { - throw new Error( + throw new QueuedConversationRecoveryBlockedError( "durable conversation runner no longer belongs to this root/target" ); } @@ -1450,7 +1426,7 @@ export async function recoverLocalConversationTurn( const canonicalItems = projectNativeConversationItems(timeline); const executionItems = projectNativeConversationItems(events); if (!nativeConversationItemsArePrefix(canonicalItems, executionItems)) { - throw new Error( + throw new QueuedConversationRecoveryBlockedError( "accepted conversation runner diverged from the canonical transcript" ); } @@ -1471,7 +1447,6 @@ export async function recoverLocalConversationTurn( ); const finished = await finishConversationTurn({ sessionId: params.runnerSessionId, - target: params.target, before: timeline, turnIntentId: params.turnIntentId, displayText: params.displayText, @@ -1479,7 +1454,6 @@ export async function recoverLocalConversationTurn( }); return { sessionId: params.runnerSessionId, - created: false, terminalStatus: finished.terminalStatus, agentTail: finished.agentTail, }; @@ -1507,10 +1481,7 @@ export async function continueLocalConversationAfterTimelineLoad( params: ContinueLocalConversationAfterTimelineLoadParams ): Promise { assertSupportedConversationTarget(params.target); - // Publish the canonical user intent before native-history I/O. Cloud roots - // can render it immediately; local/imported roots retain their durable queue - // card until the concrete execution accepts it. - await params.beforeDispatch?.(); + const { loadTimeline, ...continuationParams } = params; const candidates = await listExecutionCandidates(params.root); const matchingCandidates: ExecutionCandidate[] = []; for (const candidate of candidates) { @@ -1522,18 +1493,14 @@ export async function continueLocalConversationAfterTimelineLoad( // 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( - { ...params, beforeDispatch: undefined }, - { - loadTimeline: params.loadTimeline, - pendingPolicy: "across_session_switch", - onSessionCreated: params.onSessionPreparing, - } - ); + return runCreatedConversationTurn(continuationParams, { + loadTimeline, + onSessionCreated: params.onSessionPreparing, + }); } - const timeline = await params.loadTimeline(); + const timeline = await loadTimeline(); return continueLocalConversationAtQueueHead( - { ...params, beforeDispatch: undefined, timeline }, + { ...continuationParams, timeline }, matchingCandidates ); } diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts index 3f8e69348e..64fc1fc618 100644 --- a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts @@ -245,6 +245,21 @@ describe("native conversation materialization", () => { ]); }); + 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")]; @@ -341,7 +356,7 @@ describe("native conversation materialization", () => { expect(nativeConversationItemsEqual(left, right)).toBe(true); }); - it("keeps the full canonical transcript and its native compact windows", () => { + it("keeps the full canonical transcript while excluding provider compact state", () => { const before = projectNativeConversationItems([ message("u1", "user", "old question"), tool(), @@ -361,12 +376,7 @@ describe("native conversation materialization", () => { message("u2", "user", "continue"), ]); - expect(compacted.slice(0, -1)).toEqual(before); - expect(compacted.at(-1)).toMatchObject({ - kind: "compaction", - id: "compact-1", - summary: "provider summary", - }); + expect(compacted).toEqual(before); expect(nativeConversationItemsArePrefix(compacted, withDelta)).toBe(true); expect(withDelta.at(-1)).toMatchObject({ kind: "message", @@ -446,7 +456,7 @@ describe("native conversation materialization", () => { expect(mocks.loadEvents).not.toHaveBeenCalled(); }); - it("synchronizes one complete transcript plus its verified prefix length", async () => { + 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({ @@ -462,7 +472,6 @@ describe("native conversation materialization", () => { synchronizeNativeConversation({ sessionId: "cliagent-target", timeline, - existingEvents: existing, }) ).resolves.toMatchObject({ receipt: { nativeSessionId: "native-1", itemCount: 2 }, @@ -472,7 +481,6 @@ describe("native conversation materialization", () => { { sessionId: "cliagent-target", completeItems: projectNativeConversationItems(timeline), - prefixItemCount: 1, } ); }); diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts index 3988f35a65..d91d6b9565 100644 --- a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts @@ -39,12 +39,6 @@ type NativeConversationItem = name: string; output: string; createdAt: string; - } - | { - kind: "compaction"; - id: string; - summary: string; - createdAt: string; }; interface NativeMaterializationReceipt { @@ -211,19 +205,10 @@ export function projectNativeConversationItems( event.actionType === "context_compacted" || event.functionName === "context_compacted" ) { - const summary = eventText(event).trim(); - // Some providers emit window/checkpoint markers without a transferable - // summary. They remain useful ORG2 timeline annotations, but projecting - // one as a native compact with an empty replacement history destroys the - // target provider's effective context. In that case rebuild the complete - // structured role/tool list and let the target manage its own context. - if (!summary) continue; - items.push({ - kind: "compaction", - id: nativeSourceEventId(event), - summary, - createdAt: event.createdAt, - }); + // Compaction is provider state, not portable conversation content. The + // target runtime may compact only after its own context-exhausted + // terminal; cross-provider reconstruction carries the complete + // structured role/tool transcript and never forges native compact rows. continue; } if ( @@ -302,8 +287,12 @@ export function projectNativeConversationItems( return items; } -function sourceEventIdOfNativeItem(item: NativeConversationItem): string { - return item.id.replace(/:(?:call|result)$/, ""); +export function sourceEventIdOfNativeItem( + item: NativeConversationItem +): string { + return item.kind === "message" + ? item.id + : item.id.replace(/:(?:call|result)$/, ""); } /** @@ -352,8 +341,6 @@ function semanticItem(item: NativeConversationItem): unknown { ]; case "tool_result": return [item.kind, item.callId, item.name, item.output]; - case "compaction": - return [item.kind, item.summary]; } } @@ -381,8 +368,6 @@ function nativeItemShape(item: NativeConversationItem | undefined): string { 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 "compaction": - return `compaction:summary=${item.summary.length}`; } } @@ -496,12 +481,6 @@ export async function materializeNativeConversation(params: { `native transcript round-trip verification failed; the target session was not started (${nativeConversationMismatch(items, roundTripped)})` ); } - if (isCliSession(params.sessionId) && receipt.nativeSessionId) { - await invokeTauri("commit_native_conversation_materialization", { - sessionId: params.sessionId, - nativeSessionId: receipt.nativeSessionId, - }); - } return { events, receipt }; } catch (error) { if (isCliSession(params.sessionId)) { @@ -524,30 +503,13 @@ export async function materializeNativeConversation(params: { export async function synchronizeNativeConversation(params: { sessionId: string; timeline: readonly SessionEvent[]; - existingEvents: readonly SessionEvent[]; }): Promise<{ events: SessionEvent[]; receipt: NativeMaterializationReceipt }> { const complete = projectNativeConversationItems(params.timeline); - const existing = projectNativeConversationItems(params.existingEvents); - if (!nativeConversationItemsArePrefix(existing, complete)) { - throw new Error( - "native transcript is not a semantic prefix of the canonical conversation" - ); - } - if (existing.length === complete.length) { - return { - events: [...params.existingEvents], - receipt: { - nativeSessionId: params.sessionId, - itemCount: complete.length, - }, - }; - } const receipt = await invokeTauri( "synchronize_native_conversation", { sessionId: params.sessionId, completeItems: complete, - prefixItemCount: existing.length, } ); if (receipt.itemCount !== complete.length) { @@ -566,11 +528,5 @@ export async function synchronizeNativeConversation(params: { `native transcript synchronization round-trip verification failed (${nativeConversationMismatch(complete, projectNativeConversationItems(events))})` ); } - if (isCliSession(params.sessionId) && receipt.nativeSessionId) { - await invokeTauri("commit_native_conversation_materialization", { - sessionId: params.sessionId, - nativeSessionId: receipt.nativeSessionId, - }); - } return { events, receipt }; } diff --git a/src/engines/SessionCore/conversations/queuedConversationExecutor.ts b/src/engines/SessionCore/conversations/queuedConversationExecutor.ts index d87a893744..2f12359080 100644 --- a/src/engines/SessionCore/conversations/queuedConversationExecutor.ts +++ b/src/engines/SessionCore/conversations/queuedConversationExecutor.ts @@ -1,10 +1,10 @@ import type { Store } from "jotai/vanilla/store"; -import type { TurnTerminalStatus } from "@src/engines/SessionCore/control/turnLifecycle"; - -import type { +import { ConversationRootLocator, LocalConversationTarget, + isConversationRootLocator, + isLocalConversationTarget, } from "./conversationTypes"; export interface QueuedConversationDispatch { @@ -25,10 +25,58 @@ export interface QueuedConversationMessage { content: string; displayContent: string; imageDataUrls?: string[]; - status: "queued" | "preparing" | "accepted"; + conversationDispatch?: QueuedConversationDispatch; +} + +export interface QueuedConversationExecutionMessage extends QueuedConversationMessage { + status: "preparing" | "accepted"; runnerSessionId?: string; runnerEventStartIndex?: number; - conversationDispatch?: QueuedConversationDispatch; +} + +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. */ @@ -42,10 +90,6 @@ export interface QueuedConversationDispatchCallbacks { ) => void | Promise; } -export interface QueuedConversationExecutionResult { - terminalStatus: TurnTerminalStatus; -} - /** Another window currently owns this canonical root; keep the row queued. */ export class QueuedConversationBusyError extends Error { constructor() { @@ -54,6 +98,38 @@ export class QueuedConversationBusyError extends Error { } } +/** 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 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. * @@ -63,6 +139,6 @@ export class QueuedConversationBusyError extends Error { */ export type QueuedConversationExecutor = ( store: Store, - message: QueuedConversationMessage, + message: QueuedConversationExecutionMessage, callbacks: QueuedConversationDispatchCallbacks -) => Promise; +) => Promise; diff --git a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts index ef8ef43fbf..6f173750bf 100644 --- a/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts +++ b/src/engines/SessionCore/derived/__tests__/chatEvents.test.ts @@ -124,38 +124,6 @@ describe("chatEventsAtom live streaming overlay", () => { expect(store.get(chatEventsAtom)).toEqual([providerRow]); }); - it("keeps the pending user row visible across a native snapshot replace", () => { - 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", - } - ); - store.set(sessionIdAtom, "session-1"); - store.set(pendingSyntheticEventAtom, pending); - store.set(derivedSnapshotAtom, makeSnapshot([], false)); - - expect(store.get(chatEventsAtom)).toEqual([pending]); - - // A delayed native-history replacement remains visually lossless. - store.set( - derivedSnapshotAtom, - makeSnapshot([makeChatEvent("older", "2026-06-06T19:59:59.000Z")], false) - ); - expect(store.get(chatEventsAtom).map((event) => event.id)).toEqual([ - "older", - "user-input-pending", - ]); - }); - it("suppresses the pending overlay after the provider's real user echo", () => { const store = createStore(); const pending = makeChatEvent( @@ -191,52 +159,6 @@ describe("chatEventsAtom live streaming overlay", () => { expect(store.get(chatEventsAtom)).toEqual([echo]); }); - it("keeps a new intent visible when an older native turn is replayed with a newer timestamp", () => { - const store = createStore(); - const pending = makeChatEvent( - "user-input-pending", - "2026-06-06T20:00:01.000Z", - { - source: "user", - functionName: "user_message", - uiCanonical: "", - actionType: "raw", - displayText: "continue exploring", - result: { - syntheticUserInput: true, - turnIntentId: "turn-next", - message: { content: "continue exploring", role: "user" }, - }, - displayVariant: "message", - } - ); - const replayedOldTurn = makeChatEvent( - "provider-user-old", - "2026-06-06T20:00:02.000Z", - { - source: "user", - functionName: "user", - uiCanonical: "user", - actionType: "user_message", - displayText: "old request", - result: { - turnIntentId: "turn-old", - message: { content: "old request", role: "user" }, - }, - displayVariant: "message", - } - ); - store.set(sessionIdAtom, "session-1"); - store.set(pendingSyntheticEventAtom, pending); - store.set(derivedSnapshotAtom, makeSnapshot([replayedOldTurn], true)); - - expect(store.get(chatEventsAtom).map((event) => event.id)).toEqual([ - "provider-user-old", - "user-input-pending", - "live-assistant-session-1", - ]); - }); - 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 4ff4935c6f..8c9210d076 100644 --- a/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts +++ b/src/engines/SessionCore/derived/__tests__/queueDispatchSyncInputsAtom.test.ts @@ -1,9 +1,13 @@ import { createStore } from "jotai"; import { describe, expect, it, vi } from "vitest"; -import { messageQueueHydratedAtom } from "@src/store/ui/messageQueueAtom"; +import { + messageDeliveryRecoveryReadyAtom, + messageQueueHydratedAtom, +} from "@src/store/ui/messageQueueAtom"; import { turnLifecycleSignalAtom } from "../../control/turnLifecycle"; +import { canonicalConversationExecutionsHydratedAtom } from "../../conversations/canonicalConversationExecution"; import { queueDispatchSyncInputsAtom } from "../queueDispatchSyncInputsAtom"; describe("queueDispatchSyncInputsAtom", () => { @@ -11,11 +15,16 @@ describe("queueDispatchSyncInputsAtom", () => { const store = createStore(); store.set(messageQueueHydratedAtom, true); + store.set(canonicalConversationExecutionsHydratedAtom, true); + store.set(messageDeliveryRecoveryReadyAtom, true); store.set(turnLifecycleSignalAtom, 7); expect(store.get(queueDispatchSyncInputsAtom)).toMatchObject({ queue: [], - hydrated: true, + queueHydrated: true, + canonicalExecutions: [], + canonicalExecutionsHydrated: true, + deliveryRecoveryReady: true, turnLifecycleSignal: 7, editing: false, }); diff --git a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts index b354749d37..b458173365 100644 --- a/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts +++ b/src/engines/SessionCore/derived/__tests__/sessionScopedChatEvents.stability.test.ts @@ -1,7 +1,6 @@ import { createStore } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { pendingSyntheticEventAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; @@ -121,45 +120,4 @@ describe("chatEventsForSessionAtomFamily streaming stability", () => { ); unsub(); }); - - it("projects the foreground pending user row outside a stale native snapshot", async () => { - const sessionId = "pending-visible"; - const chatAtom = chatEventsForSessionAtomFamily(sessionId); - const unsub = store.sub(chatAtom, () => {}); - await Promise.resolve(); - - const listener = subscribers.get(sessionId); - listener?.( - streamingSnapshot(1, [ - chatEvent("assistant-old", "previous answer", { - sessionId, - displayStatus: "completed", - isDelta: false, - }), - ]) - ); - const pending = chatEvent("user-input-next", "continue exploring", { - sessionId, - source: "user", - functionName: "user_message", - uiCanonical: "", - actionType: "raw", - result: { - syntheticUserInput: true, - turnIntentId: "turn-next", - message: { content: "continue exploring", role: "user" }, - }, - displayStatus: "completed", - displayVariant: "message", - isDelta: false, - }); - store.set(pendingSyntheticEventAtom, pending); - - expect(store.get(chatAtom).map((event) => event.id)).toEqual([ - "assistant-old", - "user-input-next", - `live-assistant-${sessionId}`, - ]); - unsub(); - }); }); diff --git a/src/engines/SessionCore/derived/chatEvents.ts b/src/engines/SessionCore/derived/chatEvents.ts index 23f31e9462..093fb3a1a1 100644 --- a/src/engines/SessionCore/derived/chatEvents.ts +++ b/src/engines/SessionCore/derived/chatEvents.ts @@ -16,14 +16,9 @@ import { messageQueueAtom, } from "@src/store/ui/messageQueueAtom"; -import { syntheticSettledByScope } from "../core/atoms/actions.userMessageSync"; import { derivedSnapshotAtom, eventsAtom } from "../core/atoms/events"; -import { - pendingSyntheticEventAtom, - sessionIdAtom, -} from "../core/atoms/metadata"; +import { sessionIdAtom } from "../core/atoms/metadata"; import type { Snapshot } from "../core/store/EventStoreProxy"; -import { syntheticEvictionScopeForRealUserEvents } from "../core/store/eventStoreEvents"; import type { SessionEvent } from "../core/types"; import { isVisibleInChat } from "../ingestion/visibilityFilters"; import { @@ -203,27 +198,6 @@ export function appendLiveAssistantEvent( return [...withoutLive, liveEvent]; } -/** - * Render the single foreground optimistic user row independently of the Rust - * snapshot. Native transcript synchronization is allowed to replace the - * EventStore wholesale; without this overlay the just-submitted row vanishes - * until the replacement finishes and the provider echoes it back. The real - * echo (same event ID or durable turn-intent ID; legacy rows fall back to - * content/time reconciliation) suppresses the overlay, so it cannot create a - * second visible message. - */ -export function appendPendingSyntheticUserEvent( - events: SessionEvent[], - sessionId: string | null, - pending: SessionEvent | null -): SessionEvent[] { - if (!sessionId || !pending || pending.sessionId !== sessionId) return events; - if (events.some((event) => event.id === pending.id)) return events; - const scope = syntheticEvictionScopeForRealUserEvents(events); - if (syntheticSettledByScope(pending, scope)) return events; - return [...events, pending]; -} - /** * Project durable queue rows as ordinary pending user turns immediately. * @@ -274,7 +248,6 @@ export function appendQueuedUserEvents( export const chatEventsAtom = atom((get) => { const snap = get(derivedSnapshotAtom); const sessionId = get(sessionIdAtom); - const pendingSyntheticEvent = get(pendingSyntheticEventAtom); // Reset prev cache when the active session changes so the stability // comparison never runs across two different sessions' event arrays. @@ -296,11 +269,7 @@ export const chatEventsAtom = atom((get) => { if (snap && "chatEvents" in snap) { const rawChatEvents = appendQueuedUserEvents( - appendPendingSyntheticUserEvent( - snap.chatEvents, - sessionId, - pendingSyntheticEvent - ), + snap.chatEvents, sessionId, queuedMessages ); @@ -367,11 +336,7 @@ export const chatEventsAtom = atom((get) => { // raw StreamingSnapshot without chatEvents). Filter JS-side, same as // messagesEventsAtom / simulatorEventsAtom do in their own fallback paths. const events = appendQueuedUserEvents( - appendPendingSyntheticUserEvent( - get(eventsAtom), - sessionId, - pendingSyntheticEvent - ), + get(eventsAtom), sessionId, queuedMessages ); diff --git a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts index 3e85f7f7f7..26b3d8ddbe 100644 --- a/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts +++ b/src/engines/SessionCore/derived/queueDispatchSyncInputsAtom.ts @@ -2,12 +2,18 @@ import { atom } from "jotai"; import { type QueuedMessage, + messageDeliveryRecoveryReadyAtom, messageQueueAtom, messageQueueHydratedAtom, queueEditingAtom, } from "@src/store/ui/messageQueueAtom"; import { turnLifecycleSignalAtom } from "../control/turnLifecycle"; +import { + type CanonicalConversationExecution, + canonicalConversationExecutionsAtom, + canonicalConversationExecutionsHydratedAtom, +} from "../conversations/canonicalConversationExecution"; /** * Bundles the inputs that drive the singleton queue dispatcher. @@ -17,7 +23,10 @@ import { turnLifecycleSignalAtom } from "../control/turnLifecycle"; */ export interface QueueDispatchSyncInputs { queue: QueuedMessage[]; - hydrated: boolean; + queueHydrated: boolean; + canonicalExecutions: CanonicalConversationExecution[]; + canonicalExecutionsHydrated: boolean; + deliveryRecoveryReady: boolean; turnLifecycleSignal: number; editing: boolean; } @@ -25,7 +34,12 @@ export interface QueueDispatchSyncInputs { export const queueDispatchSyncInputsAtom = atom( (get) => ({ queue: get(messageQueueAtom), - hydrated: get(messageQueueHydratedAtom), + queueHydrated: get(messageQueueHydratedAtom), + canonicalExecutions: get(canonicalConversationExecutionsAtom), + canonicalExecutionsHydrated: get( + canonicalConversationExecutionsHydratedAtom + ), + deliveryRecoveryReady: get(messageDeliveryRecoveryReadyAtom), turnLifecycleSignal: get(turnLifecycleSignalAtom), editing: get(queueEditingAtom), }) diff --git a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts index 8762879733..d6c0f1a8c1 100644 --- a/src/engines/SessionCore/derived/sessionScopedChatEvents.ts +++ b/src/engines/SessionCore/derived/sessionScopedChatEvents.ts @@ -35,7 +35,6 @@ import { } from "@src/store/ui/messageQueueAtom"; import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; -import { pendingSyntheticEventAtom } from "../core/atoms/metadata"; import { isInteractiveTool } from "../core/interactiveTools"; import { hasLiveRuntimeResourceInLatestTurn, @@ -51,7 +50,6 @@ import type { SessionEvent } from "../core/types"; import { ensureCursorIdeEventsInStore } from "../sync/adapters/cursorIdeAdapter"; import { appendLiveAssistantEvent, - appendPendingSyntheticUserEvent, appendQueuedUserEvents, filterQueuedSyntheticUserEvents, } from "./chatEvents"; @@ -183,19 +181,14 @@ export function extractSessionChatEvents( function deriveFamilyChatEvents( snapshot: Snapshot | null, sessionId: string, - queuedMessages: readonly QueuedMessage[], - pendingSyntheticEvent: SessionEvent | null + queuedMessages: readonly QueuedMessage[] ): SessionEvent[] { const streaming = snapshot ? isSnapshotActivelyStreaming(snapshot) : false; return appendLiveAssistantEvent( derivePlanDisplayEvents( filterQueuedSyntheticUserEvents( appendQueuedUserEvents( - appendPendingSyntheticUserEvent( - extractSessionChatEvents(snapshot), - sessionId, - pendingSyntheticEvent - ), + extractSessionChatEvents(snapshot), sessionId, queuedMessages ), @@ -220,13 +213,7 @@ export const chatEventsForSessionAtomFamily = atomFamily( const a = atom((get) => { const { snapshot } = get(sessionSnapshotAtomFamily(sessionId)); const queuedMessages = get(messageQueueAtom); - const pendingSyntheticEvent = get(pendingSyntheticEventAtom); - const next = deriveFamilyChatEvents( - snapshot, - sessionId, - queuedMessages, - pendingSyntheticEvent - ); + const next = deriveFamilyChatEvents(snapshot, sessionId, queuedMessages); const streaming = snapshot ? isSnapshotActivelyStreaming(snapshot) : false; diff --git a/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts b/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts index cd42da6cef..13d6b29e08 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts @@ -8,7 +8,10 @@ import { messageQueueHydratedAtom, } from "@src/store/ui/messageQueueAtom"; -import { hydrateMessageQueue } from "../messageQueuePersistence"; +import { + hydrateMessageQueue, + refreshMessageQueueFromDurable, +} from "../messageQueuePersistence"; const mocks = vi.hoisted(() => ({ load: vi.fn(), @@ -86,4 +89,52 @@ describe("messageQueuePersistence", () => { expect(mocks.persist).toHaveBeenCalledWith([next]); }); + + it("retries a transient durable read and installs persistence after recovery", async () => { + const store = createStore(); + mocks.load + .mockRejectedValueOnce(new Error("store starting")) + .mockResolvedValueOnce([]); + + await expect(hydrateMessageQueue(store)).rejects.toThrow("store starting"); + expect(store.get(messageQueueHydratedAtom)).toBe(true); + + 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 () => durable); + mocks.persist.mockImplementation( + (snapshot: QueuedMessage[]) => + new Promise((resolve) => { + releasePersist = () => { + durable = snapshot; + resolve(); + }; + }) + ); + const store = createStore(); + await hydrateMessageQueue(store); + // Resolve the hydration's initial best-effort persistence. + releasePersist(); + await Promise.resolve(); + + store.set(messageQueueAtom, []); + const refresh = refreshMessageQueueFromDurable(store, new Set()); + await Promise.resolve(); + expect(store.get(messageQueueAtom)).toEqual([]); + releasePersist(); + await refresh; + + expect(store.get(messageQueueAtom)).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 c08c9d7318..787ae3e37e 100644 --- a/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts +++ b/src/engines/SessionCore/hooks/session/__tests__/useQueueDispatch.intervention.test.ts @@ -3,6 +3,14 @@ import { Provider, createStore } from "jotai"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type CanonicalConversationExecution, + canonicalConversationExecutionsAtom, +} from "@src/engines/SessionCore/conversations/canonicalConversationExecution"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { UserIntentSendError } from "@src/engines/SessionCore/services/userIntentDispatch"; import { type QueuedMessage, @@ -13,6 +21,7 @@ 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(), @@ -20,11 +29,13 @@ const mocks = vi.hoisted(() => ({ 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(), @@ -32,6 +43,7 @@ const mocks = vi.hoisted(() => ({ messageError: vi.fn(), messageWarning: vi.fn(), loadDurableMessageQueue: vi.fn(), + loadDurableCanonicalExecutions: vi.fn(), persistDurableMessageQueue: vi.fn(), restoreTurnWorkingAfterInterruptFailure: vi.fn(), sendMessage: vi.fn(), @@ -77,10 +89,113 @@ vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { append: mocks.append, + getPersistedEvents: mocks.getPersistedEvents, updateById: mocks.updateById, }, })); +vi.mock( + "@src/engines/SessionCore/conversations/canonicalConversationExecution", + async () => { + const { atom } = await import("jotai/vanilla"); + const { messageQueueAtom: queueAtom } = + await import("@src/store/ui/messageQueueAtom"); + const executionsAtom = atom([]); + const hydratedAtom = atom(false); + const externalMutationAtom = atom(0); + let currentStore: JotaiStore | null = null; + return { + canonicalConversationExecutionsAtom: executionsAtom, + canonicalConversationExecutionsHydratedAtom: hydratedAtom, + canonicalConversationExternalMutationAtom: externalMutationAtom, + withCanonicalConversationTurnLock: async ( + _root: unknown, + run: () => Promise + ) => await run(), + assertCanonicalExecutionIsDurableRootHead: async (id: string) => { + const owner = currentStore + ?.get(executionsAtom) + .find((candidate) => candidate.id === id); + if (!owner) throw new Error("missing execution owner"); + return owner; + }, + hydrateCanonicalConversationExecutions: async ( + store: JotaiStore, + publishQueueSnapshot?: ( + queue: QueuedMessage[], + executions: CanonicalConversationExecution[] + ) => void + ) => { + currentStore = store; + try { + const executions = await mocks.loadDurableCanonicalExecutions(); + store.set(executionsAtom, executions); + publishQueueSnapshot?.( + await mocks.loadDurableMessageQueue(), + executions + ); + } finally { + store.set(hydratedAtom, true); + } + }, + disposeCanonicalConversationExecution: () => undefined, + refreshCanonicalConversationExecutions: async () => undefined, + handoffQueuedMessageToCanonicalExecution: async ( + store: JotaiStore, + execution: CanonicalConversationExecution + ) => { + currentStore = store; + store.set(queueAtom, (current) => + current.filter((message) => message.id !== execution.id) + ); + store.set(executionsAtom, (current) => [ + ...current.filter((candidate) => candidate.id !== execution.id), + execution, + ]); + }, + returnCanonicalExecutionToMessageQueue: async ( + store: JotaiStore, + id: string, + message: QueuedMessage + ) => { + store.set(executionsAtom, (current) => + current.filter((candidate) => candidate.id !== id) + ); + store.set(queueAtom, (current) => [ + ...current.filter((candidate) => candidate.id !== message.id), + message, + ]); + }, + updateCanonicalConversationExecution: async ( + store: JotaiStore, + id: string, + update: Partial + ) => { + if (mocks.canonicalUpdateFailure) { + throw new Error("durable execution store unavailable"); + } + let updated: CanonicalConversationExecution | null = null; + store.set(executionsAtom, (current) => + current.map((candidate) => { + if (candidate.id !== id) return candidate; + updated = { ...candidate, ...update }; + return updated; + }) + ); + return updated; + }, + removeCanonicalConversationExecution: async ( + store: JotaiStore, + id: string + ) => { + store.set(executionsAtom, (current) => + current.filter((candidate) => candidate.id !== id) + ); + }, + }; + } +); + vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ SessionService: { sendMessage: mocks.sendMessage }, })); @@ -119,6 +234,8 @@ vi.mock("@src/store/session", () => ({ })); vi.mock("@src/store/ui/messageQueueRepository", () => ({ + getMessageQueueOwnerKey: async () => "queue:main", + isPrimaryMessageQueueOwnerKey: (key: string) => key === "queue:main", loadDurableMessageQueue: mocks.loadDurableMessageQueue, persistDurableMessageQueue: mocks.persistDurableMessageQueue, })); @@ -235,6 +352,7 @@ 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); @@ -250,6 +368,7 @@ describe("useQueueDispatch Agent Org intervention", () => { 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(); @@ -257,11 +376,13 @@ describe("useQueueDispatch Agent Org intervention", () => { mocks.messageError.mockReset(); mocks.messageWarning.mockReset(); mocks.loadDurableMessageQueue.mockReset().mockResolvedValue([]); + mocks.loadDurableCanonicalExecutions.mockReset().mockResolvedValue([]); mocks.persistDurableMessageQueue.mockReset().mockResolvedValue(undefined); mocks.restoreTurnWorkingAfterInterruptFailure.mockReset(); mocks.sendMessage.mockReset().mockResolvedValue(undefined); mocks.updateById.mockReset().mockResolvedValue(true); store = createStore(); + store.set(canonicalConversationExecutionsAtom, []); root = createSmokeRoot(); }); @@ -301,6 +422,40 @@ describe("useQueueDispatch Agent Org intervention", () => { await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); }); + it("continues ordinary queue delivery when canonical recovery is unavailable", async () => { + mocks.loadDurableCanonicalExecutions.mockRejectedValueOnce( + new Error("canonical store unavailable") + ); + + await mountWithQueuedMessage(); + + await vi.waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); + }); + + 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.loadDurableCanonicalExecutions + .mockRejectedValueOnce(new Error("store warming up")) + .mockResolvedValueOnce([]); + + 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 () => { const blocked = makeQueuedMessage(); const ready: QueuedMessage = { @@ -387,7 +542,61 @@ describe("useQueueDispatch Agent Org intervention", () => { expect(mocks.updateById).not.toHaveBeenCalled(); }); - it("propagates the provider terminal instead of manufacturing completion", async () => { + 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: CanonicalConversationExecution = { + id: queuedTwin.id, + message: { + id: queuedTwin.id, + turnIntentId: queuedTwin.turnIntentId, + sessionId: queuedTwin.sessionId, + content: queuedTwin.content, + displayContent: queuedTwin.displayContent, + conversationDispatch: queuedTwin.conversationDispatch, + }, + status: "accepted", + runnerSessionId: "runner-cold-start", + createdAt: queuedTwin.createdAt, + }; + mocks.loadDurableCanonicalExecutions.mockResolvedValueOnce([recovered]); + mocks.loadDurableMessageQueue.mockResolvedValueOnce([queuedTwin]); + 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); @@ -398,12 +607,9 @@ describe("useQueueDispatch Agent Org intervention", () => { await mountWithMessages([makeCanonicalMessage("canonical-cancelled")]); await vi.waitFor(() => - expect(mocks.markTurnTerminal).toHaveBeenCalledWith( - expect.stringContaining("root-1"), - "cancelled", - expect.objectContaining({ generation: 11 }) - ) + expect(store.get(canonicalConversationExecutionsAtom)).toEqual([]) ); + expect(mocks.markTurnTerminal).not.toHaveBeenCalled(); }); it("transfers a prepared canonical failure from the queue card to its failed bubble", async () => { @@ -414,11 +620,77 @@ describe("useQueueDispatch Agent Org intervention", () => { await mountWithMessages([makeCanonicalMessage("canonical-failed")]); await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); - expect(mocks.messageError).toHaveBeenCalledWith( - expect.objectContaining({ - content: expect.stringContaining("native launch failed"), - }) + expect(mocks.messageError).not.toHaveBeenCalled(); + }); + + 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(canonicalConversationExecutionsAtom)).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("returns an admission-blocked execution to a visible held queue card", async () => { + mocks.dispatchCanonicalConversation.mockRejectedValueOnce( + new QueuedConversationBlockedError("switch Cloud account") + ); + + await mountWithMessages([makeCanonicalMessage("canonical-blocked")]); + await vi.waitFor(() => + expect(store.get(messageQueueAtom)).toEqual([ + expect.objectContaining({ + id: "canonical-blocked", + requiresExplicitDispatch: true, + }), + ]) + ); + expect(store.get(canonicalConversationExecutionsAtom)).toEqual([]); + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledOnce(); + }); + + 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(canonicalConversationExecutionsAtom)).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 () => { @@ -439,7 +711,47 @@ describe("useQueueDispatch Agent Org intervention", () => { ); }); - it("serializes two canonical turns for one root through turnLifecycle", async () => { + 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(canonicalConversationExecutionsAtom)).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(canonicalConversationExecutionsAtom)).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) => { @@ -466,12 +778,14 @@ describe("useQueueDispatch Agent Org intervention", () => { expect(mocks.dispatchCanonicalConversation.mock.calls[0]?.[1].id).toBe( "canonical-first" ); - expect(store.get(messageQueueAtom)).toEqual([ + expect(store.get(canonicalConversationExecutionsAtom)).toEqual([ expect.objectContaining({ id: "canonical-first", status: "accepted", runnerSessionId: "runner-canonical-first", }), + ]); + expect(store.get(messageQueueAtom)).toEqual([ expect.objectContaining({ id: "canonical-second", status: "queued" }), ]); @@ -485,7 +799,7 @@ describe("useQueueDispatch Agent Org intervention", () => { await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); }); - it("admits another canonical root after the first provider accepts", async () => { + it("runs independent canonical roots concurrently", async () => { installLifecycleSimulation(); let acceptFirst!: () => void; const firstAcceptance = new Promise((resolve) => { @@ -507,23 +821,13 @@ describe("useQueueDispatch Agent Org intervention", () => { ]); await vi.waitFor(() => - expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(1) - ); - expect(mocks.dispatchCanonicalConversation.mock.calls[0]?.[1].id).toBe( - "root-a" + expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) ); - expect(store.get(messageQueueAtom).map((message) => message.id)).toEqual([ - "root-a", - "root-b", - ]); + expect(store.get(messageQueueAtom)).toEqual([]); acceptFirst(); await vi.waitFor(() => - expect(mocks.dispatchCanonicalConversation).toHaveBeenCalledTimes(2) + expect(store.get(canonicalConversationExecutionsAtom)).toEqual([]) ); - expect(mocks.dispatchCanonicalConversation.mock.calls[1]?.[1].id).toBe( - "root-b" - ); - await vi.waitFor(() => expect(store.get(messageQueueAtom)).toEqual([])); }); it("routes canonical Send Now through the active native runner", async () => { diff --git a/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts b/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts index 8cdf3a7c94..a9b055a092 100644 --- a/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts +++ b/src/engines/SessionCore/hooks/session/messageQueuePersistence.ts @@ -3,6 +3,7 @@ import type { Store } from "jotai/vanilla/store"; import { type QueuedMessage, boundQueuedMessages, + messageDeliveryRecoveryReadyAtom, messageQueueAtom, messageQueueHydratedAtom, } from "@src/store/ui/messageQueueAtom"; @@ -11,12 +12,17 @@ import { persistDurableMessageQueue, } from "@src/store/ui/messageQueueRepository"; -function persistQueueBestEffort(store: Store): void { - void persistDurableMessageQueue(store.get(messageQueueAtom)).catch( - (error) => { - console.warn("[messageQueuePersistence] failed to persist queue", error); - } - ); +const queueRevisionByStore = new WeakMap(); +const queuePersistByStore = new WeakMap>(); +const suppressPersistenceByStore = new WeakSet(); + +function persistQueueBestEffort(store: Store): Promise { + const write = persistDurableMessageQueue(store.get(messageQueueAtom)); + queuePersistByStore.set(store, write); + void write.catch((error) => { + console.warn("[messageQueuePersistence] failed to persist queue", error); + }); + return write; } const hydrationByStore = new WeakMap>(); @@ -27,17 +33,14 @@ function mergeQueues( live: readonly QueuedMessage[] ): QueuedMessage[] { const byIntent = new Map(); - // Legacy/plain queued rows may have crossed an old backend-ACK/dequeue crash - // window, so keep those parked for an explicit Send Now. Modern canonical - // rows persist preparing/accepted plus their runner Session and reconnect to - // that exact turn automatically; downgrading them would strand a live native - // turn and invite an unsafe replay. + // A recovered UI row may have crossed an old backend-ACK/dequeue crash + // window, so keep it parked for an explicit Send Now. Accepted canonical + // work is recovered from the separate app-global execution store. for (const message of durable) { byIntent.set(message.turnIntentId, { ...message, - ...(message.status === "queued" - ? { priority: "next" as const, requiresExplicitDispatch: true } - : {}), + priority: "next" as const, + requiresExplicitDispatch: true, }); } // Live mutations made while the async disk read was pending win. @@ -49,6 +52,56 @@ function mergeQueues( ); } +function installQueuePersistence(store: Store): void { + if (unsubscribeByStore.has(store)) return; + const unsubscribe = store.sub(messageQueueAtom, () => { + if (suppressPersistenceByStore.delete(store)) return; + queueRevisionByStore.set(store, (queueRevisionByStore.get(store) ?? 0) + 1); + persistQueueBestEffort(store); + }); + unsubscribeByStore.set(store, unsubscribe); +} + +/** Publish a queue snapshot read in the canonical delivery transaction. */ +export function hydrateMessageQueueFromSnapshot( + store: Store, + durable: readonly QueuedMessage[], + executionIntentIds: ReadonlySet +): void { + store.set(messageQueueAtom, (live) => + mergeQueues(durable, live).filter( + (message) => !executionIntentIds.has(message.turnIntentId) + ) + ); + store.set(messageQueueHydratedAtom, true); + installQueuePersistence(store); +} + +/** Re-read this window's queue after an app-global execution mutation. */ +export async function refreshMessageQueueFromDurable( + store: Store, + executionIntentIds: ReadonlySet +): Promise { + for (;;) { + const revision = queueRevisionByStore.get(store) ?? 0; + // A failed local delete/edit is not permission to trust an older disk + // snapshot. Fail closed and keep the live projection until that mutation + // can be persisted or the user retries after storage recovery. + await queuePersistByStore.get(store); + const durable = await loadDurableMessageQueue(); + if (revision !== (queueRevisionByStore.get(store) ?? 0)) continue; + // This is a post-hydration refresh, so the stable durable snapshot is + // authoritative. Additive merging would resurrect a locally deleted row + // read just before its queued persistence completed. + suppressPersistenceByStore.add(store); + store.set( + messageQueueAtom, + durable.filter((message) => !executionIntentIds.has(message.turnIntentId)) + ); + return; + } +} + /** * Hydrate then subscribe one Jotai store. The WeakMap ownership supports test * stores and multiple windows without app-lifetime listener leaks. @@ -57,23 +110,21 @@ 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); + const hydration = (async () => { + try { + const durable = await loadDurableMessageQueue(); + hydrateMessageQueueFromSnapshot(store, durable, new Set()); persistQueueBestEffort(store); - if (!unsubscribeByStore.has(store)) { - const unsubscribe = store.sub(messageQueueAtom, () => { - persistQueueBestEffort(store); - }); - unsubscribeByStore.set(store, unsubscribe); - } - }) - .catch(() => { + } catch (error) { // The repository already logs the root error. Keep the queue usable in // memory rather than blocking all sends when persistence is unavailable. store.set(messageQueueHydratedAtom, true); - }); + // Do not memoize a transient failed read. A later recovery attempt must + // re-open the durable document and install the persistence subscriber. + hydrationByStore.delete(store); + throw error; + } + })(); hydrationByStore.set(store, hydration); return hydration; @@ -83,5 +134,9 @@ export function disposeMessageQueuePersistence(store: Store): void { unsubscribeByStore.get(store)?.(); unsubscribeByStore.delete(store); hydrationByStore.delete(store); + queueRevisionByStore.delete(store); + queuePersistByStore.delete(store); + suppressPersistenceByStore.delete(store); store.set(messageQueueHydratedAtom, false); + store.set(messageDeliveryRecoveryReadyAtom, false); } diff --git a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts index ce3028e5ec..1659e22d58 100644 --- a/src/engines/SessionCore/hooks/session/useQueueDispatch.ts +++ b/src/engines/SessionCore/hooks/session/useQueueDispatch.ts @@ -36,18 +36,33 @@ import { } from "@src/config/sessionCreatorConfig"; import { cancelTurnForTimelineBoundary } from "@src/engines/SessionCore/control/sessionTimelineBoundary"; import { - beginTurnDispatch, - beginTurnStopping, - clearTurnLifecycleSession, - confirmTurnRunning, getTurnGeneration, getTurnPhase, - markTurnTerminal, restoreTurnWorkingAfterInterruptFailure, } from "@src/engines/SessionCore/control/turnLifecycle"; import { + type CanonicalConversationExecution, + assertCanonicalExecutionIsDurableRootHead, + canonicalConversationExecutionsAtom, + canonicalConversationExecutionsHydratedAtom, + canonicalConversationExternalMutationAtom, + disposeCanonicalConversationExecution, + handoffQueuedMessageToCanonicalExecution, + hydrateCanonicalConversationExecutions, + refreshCanonicalConversationExecutions, + removeCanonicalConversationExecution, + returnCanonicalExecutionToMessageQueue, + updateCanonicalConversationExecution, + withCanonicalConversationTurnLock, +} from "@src/engines/SessionCore/conversations/canonicalConversationExecution"; +import { conversationRootKey } from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + QueuedConversationBlockedError, QueuedConversationBusyError, type QueuedConversationExecutor, + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { queueDispatchSyncInputsAtom } from "@src/engines/SessionCore/derived/queueDispatchSyncInputsAtom"; import { @@ -66,12 +81,18 @@ import { import { sessionMapAtom } from "@src/store/session/sessionAtom"; import { type QueuedMessage, + messageDeliveryRecoveryReadyAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, messageQueueHydratedAtom, queueEditingAtom, queuedMessageScopeKey, } from "@src/store/ui/messageQueueAtom"; -import { persistDurableMessageQueue } from "@src/store/ui/messageQueueRepository"; +import { + getMessageQueueOwnerKey, + isPrimaryMessageQueueOwnerKey, + persistDurableMessageQueue, +} from "@src/store/ui/messageQueueRepository"; import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { @@ -86,6 +107,8 @@ import { import { disposeMessageQueuePersistence, hydrateMessageQueue, + hydrateMessageQueueFromSnapshot, + refreshMessageQueueFromDurable, } from "./messageQueuePersistence"; const log = createLogger("useQueueDispatch"); @@ -93,6 +116,7 @@ const log = createLogger("useQueueDispatch"); /** 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; function canonicalRecoveryDelayMs(attempt: number): number { return Math.min( @@ -101,6 +125,26 @@ function canonicalRecoveryDelayMs(attempt: number): number { ); } +function queuedRetryFromExecution( + execution: CanonicalConversationExecution +): QueuedMessage { + const descriptor = execution.message.conversationDispatch; + if (!descriptor) throw new Error("canonical execution target is missing"); + return { + id: execution.message.id, + turnIntentId: execution.message.turnIntentId, + sessionId: execution.message.sessionId, + content: execution.message.content, + displayContent: execution.message.displayContent, + imageDataUrls: execution.message.imageDataUrls, + conversationDispatch: descriptor, + priority: "next", + requiresExplicitDispatch: true, + status: "queued", + createdAt: execution.createdAt, + }; +} + /** * Authoritative pre-dispatch gate for the natural FIFO drain. * @@ -136,10 +180,132 @@ export function useQueueDispatch( executeCanonicalConversation?: QueuedConversationExecutor ): 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; + store.set(messageDeliveryRecoveryReadyAtom, false); + // The accepted execution store is authoritative over an older pending + // queue twin. Hydrate it first, then merge the UI queue and delete only + // exact intent twins; never upsert a pending row over accepted runner + // metadata recovered from disk. + const recoverDelivery = async (): Promise => { + try { + messageQueueOwnerKeyRef.current = await getMessageQueueOwnerKey(); + await hydrateCanonicalConversationExecutions( + store, + (queue, executions) => { + hydrateMessageQueueFromSnapshot( + store, + queue, + new Set( + executions.map((execution) => execution.message.turnIntentId) + ) + ); + } + ); + // Persist live rows that were enqueued while the atomic disk snapshot + // was loading before opening the dispatch gate. + await persistDurableMessageQueue(store.get(messageQueueAtom)); + if (!disposed) store.set(messageDeliveryRecoveryReadyAtom, true); + } catch (error) { + log.error( + "[useQueueDispatch] delivery recovery hydration failed closed:", + error + ); + // Ordinary sessions do not depend on canonical recovery. Hydrate + // their queue even when the app-global conversation store is + // unavailable; conversation rows remain gated below. + await hydrateMessageQueue(store).catch((queueError) => { + log.error( + "[useQueueDispatch] ordinary queue hydration also failed:", + queueError + ); + }); + 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(); + }, delay); + } + } + }; + void recoverDelivery(); + return () => { + disposed = true; + if (hydrationRetryTimer !== null) { + window.clearTimeout(hydrationRetryTimer); + } + disposeMessageQueuePersistence(store); + disposeCanonicalConversationExecution(store); + }; + }, [store]); + + useEffect(() => { + let refreshInFlight: Promise | null = null; + let trailingRefresh = false; + let refreshExecutionsRequested = false; + const refreshDeliveryProjection = (refreshExecutions: boolean) => { + refreshExecutionsRequested ||= refreshExecutions; + if (!store.get(messageQueueHydratedAtom)) return; + if (refreshInFlight) { + trailingRefresh = true; + return; + } + refreshInFlight = (async () => { + do { + trailingRefresh = false; + const shouldRefreshExecutions = refreshExecutionsRequested; + refreshExecutionsRequested = false; + if (shouldRefreshExecutions) { + await refreshCanonicalConversationExecutions(store); + } + const executionIntentIds = new Set( + store + .get(canonicalConversationExecutionsAtom) + .map((execution) => execution.message.turnIntentId) + ); + await refreshMessageQueueFromDurable(store, executionIntentIds); + } while (trailingRefresh); + })() + .catch((error) => + log.warn( + "[useQueueDispatch] failed to refresh delivery projection:", + error + ) + ) + .finally(() => { + refreshInFlight = null; + }); + }; + const unsubscribe = store.sub( + canonicalConversationExternalMutationAtom, + () => refreshDeliveryProjection(false) + ); + const refreshIfVisible = () => { + if ( + typeof document === "undefined" || + document.visibilityState === "visible" + ) { + refreshDeliveryProjection(true); + } + }; + window.addEventListener("focus", refreshIfVisible); + window.addEventListener("online", refreshIfVisible); + document.addEventListener("visibilitychange", refreshIfVisible); + return () => { + unsubscribe(); + window.removeEventListener("focus", refreshIfVisible); + window.removeEventListener("online", refreshIfVisible); + document.removeEventListener("visibilitychange", refreshIfVisible); + }; }, [store]); // ── Dispatch lock ───────────────────────────────────────────────────────── @@ -148,13 +314,6 @@ export function useQueueDispatch( const dispatchLockRef = useRef(false); const inFlightMessageIdRef = useRef(null); - // A canonical root can execute in a different native Session after each - // runtime switch. Keep only the currently running Session id so Send Now - // can address the ordinary interrupt path. Busy/idle ownership remains in - // turnLifecycle; this transient handle is never consulted as a queue gate. - const canonicalRunnerByScopeRef = useRef< - Map - >(new Map()); // Send Now interrupt bookkeeping: one boundary interrupt per message. const interruptRequestedByMessageIdRef = useRef>(new Set()); @@ -168,32 +327,6 @@ export function useQueueDispatch( [store] ); - const persistCanonicalDelivery = useCallback( - async ( - messageId: string, - update: Pick< - QueuedMessage, - | "status" - | "runnerSessionId" - | "runnerEventStartIndex" - | "retryAt" - | "retryAttempt" - > - ) => { - store.set(messageQueueAtom, (current) => - current.map((candidate) => - candidate.id === messageId ? { ...candidate, ...update } : candidate - ) - ); - // This is the crash-recovery boundary: provider dispatch may proceed - // only after the same durable queue row knows its concrete native - // Session. The ordinary queue subscription remains the coalesced writer - // for non-critical reorder/edit mutations. - await persistDurableMessageQueue(store.get(messageQueueAtom)); - }, - [store] - ); - const settleQueuedMessageFailure = useCallback( (message: QueuedMessage, error: unknown) => { // Once dispatchUserIntent has created a durable failed user row, that @@ -202,20 +335,26 @@ export function useQueueDispatch( store.set(messageQueueAtom, (current) => isUserIntentSendError(error) ? current.filter((candidate) => candidate.id !== message.id) - : current.map((candidate) => - candidate.id === message.id - ? { - ...candidate, - status: "queued", - runnerSessionId: undefined, - runnerEventStartIndex: undefined, - retryAt: undefined, - retryAttempt: undefined, - priority: "next", - requiresExplicitDispatch: true, - } - : candidate - ) + : current.some((candidate) => candidate.id === message.id) + ? current.map((candidate) => + candidate.id === message.id + ? { + ...candidate, + status: "queued", + priority: "next", + requiresExplicitDispatch: true, + } + : candidate + ) + : [ + ...current, + { + ...message, + status: "queued", + priority: "next", + requiresExplicitDispatch: true, + }, + ] ); interruptRequestedByMessageIdRef.current.delete(message.id); const detail = error instanceof Error ? error.message : String(error); @@ -229,44 +368,7 @@ export function useQueueDispatch( // Pending wake-up for backend-busy retries. const wakeTimerRef = useRef(null); - const canonicalRecoveryWakeTimerRef = useRef(null); - const canonicalRecoveryWakeAtRef = useRef(null); const tryDispatchNextRef = useRef<() => void>(() => {}); - const armCanonicalRecoveryWake = useCallback( - function armRecoveryWake(retryAt: number) { - if ( - canonicalRecoveryWakeAtRef.current !== null && - canonicalRecoveryWakeAtRef.current <= retryAt - ) { - return; - } - if (canonicalRecoveryWakeTimerRef.current !== null) { - window.clearTimeout(canonicalRecoveryWakeTimerRef.current); - } - canonicalRecoveryWakeAtRef.current = retryAt; - canonicalRecoveryWakeTimerRef.current = window.setTimeout( - () => { - canonicalRecoveryWakeTimerRef.current = null; - canonicalRecoveryWakeAtRef.current = null; - tryDispatchNextRef.current(); - const now = Date.now(); - const nextRetryAt = store - .get(messageQueueAtom) - .reduce((earliest, message) => { - const candidate = Date.parse(message.retryAt ?? ""); - if (candidate <= now || !Number.isFinite(candidate)) - return earliest; - return earliest === undefined || candidate < earliest - ? candidate - : earliest; - }, undefined); - if (nextRetryAt !== undefined) armRecoveryWake(nextRetryAt); - }, - Math.max(0, retryAt - Date.now()) - ); - }, - [store] - ); const dispatchMessage = useCallback( (msg: QueuedMessage, onDone: () => void) => { @@ -332,175 +434,345 @@ export function useQueueDispatch( [acceptQueuedMessage, settleQueuedMessageFailure, store] ); - const dispatchCanonicalMessage = useCallback( - (msg: QueuedMessage, onDone: () => void) => { - if (!msg.conversationDispatch) { - onDone(); - return; + const canonicalExecutionIdsRef = useRef>(new Set()); + const canonicalWakeTimerRef = useRef(null); + const canonicalWakeAtRef = useRef(null); + const tryExecuteCanonicalRef = useRef<() => void>(() => {}); + + const retryCanonicalExecution = useCallback( + async (execution: CanonicalConversationExecution) => { + const attempt = (execution.retryAttempt ?? 0) + 1; + await updateCanonicalConversationExecution(store, execution.id, { + retryAttempt: attempt, + retryAt: new Date( + Date.now() + canonicalRecoveryDelayMs(attempt) + ).toISOString(), + }); + }, + [store] + ); + + const tryExecuteCanonical = useCallback(() => { + if (!store.get(messageDeliveryRecoveryReadyAtom)) return; + if (!store.get(canonicalConversationExecutionsHydratedAtom)) return; + if (!executeCanonicalConversation) return; + const now = Date.now(); + const executions = store.get(canonicalConversationExecutionsAtom); + const ownerKey = messageQueueOwnerKeyRef.current; + if (!ownerKey) return; + const claimedRoots = new Set(); + for (const execution of executions) { + if (!canonicalExecutionIdsRef.current.has(execution.id)) continue; + const descriptor = execution.message.conversationDispatch; + if (descriptor) claimedRoots.add(conversationRootKey(descriptor.root)); + } + const runnable = executions.filter((execution) => { + if (canonicalExecutionIdsRef.current.has(execution.id)) return false; + const descriptor = execution.message.conversationDispatch; + if (!descriptor) return false; + if ( + !isPrimaryMessageQueueOwnerKey(ownerKey) && + execution.originQueueKey !== ownerKey + ) { + return false; } - const scopeKey = queuedMessageScopeKey(msg); - const dispatchGeneration = beginTurnDispatch(scopeKey); - // Loading and materializing a native transcript is already owned work. - // It can legitimately outlive the dispatching dead-man before the - // provider accepts the user turn, so enter the ordinary working phase. - confirmTurnRunning(scopeKey); - let accepted = false; - let releasedDispatchLock = false; - let runnerSessionId: string | null = null; - const releaseDispatchLock = () => { - if (releasedDispatchLock) return; - releasedDispatchLock = true; - onDone(); - }; - const rememberRunner = (sessionId: string) => { - if (getTurnGeneration(scopeKey) !== dispatchGeneration) return; - runnerSessionId = sessionId; - canonicalRunnerByScopeRef.current.set(scopeKey, { - generation: dispatchGeneration, - sessionId, - }); - }; + const rootKey = conversationRootKey(descriptor.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(execution.retryAt ?? ""); + if (Number.isFinite(retryAt) && retryAt > now) return false; + return true; + }); + const nextRetryAt = executions.reduce( + (earliest, execution) => { + const retryAt = Date.parse(execution.retryAt ?? ""); + if (!Number.isFinite(retryAt) || retryAt <= now) return earliest; + return earliest === undefined || retryAt < earliest + ? retryAt + : earliest; + }, + undefined + ); + if ( + nextRetryAt !== undefined && + (canonicalWakeAtRef.current === null || + nextRetryAt < canonicalWakeAtRef.current) + ) { + if (canonicalWakeTimerRef.current !== null) { + window.clearTimeout(canonicalWakeTimerRef.current); + } + canonicalWakeAtRef.current = nextRetryAt; + canonicalWakeTimerRef.current = window.setTimeout( + () => { + canonicalWakeTimerRef.current = null; + canonicalWakeAtRef.current = null; + tryExecuteCanonicalRef.current(); + }, + Math.max(0, nextRetryAt - now) + ); + } - const execution = (async () => { - await persistCanonicalDelivery(msg.id, { - status: "preparing", - runnerSessionId: msg.runnerSessionId, - runnerEventStartIndex: msg.runnerEventStartIndex, - retryAt: undefined, - retryAttempt: msg.retryAttempt, - }); - if (!executeCanonicalConversation) { - throw new Error("canonical conversation executor is unavailable"); - } - const persistedMessage = - store - .get(messageQueueAtom) - .find((candidate) => candidate.id === msg.id) ?? msg; - return await executeCanonicalConversation(store, persistedMessage, { - onAccepted: async (sessionId) => { - if (accepted) return; - accepted = true; - rememberRunner(sessionId); - await persistCanonicalDelivery(msg.id, { - status: "accepted", - runnerSessionId: sessionId, - runnerEventStartIndex: - store - .get(messageQueueAtom) - .find((candidate) => candidate.id === msg.id) - ?.runnerEventStartIndex ?? msg.runnerEventStartIndex, + for (const execution of runnable) { + const executionId = execution.id; + canonicalExecutionIdsRef.current.add(executionId); + const descriptor = execution.message.conversationDispatch; + if (!descriptor) { + canonicalExecutionIdsRef.current.delete(executionId); + continue; + } + void withCanonicalConversationTurnLock(descriptor.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 execution = + await assertCanonicalExecutionIsDurableRootHead(executionId); + let accepted = execution.status === "accepted"; + const message = { + ...execution.message, + status: execution.status, + runnerSessionId: execution.runnerSessionId, + runnerEventStartIndex: execution.runnerEventStartIndex, + } as const; + await executeCanonicalConversation(store, message, { + onRunnerReady: async (runnerSessionId, runnerEventStartIndex) => { + await updateCanonicalConversationExecution(store, execution.id, { + runnerSessionId, + runnerEventStartIndex, retryAt: undefined, - retryAttempt: msg.retryAttempt, }); - releaseDispatchLock(); }, - onRunnerReady: async (sessionId, eventStartIndex) => { - rememberRunner(sessionId); - await persistCanonicalDelivery(msg.id, { - status: "preparing", - runnerSessionId: sessionId, - runnerEventStartIndex: eventStartIndex, + onAccepted: async (runnerSessionId) => { + accepted = true; + await updateCanonicalConversationExecution(store, execution.id, { + status: "accepted", + runnerSessionId, retryAt: undefined, - retryAttempt: msg.retryAttempt, - }); - }, - }); - })(); - - void execution - .then( - (result) => { - acceptQueuedMessage(msg.id); - markTurnTerminal(scopeKey, result.terminalStatus, { - generation: dispatchGeneration, }); }, - async (error: unknown) => { - if (error instanceof QueuedConversationBusyError) { - // Another window owns this root. Persist the same bounded - // recovery backoff as any accepted retry; a fixed 250 ms poll - // burned CPU for the complete duration of a long provider turn. - const current = store - .get(messageQueueAtom) - .find((candidate) => candidate.id === msg.id); - if (current) { - const attempt = (current.retryAttempt ?? 0) + 1; - await persistCanonicalDelivery(msg.id, { - status: current.status, - runnerSessionId: current.runnerSessionId, - runnerEventStartIndex: current.runnerEventStartIndex, - retryAttempt: attempt, - retryAt: new Date( - Date.now() + canonicalRecoveryDelayMs(attempt) - ).toISOString(), - }); + }) + .then(async () => { + await removeCanonicalConversationExecution(store, execution.id); + }) + .catch(async (error: unknown) => { + if (error instanceof QueuedConversationRecoveryBlockedError) { + // This typed verdict proves automatic recovery cannot run the + // provider. Retire the execution owner without synthesizing a + // retry of the already accepted intent; the durable provider/ + // Cloud failure row remains the visible terminal result. + await removeCanonicalConversationExecution(store, execution.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(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (current) await retryCanonicalExecution(current); + return; } - markTurnTerminal(scopeKey, "cancelled", { - generation: dispatchGeneration, + // Admission failed before the canonical user/provider boundary. + // Return the same intent atomically to a visible held queue card. + try { + await returnCanonicalExecutionToMessageQueue( + store, + execution.id, + queuedRetryFromExecution(execution) + ); + } catch (returnError) { + log.error( + "[useQueueDispatch] could not restore blocked queue row:", + returnError + ); + const current = store + .get(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (current) await retryCanonicalExecution(current); + return; + } + Message.error({ + content: error.message, + duration: 5000, }); return; } - if (!accepted) { - settleQueuedMessageFailure(msg, error); - } else { + 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 provider turn failed after acceptance:", + "[useQueueDispatch] canonical execution needs recovery:", error ); const current = store - .get(messageQueueAtom) - .find((candidate) => candidate.id === msg.id); - if (current) { - const attempt = (current.retryAttempt ?? 0) + 1; - await persistCanonicalDelivery(msg.id, { - status: current.status, - runnerSessionId: current.runnerSessionId, - runnerEventStartIndex: current.runnerEventStartIndex, - retryAttempt: attempt, - retryAt: new Date( - Date.now() + canonicalRecoveryDelayMs(attempt) - ).toISOString(), - }); + .get(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (current) await retryCanonicalExecution(current); + return; + } + if (error instanceof QueuedConversationTurnClosedError) { + // The Cloud plane already contains the human row and its terminal + // failure result. Removing only the execution owner completes the + // lifecycle; requeueing would create a duplicate provider turn. + await removeCanonicalConversationExecution(store, execution.id); + 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(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (!current) return; + await retryCanonicalExecution(current); + return; + } + if (isUserIntentSendError(error)) { + await removeCanonicalConversationExecution(store, execution.id); + return; + } + const descriptor = execution.message.conversationDispatch; + if (descriptor) { + try { + await returnCanonicalExecutionToMessageQueue( + store, + execution.id, + queuedRetryFromExecution(execution) + ); + } catch (returnError) { + log.error( + "[useQueueDispatch] could not restore failed queue row:", + returnError + ); + const current = store + .get(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (current) await retryCanonicalExecution(current); + return; } + } else { + await removeCanonicalConversationExecution(store, execution.id); } - markTurnTerminal(scopeKey, "failed", { - generation: dispatchGeneration, + 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(); + store.set(canonicalConversationExecutionsAtom, (current) => + current.map((candidate) => + candidate.id === execution.id + ? { ...candidate, retryAt } + : candidate + ) + ); + }); + }) + .catch(async (lockError: unknown) => { + if (lockError instanceof QueuedConversationBusyError) { + await refreshCanonicalConversationExecutions(store); + const current = store + .get(canonicalConversationExecutionsAtom) + .find((candidate) => candidate.id === execution.id); + if (current) await retryCanonicalExecution(current); + return; } - ) + log.error( + "[useQueueDispatch] canonical root lock/claim failed:", + lockError + ); + const retryAt = new Date( + Date.now() + QUEUE_BACKEND_RECHECK_MS + ).toISOString(); + store.set(canonicalConversationExecutionsAtom, (current) => + current.map((candidate) => + candidate.id === execution.id + ? { ...candidate, retryAt } + : candidate + ) + ); + }) .finally(() => { - const currentRunner = canonicalRunnerByScopeRef.current.get(scopeKey); - if ( - currentRunner?.generation === dispatchGeneration && - currentRunner.sessionId === runnerSessionId - ) { - canonicalRunnerByScopeRef.current.delete(scopeKey); - } - // Canonical scope ids are virtual and do not participate in normal - // Session deletion cleanup. Drop now-idle state eagerly. - if (getTurnPhase(scopeKey) === "idle") { - clearTurnLifecycleSession(scopeKey); - } - releaseDispatchLock(); + canonicalExecutionIdsRef.current.delete(execution.id); + tryExecuteCanonicalRef.current(); tryDispatchNextRef.current(); - const retryAt = Date.parse( - store - .get(messageQueueAtom) - .find((candidate) => candidate.id === msg.id)?.retryAt ?? "" + }); + } + }, [executeCanonicalConversation, retryCanonicalExecution, store]); + + const dispatchCanonicalMessage = useCallback( + (msg: QueuedMessage, onDone: () => void) => { + if (!msg.conversationDispatch) { + onDone(); + return; + } + const execution: CanonicalConversationExecution = { + id: msg.id, + message: { + id: msg.id, + turnIntentId: msg.turnIntentId, + sessionId: msg.sessionId, + content: msg.content, + displayContent: msg.displayContent, + imageDataUrls: msg.imageDataUrls, + conversationDispatch: msg.conversationDispatch, + }, + status: "preparing", + createdAt: msg.createdAt, + }; + store.set(messageQueueHandoffIdsAtom, (current: ReadonlySet) => { + const next = new Set(current); + next.add(msg.id); + return next; + }); + void persistDurableMessageQueue(store.get(messageQueueAtom)) + .then(() => handoffQueuedMessageToCanonicalExecution(store, execution)) + .then(() => { + tryExecuteCanonicalRef.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; + } ); - if (Number.isFinite(retryAt)) { - armCanonicalRecoveryWake(retryAt); - } + onDone(); }); }, - [ - acceptQueuedMessage, - armCanonicalRecoveryWake, - executeCanonicalConversation, - persistCanonicalDelivery, - settleQueuedMessageFailure, - store, - ] + [settleQueuedMessageFailure, store] ); const tryDispatchNext = useCallback(() => { @@ -512,29 +784,30 @@ export function useQueueDispatch( if (!store.get(messageQueueHydratedAtom)) return; if (store.get(queueEditingAtom)) return; - const queue = store.get(messageQueueAtom); + const canonicalRecoveryReady = store.get(messageDeliveryRecoveryReadyAtom); + const queue = store + .get(messageQueueAtom) + .filter( + (message) => canonicalRecoveryReady || !message.conversationDispatch + ); if (queue.length === 0) return; - const now = Date.now(); const candidates = queue.filter( - (msg) => - msg.id !== inFlightMessageIdRef.current && - (Number.isNaN(Date.parse(msg.retryAt ?? "")) || - Date.parse(msg.retryAt ?? "") <= now) - ); - const earliestDeferredRetry = queue.reduce( - (earliest, message) => { - const candidate = Date.parse(message.retryAt ?? ""); - if (!Number.isFinite(candidate) || candidate <= now) return earliest; - return earliest === undefined || candidate < earliest - ? candidate - : earliest; - }, - undefined + (msg) => msg.id !== inFlightMessageIdRef.current ); - if (earliestDeferredRetry !== undefined) { - armCanonicalRecoveryWake(earliestDeferredRetry); - } + const activeCanonicalExecution = (message: QueuedMessage) => { + const descriptor = message.conversationDispatch; + if (!descriptor) return undefined; + const rootKey = conversationRootKey(descriptor.root); + return store + .get(canonicalConversationExecutionsAtom) + .find( + (execution) => + execution.message.conversationDispatch && + conversationRootKey(execution.message.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 @@ -543,7 +816,12 @@ export function useQueueDispatch( const explicitMessages = candidates.filter((msg) => msg.priority === "now"); for (const explicitMsg of explicitMessages) { const scopeKey = queuedMessageScopeKey(explicitMsg); - const phase = getTurnPhase(scopeKey); + const canonicalExecution = activeCanonicalExecution(explicitMsg); + const phase = explicitMsg.conversationDispatch + ? canonicalExecution + ? "working" + : "idle" + : getTurnPhase(scopeKey); if (phase === "idle") { // One shared admission/dispatch policy owns the Stop episode for both // ordinary Sessions and canonical runtime continuations. @@ -567,7 +845,7 @@ export function useQueueDispatch( !interruptRequestedByMessageIdRef.current.has(explicitMsg.id) ) { const interruptSessionId = explicitMsg.conversationDispatch - ? canonicalRunnerByScopeRef.current.get(scopeKey)?.sessionId + ? canonicalExecution?.runnerSessionId : explicitMsg.sessionId; // The canonical root may still be preparing its native Session. Until // onRunnerReady publishes an addressable Session there is nothing the @@ -576,11 +854,7 @@ export function useQueueDispatch( // 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); - if (explicitMsg.conversationDispatch) { - beginTurnStopping(scopeKey); - } const interruptGeneration = getTurnGeneration(interruptSessionId); - const scopeGeneration = getTurnGeneration(scopeKey); let interruptFailureHandled = false; const handleInterruptFailure = (detail: string) => { if (interruptFailureHandled) return; @@ -588,11 +862,6 @@ export function useQueueDispatch( restoreTurnWorkingAfterInterruptFailure(interruptSessionId, { generation: interruptGeneration, }); - if (scopeKey !== interruptSessionId) { - restoreTurnWorkingAfterInterruptFailure(scopeKey, { - generation: scopeGeneration, - }); - } settleQueuedMessageFailure(explicitMsg, new Error(detail)); log.warn("[useQueueDispatch] force-send interrupt failed:", detail); }; @@ -614,8 +883,8 @@ export function useQueueDispatch( if (msg.priority === "now") continue; if (msg.requiresExplicitDispatch) continue; // held by a user Stop const scopeKey = queuedMessageScopeKey(msg); - if (getTurnPhase(scopeKey) !== "idle") continue; // turn active if (msg.conversationDispatch) { + if (activeCanonicalExecution(msg)) continue; dispatchLockRef.current = true; inFlightMessageIdRef.current = msg.id; dispatchCanonicalMessage(msg, () => { @@ -627,6 +896,7 @@ export function useQueueDispatch( }); return; } + 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 @@ -690,7 +960,6 @@ export function useQueueDispatch( }, [ dispatchCanonicalMessage, dispatchMessage, - armCanonicalRecoveryWake, settleQueuedMessageFailure, store, ]); @@ -700,19 +969,27 @@ export function useQueueDispatch( }, [tryDispatchNext]); useEffect(() => { - const unsubscribe = store.sub(queueDispatchSyncInputsAtom, tryDispatchNext); - tryDispatchNext(); + tryExecuteCanonicalRef.current = tryExecuteCanonical; + const wakeDispatchers = () => { + tryExecuteCanonical(); + tryDispatchNext(); + }; + // One dependency bundle owns every wake-up, including the canonical + // hydrated=false→true flip. Subscribing only to execution rows misses a + // cold-start accepted row because hydration writes rows before the flag. + const unsubscribe = store.sub(queueDispatchSyncInputsAtom, wakeDispatchers); + wakeDispatchers(); return () => { unsubscribe(); if (wakeTimerRef.current !== null) { window.clearTimeout(wakeTimerRef.current); wakeTimerRef.current = null; } - if (canonicalRecoveryWakeTimerRef.current !== null) { - window.clearTimeout(canonicalRecoveryWakeTimerRef.current); - canonicalRecoveryWakeTimerRef.current = null; - canonicalRecoveryWakeAtRef.current = null; + if (canonicalWakeTimerRef.current !== null) { + window.clearTimeout(canonicalWakeTimerRef.current); + canonicalWakeTimerRef.current = null; + canonicalWakeAtRef.current = null; } }; - }, [store, tryDispatchNext]); + }, [store, tryDispatchNext, tryExecuteCanonical]); } diff --git a/src/engines/SessionCore/services/userIntentDispatch.test.ts b/src/engines/SessionCore/services/userIntentDispatch.test.ts index 505f33a623..fa2f69e1ae 100644 --- a/src/engines/SessionCore/services/userIntentDispatch.test.ts +++ b/src/engines/SessionCore/services/userIntentDispatch.test.ts @@ -1,12 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - pendingSyntheticEventAtom, - sessionIdAtom, -} from "@src/engines/SessionCore/core/atoms/metadata"; +import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import { - clearParkedUserIntentEvent, confirmUserIntentPreparation, dispatchUserIntent, prepareUserIntent, @@ -202,7 +198,6 @@ describe("userIntentDispatch", () => { sessionId: "agentsession-1", visibleText: "hello", runtimeStatusSource: "launch", - pendingPolicy: "across_session_switch", send: { content: "hello", turnIntentId: "intent-failed", @@ -232,11 +227,6 @@ describe("userIntentDispatch", () => { }), "agentsession-1" ); - expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toMatchObject({ - id: "user-agentsession-1", - displayStatus: "failed", - result: expect.objectContaining({ deliveryStatus: "failed" }), - }); }); it("diagnoses a missing accepted-row projection without resending transport", async () => { @@ -301,7 +291,6 @@ describe("userIntentDispatch", () => { visibleText: "hello", turnIntentId: "intent-prepared", runtimeStatusSource: "launch", - pendingPolicy: "across_session_switch", }); confirmUserIntentPreparation(preparation); @@ -323,12 +312,6 @@ describe("userIntentDispatch", () => { // Adoption is idempotently re-appended after transcript synchronization. expect(mocks.append).toHaveBeenCalledTimes(2); expect(mocks.confirmTurnRunning).toHaveBeenCalledTimes(2); - expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toEqual( - expect.objectContaining({ id: "user-cliagent-1" }) - ); - - clearParkedUserIntentEvent(preparation.userEvent.id); - expect(mocks.atomValues.get(pendingSyntheticEventAtom)).toBeNull(); }); it("rejects a preparation from a different concrete session", async () => { @@ -337,7 +320,6 @@ describe("userIntentDispatch", () => { visibleText: "hello", turnIntentId: "intent-transfer", runtimeStatusSource: "launch", - pendingPolicy: "across_session_switch", }); await expect( dispatchUserIntent({ diff --git a/src/engines/SessionCore/services/userIntentDispatch.ts b/src/engines/SessionCore/services/userIntentDispatch.ts index 7bef6305d7..dd57a2c36b 100644 --- a/src/engines/SessionCore/services/userIntentDispatch.ts +++ b/src/engines/SessionCore/services/userIntentDispatch.ts @@ -17,10 +17,6 @@ import { confirmTurnRunning, markTurnTerminal, } from "@src/engines/SessionCore/control/turnLifecycle"; -import { - pendingSyntheticEventAtom, - sessionIdAtom, -} from "@src/engines/SessionCore/core/atoms/metadata"; 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"; @@ -38,18 +34,12 @@ import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; const log = createLogger("UserIntentDispatch"); -export type UserIntentPendingPolicy = - | "none" - | "visible" - | "across_session_switch"; - export interface UserIntentPreparation { sessionId: string; userEvent: SessionEvent; generation: number; turnIntentId: string; runtimeStatusSource: SessionRuntimeStatusSource; - pendingPolicy: UserIntentPendingPolicy; } interface PrepareUserIntentParams { @@ -58,7 +48,6 @@ interface PrepareUserIntentParams { imageDataUrls?: string[]; turnIntentId: string; runtimeStatusSource?: SessionRuntimeStatusSource; - pendingPolicy?: UserIntentPendingPolicy; /** Preserve the durable queue identity on a newly created optimistic row. */ queueMessageId?: string; /** Runs after the synchronous lifecycle reserve and before EventStore I/O. */ @@ -121,20 +110,6 @@ const preparationStates = new WeakMap< UserIntentPreparationState >(); -function parkUserIntentEvent( - event: SessionEvent, - policy: UserIntentPendingPolicy -): void { - if (policy === "none") return; - const store = getInstrumentedStore(); - if ( - policy === "across_session_switch" || - store.get(sessionIdAtom) === event.sessionId - ) { - store.set(pendingSyntheticEventAtom, event); - } -} - function deliveryEvent( event: SessionEvent, status: "pending" | "sent" | "failed", @@ -171,7 +146,6 @@ async function setUserIntentDelivery( ): Promise { const next = deliveryEvent(preparation.userEvent, status, error); preparation.userEvent = next; - parkUserIntentEvent(next, preparation.pendingPolicy); const updated = await eventStoreProxy.updateById( next.id, { displayStatus: next.displayStatus, result: next.result }, @@ -184,14 +158,6 @@ async function setUserIntentDelivery( } } -export function clearParkedUserIntentEvent(userEventId: string): void { - const store = getInstrumentedStore(); - const pending = store.get(pendingSyntheticEventAtom); - if (pending?.id === userEventId) { - store.set(pendingSyntheticEventAtom, null); - } -} - /** * Reserve a turn and persist its canonical optimistic user row before any * slower transcript preparation. The returned value is dispatched in that @@ -201,7 +167,6 @@ export async function prepareUserIntent( params: PrepareUserIntentParams ): Promise { const runtimeStatusSource = params.runtimeStatusSource ?? "dispatch"; - const pendingPolicy = params.pendingPolicy ?? "none"; const generation = beginTurnDispatch(params.sessionId); publishTurnIntentDispatch(params.turnIntentId, { sessionId: params.sessionId, @@ -218,7 +183,6 @@ export async function prepareUserIntent( deliveryStatus: "pending", queueMessageId: params.queueMessageId, }); - parkUserIntentEvent(userEvent, pendingPolicy); await eventStoreProxy.append([userEvent], params.sessionId); const preparation = { sessionId: params.sessionId, @@ -226,7 +190,6 @@ export async function prepareUserIntent( generation, turnIntentId: params.turnIntentId, runtimeStatusSource, - pendingPolicy, }; preparationStates.set(preparation, "prepared"); return preparation; @@ -235,7 +198,6 @@ export async function prepareUserIntent( markTurnTerminal(params.sessionId, "failed", { generation }); if (userEvent) { const failed = deliveryEvent(userEvent, "failed", error); - parkUserIntentEvent(failed, pendingPolicy); await eventStoreProxy .updateById( failed.id, @@ -293,7 +255,6 @@ async function resolveUserIntentPreparation( imageDataUrls: params.imageDataUrls, turnIntentId: params.send.turnIntentId, runtimeStatusSource: params.runtimeStatusSource, - pendingPolicy: params.pendingPolicy, beforeAppend: params.beforeAppend, queueMessageId: params.queueMessageId, }); @@ -314,7 +275,6 @@ async function resolveUserIntentPreparation( } // Native materialization may replace EventStore between preparation and // dispatch. Append is ID-deduped, so restore the exact same optimistic row. - parkUserIntentEvent(existing.userEvent, existing.pendingPolicy); try { await eventStoreProxy.append([existing.userEvent], params.sessionId); return existing; diff --git a/src/features/ConversationContinuation/enqueueCanonicalConversation.ts b/src/features/ConversationContinuation/enqueueCanonicalConversation.ts index e6a55de693..cd8eba8294 100644 --- a/src/features/ConversationContinuation/enqueueCanonicalConversation.ts +++ b/src/features/ConversationContinuation/enqueueCanonicalConversation.ts @@ -1,9 +1,5 @@ import type { Store } from "jotai/vanilla/store"; -import { - admitUserIntentToMessageQueue, - isExplicitPostStopSubmit, -} from "@src/engines/SessionCore/control/messageQueueAdmission"; import type { ConversationRootLocator, LocalConversationTarget, @@ -13,6 +9,8 @@ import { org2CloudAuthAtom, org2CloudAuthIdentityKey, } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { postStopDispatchSessionsAtom } from "@src/store/session/cliSessionStatusAtom"; +import { enqueueMessageAtom } from "@src/store/ui/messageQueueAtom"; interface CanonicalConversationQueueInput { displayText: string; @@ -56,25 +54,24 @@ export async function enqueueCanonicalConversation(params: { return org2CloudAuthIdentityKey(auth); })() : undefined; - const result = admitUserIntentToMessageQueue({ - store, - explicitPostStopSubmit: isExplicitPostStopSubmit(store, sessionId), - message: { - id, - turnIntentId, - sessionId, - content: input.agentContent ?? input.displayText, - displayContent: input.displayText, - imageDataUrls: input.imageDataUrls, - conversationDispatch: { - kind: "canonical_conversation", - root, - target, - ...(dispatchIdentityKey ? { dispatchIdentityKey } : {}), - }, - status: "queued", - createdAt: new Date().toISOString(), + const explicitPostStopSubmit = + store.get(postStopDispatchSessionsAtom)[sessionId] === true; + const result = store.set(enqueueMessageAtom, { + id, + turnIntentId, + sessionId, + content: input.agentContent ?? input.displayText, + displayContent: input.displayText, + imageDataUrls: input.imageDataUrls, + conversationDispatch: { + kind: "canonical_conversation", + root, + target, + ...(dispatchIdentityKey ? { dispatchIdentityKey } : {}), }, + priority: explicitPostStopSubmit ? "now" : "next", + status: "queued", + createdAt: new Date().toISOString(), }); if (result === "duplicate") return true; if (result !== "enqueued") { diff --git a/src/features/ConversationContinuation/queuedConversationExecutor.recovery.test.ts b/src/features/ConversationContinuation/queuedConversationExecutor.recovery.test.ts new file mode 100644 index 0000000000..1a298f10de --- /dev/null +++ b/src/features/ConversationContinuation/queuedConversationExecutor.recovery.test.ts @@ -0,0 +1,108 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import type { QueuedConversationExecutionMessage } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { QueuedConversationRecoveryPendingError } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; + +import { dispatchQueuedCanonicalConversation } from "./queuedConversationExecutor"; + +const mocks = vi.hoisted(() => ({ + order: [] as string[], + loadSessions: vi.fn(), + loadTimeline: vi.fn(), + continueLocal: vi.fn(), + recoverLocal: vi.fn(), +})); + +vi.mock("@src/api/tauri/externalHistory", () => ({ + getImportedHistorySourceBySessionId: vi.fn(() => undefined), +})); +vi.mock( + "@src/engines/SessionCore/conversations/canonicalConversationEvents", + () => ({ loadCanonicalConversationEvents: mocks.loadTimeline }) +); +vi.mock( + "@src/engines/SessionCore/conversations/localConversationContinuation", + () => ({ + continueLocalConversationAfterTimelineLoad: mocks.continueLocal, + recoverLocalConversationTurn: mocks.recoverLocal, + }) +); +vi.mock( + "@src/features/Org2Cloud/SessionConversation/queuedConversationExecutor", + () => ({ + dispatchQueuedCloudConversation: vi.fn(), + }) +); +vi.mock("@src/store/session", async () => { + const { atom } = await import("jotai"); + return { + loadSessions: mocks.loadSessions, + sessionsAtom: atom([{ session_id: "source-session", name: "Source" }]), + }; +}); +vi.mock("@src/store/session/sessionTabPlacementAtom", async () => { + const { atom } = await import("jotai"); + return { + publishSessionContinuationAtom: atom( + null, + (_get, _set, _update: unknown) => { + mocks.order.push("reveal"); + } + ), + }; +}); +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("reveals a native child before a failed durable runner receipt and keeps recovery pending", async () => { + mocks.order.length = 0; + mocks.loadSessions.mockImplementation(async () => { + mocks.order.push("load"); + }); + 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(["load", "reveal", "persist"]); + expect(onRunnerReady).toHaveBeenCalledWith("cliagent-child", 7); + }); +}); diff --git a/src/features/ConversationContinuation/queuedConversationExecutor.test.ts b/src/features/ConversationContinuation/queuedConversationExecutor.test.ts index d7f58fc02b..60c5ab4437 100644 --- a/src/features/ConversationContinuation/queuedConversationExecutor.test.ts +++ b/src/features/ConversationContinuation/queuedConversationExecutor.test.ts @@ -1,10 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { withCanonicalConversationTurnLock } from "@src/engines/SessionCore/conversations/canonicalConversationExecution"; import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; import { QueuedConversationBusyError } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; -import { withCanonicalConversationTurnLock } from "./queuedConversationExecutor"; - function installSerialWebLocks(): string[] { const requested: string[] = []; const held = new Set(); diff --git a/src/features/ConversationContinuation/queuedConversationExecutor.ts b/src/features/ConversationContinuation/queuedConversationExecutor.ts index 7733107c4d..84a54f3bab 100644 --- a/src/features/ConversationContinuation/queuedConversationExecutor.ts +++ b/src/features/ConversationContinuation/queuedConversationExecutor.ts @@ -2,20 +2,18 @@ import type { Store } from "jotai/vanilla/store"; import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; -import { - type ConversationRootLocator, - conversationRootKey, -} from "@src/engines/SessionCore/conversations/conversationTypes"; import { continueLocalConversationAfterTimelineLoad, recoverLocalConversationTurn, } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import type { - QueuedConversationExecutionResult, + QueuedConversationExecutionMessage, QueuedConversationExecutor, - QueuedConversationMessage, } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; -import { QueuedConversationBusyError } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { dispatchQueuedCloudConversation } from "@src/features/Org2Cloud/SessionConversation/queuedConversationExecutor"; import type { Session } from "@src/store/session"; import { loadSessions, sessionsAtom } from "@src/store/session"; @@ -23,68 +21,6 @@ import { publishSessionContinuationAtom } from "@src/store/session/sessionTabPla import { resolveExternalHistoryContinuation } from "./externalHistoryContinuation"; -const CANONICAL_CONVERSATION_LOCK_PREFIX = "orgii:canonical-conversation:"; - -/** - * Serialize one canonical root across the main and detached Tauri webviews. - * - * Each webview intentionally owns its existing durable message queue, but a - * canonical root can be visible in more than one window. Web Locks are already - * the app's cross-webview mutex primitive (the Cloud auth refresh path uses the - * same API). Holding this lock for the provider turn prevents two independent - * queue realms from materializing and running divergent native episodes at - * once. The queue remains the sole dispatcher; this is only its process-wide - * root boundary, and a closed/crashed webview releases the lock automatically. - */ -export async function withCanonicalConversationTurnLock( - root: 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 = `${CANONICAL_CONVERSATION_LOCK_PREFIX}${conversationRootKey(root)}`; - let result: - | { ok: true; value: T } - | { ok: false; error: unknown } - | undefined; - try { - // Keep callback failures inside a fulfilled lock request. Otherwise a - // broad acquisition fallback cannot distinguish "Web Locks unavailable" - // from "the provider turn failed" and may execute the same user turn a - // second time outside the lock. - 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 { - // Executing unlocked is not safe: another window may already own this - // canonical root and materialize a divergent native episode. Let the - // existing queue surface a retryable failed message instead of risking a - // duplicate provider turn. - 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; -} - function sessionById(store: Store, sessionId: string): Session | undefined { return store .get(sessionsAtom) @@ -110,9 +46,9 @@ async function revealRunnerIfSourceIsVisible( async function dispatchQueuedLocalConversation( store: Store, - message: QueuedConversationMessage, + message: QueuedConversationExecutionMessage, callbacks: Parameters[2] -): Promise { +): Promise { const descriptor = message.conversationDispatch; if (!descriptor) throw new Error("canonical conversation target is missing"); const { root } = descriptor; @@ -154,6 +90,39 @@ async function dispatchQueuedLocalConversation( target.workspaceRepoPath ?? undefined ); }; + let runnerReady = false; + let providerAccepted = message.status === "accepted"; + const announceRunner = async ( + sessionId: string, + eventStartIndex: number + ): Promise => { + runnerReady = true; + // The native child already exists at this boundary. Publish it to the + // mounted surface before durable queue bookkeeping so a transient receipt + // write cannot leave a real execution episode hidden from the user. + try { + await revealRunner(sessionId); + } catch (error) { + throw new QueuedConversationRecoveryPendingError( + `runner ${sessionId} could not be revealed yet: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + try { + await callbacks.onRunnerReady?.(sessionId, eventStartIndex); + } catch (error) { + // The child is discoverable again through its canonical parent. Keep the + // global execution owner so the same turn can reconnect to that child; + // treating this as an ordinary send failure would delete the only retry + // authority and create another native episode on the next attempt. + throw new QueuedConversationRecoveryPendingError( + `runner ${sessionId} is visible but its recovery receipt could not be persisted: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + }; const continuationParams = { root, title, @@ -164,28 +133,41 @@ async function dispatchQueuedLocalConversation( imageDataUrls: message.imageDataUrls, target, turnIntentId: message.turnIntentId, - onSessionPreparing: async (sessionId: string) => { - await callbacks.onRunnerReady?.(sessionId, Number.MAX_SAFE_INTEGER); - await revealRunner(sessionId); + onSessionPreparing: (sessionId: string) => + announceRunner(sessionId, Number.MAX_SAFE_INTEGER), + onSessionReady: announceRunner, + onTurnAccepted: async (sessionId: string) => { + providerAccepted = true; + await callbacks.onAccepted(sessionId); }, - onSessionReady: async (sessionId: string, eventStartIndex: number) => { - await callbacks.onRunnerReady?.(sessionId, eventStartIndex); - await revealRunner(sessionId); - }, - onTurnAccepted: callbacks.onAccepted, }; - if (message.status !== "queued" && message.runnerSessionId) { + if (message.runnerSessionId) { const recovered = await recoverLocalConversationTurn({ ...continuationParams, timeline: await continuationParams.loadTimeline(), runnerSessionId: message.runnerSessionId, eventStartIndex: message.runnerEventStartIndex, }); - if (recovered) return { terminalStatus: recovered.terminalStatus }; + if (recovered) return; + if (message.status === "accepted") { + throw new QueuedConversationRecoveryPendingError(); + } } - const result = + try { await continueLocalConversationAfterTimelineLoad(continuationParams); - return { terminalStatus: result.terminalStatus }; + } 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; + } } /** The sole canonical executor injected into SessionCore's existing queue. */ @@ -195,18 +177,13 @@ export const dispatchQueuedCanonicalConversation: QueuedConversationExecutor = if (!descriptor || descriptor.kind !== "canonical_conversation") { throw new Error("queued message is not a canonical conversation turn"); } - return await withCanonicalConversationTurnLock( - descriptor.root, - async () => { - if (descriptor.root.authority === "org2-cloud") { - return await dispatchQueuedCloudConversation( - store, - message, - descriptor.root, - callbacks - ); - } - return await dispatchQueuedLocalConversation(store, message, callbacks); - } - ); + if (descriptor.root.authority === "org2-cloud") { + return await dispatchQueuedCloudConversation( + store, + message, + descriptor.root, + callbacks + ); + } + return await dispatchQueuedLocalConversation(store, message, callbacks); }; diff --git a/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx index 69f3b27112..2cd1d68cb7 100644 --- a/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx +++ b/src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider.tsx @@ -1,14 +1,12 @@ import { useAtomValue } from "jotai"; import React, { useMemo } from "react"; -import { - ConversationSenderMetadataProvider, - useConversationViewerState, -} from "@src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext"; +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"; @@ -152,8 +150,9 @@ function SubscribedOrg2ConversationSenderMetadataProvider({ const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); const loadingSource = useCloudSessionLoadingSource(sessionId); const comments = useSessionCommentsContext(); - const viewer = useConversationViewerState( - auth?.userId ?? comments?.viewerUserId ?? null + const viewer = resolveConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null, + true ); const forkedFrom = useMemo( () => (session ? getSessionForkedFrom(session) : undefined), diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts deleted file mode 100644 index a63ca97cbe..0000000000 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; - -import { - type ActiveConversationRunner, - activeConversationRunnerKey, - buildConversationRunnerOverlay, - collectLandedTurnIds, - removeConversationRunnerByTurn, - selectActiveRunners, - selectConversationRunnerTail, - upsertConversationRunner, -} 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", eventStartIndex: 8 }, - { runnerSessionId: "r2", turnId: "t2", eventStartIndex: 0 }, - ]; - - 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]]); - }); -}); - -describe("selectConversationRunnerTail", () => { - it("windows a reused native session to the current non-user tail", () => { - const events = [ - { id: "old-agent", source: "assistant" }, - { id: "current-user", source: "user" }, - { id: "current-tool", source: "system" }, - { id: "current-agent", source: "assistant" }, - ] as unknown as SessionEvent[]; - expect( - selectConversationRunnerTail( - { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, - events - ).map((event) => event.id) - ).toEqual(["current-tool", "current-agent"]); - }); - - it("builds the production overlay from only that windowed tail", () => { - const events = [ - { id: "old-agent", chunk_id: "old-agent", source: "assistant" }, - { id: "current-user", chunk_id: "current-user", source: "user" }, - { id: "current-agent", chunk_id: "current-agent", source: "assistant" }, - ] as unknown as SessionEvent[]; - expect( - buildConversationRunnerOverlay( - { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, - events, - "canonical-root" - ) - ).toEqual([ - expect.objectContaining({ - id: "runlive-current-agent", - chunk_id: "runlive-current-agent", - sessionId: "canonical-root", - }), - ]); - }); -}); - -describe("removeConversationRunnerByTurn", () => { - it("drops only the empty terminal turn and removes an empty root bucket", () => { - const registry = { - root: [ - { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, - { runnerSessionId: "r2", turnId: "t2", eventStartIndex: 2 }, - ], - }; - expect(removeConversationRunnerByTurn(registry, "root", "t1")).toEqual({ - root: [{ runnerSessionId: "r2", turnId: "t2", eventStartIndex: 2 }], - }); - expect( - removeConversationRunnerByTurn({ root: [registry.root[0]] }, "root", "t1") - ).toEqual({}); - }); -}); - -describe("active conversation runner registry identity and bounds", () => { - const root = (conversationId: string) => ({ - authority: "org2-cloud", - authorityScope: ["org-1"], - conversationId, - }); - - it("partitions runners by the exact auth identity and canonical root", () => { - const authARoot1 = activeConversationRunnerKey("auth-a", root("root-1")); - const authBRoot1 = activeConversationRunnerKey("auth-b", root("root-1")); - const authARoot2 = activeConversationRunnerKey("auth-a", root("root-2")); - - expect(authARoot1).not.toBe(authBRoot1); - expect(authARoot1).not.toBe(authARoot2); - - const first = { - runnerSessionId: "runner-a", - turnId: "turn-a", - eventStartIndex: 0, - }; - const second = { - runnerSessionId: "runner-b", - turnId: "turn-b", - eventStartIndex: 0, - }; - const registry = upsertConversationRunner( - upsertConversationRunner({}, authARoot1, first), - authBRoot1, - second - ); - - expect(registry[authARoot1]).toEqual([first]); - expect(registry[authBRoot1]).toEqual([second]); - expect(registry[authARoot2]).toBeUndefined(); - }); - - it("bounds both runners per root and remembered root buckets", () => { - const key = activeConversationRunnerKey("auth-a", root("busy-root")); - let registry: Record = {}; - for (let index = 0; index < 9; index += 1) { - registry = upsertConversationRunner(registry, key, { - runnerSessionId: `runner-${index}`, - turnId: `turn-${index}`, - eventStartIndex: index, - }); - } - expect(registry[key]).toHaveLength(8); - expect(registry[key]?.[0]?.runnerSessionId).toBe("runner-1"); - - for (let index = 0; index < 33; index += 1) { - const rootKey = activeConversationRunnerKey( - "auth-a", - root(`root-${index}`) - ); - registry = upsertConversationRunner(registry, rootKey, { - runnerSessionId: `root-runner-${index}`, - turnId: `root-turn-${index}`, - eventStartIndex: 0, - }); - } - expect(Object.keys(registry)).toHaveLength(32); - expect( - registry[activeConversationRunnerKey("auth-a", root("root-0"))] - ).toBeUndefined(); - expect( - registry[activeConversationRunnerKey("auth-a", root("root-32"))] - ).toHaveLength(1); - }); -}); diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts deleted file mode 100644 index b0d1b141dd..0000000000 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Live overlay registry for in-flight member turns. - * - * A member's send runs the turn in an invisible durable local execution - * Session 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 ConversationRootLocator, - conversationRootKey, -} from "@src/engines/SessionCore/conversations/conversationTypes"; -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; - /** Native-event prefix from earlier turns; never overlay it again. */ - eventStartIndex: number; -} - -const MAX_ACTIVE_CONVERSATION_ROOTS = 32; -const MAX_ACTIVE_RUNNERS_PER_ROOT = 8; - -/** - * The overlay is local UI state, but the plane it shadows is Cloud state. - * Include the endpoint/account identity as well as the canonical root so an - * account or endpoint switch can never expose a runner from the previous - * identity merely because the org/session ids happen to match. - */ -export function activeConversationRunnerKey( - authIdentityKey: string, - root: ConversationRootLocator -): string { - return JSON.stringify([authIdentityKey, conversationRootKey(root)]); -} - -/** `(auth identity, canonical root)` → this device's in-flight runners. */ -export const activeConversationRunnersAtom = atom< - Record ->({}); -activeConversationRunnersAtom.debugLabel = "activeConversationRunnersAtom"; - -/** Insert one runner while bounding both a busy root and the registry itself. */ -export function upsertConversationRunner( - registry: Readonly>, - key: string, - runner: ActiveConversationRunner -): Record { - const runners = [ - ...(registry[key] ?? []).filter( - (candidate) => candidate.runnerSessionId !== runner.runnerSessionId - ), - runner, - ].slice(-MAX_ACTIVE_RUNNERS_PER_ROOT); - const entries = Object.entries(registry).filter( - ([candidateKey]) => candidateKey !== key - ); - return Object.fromEntries([ - ...entries.slice(-(MAX_ACTIVE_CONVERSATION_ROOTS - 1)), - [key, runners], - ]); -} - -/** 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)); -} - -/** Current-turn native tail only; prior turns and the injected user row stay hidden. */ -export function selectConversationRunnerTail( - runner: ActiveConversationRunner, - events: readonly SessionEvent[] -): SessionEvent[] { - return events - .slice(Math.max(0, runner.eventStartIndex)) - .filter((event) => event.source !== "user"); -} - -/** Namespace the exact current-turn tail for the canonical live overlay. */ -export function buildConversationRunnerOverlay( - runner: ActiveConversationRunner, - events: readonly SessionEvent[], - canonicalSessionId: string -): SessionEvent[] { - return selectConversationRunnerTail(runner, events).map((event) => ({ - ...event, - id: `runlive-${event.id}`, - chunk_id: `runlive-${event.id}`, - sessionId: canonicalSessionId, - })); -} - -/** Remove one terminal runner when no plane tail can perform normal cleanup. */ -export function removeConversationRunnerByTurn( - registry: Readonly>, - key: string, - turnId: string -): Record { - const current = registry[key] ?? []; - const kept = current.filter((runner) => runner.turnId !== turnId); - if (kept.length === current.length) return registry; - const next = { ...registry }; - if (kept.length === 0) delete next[key]; - else next[key] = kept; - return next; -} diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts index bf57979dcb..7df27bcf0c 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts @@ -15,7 +15,6 @@ import { useStore, } from "jotai"; import { useEffect, useMemo, useRef } from "react"; -import { useCallback } from "react"; import { createLogger } from "@src/hooks/logger"; import { BoundedMap } from "@src/util/collections/BoundedMap"; @@ -35,10 +34,10 @@ import { } from "../org2CloudConversationEventsClient"; import { REALTIME_SIGNAL_COALESCE_MS } from "../org2CloudRealtimeSignalCoalescer"; import type { SessionCommentTarget } from "../sessionCommentTarget"; -import { drainConversationTailOutbox } from "./conversationTailOutbox"; const log = createLogger("ConversationPlane"); const MAX_CONVERSATION_PLANE_ENTRIES = 64; +const MAX_CONVERSATION_PLANE_BYTES = 128 * 1024 * 1024; const MAX_CONVERSATION_PLANE_SIGNALS = 64; export type ConversationPlaneState = @@ -57,6 +56,8 @@ export interface ConversationPlaneEntry { /** Ordered by seq asc; deduped by wire id. */ events: CloudConversationEvent[]; lastSeq: number; + /** Cached payload estimate; avoids serializing every retained transcript. */ + approximateBytes?: number; } export interface ConversationPlaneLocator { @@ -73,6 +74,7 @@ function emptyEntry(locator: ConversationPlaneLocator): ConversationPlaneEntry { state: "idle", events: [], lastSeq: 0, + approximateBytes: 0, }; } @@ -114,6 +116,7 @@ interface ConversationPlaneRequestState { activeIdentityKey: string | null; epoch: number; inFlightByKey: Map>; + trailingRefreshKeys: Set; } const requestStateByStore = new WeakMap< @@ -128,6 +131,7 @@ function requestStateFor(store: JotaiStore): ConversationPlaneRequestState { activeIdentityKey: null, epoch: 0, inFlightByKey: new Map(), + trailingRefreshKeys: new Set(), }; requestStateByStore.set(store, state); } @@ -153,12 +157,58 @@ function writeConversationPlaneEntry( key: string, entry: ConversationPlaneEntry ): ConversationPlaneEntries { - return boundedRecordWrite( + 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); +} + +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 appendConversationEvents( + base: readonly CloudConversationEvent[], + incoming: readonly CloudConversationEvent[] +): 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); } function activateConversationPlaneIdentity( @@ -171,6 +221,7 @@ function activateConversationPlaneIdentity( state.activeIdentityKey = authIdentityKey; state.epoch += 1; state.inFlightByKey.clear(); + state.trailingRefreshKeys.clear(); setEntries((current) => { const retained = Object.fromEntries( Object.entries(current).filter( @@ -218,26 +269,6 @@ function entryMatchesLocator( ); } -function mergePlaneEvents( - previous: ConversationPlaneEntry, - incoming: readonly CloudConversationEvent[] -): ConversationPlaneEntry { - if (incoming.length === 0) { - return { ...previous, state: "ready" }; - } - 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 { - ...previous, - state: "ready", - events, - lastSeq: events.length > 0 ? events[events.length - 1].seq : 0, - }; -} - /** * One authoritative loader shared by the mounted transcript and the submit * boundary. A capable backend must never race through the legacy visible-fork @@ -265,7 +296,13 @@ export function refreshConversationPlaneEntry( requestState.activeIdentityKey === locator.authIdentityKey && requestState.epoch === requestEpoch; const existing = requestState.inFlightByKey.get(key); - if (existing) return existing; + if (existing) { + requestState.trailingRefreshKeys.add(key); + return existing.then((entry) => { + if (!requestState.trailingRefreshKeys.delete(key)) return entry; + return refreshConversationPlaneEntry(params); + }); + } const load = (async (): Promise => { const storedBefore = params.getEntry(); @@ -292,7 +329,14 @@ export function refreshConversationPlaneEntry( throw new Error("cloud auth identity changed during plane refresh"); } commitRefreshedAuth(params.setAuth, params.auth, fresh); - const probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); + 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( @@ -312,40 +356,45 @@ export function refreshConversationPlaneEntry( } const stored = params.getEntry(); - let resolved = entryMatchesLocator(stored, locator) ? stored : before; - let afterSeq = resolved.lastSeq; + const base = entryMatchesLocator(stored, locator) ? stored : before; + let afterSeq = base.lastSeq; + const incomingWireEvents: CloudConversationEvent[] = []; for (;;) { - const page = await listConversationEvents(fresh.accessToken, { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - afterSeq, - }); + 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"); } - params.setEntries((current) => { - if (!isCurrentRequest()) return current; - const storedCurrent = current[key]; - const previous = entryMatchesLocator(storedCurrent, locator) - ? storedCurrent - : emptyEntry(locator); - resolved = mergePlaneEvents(previous, page.events); - return writeConversationPlaneEntry(current, key, resolved); - }); + incomingWireEvents.push(...page.events); + if (page.events.length > 0) { + afterSeq = page.events[page.events.length - 1].seq; + } if (!page.hasMore || page.events.length === 0) break; - afterSeq = page.events[page.events.length - 1].seq; } - const wireLastSeq = resolved.lastSeq; - const decodedEvents = await decodeConversationEventChunks( - resolved.events + const decodedIncoming = + await decodeConversationEventChunks(incomingWireEvents); + const known = new Set(base.events.map((event) => event.id)); + const novelIncoming = decodedIncoming.filter( + (event) => !known.has(event.id) ); - resolved = { - ...resolved, - events: decodedEvents, - // Chunk envelopes collapse to one logical event whose row carries the - // last chunk seq. Preserve the raw cursor even when no logical event - // was added by this refresh. - lastSeq: wireLastSeq, + const resolved: ConversationPlaneEntry = { + ...base, + state: "ready", + events: appendConversationEvents(base.events, novelIncoming), + // 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: + conversationPlaneEntryBytes(base) + + approximateEventBytes(novelIncoming), }; params.setEntries((current) => isCurrentRequest() @@ -418,20 +467,6 @@ export function useConversationPlaneEvents( : emptyEntry(locator) : emptyEntry({ authIdentityKey: "", orgId: "", rootSessionId: "" }); - const drainTailOutbox = useCallback(async () => { - if (!auth || !authIdentityKey) return; - await drainConversationTailOutbox({ - authIdentityKey, - getAccessToken: async () => { - const fresh = await ensureFreshSession(auth); - if (!fresh) throw new Error("cloud auth refresh failed"); - commitRefreshedAuth(setAuth, auth, fresh); - return fresh.accessToken; - }, - onPushed: (orgId) => bumpConversationPlaneSignal(setSignals, orgId), - }); - }, [auth, authIdentityKey, setAuth, setSignals]); - useEffect(() => { const previousIdentity = requestStateFor(store).activeIdentityKey; activateConversationPlaneIdentity(store, authIdentityKey, setEntries); @@ -446,9 +481,6 @@ export function useConversationPlaneEvents( : emptyEntry(locator); if (currentEntry.state === "unsupported") return; void (async () => { - await drainTailOutbox().catch((error: unknown) => { - log.warn("conversation tail outbox recovery failed", error); - }); await refreshConversationPlaneEntry({ store, auth, @@ -471,7 +503,6 @@ export function useConversationPlaneEvents( setEntries, signal, store, - drainTailOutbox, ]); // A short foreground switch does not release the shared Realtime socket @@ -508,7 +539,6 @@ export function useConversationPlaneEvents( // distinct app switch must advance this cheap `after_seq` cursor. lastForegroundRecoverAtRef.current = Date.now(); void (async () => { - await drainTailOutbox(); await refreshConversationPlaneEntry({ store, auth, @@ -542,7 +572,6 @@ export function useConversationPlaneEvents( setAuth, setEntries, store, - drainTailOutbox, ]); return entry; diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts new file mode 100644 index 0000000000..d81ef5cc37 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay.ts @@ -0,0 +1,39 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +export interface ConversationRunnerOverlay { + runnerSessionId: string; + turnId: string; + eventStartIndex: number; +} + +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[] { + return events + .slice(Math.max(0, runner.eventStartIndex)) + .filter((event) => event.source !== "user"); +} + +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, + })); +} diff --git a/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts b/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts deleted file mode 100644 index 809530c6a1..0000000000 --- a/src/features/Org2Cloud/SessionConversation/conversationTailOutbox.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { type Store, load } from "@tauri-apps/plugin-store"; - -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { createLogger } from "@src/hooks/logger"; - -import { - CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH, - Org2CloudConversationError, - pushConversationEvents, -} from "../org2CloudConversationEventsClient"; - -const log = createLogger("ConversationTailOutbox"); -const STORE_PATH = "cloud-conversation-tail-outbox.json"; -const STORE_KEY = "pendingChunks"; -const OUTBOX_LOCK_NAME = "orgii:cloud-conversation-tail-outbox"; -const MAX_PENDING_CONVERSATION_TAIL_CHUNKS = 512; -const MAX_DRAIN_CHUNKS_PER_PASS = 64; - -interface PendingConversationTailChunk { - id: string; - authIdentityKey: string; - orgId: string; - rootSessionId: string; - turnId: string; - chunkIndex: number; - events: SessionEvent[]; - createdAt: string; - failedError?: string; -} - -export interface ConversationTailDrainResult { - pushedChunks: Array<{ id: string; eventCount: number }>; - failedChunkIds: string[]; - pendingChunkIds: string[]; -} - -let storePromise: Promise | null = null; -let fallbackChain: Promise = Promise.resolve(); - -function durableStore(): Promise { - storePromise ??= load(STORE_PATH, { defaults: {}, autoSave: false }); - return storePromise; -} - -function validRow(value: unknown): value is PendingConversationTailChunk { - if (!value || typeof value !== "object") return false; - const row = value as Partial; - return ( - typeof row.id === "string" && - typeof row.authIdentityKey === "string" && - typeof row.orgId === "string" && - typeof row.rootSessionId === "string" && - typeof row.turnId === "string" && - Number.isSafeInteger(row.chunkIndex) && - Array.isArray(row.events) && - row.events.length > 0 && - row.events.length <= CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH && - typeof row.createdAt === "string" - ); -} - -function isSameStagedRevision( - current: PendingConversationTailChunk, - snapshot: PendingConversationTailChunk -): boolean { - return current.id === snapshot.id && current.createdAt === snapshot.createdAt; -} - -async function loadRows(store: Store): Promise { - // Each webview owns a plugin-store handle. The Web Lock serializes writers, - // but it does not refresh another webview's cached document; always reload - // inside the lock before read-modify-write or one window can erase another - // window's newly staged tail. - await store.reload(); - const stored = await store.get(STORE_KEY); - return Array.isArray(stored) ? stored.filter(validRow) : []; -} - -async function saveRows( - store: Store, - rows: readonly PendingConversationTailChunk[] -): Promise { - await store.set(STORE_KEY, rows); - await store.save(); -} - -async function withOutboxLock(operation: () => Promise): Promise { - const locks = typeof navigator !== "undefined" ? navigator.locks : undefined; - if (locks?.request) { - return await locks.request( - OUTBOX_LOCK_NAME, - { mode: "exclusive" }, - operation - ); - } - const next = fallbackChain.catch(() => undefined).then(operation); - fallbackChain = next; - return await next; -} - -/** - * Persist the normalized tail before its first network attempt. The Cloud RPC - * is idempotent by event id, so a crash after the RPC but before the local - * delete safely replays the same chunk after restart. - */ -export async function stageConversationTail(params: { - authIdentityKey: string; - orgId: string; - rootSessionId: string; - turnId: string; - batchId: string; - events: readonly SessionEvent[]; -}): Promise { - if (params.events.length === 0) return []; - const chunkCount = Math.ceil( - params.events.length / CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH - ); - if (chunkCount > MAX_PENDING_CONVERSATION_TAIL_CHUNKS) { - throw new Error( - `Cloud conversation tail is too large (${chunkCount}/${MAX_PENDING_CONVERSATION_TAIL_CHUNKS} chunks)` - ); - } - const chunks: PendingConversationTailChunk[] = []; - for ( - let offset = 0, chunkIndex = 0; - offset < params.events.length; - offset += CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH, chunkIndex += 1 - ) { - chunks.push({ - id: [ - params.authIdentityKey, - params.orgId, - params.rootSessionId, - params.turnId, - params.batchId, - chunkIndex, - ].join("\u001f"), - authIdentityKey: params.authIdentityKey, - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId: params.turnId, - chunkIndex, - events: params.events.slice( - offset, - offset + CLOUD_CONVERSATION_MAX_EVENTS_PER_PUSH - ), - createdAt: new Date().toISOString(), - }); - } - await withOutboxLock(async () => { - const store = await durableStore(); - const rows = await loadRows(store); - const byId = new Map(rows.map((row) => [row.id, row] as const)); - for (const chunk of chunks) byId.set(chunk.id, chunk); - const merged = [...byId.values()]; - if (merged.length > MAX_PENDING_CONVERSATION_TAIL_CHUNKS) { - throw new Error( - `Cloud conversation tail outbox is full (${merged.length}/${MAX_PENDING_CONVERSATION_TAIL_CHUNKS} chunks)` - ); - } - await saveRows(store, merged); - }); - return chunks.map((chunk) => chunk.id); -} - -/** - * Drain only the signed-in account's rows. Network I/O happens outside the - * store lock so an offline request cannot block a new provider turn from - * durably staging its tail. Duplicate concurrent pushes are harmless because - * Cloud event ids are idempotent. - */ -export async function drainConversationTailOutbox(params: { - authIdentityKey: string; - getAccessToken: () => Promise; - onPushed?: (orgId: string) => void; -}): Promise { - const store = await durableStore(); - const pushedChunks: ConversationTailDrainResult["pushedChunks"] = []; - const attempted = new Set(); - for (;;) { - const snapshot = await withOutboxLock(async () => - (await loadRows(store)) - .filter( - (candidate) => - candidate.authIdentityKey === params.authIdentityKey && - !candidate.failedError && - !attempted.has(candidate.id) - ) - .slice(0, MAX_DRAIN_CHUNKS_PER_PASS) - ); - if (snapshot.length === 0) break; - - const successful: PendingConversationTailChunk[] = []; - const terminalFailures = new Map< - string, - { row: PendingConversationTailChunk; failedError: string } - >(); - let transportError: unknown = null; - const accessToken = await params.getAccessToken(); - for (const row of snapshot) { - attempted.add(row.id); - try { - await pushConversationEvents(accessToken, { - orgId: row.orgId, - rootSessionId: row.rootSessionId, - turnId: row.turnId, - events: row.events, - }); - successful.push(row); - } catch (error) { - const rowTerminal = - error instanceof Org2CloudConversationError && - (error.code === "ORG2_VALIDATION" || - error.code === "ORG2_ORG_NOT_FOUND" || - error.code === "ORG2_FORBIDDEN" || - error.code === "ORG2_MEMBER_REQUIRED" || - error.code === "ORG2_CONVERSATION_BATCH_TOO_LARGE" || - error.code === "ORG2_CONVERSATION_EVENT_TOO_LARGE"); - if (!rowTerminal) { - transportError = error; - break; - } - const failedError = - error instanceof Error ? error.message : "Cloud publication failed"; - terminalFailures.set(row.id, { row, failedError }); - log.error( - `conversation tail ${row.id} requires manual recovery`, - error - ); - } - } - - // One CAS-style commit per bounded network pass. A concurrent restage of - // the same id is a new revision and must never be removed or marked failed - // by this older attempt. - await withOutboxLock(async () => { - const current = await loadRows(store); - const successfulById = new Map( - successful.map((row) => [row.id, row] as const) - ); - const next = current.flatMap((candidate) => { - const pushed = successfulById.get(candidate.id); - if (pushed && isSameStagedRevision(candidate, pushed)) return []; - const failed = terminalFailures.get(candidate.id); - if (failed && isSameStagedRevision(candidate, failed.row)) { - return [{ ...candidate, failedError: failed.failedError }]; - } - return [candidate]; - }); - await saveRows(store, next); - }); - for (const row of successful) { - pushedChunks.push({ id: row.id, eventCount: row.events.length }); - params.onPushed?.(row.orgId); - } - if (transportError) throw transportError; - } - const remaining = await withOutboxLock(async () => - (await loadRows(store)).filter( - (row) => row.authIdentityKey === params.authIdentityKey - ) - ); - const pushed = pushedChunks.reduce( - (total, chunk) => total + chunk.eventCount, - 0 - ); - if (pushed > 0) { - log.info(`published ${pushed} durable conversation tail event(s)`); - } - return { - pushedChunks, - failedChunkIds: remaining - .filter((row) => Boolean(row.failedError)) - .map((row) => row.id), - pendingChunkIds: remaining - .filter((row) => !row.failedError) - .map((row) => row.id), - }; -} diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts index a70a7af667..f265c9a9c6 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import { + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { buildPushedUserEvent, @@ -9,10 +13,6 @@ import { const mocks = vi.hoisted(() => ({ continueLocalConversation: vi.fn(), - pushConversationEvents: vi.fn(), - pushConversationEventsChunked: vi.fn(), - stageConversationTail: vi.fn(), - drainConversationTailOutbox: vi.fn(), })); vi.mock( @@ -23,37 +23,12 @@ vi.mock( }) ); -vi.mock("../org2CloudConversationEventsClient", async (importOriginal) => ({ - ...(await importOriginal()), - boundConversationEventForPush: (event: unknown) => event, - pushConversationEvents: mocks.pushConversationEvents, - pushConversationEventsChunked: mocks.pushConversationEventsChunked, -})); - -vi.mock("./conversationTailOutbox", () => ({ - stageConversationTail: mocks.stageConversationTail, - drainConversationTailOutbox: mocks.drainConversationTailOutbox, -})); - beforeEach(() => { vi.clearAllMocks(); - mocks.pushConversationEvents.mockResolvedValue({ firstSeq: 1, lastSeq: 1 }); - mocks.pushConversationEventsChunked.mockResolvedValue({ - firstSeq: 2, - lastSeq: 2, - }); - mocks.stageConversationTail.mockResolvedValue(["staged-tail"]); - mocks.drainConversationTailOutbox.mockResolvedValue({ - pushedChunks: [{ id: "staged-tail", eventCount: 1 }], - failedChunkIds: [], - pendingChunkIds: [], - }); mocks.continueLocalConversation.mockImplementation(async (params) => { - await params.beforeDispatch?.(); params.onSessionReady?.("cliagent-owner", 3); return { sessionId: "cliagent-owner", - created: false, terminalStatus: "completed", agentTail: [], }; @@ -85,23 +60,23 @@ describe("buildPushedUserEvent", () => { 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.beforeDispatch?.(); await params.onSessionPreparing?.("cliagent-fresh"); await params.onSessionReady?.("cliagent-fresh", 7); return { sessionId: "cliagent-fresh", - created: true, terminalStatus: "completed", agentTail: [], }; }); const result = await runConversationTurn({ - getAccessToken: async () => "token", - authIdentityKey: "user-1", - orgId: "org-1", - rootSessionId: "shared-root", + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, conversationTitle: "Shared conversation", displayText: "continue", timeline: [], @@ -112,6 +87,7 @@ describe("runConversationTurn", () => { }, turnIntentId: "turn-fresh", onRunnerReady, + publishTail, }); expect(onRunnerReady.mock.calls).toEqual([ @@ -121,7 +97,6 @@ describe("runConversationTurn", () => { expect(result).toEqual( expect.objectContaining({ terminalStatus: "completed", - pushedAgentEventCount: 0, }) ); }); @@ -134,10 +109,7 @@ describe("runConversationTurn", () => { } as const; await runConversationTurn({ - getAccessToken: async () => "token", - authIdentityKey: "user-1", - orgId: "org-1", - rootSessionId: "shared-root", + root: executionRoot, conversationTitle: "Shared conversation", displayText: "continue", timeline: [], @@ -146,29 +118,29 @@ describe("runConversationTurn", () => { accountId: "acct-codex", model: "gpt-5.6-sol", }, - executionRoot, turnIntentId: "turn-owner", + publishTail: vi.fn(), }); expect(mocks.continueLocalConversation).toHaveBeenCalledWith( expect.objectContaining({ root: executionRoot }) ); - expect(mocks.stageConversationTail).not.toHaveBeenCalled(); }); it("publishes a non-portable transcript error when execution fails after the user row", async () => { const failure = new Error("native materialization failed"); - mocks.continueLocalConversation.mockImplementationOnce(async (params) => { - await params.beforeDispatch?.(); + const publishTail = vi.fn().mockResolvedValue(undefined); + mocks.continueLocalConversation.mockImplementationOnce(async () => { throw failure; }); await expect( runConversationTurn({ - getAccessToken: async () => "token", - authIdentityKey: "user-1", - orgId: "org-1", - rootSessionId: "shared-root", + root: { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId: "shared-root", + }, conversationTitle: "Shared conversation", displayText: "continue", timeline: [], @@ -178,26 +150,49 @@ describe("runConversationTurn", () => { model: "gpt-5.6-sol", }, turnIntentId: "turn-failed", + publishTail, }) - ).rejects.toBe(failure); + ).rejects.toBeInstanceOf(QueuedConversationTurnClosedError); - expect(mocks.stageConversationTail).toHaveBeenCalledOnce(); - const failurePush = mocks.stageConversationTail.mock.calls[0]?.[0]; - expect(failurePush).toEqual( + expect(publishTail).toHaveBeenCalledOnce(); + const [failureTurnId, failureEvents] = publishTail.mock.calls[0] ?? []; + expect(failureTurnId).toBe("turn-failed"); + expect(failureEvents).toEqual([ expect.objectContaining({ - turnId: "turn-failed", - events: [ - expect.objectContaining({ - source: "system", - displayVariant: "error", - displayStatus: "failed", - result: expect.objectContaining({ - error: "native materialization failed", - }), - }), - ], - }) + source: "system", + displayVariant: "error", + displayStatus: "failed", + result: expect.objectContaining({ + error: "native materialization 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") ); - expect(projectNativeConversationItems(failurePush.events)).toEqual([]); + + 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 Error("cloud offline")), + }) + ).rejects.toBeInstanceOf(QueuedConversationRecoveryPendingError); }); }); diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index 66ba67347a..19c5f51dd6 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -14,15 +14,15 @@ import { continueLocalConversation, recoverLocalConversationTurn, } from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { + QueuedConversationRecoveryBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { createLogger } from "@src/hooks/logger"; import { conversationEventsForPush } from "../org2CloudConversationEventsClient"; -import { - drainConversationTailOutbox, - stageConversationTail, -} from "./conversationTailOutbox"; const log = createLogger("ConversationTurnRunner"); @@ -92,11 +92,7 @@ function buildPushedDispatchFailureEvent( } interface RunConversationTurnParams { - /** Resolved separately for every push; long turns may outlive a JWT. */ - getAccessToken: () => Promise; - authIdentityKey: string; - orgId: string; - rootSessionId: string; + root: ConversationRootLocator; conversationTitle: string; displayText: string; agentContent?: string; @@ -105,13 +101,12 @@ interface RunConversationTurnParams { timeline: readonly SessionEvent[]; /** Composer-selected local runtime/account/model. Never resolved by a modal. */ target: LocalConversationTarget; - /** - * A compatible local native root can be reused directly. Otherwise this - * device keeps its own durable execution episode for the Cloud root. - */ - executionRoot?: ConversationRootLocator; - turnIntentId?: string; - recovery?: { runnerSessionId: string; eventStartIndex?: number }; + turnIntentId: string; + recovery?: { + runnerSessionId: string; + eventStartIndex?: number; + providerAccepted: boolean; + }; onRunnerReady?: ( sessionId: string, turnId: string, @@ -119,38 +114,28 @@ interface RunConversationTurnParams { ) => void | Promise; /** Local provider accepted the turn; distinct from Cloud user publication. */ onTurnAccepted?: (sessionId: string) => void | Promise; - onPushed?: () => void; + /** Idempotently publish the normalized provider tail to the Cloud plane. */ + publishTail: (turnId: string, events: SessionEvent[]) => Promise; } interface RunConversationTurnResult { runnerSessionId: string; - pushedEventCount: number; - pushedAgentEventCount: number; - tailPublicationPending: boolean; terminalStatus: TurnTerminalStatus; - turnIntentId: string; } export async function runConversationTurn( params: RunConversationTurnParams ): Promise { - const turnIntentId = params.turnIntentId ?? mintTurnIntentId(); - const root = - params.executionRoot ?? - ({ - authority: "org2-cloud", - authorityScope: [params.orgId], - conversationId: params.rootSessionId, - } as const); + const turnIntentId = params.turnIntentId; + const root = params.root; + const rootLabel = `${root.authority}:${root.conversationId}`; log.info( - `resolved execution for ${params.orgId}:${params.rootSessionId}; ` + + `resolved execution for ${rootLabel}; ` + `selected=${params.target.cliAgentType ?? "native"}` ); - // The idempotent conversation-plane push already published the user event. - // Native materialization may proceed without a second wire path. - const beforeDispatch = async () => undefined; let result: Awaited>; + let providerAccepted = params.recovery?.providerAccepted === true; try { const continuationParams = { root, @@ -161,7 +146,6 @@ export async function runConversationTurn( imageDataUrls: params.imageDataUrls, target: params.target, turnIntentId, - beforeDispatch, // 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. @@ -173,7 +157,10 @@ export async function runConversationTurn( ), onSessionReady: (sessionId: string, eventStartIndex: number) => params.onRunnerReady?.(sessionId, turnIntentId, eventStartIndex), - onTurnAccepted: params.onTurnAccepted, + onTurnAccepted: async (sessionId: string) => { + providerAccepted = true; + await params.onTurnAccepted?.(sessionId); + }, }; const recovered = params.recovery ? await recoverLocalConversationTurn({ @@ -182,12 +169,21 @@ export async function runConversationTurn( eventStartIndex: params.recovery.eventStartIndex, }) : null; + if (!recovered && params.recovery?.providerAccepted) { + throw new QueuedConversationRecoveryPendingError(); + } result = recovered ?? (await continueLocalConversation(continuationParams)); } catch (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; + } try { const failureEvents = await conversationEventsForPush( buildPushedDispatchFailureEvent( @@ -196,34 +192,23 @@ export async function runConversationTurn( turnIntentId ) ); - const stagedIds = await stageConversationTail({ - authIdentityKey: params.authIdentityKey, - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId: turnIntentId, - batchId: "failure", - events: failureEvents, - }); - const drained = await drainConversationTailOutbox({ - authIdentityKey: params.authIdentityKey, - getAccessToken: params.getAccessToken, - onPushed: () => params.onPushed?.(), - }); - const unresolved = new Set([ - ...drained.failedChunkIds, - ...drained.pendingChunkIds, - ]); - if (stagedIds.some((id) => unresolved.has(id))) { - throw new Error("Cloud did not durably publish the turn failure"); - } + await params.publishTail(turnIntentId, failureEvents); } catch (publishError) { log.warn( - `failed to publish execution error for ${params.orgId}:${params.rootSessionId}`, + `failed to publish execution error for ${rootLabel}`, publishError ); - throw publishError; + // The canonical user event already exists, so removing this execution + // owner would strand a permanently pending transcript row (or allow a + // later retry to run the provider without closing the failed attempt). + // Retain the same owner until the idempotent failure tail can publish. + throw new QueuedConversationRecoveryPendingError( + "conversation failure result could not be published yet" + ); } - throw error; + throw new QueuedConversationTurnClosedError( + error instanceof Error ? error.message : String(error) + ); } const terminalTail = @@ -239,68 +224,18 @@ export async function runConversationTurn( const agentTail = ( await Promise.all(terminalTail.map(conversationEventsForPush)) ).flat(); - let pushedAgentEventCount = 0; - let tailPublicationPending = false; if (agentTail.length > 0) { - // Staging is the crash-consistency boundary. If local durable storage - // fails, propagate the error so the accepted canonical queue row - // remains and restart recovery can re-read this exact native tail. - const stagedIds = await stageConversationTail({ - authIdentityKey: params.authIdentityKey, - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId: turnIntentId, - batchId: "agent", - events: agentTail, - }); - try { - const drained = await drainConversationTailOutbox({ - authIdentityKey: params.authIdentityKey, - getAccessToken: params.getAccessToken, - onPushed: () => params.onPushed?.(), - }); - const staged = new Set(stagedIds); - pushedAgentEventCount = drained.pushedChunks - .filter((chunk) => staged.has(chunk.id)) - .reduce((total, chunk) => total + chunk.eventCount, 0); - const unresolved = new Set([ - ...drained.failedChunkIds, - ...drained.pendingChunkIds, - ]); - tailPublicationPending = stagedIds.some((id) => unresolved.has(id)); - if (stagedIds.some((id) => drained.failedChunkIds.includes(id))) { - throw new Error( - "Cloud permanently rejected a staged provider tail; keeping its accepted queue row for visible recovery" - ); - } - } catch (error) { - if ( - error instanceof Error && - error.message.startsWith("Cloud permanently rejected") - ) { - throw error; - } - // The provider turn and outbox row are both durable. Keep the episode - // overlaid and let ordinary outbox drain retry after connectivity or - // auth recovers; no provider replay is needed. - tailPublicationPending = true; - log.warn( - `network drain deferred for ${agentTail.length} durably staged tail event(s) for ${params.orgId}:${params.rootSessionId}`, - error - ); - } + // 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( - `continued ${params.orgId}:${params.rootSessionId} in ${result.sessionId}; ` + - `pushed 1 + ${pushedAgentEventCount} event(s)` + - (tailPublicationPending ? "; tail pending durable retry" : "") + `continued ${rootLabel} in ${result.sessionId}; ` + + `staged ${agentTail.length} agent event(s)` ); return { runnerSessionId: result.sessionId, - pushedEventCount: 1 + pushedAgentEventCount, - pushedAgentEventCount, - tailPublicationPending, terminalStatus: result.terminalStatus, - turnIntentId, }; } diff --git a/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts b/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts index 6545503728..10c336550b 100644 --- a/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts +++ b/src/features/Org2Cloud/SessionConversation/queuedConversationExecutor.ts @@ -2,16 +2,18 @@ import type { Store } from "jotai/vanilla/store"; import { loadCanonicalConversationEvents } from "@src/engines/SessionCore/conversations/canonicalConversationEvents"; import type { ConversationRootLocator } from "@src/engines/SessionCore/conversations/conversationTypes"; -import { - CONVERSATION_TURN_ID_ARG, - localConversationRootForSession, -} from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import { localConversationRootForSession } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import type { QueuedConversationDispatchCallbacks, - QueuedConversationExecutionResult, - QueuedConversationMessage, + QueuedConversationExecutionMessage, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; +import { + QueuedConversationBlockedError, + QueuedConversationRecoveryPendingError, + QueuedConversationTurnClosedError, } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { + type Org2CloudAuthState, commitRefreshedAuth, org2CloudAuthAtom, org2CloudAuthIdentityKey, @@ -24,15 +26,10 @@ import { pushConversationEventsChunked, } from "@src/features/Org2Cloud/org2CloudConversationEventsClient"; import { groupCommentThreads } from "@src/features/Org2Cloud/org2CloudSessionCommentsAtom"; +import { normalizeSourceEndpointUrl } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; import type { Session } from "@src/store/session"; import { sessionsAtom } from "@src/store/session"; -import { - activeConversationRunnerKey, - activeConversationRunnersAtom, - removeConversationRunnerByTurn, - upsertConversationRunner, -} from "./activeConversationRunnersAtom"; import { bumpConversationPlaneSignal, conversationPlaneAtom, @@ -59,50 +56,86 @@ function sessionById(store: Store, sessionId: string): Session | undefined { function cloudLocator(root: ConversationRootLocator): { orgId: string; rootSessionId: string; + sourceEndpointUrl?: string; } { - const [orgId, ...extraScope] = root.authorityScope; - if (root.authority !== "org2-cloud" || !orgId || extraScope.length > 0) { + if ( + root.authority !== "org2-cloud" || + (root.authorityScope.length !== 1 && root.authorityScope.length !== 2) + ) { throw new Error("invalid Cloud conversation identity"); } - return { orgId, rootSessionId: root.conversationId }; + 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: QueuedConversationMessage, + message: QueuedConversationExecutionMessage, root: ConversationRootLocator, callbacks: QueuedConversationDispatchCallbacks -): Promise { +): Promise { const descriptor = message.conversationDispatch; if (!descriptor) throw new Error("canonical conversation target is missing"); - const { orgId, rootSessionId } = cloudLocator(root); + const { orgId, rootSessionId, sourceEndpointUrl } = cloudLocator(root); - const getAccessToken = async (): Promise => { + 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 Error("cloud sign-in required"); + 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 fresh = await ensureFreshSession(current); if (!fresh) throw new Error("cloud auth refresh failed"); + if (org2CloudAuthIdentityKey(fresh) !== expectedIdentityKey) { + throw new Error("cloud auth identity changed during refresh"); + } commitRefreshedAuth( (update) => store.set(org2CloudAuthAtom, update), current, fresh ); - return fresh.accessToken; + requireBoundAuth(); + return fresh; }; - const auth = store.get(org2CloudAuthAtom); - if (!auth) throw new Error("cloud sign-in required"); - const authIdentityKey = org2CloudAuthIdentityKey(auth); - if (descriptor.dispatchIdentityKey !== authIdentityKey) { - throw new Error( - descriptor.dispatchIdentityKey - ? "This queued turn belongs to a different Cloud account; switch back to its author account to send it" - : "This restored Cloud turn predates sender binding; edit and send it again under the current account" - ); - } + const auth = await refreshBoundAuth(); + const authIdentityKey = expectedIdentityKey; + const endpoint = { + supabaseUrl: auth.supabaseUrl, + anonKey: auth.supabaseAnonKey, + }; const capabilityProbe = await getCloudCapabilitiesConfirmed( - await getAccessToken() + auth.accessToken, + endpoint ); if ( !capabilityProbe.confirmed || @@ -112,157 +145,174 @@ export async function dispatchQueuedCloudConversation( "Cloud conversation idempotency is unavailable; refusing an unsafe retry" ); } - const runnerRegistryKey = activeConversationRunnerKey(authIdentityKey, root); - 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), - }); - if (plane.state !== "ready") { - throw new Error("canonical conversation plane is unavailable"); - } - const sourceSession = sessionById(store, message.sessionId); - const rootLocal = sessionById(store, rootSessionId) ?? sourceSession ?? null; - const rootEvents = rootLocal - ? (await loadCanonicalConversationEvents(rootLocal.session_id)).events - : []; - const planeTimeline = mergePlaneIntoTranscript( - rootEvents, - plane.events, - message.sessionId, - { status: "known", userId: auth.userId } - ); - const listing = await listSessionComments( - await getAccessToken(), - orgId, - rootSessionId - ); - const sourceIds = new Set(planeTimeline.map((event) => event.id)); - const grouped = groupCommentThreads(listing.comments, sourceIds); - const bySourceId = new Map( - planeTimeline.map((event) => [event.id, event] as const) - ); - const timeline = mergeConversationEvents( - planeTimeline, - buildDiscussionEvents(grouped, message.sessionId, bySourceId) - ); - // A crash after the user-event push but before native-runner persistence leaves - // this same durable queue row retryable. Its user event is now in the plane, - // but it must not be materialized into the prefix AND sent again. Exclude the - // current turn from the canonical prefix on every attempt; the native send - // remains the one user-message append for this provider episode. - const executionTimeline = timeline.filter( - (event) => event.args?.[CONVERSATION_TURN_ID_ARG] !== message.turnIntentId - ); - const executionRoot = - sourceSession && - !sourceSession.importedFrom && - sourceSession.session_id === rootSessionId - ? (localConversationRootForSession( - sourceSession.session_id, - sourceSession.cliAgentType, - sourceSession.agentDefinitionId - ) ?? undefined) - : undefined; - - await pushConversationEventsChunked(await getAccessToken(), { - orgId, - rootSessionId, - turnId: message.turnIntentId, - events: await conversationEventsForPush( - buildPushedUserEvent( - message.displayContent, - message.content, - message.imageDataUrls, - new Date().toISOString(), - message.turnIntentId - ) - ), - }); - bumpConversationPlaneSignal( - (update) => store.set(conversationPlaneSignalAtom, update), - orgId + // Admission becomes visible on the canonical plane before any expensive + // local history scan, comment merge, candidate probe, or materialization. + // The push is idempotent by turn id, so a crash/retry preserves one pending + // human row while the durable execution owner continues from this point. + const userEvents = await conversationEventsForPush( + buildPushedUserEvent( + message.displayContent, + message.content, + message.imageDataUrls, + new Date().toISOString(), + message.turnIntentId + ) ); - - let accepted = false; - const accept = async (sessionId: string) => { - if (accepted) return; - accepted = true; - await callbacks.onAccepted(sessionId); - }; - let result: Awaited> | null = null; + requireBoundAuth(); try { - result = await runConversationTurn({ - getAccessToken, + // Crossing into this RPC is irreversible even if its response is lost: + // Cloud may already contain the idempotent human row. Every failure from + // this point stays owned by the canonical execution and retries with the + // same turn id; it must never be demoted to an editable queue row. + await pushConversationEventsChunked( + auth.accessToken, + { + orgId, + rootSessionId, + turnId: message.turnIntentId, + events: userEvents, + }, + endpoint + ); + requireBoundAuth(); + bumpConversationPlaneSignal( + (update) => store.set(conversationPlaneSignalAtom, update), + orgId + ); + + 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), + }); + if (plane.state !== "ready") { + throw new Error("canonical conversation plane is unavailable"); + } + requireBoundAuth(); + + const sourceSession = sessionById(store, message.sessionId); + const rootLocal = + sessionById(store, rootSessionId) ?? sourceSession ?? null; + const rootEvents = rootLocal + ? (await loadCanonicalConversationEvents(rootLocal.session_id)).events + : []; + const planeTimeline = mergePlaneIntoTranscript( + rootEvents, + plane.events, + message.sessionId, + { status: "known", userId: auth.userId } + ); + const listing = await listSessionComments( + auth.accessToken, + orgId, + rootSessionId, + { endpoint } + ); + requireBoundAuth(); + const sourceIds = new Set(planeTimeline.map((event) => event.id)); + const grouped = groupCommentThreads(listing.comments, sourceIds); + const bySourceId = new Map( + planeTimeline.map((event) => [event.id, event] as const) + ); + const timeline = mergeConversationEvents( + planeTimeline, + buildDiscussionEvents(grouped, message.sessionId, bySourceId) + ); + // A crash after the user-event push but before native-runner persistence leaves + // this same durable queue row retryable. Its user event is now in the plane, + // but it must not be materialized into the prefix AND sent again. Exclude the + // current turn from the canonical prefix on every attempt; the native send + // remains the one user-message append for this provider episode. + const executionRoot = + sourceSession && + !sourceSession.importedFrom && + sourceSession.session_id === rootSessionId + ? (localConversationRootForSession( + sourceSession.session_id, + sourceSession.cliAgentType, + sourceSession.agentDefinitionId + ) ?? undefined) + : undefined; + + 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 ?? rootLocal?.name ?? "Conversation", displayText: message.displayContent, agentContent: message.content, imageDataUrls: message.imageDataUrls, - timeline: executionTimeline, + timeline, target: descriptor.target, turnIntentId: message.turnIntentId, - ...(message.status !== "queued" && message.runnerSessionId + ...(message.runnerSessionId ? { recovery: { runnerSessionId: message.runnerSessionId, eventStartIndex: message.runnerEventStartIndex, + providerAccepted: message.status === "accepted", }, } : {}), - ...(executionRoot ? { executionRoot } : {}), - onRunnerReady: async (runnerSessionId, turnId, eventStartIndex) => { - store.set(activeConversationRunnersAtom, (current) => - upsertConversationRunner(current, runnerRegistryKey, { - runnerSessionId, + publishTail: async (turnId, events) => { + const fresh = await refreshBoundAuth(); + const freshEndpoint = { + supabaseUrl: fresh.supabaseUrl, + anonKey: fresh.supabaseAnonKey, + }; + await pushConversationEventsChunked( + fresh.accessToken, + { + orgId, + rootSessionId, turnId, - eventStartIndex, - }) + events, + }, + freshEndpoint ); - await callbacks.onRunnerReady?.(runnerSessionId, eventStartIndex); - }, - onTurnAccepted: accept, - onPushed: () => + requireBoundAuth(); bumpConversationPlaneSignal( (update) => store.set(conversationPlaneSignalAtom, update), orgId - ), + ); + }, + onRunnerReady: async (runnerSessionId, turnId, eventStartIndex) => { + void turnId; + await callbacks.onRunnerReady?.(runnerSessionId, eventStartIndex); + }, + onTurnAccepted: accept, }); - if (result.tailPublicationPending) { - throw new Error( - "Provider tail is durably queued for Cloud publication; keeping the accepted turn for recovery" - ); - } + // 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); - return { terminalStatus: result.terminalStatus }; - } finally { - // A successful non-empty tail remains overlaid until the refreshed plane - // contains it. Empty cancel/failure tails (and thrown publication errors) - // have no plane row that could ever trigger that normal cleanup. + } catch (error) { if ( - !result || - (result.pushedAgentEventCount === 0 && !result.tailPublicationPending) + error instanceof QueuedConversationRecoveryPendingError || + error instanceof QueuedConversationTurnClosedError ) { - store.set(activeConversationRunnersAtom, (current) => - removeConversationRunnerByTurn( - current, - runnerRegistryKey, - message.turnIntentId - ) - ); + throw error; } + // The human row is already durable on the Cloud plane. Never demote this + // execution back to the UI queue: retry the same idempotent turn owner + // until it either starts the provider or publishes a terminal failure. + throw new QueuedConversationRecoveryPendingError( + error instanceof Error ? error.message : String(error) + ); } } diff --git a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts index 7442ca5be9..c40ad24241 100644 --- a/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts +++ b/src/features/Org2Cloud/SessionConversation/useCloudConversationSource.ts @@ -2,12 +2,14 @@ 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 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, @@ -23,6 +25,7 @@ export function conversationSourceFromCloudReplay(params: { orgId?: string; remoteSession?: RemoteTeammateSessionMetadata; sessionName?: string; + sourceEndpointUrl?: string; workspaceRepoPath: string | null; }): ConversationSource | undefined { const orgId = params.importedFrom?.orgId ?? params.orgId; @@ -32,10 +35,14 @@ export function conversationSourceFromCloudReplay(params: { if (!orgId || !sourceSessionId) return undefined; const rootId = params.remoteSession?.forkedFrom?.rootSessionId ?? sourceSessionId; + const endpoint = + params.importedFrom?.sourceEndpointUrl ?? params.sourceEndpointUrl; return { root: { authority: "org2-cloud", - authorityScope: [orgId], + authorityScope: endpoint + ? [normalizeSourceEndpointUrl(endpoint), orgId] + : [orgId], conversationId: rootId, }, sourceTitle: @@ -76,6 +83,7 @@ export function useCloudConversationSource({ sessions, repos, }: CloudConversationSourceInput): CloudConversationSourceResolution { + const auth = useAtomValue(org2CloudAuthAtom); const loadingSource = useCloudSessionLoadingSource(sessionId); const importedFrom = session?.importedFrom; const remoteEntries = useAtomValue( @@ -154,6 +162,7 @@ export function useCloudConversationSource({ orgId: importedOrgId, remoteSession: importedRemoteRow, sessionName: session?.name, + 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: importedWorkspacePath, @@ -163,6 +172,7 @@ export function useCloudConversationSource({ importedOrgId, importedRemoteRow, importedWorkspacePath, + auth?.supabaseUrl, session?.name, ] ); diff --git a/src/features/Org2Cloud/org2CloudCommentsClient.ts b/src/features/Org2Cloud/org2CloudCommentsClient.ts index a0a6ea4516..d7b007be15 100644 --- a/src/features/Org2Cloud/org2CloudCommentsClient.ts +++ b/src/features/Org2Cloud/org2CloudCommentsClient.ts @@ -25,7 +25,11 @@ 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"); @@ -88,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}`, { @@ -522,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) @@ -539,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 { @@ -558,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.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts index d6c56ce5f2..4f02eca4fe 100644 --- a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts @@ -20,7 +20,11 @@ import { z } from "zod/v4"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; 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"; import { sha256Hex } from "./org2CloudOrgManagement"; @@ -64,9 +68,9 @@ 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}`, { @@ -145,7 +149,8 @@ export async function listConversationEvents( rootSessionId: string; afterSeq?: number; limit?: number; - } + }, + endpoint?: Pick ): Promise { const payload = await callConversationRpc( "cloud_list_conversation_events", @@ -155,7 +160,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) { @@ -182,7 +188,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"); @@ -198,7 +205,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) { @@ -221,7 +229,8 @@ export async function pushConversationEventsChunked( rootSessionId: string; turnId: string; events: readonly SessionEvent[]; - } + }, + endpoint?: Pick ): Promise { let result: PushConversationEventsResult | null = null; for ( @@ -229,13 +238,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"); diff --git a/src/features/Org2Cloud/sessionCommentTarget.ts b/src/features/Org2Cloud/sessionCommentTarget.ts index 99364c16f8..a2c7425f8b 100644 --- a/src/features/Org2Cloud/sessionCommentTarget.ts +++ b/src/features/Org2Cloud/sessionCommentTarget.ts @@ -44,13 +44,14 @@ export function sessionCommentTargetForConversationRoot( ): SessionCommentTarget | null { if ( root?.authority !== "org2-cloud" || - root.authorityScope.length !== 1 || - !root.authorityScope[0] + (root.authorityScope.length !== 1 && root.authorityScope.length !== 2) ) { return null; } + const orgId = root.authorityScope.at(-1); + if (!orgId) return null; return { - orgId: root.authorityScope[0], + orgId, sessionId: root.conversationId, }; } diff --git a/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx b/src/features/SessionCreator/variants/ChatPanel/SessionCreatorChatPanelView.tsx index d0e73c70bc..4ba6d90aa9 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; } @@ -634,34 +632,18 @@ const SessionCreatorChatPanelView: React.FC< /> )} - {categoryPickerProps.modelPickerStyle === "dropdown" ? ( - - ) : ( - - )} + {screenPickerProps && }
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/store/ui/__tests__/messageQueueAtom.test.ts b/src/store/ui/__tests__/messageQueueAtom.test.ts index d62fa9e6a0..923287dfa5 100644 --- a/src/store/ui/__tests__/messageQueueAtom.test.ts +++ b/src/store/ui/__tests__/messageQueueAtom.test.ts @@ -14,6 +14,7 @@ import { enqueueMessageAtom, forceSendMessageAtom, messageQueueAtom, + messageQueueHandoffIdsAtom, parkSessionQueuedMessagesAfterStopAtom, queueEditTargetAtom, queueEditingAtom, @@ -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" })); diff --git a/src/store/ui/conversationTargetAtom.ts b/src/store/ui/conversationTargetAtom.ts new file mode 100644 index 0000000000..f5f024a510 --- /dev/null +++ b/src/store/ui/conversationTargetAtom.ts @@ -0,0 +1,27 @@ +import { atom } from "jotai"; + +import type { LocalConversationTarget } 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 }) => { + 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); + } +); diff --git a/src/store/ui/messageQueueAtom.ts b/src/store/ui/messageQueueAtom.ts index 43d8bcd3e8..903080128c 100644 --- a/src/store/ui/messageQueueAtom.ts +++ b/src/store/ui/messageQueueAtom.ts @@ -4,6 +4,11 @@ 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/queuedConversationExecutor"; +import { + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS, + MAX_QUEUED_CONVERSATION_MESSAGE_CHARS_TOTAL, + queuedConversationMessageCharSize, +} from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; 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"; @@ -13,7 +18,7 @@ import { isCliSession } from "@src/util/session/sessionDispatch"; // ============================================ export type QueuedMessagePriority = "now" | "next"; -export type QueuedMessageDeliveryState = "queued" | "preparing" | "accepted"; +export type QueuedMessageDeliveryState = "queued"; export interface QueuedMessage { id: string; @@ -73,26 +78,18 @@ export interface QueuedMessage { * dispatch them. */ requiresExplicitDispatch?: boolean; - /** - * Durable delivery state for the same queue row. Canonical continuations - * keep the row through provider completion so a renderer restart can - * reconnect to the exact native turn instead of replaying it. - */ + /** UI queue rows are pending sends. Accepted canonical work moves to the + * app-global canonical execution store instead of turning this UI row into + * a second job/runner registry. */ status: QueuedMessageDeliveryState; - /** Concrete native Session selected before provider dispatch. */ - runnerSessionId?: string; - /** Verified native prefix used by the live overlay once materialized. */ - runnerEventStartIndex?: number; - /** Durable recovery backoff for an accepted canonical turn. */ - retryAt?: string; - retryAttempt?: number; createdAt: string; } 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" @@ -102,14 +99,7 @@ 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 { @@ -157,10 +147,29 @@ export function boundQueuedMessages( export const messageQueueAtom = atom([]); messageQueueAtom.debugLabel = "messageQueueAtom"; +/** + * Short-lived admission freeze while the queue owner is atomically handed to + * the canonical execution store. This is UI state only: the durable queue and + * execution rows remain the sole delivery owners. + */ +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"; +/** + * True only after queue + accepted-execution hydration have been reconciled + * and the canonical-wins result is durably saved. Dispatch fails closed until + * this flips; both phases share one transactional persistence document. + */ +export const messageDeliveryRecoveryReadyAtom = atom(false); +messageDeliveryRecoveryReadyAtom.debugLabel = + "messageDeliveryRecoveryReadyAtom"; + /** Tracks which queued message is currently being edited in the main input box. */ export interface QueueEditTarget { messageId: string; @@ -188,7 +197,9 @@ export const enqueueMessageAtom = atom( // hydrated durable rows. Text is not identity: the user may intentionally // send the same content more than once. const duplicate = current.some( - (existing) => existing.turnIntentId === message.turnIntentId + (existing) => + existing.id === message.id || + existing.turnIntentId === message.turnIntentId ); if (duplicate) return "duplicate"; const rejected = queueAdmissionResult(current, message); @@ -200,7 +211,8 @@ export const enqueueMessageAtom = atom( ); enqueueMessageAtom.debugLabel = "enqueueMessageAtom"; -export const dequeueMessageAtom = atom(null, (_get, set, messageId: string) => { +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") ); @@ -217,6 +229,7 @@ dequeueMessageAtom.debugLabel = "dequeueMessageAtom"; export const forceSendMessageAtom = atom( null, (get, set, messageId: string) => { + if (get(messageQueueHandoffIdsAtom).has(messageId)) return; if ( !get(messageQueueAtom).some( (msg) => msg.id === messageId && msg.status === "queued" @@ -236,8 +249,6 @@ export const forceSendMessageAtom = atom( turnIntentId: mintTurnIntentId(), priority: "now", requiresExplicitDispatch: false, - retryAt: undefined, - retryAttempt: undefined, } : msg ) @@ -254,6 +265,7 @@ forceSendMessageAtom.debugLabel = "forceSendMessageAtom"; export const parkSessionQueuedMessagesAfterStopAtom = atom( null, (get, set, sessionId: string) => { + const handoffIds = get(messageQueueHandoffIdsAtom); const current = get(messageQueueAtom); const conversationKeys = new Set( current.flatMap((message) => @@ -264,6 +276,7 @@ export const parkSessionQueuedMessagesAfterStopAtom = atom( ); set(messageQueueAtom, (prev) => prev.map((msg) => + !handoffIds.has(msg.id) && (msg.sessionId === sessionId || (msg.conversationDispatch !== undefined && conversationKeys.has( @@ -294,6 +307,7 @@ export const clearSessionQueueAtom = atom( set(messageQueueAtom, (prev) => prev.filter( (msg) => + get(messageQueueHandoffIdsAtom).has(msg.id) || msg.status !== "queued" || (msg.sessionId !== sessionId && (msg.conversationDispatch === undefined || @@ -309,12 +323,16 @@ 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) => message.status !== "queued" || !ids.has(message.id) + (message) => + handoffIds.has(message.id) || + message.status !== "queued" || + !ids.has(message.id) ) ); } @@ -324,7 +342,7 @@ clearQueuedMessagesAtom.debugLabel = "clearQueuedMessagesAtom"; export const editMessageAtom = atom( null, ( - _get, + get, set, update: { messageId: string; @@ -335,6 +353,7 @@ export const editMessageAtom = atom( agentExecMode?: AgentExecMode; } ) => { + if (get(messageQueueHandoffIdsAtom).has(update.messageId)) return false; let updated = false; set(messageQueueAtom, (prev) => prev.map((msg) => { @@ -380,8 +399,6 @@ export const editMessageAtom = atom( ...(update.agentExecMode !== undefined && { agentExecMode: update.agentExecMode, }), - retryAt: undefined, - retryAttempt: undefined, }; const siblings = prev.filter((item) => item.id !== msg.id); if (queueAdmissionResult(siblings, next)) return msg; @@ -397,10 +414,11 @@ editMessageAtom.debugLabel = "editMessageAtom"; export const reorderQueueAtom = atom( null, ( - _get, + get, set, { fromIndex, toIndex }: { fromIndex: number; toIndex: number } ) => { + const handoffIds = get(messageQueueHandoffIdsAtom); set(messageQueueAtom, (prev) => { if ( fromIndex === toIndex || @@ -409,7 +427,9 @@ export const reorderQueueAtom = atom( fromIndex >= prev.length || toIndex >= prev.length || prev[fromIndex]?.status !== "queued" || - prev[toIndex]?.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.test.ts b/src/store/ui/messageQueueRepository.test.ts new file mode 100644 index 0000000000..6fdb596918 --- /dev/null +++ b/src/store/ui/messageQueueRepository.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { QueuedMessage } from "./messageQueueAtom"; +import { + loadDurableMessageQueue, + persistDurableMessageQueue, + resetMessageQueueRepositoryForTests, +} from "./messageQueueRepository"; + +const mocks = vi.hoisted(() => ({ + values: new Map(), + save: vi.fn(), +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ label: "main" }), +})); + +vi.mock("@tauri-apps/plugin-store", () => ({ + load: async () => ({ + reload: async () => undefined, + get: async (key: string) => mocks.values.get(key), + set: async (key: string, value: unknown) => mocks.values.set(key, value), + 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", + }; +} + +describe("message queue repository", () => { + beforeEach(() => { + mocks.values.clear(); + mocks.save.mockReset(); + resetMessageQueueRepositoryForTests(); + }); + + it("does not let a stale queue snapshot resurrect an execution twin", async () => { + mocks.values.set("executions", [ + { id: "first", message: { turnIntentId: "turn-first" } }, + ]); + + await persistDurableMessageQueue([message("first"), message("second")]); + + expect(mocks.values.get("queue:main")).toEqual([message("second")]); + }); + + it("fails closed on an invalid durable row", async () => { + mocks.values.set("queue:main", [{ id: "truncated" }]); + + await expect(loadDurableMessageQueue()).rejects.toThrow("invalid row"); + }); + + it("fails closed on duplicate message or intent identity", async () => { + mocks.values.set("queue:main", [ + message("first"), + { ...message("second"), id: "first" }, + ]); + await expect(loadDurableMessageQueue()).rejects.toThrow( + "duplicate identity" + ); + + mocks.values.set("queue:main", [ + message("first"), + { ...message("second"), turnIntentId: "turn-first" }, + ]); + await expect(loadDurableMessageQueue()).rejects.toThrow( + "duplicate identity" + ); + }); +}); diff --git a/src/store/ui/messageQueueRepository.ts b/src/store/ui/messageQueueRepository.ts index d9f5ba4a27..1bb2354a28 100644 --- a/src/store/ui/messageQueueRepository.ts +++ b/src/store/ui/messageQueueRepository.ts @@ -1,9 +1,6 @@ import { type Store, load } from "@tauri-apps/plugin-store"; -import { - isConversationRootLocator, - isLocalConversationTarget, -} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { isQueuedConversationMessagePayload } from "@src/engines/SessionCore/conversations/queuedConversationExecutor"; import { createLogger } from "@src/hooks/logger"; import { @@ -20,7 +17,7 @@ const STORE_LOCK_NAME = "orgii:chat-message-queue-store"; 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(); async function withStoreLock(operation: () => Promise): Promise { @@ -40,14 +37,9 @@ async function withStoreLock(operation: () => Promise): Promise { function isQueuedMessage(value: unknown): value is QueuedMessage { if (!value || typeof value !== "object") return false; const item = value as Partial; - const conversationDispatch = item.conversationDispatch; - const validConversationDispatch = - conversationDispatch === undefined || - (conversationDispatch.kind === "canonical_conversation" && - isConversationRootLocator(conversationDispatch.root) && - isLocalConversationTarget(conversationDispatch.target) && - (conversationDispatch.dispatchIdentityKey === undefined || - typeof conversationDispatch.dispatchIdentityKey === "string")); + const validConversationDispatch = item.conversationDispatch + ? isQueuedConversationMessagePayload(item) + : true; return ( typeof item.id === "string" && typeof item.turnIntentId === "string" && @@ -59,28 +51,33 @@ function isQueuedMessage(value: unknown): value is QueuedMessage { (Array.isArray(item.imageDataUrls) && item.imageDataUrls.every((image) => typeof image === "string"))) && (item.priority === "now" || item.priority === "next") && - (item.status === "queued" || - item.status === "preparing" || - item.status === "accepted") && - (item.runnerSessionId === undefined || - typeof item.runnerSessionId === "string") && - (item.runnerEventStartIndex === undefined || - (typeof item.runnerEventStartIndex === "number" && - Number.isSafeInteger(item.runnerEventStartIndex) && - item.runnerEventStartIndex >= 0)) && - (item.retryAt === undefined || - (typeof item.retryAt === "string" && - Number.isFinite(Date.parse(item.retryAt)))) && - (item.retryAttempt === undefined || - (typeof item.retryAttempt === "number" && - Number.isSafeInteger(item.retryAttempt) && - item.retryAttempt >= 0)) && - (item.status !== "accepted" || typeof item.runnerSessionId === "string") && + item.status === "queued" && typeof item.createdAt === "string" && queuedMessageCharSize(item as QueuedMessage) <= MAX_QUEUED_MESSAGE_CHARS ); } +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; +} + async function durableStore(): Promise { if (storePromise) return storePromise; storePromise = load(STORE_PATH, { @@ -107,18 +104,57 @@ async function queueKey(): Promise { return queueKeyPromise; } -/** Load this window's durable queue. Invalid rows are ignored, never dispatched. */ -export async function loadDurableMessageQueue(): Promise { +/** Durable queue identity for this renderer; main may recover any orphan. */ +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` + ); +} + +/** + * One reload/lock boundary for every durable delivery mutation. + * + * Canonical handoff uses this same transaction to replace a window-local + * queue row with its app-global execution row. Keeping both keys in one + * document prevents a renderer crash from resurrecting an already-started + * provider request. + */ +export async function withMessageQueueStoreTransaction( + operation: (store: Store, windowQueueKey: string) => Promise +): Promise { const store = await durableStore(); if (!store) { throw new Error("durable message queue store is unavailable"); } + return await withStoreLock(async () => { + await store.reload(); + return await operation(store, await queueKey()); + }); +} + +/** Serialize every write to the shared queue/execution document. */ +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; +} + +/** Load this window's durable queue. Invalid rows are ignored, never dispatched. */ +export async function loadDurableMessageQueue(): Promise { try { - return await withStoreLock(async () => { - await store.reload(); - const stored = await store.get(await queueKey()); - if (!Array.isArray(stored)) return []; - return boundQueuedMessages(stored.filter(isQueuedMessage)); + return await withMessageQueueStoreTransaction(async (store, key) => { + const stored = await store.get(key); + return validatedDurableMessageQueue(stored); }); } catch (error) { log.warn("[messageQueueRepository] failed to load queue", error); @@ -141,37 +177,41 @@ export function persistDurableMessageQueue( 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) { - throw new Error("durable message queue store is unavailable"); - } - await withStoreLock(async () => { - // Store handles are cached per webview. Reload under the cross-window - // lock before changing only this window's key, otherwise a stale save - // can erase a sibling window's durable queue. - await store.reload(); - await store.set(await queueKey(), snapshot); - await store.save(); - }); - }); + const write = serializeMessageQueueStoreMutation(async (store, key) => { + // A stale window-local snapshot must never resurrect the queued twin of + // an app-global execution that has already started. + const executions = await store.get("executions"); + const executionIntentIds = new Set( + Array.isArray(executions) + ? executions.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const message = (value as { message?: unknown }).message; + if (!message || typeof message !== "object") return []; + const turnIntentId = (message as { turnIntentId?: unknown }) + .turnIntentId; + return typeof turnIntentId === "string" ? [turnIntentId] : []; + }) + : [] + ); + await store.set( + key, + snapshot.filter( + (message) => !executionIntentIds.has(message.turnIntentId) + ) + ); + await store.save(); + }); // Deliberately propagate the current write failure. Provider dispatch uses // this promise as its crash-consistency boundary: starting a native turn // without the corresponding durable queue row would make renderer restart // recovery ambiguous and can replay the same user intent. Background // subscribers attach their own best-effort logging handler. - return writeChain; + return write; } export function resetMessageQueueRepositoryForTests(): void { storePromise = null; queueKeyPromise = null; - writeChain = Promise.resolve(); + mutationChain = Promise.resolve(); fallbackStoreLock = Promise.resolve(); }