Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ MCP 副本的连接状态不借用外部来源 activation,页面明确“尚
不支持的消息角色、工具状态、损坏记录和超出读取预算的历史明确失败,不以空历史伪装成功。

产品装配按来源选择 Codex 或 Zcode reader,复用工作区映射、原子写入及 Session owner;
续聊分支使用宿主当前配置的主模型。来源生态与 Session ID 共同参与匹配,避免跨生态混认。
续聊分支使用宿主当前配置的主模型,自动发现的来源视图以 `Standard` 作为原生续聊模式;模型选择值
`primary` 不作为会话模式。旧索引、快照及已有续聊分支中带 Codex/Zcode 来源身份的 `primary` 模式
在读取时兼容为 `Standard`,不重写原始历史;其他自定义模式和外部运行时绑定保持原样。
分叉后发送使用主机返回的分支模式,并保留用户另选的模式;旧失败队列仍携带 `primary` 时,重试使用
已修正分支的当前模式。来源生态与 Session ID 共同参与匹配,避免跨生态混认。
本功能开发阶段的手动列表、计划、执行和取消命令已随旧界面移除;当前入口使用原生会话列表、
历史读取和分叉协议。已有手动导入副本、旧索引及历史修复路径继续兼容,不删除用户数据。
Zcode 会话读取不意味着其 ACP 运行时、设置、Skill、MCP 或 Hook 已接入,其余类别仍显示真实支持状态。
Expand Down
104 changes: 104 additions & 0 deletions src/crates/assembly/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16526,6 +16526,110 @@ mod tests {
assert!(error.to_string().contains("candidate_unavailable"));
}

#[tokio::test]
async fn legacy_imported_primary_branches_resolve_real_turn_modes() {
use crate::agentic::persistence::SessionBranchRequest;
use openbitfun_services_core::session::{
DialogTurnData, DialogTurnKind, OfflineSessionBundle, OfflineSessionImportStore,
SessionBranchBoundary, SessionMetadata, TurnStatus, UserMessageData,
};

let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
std::fs::create_dir_all(&project).unwrap();
let workspace =
crate::service::workspace::legacy_compat::register_local_fixture_blocking(&project);
let paths = Arc::new(PathManager::with_user_root_for_tests(
temp.path().join("user"),
));
let root = paths.project_sessions_dir(&workspace.root_path);
let persistence = PersistenceManager::new(paths).unwrap();
let store = OfflineSessionImportStore::new(&root);
for ecosystem in ["codex", "zcode"] {
let id = format!("legacy-{ecosystem}-branch");
let turn_id = format!("{id}-turn-0");
let metadata: SessionMetadata = serde_json::from_value(serde_json::json!({
"session_id": id, "session_name": "Existing native branch", "agent_type": "primary", "model_name": "primary",
"created_at": 1, "last_active_at": 2, "turn_count": 1, "message_count": 1, "tool_call_count": 0, "status": "completed",
"workspace_id": workspace.id, "workspace_path": workspace.root_path,
"project_workspace_id": workspace.id, "project_workspace_path": workspace.root_path,
"custom_metadata": {"externalBranchOf": {"sessionId": "source-view", "ecosystemId": ecosystem, "sourceSessionId": "source", "contentVersion": "pinned"}}
})).unwrap();
let mut turn = DialogTurnData::new_with_kind(
DialogTurnKind::UserDialog,
turn_id.clone(),
0,
id.clone(),
Some("primary".into()),
UserMessageData {
id: format!("{id}-user"),
content: "Preserved imported question".into(),
timestamp: 1,
metadata: None,
},
);
turn.status = TurnStatus::Completed;
store
.write_bundle_atomic(&OfflineSessionBundle {
metadata,
turns: vec![turn],
})
.await
.unwrap();
let metadata_path = root.join(&id).join("metadata.json");
let before = std::fs::read(&metadata_path).unwrap();
let restored = persistence
.load_session(&workspace.root_path, &id)
.await
.unwrap();

let admitted = ConversationCoordinator::resolve_session_agent_for_turn(
&restored,
&restored.agent_type,
&None,
)
.await
.expect("legacy native branches must pass the same mode admission as a message send");
assert_eq!(admitted.runtime_agent_key, "Standard");
assert_eq!(restored.config.model_id.as_deref(), Some("primary"));
assert_eq!(std::fs::read(&metadata_path).unwrap(), before);

let branch = persistence
.branch_session(
&workspace.root_path,
&SessionBranchRequest {
source_session_id: id.clone(),
source_turn_id: turn_id,
boundary: SessionBranchBoundary::ThroughTurn,
},
)
.await
.unwrap();
let continued = persistence
.load_session(&workspace.root_path, &branch.session_id)
.await
.unwrap();
let admitted = ConversationCoordinator::resolve_session_agent_for_turn(
&continued,
&continued.agent_type,
&None,
)
.await
.expect("forking an existing legacy branch must retain a valid runtime mode");
assert_eq!(admitted.runtime_agent_key, "Standard");
let copied = store
.load_bundle(&branch.session_id)
.await
.unwrap()
.unwrap();
assert_eq!(
copied.turns[0].user_message.content,
"Preserved imported question"
);
assert_eq!(copied.turns[0].agent_type.as_deref(), Some("primary"));
}
}

