Merge branch 'master' into feat/agc-use-ui-design
Project CI / Repository checks (pull_request) Successful in 4m32s
Project CI / Frontend tests (pull_request) Successful in 4m4s
Project CI / Backend tests (pull_request) Successful in 7m17s
Project CI / Native shell tests (pull_request) Failing after 17m10s

This commit is contained in:
2026-09-01 20:20:17 +08:00
32 changed files with 4047 additions and 55 deletions
+1
View File
@@ -1743,6 +1743,7 @@ dependencies = [
"tauri-plugin-opener",
"tempfile",
"tokio",
"toml 0.8.2",
"ts-rs",
"ttf-parser",
"typed_floats",
@@ -50,6 +50,7 @@ tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
toml = "0.8"
ttf-parser = "0.25.1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
url = "2"
@@ -7,7 +7,7 @@ use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
@@ -28,6 +28,7 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000;
const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000;
const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000;
const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000;
const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000;
const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration =
std::time::Duration::from_millis(250);
const DIRECT_CODEX_SHELL_ENVIRONMENT_POLICY: &str = "shell_environment_policy.inherit=\"core\"";
@@ -526,6 +527,55 @@ fn should_emit_direct_codex_activity(
true
}
fn is_terminal_client_mcp_startup_status(status: Option<&str>) -> bool {
matches!(status, Some("ready") | Some("failed") | Some("cancelled"))
}
async fn wait_for_client_mcp_startup_gate(
client_mcp_server_ids_by_name: &HashMap<String, String>,
client_mcp_startup_statuses: &Mutex<HashMap<String, String>>,
client_mcp_startup_notify: &Notify,
grace: std::time::Duration,
) {
let deadline = tokio::time::Instant::now() + grace;
loop {
// Register before reading the shared state. `notify_waiters` does not retain a
// permit for a future created after the notification, so this ordering is part
// of the readiness gate's correctness contract.
let notified = client_mcp_startup_notify.notified();
tokio::pin!(notified);
let pending = {
let statuses = client_mcp_startup_statuses.lock().await;
client_mcp_server_ids_by_name.keys().any(|name| {
!is_terminal_client_mcp_startup_status(statuses.get(name).map(String::as_str))
})
};
if !pending {
return;
}
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
eprintln!(
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
grace.as_millis()
);
return;
}
tokio::select! {
_ = &mut notified => {}
_ = tokio::time::sleep(remaining) => {
eprintln!(
"agent.direct_codex.client_mcp_startup_wait timed out after {}ms",
grace.as_millis()
);
return;
}
}
}
}
fn game_creator_codex_app_server_idle_timeout_ms(
workspace_mode: CodexAppServerWorkspaceMode,
request_timeout_ms: u64,
@@ -568,9 +618,14 @@ struct CodexAppServerInner {
_working_dir: tempfile::TempDir,
workspace_path: std::path::PathBuf,
workspace_mode: CodexAppServerWorkspaceMode,
client_mcp_server_ids_by_name: HashMap<String, String>,
client_mcp_connection_id: Option<String>,
client_mcp_startup_statuses: Mutex<HashMap<String, String>>,
client_mcp_startup_notify: Notify,
initial_client_mcp_startup_waited: AtomicBool,
_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 +670,18 @@ 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 client_mcp_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
crate::client_extensions::enabled_client_mcp_fingerprint()
.unwrap_or_else(|_| "invalid-client-mcp".to_string())
} else {
"disabled".to_string()
};
let stable = serde_json::json!({
"credentialFingerprint": credential_fingerprint,
"baseUrl": llm.base_url,
@@ -628,6 +695,8 @@ fn game_creator_codex_app_server_pool_key(
},
"workspaceMode": workspace_mode.pool_identity(),
"skillPackIdentity": skill_pack_identity,
"clientSkillIdentity": client_skill_identity,
"clientMcpIdentity": client_mcp_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" },
@@ -1108,17 +1177,20 @@ fn configure_game_creator_codex_app_server_command_for_mode(
let controlled_web_search =
workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled;
command.arg("app-server").arg("--stdio");
command
.arg("-c")
.arg("mcp_servers={}")
.arg("-c")
.arg("web_search=\"disabled\"");
if workspace_mode != CodexAppServerWorkspaceMode::DirectProject {
command.arg("-c").arg("mcp_servers={}");
}
command.arg("-c").arg("web_search=\"disabled\"");
if workspace_mode != CodexAppServerWorkspaceMode::DirectProject {
command.arg("-c").arg("agents.enabled=false");
}
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let current_executable = direct_tools_mcp_executable_path()?;
command
.arg("-c")
.arg(format!(
"mcp_optional_startup_grace_ms={DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS}"
))
.arg("-c")
.arg(format!(
"mcp_servers.agc_tools.command={}",
@@ -1306,14 +1378,41 @@ fn prepare_isolated_game_creator_codex_home(
fn trust_isolated_game_creator_codex_workspace(
codex_home: &std::path::Path,
workspace: &std::path::Path,
client_mcp_servers: &[crate::client_extensions::ClientMcpRuntimeServer],
) -> Result<(), platform_llm::LlmError> {
let workspace = workspace.to_string_lossy();
let quoted_workspace = quoted_toml_string(&workspace)?;
let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n");
std::fs::write(codex_home.join("config.toml"), config).map_err(|error| {
platform_llm::LlmError::Transport(format!(
"写入隔离 Codex app-server 项目信任配置失败:{error}"
let mut project = toml::map::Map::new();
project.insert(
"trust_level".to_string(),
toml::Value::String("trusted".to_string()),
);
let mut projects = toml::map::Map::new();
projects.insert(
workspace.to_string_lossy().into_owned(),
toml::Value::Table(project),
);
let mut root = toml::map::Map::new();
root.insert("projects".to_string(), toml::Value::Table(projects));
root.insert(
"mcp_optional_startup_grace_ms".to_string(),
toml::Value::Integer(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64),
);
if !client_mcp_servers.is_empty() {
let mut mcp_servers = toml::map::Map::new();
for server in client_mcp_servers {
mcp_servers.insert(
server.name.clone(),
toml::Value::Table(server.config.clone().into_iter().collect()),
);
}
root.insert("mcp_servers".to_string(), toml::Value::Table(mcp_servers));
}
let config = toml::to_string(&toml::Value::Table(root)).map_err(|error| {
platform_llm::LlmError::InvalidConfig(format!(
"序列化隔离 Codex app-server 配置失败:{error}"
))
})?;
std::fs::write(codex_home.join("config.toml"), config).map_err(|error| {
platform_llm::LlmError::Transport(format!("写入隔离 Codex app-server 配置失败:{error}"))
})
}
@@ -1496,8 +1595,25 @@ impl CodexAppServerConnection {
.unwrap_or_else(|| isolated_workspace.clone()),
)
};
let client_mcp_servers = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
crate::client_extensions::prepare_enabled_client_mcp_servers()
.map_err(platform_llm::LlmError::InvalidConfig)?
} else {
Vec::new()
};
let client_mcp_server_ids_by_name = client_mcp_servers
.iter()
.map(|server| (server.name.clone(), server.extension_id.clone()))
.collect::<HashMap<_, _>>();
let client_mcp_connection_id = (!client_mcp_servers.is_empty()
&& workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.then(|| uuid::Uuid::new_v4().simple().to_string());
if workspace_override.is_some() && workspace_mode.allows_workspace_writes() {
trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?;
trust_isolated_game_creator_codex_workspace(
&isolated_codex_home,
&workspace_path,
&client_mcp_servers,
)?;
}
let isolated_os_home = working_dir.path().join("home");
let isolated_app_data = isolated_os_home.join("appdata");
@@ -1513,10 +1629,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
};
@@ -1630,10 +1754,27 @@ impl CodexAppServerConnection {
_working_dir: working_dir,
workspace_path,
workspace_mode,
client_mcp_server_ids_by_name,
client_mcp_connection_id: client_mcp_connection_id.clone(),
client_mcp_startup_statuses: Mutex::new(HashMap::new()),
client_mcp_startup_notify: Notify::new(),
initial_client_mcp_startup_waited: AtomicBool::new(false),
_provider_proxy: provider_proxy,
tool_bridge,
_skill_root: skill_root,
_skill_roots: skill_roots,
});
if let Some(connection_id) = client_mcp_connection_id.as_deref() {
if let Err(error) = crate::client_extensions::claim_client_mcp_connection(
client_mcp_servers
.iter()
.map(|server| server.extension_id.clone()),
connection_id,
) {
shutdown_game_creator_codex_app_server_inner(&inner, "客户端 MCP 连接登记失败")
.await;
return Err(platform_llm::LlmError::InvalidConfig(error));
}
}
tokio::spawn(read_game_creator_codex_app_server_stdout(
Arc::downgrade(&inner),
stdout,
@@ -1664,11 +1805,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)?;
@@ -1706,6 +1847,26 @@ impl CodexAppServerConnection {
.map_err(|error| format!("刷新 Codex app-server stdin 失败:{error}"))
}
async fn wait_for_initial_client_mcp_startup(&self) {
if self.inner.workspace_mode != CodexAppServerWorkspaceMode::DirectProject
|| self.inner.client_mcp_server_ids_by_name.is_empty()
|| self
.inner
.initial_client_mcp_startup_waited
.swap(true, Ordering::AcqRel)
{
return;
}
wait_for_client_mcp_startup_gate(
&self.inner.client_mcp_server_ids_by_name,
&self.inner.client_mcp_startup_statuses,
&self.inner.client_mcp_startup_notify,
std::time::Duration::from_millis(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS),
)
.await;
}
async fn notify(&self, method: &str, params: serde_json::Value) -> Result<(), String> {
self.write_message(&serde_json::json!({
"method": method,
@@ -1927,6 +2088,7 @@ impl CodexAppServerConnection {
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let _turn_guard = self.inner.turn_gate.lock().await;
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
self.wait_for_initial_client_mcp_startup().await;
let thread_id = thread_lease.thread_id.clone();
let prompt = if self.inner.workspace_mode.uses_direct_conversation() {
direct_codex_user_prompt(&request)
@@ -2510,6 +2672,37 @@ async fn read_game_creator_codex_app_server_stdout(
.get("method")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if method == "mcpServer/startupStatus/updated"
&& inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
{
if let (Some(name), Some(status)) = (
message
.pointer("/params/name")
.and_then(serde_json::Value::as_str),
message
.pointer("/params/status")
.and_then(serde_json::Value::as_str),
) {
if inner.client_mcp_server_ids_by_name.contains_key(name) {
inner
.client_mcp_startup_statuses
.lock()
.await
.insert(name.to_string(), status.to_string());
inner.client_mcp_startup_notify.notify_waiters();
}
if let Some(extension_id) = inner.client_mcp_server_ids_by_name.get(name) {
if let Some(connection_id) = inner.client_mcp_connection_id.as_deref() {
let _ = crate::client_extensions::record_client_mcp_startup_status(
extension_id,
connection_id,
status,
);
}
}
}
continue;
}
let safe_activity = direct_codex_safe_activity_for_notification(method);
if !matches!(
method,
@@ -2750,6 +2943,7 @@ async fn fail_game_creator_codex_app_server_connection(
if inner.closed.swap(true, Ordering::AcqRel) {
return;
}
release_client_mcp_connection_for_inner(&inner);
let exit_status = inner
.child
.lock()
@@ -2776,6 +2970,7 @@ async fn shutdown_game_creator_codex_app_server_inner(
reason: &str,
) {
inner.closed.store(true, Ordering::Release);
release_client_mcp_connection_for_inner(inner);
for (_, pending) in inner.pending.lock().await.drain() {
let _ = pending.sender.send(Err(reason.to_string()));
}
@@ -2789,6 +2984,16 @@ async fn shutdown_game_creator_codex_app_server_inner(
}
}
fn release_client_mcp_connection_for_inner(inner: &Arc<CodexAppServerInner>) {
let Some(connection_id) = inner.client_mcp_connection_id.as_deref() else {
return;
};
let _ = crate::client_extensions::release_client_mcp_connection(
inner.client_mcp_server_ids_by_name.values().cloned(),
connection_id,
);
}
pub(in crate::agent) async fn request_game_creator_agent_codex_app_server(
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
@@ -3190,6 +3395,50 @@ mod tests {
);
}
#[test]
fn client_mcp_startup_terminal_statuses_are_recognized() {
for status in ["ready", "failed", "cancelled"] {
assert!(is_terminal_client_mcp_startup_status(Some(status)));
}
for status in [None, Some("starting"), Some("unknown")] {
assert!(!is_terminal_client_mcp_startup_status(status));
}
}
#[tokio::test]
async fn client_mcp_startup_gate_rechecks_after_status_notification() {
let server_ids_by_name = Arc::new(HashMap::from([(
"search".to_string(),
"extension-search".to_string(),
)]));
let statuses = Arc::new(Mutex::new(HashMap::new()));
let notify = Arc::new(Notify::new());
let wait_server_ids_by_name = Arc::clone(&server_ids_by_name);
let wait_statuses = Arc::clone(&statuses);
let wait_notify = Arc::clone(&notify);
let waiter = tokio::spawn(async move {
wait_for_client_mcp_startup_gate(
&wait_server_ids_by_name,
&wait_statuses,
&wait_notify,
std::time::Duration::from_millis(250),
)
.await;
});
tokio::task::yield_now().await;
statuses
.lock()
.await
.insert("search".to_string(), "ready".to_string());
notify.notify_waiters();
tokio::time::timeout(std::time::Duration::from_millis(100), waiter)
.await
.expect("startup gate should wake after terminal status")
.expect("startup gate task should not panic");
}
#[test]
fn direct_home_mode_is_read_only_and_rejects_non_passive_items() {
assert!(CodexAppServerWorkspaceMode::DirectHome.uses_direct_conversation());
@@ -3690,6 +3939,46 @@ mod tests {
assert!(joined.contains("mcp_servers.agc_tools.command="));
assert!(joined.contains(DIRECT_TOOLS_MCP_MODE_FLAG));
assert!(joined.contains("mcp_servers.agc_tools.required=true"));
assert!(joined.contains("mcp_optional_startup_grace_ms=120000"));
assert!(!joined.contains("mcp_servers={}"));
}
#[test]
fn isolated_direct_project_config_contains_client_mcp_without_agc_tools() {
let directory = tempfile::tempdir().expect("temporary config home");
let workspace = directory.path().join("workspace");
std::fs::create_dir(&workspace).expect("create workspace");
let client_mcp = crate::client_extensions::ClientMcpRuntimeServer {
extension_id: "extension-1".to_string(),
name: "plugin-search".to_string(),
config: std::collections::BTreeMap::from([
(
"command".to_string(),
toml::Value::String("fixture-search".to_string()),
),
("required".to_string(), toml::Value::Boolean(false)),
]),
};
trust_isolated_game_creator_codex_workspace(directory.path(), &workspace, &[client_mcp])
.expect("write isolated config");
let config = std::fs::read_to_string(directory.path().join("config.toml"))
.expect("read isolated config");
let parsed = config
.parse::<toml::Value>()
.expect("parse isolated config");
assert_eq!(
parsed["mcp_servers"]["plugin-search"]["command"].as_str(),
Some("fixture-search")
);
assert_eq!(
parsed["mcp_servers"]["plugin-search"]["required"].as_bool(),
Some(false)
);
assert_eq!(
parsed["mcp_optional_startup_grace_ms"].as_integer(),
Some(DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS as i64)
);
assert!(parsed["mcp_servers"].get("agc_tools").is_none());
}
#[tokio::test]
@@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160;
const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill,以及经审核的 `agc_tools` MCP。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;`agc_read_skill_resource` 用于按需读取审核 Skill。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。";
const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png";
const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png";
const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png";
@@ -4362,6 +4362,8 @@ mod tests {
assert!(prompt.contains("不要等待 Supervisor"));
assert!(prompt.contains("提示词与技能"));
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP"));
assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时"));
assert!(prompt.contains("先按需读取当前 cwd 下适用的 `AGENTS.md`"));
assert!(prompt.contains("agc_write_file"));
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
File diff suppressed because it is too large Load Diff
@@ -230,6 +230,7 @@ mod agent_native_tools;
mod assets;
mod browser;
mod cli;
mod client_extensions;
mod collaboration;
mod command_exec;
mod command_output;
@@ -268,6 +269,7 @@ use agent_native_tools::*;
use assets::*;
use browser::*;
use cli::*;
use client_extensions::*;
use collaboration::*;
use command_exec::*;
use command_output::*;
@@ -2404,6 +2406,13 @@ fn main() {
inspect_local_project_directory,
pick_local_project_directory,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
remove_client_extension,
open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run,
@@ -0,0 +1,26 @@
# DirectProject 扩展导入阶段 0 样例
本目录只保存导入分类和命名契约使用的最小样例,不作为运行时扩展安装目录。阶段 0 不启动其中的 MCP,也不执行 Skill 中的脚本。
## 样例与预期
| 样例 | 预期拆分 | 备注 |
| --- | --- | --- |
| `single-skill/` | 1 个 Skill`single-skill` | 根目录 `SKILL.md` |
| `multi-skill/` | 2 个 Skill`art-skill``code-skill` | 一个 Skill root,分别导入 |
| `mcp-config/` | 2 个 MCP`search``filesystem` | 原生 `config.toml` |
| `mcp-json/` | 2 个 MCP`search``filesystem` | 兼容 `.mcp.json` |
| `plugin/` | 1 个 Skill`plugin-skill`、1 个 MCP`plugin-search` | Plugin 只作为导入来源 |
| `mixed-source/` | 1 个 Skill`mixed-skill`、2 个 MCP`search``filesystem` | 一个来源拆成多个独立项 |
| `unknown/` | 1 个未知项:`unknown.bin` | 不执行、不作为 MCP 入口 |
`mixed-source/` 表示目录或 zip 解压后的标准内容。实际 zip 测试可以在测试运行时将该目录压缩为临时归档,不把生成的二进制 zip 提交到仓库。
## 阶段 0 固定规则
- 一个来源里的每个 Skill 和每个 MCP Server 都是独立扩展项。
- 导入后已识别项默认启用;未知项保留但不可启动。
- 同名或重复内容再次导入时保留新项,名称使用原生标识追加 `-2``-3`
- 前端列表名称和 Codex 运行时名称相同,不维护两套名称。
- 单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
- Plugin 只提取支持的 Skill/MCP,不开启 hooks、apps 或完整 Plugin Runtime。
@@ -0,0 +1,68 @@
{
"schemaVersion": "direct-project-extension-fixture.v1",
"cases": [
{
"id": "single-skill",
"source": "single-skill",
"items": [
{ "type": "skill", "name": "single-skill" }
]
},
{
"id": "multi-skill",
"source": "multi-skill",
"items": [
{ "type": "skill", "name": "art-skill" },
{ "type": "skill", "name": "code-skill" }
]
},
{
"id": "mcp-config",
"source": "mcp-config/config.toml",
"items": [
{ "type": "mcp", "name": "search" },
{ "type": "mcp", "name": "filesystem" }
]
},
{
"id": "mcp-json",
"source": "mcp-json/.mcp.json",
"items": [
{ "type": "mcp", "name": "search" },
{ "type": "mcp", "name": "filesystem" }
]
},
{
"id": "plugin",
"source": "plugin",
"items": [
{ "type": "skill", "name": "plugin-skill" },
{ "type": "mcp", "name": "plugin-search" }
],
"ignoredCapabilities": ["hooks", "apps", "remote_plugin"]
},
{
"id": "mixed-source",
"source": "mixed-source",
"items": [
{ "type": "skill", "name": "mixed-skill" },
{ "type": "mcp", "name": "search" },
{ "type": "mcp", "name": "filesystem" }
]
},
{
"id": "unknown",
"source": "unknown/unknown.bin",
"items": [
{ "type": "unknown", "name": "unknown.bin", "launchable": false }
]
}
],
"duplicateImport": {
"source": "single-skill",
"items": [
{ "type": "skill", "name": "single-skill-2" }
],
"preserveOriginal": true
}
}
@@ -0,0 +1,5 @@
[mcp_servers.search]
command = "fixture-search"
[mcp_servers.filesystem]
command = "fixture-filesystem"
@@ -0,0 +1,10 @@
{
"mcpServers": {
"search": {
"command": "fixture-search"
},
"filesystem": {
"command": "fixture-filesystem"
}
}
}
@@ -0,0 +1,5 @@
[mcp_servers.search]
command = "fixture-search"
[mcp_servers.filesystem]
command = "fixture-filesystem"
@@ -0,0 +1,8 @@
---
name: mixed-skill
description: Stage 0 fixture for a source containing independent Skill and MCP entries.
---
# Mixed Source Skill Fixture
This fixture is used only to verify independent import items.
@@ -0,0 +1,8 @@
---
name: art-skill
description: Stage 0 fixture for an imported art Skill.
---
# Art Skill Fixture
This fixture is used only to verify that one Skill root becomes independent items.
@@ -0,0 +1,8 @@
---
name: code-skill
description: Stage 0 fixture for an imported code Skill.
---
# Code Skill Fixture
This fixture is used only to verify that one Skill root becomes independent items.
@@ -0,0 +1,6 @@
{
"name": "stage-0-fixture-plugin",
"version": "0.1.0",
"description": "Fixture for extracting standard Skill content from a Plugin source.",
"skills": "./skills/"
}
@@ -0,0 +1,8 @@
{
"mcpServers": {
"plugin-search": {
"type": "stdio",
"command": "fixture-plugin-search"
}
}
}
@@ -0,0 +1,8 @@
---
name: plugin-skill
description: Stage 0 fixture for a Skill contained in a Plugin.
---
# Plugin Skill Fixture
This fixture is used only to verify Plugin source extraction.
@@ -0,0 +1,8 @@
---
name: single-skill
description: Stage 0 fixture for a single imported Skill.
---
# Single Skill Fixture
This fixture is used only to verify discovery and naming.
@@ -0,0 +1 @@
This is an inert unknown-file fixture. It must never be executed as an MCP server.
@@ -51,6 +51,27 @@ export type LauncherImportedAttachment = {
size?: number;
};
export type ClientExtensionType = 'skill' | 'mcp' | 'unknown';
export type ClientExtensionItem = {
id: string;
name: string;
originalName: string;
extensionType: ClientExtensionType;
sourceName: string;
sourceRelativePath: string;
enabled: boolean;
status: 'enabled' | 'disabled' | 'unknown' | 'startup-failed';
lastError: string | null;
};
export type ClientExtensionImportResult = {
imported: ClientExtensionItem[];
sourceName: string;
renamed: boolean;
duplicate: boolean;
};
export type LocalProjectKind = 'web' | 'godot';
export type ProjectStartMode = 'planning' | 'direct-build';
@@ -5,10 +5,13 @@ import {
CircleAlert,
Info,
LoaderCircle,
Pencil,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
Upload,
X,
} from 'lucide-react';
import { type FormEvent, useEffect, useRef, useState } from 'react';
@@ -23,6 +26,8 @@ import {
} from '../../app/dialogs';
import { resolveTauriInvoke } from '../../app/tauri';
import {
type ClientExtensionImportResult,
type ClientExtensionItem,
type GameCreatorAgentLlmConfig,
type GameCreatorAppConfig,
type GameCreatorAppConfigView,
@@ -87,6 +92,7 @@ type RuntimeSettingsSection =
| 'general'
| 'agents'
| 'connections'
| 'extensions'
| 'advanced'
| 'about';
@@ -95,6 +101,8 @@ type RuntimeConfigToast = {
message: string;
};
type ClientExtensionsLoadState = 'loading' | 'ready' | 'error';
const runtimeSettingsSections = [
{
id: 'general',
@@ -114,6 +122,12 @@ const runtimeSettingsSections = [
description: '外部服务',
icon: Cable,
},
{
id: 'extensions',
label: '扩展',
description: 'Skill 与 MCP',
icon: Upload,
},
{
id: 'advanced',
label: '高级参数',
@@ -384,6 +398,17 @@ export function RuntimeConfigDialog({
const [activeSection, setActiveSection] =
useState<RuntimeSettingsSection>('general');
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
const [clientExtensions, setClientExtensions] = useState<
ClientExtensionItem[]
>([]);
const [clientExtensionsBusy, setClientExtensionsBusy] = useState(false);
const [clientExtensionsLoadState, setClientExtensionsLoadState] =
useState<ClientExtensionsLoadState>('loading');
const [clientExtensionsStatus, setClientExtensionsStatus] = useState('');
const [editingExtensionId, setEditingExtensionId] = useState<string | null>(
null,
);
const [editingExtensionName, setEditingExtensionName] = useState('');
const [appUpdateStatus, setAppUpdateStatus] = useState('');
const [appUpdateChecking, setAppUpdateChecking] = useState(false);
const runtimeConfigBusyRef = useRef(false);
@@ -404,10 +429,176 @@ export function RuntimeConfigDialog({
useEffect(() => {
void readRuntimeConfig();
void readClientExtensions();
// The dialog reads once on mount; subsequent reads are explicit user actions.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function readClientExtensions() {
const invoke = resolveTauriInvoke();
if (!invoke) {
setClientExtensionsLoadState('error');
setClientExtensionsStatus('需要在 Tauri App 内运行');
return;
}
setClientExtensionsLoadState('loading');
setClientExtensionsBusy(true);
try {
const result = await invoke<ClientExtensionItem[]>(
'list_client_extensions',
);
setClientExtensions(result);
setClientExtensionsLoadState('ready');
setClientExtensionsStatus('');
} catch (error) {
setClientExtensionsLoadState('error');
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setClientExtensionsBusy(false);
}
}
async function importClientExtension(kind: 'file' | 'directory') {
const invoke = resolveTauriInvoke();
if (!invoke) {
setClientExtensionsStatus('需要在 Tauri App 内运行');
return;
}
if (clientExtensionsBusy) {
return;
}
setClientExtensionsBusy(true);
setClientExtensionsStatus('正在导入');
try {
const selectedPath =
kind === 'file'
? await invoke<string | null>('pick_client_extension_file')
: await invoke<string | null>('pick_client_extension_directory');
if (!selectedPath) {
setClientExtensionsStatus('');
return;
}
const result = await invoke<ClientExtensionImportResult>(
'import_client_extension',
{ sourcePath: selectedPath },
);
const refreshed = await invoke<ClientExtensionItem[]>(
'list_client_extensions',
);
setClientExtensions(refreshed);
setClientExtensionsLoadState('ready');
const importedCount = result.imported.length;
setClientExtensionsStatus(
importedCount > 0
? `已导入 ${importedCount} 个扩展${result.renamed ? ',同名项已自动追加编号' : ''}`
: '未发现可管理的扩展',
);
} catch (error) {
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setClientExtensionsBusy(false);
}
}
function beginRenameClientExtension(item: ClientExtensionItem) {
setEditingExtensionId(item.id);
setEditingExtensionName(item.name);
setClientExtensionsStatus('');
}
function cancelRenameClientExtension() {
setEditingExtensionId(null);
setEditingExtensionName('');
}
async function saveClientExtensionName(item: ClientExtensionItem) {
const invoke = resolveTauriInvoke();
if (!invoke || clientExtensionsBusy) {
return;
}
setClientExtensionsBusy(true);
try {
const updated = await invoke<ClientExtensionItem>(
'rename_client_extension',
{ id: item.id, name: editingExtensionName },
);
setClientExtensions((current) =>
current.map((candidate) =>
candidate.id === updated.id ? updated : candidate,
),
);
const requestedName = editingExtensionName.trim();
setClientExtensionsStatus(
updated.name === requestedName
? '扩展名称已更新'
: `扩展名称已调整为“${updated.name}`,
);
cancelRenameClientExtension();
} catch (error) {
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setClientExtensionsBusy(false);
}
}
async function setClientExtensionEnabled(
item: ClientExtensionItem,
enabled: boolean,
) {
const invoke = resolveTauriInvoke();
if (!invoke || clientExtensionsBusy || item.extensionType === 'unknown') {
return;
}
setClientExtensionsBusy(true);
try {
const updated = await invoke<ClientExtensionItem>(
'set_client_extension_enabled',
{ id: item.id, enabled },
);
setClientExtensions((current) =>
current.map((candidate) =>
candidate.id === updated.id ? updated : candidate,
),
);
} catch (error) {
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setClientExtensionsBusy(false);
}
}
async function removeClientExtension(item: ClientExtensionItem) {
const invoke = resolveTauriInvoke();
if (!invoke || clientExtensionsBusy) {
return;
}
setClientExtensionsBusy(true);
try {
await invoke('remove_client_extension', { id: item.id });
setClientExtensions((current) =>
current.filter((candidate) => candidate.id !== item.id),
);
if (editingExtensionId === item.id) {
cancelRenameClientExtension();
}
setClientExtensionsStatus('扩展已移除');
} catch (error) {
setClientExtensionsStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
setClientExtensionsBusy(false);
}
}
function updateRuntimeLlmConfig<K extends keyof GameCreatorAppConfig['llm']>(
key: K,
value: GameCreatorAppConfig['llm'][K],
@@ -711,6 +902,25 @@ export function RuntimeConfigDialog({
</div>
{activeSection === 'agents' ? (
<span>{configuredAgentCount} </span>
) : activeSection === 'extensions' ? (
<div className="runtime-settings-section-actions">
<button
type="button"
disabled={clientExtensionsBusy}
onClick={() => void importClientExtension('file')}
>
<Upload size={14} aria-hidden="true" />
zip
</button>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={() => void importClientExtension('directory')}
>
<Upload size={14} aria-hidden="true" />
</button>
</div>
) : null}
</header>
<div className="settings-grid runtime-settings-fields">
@@ -1293,6 +1503,159 @@ export function RuntimeConfigDialog({
) : null}
</section>
) : null}
{activeSection === 'extensions' ? (
<section
className="runtime-settings-section runtime-settings-extensions"
aria-label="客户端扩展"
>
{clientExtensionsStatus &&
clientExtensionsLoadState !== 'error' ? (
<p className="runtime-settings-inline-status" role="status">
{clientExtensionsStatus}
</p>
) : null}
{clientExtensionsLoadState === 'loading' ? (
<div className="runtime-settings-empty-state" role="status">
<strong></strong>
<span> Skill MCP</span>
</div>
) : clientExtensionsLoadState === 'error' ? (
<div className="runtime-settings-empty-state" role="alert">
<strong></strong>
<span>
{clientExtensionsStatus || '暂时无法读取扩展列表。'}
</span>
</div>
) : clientExtensions.length > 0 ? (
<div className="runtime-settings-extension-list">
{clientExtensions.map((item) => {
const editing = editingExtensionId === item.id;
const typeLabel =
item.extensionType === 'skill'
? 'Skill'
: item.extensionType === 'mcp'
? 'MCP'
: '未识别';
const statusLabel =
item.status === 'enabled'
? '已启用'
: item.status === 'disabled'
? '已禁用'
: item.status === 'startup-failed'
? '启动失败'
: '当前不可用';
return (
<article
className="runtime-settings-extension-item"
key={item.id}
>
<div className="runtime-settings-extension-main">
{editing ? (
<input
aria-label={`${item.name} 新名称`}
autoFocus
value={editingExtensionName}
onChange={(event) =>
setEditingExtensionName(
event.currentTarget.value,
)
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
void saveClientExtensionName(item);
} else if (event.key === 'Escape') {
event.stopPropagation();
cancelRenameClientExtension();
}
}}
/>
) : (
<strong title={item.name}>{item.name}</strong>
)}
<span>
{typeLabel} · {item.sourceName}
</span>
{item.lastError ? (
<small title={item.lastError}>
{item.lastError}
</small>
) : null}
</div>
<div className="runtime-settings-extension-actions">
<span>{statusLabel}</span>
{editing ? (
<>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={() =>
void saveClientExtensionName(item)
}
>
</button>
<button
type="button"
disabled={clientExtensionsBusy}
onClick={cancelRenameClientExtension}
>
</button>
</>
) : (
<button
type="button"
aria-label={`重命名 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() =>
beginRenameClientExtension(item)
}
>
<Pencil size={14} aria-hidden="true" />
</button>
)}
{item.extensionType === 'unknown' ? null : (
<button
type="button"
role="switch"
aria-checked={item.enabled}
aria-label={`${item.name} ${item.enabled ? '禁用' : '启用'}`}
disabled={clientExtensionsBusy}
onClick={() =>
void setClientExtensionEnabled(
item,
!item.enabled,
)
}
>
{item.enabled ? '禁用' : '启用'}
</button>
)}
<button
type="button"
aria-label={`删除 ${item.name}`}
disabled={clientExtensionsBusy}
onClick={() => void removeClientExtension(item)}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
</article>
);
})}
</div>
) : (
<div className="runtime-settings-empty-state">
<strong></strong>
<span>
SkillMCP Plugin
</span>
</div>
)}
</section>
) : null}
{activeSection === 'about' ? (
<section
className="runtime-settings-about"
+148
View File
@@ -3703,6 +3703,136 @@ h2 {
font-size: 11px;
}
.runtime-settings-section-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 7px;
}
.runtime-settings-section-actions button,
.runtime-settings-extension-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 30px;
padding: 0 9px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-button-ghost-fill);
color: var(--platform-text-base);
font-size: 11px;
cursor: pointer;
}
.runtime-settings-section-actions button:hover,
.runtime-settings-extension-actions button:hover {
border-color: var(--platform-surface-hover-border);
background: var(--platform-button-ghost-fill);
}
.runtime-settings-section-actions button:disabled,
.runtime-settings-extension-actions button:disabled {
cursor: default;
opacity: 0.55;
}
.runtime-settings-extensions {
display: grid;
gap: 10px;
}
.runtime-settings-inline-status {
margin: 0;
color: var(--platform-text-soft);
font-size: 11px;
}
.runtime-settings-extension-list {
display: grid;
gap: 8px;
}
.runtime-settings-extension-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--platform-subpanel-border);
border-radius: 12px;
background: var(--runtime-settings-subpanel-fill);
}
.runtime-settings-extension-main {
display: grid;
min-width: 0;
gap: 3px;
}
.runtime-settings-extension-main strong,
.runtime-settings-extension-main input {
min-width: 0;
color: var(--platform-text-strong);
font-size: 13px;
font-weight: 800;
}
.runtime-settings-extension-main input {
width: min(100%, 300px);
padding: 6px 8px;
border: 1px solid var(--platform-surface-border);
border-radius: 7px;
background: var(--platform-input-fill);
}
.runtime-settings-extension-main > span,
.runtime-settings-extension-main > small,
.runtime-settings-extension-actions > span {
overflow: hidden;
color: var(--platform-text-soft);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-settings-extension-main > small {
color: var(--platform-danger, #b45309);
}
.runtime-settings-extension-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-shrink: 0;
}
.runtime-settings-extension-actions > span {
margin-right: 2px;
}
.runtime-settings-empty-state {
display: grid;
gap: 4px;
padding: 24px 14px;
border: 1px dashed var(--platform-subpanel-border);
border-radius: 12px;
color: var(--platform-text-soft);
text-align: center;
}
.runtime-settings-empty-state strong {
color: var(--platform-text-base);
font-size: 13px;
}
.runtime-settings-empty-state span {
font-size: 11px;
}
.runtime-settings-fields {
align-content: start;
}
@@ -4669,6 +4799,24 @@ iframe.preview-frame {
padding: 18px 14px 22px;
}
.runtime-settings-section-header {
align-items: flex-start;
flex-direction: column;
}
.runtime-settings-section-actions {
justify-content: flex-start;
}
.runtime-settings-extension-item {
align-items: stretch;
flex-direction: column;
}
.runtime-settings-extension-actions {
justify-content: flex-start;
}
.runtime-agent-card-fields {
grid-template-columns: 1fr;
}
@@ -255,6 +255,59 @@ export function registerAgentStatusDerivationTests() {
}
export function registerRuntimeSettingsTests() {
it('distinguishes loading the client extension list from an empty list', async () => {
let resolveExtensions!: (items: unknown[]) => void;
const extensionsRead = new Promise<unknown[]>((resolve) => {
resolveExtensions = resolve;
});
const invoke = vi.fn((command: string) => {
if (command === 'read_game_creator_app_config') {
return Promise.resolve({
path: '/home/test/AppData/game-creator.config.json',
config: {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-settings-extensions',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: '',
},
},
});
}
if (command === 'list_client_extensions') {
return extensionsRead;
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '配置' }));
await screen.findByRole('dialog', { name: '运行时配置' });
fireEvent.click(screen.getByRole('button', { name: /^扩展/ }));
expect(await screen.findByText('正在加载扩展')).not.toBeNull();
expect(screen.queryByText('还没有导入扩展')).toBeNull();
await act(async () => {
resolveExtensions([]);
});
expect(await screen.findByText('还没有导入扩展')).not.toBeNull();
expect(screen.queryByText('正在加载扩展')).toBeNull();
});
it('shows the client version on the About settings page', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_game_creator_app_config') {
+1
View File
@@ -20,6 +20,7 @@
## AI 游戏创作与 Agent Runtime
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -15,6 +15,18 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
- 决策:客户端提供一个全局“扩展”入口,统一接受文件、目录、zip 和标准 Plugin;目录、zip、Plugin 只是导入来源,发现出的每个 Skill 和每个 MCP Server 分别成为独立扩展项,分别列表、重命名、启用、禁用和删除。已识别项导入后默认启用,下次 DirectProject Codex 启动时按原生 Skill root 和 MCP 配置注入。
- 命名:客户端列表名称与 Codex 运行时名称使用同一个原生标识,不维护 display/runtime 两套名称;重复或同名项保留为新的独立项并自动追加 `-2``-3`。Skill 重命名只修改客户端运行时副本中的有效名称,原始导入内容不修改。
- Plugin 边界:Plugin 只作为导入容器提取 Skill/MCP;当前 DirectProject 关闭的 hooks、apps、remote plugin 和完整 Plugin Runtime 不接入。单个可执行文件或脚本不提供手动指定为 MCP 入口的功能。
- 信任边界:不审核第三方 Skill 文案、脚本、二进制、MCP tool 或网络行为;导入阶段不执行内容。客户端只做标准结构识别、必要配置解析和 zip staging 路径边界处理,且不向第三方扩展注入 AGC 凭据或内部路径。
- 影响范围:AGC 客户端扩展设置 UI、客户端本地扩展存储、DirectProject Codex app-server 启动准备和 pool fingerprint;不新增 HTTP 服务、SpacetimeDB schema、公开 API 或独立 Plugin Runtime。
- 当前实现:客户端导入/list、Skill 临时 root 和 MCP 隔离配置注入均已落地。第三方 MCP 只从客户端已启用独立项生成本次隔离 `CODEX_HOME/config.toml`,每项固定非 required;配置错误或 app-server 启动状态失败只更新对应 `last_error`,内置 `agc_tools` 继续由客户端单独注入。客户端已启用 Skill/MCP 的名称、来源路径和内容指纹共同参与 DirectProject app-server pool identity。
- 验证方式:分三阶段验收:先验证导入拆分和完整列表,再验证 Skill 运行时发现和重命名,最后验证 MCP 配置合并、Plugin 提取和失败隔离;只增加对应的定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md``apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx``apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs`
## 2026-08-30 批准 GDD 直接进入做游戏链路
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。

Some files were not shown because too many files have changed in this diff Show More