use std::collections::HashMap; use ai::api_keys::ApiKeyManager; use galaxy_core::features::FeatureFlag; use warp_multi_agent_api as api; use warpui::{App, SingletonEntity}; use super::{ artifact_from_fork_proto, footer_model_token_usage, AIConversation, AIConversationAutoexecuteMode, AIConversationId, ConversationStatus, RestoreConversationError, }; use crate::ai::artifacts::Artifact; use crate::ai::llms::LLMPreferences; use crate::auth::auth_manager::AuthManager; use crate::auth::AuthStateProvider; use crate::network::NetworkStatus; use crate::persistence::model::AgentConversationData; use crate::server::server_api::ServerApiProvider; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::user_workspaces::UserWorkspaces; fn restored_conversation(conversation_data: Option) -> AIConversation { AIConversation::new_restored( AIConversationId::new(), vec![api::Task { id: "root-task".to_string(), messages: vec![], dependencies: None, description: String::new(), summary: String::new(), server_data: String::new(), }], conversation_data, ) .unwrap() } fn restored_conversation_with_root_description(description: &str) -> AIConversation { AIConversation::new_restored( AIConversationId::new(), vec![api::Task { id: "root-task".to_string(), messages: vec![], dependencies: None, description: description.to_string(), summary: String::new(), server_data: String::new(), }], None, ) .unwrap() } fn user_query_message(id: &str, request_id: &str, query: &str) -> api::Message { api::Message { fetched_memories: vec![], id: id.to_string(), task_id: "root-task".to_string(), server_message_data: String::new(), citations: vec![], message: Some(api::message::Message::UserQuery(api::message::UserQuery { query: query.to_string(), context: None, referenced_attachments: HashMap::new(), mode: None, intended_agent: Default::default(), })), request_id: request_id.to_string(), timestamp: None, } } fn agent_output_message(id: &str, request_id: &str) -> api::Message { api::Message { fetched_memories: vec![], id: id.to_string(), task_id: "root-task".to_string(), server_message_data: String::new(), citations: vec![], message: Some(api::message::Message::AgentOutput( api::message::AgentOutput { text: "Done".to_string(), }, )), request_id: request_id.to_string(), timestamp: None, } } fn restored_conversation_with_queries(queries: &[&str]) -> AIConversation { let messages = queries .iter() .enumerate() .flat_map(|(index, query)| { let request_id = format!("request-{index}"); [ user_query_message(&format!("user-{index}"), &request_id, query), agent_output_message(&format!("agent-{index}"), &request_id), ] }) .collect(); AIConversation::new_restored( AIConversationId::new(), vec![api::Task { id: "root-task".to_string(), messages, dependencies: None, description: String::new(), summary: String::new(), server_data: String::new(), }], None, ) .unwrap() } fn initialize_custom_endpoint_usage_test_app(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_| ServerApiProvider::new_for_test()); app.add_singleton_model(|_| NetworkStatus::new()); app.add_singleton_model(UserWorkspaces::default_mock); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AuthManager::new_for_test); } #[allow(deprecated)] fn custom_endpoint_usage_metadata( config_key: &str, total_tokens: u32, ) -> api::response_event::stream_finished::ConversationUsageMetadata { let category = "primary_agent".to_string(); api::response_event::stream_finished::ConversationUsageMetadata { context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, summarized: false, token_usage: vec![], tool_usage_metadata: None, total_input_tokens: 0, warp_token_usage: HashMap::new(), byok_token_usage: HashMap::new(), context_window_segments: Vec::new(), custom_endpoint_token_usage: HashMap::from([( config_key.to_string(), api::response_event::stream_finished::ModelTokenUsage { model_id: config_key.to_string(), total_tokens, token_usage_by_category: HashMap::from([(category, total_tokens)]), }, )]), } } #[test] fn latest_user_query_returns_latest_non_empty_user_query() { let conversation = restored_conversation_with_queries(&["write unit tests", "fix the failing test"]); assert_eq!( conversation.latest_user_query(), Some("fix the failing test".to_string()) ); } #[test] fn latest_user_query_trims_and_skips_empty_queries() { let conversation = restored_conversation_with_queries(&[" write unit tests ", " "]); assert_eq!( conversation.latest_user_query(), Some("write unit tests".to_string()) ); } #[test] fn title_uses_root_task_description() { let conversation = restored_conversation_with_root_description("Root task title"); assert_eq!(conversation.title().as_deref(), Some("Root task title")); } #[test] fn title_falls_back_to_initial_query_when_root_description_is_empty() { let conversation = restored_conversation_with_queries(&["Initial query"]); assert_eq!(conversation.title().as_deref(), Some("Initial query")); } #[test] fn reassign_exchange_ids_keeps_exchange_lookup_consistent() { let mut conversation = restored_conversation_with_queries(&["one", "two"]); let old_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect(); assert!(!old_ids.is_empty()); // Pre-condition: every original id resolves via the exchange-id index. for id in &old_ids { assert!(conversation.exchange_with_id(*id).is_some()); } conversation.reassign_exchange_ids(); // Reassigning regenerates ids without changing the exchange count, so // `modify_task` does not rebuild the index; correctness relies on the // explicit `rebuild_exchange_id_index()` call. The stale ids must be gone. for id in &old_ids { assert!(conversation.exchange_with_id(*id).is_none()); } // Every current id resolves via the rebuilt index. let new_ids: Vec<_> = conversation.all_exchanges().iter().map(|e| e.id).collect(); assert_eq!(new_ids.len(), old_ids.len()); for id in &new_ids { assert!(conversation.exchange_with_id(*id).is_some()); } } #[test] fn restored_conversation_defaults_autoexecute_override_when_not_persisted() { let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true); let conversation_data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null}"#).unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!( conversation.autoexecute_override(), AIConversationAutoexecuteMode::RespectUserSettings ); } #[test] fn restored_conversation_uses_persisted_last_event_sequence() { let conversation_data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null,"last_event_sequence":42}"#) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!(conversation.last_event_sequence(), Some(42)); } #[test] fn restored_conversation_uses_persisted_remote_child_marker() { let conversation_data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null,"is_remote_child":true}"#) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert!(conversation.is_remote_child()); } #[test] fn child_conversation_detection_uses_parent_agent_id() { let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"parent_agent_id":"parent-run-id"}"#, ) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert!(conversation.is_child_agent_conversation()); assert_eq!(conversation.parent_conversation_id(), None); } /// When the persisted task list is empty (e.g. a child conversation persisted /// before any server response), restoring via `new_restored_synthesizing_on_empty` /// must produce a fresh in-progress optimistic root, mirroring /// `AIConversation::new()`. #[test] fn restored_conversation_with_empty_task_list_creates_in_progress_optimistic_root() { let conversation = AIConversation::new_restored_synthesizing_on_empty(AIConversationId::new(), vec![], None) .expect("empty task list must synthesize an optimistic root"); let root_task = conversation .get_root_task() .expect("synthesized root task should exist"); assert!(root_task.is_root_task()); assert!( root_task.source().is_none(), "synthesized root is optimistic and has no api::Task source" ); assert!( !root_task.id().to_string().is_empty(), "synthesized optimistic root must have a non-empty UUID id" ); assert_eq!(conversation.status(), &ConversationStatus::InProgress); assert!(conversation.status_error_message().is_none()); } #[test] fn update_cost_and_usage_resolves_custom_endpoint_alias_for_footer_usage() { App::test((), |mut app| async move { initialize_custom_endpoint_usage_test_app(&mut app); ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| { manager.add_custom_endpoint( "Endpoint".to_string(), "https://custom.example".to_string(), "key".to_string(), vec![( "raw-model".to_string(), Some("Friendly alias".to_string()), Some("config-key".to_string()), )], ctx, ); }); app.add_singleton_model(LLMPreferences::new); let mut conversation = AIConversation::new(false, false); app.read(|ctx| { conversation .update_cost_and_usage_for_request( None, vec![], Some(custom_endpoint_usage_metadata("config-key", 6)), false, ctx, ) .expect("custom endpoint usage should update"); }); let usage = conversation .token_usage() .iter() .find(|usage| usage.model_id == "Friendly alias") .expect("custom endpoint alias should resolve into footer usage"); assert_eq!(usage.custom_endpoint_tokens, 6); assert_eq!(usage.byok_tokens, 0); assert_eq!( usage .custom_endpoint_token_usage_by_category .get("primary_agent"), Some(&6) ); }); } #[test] fn update_cost_and_usage_uses_fallback_label_for_unknown_custom_endpoint() { App::test((), |mut app| async move { initialize_custom_endpoint_usage_test_app(&mut app); app.add_singleton_model(LLMPreferences::new); let mut conversation = AIConversation::new(false, false); app.read(|ctx| { conversation .update_cost_and_usage_for_request( None, vec![], Some(custom_endpoint_usage_metadata("missing-config-key", 9)), false, ctx, ) .expect("fallback custom endpoint usage should update"); }); let usage = conversation .token_usage() .iter() .find(|usage| usage.model_id == "Custom endpoint") .expect("unknown custom endpoint usage should use the fallback label"); assert_eq!(usage.custom_endpoint_tokens, 9); assert_eq!(usage.byok_tokens, 0); assert_eq!( usage .custom_endpoint_token_usage_by_category .get("primary_agent"), Some(&9) ); }); } #[allow(deprecated)] #[test] fn footer_model_token_usage_keeps_custom_endpoint_usage_distinct_from_same_labeled_models() { App::test((), |mut app| async move { initialize_custom_endpoint_usage_test_app(&mut app); ApiKeyManager::handle(&app).update(&mut app, |manager, ctx| { manager.add_custom_endpoint( "Endpoint".to_string(), "https://custom.example".to_string(), "key".to_string(), vec![( "raw-model".to_string(), Some("Resolved custom".to_string()), Some("config-key".to_string()), )], ctx, ); }); app.add_singleton_model(LLMPreferences::new); let category = "primary_agent".to_string(); let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata { context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, summarized: false, #[allow(deprecated)] token_usage: vec![], tool_usage_metadata: None, total_input_tokens: 0, warp_token_usage: HashMap::new(), byok_token_usage: HashMap::from([( "Resolved custom".to_string(), api::response_event::stream_finished::ModelTokenUsage { model_id: "Resolved custom".to_string(), total_tokens: 4, token_usage_by_category: HashMap::from([(category.clone(), 4)]), }, )]), custom_endpoint_token_usage: HashMap::from([( "config-key".to_string(), api::response_event::stream_finished::ModelTokenUsage { model_id: "config-key".to_string(), total_tokens: 6, token_usage_by_category: HashMap::from([(category.clone(), 6)]), }, )]), context_window_segments: Vec::new(), }; let model_usage = app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx))); let byok_usage = model_usage .iter() .find(|usage| usage.model_id == "Resolved custom" && usage.byok_tokens == 4) .expect("existing model usage should be present"); let custom_usage = model_usage .iter() .find(|usage| usage.model_id == "Resolved custom" && usage.custom_endpoint_tokens == 6) .expect("custom endpoint usage should remain distinct"); assert_eq!(model_usage.len(), 2); assert_eq!( byok_usage.byok_token_usage_by_category.get(&category), Some(&4) ); assert_eq!( custom_usage .custom_endpoint_token_usage_by_category .get(&category), Some(&6) ); assert_eq!(byok_usage.warp_tokens, 0); assert_eq!(custom_usage.warp_tokens, 0); assert_eq!(custom_usage.byok_tokens, 0); }); } #[allow(deprecated)] #[test] fn footer_model_token_usage_preserves_unresolved_custom_endpoint_usage_with_fallback_label() { App::test((), |mut app| async move { initialize_custom_endpoint_usage_test_app(&mut app); app.add_singleton_model(LLMPreferences::new); let category = "primary_agent".to_string(); let usage_metadata = api::response_event::stream_finished::ConversationUsageMetadata { context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, summarized: false, #[allow(deprecated)] token_usage: vec![], tool_usage_metadata: None, total_input_tokens: 0, warp_token_usage: HashMap::new(), byok_token_usage: HashMap::new(), custom_endpoint_token_usage: HashMap::from([( "missing-config-key".to_string(), api::response_event::stream_finished::ModelTokenUsage { model_id: "missing-config-key".to_string(), total_tokens: 9, token_usage_by_category: HashMap::from([(category.clone(), 9)]), }, )]), context_window_segments: Vec::new(), }; let model_usage = app.read(|ctx| footer_model_token_usage(&usage_metadata, LLMPreferences::as_ref(ctx))); let custom_usage = model_usage .iter() .find(|usage| usage.model_id == "Custom endpoint") .expect("fallback custom endpoint usage should be present"); assert_eq!(model_usage.len(), 1); assert_eq!(custom_usage.custom_endpoint_tokens, 9); assert_eq!(custom_usage.byok_tokens, 0); assert_eq!( custom_usage .custom_endpoint_token_usage_by_category .get(&category), Some(&9) ); assert_eq!(custom_usage.warp_tokens, 0); }); } /// The legacy `AgentConversationData.root_task_is_optimistic` flag must be /// ignored on restore. A non-empty task list always produces a real /// server-backed root regardless of whether the flag is set. #[test] fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_non_empty_tasks() { let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#, ) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); let root_task = conversation .get_root_task() .expect("root task should exist"); assert_eq!(root_task.id().to_string(), "root-task"); assert!(root_task.is_root_task()); assert!( root_task.source().is_some(), "with a real task list, the legacy optimistic flag must be ignored", ); } /// The legacy `root_task_is_optimistic` flag is ignored when restoring an /// empty task list via `new_restored_synthesizing_on_empty`. #[test] fn restored_conversation_ignores_legacy_root_task_is_optimistic_flag_with_empty_tasks() { let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"root_task_is_optimistic":true}"#, ) .unwrap(); let conversation = AIConversation::new_restored_synthesizing_on_empty( AIConversationId::new(), vec![], Some(conversation_data), ) .expect("empty task list must synthesize an optimistic root regardless of legacy flag"); let root_task = conversation .get_root_task() .expect("synthesized root task should exist"); assert!(root_task.is_root_task()); assert!(root_task.source().is_none()); assert_eq!(conversation.status(), &ConversationStatus::InProgress); } /// Strict `new_restored` returns `NoRootTask` for an empty task list. #[test] fn new_restored_with_empty_task_list_returns_no_root_task_error() { let result = AIConversation::new_restored(AIConversationId::new(), vec![], None); assert!( matches!(result, Err(RestoreConversationError::NoRootTask)), "empty task list via strict new_restored must return NoRootTask; got {result:?}", ); } /// When multiple parentless tasks exist (e.g. a legacy orphan optimistic /// stub alongside the real server root), `new_restored` must prefer the /// candidate whose `messages` is non-empty. Each ordering runs in a loop to /// surface any nondeterminism in candidate selection. #[test] fn test_new_restored_prefers_parentless_task_with_messages_over_empty_stub() { let stub = api::Task { id: "optimistic-stub-uuid".to_string(), messages: vec![], dependencies: None, description: String::new(), summary: String::new(), server_data: String::new(), }; let real = api::Task { id: "server-root-id".to_string(), messages: vec![user_query_message("user-msg", "request-1", "real query")], dependencies: None, description: String::new(), summary: String::new(), server_data: String::new(), }; // Stub appears first in the vec. for _ in 0..50 { let conversation = AIConversation::new_restored( AIConversationId::new(), vec![stub.clone(), real.clone()], None, ) .expect("restore with stub + real parentless tasks must succeed"); let root_task = conversation .get_root_task() .expect("restored conversation must have a root task"); assert_eq!( root_task.id().to_string(), "server-root-id", "expected the real (non-empty) parentless task to win when stub is first", ); let source = root_task .source() .expect("chosen root must have api::Task source"); assert!( !source.messages.is_empty(), "chosen root must have non-empty messages", ); } // Real appears first in the vec. for _ in 0..50 { let conversation = AIConversation::new_restored( AIConversationId::new(), vec![real.clone(), stub.clone()], None, ) .expect("restore with real + stub parentless tasks must succeed"); let root_task = conversation .get_root_task() .expect("restored conversation must have a root task"); assert_eq!( root_task.id().to_string(), "server-root-id", "expected the real (non-empty) parentless task to win when real is first", ); let source = root_task .source() .expect("chosen root must have api::Task source"); assert!( !source.messages.is_empty(), "chosen root must have non-empty messages", ); } } #[test] fn cli_agent_transcript_vehicle_is_excluded_from_navigation() { let conversation = AIConversation::new(false, true); assert!(conversation.should_exclude_from_navigation()); } #[test] fn restored_conversation_defaults_unknown_persisted_autoexecute_override() { let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true); let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"autoexecute_override":"UnexpectedValue"}"#, ) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!( conversation.autoexecute_override(), AIConversationAutoexecuteMode::RespectUserSettings ); } #[test] fn restored_conversation_uses_persisted_autoexecute_override_when_enabled() { let _flag = FeatureFlag::RememberFastForwardState.override_enabled(true); let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"autoexecute_override":"RunToCompletion"}"#, ) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!( conversation.autoexecute_override(), AIConversationAutoexecuteMode::RunToCompletion ); } #[test] fn restored_conversation_ignores_persisted_autoexecute_override_when_disabled() { let _flag = FeatureFlag::RememberFastForwardState.override_enabled(false); let conversation_data: AgentConversationData = serde_json::from_str( r#"{"server_conversation_token":null,"autoexecute_override":"RunToCompletion"}"#, ) .unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!( conversation.autoexecute_override(), AIConversationAutoexecuteMode::RespectUserSettings ); } #[test] fn fork_artifacts_adds_file_artifacts_to_conversation() { let proto_artifact = api::message::artifact_event::ConversationArtifact { artifact: Some( api::message::artifact_event::conversation_artifact::Artifact::File( api::message::artifact_event::FileArtifact { artifact_uid: "artifact-file-1".to_string(), filepath: "outputs/report.txt".to_string(), mime_type: "text/plain".to_string(), size_bytes: 42, description: "Daily summary".to_string(), }, ), ), }; assert_eq!( artifact_from_fork_proto(&proto_artifact), Some(Artifact::File { artifact_uid: "artifact-file-1".to_string(), filepath: "outputs/report.txt".to_string(), filename: "report.txt".to_string(), mime_type: "text/plain".to_string(), description: Some("Daily summary".to_string()), size_bytes: Some(42), }) ); } #[test] fn waiting_for_events_display_label_is_waiting() { assert_eq!( format!("{}", ConversationStatus::WaitingForEvents), "Waiting" ); } /// `is_done` returns true only for `Success | Error | Cancelled`; /// `WaitingForEvents` and `Blocked` are not done because the run can still /// resume on its own. #[test] fn is_done_only_includes_success_error_cancelled() { assert!(ConversationStatus::Success.is_done()); assert!(ConversationStatus::Error.is_done()); assert!(ConversationStatus::Cancelled.is_done()); assert!(!ConversationStatus::InProgress.is_done()); assert!(!ConversationStatus::Blocked { blocked_action: "approve".to_string() } .is_done()); assert!(!ConversationStatus::WaitingForEvents.is_done()); } /// `is_waiting_for_events` is true only for the new variant. #[test] fn is_waiting_for_events_returns_true_only_for_waiting_for_events_variant() { assert!(ConversationStatus::WaitingForEvents.is_waiting_for_events()); assert!(!ConversationStatus::InProgress.is_waiting_for_events()); assert!(!ConversationStatus::Success.is_waiting_for_events()); assert!(!ConversationStatus::Error.is_waiting_for_events()); assert!(!ConversationStatus::Cancelled.is_waiting_for_events()); assert!(!ConversationStatus::Blocked { blocked_action: "approve".to_string() } .is_waiting_for_events()); } /// A conversation that was yielded via `wait_for_events` at shutdown /// restores as whatever `derive_status_from_root_task` returns (Success /// for a cleanly-streamed last exchange). The unresolved tool call stays /// in the transcript as an orphan; the next outbound request triggers /// the server's existing supersede mechanism to synthesize the matching /// `Cancel`. The waiting state itself is not durable across restart. #[test] fn restored_conversation_does_not_re_enter_waiting_for_events() { let conversation_data: AgentConversationData = serde_json::from_str(r#"{"server_conversation_token":null}"#).unwrap(); let conversation = restored_conversation(Some(conversation_data)); assert_eq!(conversation.status(), &ConversationStatus::Success); } fn fetched_memory( memory_id: &str, content: &str, memory_store_id: &str, source: Option, ) -> api::message::FetchedMemory { api::message::FetchedMemory { memory_id: memory_id.to_string(), content: content.to_string(), memory_store_id: memory_store_id.to_string(), source, } } fn conversation_source(conversation_id: &str) -> Option { Some(api::message::fetched_memory::Source::Conversation( api::message::fetched_memory::Conversation { conversation_id: conversation_id.to_string(), }, )) } fn restored_conversation_with_memories_per_query( memories_per_query: Vec>, ) -> AIConversation { let messages = memories_per_query .into_iter() .enumerate() .flat_map(|(index, memories)| { let request_id = format!("request-{index}"); let query = api::Message { fetched_memories: memories, ..user_query_message(&format!("user-{index}"), &request_id, "query") }; [ query, agent_output_message(&format!("agent-{index}"), &request_id), ] }) .collect(); AIConversation::new_restored( AIConversationId::new(), vec![api::Task { id: "root-task".to_string(), messages, ..Default::default() }], None, ) .unwrap() } #[test] fn fetched_memories_is_empty_when_no_message_has_memories() { let conversation = restored_conversation_with_memories_per_query(vec![vec![]]); assert_eq!(conversation.fetched_memories(), vec![]); } #[test] fn fetched_memories_preserves_order_across_and_within_messages() { let conversation = restored_conversation_with_memories_per_query(vec![ vec![ fetched_memory("m1", "first", "store-1", None), fetched_memory("m2", "second", "store-1", None), ], vec![fetched_memory("m3", "third", "store-2", None)], ]); let ids: Vec = conversation .fetched_memories() .into_iter() .map(|memory| memory.memory_id) .collect(); assert_eq!(ids, vec!["m1", "m2", "m3"]); } #[test] fn fetched_memories_dedupes_keeping_first_position_and_latest_data() { let conversation = restored_conversation_with_memories_per_query(vec![ vec![ fetched_memory("m1", "old content", "store-1", None), fetched_memory("m2", "other", "store-1", None), ], vec![ fetched_memory( "m1", "new content", "store-1", conversation_source("conversation-1"), ), fetched_memory("m1", "same memory id different store", "store-2", None), ], ]); let memories = conversation.fetched_memories(); assert_eq!( memories, vec![ fetched_memory( "m1", "new content", "store-1", conversation_source("conversation-1"), ), fetched_memory("m2", "other", "store-1", None), fetched_memory("m1", "same memory id different store", "store-2", None), ] ); }