合并 master 最新变更
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 2m16s
Project CI / Repository checks (pull_request) Failing after 18s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Successful in 6m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 14m9s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 14m23s

# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
2026-10-02 13:52:07 +08:00
53 changed files with 2317 additions and 253 deletions
+7 -1
View File
@@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
export type AdminAgcAgentMode = 'codex' | 'cc';
export type AdminAgcModelProtocol =
| 'openai_responses'
| 'openai_chat'
| 'anthropic';
export interface AdminAgcModel {
id: string;
@@ -1083,6 +1087,8 @@ export interface AdminAgcModel {
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
/** 上游线协议,与执行器正交。 */
protocol?: AdminAgcModelProtocol;
}
export interface AdminAgcModelCatalog {
@@ -104,3 +104,49 @@ test('new models default to Codex and save the selected Agent mode', async () =>
});
});
});
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'claude-opus-5-5',
enabled: true,
agentMode: 'cc' as const,
protocol: 'anthropic' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('claude-opus-5-5');
const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement;
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
expect(agentMode.value).toBe('cc');
expect(protocol.value).toBe('anthropic');
// 执行器下拉里不再混入协议取值。
expect(
Array.from(agentMode.options).map((option) => option.value),
).toEqual(['codex', 'cc']);
fireEvent.change(agentMode, { target: { value: 'codex' } });
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models[0]).toMatchObject({
agentMode: 'codex',
protocol: 'openai_chat',
});
});
});
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'protocol', label: '协议' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
modelId: '',
enabled: true,
agentMode: 'codex',
protocol: 'openai_responses',
},
],
},
@@ -226,7 +228,22 @@ export function AdminAgcModelsPage({
>
<option value="codex">Codex</option>
<option value="cc">Claude Code</option>
<option value="anthropic">Anthropic 协议</option>
</select>
</td>
<td>
<select
aria-label={`模型 ${index + 1} 协议`}
value={model.protocol ?? 'openai_responses'}
disabled={busy}
onChange={(e) =>
update(model.id, {
protocol: e.target.value as AdminAgcModel['protocol'],
})
}
>
<option value="openai_responses">OpenAI Responses</option>
<option value="openai_chat">OpenAI Chat Completions</option>
<option value="anthropic">Anthropic Messages</option>
</select>
</td>
<td>
@@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|_| "Claude Agent SDK sidecar 的 Node runtime 不可用".to_string())?;
.map_err(|error| {
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
// 不能笼统说成「Node runtime 不可用」并丢掉 OS 错误。
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
})?;
if !output.status.success() {
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
}
@@ -1971,6 +1971,28 @@ fn configure_game_creator_codex_app_server_command(
)
}
/// Codex 的 `wire_api` 取自所选模型的线协议。
///
/// 单测不读全局配置:用例里没有真实的模型选择,读它会引入环境依赖,所以测试态固定
/// `responses`,真实构建按 `selected_model_protocol` 在 `responses` / `chat` 间切换。
fn game_creator_codex_provider_wire_api() -> &'static str {
#[cfg(test)]
{
"responses"
}
#[cfg(not(test))]
{
match crate::config::load_game_creator_app_config()
.ok()
.and_then(|config| config.selected_model_protocol)
.as_deref()
{
Some("openai_chat") => "chat",
_ => "responses",
}
}
}
fn configure_game_creator_codex_app_server_command_for_mode(
command: &mut tokio::process::Command,
llm: &GameCreatorLlmConfig,
@@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode(
let provider_token = provider_proxy
.map(CodexProviderProxy::downstream_bearer_token)
.unwrap_or(legacy_api_key);
// 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions,
// 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。
let wire_api = game_creator_codex_provider_wire_api();
command
.arg("-c")
.arg(format!(
@@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode(
.arg("-c")
.arg(format!(
"model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}",
quoted_toml_string("responses")?
quoted_toml_string(wire_api)?
))
.env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token);
}
@@ -7338,7 +7338,7 @@ mod tests {
);
assert!(error
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
@@ -7382,7 +7382,7 @@ mod tests {
assert!(
error.starts_with(
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
"direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
),
"{error}"
);
@@ -7421,7 +7421,7 @@ mod tests {
);
assert!(
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
@@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
let scope_relative = scope
.and_then(|scope| scope.project_relative)
.filter(|path| !path.is_empty());
let listed = list_local_project_files_at(root)?;
let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/"));
let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| {
bridge_project_file_is_hidden_control_path(path)
|| should_skip_project_snapshot_path(path)
|| reject_sensitive_project_file_read(path).is_err()
})?;
let mut files = listed
.files
.into_iter()
.filter(|file| file.kind == "file")
.filter(|file| !bridge_project_file_is_hidden_control_path(&file.path))
.filter(|file| !should_skip_project_snapshot_path(&file.path))
.filter(|file| reject_sensitive_project_file_read(&file.path).is_ok())
.filter(|file| {
scope_relative.as_ref().is_none_or(|scope| {
file.path == *scope
|| scope_prefix
.as_ref()
.is_some_and(|prefix| file.path.starts_with(prefix))
})
})
.filter(|file| {
let (category, _) = bridge_project_file_class(&file.path);
requested_kind == "all" || requested_kind == category
@@ -1622,11 +1614,13 @@ async fn bridge_import_account_assets(
}
}
if !local_paths.is_empty() {
match crate::commands::import_local_project_assets_for_agent_with_write_permit(
match crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
&state.root,
&local_paths,
write_permit.as_ref(),
) {
)
.await
{
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
json!({
"id": asset.id,
@@ -4193,6 +4187,73 @@ mod tests {
);
}
#[test]
fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap();
for path in [
"assets/z.png",
"assets/nested/b.png",
"assets/a.png",
"assets/notes.txt",
"assets-other/wrong.png",
"unrelated/wrong.png",
".agent/hidden.png",
"assets/.env",
"assets/secret.key",
"assets/.codex/hidden.png",
"assets/node_modules/hidden.png",
"assets/.git/hidden.png",
] {
let target = root.join(path);
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(target, b"test-only").unwrap();
}
let payload = |arguments: Value| {
let result = bridge_list_project_files(root, &arguments);
assert_eq!(result["isError"], false, "{result}");
serde_json::from_str::<Value>(result["content"][0]["text"].as_str().unwrap()).unwrap()
};
let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 }));
assert_eq!(first["total"], 3);
assert_eq!(first["offset"], 0);
assert_eq!(first["limit"], 2);
assert_eq!(first["nextOffset"], 2);
assert_eq!(first["files"][0]["path"], "assets/a.png");
assert_eq!(first["files"][1]["path"], "assets/nested/b.png");
let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 }));
assert_eq!(last["total"], 3);
assert_eq!(last["files"].as_array().unwrap().len(), 1);
assert_eq!(last["files"][0]["path"], "assets/z.png");
assert!(last["nextOffset"].is_null());
for path in ["assets/nested", "assets/nested/b.png"] {
let page = payload(json!({ "path": path }));
assert_eq!(page["total"], 1);
assert_eq!(page["files"][0]["path"], "assets/nested/b.png");
}
assert_eq!(
payload(json!({ "path": "assets", "query": "NESTED" }))["total"],
1
);
assert_eq!(payload(json!({ "path": "assets" }))["total"], 4);
assert_eq!(payload(json!({ "path": "missing" }))["total"], 0);
let beyond = payload(json!({ "path": "assets", "offset": 20 }));
assert!(beyond["files"].as_array().unwrap().is_empty());
assert!(beyond["nextOffset"].is_null());
for path in [
".agent",
"assets/.env",
"assets/.codex",
"assets/node_modules",
] {
assert_eq!(
bridge_list_project_files(root, &json!({ "path": path }))["isError"],
true
);
}
}
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
@@ -4748,21 +4809,39 @@ mod tests {
let revision_before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
session
.interrupt("fixture cancellation before asset commit".into())
.unwrap();
assert!(
crate::commands::import_local_project_assets_for_agent_with_write_permit(
assert!(crate::tests::with_conversation_write_contention(
root,
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
root,
&["game/import-source.png".to_string()],
Some(&permit),
)
.is_err()
);
assert!(
crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit)
.is_err()
);
),
|| session
.interrupt("fixture cancellation during asset lock wait".into())
.unwrap(),
)
.await
.is_err());
let commit_root = root.to_path_buf();
let commit_bytes = bytes.clone();
let commit_permit = permit.clone();
assert!(crate::tests::with_conversation_write_contention(
root,
async move {
tokio::task::spawn_blocking(move || {
crate::commands::commit_account_asset_for_write_permit_test(
&commit_root,
&commit_bytes,
&commit_permit,
)
})
.await
.unwrap()
},
|| {}
)
.await
.is_err());
assert_eq!(
fs::read(root.join(".agent/manifest.json")).unwrap(),
manifest_before
@@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
/// 直连回合失败的持久化标签。
///
/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与
/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`:
/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」,
/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
} else if normalized.contains("sidecar") && normalized.contains("超时") {
"sidecar-turn-timeout"
} else if normalized.contains("sidecar") && normalized.contains("启动失败") {
"sidecar-launch-failed"
} else if normalized.contains("codex app-server 启动失败") {
"app-server-launch-failed"
} else if normalized.contains("claude code 返回失败终态") {
"claude-code-failed"
} else {
"runtime-failure"
"runtime-unclassified"
}
}
@@ -361,4 +375,39 @@ mod tests {
"tool-invalid-arguments"
);
}
#[test]
fn known_sidecar_and_app_server_failures_keep_their_own_codes() {
assert_eq!(
classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"),
"sidecar-turn-timeout"
);
assert_eq!(
classify_direct_codex_error(
"Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问"
),
"sidecar-launch-failed"
);
assert_eq!(
classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"),
"app-server-launch-failed"
);
assert_eq!(
classify_direct_codex_error("Claude Code 返回失败终态"),
"claude-code-failed"
);
}
#[test]
fn unclassified_failures_get_a_dedicated_code() {
// 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。
assert_eq!(
classify_direct_codex_error("某种尚未识别的新失败"),
"runtime-unclassified"
);
assert_eq!(
classify_direct_codex_error("读取陶泥儿画布资源失败:<redacted-url>"),
"runtime-unclassified"
);
}
}
@@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import(
}
}
if !local_paths.is_empty() {
match import_local_project_assets_for_agent(root, &local_paths) {
match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await
{
Ok(result) => imported.extend(result.assets.into_iter().map(|asset| {
serde_json::json!({
"id": asset.id,
@@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search(
mod asset_import_input_tests {
use super::*;
#[tokio::test]
async fn runtime_local_import_waits_without_blocking_worker() {
let project = tempfile::tempdir().unwrap();
let root = project.path();
init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap();
fs::write(root.join("game/import.md"), b"# Imported").unwrap();
let observation = crate::tests::with_conversation_write_contention(
root,
async {
let observation = observe_agent_runtime_asset_import(
root,
&serde_json::json!({"localPaths": ["game/import.md"]}),
)
.await;
if observation.status != "ok" {
return Err(format!("{observation:?}"));
}
Ok(observation)
},
|| {},
)
.await
.unwrap();
let detail: serde_json::Value =
serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap();
assert_eq!(detail["imported"].as_array().unwrap().len(), 1);
assert_eq!(detail["revisionAdvanceCount"], 1);
}
#[test]
fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() {
let temporary = tempfile::tempdir().expect("create asset context project root");
@@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
}
let stylesVisible = true;
let pointerBlocked = false;
// 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。
const pointerBlocked = getComputedStyle(this).pointerEvents === 'none';
for (let current = this; current instanceof HTMLElement; current = current.parentElement) {
const style = getComputedStyle(current);
const opacity = Number.parseFloat(style.opacity);
@@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() {
&& style.visibility !== 'hidden'
&& style.visibility !== 'collapse'
&& (Number.isNaN(opacity) || opacity > 0);
pointerBlocked ||= style.pointerEvents === 'none';
}
const rect = this.getBoundingClientRect();
@@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1);
let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1);
assert_eq!(
generic,
"e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d"
);
assert_eq!(
tetris,
"b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c"
);
assert_eq!(
lane,
"6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a"
[generic.as_str(), tetris.as_str(), lane.as_str()],
[
"85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d",
"60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff",
"a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466",
]
);
assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit()));
@@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() {
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() {
let result = run_stable_generic_viewport_fixture(false).await;
let result = run_stable_generic_viewport_fixture(false, None).await;
assert!(
result.passed,
"diagnostics={:#?}\nviewports={:#?}",
@@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
let result = run_stable_generic_viewport_fixture(true).await;
let result = run_stable_generic_viewport_fixture(true, None).await;
assert!(
!result.passed,
"a successful desktop cannot hide mobile gameplay failure"
@@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() {
.any(|message| message.starts_with("playtest mobile:")));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() {
let result = run_stable_generic_viewport_fixture(
false,
Some(
r#"<div style="pointer-events:none;padding:20px">
<button type="button" data-playtest-id="start" style="pointer-events:auto;padding:0">
<span style="display:block;padding:20px">Start</span>
</button>
</div>"#,
),
)
.await;
assert!(result.passed, "{:?}", result.diagnostics);
for viewport in REQUIRED_VIEWPORTS {
let playtest = &result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == viewport)
.expect("each viewport must have gameplay evidence")
.result;
assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics);
assert_eq!(playtest.initial_sequence, Some(0));
assert_eq!(playtest.final_sequence, Some(3));
assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready));
assert!(playtest.assertions.iter().all(|assertion| assertion.passed));
}
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"]
async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() {
for (case, markup) in [
(
"inherited-none",
r#"<div style="pointer-events:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"own-none",
r#"<button data-playtest-id="start" style="pointer-events:none">Start</button>"#,
),
(
"own-none-child-auto",
r#"<button data-playtest-id="start" style="pointer-events:none;padding:0"><span style="pointer-events:auto;display:block;padding:20px">Start</span></button>"#,
),
(
"disabled",
r#"<button data-playtest-id="start" disabled>Start</button>"#,
),
(
"disabled-fieldset",
r#"<fieldset disabled><button data-playtest-id="start">Start</button></fieldset>"#,
),
(
"aria-disabled",
r#"<button data-playtest-id="start" aria-disabled="true">Start</button>"#,
),
(
"inert",
r#"<div inert><button data-playtest-id="start" style="pointer-events:auto">Start</button></div>"#,
),
(
"hidden",
r#"<div style="display:none"><button data-playtest-id="start">Start</button></div>"#,
),
(
"covered",
r#"<div style="position:relative;width:max-content"><button data-playtest-id="start">Start</button><div style="position:absolute;inset:0;background:black"></div></div>"#,
),
] {
let result = run_stable_generic_viewport_fixture(false, Some(markup)).await;
assert!(!result.passed, "{case} must fail");
for viewport in REQUIRED_VIEWPORTS {
let playtest = &result
.viewport_playtests
.iter()
.find(|entry| entry.viewport == viewport)
.expect("each viewport must have gameplay evidence")
.result;
assert!(!playtest.passed, "{case} / {viewport:?} must fail");
assert!(
playtest
.diagnostics
.iter()
.any(|message| message.contains("固定试玩控件 start")),
"{case} / {viewport:?}: {:?}",
playtest.diagnostics
);
assert!(
playtest.assertions.iter().any(|assertion| {
assertion.name == "start-control-clicked" && !assertion.passed
}),
"{case} / {viewport:?} must be rejected before input"
);
if case == "own-none-child-auto" {
assert!(
playtest.diagnostics.iter().any(|message| {
message == "固定试玩控件 start 处于 disabled 状态"
}),
"{viewport:?}: child hit must not enable the pointer-blocked target: {:?}",
playtest.diagnostics
);
}
}
}
}
async fn run_stable_generic_viewport_fixture(
mobile_action_broken: bool,
start_markup: Option<&str>,
) -> BrowserValidationResult {
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture(
</body>
</html>"#;
let html = String::from_utf8_lossy(html)
.replace(
r#"<button type="button" data-playtest-id="start">Start</button>"#,
start_markup
.unwrap_or(r#"<button type="button" data-playtest-id="start">Start</button>"#),
)
.replace(
"const mobileActionBroken = false;",
if mobile_action_broken {
@@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner(
model_id: String,
is_default: bool,
catalog_agent_mode: Option<String>,
catalog_protocol: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock()
@@ -1098,13 +1099,34 @@ fn select_game_creator_model_inner(
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
// `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
"cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
"cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
// 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。
let protocol = match catalog_protocol.as_deref().map(str::trim) {
Some("openai_responses") => "openai_responses",
Some("openai_chat") => "openai_chat",
Some("anthropic") => "anthropic",
Some("") | None => {
if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI {
"anthropic"
} else {
"openai_responses"
}
}
Some(value) => return Err(format!("模型协议无效:{value}")),
};
let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI;
if claude != (protocol == "anthropic") {
return Err(format!(
"执行器与协议组合无效:{} 不支持 {protocol}",
config.agent_mode
));
}
config.selected_model_id = model_id;
config.selected_model_is_default = is_default;
config.selected_model_protocol = Some(protocol.to_string());
persist_game_creator_app_config(config, overlays, true)
}
@@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model(
model_id: String,
is_default: bool,
agent_mode: Option<String>,
protocol: Option<String>,
) -> Result<GameCreatorAppConfigView, String> {
select_game_creator_model_inner(model_id, is_default, agent_mode)
select_game_creator_model_inner(model_id, is_default, agent_mode, protocol)
}
fn persist_game_creator_app_config(
@@ -1981,6 +2004,126 @@ mod agent_asset_import_tests {
);
}
fn account_import_contention_downloads(
) -> Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)> {
vec![(
AgentEditorAssetRecord {
asset_id: "account-contention".into(),
origin: AgentEditorAssetOrigin::AccountLibrary,
canvas_project_id: None,
folder_id: None,
folder_label: None,
label: "账户争用测试".into(),
object_key: None,
image_src: None,
asset_object_id: None,
asset_kind: Some("image".into()),
source_type: None,
width: None,
height: None,
size_bytes: None,
},
"image/png".into(),
"assets/uploads/account-contention.png".into(),
tiny_png(),
)]
}
#[tokio::test]
async fn asset_imports_wait_for_conversation_without_blocking_or_losing_updates() {
let project = crate::tests::canonical_test_tempdir("import-contention-");
let root = project.path();
init_local_game_project_at(root, "import-contention", "导入争用").unwrap();
fs::write(root.join("game/local.png"), tiny_png()).unwrap();
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let local_paths = vec!["game/local.png".to_string()];
let (local, account) = crate::tests::with_conversation_write_contention(
root,
async {
tokio::try_join!(
import_local_project_assets_for_agent_in_blocking_pool(
root,
&local_paths,
None,
),
commit_account_editor_asset_downloads_in_blocking_pool(
root,
account_import_contention_downloads(),
None,
None,
),
)
},
|| {},
)
.await
.unwrap();
let manifest = read_existing_manifest_for_project(root).unwrap();
for result in [&local, &account] {
assert_eq!(result.assets.len(), 1);
let asset = &result.assets[0];
assert!(manifest.assets.iter().any(|entry| entry.id == asset.id));
assert_eq!(fs::read(root.join(&asset.local_path)).unwrap(), tiny_png());
}
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before + 2
);
}
#[tokio::test]
async fn account_asset_commit_rechecks_session_after_conversation_wait() {
let _session_guard = install_test_platform_session(
"import-owner-a",
"import-token-a",
"https://import.example.test",
);
let session = current_platform_session().unwrap();
let project = crate::tests::canonical_test_tempdir("import-session-contention-");
let root = project.path();
init_local_game_project_at(root, "import-session-contention", "导入身份争用").unwrap();
let before = fs::read(root.join(".agent/manifest.json")).unwrap();
let before_revision = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let generation = session.identity_generation + 1;
let error = crate::tests::with_conversation_write_contention(
root,
commit_account_editor_asset_downloads_in_blocking_pool(
root,
account_import_contention_downloads(),
None,
Some(session),
),
|| {
install_platform_session(
"import-owner-b",
"import-token-b",
"https://import.example.test",
generation,
generation,
)
.unwrap();
},
)
.await
.err()
.expect("旧账号不能在等待后提交");
assert!(error.contains("authentication-required"), "{error}");
assert_eq!(fs::read(root.join(".agent/manifest.json")).unwrap(), before);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before_revision
);
assert!(!root.join("assets/uploads/account-contention.png").exists());
}
#[test]
fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() {
let project = crate::tests::canonical_test_tempdir("agent-local-import-");
@@ -3237,6 +3380,25 @@ pub(crate) fn import_local_project_assets_for_agent(
import_local_project_assets_for_agent_with_write_permit(root, relative_paths, None)
}
pub(crate) async fn import_local_project_assets_for_agent_in_blocking_pool(
root: &Path,
relative_paths: &[String],
write_permit: Option<&crate::agent::WritePermit>,
) -> Result<RemoteImportResult, String> {
let root = root.to_path_buf();
let relative_paths = relative_paths.to_vec();
let write_permit = write_permit.cloned();
tokio::task::spawn_blocking(move || {
import_local_project_assets_for_agent_with_write_permit(
&root,
&relative_paths,
write_permit.as_ref(),
)
})
.await
.map_err(|error| format!("本地素材导入任务未返回:{error}"))?
}
pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
root: &Path,
relative_paths: &[String],
@@ -3301,7 +3463,11 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit(
));
}
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_import",
)?;
enforce_project_permission_policy(root, "canvas.asset_import")?;
let commit = || {
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(inputs.len());
@@ -3497,20 +3663,50 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit(
}
access.validate_frozen_session()?;
let _platform_session_lease = frozen_session
.as_ref()
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
// 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。
commit_account_editor_asset_downloads(root, downloads, write_permit)
commit_account_editor_asset_downloads_in_blocking_pool(
root,
downloads,
write_permit,
frozen_session,
)
.await
}
async fn commit_account_editor_asset_downloads_in_blocking_pool(
root: &Path,
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
write_permit: Option<&crate::agent::WritePermit>,
frozen_session: Option<PlatformSessionSnapshot>,
) -> Result<RemoteImportResult, String> {
let root = root.to_path_buf();
let write_permit = write_permit.cloned();
tokio::task::spawn_blocking(move || {
commit_account_editor_asset_downloads(
&root,
downloads,
write_permit.as_ref(),
frozen_session.as_ref(),
)
})
.await
.map_err(|error| format!("账户素材提交任务未返回:{error}"))?
}
fn commit_account_editor_asset_downloads(
root: &Path,
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec<u8>)>,
write_permit: Option<&crate::agent::WritePermit>,
frozen_session: Option<&PlatformSessionSnapshot>,
) -> Result<RemoteImportResult, String> {
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_import",
)?;
enforce_project_permission_policy(root, "canvas.asset_import")?;
// 等待后才取得身份租约;等待期间允许换号,提交时拒绝旧账号结果。
let _platform_session_lease = frozen_session
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
let commit = || {
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(downloads.len());
@@ -3639,6 +3835,7 @@ pub(crate) fn commit_account_asset_for_write_permit_test(
bytes.to_vec(),
)],
Some(write_permit),
None,
)
}
@@ -583,7 +583,12 @@ fn check_game_creator_codex_app_server_available() -> Result<(), String> {
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
.map_err(|error| {
// 进程起不来(可执行文件缺失、路径错误、权限不足或被安全软件拦截)与
// 「不支持 app-server 子命令」是两码事:报成后者会让用户去升级 CLI,
// 升级完仍是同一个错。这里保留真实 OS 错误。
format!("Codex CLI 启动失败:{error}")
})?;
if !output.status.success()
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
{
@@ -4037,6 +4042,9 @@ fn merge_game_creator_config_content(
if let Some(selected_model_is_default) = file_config.selected_model_is_default {
config.selected_model_is_default = selected_model_is_default;
}
if let Some(selected_model_protocol) = file_config.selected_model_protocol {
config.selected_model_protocol = Some(selected_model_protocol);
}
Ok(())
}
@@ -302,6 +302,46 @@ async fn host_npm(
.map_err(str::to_string)
}
/// 浏览器验证失败的宿主错误码。
///
/// 验证层会为不同原因返回不同文本:没有可用浏览器、启动失败或启动超时、宿主取消、
/// 浏览器收束未确认、证据写入失败、输入或页面校验失败。把它们统一压成「浏览器有问题」,
/// 用户和值班人员都会朝浏览器方向排查却查不到原因,所以这里按真实原因分流;
/// 未识别的原因落回专用兜底码 `web-preflight-unclassified`,不再借用某个具体子系统。
fn browser_validation_failure_code(error: &str) -> &'static str {
if error.contains("未发现可用的") {
"web-preflight-browser-missing"
} else if error.contains("启动浏览器超时") {
"web-preflight-browser-launch-timeout"
} else if error.contains("启动浏览器失败") {
"web-preflight-browser-launch-failed"
} else if error.contains("宿主已停止本轮浏览器验证") {
"web-preflight-cancelled"
} else if error.contains("browser-cleanup")
|| error.contains("browser-tree-reap")
|| error.contains("browser-version-invalid")
{
"web-preflight-browser-cleanup-failed"
} else if error.contains("浏览器临时") || error.contains("浏览器配置") {
"web-preflight-browser-environment-failed"
} else if error.contains("evidenceRoot")
|| error.contains("证据")
|| error.contains("浏览器验证报告")
|| error.contains("浏览器验证截图")
{
"web-preflight-evidence-failed"
} else if error.contains("预览 URL")
|| error.contains("viewports")
|| error.contains("settleMs")
{
"web-preflight-input-invalid"
} else if error.contains("页面导航失败") || error.contains("截图不是有效 PNG") {
"web-preflight-page-check-failed"
} else {
"web-preflight-unclassified"
}
}
pub(crate) async fn host_web_creation_preflight() -> Value {
let started = Instant::now();
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
@@ -322,7 +362,7 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
}).await;
let result = validation.map_err(|_| "web-preflight-browser-failed")?;
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
let mut pngs = Vec::new();
for viewport in result.viewport_results {
@@ -382,6 +422,24 @@ fn claim_preparation(root: &Path) -> Result<Option<(ScaffoldReceipt, Preparation
Ok(Some((receipt, claim)))
}
/// 依赖准备收据里的失败码。
///
/// 收据只存机器码,但也不能把任何失败都写成同一个通用码——那等于把这条诊断线索作废。
/// 这里从宿主错误里取出真正的码(`Web 新项目依赖准备失败:web-build-timeout;…` 取
/// `web-build-timeout`,裸码原样保留),只有确实拿不到码时才落回专用兜底。
fn scaffold_failure_code(error: &str) -> String {
let candidate = error
.split(|ch: char| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'))
.find(|token| {
token.len() >= 4
&& token.chars().any(|ch| ch.is_ascii_lowercase())
&& token.chars().any(|ch| ch == '-')
});
candidate
.unwrap_or("web-scaffold-preparation-failed")
.to_string()
}
pub(crate) async fn prepare_new_web_project_at(
root: &Path,
creation_type: Option<&str>,
@@ -457,7 +515,7 @@ pub(crate) async fn prepare_new_web_project_at(
receipt.last_failure_code = result
.as_ref()
.err()
.map(|_| "web-scaffold-preparation-failed".into());
.map(|error| scaffold_failure_code(error));
if result.is_ok() {
let entry = fs::read(ordinary_path(root, "game/dist/index.html")?)
.map_err(|_| "web-scaffold-dist-missing")?;
@@ -474,6 +532,78 @@ pub(crate) async fn prepare_new_web_project_at(
mod tests {
use super::*;
#[test]
fn browser_validation_failures_keep_their_own_reason_codes() {
assert_eq!(
browser_validation_failure_code(
"未发现可用的 Google Chrome、Chromium 或 Microsoft Edge"
),
"web-preflight-browser-missing"
);
assert_eq!(
browser_validation_failure_code("启动浏览器超时"),
"web-preflight-browser-launch-timeout"
);
assert_eq!(
browser_validation_failure_code("启动浏览器失败:2"),
"web-preflight-browser-launch-failed"
);
assert_eq!(
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
"web-preflight-cancelled"
);
assert_eq!(
browser_validation_failure_code(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
),
"web-preflight-browser-cleanup-failed"
);
assert_eq!(
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
"web-preflight-browser-environment-failed"
);
assert_eq!(
browser_validation_failure_code("evidenceRoot 必须是真实目录且不能是符号链接"),
"web-preflight-evidence-failed"
);
assert_eq!(
browser_validation_failure_code("只允许带显式端口的 http://127.0.0.1 预览 URL"),
"web-preflight-input-invalid"
);
assert_eq!(
browser_validation_failure_code("desktop 页面导航失败:timeout"),
"web-preflight-page-check-failed"
);
// 未识别的原因必须落回专用兜底码,不能借上面任何一个具体子系统码。
assert_eq!(
browser_validation_failure_code("某种尚未识别的新失败"),
"web-preflight-unclassified"
);
}
#[test]
fn scaffold_receipt_keeps_the_real_failure_code() {
assert_eq!(
scaffold_failure_code("web-build-timeout"),
"web-build-timeout"
);
assert_eq!(
scaffold_failure_code("Web 新项目依赖准备失败:web-build-command-failed;尚未启动生成"),
"web-build-command-failed"
);
assert_eq!(
scaffold_failure_code(
"Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成"
),
"web-preflight-browser-missing"
);
// 拿不到机器码时才使用专用兜底。
assert_eq!(
scaffold_failure_code("没有机器码的中文说明"),
"web-scaffold-preparation-failed"
);
}
#[test]
fn only_new_unmodified_scaffolds_receive_a_preparation_receipt() {
let root = tempfile::tempdir().unwrap();
@@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String {
"codex".to_string()
}
fn default_model_protocol() -> String {
"openai_responses".to_string()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireModelSummary {
@@ -23,6 +27,9 @@ struct WireModelSummary {
display_name: String,
#[serde(default = "default_codex_agent_mode")]
agent_mode: String,
/// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。
#[serde(default = "default_model_protocol")]
protocol: String,
}
#[derive(Debug, Deserialize)]
@@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse {
id: model.id,
display_name: model.display_name,
agent_mode: model.agent_mode,
protocol: model.protocol,
})
.collect(),
revision: catalog.revision,
@@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile {
selected_model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option<bool>,
/// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_protocol: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@@ -873,6 +876,9 @@ struct GameCreatorAppConfig {
selected_model_id: String,
#[serde(default)]
selected_model_is_default: bool,
/// 所选模型的上游线协议;缺省按执行器推导。
#[serde(default)]
selected_model_protocol: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -1384,6 +1390,7 @@ impl Default for GameCreatorAppConfig {
editor_api: GameCreatorEditorApiConfig::default(),
selected_model_id: String::new(),
selected_model_is_default: false,
selected_model_protocol: None,
}
}
}
@@ -1,5 +1,8 @@
use super::*;
#[cfg(test)]
mod listing_tests;
/**
* Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。
*
@@ -18,6 +21,24 @@ fn is_engine_generated_root_directory(relative_path: &str) -> bool {
pub(crate) fn list_local_project_files_at(
root: &Path,
) -> Result<ListLocalProjectFilesResult, String> {
list_local_project_files_scoped_at(root, None, |_| false)
}
/// 在读取元数据前裁剪范围;调用方的可见性规则不改变通用文件树。
pub(crate) fn list_local_project_files_scoped_at(
root: &Path,
scope: Option<&str>,
exclude: impl Fn(&str) -> bool,
) -> Result<ListLocalProjectFilesResult, String> {
list_local_project_files_with_metadata(root, scope, exclude, |path| fs::symlink_metadata(path))
}
fn list_local_project_files_with_metadata(
root: &Path,
scope: Option<&str>,
exclude: impl Fn(&str) -> bool,
mut read_metadata: impl FnMut(&Path) -> std::io::Result<fs::Metadata>,
) -> Result<ListLocalProjectFilesResult, String> {
validate_project_root(root)?;
if !root.exists() {
@@ -37,28 +58,58 @@ pub(crate) fn list_local_project_files_at(
*/
let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some();
let is_unity_project = discover_local_unity_project_root(root)?.is_some();
let scope_prefix = scope.map(|path| format!("{path}/"));
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir)
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
{
// 目录排队后可能消失或被换成链接,进入前重新核验。
let metadata = match read_metadata(&dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(format!("读取文件元数据失败:{}: {error}", dir.display())),
};
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
continue;
}
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(format!("读取项目目录失败:{}: {error}", dir.display())),
};
for entry in entries {
let entry =
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
let relative_path = relative_project_path(root, &path)?;
let in_scope = scope.is_none_or(|scope| {
relative_path == scope
|| scope_prefix
.as_ref()
.is_some_and(|prefix| relative_path.starts_with(prefix))
});
let scope_ancestor =
scope.is_some_and(|scope| scope.starts_with(&format!("{relative_path}/")));
if (!in_scope && !scope_ancestor) || exclude(&relative_path) {
continue;
}
let file_type = metadata.file_type();
let relative_path = relative_project_path(root, &path)?;
if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path)
|| is_agent_workbench_control_path(&relative_path)
{
continue;
}
let metadata = match read_metadata(&path) {
Ok(metadata) => metadata,
// read_dir 只观察名字,不保证随后还能读取该条目。
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(format!("读取文件元数据失败:{}: {error}", path.display()))
}
};
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
continue;
}
let file_type = metadata.file_type();
let modified_at = metadata
.modified()
.ok()
@@ -80,14 +131,16 @@ pub(crate) fn list_local_project_files_at(
{
continue;
}
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "directory".to_string(),
size: 0,
modified_at,
});
if in_scope {
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "directory".to_string(),
size: 0,
modified_at,
});
}
dirs.push(path);
} else if file_type.is_file() {
} else if file_type.is_file() && in_scope {
let size = metadata.len();
files.push(LocalProjectFileEntry {
path: relative_path,
@@ -0,0 +1,288 @@
use super::*;
use std::io::{Error, ErrorKind};
use std::sync::mpsc;
use std::time::Duration;
#[test]
fn project_file_listing_survives_lock_release_between_enumeration_and_metadata() {
for scope in [None, Some("assets")] {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().canonicalize().unwrap();
fs::create_dir(root.join("assets")).unwrap();
fs::write(root.join("assets/hero.png"), b"image").unwrap();
let (ready_tx, ready_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let (released_tx, released_rx) = mpsc::channel();
let timeout = Duration::from_secs(10);
std::thread::scope(|threads| {
let owner_root = &root;
let owner = threads.spawn(move || {
let lock = acquire_project_write_lock(owner_root, "file-listing-race").unwrap();
ready_tx.send(()).unwrap();
release_rx.recv_timeout(timeout).unwrap();
drop(lock);
released_tx.send(()).unwrap();
});
ready_rx.recv_timeout(timeout).unwrap();
let mut observed_gap = false;
let listed = list_local_project_files_with_metadata(
&root,
scope,
|_| false,
|path| {
if scope.is_some() {
assert!(
!path.starts_with(root.join(".agent")),
"定向列举不得检查锁目录"
);
}
let trigger = if scope.is_some() {
root.join("assets")
} else {
root.join(PROJECT_WRITE_LOCK_PATH)
};
if path == trigger && !observed_gap {
// 全量列举复现锁的 TOCTOU;定向列举在访问 assets 时同时释放锁。
release_tx.send(()).unwrap();
released_rx.recv_timeout(timeout).unwrap();
let result = fs::symlink_metadata(path);
if scope.is_none() {
assert_eq!(result.as_ref().unwrap_err().kind(), ErrorKind::NotFound);
}
observed_gap = true;
return result;
}
fs::symlink_metadata(path)
},
)
.unwrap();
owner.join().unwrap();
assert!(observed_gap, "夹具必须在列举期间释放真实锁");
assert!(listed
.files
.iter()
.any(|file| file.path == "assets/hero.png"));
assert!(!listed
.files
.iter()
.any(|file| file.path == PROJECT_WRITE_LOCK_PATH));
});
}
}
#[test]
fn project_file_listing_prunes_scope_and_private_paths_before_metadata() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
for path in [
"assets/nested",
"assets-other",
".agent/runtime",
".agent/checkpoints",
".agent/workbench",
] {
fs::create_dir_all(root.join(path)).unwrap();
}
fs::write(root.join("assets/nested/hero.png"), b"image").unwrap();
fs::write(root.join("assets/other.png"), b"other").unwrap();
fs::write(root.join(".env"), b"test-only").unwrap();
for scope in ["assets/nested", "assets/nested/hero.png"] {
let listed = list_local_project_files_with_metadata(
root,
Some(scope),
|_| false,
|path| {
if path != root
&& path != root.join("assets")
&& !path.starts_with(root.join("assets/nested"))
{
panic!("不应读取范围外元数据:{}", path.display());
}
fs::symlink_metadata(path)
},
)
.unwrap();
assert_eq!(
listed
.files
.iter()
.filter(|file| file.kind == "file")
.map(|file| file.path.as_str())
.collect::<Vec<_>>(),
["assets/nested/hero.png"]
);
}
let listed = list_local_project_files_with_metadata(
root,
None,
|path| path == ".env",
|path| {
for excluded in [
".env",
".agent/runtime",
".agent/checkpoints",
".agent/workbench",
] {
assert!(
!path.starts_with(root.join(excluded)),
"过滤必须早于元数据读取"
);
}
fs::symlink_metadata(path)
},
)
.unwrap();
assert!(
listed.files.iter().any(|file| file.path == ".agent"),
"通用文件树仍可见普通控制面目录"
);
}
#[test]
fn project_file_listing_skips_disappearing_entries_but_preserves_io_errors() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
fs::create_dir(root.join("assets")).unwrap();
let target = root.join("assets/hero.png");
fs::write(&target, b"image").unwrap();
for kind in [ErrorKind::PermissionDenied, ErrorKind::Other] {
let error = list_local_project_files_with_metadata(
root,
Some("assets"),
|_| false,
|path| {
if path == target {
return Err(Error::new(kind, "listing-error-sentinel"));
}
fs::symlink_metadata(path)
},
)
.unwrap_err();
assert!(
error.contains("hero.png") && error.contains("listing-error-sentinel"),
"{error}"
);
}
let listed = list_local_project_files_with_metadata(
root,
Some("assets"),
|_| false,
|path| {
if path == target {
fs::remove_file(path).unwrap();
}
fs::symlink_metadata(path)
},
)
.unwrap();
assert!(!listed.files.iter().any(|file| file.kind == "file"));
}
#[test]
fn project_file_listing_handles_queued_directory_disappearance_and_type_change() {
for change in ["before-metadata", "before-read-dir", "replace-with-file"] {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
let target = root.join("assets");
fs::create_dir(&target).unwrap();
let mut visits = 0;
let result = list_local_project_files_with_metadata(
root,
Some("assets"),
|_| false,
|path| {
if path == target {
visits += 1;
if visits == 2 {
let previous = fs::symlink_metadata(path);
fs::remove_dir(path).unwrap();
if change == "before-read-dir" {
return previous;
}
if change == "replace-with-file" {
fs::write(path, b"not a directory").unwrap();
}
}
}
fs::symlink_metadata(path)
},
);
assert_eq!(visits, 2);
if change == "replace-with-file" {
assert!(result.unwrap_err().contains("读取项目目录失败"));
} else {
assert!(!result.unwrap().files.iter().any(|file| file.kind == "file"));
}
}
}
#[cfg(unix)]
#[test]
fn project_file_listing_reports_target_permission_errors_but_ignores_unrelated_ones() {
use std::os::unix::fs::PermissionsExt;
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
let blocked = root.join("blocked");
fs::create_dir(&blocked).unwrap();
fs::create_dir(root.join("assets")).unwrap();
fs::write(root.join("assets/hero.png"), b"image").unwrap();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap();
// 先恢复权限再断言,避免失败时影响临时目录回收。
let scoped = list_local_project_files_scoped_at(root, Some("assets"), |_| false);
let blocked_result = list_local_project_files_scoped_at(root, Some("blocked"), |_| false);
let permission_denied = fs::read_dir(&blocked).is_err();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap();
assert!(scoped
.unwrap()
.files
.iter()
.any(|file| file.path == "assets/hero.png"));
// root / CAP_DAC_OVERRIDE 环境不能制造真实 EACCES;注入用例仍覆盖错误分支。
if permission_denied {
let error = blocked_result.unwrap_err();
assert!(
error.contains("读取项目目录失败") && error.contains("blocked"),
"{error}"
);
}
}
#[cfg(unix)]
#[test]
fn project_file_listing_never_enters_linked_or_replaced_directories() {
use std::os::unix::fs::symlink;
let temporary = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let root = temporary.path();
fs::write(outside.path().join("private.txt"), b"outside").unwrap();
symlink(outside.path(), root.join("linked")).unwrap();
fs::create_dir(root.join("assets")).unwrap();
let mut visits = 0;
let listed = list_local_project_files_with_metadata(
root,
None,
|_| false,
|path| {
if path == root.join("assets") {
visits += 1;
if visits == 2 {
fs::remove_dir(path).unwrap();
symlink(outside.path(), path).unwrap();
}
}
fs::symlink_metadata(path)
},
)
.unwrap();
assert_eq!(visits, 2);
assert!(!listed.files.iter().any(|file| file.kind == "file"));
for scope in ["linked", "linked/private.txt", "assets/private.txt"] {
assert!(
list_local_project_files_scoped_at(root, Some(scope), |_| false)
.unwrap()
.files
.is_empty()
);
}
}
@@ -4302,7 +4302,8 @@ fn commit_resource_edit_asset_internal(
reference_resource_ids: vec![source.canonical_resource_id.clone()],
},
};
let _project_lock = acquire_project_write_lock(root, "resource.edit")?;
let _project_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
let mut manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -4615,6 +4616,10 @@ fn commit_resource_edit_asset_with_frozen_platform_session(
ledger: &mut ResourceEditLedger,
platform_session: Option<&PlatformSessionSnapshot>,
) -> Result<DeriveLocalProjectResourceResult, String> {
// 项目锁先于平台身份租约,避免等待时挡住换号或与其它提交形成锁顺序反转。
// 内层提交在同一线程复用项目锁,整个事务不能跨 await 或线程迁移。
let _project_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?;
with_frozen_resource_edit_platform_session(platform_session, || {
commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger)
})
@@ -4696,7 +4701,10 @@ fn commit_resource_edit_version(
ledger: &mut ResourceEditLedger,
) -> Result<DeriveLocalProjectResourceResult, String> {
let version_id = format!("edit-{}", input.operation_id);
let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?;
let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"resource.edit.version",
)?;
let mut manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -5518,7 +5526,7 @@ pub(crate) async fn derive_local_project_resource_at(
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?;
let _operation_guard = operation_lock.lock().await;
let operation_guard = operation_lock.lock_owned().await;
let manifest = read_existing_manifest_for_project(root)?;
if manifest.project_id != input.expected_project_id {
return Err("project-identity-conflict".to_string());
@@ -5652,23 +5660,36 @@ pub(crate) async fn derive_local_project_resource_at(
ledger
}
};
if ledger.phase == ResourceEditLedgerPhase::Committed {
if ledger.phase == ResourceEditLedgerPhase::Committed
|| input.edit_kind == LocalProjectResourceEditKind::Version
{
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?;
cleanup_committed_resource_edit_staging(root, &mut ledger)?;
return committed_resource_edit_result(
root,
&input,
&source.canonical_resource_id,
ledger.result_asset_id.as_deref(),
ledger.result_version_id.as_deref(),
);
}
if input.edit_kind == LocalProjectResourceEditKind::Version {
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger);
let project_guard = project_lock.lock_owned().await;
let root = root.to_path_buf();
return tokio::task::spawn_blocking(move || {
// 调用方取消等待时,互斥仍由正在运行的提交持有。
let _operation_guard = operation_guard;
let _project_guard = project_guard;
if ledger.phase == ResourceEditLedgerPhase::Committed {
let _project_write_lock =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(
&root,
"resource.edit.cleanup",
)?;
cleanup_committed_resource_edit_staging(&root, &mut ledger)?;
committed_resource_edit_result(
&root,
&input,
&source.canonical_resource_id,
ledger.result_asset_id.as_deref(),
ledger.result_version_id.as_deref(),
)
} else {
commit_resource_edit_version(&root, &input, &source, &prompt, &mut ledger)
}
})
.await
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))?;
}
let remote_credentials = if input.edit_kind.is_remote_media()
&& ledger.phase != ResourceEditLedgerPhase::MediaDownloaded
@@ -5775,16 +5796,28 @@ pub(crate) async fn derive_local_project_resource_at(
}
}
let project_lock = resource_edit_project_mutation_lock(root)?;
let _project_guard = project_lock.lock().await;
let result = commit_resource_edit_asset_with_frozen_platform_session(
root,
&input,
&source,
&prompt,
&asset_name,
&mut ledger,
frozen_platform_session.as_ref(),
)?;
let project_guard = project_lock.lock_owned().await;
let commit_root = root.to_path_buf();
let commit_input = input.clone();
let commit_session = frozen_platform_session.clone();
let (result, ledger, _operation_guard, _project_guard) =
tokio::task::spawn_blocking(move || {
let operation_guard = operation_guard;
let project_guard = project_guard;
let result = commit_resource_edit_asset_with_frozen_platform_session(
&commit_root,
&commit_input,
&source,
&prompt,
&asset_name,
&mut ledger,
commit_session.as_ref(),
)?;
// 正常返回仍覆盖后续 binding 写入;取消等待则在阻塞任务结束后释放。
Ok::<_, String>((result, ledger, operation_guard, project_guard))
})
.await
.map_err(|error| format!("资源编辑提交任务未返回:{error}"))??;
if let (Some((api_base_url, api_key, _)), Some(principal)) =
(remote_credentials.as_ref(), binding_principal.as_ref())
{
@@ -6462,7 +6495,7 @@ mod tests {
drop(other_project_guard);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() {
let directory = tempfile::tempdir().expect("create concurrent commit fixture");
let root = directory.path();
@@ -6513,14 +6546,31 @@ mod tests {
requests.push(request);
}
let (first, second) = tokio::join!(
derive_local_project_resource_at(requests[0].clone()),
derive_local_project_resource_at(requests[1].clone())
);
first.expect("commit first operation");
second.expect("commit second operation");
fs::write(root.join("game/parallel.md"), b"# Imported").unwrap();
let local_paths = vec!["game/parallel.md".to_string()];
let (_, _, imported) = crate::tests::with_conversation_write_contention(
root,
async {
tokio::try_join!(
derive_local_project_resource_at(requests[0].clone()),
derive_local_project_resource_at(requests[1].clone()),
crate::commands::import_local_project_assets_for_agent_in_blocking_pool(
root,
&local_paths,
None,
),
)
},
|| {},
)
.await
.expect("并行导入和资源提交必须等待后成功");
let committed = read_existing_manifest_for_project(root).expect("read committed manifest");
assert!(committed
.assets
.iter()
.any(|asset| asset.id == imported.assets[0].id));
for request in &requests {
assert_eq!(
committed
@@ -6535,10 +6585,185 @@ mod tests {
read_game_creator_agent_runtime_project_revision(root)
.expect("read committed revision")
.revision,
2
3
);
}
fn staged_background_removal_for_contention(root: &Path) -> DeriveLocalProjectResourceInput {
init_local_game_project_at(root, PROJECT_ID, "资源提交争用").unwrap();
let uploaded =
upload_local_asset_at(root, "source.png", "image/png", &resource_editor_test_png())
.unwrap();
let manifest = read_existing_manifest_for_project(root).unwrap();
let mut request = input(
root,
Uuid::new_v4().to_string(),
LocalProjectResourceEditKind::BackgroundRemoval,
format!("asset:{}", uploaded.id),
);
request.source_asset_id = Some(uploaded.id);
request.source_path = Some(uploaded.local_path);
request.source_media_type = Some("image/png".into());
let source = resolve_resource_edit_source(root, &manifest, &request).unwrap();
let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded);
ledger.request_fingerprint = resource_edit_request_fingerprint(
&request,
&source,
&request.prompt,
&request.asset_name,
)
.unwrap();
ledger.remote_operation_id = Some("completed-background-removal".into());
ledger.remote_object_key = Some("generated/completed.png".into());
ledger.staged_media_type = Some("image/png".into());
ledger.staged_extension = Some("png".into());
write_resource_edit_ledger(root, &ledger).unwrap();
write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png())
.unwrap();
request
}
#[tokio::test]
async fn downloaded_resource_commit_and_replay_wait_without_new_remote_work() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
// 不提供远端凭据:下载完成后的提交与重放必须只使用原账本和 staging。
let result = crate::tests::with_conversation_write_contention(
root,
derive_local_project_resource_at(request.clone()),
|| {},
)
.await
.unwrap();
assert_eq!(result.operation_id, request.operation_id);
assert_eq!(result.committed_project_revision, before + 1);
let ledger = read_resource_edit_ledger(root, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed);
assert_eq!(ledger.idempotency_key, request.idempotency_key);
assert_eq!(
ledger.remote_operation_id.as_deref(),
Some("completed-background-removal")
);
let replay = crate::tests::with_conversation_write_contention(
root,
derive_local_project_resource_at(request.clone()),
|| {},
)
.await
.unwrap();
assert_eq!(replay.operation_id, result.operation_id);
assert_eq!(
replay.committed_project_revision,
result.committed_project_revision
);
assert_eq!(replay.asset, result.asset);
assert_eq!(
replay
.manifest
.assets
.iter()
.filter(|asset| asset.id == format!("edit-{}", request.operation_id))
.count(),
1
);
}
#[tokio::test]
async fn cancelled_resource_commit_wait_keeps_operation_and_project_guards() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let operation_lock = resource_edit_operation_lock(root, &request.operation_id).unwrap();
let project_lock = resource_edit_project_mutation_lock(root).unwrap();
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
crate::tests::with_conversation_write_contention(
root,
async {
let mut commit = Box::pin(derive_local_project_resource_at(request.clone()));
assert!(futures::poll!(commit.as_mut()).is_pending());
drop(commit);
// JoinHandle 被丢弃后阻塞任务仍会继续,互斥必须随任务保留。
assert!(operation_lock.try_lock().is_err());
assert!(project_lock.try_lock().is_err());
let _finished =
tokio::time::timeout(Duration::from_secs(10), operation_lock.lock())
.await
.expect("阻塞提交应在项目锁释放后完成");
Ok(())
},
|| {},
)
.await
.unwrap();
assert_eq!(
read_resource_edit_ledger(root, &request.operation_id)
.unwrap()
.unwrap()
.phase,
ResourceEditLedgerPhase::Committed
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before + 1
);
assert!(project_lock.try_lock().is_ok());
}
#[tokio::test]
async fn resource_commit_timeout_preserves_staging_and_resumes_original_operation() {
let _session_guard = clear_test_platform_session();
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
let request = staged_background_removal_for_contention(root);
let before = read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision;
let ledger_before = read_resource_edit_ledger(root, &request.operation_id).unwrap();
let lock = acquire_project_write_lock(root, "conversation.write").unwrap();
let started = std::time::Instant::now();
let error = derive_local_project_resource_at(request.clone())
.await
.unwrap_err();
assert!(
started.elapsed() >= Duration::from_secs(9),
"未使用完整有界等待:{error}"
);
assert!(error.contains("commandId=conversation.write"), "{error}");
assert!(error.contains("ownerIsSelf=true"), "{error}");
assert_eq!(
read_resource_edit_ledger(root, &request.operation_id).unwrap(),
ledger_before
);
assert_eq!(
read_resource_edit_staging(root, &request.operation_id).unwrap(),
resource_editor_test_png()
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
before
);
drop(lock);
let recovered = derive_local_project_resource_at(request.clone())
.await
.unwrap();
assert_eq!(recovered.operation_id, request.operation_id);
assert_eq!(recovered.committed_project_revision, before + 1);
}
#[test]
fn edit_kind_provenance_uses_stable_kebab_case_values() {
assert_eq!(
@@ -38,9 +38,9 @@ fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials(
write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap();
let mut config = read_game_creator_app_config().unwrap().config;
assert_eq!(config.llm.model, "a/v1");
let selected = select_game_creator_model("b:v2".into(), false, None).unwrap();
let selected = select_game_creator_model("b:v2".into(), false, None, None).unwrap();
assert_eq!(selected.config.llm.model, "b:v2");
assert!(select_game_creator_model("not-listed".into(), false, None).is_err());
assert!(select_game_creator_model("not-listed".into(), false, None, None).is_err());
config.llm.visible_models = vec!["b:v2".into()];
config.llm.api_key = "changed-fixture-key".into();
write_game_creator_app_config(config).unwrap();
@@ -817,6 +817,7 @@ fn app_config_commands_write_runtime_config_file() {
agent_llm,
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
})
.expect("write runtime config");
@@ -964,7 +965,7 @@ fn app_config_model_selection_only_updates_model_overlay() {
)
.expect("write overlay");
let saved =
select_game_creator_model("new-model".to_string(), true, None).expect("select model");
select_game_creator_model("new-model".to_string(), true, None, None).expect("select model");
let effective = load_game_creator_app_config().expect("effective config");
assert_eq!(saved.config.selected_model_id, "new-model");
assert_eq!(effective.selected_model_id, "new-model");
@@ -986,13 +987,13 @@ fn model_selection_default_flag_round_trips() {
fs::create_dir_all(&root).expect("runtime config dir");
let _guard = use_test_runtime_config_dir(root.clone());
let saved =
select_game_creator_model("quality".to_string(), true, None).expect("select default model");
let saved = select_game_creator_model("quality".to_string(), true, None, None)
.expect("select default model");
assert_eq!(saved.config.selected_model_id, "quality");
assert!(saved.config.selected_model_is_default);
let saved =
select_game_creator_model("fast".to_string(), false, None).expect("select explicit model");
let saved = select_game_creator_model("fast".to_string(), false, None, None)
.expect("select explicit model");
assert_eq!(saved.config.selected_model_id, "fast");
assert!(!saved.config.selected_model_is_default);
@@ -1021,6 +1022,7 @@ fn app_config_write_rejects_invalid_api_kind() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -1048,6 +1050,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -1075,6 +1078,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
agent_llm: BTreeMap::new(),
selected_model_id: "default".to_string(),
selected_model_is_default: false,
selected_model_protocol: None,
});
assert!(result
@@ -16,6 +16,44 @@ const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from
const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500);
const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024;
/// 在另一线程持有真实项目锁;单线程 runtime 必须能在释放锁前推进心跳。
pub(crate) async fn with_conversation_write_contention<T>(
root: &Path,
operation: impl std::future::Future<Output = Result<T, String>>,
while_waiting: impl FnOnce(),
) -> Result<T, String> {
let root = root.to_path_buf();
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let holder = std::thread::spawn(move || {
let _lock = acquire_project_write_lock(&root, "conversation.write").unwrap();
ready_tx.send(()).unwrap();
// 若回归为 runtime 同步阻塞,限时释放以让断言失败而不是挂死测试。
let _ = release_rx.recv_timeout(Duration::from_secs(5));
});
ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let finished = AtomicBool::new(false);
let started = Instant::now();
let (result, ()) = tokio::join!(
async {
let result = operation.await;
finished.store(true, Ordering::SeqCst);
result
},
async {
tokio::time::sleep(Duration::from_millis(100)).await;
let elapsed = started.elapsed();
let completed_while_locked = finished.load(Ordering::SeqCst);
while_waiting();
let _ = release_tx.send(());
assert!(elapsed < Duration::from_secs(3), "项目锁等待阻塞了 runtime");
assert!(!completed_while_locked, "项目锁释放前操作已返回");
}
);
holder.join().unwrap();
result
}
// 委派测试显式持有与正式 action 执行链相同的项目写锁,随后调用现役核心。
fn observe_agent_runtime_agent_delegate(
root: &Path,
@@ -3860,6 +3860,23 @@ fn local_project_file_listing_skips_engine_generated_directories_only_for_engine
.expect("write cocos package.json");
let listed = list_local_project_files_at(cocos).expect("list cocos project files");
for scope in ["library", "library/imported/hero.json", "temp/programming"] {
assert!(
list_local_project_files_scoped_at(cocos, Some(scope), |_| false)
.expect("list generated scope")
.files
.is_empty()
);
}
fs::create_dir_all(cocos.join("assets/library")).unwrap();
fs::write(cocos.join("assets/library/kept.png"), b"image").unwrap();
assert!(
list_local_project_files_scoped_at(cocos, Some("assets/library"), |_| false)
.unwrap()
.files
.iter()
.any(|file| file.path == "assets/library/kept.png")
);
let paths = listed
.files
.iter()
@@ -122,7 +122,7 @@ pub(crate) async fn run_ui_design_doc_workflow(
)
.await?;
// 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。
let cut_images = register_cut_image_sprites(root, &dto)?;
let cut_images = register_cut_image_sprites(root, &dto).await?;
let mut backfill_errors = cut_images.errors;
progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?;
backfill_errors.extend(apply_separation(
@@ -6,7 +6,7 @@
//! 说明,不回滚已经登记成功的资源。
use super::normalize_cut_image_path;
use crate::import_local_project_assets_for_agent;
use crate::import_local_project_assets_for_agent_in_blocking_pool;
use crate::ui_editor::commands::SeparationDTO;
use crate::ui_editor::resource::sprite::SpriteAsset;
use crate::ui_editor::utils::SpriteAssetId;
@@ -29,7 +29,7 @@ pub(crate) struct CutImageSprites {
pub(crate) errors: Vec<String>,
}
pub(crate) fn register_cut_image_sprites(
pub(crate) async fn register_cut_image_sprites(
root: &Path,
dto: &SeparationDTO,
) -> Result<CutImageSprites, String> {
@@ -43,7 +43,8 @@ pub(crate) fn register_cut_image_sprites(
}
let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new();
for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) {
let imported = import_local_project_assets_for_agent(root, batch)?;
let imported =
import_local_project_assets_for_agent_in_blocking_pool(root, batch, None).await?;
if imported.assets.len() != batch.len() {
return Err(format!(
"本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个",
@@ -140,6 +141,36 @@ mod tests {
}
}
#[tokio::test]
async fn cut_image_registration_waits_without_blocking_worker() {
let project = tempfile::tempdir().unwrap();
let root = project.path();
crate::init_local_game_project_at(root, "cut-import-wait", "切图导入等待").unwrap();
image::DynamicImage::new_rgba8(2, 2)
.save(root.join("game/cut.png"))
.unwrap();
let dto = SeparationDTO {
bound_nodes: vec![bound("game/cut.png"), bound("game/cut.png")],
problematic_nodes: Vec::new(),
};
let result = crate::tests::with_conversation_write_contention(
root,
register_cut_image_sprites(root, &dto),
|| {},
)
.await
.unwrap();
assert_eq!(result.sprites.len(), 1);
assert!(result.by_path.contains_key("game/cut.png"));
assert!(result.errors.is_empty());
assert_eq!(
crate::read_game_creator_agent_runtime_project_revision(root)
.unwrap()
.revision,
1
);
}
#[test]
fn unique_paths_normalize_separators_and_drop_duplicates() {
let dto = SeparationDTO {
@@ -0,0 +1,116 @@
/**
* 首页自动创建失败的用户可见文案与证据出口。
*
* 两条硬约束:
*
* 1. 首页状态区只显示**本模块算出来的**文案。宿主返回的原始文本(Provider 英文、IPC 报错、
* 测试夹具字符串)一律不上屏,只作为证据写进 `application.log`;原来的写法把整段底层文本
* 透传上屏,或者反过来把它覆盖成通用文案,两种都不对。
* 2. 兜底必须是专门的兜底文案,不能借用某个具体原因冒充(把 IPC 断连说成「环境预检未通过」、
* 把已经建好的工作区说成「创建未完成」都属此类)。宿主给了机器码就显示机器码,给不出就
* 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。
*/
/** 未分类失败的专用兜底文案。 */
export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试';
export const HOME_CREATION_AFTER_PROJECT_FAILURE =
'工作区已创建,但未能进入项目开发;请从「已创建的工作区」打开后重试。';
export const HOME_CREATION_IPC_FAILURE =
'与客户端本体的连接中断,创建未完成;请重启客户端后重试。';
export const HOME_CREATION_AFTER_PROJECT_IPC_FAILURE =
'工作区已创建;与客户端本体的连接中断,未能进入项目开发;请重启客户端后从「已创建的工作区」打开。';
/** WebView / Tauri 调用通道自身失联时的固定形状。 */
const HOME_CREATION_TRANSPORT_PATTERNS = [
'failed to fetch',
'ipc custom protocol failed',
'window.__tauri__',
] as const;
/** 宿主错误里的机器码(`project-revision-conflict`、`web-build-timeout` 这类连字符标识)。 */
const HOME_CREATION_HOST_CODE = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/;
/**
* 已经整理好用户文案的创建失败。
*
* `diagnostic` 保留宿主原始文本,只用于日志与诊断;因为它可能包含 Provider、路径或凭据文本,
* 不允许直接进入首页状态区。
*/
export class HomeCreationFailure extends Error {
readonly userMessage: string;
readonly diagnostic: string;
constructor(userMessage: string, diagnostic: string) {
super(userMessage);
this.name = 'HomeCreationFailure';
this.userMessage = userMessage;
this.diagnostic = diagnostic;
}
}
export function homeCreationDiagnosticText(error: unknown): string {
if (error instanceof Error) return error.message.trim();
if (typeof error === 'string') return error.trim();
return '';
}
function isHomeCreationTransportText(text: string): boolean {
const normalized = text.toLowerCase();
return HOME_CREATION_TRANSPORT_PATTERNS.some((pattern) =>
normalized.includes(pattern),
);
}
/**
* 由宿主错误算出用户可见文案。
*
* `projectCreated` 必须由调用方按自己掌握的事实传入:工作区已经建好时,文案要保留这个事实
* 并指向「已创建的工作区」恢复入口。
*/
export function homeCreationFailureMessage(
error: unknown,
options: { projectCreated?: boolean } = {},
): string {
const projectCreated = options.projectCreated === true;
const text = homeCreationDiagnosticText(error);
if (isHomeCreationTransportText(text)) {
return projectCreated
? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE
: HOME_CREATION_IPC_FAILURE;
}
const code = HOME_CREATION_HOST_CODE.exec(text)?.[0];
if (code) {
return projectCreated
? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。`
: `创建未完成(${code});请重试。`;
}
return projectCreated
? HOME_CREATION_AFTER_PROJECT_FAILURE
: HOME_CREATION_UNCLASSIFIED_FAILURE;
}
/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */
export function createHomeCreationFailure(
error: unknown,
options: { projectCreated?: boolean } = {},
): HomeCreationFailure {
return new HomeCreationFailure(
homeCreationFailureMessage(error, options),
homeCreationDiagnosticText(error),
);
}
/**
* 首页出口:只认控制器包装过的文案。
*
* 走到这里却不是 `HomeCreationFailure`,说明失败发生在控制器之外,只能落回专用兜底——
* 绝不把未知文本原样渲染到状态区。
*/
export function resolveHomeCreationFailureMessage(error: unknown): string {
return error instanceof HomeCreationFailure
? error.userMessage
: HOME_CREATION_UNCLASSIFIED_FAILURE;
}

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