From 27cbb20990f443c5f3a315c758c6e51f8e2bcef5 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Fri, 24 Jul 2026 14:30:35 +0800 Subject: [PATCH] feat(btw): persist side threads with opt-in memory generation - create BTW children as durable standard sessions with typed parent anchors - restore and group persistent BTW sessions, including remote workspace routing - resume parent coordinator state before forking a BTW conversation - add a default-off setting for BTW transcripts to generate memories - remove legacy BTW hiding and document the forward-only rollout --- BTW_PERSISTENCE_AND_MEMORY_PLAN.md | 261 ++++++++++++++++++ src/apps/desktop/src/api/btw_api.rs | 12 +- .../src/api/remote_workspace_policy.rs | 6 +- .../src/agentic/coordination/coordinator.rs | 167 ++++++++++- .../core/src/agentic/memories/service.rs | 30 +- .../src/agentic/session/session_manager.rs | 11 + .../core/src/service/config/manager.rs | 2 + .../assembly/core/src/service/config/types.rs | 6 + .../sections/sessions/SessionsSection.tsx | 5 +- .../services/BtwThreadService.test.ts | 186 +++++++------ .../flow_chat/services/BtwThreadService.ts | 125 +++------ .../src/flow_chat/services/FlowChatManager.ts | 6 + .../flow-chat-manager/MessageModule.test.ts | 21 +- .../flow-chat-manager/MessageModule.ts | 38 --- .../src/flow_chat/store/FlowChatStore.ts | 7 - .../flow_chat/utils/sessionMetadata.test.ts | 15 + .../src/flow_chat/utils/sessionMetadata.ts | 14 +- .../infrastructure/api/service-api/BtwAPI.ts | 2 + .../config/components/MemoriesConfig.tsx | 15 + .../src/infrastructure/config/types/index.ts | 1 + .../src/locales/en-US/settings/memories.json | 4 + .../src/locales/zh-CN/settings/memories.json | 4 + .../src/locales/zh-TW/settings/memories.json | 4 + 23 files changed, 669 insertions(+), 273 deletions(-) create mode 100644 BTW_PERSISTENCE_AND_MEMORY_PLAN.md diff --git a/BTW_PERSISTENCE_AND_MEMORY_PLAN.md b/BTW_PERSISTENCE_AND_MEMORY_PLAN.md new file mode 100644 index 0000000000..1c2ee60444 --- /dev/null +++ b/BTW_PERSISTENCE_AND_MEMORY_PLAN.md @@ -0,0 +1,261 @@ +# /btw Persistence and Memory Plan + +## 1. Background + +`/btw` currently creates a transient child conversation. The frontend marks the +session as `isTransient: true` and `sessionKind: 'btw'`; the backend creates an +`EphemeralChild` session. `EphemeralChild` is intentionally excluded from +session persistence, so closing the desktop window makes an active `/btw` +conversation unavailable. + +This is unsuitable when a user turns a `/btw` discussion into a real task. The +conversation must remain available after restart, while still being presented +as a child of the parent conversation rather than as an unrelated root session. + +At the same time, a persistent `/btw` conversation must not silently become a +source of global memories. Its inclusion in the memory-generation lifecycle is +an explicit user configuration, disabled by default. + +## 2. Goals + +1. Persist every newly created `/btw` conversation and restore it after an app + restart. +2. Preserve the initial parent-context fork, model, mode, prompt-cache, and + constraints used by the current `/btw` flow. +3. Identify persistent BTW children structurally, so the UI can restore their + nested relationship with the parent. +4. Default persistent BTW children to excluded from memory generation, even + when normal-session memory generation is enabled. +5. Provide a Memory settings switch that lets a user opt new BTW conversations + into the ordinary Phase 1 and Phase 2 memory-generation lifecycle. + +## 3. Non-goals + +1. Do not change the behavior of existing ordinary sessions. +2. Do not migrate, repair, or reconstruct parent relationships for first-generation + BTW records. +3. Do not add a remote-workspace restriction to an existing remote `/btw` + workflow. Persistence must preserve its current remote behavior. +4. Do not couple "generate a new memory from this session" with "inject + existing global memory into this session". They are separate policies. + +## 4. Target Session Model + +New `/btw` sessions will be runtime `SessionKind::Standard` sessions with a +structured relationship: + +```text +session_kind: Standard +relationship: + kind: Btw + parent_session_id: + parent_request_id: + parent_dialog_turn_id: + parent_turn_index: +tags: ["btw"] +``` + +`Standard` is required for normal persistence, message updates, cancellation, +model changes, transcript reload, and memory-mode storage. `relationship.kind = +Btw` retains the product meaning that was previously inferred from the +ephemeral session kind and/or legacy metadata. + +The relationship is the source of truth. The `btw` tag is only a convenient +index/display hint and must not be used to classify structured and legacy BTW +sessions as equivalent. + +## 5. Creation and Runtime Flow + +The `/btw` command must continue to enter through the dedicated backend flow, +not through a generic frontend `createSession` followed by a normal send. The +current backend path forks the parent context before starting the first turn; +creating an empty persistent session in the frontend would lose that snapshot. + +The new backend flow is: + +1. Resolve the parent session and capture the same context snapshot currently + used by `ensure_hidden_btw_session` / `start_hidden_btw_turn`. +2. Create a persistent `Standard` child session. +3. Write `relationship.kind = Btw` and its parent linkage before the first + user turn is started. +4. Determine and persist the session's `memory_mode` as described in section + 7. +5. Persist the inherited context snapshot, then start the first BTW turn. +6. Route subsequent turns through the normal persistent-session send, cancel, + model-update, and persistence paths. + +The frontend API may retain BTW-specific names, but it must stop creating an +`isTransient` session. Its returned session identifier is a normal durable +session identifier. + +## 6. Restore and UI Behavior + +On startup, the session loader must retain structured BTW children and the +session navigation must group them under `relationship.parent_session_id`. + +- When the parent exists, opening the child restores it in the existing BTW + auxiliary panel. +- When the parent is missing or archived, opening the child falls back to an + independent session view rather than hiding an otherwise valid task. +- Remove the legacy BTW hiding predicate. First-generation records are not + recognized as BTW children and are not migrated; the ordinary session loader + may show them as independent root sessions. They have no supported parent + placement or auxiliary-panel behavior. + +## 7. Memory Policy + +### 7.1 Configuration + +Add this field to `MemoriesConfig`: + +```text +memories.generate_for_btw_sessions: false +``` + +It appears as a switch in the Memory settings page, adjacent to the global +"Generate memories" switch. The setting is effective only when the global +`memories.generate_memories` switch is also enabled. + +The field must be aligned in all configuration surfaces: + +1. Rust `MemoriesConfig`, serde default, and config persistence/default-pruning + tests. +2. Frontend `MemoriesConfig` type and config fallback/default handling. +3. Memory settings UI, locale strings, and settings search/index metadata. + +Because the Rust config is serde-defaulted, existing config files that lack the +new field resolve safely to `false`. + +### 7.2 Source Eligibility + +`SessionMetadata.memory_mode` is the durable source-eligibility contract. Phase +1 currently only extracts sessions that are both `SessionKind::Standard` and +`SessionMemoryMode::Enabled`. + +When a persistent BTW child is created, set its mode as follows: + +| Global `generate_memories` | `generate_for_btw_sessions` | BTW `memory_mode` | +| --- | --- | --- | +| false | false or true | `Disabled` | +| true | false | `Disabled` | +| true | true | `Enabled` | + +This decision is stored with the newly created session rather than only +skipping the BTW completion hook. Memory Phase 1 scans historical sessions each +time it starts; a startup-only guard would allow a disabled BTW to be extracted +later when an unrelated normal session starts a scan. + +The setting applies to conversations created after the setting is chosen. It +does not retrospectively enable previously excluded BTW transcripts. This +preserves the user's original choice not to contribute that task to global +memory. A future product request can add an explicit per-session migration or +selection action if retroactive enrollment is wanted. + +### 7.3 Existing-memory Injection + +`memories.use_memories` controls injection of the existing consolidated memory +summary into prompts. It is currently a global prompt-building decision and is +not session-kind aware. + +Recommended initial policy: a persistent BTW child continues to read existing +memory whenever `use_memories` is enabled. This is useful for a task +continuation and does not make the BTW transcript a new memory source. + +If product policy requires a fully isolated BTW environment, add a separate +future flag such as `memories.use_for_btw_sessions`, default `false`. Do not +reuse `generate_for_btw_sessions` for this purpose, because the two data flows +have different privacy and product semantics. + +## 8. Remote Workspace Support and Audit + +`/btw` already works in remote workspaces. The BTW child inherits the parent +`SessionConfig`, and each turn continues to pass the session's +`remote_connection_id` and `remote_ssh_host` into the normal dialog-turn path. +Remote sessions also have a dedicated local mirror storage path for their +session metadata and transcripts. + +The desktop command-policy registry currently marks `btw_ask_stream` and +`btw_cancel` as `LegacyUnaudited`. That is a classification backlog, not a +runtime rejection or a claim that remote BTW is unsupported. + +The persistence change must preserve this existing capability: + +1. Create the durable BTW child from the inherited remote-aware `SessionConfig`. +2. Resolve and store it through the same remote-session mirror path as the + parent, retaining remote connection identity across restart. +3. Add focused remote regression coverage for create, restart/restore, resume, + and cancel. +4. Audit the two desktop handlers with this flow and then promote + `btw_ask_stream` and `btw_cancel` to `RemoteRouted` in the command-policy + registry. + +No explicit unsupported state or remote-only feature gate is part of this plan. + +## 9. Implementation Areas + +| Area | Main files | Change | +| --- | --- | --- | +| Frontend BTW entry | `src/web-ui/src/flow_chat/services/BtwThreadService.ts` | Stop requesting a transient BTW session and consume the durable child result. | +| Desktop API | `src/apps/desktop/src/api/btw_api.rs` | Preserve the command contract while calling the persistent BTW coordinator path. | +| Coordinator | `src/crates/assembly/core/src/agentic/coordination/coordinator.rs` | Replace ephemeral BTW child construction with durable Standard creation, context fork, relationship, and memory-mode selection. | +| Session persistence | `src/crates/assembly/core/src/agentic/session/session_manager.rs` and `src/crates/assembly/core/src/agentic/persistence/manager.rs` | Persist and reload the structured BTW child without changing ordinary-session behavior. | +| Shared session contract | `src/crates/services/services-core/src/session/types.rs` and related metadata helpers | Reuse `SessionRelationshipKind::Btw`; do not add a second BTW classification format. | +| Session navigation | `src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx` | Restore/group structured BTW children; use orphan fallback. | +| Frontend metadata parsing and loading | `src/web-ui/src/flow_chat/utils/sessionMetadata.ts` and `src/web-ui/src/flow_chat/store/FlowChatStore.ts` | Use `relationship.kind = Btw` as the only BTW classifier; remove `isLegacyPersistedBtwSession` and both metadata-load skip branches. | +| Memory configuration | `src/crates/assembly/core/src/service/config/types.rs`, config manager, frontend config types, and `MemoriesConfig.tsx` | Add the default-off BTW source-generation switch and UI/i18n plumbing. | +| Memory source selection | `src/crates/assembly/core/src/agentic/memories/service.rs` | Continue honoring durable `memory_mode`; add an explicit test proving an excluded BTW is not claimed during a later ordinary scan. | +| Remote BTW audit | `src/apps/desktop/src/api/remote_workspace_policy.rs`, BTW API, and remote-session tests | Preserve inherited remote identity, verify the persistent flow, then promote the BTW commands to `RemoteRouted`. | + +## 10. Test Plan + +### Rust + +1. Config defaults and deserialization: missing + `generate_for_btw_sessions` resolves to `false`; non-default values persist + and reload correctly. +2. BTW creation: durable child is `Standard`, carries `Btw` relationship and + parent linkage, and retains the parent-context snapshot. +3. Memory mode matrix: verify all three effective cases in section 7.2. +4. Restart/reload: child transcript and relationship survive persistence. +5. Phase 1 candidate selection: a BTW created while the new flag is off is + never claimed, including during a memory run started by another session. +6. Remote persistence: create a BTW in a remote workspace, restart and restore + it, resume/cancel it, and confirm that its remote identity and mirror + storage remain intact. Promote the two BTW commands to `RemoteRouted` after + that audit passes. + +### Frontend + +1. New BTW session is not transient and can send further messages through the + normal session path. +2. Structured BTW child restores beneath its parent and opens in the auxiliary + panel. +3. Orphaned structured BTW opens independently. +4. Memory settings switch reads, writes, and renders its default-off state. + +### Commands after implementation + +Run the narrow tests that cover the changed Rust modules and Web UI behavior, +then at minimum run: + +```text +pnpm run type-check:web +cargo check --workspace +``` + +Add the focused frontend and Rust test commands to the implementation handoff +only after their exact test locations are finalized. + +## 11. Rollout and Compatibility + +The feature is forward-only. New BTW sessions use structured relationships and +durable session storage; historical transient BTW sessions do not have data to +restore. First-generation persisted BTW-shaped records receive no compatibility +handling: the implementation removes their legacy hide/recognition branches and +does not migrate their tag/custom-metadata relationship into the new structured +format. Any resulting ordinary-session loading is incidental and has no +compatibility test, parent placement, or BTW auxiliary-panel guarantee. + +The memory setting defaults to closed for both new users and existing config +files. This permits persistent BTW task recovery without widening the set of +transcripts that can contribute to global memory. diff --git a/src/apps/desktop/src/api/btw_api.rs b/src/apps/desktop/src/api/btw_api.rs index 3501a996b1..c636011db6 100644 --- a/src/apps/desktop/src/api/btw_api.rs +++ b/src/apps/desktop/src/api/btw_api.rs @@ -2,9 +2,9 @@ //! //! Desktop adapter for the core `/btw` feature. //! -//! `/btw` runs as a hidden transient child session that reuses the parent -//! session's full context snapshot while still flowing through the normal -//! agentic event pipeline. +//! `/btw` runs as a persistent child session that reuses the parent session's +//! full context snapshot while still flowing through the normal agentic event +//! pipeline. use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -23,6 +23,8 @@ pub struct BtwAskStreamRequest { pub question: String, pub child_session_id: String, pub child_session_name: Option, + pub parent_dialog_turn_id: Option, + pub parent_turn_index: Option, /// Optional model id override. Supports "fast"/"primary" aliases. pub model_id: Option, #[serde(default)] @@ -97,7 +99,7 @@ pub async fn btw_ask_stream( let image_contexts = request.image_contexts; let turn_id = coordinator - .start_hidden_btw_turn( + .start_btw_turn( &request.request_id, &request.session_id, &child_session_id, @@ -105,6 +107,8 @@ pub async fn btw_ask_stream( &request.question, model_id.as_deref(), image_contexts, + request.parent_dialog_turn_id.as_deref(), + request.parent_turn_index, ) .await .map_err(|e| e.to_string())?; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index dad1c302e9..074e0a0dd0 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -189,8 +189,8 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "browser_webview_set_bounds", RemoteWorkspacePolicy::LocalOnly, ), - ("btw_ask_stream", RemoteWorkspacePolicy::LegacyUnaudited), - ("btw_cancel", RemoteWorkspacePolicy::LegacyUnaudited), + ("btw_ask_stream", RemoteWorkspacePolicy::RemoteRouted), + ("btw_cancel", RemoteWorkspacePolicy::RemoteRouted), ( "cancel_acp_dialog_turn", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1831,8 +1831,6 @@ mod tests { "apply_patch", "archive_all_sessions", "archive_session", - "btw_ask_stream", - "btw_cancel", "cancel_acp_dialog_turn", "cancel_dialog_turn", "cancel_insights_generation", diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index eb2d707f13..c3243c9e16 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -6408,13 +6408,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ForkAgentContextSnapshot::from_parent_session(&parent_session, context_messages) } - async fn ensure_hidden_btw_session( + async fn ensure_btw_session( &self, parent_session_id: &str, child_session_id: &str, child_session_name: Option<&str>, + request_id: &str, + parent_dialog_turn_id: Option<&str>, + parent_turn_index: Option, ) -> BitFunResult { if let Some(session) = self.session_manager.get_session(child_session_id) { + self.session_manager + .merge_session_relationship( + child_session_id, + SessionRelationship { + kind: Some(SessionRelationshipKind::Btw), + parent_session_id: Some(parent_session_id.to_string()), + parent_request_id: Some(request_id.to_string()), + parent_dialog_turn_id: parent_dialog_turn_id.map(str::to_string), + parent_turn_index, + ..Default::default() + }, + ) + .await?; return Ok(session); } @@ -6434,7 +6450,26 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet snapshot.parent_agent_type.clone(), snapshot.build_child_session_config(None), Some(format!("session-{}", snapshot.parent_session_id)), - SessionKind::EphemeralChild, + SessionKind::Standard, + ) + .await?; + self.session_manager + .merge_session_relationship( + child_session_id, + SessionRelationship { + kind: Some(SessionRelationshipKind::Btw), + parent_session_id: Some(parent_session_id.to_string()), + parent_request_id: Some(request_id.to_string()), + parent_dialog_turn_id: parent_dialog_turn_id.map(str::to_string), + parent_turn_index, + ..Default::default() + }, + ) + .await?; + self.session_manager + .set_persisted_session_memory_mode( + child_session_id, + new_btw_session_memory_mode_from_global_config().await, ) .await?; self.session_manager @@ -6469,7 +6504,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(child_session) } - pub async fn start_hidden_btw_turn( + pub async fn start_btw_turn( &self, request_id: &str, parent_session_id: &str, @@ -6478,6 +6513,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet question: &str, model_id: Option<&str>, image_contexts: Option>, + parent_dialog_turn_id: Option<&str>, + parent_turn_index: Option, ) -> BitFunResult { if request_id.trim().is_empty() { return Err(BitFunError::Validation( @@ -6499,7 +6536,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } let child_session = self - .ensure_hidden_btw_session(parent_session_id, child_session_id, child_session_name) + .ensure_btw_session( + parent_session_id, + child_session_id, + child_session_name, + request_id, + parent_dialog_turn_id, + parent_turn_index, + ) .await?; if let Some(model_id) = model_id @@ -8870,6 +8914,17 @@ async fn is_ai_session_title_generation_enabled() -> bool { } } +fn btw_session_memory_mode( + generate_memories: bool, + generate_for_btw_sessions: bool, +) -> SessionMemoryMode { + if generate_memories && generate_for_btw_sessions { + SessionMemoryMode::Enabled + } else { + SessionMemoryMode::Disabled + } +} + async fn new_session_memory_mode_from_global_config() -> SessionMemoryMode { match crate::service::config::get_global_config_service().await { Ok(service) => { @@ -8890,6 +8945,20 @@ async fn new_session_memory_mode_from_global_config() -> SessionMemoryMode { } } +async fn new_btw_session_memory_mode_from_global_config() -> SessionMemoryMode { + match crate::service::config::get_global_config_service().await { + Ok(service) => { + let config: crate::service::config::types::GlobalConfig = + service.get_config(None).await.unwrap_or_default(); + btw_session_memory_mode( + config.memories.generate_memories, + config.memories.generate_for_btw_sessions, + ) + } + Err(_) => SessionMemoryMode::Disabled, + } +} + // Global coordinator singleton static GLOBAL_COORDINATOR: OnceLock> = OnceLock::new(); @@ -8936,12 +9005,13 @@ fn merge_prepended_messages_for_turn( #[cfg(test)] mod tests { use super::{ - build_subagent_session_relationship, logical_subagent_type_or_runtime, - merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, - resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, - resolve_subagent_model_selection, runtime_port_error_preserving_message, - runtime_tool_restrictions_for_session_lifetime, turn_review_manifest_for_agent, - BackgroundSubagentWaitMode, ConversationCoordinator, SessionReferenceLocator, + btw_session_memory_mode, build_subagent_session_relationship, + logical_subagent_type_or_runtime, merge_prepended_messages_for_turn, + normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, + resolve_agent_submission_turn_id, resolve_subagent_model_selection, + runtime_port_error_preserving_message, runtime_tool_restrictions_for_session_lifetime, + turn_review_manifest_for_agent, BackgroundSubagentWaitMode, ConversationCoordinator, + SessionMemoryMode, SessionReferenceLocator, SessionRelationshipKind, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::coordination::coordination_store::{ @@ -8983,6 +9053,26 @@ mod tests { use std::sync::Arc; use std::time::Duration; + #[test] + fn btw_session_memory_mode_requires_both_generation_switches() { + assert_eq!( + btw_session_memory_mode(false, false), + SessionMemoryMode::Disabled + ); + assert_eq!( + btw_session_memory_mode(false, true), + SessionMemoryMode::Disabled + ); + assert_eq!( + btw_session_memory_mode(true, false), + SessionMemoryMode::Disabled + ); + assert_eq!( + btw_session_memory_mode(true, true), + SessionMemoryMode::Enabled + ); + } + #[test] fn session_reference_artifact_stems_extend_only_for_collisions() { let references = vec![ @@ -9404,6 +9494,10 @@ mod tests { test_coordinator_with_config(max_active_sessions, false) } + fn test_persistent_coordinator() -> (ConversationCoordinator, Arc) { + test_coordinator_with_config(100, true) + } + fn test_coordinator() -> (ConversationCoordinator, Arc) { test_coordinator_with_max_active_sessions(100) } @@ -11300,8 +11394,8 @@ mod tests { } #[tokio::test] - async fn hidden_btw_session_seeds_forked_listing_baselines() { - let (coordinator, session_manager) = test_coordinator(); + async fn btw_session_persists_relationship_and_seeds_forked_listing_baselines() { + let (coordinator, session_manager) = test_persistent_coordinator(); let workspace_path = std::env::temp_dir().join(format!("bitfun-btw-baseline-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); @@ -11319,6 +11413,8 @@ mod tests { "agentic".to_string(), SessionConfig { workspace_path: Some(workspace_path.to_string_lossy().into_owned()), + remote_connection_id: Some("ssh-user@example.test:22".to_string()), + remote_ssh_host: Some("example.test".to_string()), ..Default::default() }, ) @@ -11375,13 +11471,20 @@ mod tests { .await; let child_session = coordinator - .ensure_hidden_btw_session(&parent_session.session_id, "btw-child", None) + .ensure_btw_session( + &parent_session.session_id, + "btw-child", + None, + "btw-request", + Some("parent-turn"), + Some(2), + ) .await .expect("btw child session should be created"); assert_eq!( child_session.kind, - crate::agentic::core::SessionKind::EphemeralChild + crate::agentic::core::SessionKind::Standard ); assert_eq!( child_session.last_user_dialog_agent_type.as_deref(), @@ -11391,6 +11494,14 @@ mod tests { child_session.last_submitted_agent_type.as_deref(), Some("agentic") ); + assert_eq!( + child_session.config.remote_connection_id.as_deref(), + Some("ssh-user@example.test:22") + ); + assert_eq!( + child_session.config.remote_ssh_host.as_deref(), + Some("example.test") + ); assert_eq!( session_manager .cached_system_prompt(&child_session.session_id, &system_prompt_identity) @@ -11415,6 +11526,34 @@ mod tests { .await, Some(baseline_snapshot) ); + + let session_storage_path = session_manager + .storage_path_binding_for_test(&child_session.session_id) + .expect("BTW storage path should be bound"); + let _storage_guard = TempWorkspaceGuard(session_storage_path.clone()); + let metadata = session_manager + .load_session_metadata(&session_storage_path, &child_session.session_id) + .await + .expect("BTW metadata should load") + .expect("BTW metadata should exist"); + let relationship = metadata + .relationship + .expect("BTW relationship should persist"); + assert_eq!(relationship.kind, Some(SessionRelationshipKind::Btw)); + assert_eq!( + relationship.parent_session_id.as_deref(), + Some(parent_session.session_id.as_str()) + ); + assert_eq!( + relationship.parent_request_id.as_deref(), + Some("btw-request") + ); + assert_eq!( + relationship.parent_dialog_turn_id.as_deref(), + Some("parent-turn") + ); + assert_eq!(relationship.parent_turn_index, Some(2)); + assert_eq!(metadata.memory_mode, SessionMemoryMode::Disabled); } #[test] diff --git a/src/crates/assembly/core/src/agentic/memories/service.rs b/src/crates/assembly/core/src/agentic/memories/service.rs index b7931652e3..9ee1bd8b8b 100644 --- a/src/crates/assembly/core/src/agentic/memories/service.rs +++ b/src/crates/assembly/core/src/agentic/memories/service.rs @@ -324,7 +324,7 @@ impl MemoryPhase1Service { ); continue; } - if metadata.session_kind != SessionKind::Standard { + if phase1_session_kind_gate_skips(&metadata) { debug!( "Memory phase1 candidate skipped by session kind: session_id={}, workspace_path={}, session_storage_path={}, session_kind={:?}", metadata.session_id, @@ -334,7 +334,7 @@ impl MemoryPhase1Service { ); continue; } - if metadata.memory_mode != SessionMemoryMode::Enabled { + if phase1_memory_mode_gate_skips(&metadata) { debug!( "Memory phase1 candidate skipped by memory mode: session_id={}, workspace_path={}, session_storage_path={}, memory_mode={:?}", metadata.session_id, @@ -681,6 +681,14 @@ fn phase1_status_gate_skips(metadata: &SessionMetadata) -> bool { metadata.status == SessionStatus::Archived } +fn phase1_session_kind_gate_skips(metadata: &SessionMetadata) -> bool { + metadata.session_kind != SessionKind::Standard +} + +fn phase1_memory_mode_gate_skips(metadata: &SessionMetadata) -> bool { + metadata.memory_mode != SessionMemoryMode::Enabled +} + fn build_prompt(source: &MemorySourceSession, transcript: &str) -> String { let assistant_persona_rules = if is_claw_agent_type(&source.agent_type) { format!("\n{}\n", CLAW_PERSONA_MEMORY_RULES) @@ -1106,6 +1114,24 @@ mod tests { assert!(!phase1_status_gate_skips(&metadata)); } + #[test] + fn phase1_memory_mode_gate_excludes_disabled_standard_btw_sessions() { + let mut metadata = SessionMetadata::new( + "btw-session".to_string(), + "Side thread".to_string(), + "agentic".to_string(), + "model".to_string(), + ); + metadata.session_kind = SessionKind::Standard; + metadata.memory_mode = SessionMemoryMode::Disabled; + + assert!(!phase1_session_kind_gate_skips(&metadata)); + assert!(phase1_memory_mode_gate_skips(&metadata)); + + metadata.memory_mode = SessionMemoryMode::Enabled; + assert!(!phase1_memory_mode_gate_skips(&metadata)); + } + fn gemini_response(text: &str) -> bitfun_ai_adapters::GeminiResponse { bitfun_ai_adapters::GeminiResponse { text: text.to_string(), diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 9cf65de64b..d0264cb2b0 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5148,6 +5148,17 @@ impl SessionManager { .await } + pub async fn set_persisted_session_memory_mode( + &self, + session_id: &str, + mode: SessionMemoryMode, + ) -> BitFunResult<()> { + self.update_persisted_session_metadata(session_id, |metadata| { + metadata.memory_mode = mode; + }) + .await + } + pub async fn mark_session_memory_mode_polluted( &self, workspace_path: &Path, diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 65af3a7a92..79ade13f24 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -1121,6 +1121,7 @@ mod tests { fn persistence_keeps_only_non_default_memories_fields() { let mut config = GlobalConfig::default(); config.memories.generate_memories = false; + config.memories.generate_for_btw_sessions = true; config.memories.max_rollouts_per_startup = 12; let value = @@ -1134,6 +1135,7 @@ mod tests { assert_eq!( value.get("memories"), Some(&serde_json::json!({ + "generate_for_btw_sessions": true, "max_rollouts_per_startup": 12 })) ); diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 4dbe1cf9fc..bc9366521b 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -755,6 +755,8 @@ pub enum MemoryExternalContextPolicy { pub struct MemoriesConfig { /// Enables automatic Phase 1 extraction and Phase 2 consolidation. pub generate_memories: bool, + /// Allows persistent BTW sessions to become memory-generation sources. + pub generate_for_btw_sessions: bool, /// Enables prompt injection of the consolidated memory summary. pub use_memories: bool, /// Controls how sessions that used external context tools are handled. @@ -1768,6 +1770,7 @@ impl Default for MemoriesConfig { fn default() -> Self { Self { generate_memories: false, + generate_for_btw_sessions: false, use_memories: false, external_context_policy: MemoryExternalContextPolicy::ClearToolResults, max_raw_memories_for_consolidation: default_memory_max_raw_memories_for_consolidation(), @@ -2507,6 +2510,7 @@ mod tests { let config = GlobalConfig::default(); assert!(!config.memories.generate_memories); + assert!(!config.memories.generate_for_btw_sessions); assert!(!config.memories.use_memories); assert_eq!( config.memories.external_context_policy, @@ -2533,6 +2537,7 @@ mod tests { let config: GlobalConfig = serde_json::from_value(serde_json::json!({ "memories": { "generate_memories": false, + "generate_for_btw_sessions": true, "use_memories": false, "external_context_policy": "skip_session", "max_raw_memories_for_consolidation": 12, @@ -2554,6 +2559,7 @@ mod tests { .expect("global config with memories section should deserialize"); assert!(!config.memories.generate_memories); + assert!(config.memories.generate_for_btw_sessions); assert!(!config.memories.use_memories); assert_eq!( config.memories.external_context_policy, diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 258b9c4472..d0abc68143 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -690,7 +690,10 @@ const SessionsSection: React.FC = ({ } : undefined; - if (relationship.canOpenInAuxPane && parentSessionId && session) { + const parentSession = parentSessionId + ? flowChatStore.getState().sessions.get(parentSessionId) + : undefined; + if (relationship.canOpenInAuxPane && parentSessionId && parentSession && session) { await openMainSession(parentSessionId, { workspaceId, activateWorkspace, diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts index 905ec46107..e0bc49b39c 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts @@ -7,11 +7,7 @@ const mockUpdateSessionRelationship = vi.fn(); const mockUpdateSessionBtwOrigin = vi.fn(); const mockAddBtwThreadMarker = vi.fn(); const mockUpdateSessionModelName = vi.fn(); -const mockAddDialogTurn = vi.fn(); -const mockDeleteDialogTurn = vi.fn(); -const mockCancelSessionTask = vi.fn(); -const mockBtwCancel = vi.fn(); -const mockTransition = vi.fn(); +const mockEnsureBackendSession = vi.fn(); const sessions = new Map(); @@ -21,7 +17,6 @@ vi.mock('@/infrastructure/api', () => ({ }, btwAPI: { askStream: (...args: any[]) => mockAskStream(...args), - cancel: (...args: any[]) => mockBtwCancel(...args), }, })); @@ -33,30 +28,23 @@ vi.mock('../store/FlowChatStore', () => ({ updateSessionBtwOrigin: (...args: any[]) => mockUpdateSessionBtwOrigin(...args), addBtwThreadMarker: (...args: any[]) => mockAddBtwThreadMarker(...args), updateSessionModelName: (...args: any[]) => mockUpdateSessionModelName(...args), - addDialogTurn: (...args: any[]) => mockAddDialogTurn(...args), - deleteDialogTurn: (...args: any[]) => mockDeleteDialogTurn(...args), - cancelSessionTask: (...args: any[]) => mockCancelSessionTask(...args), }, })); vi.mock('../state-machine', () => ({ - SessionExecutionEvent: { - START: 'start', - FINISHING_SETTLED: 'finishing_settled', - }, stateMachineManager: { get: () => ({ getContext: () => ({ currentDialogTurnId: 'turn-parent-1', }), }), - transition: (...args: any[]) => mockTransition(...args), }, })); vi.mock('./FlowChatManager', () => ({ flowChatManager: { discardLocalSession: vi.fn(), + ensureBackendSession: (...args: any[]) => mockEnsureBackendSession(...args), }, })); @@ -67,9 +55,10 @@ vi.mock('@/shared/notification-system', () => ({ })); import { - cancelTransientBtwSession, createBtwChildSession, - sendMessageToTransientBtwSession, + createBtwSessionPlaceholder, + sendMessageToBtwSession, + startBtwThread, } from './BtwThreadService'; describe('BtwThreadService', () => { @@ -92,8 +81,6 @@ describe('BtwThreadService', () => { ], }); mockAskStream.mockResolvedValue({ ok: true }); - mockBtwCancel.mockResolvedValue(undefined); - mockTransition.mockResolvedValue(true); mockCreateSession.mockResolvedValue({ sessionId: 'child-1', }); @@ -160,17 +147,55 @@ describe('BtwThreadService', () => { })); }); - it('passes image contexts through to the desktop /btw API', async () => { + it('creates a durable /btw placeholder with its parent anchor', () => { + const result = createBtwSessionPlaceholder({ + parentSessionId: 'parent-1', + workspacePath: '/workspace', + childSessionName: 'Side question', + }); + + expect(result.childSessionId).toMatch(/^btw_session_/); + expect(result.parentDialogTurnId).toBe('turn-parent-1'); + expect(result.parentTurnIndex).toBe(1); + expect(mockAddExternalSession).toHaveBeenCalledWith( + result.childSessionId, + 'Side question', + 'agentic', + '/workspace', + expect.objectContaining({ + parentSessionId: 'parent-1', + sessionKind: 'btw', + isTransient: false, + btwOrigin: { + parentSessionId: 'parent-1', + parentDialogTurnId: 'turn-parent-1', + parentTurnIndex: 1, + }, + }), + 'remote-1', + 'host-1', + ); + expect(mockUpdateSessionRelationship).toHaveBeenCalledWith(result.childSessionId, { + parentSessionId: 'parent-1', + sessionKind: 'btw', + }); + }); + + it('passes image contexts and parent turn metadata through to the desktop /btw API', async () => { sessions.set('btw-child', { sessionId: 'btw-child', title: 'Side question', - isTransient: true, + isTransient: false, sessionKind: 'btw', - agentBackedTransient: false, config: { modelName: 'fast' }, + btwOrigin: { + parentSessionId: 'parent-1', + parentDialogTurnId: 'turn-parent-1', + parentTurnIndex: 1, + }, }); - await sendMessageToTransientBtwSession({ + await sendMessageToBtwSession({ parentSessionId: 'parent-1', childSessionId: 'btw-child', question: 'What is in this image?', @@ -199,6 +224,8 @@ describe('BtwThreadService', () => { sessionId: 'parent-1', childSessionId: 'btw-child', question: 'What is in this image?', + parentDialogTurnId: 'turn-parent-1', + parentTurnIndex: 1, imageContexts: [ expect.objectContaining({ id: 'img-1', @@ -209,97 +236,90 @@ describe('BtwThreadService', () => { }), ); expect(mockAskStream.mock.calls[0][0]).not.toHaveProperty('modelId'); - expect(mockAddDialogTurn).toHaveBeenCalledWith( - 'btw-child', - expect.objectContaining({ - id: expect.stringMatching(/^btw-turn-/), - sessionId: 'btw-child', - userMessage: expect.objectContaining({ - content: 'What is in this image?', - hasImages: true, - images: [ - expect.objectContaining({ - id: 'img-1', - name: 'clip.png', - imagePath: 'C:/tmp/clip.png', - }), - ], - }), - status: 'pending', - }), - ); expect(mockUpdateSessionBtwOrigin).toHaveBeenCalledWith( 'btw-child', expect.objectContaining({ requestId: expect.any(String), parentSessionId: 'parent-1', + parentDialogTurnId: 'turn-parent-1', + parentTurnIndex: 1, }), 'btw', ); }); - it('cancels transient /btw sessions through the desktop /btw API', async () => { - sessions.set('btw-child', { - sessionId: 'btw-child', - title: 'Side question', - isTransient: true, - sessionKind: 'btw', - agentBackedTransient: false, - config: { modelName: 'fast' }, - btwOrigin: { - parentSessionId: 'parent-1', - requestId: 'req-1', - }, + it('discards the local placeholder when the first /btw request fails', async () => { + const error = new Error('backend refused'); + mockAskStream.mockRejectedValueOnce(error); + mockAddExternalSession.mockImplementationOnce((sessionId, title, mode, workspacePath, meta) => { + sessions.set(sessionId, { + sessionId, + title, + mode, + workspacePath, + config: {}, + sessionKind: meta?.sessionKind, + parentSessionId: meta?.parentSessionId, + btwOrigin: meta?.btwOrigin, + isTransient: meta?.isTransient, + }); }); - await expect(cancelTransientBtwSession('btw-child')).resolves.toBe(true); + await expect(startBtwThread({ + parentSessionId: 'parent-1', + question: 'Will this send?', + workspacePath: '/workspace', + })).rejects.toThrow('backend refused'); - expect(mockBtwCancel).toHaveBeenCalledWith({ requestId: 'req-1' }); - expect(mockCancelSessionTask).not.toHaveBeenCalled(); + const childSessionId = mockAddExternalSession.mock.calls[0][0]; + const { flowChatManager } = await import('./FlowChatManager'); + expect(flowChatManager.discardLocalSession).toHaveBeenCalledWith(childSessionId); }); - it('removes the pending local turn and settles the state machine when /btw send fails', async () => { - const error = new Error('backend refused'); - mockAskStream.mockRejectedValueOnce(error); - sessions.set('btw-child', { - sessionId: 'btw-child', - title: 'Side question', - isTransient: true, - sessionKind: 'btw', - agentBackedTransient: false, - config: { modelName: 'fast' }, - dialogTurns: [], + it('restores the parent coordinator session before starting a persistent /btw thread', async () => { + sessions.set('parent-1', { + ...sessions.get('parent-1'), + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + }); + mockAddExternalSession.mockImplementationOnce((sessionId, title, mode, workspacePath, meta) => { + sessions.set(sessionId, { + sessionId, + title, + mode, + workspacePath, + config: {}, + sessionKind: meta?.sessionKind, + parentSessionId: meta?.parentSessionId, + btwOrigin: meta?.btwOrigin, + isTransient: meta?.isTransient, + }); }); - await expect(sendMessageToTransientBtwSession({ + await startBtwThread({ parentSessionId: 'parent-1', - childSessionId: 'btw-child', - question: 'Will this send?', - })).rejects.toThrow('backend refused'); + question: 'Restore the parent first', + workspacePath: '/workspace', + }); - expect(mockAddDialogTurn).toHaveBeenCalledWith( - 'btw-child', - expect.objectContaining({ - id: expect.stringMatching(/^btw-turn-/), - }), - ); - const [, localTurn] = mockAddDialogTurn.mock.calls.at(-1)!; - expect(mockDeleteDialogTurn).toHaveBeenCalledWith('btw-child', localTurn.id); - expect(mockTransition).toHaveBeenCalledWith('btw-child', 'finishing_settled'); + expect(mockEnsureBackendSession).toHaveBeenCalledWith('parent-1'); + expect(mockAskStream).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'parent-1', + })); }); it('uses an explicit model override for /btw sends when provided', async () => { sessions.set('btw-child', { sessionId: 'btw-child', title: 'Side question', - isTransient: true, + isTransient: false, sessionKind: 'btw', - agentBackedTransient: false, config: { modelName: 'parent-multimodal-model' }, dialogTurns: [], }); - await sendMessageToTransientBtwSession({ + await sendMessageToBtwSession({ parentSessionId: 'parent-1', childSessionId: 'btw-child', question: 'What is in this image?', diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index bf3a391656..bf7e3f4941 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -1,9 +1,9 @@ import { agentAPI, btwAPI } from '@/infrastructure/api'; import { notificationService } from '@/shared/notification-system'; import { flowChatStore } from '../store/FlowChatStore'; -import { SessionExecutionEvent, stateMachineManager } from '../state-machine'; +import { stateMachineManager } from '../state-machine'; import { flowChatManager } from './FlowChatManager'; -import type { DialogTurn, Session } from '../types/flow-chat'; +import type { Session } from '../types/flow-chat'; import type { SessionKind, SessionRelationship } from '@/shared/types/session-history'; import type { ReviewTargetEvidence, @@ -61,51 +61,6 @@ function requireSession(sessionId: string): Session { return session; } -function createPendingBtwTurn(params: { - childSessionId: string; - requestId: string; - question: string; - imagePayload?: ImagePayload; -}): string { - const dialogTurnId = `btw-turn-${params.requestId.trim()}`; - const existingSession = flowChatStore.getState().sessions.get(params.childSessionId); - if (existingSession?.dialogTurns?.some(turn => turn.id === dialogTurnId)) { - return dialogTurnId; - } - - const hasImages = (params.imagePayload?.imageContexts.length ?? 0) > 0; - const dialogTurn: DialogTurn = { - id: dialogTurnId, - sessionId: params.childSessionId, - kind: 'user_dialog', - userMessage: { - id: `user_btw_${Date.now()}`, - content: params.question, - timestamp: Date.now(), - hasImages, - images: params.imagePayload?.imageDisplayData, - metadata: { - kind: 'btw', - requestId: params.requestId, - }, - }, - modelRounds: [], - status: 'pending', - startTime: Date.now(), - }; - - flowChatStore.addDialogTurn(params.childSessionId, dialogTurn); - void stateMachineManager.transition(params.childSessionId, SessionExecutionEvent.START, { - taskId: params.childSessionId, - dialogTurnId, - }); - return dialogTurnId; -} - -export function isTransientBtwSession(session: Session | undefined): boolean { - return session?.isTransient === true && session.sessionKind === 'btw' && session.agentBackedTransient !== true; -} - export async function createBtwChildSession(params: { parentSessionId: string; workspacePath?: string; @@ -237,11 +192,11 @@ export async function createBtwChildSession(params: { }; } -export function createTransientBtwSession(params: { +export function createBtwSessionPlaceholder(params: { parentSessionId: string; workspacePath?: string; childSessionName: string; -}): { childSessionId: string } { +}): { childSessionId: string; parentDialogTurnId?: string; parentTurnIndex?: number } { const parentSession = requireSession(params.parentSessionId); const workspacePath = params.workspacePath || parentSession.workspacePath; if (!workspacePath) { @@ -250,6 +205,7 @@ export function createTransientBtwSession(params: { const childSessionId = createBtwRequestId('btw_session'); const childSessionName = params.childSessionName.trim() || 'Side thread'; + const { parentDialogTurnId, parentTurnIndex } = getParentInterruptionContext(params.parentSessionId); flowChatStore.addExternalSession( childSessionId, @@ -261,24 +217,33 @@ export function createTransientBtwSession(params: { sessionKind: 'btw', btwOrigin: { parentSessionId: params.parentSessionId, + parentDialogTurnId, + parentTurnIndex, }, - isTransient: true, + isTransient: false, agentBackedTransient: false, }, parentSession.remoteConnectionId, parentSession.remoteSshHost ); - return { childSessionId }; + flowChatStore.updateSessionRelationship(childSessionId, { + parentSessionId: params.parentSessionId, + sessionKind: 'btw', + }); + + return { childSessionId, parentDialogTurnId, parentTurnIndex }; } -export async function sendMessageToTransientBtwSession(params: { +export async function sendMessageToBtwSession(params: { parentSessionId: string; childSessionId: string; question: string; childSessionName?: string; modelId?: string; imagePayload?: ImagePayload; + parentDialogTurnId?: string; + parentTurnIndex?: number; }): Promise<{ requestId: string }> { const question = params.question.trim(); if (!question) { @@ -287,8 +252,8 @@ export async function sendMessageToTransientBtwSession(params: { } const childSession = requireSession(params.childSessionId); - if (!isTransientBtwSession(childSession)) { - throw new Error(`Session is not a transient /btw session: ${params.childSessionId}`); + if (childSession.sessionKind !== 'btw' || childSession.isTransient) { + throw new Error(`Session is not a persistent /btw session: ${params.childSessionId}`); } const requestId = createBtwRequestId('btw'); @@ -296,29 +261,21 @@ export async function sendMessageToTransientBtwSession(params: { ...(childSession.btwOrigin || {}), requestId, parentSessionId: params.parentSessionId, + parentDialogTurnId: params.parentDialogTurnId ?? childSession.btwOrigin?.parentDialogTurnId, + parentTurnIndex: params.parentTurnIndex ?? childSession.btwOrigin?.parentTurnIndex, }, 'btw'); - const localTurnId = createPendingBtwTurn({ - childSessionId: params.childSessionId, + const modelId = params.modelId?.trim(); + await btwAPI.askStream({ requestId, + sessionId: params.parentSessionId, + childSessionId: params.childSessionId, + childSessionName: params.childSessionName || childSession.title || 'Side thread', question, - imagePayload: params.imagePayload, + ...(modelId ? { modelId } : {}), + parentDialogTurnId: params.parentDialogTurnId ?? childSession.btwOrigin?.parentDialogTurnId, + parentTurnIndex: params.parentTurnIndex ?? childSession.btwOrigin?.parentTurnIndex, + imageContexts: params.imagePayload?.imageContexts, }); - const modelId = params.modelId?.trim(); - try { - await btwAPI.askStream({ - requestId, - sessionId: params.parentSessionId, - childSessionId: params.childSessionId, - childSessionName: params.childSessionName || childSession.title || 'Side thread', - question, - ...(modelId ? { modelId } : {}), - imageContexts: params.imagePayload?.imageContexts, - }); - } catch (error) { - flowChatStore.deleteDialogTurn(params.childSessionId, localTurnId); - await stateMachineManager.transition(params.childSessionId, SessionExecutionEvent.FINISHING_SETTLED); - throw error; - } if (modelId) { flowChatStore.updateSessionModelName(params.childSessionId, modelId); } @@ -326,21 +283,6 @@ export async function sendMessageToTransientBtwSession(params: { return { requestId }; } -export async function cancelTransientBtwSession(sessionId: string): Promise { - const session = flowChatStore.getState().sessions.get(sessionId); - if (!session || !isTransientBtwSession(session)) { - return false; - } - - const requestId = session.btwOrigin?.requestId?.trim(); - if (!requestId) { - return false; - } - - await btwAPI.cancel({ requestId }); - return true; -} - export async function startBtwThread(params: { parentSessionId: string; workspacePath: string; @@ -354,21 +296,24 @@ export async function startBtwThread(params: { throw new Error('Empty /btw question'); } + await flowChatManager.ensureBackendSession(params.parentSessionId); const childSessionName = buildChildSessionName(question); - const { childSessionId } = createTransientBtwSession({ + const { childSessionId, parentDialogTurnId, parentTurnIndex } = createBtwSessionPlaceholder({ parentSessionId: params.parentSessionId, workspacePath: params.workspacePath, childSessionName, }); try { - const { requestId } = await sendMessageToTransientBtwSession({ + const { requestId } = await sendMessageToBtwSession({ parentSessionId: params.parentSessionId, childSessionId, question, childSessionName, modelId: params.modelId, imagePayload: params.imagePayload, + parentDialogTurnId, + parentTurnIndex, }); return { requestId, childSessionId }; } catch (error) { diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 35319ab36e..6ceff7faa9 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -49,6 +49,7 @@ import { updateImageAnalysisItem as updateImageAnalysisItemModule, updateSessionMetadata, } from './flow-chat-manager'; +import { ensureBackendSession } from './flow-chat-manager/SessionModule'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; const log = createLogger('FlowChatManager'); @@ -512,6 +513,11 @@ export class FlowChatManager { return removedSessionIds; } + /** Restores a persisted session into the coordinator before a non-message workflow uses it. */ + public async ensureBackendSession(sessionId: string): Promise { + await ensureBackendSession(this.context, sessionId); + } + public discardLocalSessionsForWorkspace( workspace: Pick ): string[] { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index e740cc72a8..6c85714f12 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -2,20 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cancelSessionTask, syncSessionModelSelection } from './MessageModule'; import { SessionExecutionEvent } from '../../state-machine/types'; -const mockCancelTransientBtwSession = vi.fn(); const mockTransition = vi.fn(); const mockUpdateSessionModel = vi.fn(); const mockGetConfigs = vi.fn(); -vi.mock('../BtwThreadService', () => ({ - cancelTransientBtwSession: (...args: any[]) => mockCancelTransientBtwSession(...args), - isTransientBtwSession: (session: any) => - session?.isTransient === true && - session?.sessionKind === 'btw' && - session?.agentBackedTransient !== true, - sendMessageToTransientBtwSession: vi.fn(), -})); - vi.mock('../../state-machine', () => ({ SessionExecutionEvent: { FINISHING_SETTLED: 'finishing_settled', @@ -55,16 +45,14 @@ vi.mock('../../../shared/notification-system', () => ({ describe('MessageModule cancellation', () => { beforeEach(() => { vi.clearAllMocks(); - mockCancelTransientBtwSession.mockResolvedValue(true); mockTransition.mockResolvedValue(true); }); - it('cancels transient /btw sessions locally and through the /btw API path', async () => { + it('cancels persistent /btw sessions through the ordinary dialog-turn path', async () => { const session = { sessionId: 'btw-child', - isTransient: true, + isTransient: false, sessionKind: 'btw', - agentBackedTransient: false, dialogTurns: [], config: {}, }; @@ -93,12 +81,11 @@ describe('MessageModule cancellation', () => { await expect(cancelSessionTask(context, 'btw-child')).resolves.toBe(true); - expect(mockStoreCancelSessionTask).toHaveBeenCalledWith('btw-child'); expect(mockTransition).toHaveBeenCalledWith( 'btw-child', - SessionExecutionEvent.FINISHING_SETTLED, + SessionExecutionEvent.USER_CANCEL, ); - expect(mockCancelTransientBtwSession).toHaveBeenCalledWith('btw-child'); + expect(mockStoreCancelSessionTask).not.toHaveBeenCalled(); expect(context.userCancelledSessionIds.has('btw-child')).toBe(true); expect(contentBuffers.has('btw-child')).toBe(false); expect(activeTextItems.has('btw-child')).toBe(false); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index d8f3ef7d02..659ca4ae62 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -21,11 +21,6 @@ import { FLOWCHAT_PIN_TURN_TO_TOP_EVENT, type FlowChatPinTurnToTopRequest, } from '../../events/flowchatNavigation'; -import { - cancelTransientBtwSession, - isTransientBtwSession, - sendMessageToTransientBtwSession, -} from '../BtwThreadService'; import { pendingQueueManager } from './PendingQueueModule'; const log = createLogger('MessageModule'); @@ -213,28 +208,6 @@ export async function sendMessage( throw new Error('Session history is still restoring, please retry once loading finishes'); } - if (isTransientBtwSession(refreshedSession)) { - const parentSessionId = refreshedSession.parentSessionId?.trim(); - if (!parentSessionId) { - throw new Error(`Transient /btw session is missing parentSessionId: ${sessionId}`); - } - - await sendMessageToTransientBtwSession({ - parentSessionId, - childSessionId: sessionId, - question: message, - childSessionName: refreshedSession.title, - modelId: refreshedSession.config.modelName, - imagePayload: options?.imageContexts - ? { - imageContexts: options.imageContexts, - imageDisplayData: options.imageDisplayData ?? [], - } - : undefined, - }); - return; - } - if (!acpClientId) { await ensureBackendSession(context, sessionId); } @@ -434,17 +407,6 @@ export async function cancelSessionTask(context: FlowChatContext, requestedSessi return false; } - const session = state.sessions.get(sessionId); - if (isTransientBtwSession(session)) { - context.userCancelledSessionIds.add(sessionId); - context.flowChatStore.cancelSessionTask(sessionId); - markCurrentTurnItemsAsCancelled(context, sessionId); - cleanupSessionBuffers(context, sessionId); - await stateMachineManager.transition(sessionId, SessionExecutionEvent.FINISHING_SETTLED); - const success = await cancelTransientBtwSession(sessionId); - return success; - } - const currentState = stateMachineManager.getCurrentState(sessionId); const success = currentState === SessionExecutionState.PROCESSING ? await stateMachineManager.transition(sessionId, SessionExecutionEvent.USER_CANCEL) diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 414a9c4cc2..9cb597f489 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -40,7 +40,6 @@ import type { SessionMetadataPage } from '@/infrastructure/api/service-api/Sessi import { deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, - isLegacyPersistedBtwSession, normalizeSessionRelationship, } from '../utils/sessionMetadata'; import type { SessionTitleDescriptor } from '../utils/sessionTitle'; @@ -3717,9 +3716,6 @@ export class FlowChatStore { if (existingSession) { return; } - if (isLegacyPersistedBtwSession(metadata)) { - return; - } // Skip archived sessions - they are managed in the settings page. if (metadata.status === 'archived') { return; @@ -4088,9 +4084,6 @@ export class FlowChatStore { if (existingSession) { return; } - if (isLegacyPersistedBtwSession(metadata)) { - return; - } // Skip archived sessions - they are managed in the settings page if (metadata.status === 'archived') { return; diff --git a/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts b/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts index 177ad58f41..2de140ccf1 100644 --- a/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionMetadata.test.ts @@ -9,6 +9,7 @@ vi.mock('@/infrastructure/i18n/core/I18nService', () => ({ })); import { + buildCreateSessionRelationship, buildSessionMetadata, deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, @@ -133,6 +134,20 @@ describe('sessionMetadata', () => { expect(metadata.customMetadata).toEqual({ unrelated: 'preserved', }); + expect(metadata.relationship).toMatchObject({ + kind: 'btw', + parentSessionId: 'parent-1', + parentRequestId: 'req-1', + parentDialogTurnId: 'turn-9', + parentTurnIndex: 9, + }); + expect(buildCreateSessionRelationship(session)).toMatchObject({ + kind: 'btw', + parentSessionId: 'parent-1', + parentRequestId: 'req-1', + parentDialogTurnId: 'turn-9', + parentTurnIndex: 9, + }); expect(metadata.lastFinishedAt).toBeNull(); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts index eb1bfbdfa4..19e3c85ec7 100644 --- a/src/web-ui/src/flow_chat/utils/sessionMetadata.ts +++ b/src/web-ui/src/flow_chat/utils/sessionMetadata.ts @@ -189,18 +189,6 @@ export function deriveSessionRelationshipFromMetadata( }); } -export function isLegacyPersistedBtwSession( - metadata?: Pick | null -): boolean { - const kind = normalizeSessionKind(metadata?.customMetadata?.kind); - if (kind === 'btw') { - return true; - } - - const tags = metadata?.tags; - return Array.isArray(tags) && tags.includes('btw'); -} - export function deriveLastFinishedAtFromMetadata( metadata?: Pick | null ): number | undefined { @@ -305,7 +293,7 @@ export function buildCreateSessionRelationship( ): SessionRelationship | undefined { const normalized = normalizeSessionRelationship(session); - if (normalized.sessionKind === 'normal' || normalized.sessionKind === 'btw') { + if (normalized.sessionKind === 'normal') { return undefined; } diff --git a/src/web-ui/src/infrastructure/api/service-api/BtwAPI.ts b/src/web-ui/src/infrastructure/api/service-api/BtwAPI.ts index 2d178d0b21..c88484a404 100644 --- a/src/web-ui/src/infrastructure/api/service-api/BtwAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/BtwAPI.ts @@ -9,6 +9,8 @@ export interface BtwAskStreamRequest { modelId?: string; childSessionId: string; childSessionName?: string; + parentDialogTurnId?: string; + parentTurnIndex?: number; imageContexts?: ImageInputContextData[]; } diff --git a/src/web-ui/src/infrastructure/config/components/MemoriesConfig.tsx b/src/web-ui/src/infrastructure/config/components/MemoriesConfig.tsx index 9b1574460a..f1d6c81bb7 100644 --- a/src/web-ui/src/infrastructure/config/components/MemoriesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/MemoriesConfig.tsx @@ -29,6 +29,7 @@ const log = createLogger('MemoriesConfig'); const DEFAULT_MEMORIES_CONFIG: MemoriesConfigShape = { generate_memories: true, + generate_for_btw_sessions: false, use_memories: true, external_context_policy: 'clear_tool_results', max_raw_memories_for_consolidation: 64, @@ -59,6 +60,7 @@ function normalizeMemoriesConfig(config: Partial | null | u }; return { generate_memories: normalized.generate_memories, + generate_for_btw_sessions: normalized.generate_for_btw_sessions, use_memories: normalized.use_memories, external_context_policy: normalized.external_context_policy, max_raw_memories_for_consolidation: normalized.max_raw_memories_for_consolidation, @@ -297,6 +299,19 @@ const MemoriesConfig: React.FC = () => { /> + + void updateConfig('generate_for_btw_sessions', event.target.checked)} + disabled={savingKey === 'generate_for_btw_sessions'} + size="small" + /> + +