完成 DirectProject 扩展导入阶段二
接入客户端已启用 Skill 的隔离运行时副本 将重命名同步到运行时 frontmatter 并保留原始来源 把客户端 Skill 集合纳入 app-server 连接池指纹 补充 Skill 运行时复制和指纹定向测试 更新 DirectProject 扩展阶段文档状态
This commit is contained in:
@@ -570,7 +570,7 @@ struct CodexAppServerInner {
|
||||
workspace_mode: CodexAppServerWorkspaceMode,
|
||||
_provider_proxy: Option<CodexProviderProxy>,
|
||||
tool_bridge: Option<DirectToolBridge>,
|
||||
_skill_root: Option<std::path::PathBuf>,
|
||||
_skill_roots: Option<Vec<std::path::PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -615,6 +615,12 @@ fn game_creator_codex_app_server_pool_key(
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let client_skill_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
crate::client_extensions::enabled_client_skill_fingerprint()
|
||||
.unwrap_or_else(|_| "invalid-client-skills".to_string())
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
};
|
||||
let stable = serde_json::json!({
|
||||
"credentialFingerprint": credential_fingerprint,
|
||||
"baseUrl": llm.base_url,
|
||||
@@ -628,6 +634,7 @@ fn game_creator_codex_app_server_pool_key(
|
||||
},
|
||||
"workspaceMode": workspace_mode.pool_identity(),
|
||||
"skillPackIdentity": skill_pack_identity,
|
||||
"clientSkillIdentity": client_skill_identity,
|
||||
"controlledWebSearch": llm.web_search_enabled,
|
||||
"directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" },
|
||||
"providerProxyProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" },
|
||||
@@ -1513,10 +1520,18 @@ impl CodexAppServerConnection {
|
||||
))
|
||||
})?;
|
||||
}
|
||||
let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let skill_roots = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
install_agc_skill_pack(&isolated_os_home)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?;
|
||||
Some(isolated_os_home.join(".agents").join("skills"))
|
||||
let bundled_root = isolated_os_home.join(".agents").join("skills");
|
||||
let mut roots = vec![bundled_root];
|
||||
if let Some(client_root) =
|
||||
crate::client_extensions::prepare_enabled_client_skill_root(&isolated_os_home)
|
||||
.map_err(platform_llm::LlmError::InvalidConfig)?
|
||||
{
|
||||
roots.push(client_root);
|
||||
}
|
||||
Some(roots)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1632,7 +1647,7 @@ impl CodexAppServerConnection {
|
||||
workspace_mode,
|
||||
_provider_proxy: provider_proxy,
|
||||
tool_bridge,
|
||||
_skill_root: skill_root,
|
||||
_skill_roots: skill_roots,
|
||||
});
|
||||
tokio::spawn(read_game_creator_codex_app_server_stdout(
|
||||
Arc::downgrade(&inner),
|
||||
@@ -1664,11 +1679,11 @@ impl CodexAppServerConnection {
|
||||
if let Some(reason) = remote_control_disable_reason {
|
||||
eprintln!("agent.codex_app_server.remote_control disabled reason={reason}");
|
||||
}
|
||||
if let Some(skill_root) = connection.inner._skill_root.as_ref() {
|
||||
if let Some(skill_roots) = connection.inner._skill_roots.as_ref() {
|
||||
connection
|
||||
.request(
|
||||
"skills/extraRoots/set",
|
||||
serde_json::json!({ "extraRoots": [skill_root] }),
|
||||
serde_json::json!({ "extraRoots": skill_roots }),
|
||||
)
|
||||
.await
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
|
||||
@@ -487,6 +487,165 @@ fn stored_item_view(
|
||||
}
|
||||
}
|
||||
|
||||
fn client_skill_set_fingerprint(index: &ClientExtensionIndex) -> String {
|
||||
let mut entries = index
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.extension_type == "skill" && item.enabled)
|
||||
.map(|item| {
|
||||
(
|
||||
item.name.clone(),
|
||||
item.source_relative_path.clone(),
|
||||
item.fingerprint.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort();
|
||||
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"direct-project-client-skills.v1\0");
|
||||
for (name, source_relative_path, fingerprint) in entries {
|
||||
for value in [name, source_relative_path, fingerprint] {
|
||||
digest.update((value.len() as u64).to_le_bytes());
|
||||
digest.update(value.as_bytes());
|
||||
}
|
||||
}
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
pub(crate) fn enabled_client_skill_fingerprint() -> Result<String, String> {
|
||||
let root = extensions_root()?;
|
||||
let index = read_index(&root)?;
|
||||
Ok(client_skill_set_fingerprint(&index))
|
||||
}
|
||||
|
||||
fn rewrite_skill_name(content: &[u8], name: &str) -> Vec<u8> {
|
||||
let Ok(text) = std::str::from_utf8(content) else {
|
||||
return content.to_vec();
|
||||
};
|
||||
let mut lines = text.split_inclusive('\n');
|
||||
let Some(first) = lines.next() else {
|
||||
return content.to_vec();
|
||||
};
|
||||
if first.trim_matches(['\r', '\n', ' ', '\t']) != "---" {
|
||||
return content.to_vec();
|
||||
}
|
||||
|
||||
let newline = if first.ends_with("\r\n") {
|
||||
"\r\n"
|
||||
} else if first.ends_with('\n') {
|
||||
"\n"
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let mut output = String::with_capacity(text.len() + name.len() + 16);
|
||||
output.push_str(first);
|
||||
let mut found_name = false;
|
||||
let mut in_frontmatter = true;
|
||||
for line in lines {
|
||||
let content_end = line.trim_end_matches(['\r', '\n']);
|
||||
let line_ending = &line[content_end.len()..];
|
||||
if in_frontmatter && content_end.trim() == "---" {
|
||||
if !found_name {
|
||||
output.push_str("name: ");
|
||||
output.push_str(name);
|
||||
output.push_str(newline);
|
||||
}
|
||||
in_frontmatter = false;
|
||||
output.push_str(line);
|
||||
continue;
|
||||
}
|
||||
if in_frontmatter {
|
||||
let trimmed = content_end.trim_start();
|
||||
if trimmed.starts_with("name:") {
|
||||
let indent_len = content_end.len() - trimmed.len();
|
||||
output.push_str(&content_end[..indent_len]);
|
||||
output.push_str("name: ");
|
||||
output.push_str(name);
|
||||
output.push_str(line_ending);
|
||||
found_name = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push_str(line);
|
||||
}
|
||||
if in_frontmatter && !found_name {
|
||||
output.push_str("name: ");
|
||||
output.push_str(name);
|
||||
output.push_str(newline);
|
||||
}
|
||||
output.into_bytes()
|
||||
}
|
||||
|
||||
fn copy_skill_runtime_tree(
|
||||
source_directory: &Path,
|
||||
skill_file: &Path,
|
||||
destination: &Path,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
let files = sorted_directory_files(source_directory)?;
|
||||
for (relative, path) in files {
|
||||
let target = destination.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR));
|
||||
if path == skill_file {
|
||||
let content = fs::read(&path)
|
||||
.map_err(|error| format!("读取客户端 Skill 失败:{}: {error}", path.display()))?;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!("创建运行时 Skill 目录失败:{}: {error}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(&target, rewrite_skill_name(&content, name))
|
||||
.map_err(|error| format!("写入运行时 Skill 失败:{}: {error}", target.display()))?;
|
||||
} else {
|
||||
copy_file(&path, &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_enabled_client_skill_root(
|
||||
isolated_os_home: &Path,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
let root = extensions_root()?;
|
||||
let index = read_index(&root)?;
|
||||
let client_skill_root = isolated_os_home.join(".agents").join("client-skills");
|
||||
let mut copied_any = false;
|
||||
|
||||
for item in index
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| item.extension_type == "skill" && item.enabled)
|
||||
{
|
||||
let Some(source) = index
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| source.id == item.source_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let source_root = root.join(&source.storage_path);
|
||||
let source_relative_path = Path::new(&item.source_relative_path);
|
||||
let skill_file = source_root.join(source_relative_path);
|
||||
if !skill_file.is_file() {
|
||||
continue;
|
||||
}
|
||||
let source_directory = skill_file.parent().unwrap_or(&source_root);
|
||||
let destination = client_skill_root.join(&item.name);
|
||||
if !copied_any {
|
||||
fs::create_dir_all(&client_skill_root).map_err(|error| {
|
||||
format!(
|
||||
"创建客户端 Skill 运行时目录失败:{}: {error}",
|
||||
client_skill_root.display()
|
||||
)
|
||||
})?;
|
||||
copied_any = true;
|
||||
}
|
||||
copy_skill_runtime_tree(source_directory, &skill_file, &destination, &item.name)?;
|
||||
}
|
||||
|
||||
Ok(copied_any.then_some(client_skill_root))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn pick_client_extension_file(
|
||||
app: tauri::AppHandle,
|
||||
@@ -824,6 +983,57 @@ mod tests {
|
||||
assert_eq!(parse_skill_name(&path), "skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_skill_copy_rewrites_only_frontmatter_name() {
|
||||
let directory = tempfile::tempdir().expect("temp directory");
|
||||
let source = directory.path().join("source");
|
||||
let destination = directory.path().join("destination");
|
||||
fs::create_dir_all(source.join("references")).expect("create source tree");
|
||||
let skill_file = source.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_file,
|
||||
"---\nname: original-skill\ndescription: keep this\n---\n\n# original-skill\n",
|
||||
)
|
||||
.expect("write source skill");
|
||||
fs::write(source.join("references/guide.md"), "reference\n").expect("write reference");
|
||||
|
||||
copy_skill_runtime_tree(&source, &skill_file, &destination, "renamed-skill")
|
||||
.expect("copy runtime skill");
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("SKILL.md")).expect("read runtime skill"),
|
||||
"---\nname: renamed-skill\ndescription: keep this\n---\n\n# original-skill\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("references/guide.md")).expect("read reference"),
|
||||
"reference\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_skill_fingerprint_tracks_enabled_names_and_content() {
|
||||
let mut index = ClientExtensionIndex::default();
|
||||
index.items.push(StoredExtensionItem {
|
||||
id: "skill-1".to_string(),
|
||||
source_id: "source-1".to_string(),
|
||||
extension_type: "skill".to_string(),
|
||||
name: "first-skill".to_string(),
|
||||
original_name: "first-skill".to_string(),
|
||||
source_relative_path: "first/SKILL.md".to_string(),
|
||||
enabled: true,
|
||||
fingerprint: "content-a".to_string(),
|
||||
last_error: None,
|
||||
mcp_config: None,
|
||||
});
|
||||
let first = client_skill_set_fingerprint(&index);
|
||||
index.items[0].enabled = false;
|
||||
let disabled = client_skill_set_fingerprint(&index);
|
||||
assert_ne!(first, disabled);
|
||||
index.items[0].enabled = true;
|
||||
index.items[0].name = "renamed-skill".to_string();
|
||||
let renamed = client_skill_set_fingerprint(&index);
|
||||
assert_ne!(first, renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_directory_splits_independent_skill_and_mcp_items() {
|
||||
let fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -413,6 +413,8 @@ remove_client_extension(id)
|
||||
|
||||
### 阶段 2:Skill 运行时闭环
|
||||
|
||||
当前实施状态:已完成。DirectProject 启动准备会在隔离 HOME 中分别创建内置 AGC Skill root 和客户端 Skill root,并通过同一次 `skills/extraRoots/set` 注册;客户端 Skill root 只包含当前 `enabled=true` 的 Skill 独立副本。用户重命名时仅在该临时副本的标准 frontmatter 中更新 `name`,原始来源副本和客户端索引保持不变。已启用 Skill 集合的名称、来源相对路径和内容指纹已纳入 DirectProject app-server pool key,启停或重命名从下一次启动生效,不热更新现有连接。
|
||||
|
||||
完成:
|
||||
|
||||
- 读取客户端已启用 Skill;
|
||||
|
||||
Reference in New Issue
Block a user