#[tokio::test]
async fn subagent_conversation_resolves_legacy_children_on_local_and_remote_workspaces() {
for agent_type in ["GeneralPurpose", "Explore", "Standard"] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ async fn read_index(root: &Path) -> OpenBitFunResult<Arc<Index>> {
return Ok(index.clone());
}
}
let index: Index = JsonFileStore
let mut index: Index = JsonFileStore
.read_optional(&path)
.await
.map_err(index_error)?
Expand All @@ -125,6 +125,10 @@ async fn read_index(root: &Path) -> OpenBitFunResult<Arc<Index>> {
"This external history index requires a newer OpenBitFun host",
));
}
// Read compatibility must not rewrite the user's index or frozen history.
for entry in index.entries.values_mut() {
entry.metadata.normalize_legacy_imported_agent_type();
}
let index = Arc::new(index);
cache.insert(path, (stamp, index.clone()));
Ok(index)
Expand Down Expand Up @@ -443,7 +447,7 @@ pub(crate) async fn register(
.title
.clone()
.unwrap_or_else(|| "Imported session".into()),
"primary".into(),
"Standard".into(),
"primary".into(),
);
metadata.workspace_id = Some(workspace.id.clone());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,18 @@ fn unedited_prefix(
if before.metadata.status == SessionStatus::Active || before.turns.len() > after.turns.len() {
return Ok(false);
}
let mut compatible = before.metadata.clone();
compatible.normalize_legacy_imported_agent_type();
for (old, new) in before.turns.iter().zip(&after.turns) {
let mut old = old.clone();
// The retired imported placeholder and its corrected native mode are
// equivalent for comparison only. Keep the persisted history intact,
// and still detect any other mode or content edit as a local change.
if old.agent_type.as_deref() == Some(before.metadata.agent_type.as_str())
&& new.agent_type.as_deref() == Some(compatible.agent_type.as_str())
{
old.agent_type = new.agent_type.clone();
}
// A downgraded host may retain a view's versioned turn identity.
if old.turn_id
== format!(
Expand Down Expand Up @@ -172,6 +182,51 @@ fn unedited_prefix(
Ok(true)
}

#[cfg(test)]
mod tests {
use super::super::tests::fixture;
use super::*;
use crate::external_session_import::auto_discovery::tests::{candidate, document};

#[tokio::test]
async fn legacy_mode_comparison_preserves_history_and_detects_real_edits() {
let (_temp, workspace, _root, _manager) = fixture();
for ecosystem in ["codex", "zcode"] {
let source = document(candidate(ecosystem, "source-1", &workspace.root_path));
let convert = |mode| {
convert_document(
"legacy",
&workspace,
&workspace,
mode,
"Old import",
&source,
true,
true,
)
.unwrap()
};
let before = convert("primary");
let after = convert("Standard");
let original = serde_json::to_value(&before).unwrap();
assert!(unedited_prefix(&before, &after).unwrap());
assert_eq!(serde_json::to_value(&before).unwrap(), original);

let mut edited = before.clone();
edited.turns[0].agent_type = Some("Creative".into());
assert!(!unedited_prefix(&edited, &after).unwrap());

let mut edited = before.clone();
edited.turns[0].user_message.content = "Local prompt edit".into();
assert!(!unedited_prefix(&edited, &after).unwrap());

let mut unknown = before;
unknown.metadata.custom_metadata = None;
assert!(!unedited_prefix(&unknown, &after).unwrap());
}
}
}

/// Called only when opening one source, never while discovering/listing it.
pub(super) async fn validate_native(
root: &Path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ fn validate_snapshot(
}

fn overlay_view_metadata(metadata: &mut SessionMetadata, entry: &Reference) {
metadata.normalize_legacy_imported_agent_type();
// Names and archive state belong to the local view, not to a frozen version.
metadata.session_name = entry.metadata.session_name.clone();
metadata.tags = entry.metadata.tags.clone();
Expand Down Expand Up @@ -689,6 +690,89 @@ mod tests {
assert!(!root.join(&entry.metadata.session_id).exists());
}

#[tokio::test]
async fn legacy_primary_source_snapshots_fork_with_a_valid_mode_without_rewriting_history() {
let (_temp, workspace, root, manager) = fixture();
let entry = create_source(&root, &workspace).await;
assert_eq!(entry.metadata.agent_type, "Standard");
assert_eq!(entry.metadata.model_name, "primary");
let id = &entry.metadata.session_id;
publish(
&root,
&workspace,
&entry,
document(candidate("codex", "source-1", &workspace.root_path)),
)
.await;
let current = reference(&root, id).await.unwrap().unwrap();
let version = content_version(&current.metadata).unwrap();
let path = snapshot_path(&root, id, version).unwrap();
let mut frozen: Snapshot = JsonFileStore.read_optional(&path).await.unwrap().unwrap();
// A previous host wrote the model selector into both metadata and turns.
frozen.bundle.metadata.agent_type = "primary".into();
frozen.bundle.turns[0].agent_type = Some("primary".into());
JsonFileStore
.write_atomic_strict(&path, &frozen)
.await
.unwrap();
update_index(&root, |index| {
index.entries.get_mut(id).unwrap().metadata.agent_type = "primary".into();
((), true)
})
.await
.unwrap();
let original_history = std::fs::read(&path).unwrap();
let original_index = std::fs::read(root.join(INDEX_FILE)).unwrap();

assert_eq!(
metadata(&root, id).await.unwrap().unwrap().agent_type,
"Standard"
);
assert_eq!(
merge_metadata(&root, vec![]).await.unwrap()[0].agent_type,
"Standard"
);
let viewed = snapshot(&root, id, false).await.unwrap().unwrap();
assert_eq!(viewed.metadata.agent_type, "Standard");
assert_eq!(viewed.metadata.model_name, "primary");
assert_eq!(viewed.turns[0].agent_type.as_deref(), Some("primary"));
let branch_source = manager
.external_history_branch_source(
&workspace.root_path,
id,
Some(&viewed.turns[0].turn_id),
)
.await
.unwrap()
.unwrap();
let branch = manager
.branch_transient_session(
&workspace.root_path,
&SessionBranchRequest {
source_session_id: id.clone(),
source_turn_id: viewed.turns[0].turn_id.clone(),
boundary: SessionBranchBoundary::ThroughTurn,
},
branch_source,
)
.await
.unwrap();
assert_eq!(branch.agent_type, "Standard");
let native = OfflineSessionImportStore::new(&root)
.load_bundle(&branch.session_id)
.await
.unwrap()
.unwrap();
assert_eq!(native.metadata.agent_type, "Standard");
assert_eq!(native.turns[0].agent_type.as_deref(), Some("primary"));
assert_eq!(native.turns[0].user_message.content, "Saved question");
assert_eq!(std::fs::read(&path).unwrap(), original_history);
assert_eq!(
std::fs::read(root.join(INDEX_FILE)).unwrap(),
original_index
);
}

#[tokio::test]
async fn append_rewrite_and_source_loss_preserve_viewed_versions_without_native_copies() {
let (_temp, workspace, root, manager) = fixture();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,32 @@ async fn history_cache_keeps_legacy_snapshot_and_window_shapes_readable() {
.await
.unwrap();
update_index(&root, |index| {
index
.entries
.get_mut(id)
.unwrap()
.metadata
.custom_metadata
.as_mut()
.unwrap()[VERSION] = json!(version);
let metadata = &mut index.entries.get_mut(id).unwrap().metadata;
metadata.agent_type = "primary".into();
metadata.custom_metadata.as_mut().unwrap()[VERSION] = json!(version);
((), true)
})
.await
.unwrap();
let bytes = std::fs::read(&path).unwrap();
let index_bytes = std::fs::read(root.join(INDEX_FILE)).unwrap();
assert_eq!(
metadata(&root, id).await.unwrap().unwrap().agent_type,
"Standard"
);
assert_eq!(
merge_metadata(&root, vec![]).await.unwrap()[0].agent_type,
"Standard"
);
let probe = probes::Probe::new(&root);
let cold = snapshot_shared(&root, id, false).await.unwrap().unwrap();
let warm = snapshot_shared(&root, id, false).await.unwrap().unwrap();
for snapshot in [&cold, &warm] {
assert_eq!(snapshot.metadata.agent_type, "Standard");
assert_eq!(snapshot.metadata.model_name, "primary");
}
assert!(Arc::ptr_eq(&cold.history, &warm.history));
assert_eq!(probe.take().snapshot_reads, 1);
let request = SessionTurnWindowRequest {
workspace_path: workspace.root_path.clone(),
session_id: id.clone(),
Expand Down Expand Up @@ -217,6 +230,7 @@ async fn history_cache_keeps_legacy_snapshot_and_window_shapes_readable() {
assert!(
matches!(missing, SessionTurnWindowResponse::NotFound { catalog } if catalog.total_turn_count == 1)
);
assert_eq!(std::fs::read(root.join(INDEX_FILE)).unwrap(), index_bytes);
}

#[tokio::test]
Expand Down
Loading
Loading