diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml
index 2de097ee1..0b20e28e2 100644
--- a/.gitea/workflows/project-ci.yml
+++ b/.gitea/workflows/project-ci.yml
@@ -69,23 +69,8 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- - name: Run repository lint gates
- run: npm run lint
-
- - name: Build web applications
- run: npm run build
-
- - name: Validate content data
- run: npm run check:content
-
- - name: Check committed whitespace
- shell: bash
- run: |
- set -euo pipefail
- base_ref="${SPACETIME_SCHEMA_BASE_REF:-}"
- test -n "${base_ref}"
- git cat-file -e "${base_ref}^{commit}"
- git diff --check "${base_ref}"...HEAD
+ - name: Run repository checks
+ run: npm run check:repository-ci
frontend-tests:
name: Frontend tests
diff --git a/.husky/pre-push b/.husky/pre-push
new file mode 100755
index 000000000..fdb72ecc2
--- /dev/null
+++ b/.husky/pre-push
@@ -0,0 +1 @@
+npm run check:pre-push-master -- "$@"
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
index eeedc2dfd..08348c123 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs
@@ -20,6 +20,8 @@ const GAME_CREATOR_CODEX_APP_SERVER_THREAD_MAX: usize = 128;
const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000;
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str =
"codex-app-server-terminal-unknown:";
+pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
+ "codex-app-server-error:";
type RpcResult = Result;
@@ -177,6 +179,92 @@ fn game_creator_codex_app_server_terminal_unknown(
))
}
+fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmError {
+ platform_llm::LlmError::InvalidRequest(format!(
+ "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}"
+ ))
+}
+
+fn game_creator_codex_app_server_error_http_status(
+ info: &serde_json::Value,
+ field: &str,
+) -> Option {
+ info.get(field)?
+ .get("httpStatusCode")?
+ .as_u64()
+ .and_then(|status| u16::try_from(status).ok())
+ .filter(|status| (100..=599).contains(status))
+}
+
+fn game_creator_codex_app_server_connection_error(
+ info: &serde_json::Value,
+ field: &str,
+) -> platform_llm::LlmError {
+ match game_creator_codex_app_server_error_http_status(info, field) {
+ Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"),
+ Some(status_code) => platform_llm::LlmError::Upstream {
+ status_code,
+ message: "Codex app-server 连接上游失败".to_string(),
+ },
+ None => platform_llm::LlmError::Connectivity {
+ attempts: 1,
+ message: "Codex app-server 连接失败".to_string(),
+ },
+ }
+}
+
+fn game_creator_codex_app_server_failed_turn_error(
+ turn: &serde_json::Value,
+) -> platform_llm::LlmError {
+ let Some(info) = turn
+ .get("error")
+ .and_then(|error| error.get("codexErrorInfo"))
+ .filter(|info| !info.is_null())
+ else {
+ return game_creator_codex_app_server_error_kind("other");
+ };
+ if let Some(kind) = info.as_str() {
+ return match kind {
+ "contextWindowExceeded" => {
+ game_creator_codex_app_server_error_kind("context-window-exceeded")
+ }
+ "sessionBudgetExceeded" => {
+ game_creator_codex_app_server_error_kind("session-budget-exceeded")
+ }
+ "usageLimitExceeded" => {
+ game_creator_codex_app_server_error_kind("usage-limit-exceeded")
+ }
+ "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream {
+ status_code: 503,
+ message: "Codex app-server 上游服务暂时不可用".to_string(),
+ },
+ "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"),
+ "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"),
+ "badRequest" => game_creator_codex_app_server_error_kind("bad-request"),
+ "threadRollbackFailed" => {
+ game_creator_codex_app_server_error_kind("thread-rollback-failed")
+ }
+ "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
+ "other" => game_creator_codex_app_server_error_kind("other"),
+ _ => game_creator_codex_app_server_error_kind("other"),
+ };
+ }
+ for field in [
+ "httpConnectionFailed",
+ "responseStreamConnectionFailed",
+ "responseStreamDisconnected",
+ "responseTooManyFailedAttempts",
+ ] {
+ if info.get(field).is_some() {
+ return game_creator_codex_app_server_connection_error(info, field);
+ }
+ }
+ if info.get("activeTurnNotSteerable").is_some() {
+ return game_creator_codex_app_server_error_kind("active-turn-not-steerable");
+ }
+ game_creator_codex_app_server_error_kind("other")
+}
+
async fn isolate_game_creator_codex_app_server_terminal_unknown(
inner: &Arc,
detail: impl Into,
@@ -1012,9 +1100,7 @@ impl CodexAppServerConnection {
))
}
"failed" => {
- return Err(platform_llm::LlmError::InvalidRequest(
- "Codex app-server turn 执行失败".to_string(),
- ))
+ return Err(game_creator_codex_app_server_failed_turn_error(turn))
}
status => {
return Err(platform_llm::LlmError::Deserialize(format!(
@@ -1577,6 +1663,81 @@ mod tests {
assert!(response.text.is_empty());
}
+ #[test]
+ fn codex_app_server_failed_turn_uses_structured_error_info_without_raw_details() {
+ let secret = ["sk", "turn-secret"].join("-");
+ let failed_turn = serde_json::json!({
+ "status": "failed",
+ "error": {
+ "message": format!("private message {secret}"),
+ "additionalDetails": "https://provider.example/private C:\\Users\\victim\\project",
+ "codexErrorInfo": "contextWindowExceeded"
+ }
+ });
+ let error = game_creator_codex_app_server_failed_turn_error(&failed_turn);
+ assert_eq!(
+ error,
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:context-window-exceeded".to_string()
+ )
+ );
+ let visible = error.to_string();
+ assert!(!visible.contains(&secret));
+ assert!(!visible.contains("provider.example"));
+ assert!(!visible.contains("victim"));
+ }
+
+ #[test]
+ fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() {
+ for (info, expected) in [
+ (
+ serde_json::json!("usageLimitExceeded"),
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:usage-limit-exceeded".to_string(),
+ ),
+ ),
+ (
+ serde_json::json!("unauthorized"),
+ platform_llm::LlmError::InvalidRequest(
+ "codex-app-server-error:unauthorized".to_string(),
+ ),
+ ),
+ (
+ serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}),
+ platform_llm::LlmError::Upstream {
+ status_code: 429,
+ message: "Codex app-server 连接上游失败".to_string(),
+ },
+ ),
+ (
+ serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}),
+ platform_llm::LlmError::Connectivity {
+ attempts: 1,
+ message: "Codex app-server 连接失败".to_string(),
+ },
+ ),
+ ] {
+ let turn = serde_json::json!({
+ "status": "failed",
+ "error": {
+ "message": "private upstream body",
+ "additionalDetails": "private diagnostics",
+ "codexErrorInfo": info
+ }
+ });
+ assert_eq!(
+ game_creator_codex_app_server_failed_turn_error(&turn),
+ expected
+ );
+ }
+ assert_eq!(
+ game_creator_codex_app_server_failed_turn_error(
+ &serde_json::json!({"status":"failed","error":null})
+ ),
+ platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string())
+ );
+ }
+
#[test]
fn codex_app_server_rejects_non_responses_key_mapping() {
let mut llm = test_llm();
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
index dd84942a3..43a6aa0ed 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs
@@ -547,7 +547,13 @@ pub(crate) fn game_creator_agent_llm_error_public_summary(
(format!("upstream-{status_code}"), Some(*status_code))
}
platform_llm::LlmError::InvalidConfig(_) => ("invalid-config".to_string(), None),
- platform_llm::LlmError::InvalidRequest(_) => ("invalid-request".to_string(), None),
+ platform_llm::LlmError::InvalidRequest(message) => (
+ message
+ .strip_prefix(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX)
+ .map(|kind| format!("codex-app-server-{kind}"))
+ .unwrap_or_else(|| "invalid-request".to_string()),
+ None,
+ ),
platform_llm::LlmError::StreamUnavailable => ("stream-unavailable".to_string(), None),
platform_llm::LlmError::EmptyResponse => ("empty-response".to_string(), None),
platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
index 3f31ca775..fa6ecd3fc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs
@@ -60,6 +60,25 @@ pub(super) fn game_creator_agent_background_final_reply_fallback(
}
}
+pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) -> bool {
+ const KIND_PREFIX: &str = "kind=";
+ let Some(start) = error.rfind(KIND_PREFIX) else {
+ return false;
+ };
+ if error[..start]
+ .chars()
+ .next_back()
+ .is_some_and(|boundary| !boundary.is_whitespace() && boundary != ':' && boundary != ':')
+ {
+ return false;
+ }
+ let kind = error[start + KIND_PREFIX.len()..]
+ .chars()
+ .take_while(|character| character.is_ascii_lowercase() || *character == '-')
+ .collect::();
+ matches!(kind.as_str(), "empty-response" | "deserialize")
+}
+
fn requested_game_chat_fast_path_plan_at(
root: &Path,
plan: AgentRuntimeToolPlan,
@@ -3799,7 +3818,10 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
{
return AgentBackgroundTaskOutcome::NeedsReconciliation;
}
- Err(_) if final_reply_fallback.is_some() => {
+ Err(error)
+ if final_reply_fallback.is_some()
+ && game_creator_agent_final_reply_error_allows_fallback(&error) =>
+ {
final_reply_fallback.expect("checked final reply fallback")
}
Err(error) => {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
index 5f3d126cb..ecee106d9 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs
@@ -4529,6 +4529,36 @@ fn plan_response_precedes_autonomous_supervisor_deterministic_fallback() {
);
}
+#[test]
+fn autonomous_final_reply_fallback_only_accepts_safe_response_shape_failures() {
+ let fingerprint = "a".repeat(64);
+ for allowed in ["empty-response", "deserialize"] {
+ assert!(game_creator_agent_final_reply_error_allows_fallback(
+ &format!("后台 Agent 最终回复调用 LLM 失败:kind={allowed} fingerprint={fingerprint} chars=12")
+ ));
+ }
+ for rejected in [
+ "codex-app-server-unauthorized",
+ "codex-app-server-usage-limit-exceeded",
+ "codex-app-server-context-window-exceeded",
+ "codex-app-server-cyber-policy",
+ "codex-app-server-sandbox-error",
+ "invalid-config",
+ "transport",
+ "upstream-503",
+ ] {
+ assert!(
+ !game_creator_agent_final_reply_error_allows_fallback(&format!(
+ "后台 Agent 最终回复调用 LLM 失败:kind={rejected} fingerprint={fingerprint} chars=12"
+ )),
+ "final reply fallback must reject {rejected}"
+ );
+ }
+ assert!(!game_creator_agent_final_reply_error_allows_fallback(
+ "上游自由文本kind=deserialize fingerprint=private"
+ ));
+}
+
#[tokio::test]
async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallback_once() {
const RUN_ID: &str = "autonomous-final-reply-fallback-run";
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
index 0a15a6860..ef3faa7bc 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs
@@ -204,6 +204,40 @@ pub(in crate::agent) fn game_creator_agent_runtime_failure_conversation_message(
} else {
"专业 Agent"
};
+ let public_kind_prefix = "kind=codex-app-server-";
+ let codex_error_kind = error
+ .rfind(public_kind_prefix)
+ .map(|start| &error[start + public_kind_prefix.len()..])
+ .or_else(|| {
+ error
+ .rfind(GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX)
+ .map(|start| {
+ &error[start + GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX.len()..]
+ })
+ })
+ .map(|kind| {
+ kind.chars()
+ .take_while(|character| character.is_ascii_lowercase() || *character == '-')
+ .collect::()
+ })
+ .filter(|kind| !kind.is_empty());
+ if let Some(kind) = codex_error_kind {
+ let detail = match kind
+ .trim_matches(|character: char| !character.is_ascii_lowercase() && character != '-')
+ {
+ "context-window-exceeded" => "模型上下文已超限,请缩小任务范围后重试",
+ "session-budget-exceeded" => "本次会话预算已耗尽,请缩小任务范围或新建任务",
+ "usage-limit-exceeded" => "Codex 用量已达上限,请检查账户额度后重试",
+ "unauthorized" => "Codex 鉴权失败,请重新登录或检查 API Key",
+ "bad-request" => "Codex 请求无效,请检查模型与运行时配置",
+ "cyber-policy" => "Codex 安全策略拒绝了本次请求,请调整任务内容",
+ "sandbox-error" => "Codex 隔离环境启动失败,请重试或检查本机环境",
+ "thread-rollback-failed" => "Codex 会话恢复失败,请新建任务后重试",
+ "active-turn-not-steerable" => "当前 Codex 任务无法追加指令,请等待结束后重试",
+ _ => "Codex 执行失败,请查看运行详情后重试",
+ };
+ return format!("{subject} {detail}");
+ }
if let Some((http_status, retry_attempt, max_retries)) =
game_creator_agent_runtime_exhausted_upstream_retry_fields(error)
{
@@ -1956,4 +1990,43 @@ mod tests {
assert!(!unrelated_visible.contains("absolute-path"));
assert!(!unrelated_visible.contains("redacted-secret"));
}
+
+ #[test]
+ fn codex_app_server_failure_kind_has_actionable_safe_public_summary() {
+ let private_error = format!(
+ "agentLlm.code-prototype 调用 LLM 失败:kind=codex-app-server-context-window-exceeded fingerprint={} chars=999",
+ "a".repeat(64)
+ );
+ assert_eq!(
+ game_creator_agent_runtime_failure_conversation_message(
+ "code-prototype",
+ &private_error,
+ ),
+ "专业 Agent 模型上下文已超限,请缩小任务范围后重试"
+ );
+ let unauthorized = format!(
+ "kind=codex-app-server-unauthorized fingerprint={} chars=32",
+ "b".repeat(64)
+ );
+ assert_eq!(
+ game_creator_agent_runtime_failure_conversation_message(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &unauthorized,
+ ),
+ "项目总控 Agent Codex 鉴权失败,请重新登录或检查 API Key"
+ );
+ for visible in [
+ game_creator_agent_runtime_failure_conversation_message(
+ "code-prototype",
+ &private_error,
+ ),
+ game_creator_agent_runtime_failure_conversation_message(
+ GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
+ &unauthorized,
+ ),
+ ] {
+ assert!(!visible.contains("fingerprint"));
+ assert!(!visible.contains("chars="));
+ }
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs
index 820ea4827..8ca059907 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs
@@ -2599,6 +2599,21 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
)
})?;
}
+ let failure_detail = matches!(
+ event_type,
+ "error" | "turn.failed" | "turn.budget_exhausted"
+ )
+ .then(|| {
+ detail.map(|value| game_creator_agent_runtime_public_failure_detail(&state.agent_id, value))
+ })
+ .flatten();
+ let public_text = if matches!(event_type, "turn.failed" | "turn.budget_exhausted") {
+ failure_detail
+ .clone()
+ .or_else(|| game_creator_agent_runtime_public_event_text(root, event_type, summary))
+ } else {
+ game_creator_agent_runtime_public_event_text(root, event_type, summary)
+ };
let event = AgentRuntimeEvent {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: state.agent_id.clone(),
@@ -2612,7 +2627,7 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
status: status.to_string(),
phase: phase.to_string(),
summary: summary.to_string(),
- public_text: game_creator_agent_runtime_public_event_text(root, event_type, summary),
+ public_text,
detail: detail
.filter(|_| {
!(event_type == "observation"
@@ -2624,10 +2639,9 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action(
event_type,
"error" | "turn.failed" | "turn.budget_exhausted"
) {
- return game_creator_agent_runtime_public_failure_detail(
- &state.agent_id,
- value,
- );
+ return failure_detail.clone().unwrap_or_else(|| {
+ game_creator_agent_runtime_public_failure_detail(&state.agent_id, value)
+ });
}
let max_chars = if event_type == "observation"
&& summary.starts_with("agent.action_history:")
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs
index d0fe0840b..8bf085900 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs
@@ -1628,17 +1628,23 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply(
.write_all(planning_response.as_bytes())
.expect("mock tool plan response");
- let (mut final_stream, _) = listener.accept().expect("mock final reply accept");
- drop(read_mock_http_request(&mut final_stream));
- let invalid_body = "{invalid-json";
- let final_response = format!(
- "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
- invalid_body.len(),
- invalid_body
- );
- final_stream
- .write_all(final_response.as_bytes())
- .expect("mock invalid final reply response");
+ // Autonomous runs enforce a 12-retry floor. Return the same malformed
+ // response for the initial final-reply request and every retry so this
+ // fixture tests deserialize exhaustion rather than an accidental
+ // connection-refused fallback after the first malformed response.
+ for _ in 0..=12 {
+ let (mut final_stream, _) = listener.accept().expect("mock final reply accept");
+ drop(read_mock_http_request(&mut final_stream));
+ let invalid_body = "{invalid-json";
+ let final_response = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+ invalid_body.len(),
+ invalid_body
+ );
+ final_stream
+ .write_all(final_response.as_bytes())
+ .expect("mock invalid final reply response");
+ }
});
base_url
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs
index b9d345fde..b0dadfb3f 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs
@@ -617,7 +617,8 @@ async fn response_stream_private_process_output_is_never_published_or_committed_
}
#[tokio::test]
-async fn response_stream_final_failure_with_retry_disabled_commits_planning_fallback() {
+async fn response_stream_final_disconnect_with_retry_disabled_fails_without_committing_planning_fallback(
+) {
let root = unique_project_path();
init_local_game_project_at(
&root,
@@ -686,32 +687,28 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall
);
drop(finalization_lock);
- let completed = wait_for_agent_runtime_idle(&root, "design-director");
- assert_eq!(completed.phase, "completed");
- assert_eq!(completed.last_response.as_deref(), Some(planning_fallback));
- let committed = wait_for_response_stream_status(
- &root,
- "design-director",
- run_id,
- "committed",
- ready.sequence.saturating_add(1),
- );
- assert_eq!(committed.accumulated_text, planning_fallback);
+ let failed = wait_for_agent_runtime_phase(&root, "design-director", "failed");
+ assert_eq!(failed.status, "failed");
+ assert!(failed
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("kind=transport")));
+ assert_eq!(failed.last_response, None);
let conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&started.state.session_id),
)
- .expect("read committed fallback conversation");
- assert_eq!(
- conversation
- .messages
- .iter()
- .filter(|message| message.role == "assistant")
- .map(|message| message.content.as_str())
- .collect::>(),
- vec![planning_fallback]
- );
+ .expect("read failed final stream conversation");
+ let assistant_messages = conversation
+ .messages
+ .iter()
+ .filter(|message| message.role == "assistant")
+ .map(|message| message.content.as_str())
+ .collect::>();
+ assert_eq!(assistant_messages.len(), 1);
+ assert_ne!(assistant_messages[0], planning_fallback);
+ assert!(assistant_messages[0].contains("失败"));
let requests = mock.stop_and_collect();
assert_eq!(
@@ -760,11 +757,21 @@ async fn response_stream_final_failure_with_retry_disabled_commits_planning_fall
final_lifecycle[0]["requestSlot"],
final_lifecycle[1]["requestSlot"]
);
- assert_response_stream_completion_event_details(
- &root,
- "design-director",
- run_id,
- planning_fallback,
+ let mut final_reply_failed_audit = false;
+ for _ in 0..250 {
+ final_reply_failed_audit = read_agent_db_records_for_test(&root).iter().any(|record| {
+ record["recordType"] == "agent.runtime.background_task.failed"
+ && record["runId"] == run_id
+ && record["failureKind"] == "final-reply-failed"
+ });
+ if final_reply_failed_audit {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ assert!(
+ final_reply_failed_audit,
+ "final reply transport failure audit must eventually persist"
);
assert_response_stream_public_surfaces_exclude(&root, "design-director", &[planning_fallback]);
diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs
index 4d013debf..d77a1e92c 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs
@@ -147,12 +147,23 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() {
"public-event-action-1",
)
.expect("repeat public action idempotently");
+ append_game_creator_agent_runtime_action_event(
+ &root,
+ &state,
+ "turn.failed",
+ "failed",
+ "failed",
+ "Agent Runtime 本轮处理失败。",
+ Some("kind=codex-app-server-context-window-exceeded fingerprint=private"),
+ "public-event-failure-1",
+ )
+ .expect("append classified public failure");
let events = read_recent_game_creator_agent_runtime_events(
&game_creator_agent_runtime_event_path(&root, "code-prototype"),
)
.expect("read public runtime events");
- assert_eq!(events.len(), 4);
+ assert_eq!(events.len(), 5);
assert!(events.iter().all(|event| !event.event_id.trim().is_empty()));
let mut event_ids = events
.iter()
@@ -176,6 +187,16 @@ fn runtime_events_expose_stable_ids_and_backend_owned_public_text_only() {
.as_deref()
.unwrap_or_default()
.contains("raw tool input must stay private"));
+ assert_eq!(
+ events[4].public_text.as_deref(),
+ Some("专业 Agent 模型上下文已超限,请缩小任务范围后重试")
+ );
+ assert_eq!(events[4].detail, events[4].public_text);
+ assert!(!events[4]
+ .public_text
+ .as_deref()
+ .unwrap_or_default()
+ .contains("fingerprint"));
fs::remove_dir_all(root).ok();
}
diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts
index 6cf04d7c7..d97a3ca63 100644
--- a/apps/ai-game-creator-shell/src/app/types.ts
+++ b/apps/ai-game-creator-shell/src/app/types.ts
@@ -846,6 +846,7 @@ export interface AgentStatusCard {
runtimeWaitingOn: string | null;
runtimeNextStep: string | null;
runtimeTask: string | null;
+ runtimeError?: string | null;
runtimeRunId: string | null;
runtimeLoopIteration: number | null;
runtimeMaxLoopIterations: number | null;
diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts
index 9113fecc6..4e7e13d4e 100644
--- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts
+++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts
@@ -709,8 +709,16 @@ export function agentRuntimeStartedRunId(
export function isAgentRuntimeTerminalState(runtime: AgentRuntimeState) {
return (
- ['completed', 'failed', 'cancelled'].includes(runtime.phase) ||
- ['completed', 'failed', 'cancelled', 'idle'].includes(runtime.status)
+ ['completed', 'failed', 'cancelled', 'needs-reconciliation'].includes(
+ runtime.phase,
+ ) ||
+ [
+ 'completed',
+ 'failed',
+ 'cancelled',
+ 'idle',
+ 'needs-reconciliation',
+ ].includes(runtime.status)
);
}
@@ -979,6 +987,12 @@ function agentRuntimeProviderRetryStatus(runtime: AgentRuntimeState) {
export function agentRuntimeConversationStatus(runtime: AgentRuntimeState) {
if (isAgentRuntimeTerminalState(runtime)) {
+ if (
+ runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation'
+ ) {
+ return 'Agent 运行状态需要核对,正在同步记录';
+ }
if (runtime.status === 'failed' || runtime.phase === 'failed') {
return 'Agent 运行失败,正在同步错误记录';
}
@@ -1023,6 +1037,14 @@ export function projectSupervisorChatRuntimeStatus(runtime: AgentRuntimeState) {
if (runtime.status === 'idle' || runtime.phase === 'idle') {
return '等待输入';
}
+ if (
+ runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation'
+ ) {
+ return runtime.error
+ ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true)
+ : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试';
+ }
if (isAgentRuntimeTerminalState(runtime)) {
if (runtime.status === 'failed' || runtime.phase === 'failed') {
return runtime.error
@@ -1043,15 +1065,13 @@ export function formatAgentRuntimeEvent(event: AgentRuntimeEventRecord) {
'turn.failed',
'turn.budget_exhausted',
].includes(event.eventType);
- const containsInternalFailureDiagnostics = Boolean(
- event.detail &&
- /(?:errorSha256|errorChars|fingerprint|chars|retryAttempt|retryState)=|<(?:absolute-path|redacted-url)>|\[redacted(?:[- ]secret| sensitive context)\]/i.test(
- event.detail,
- ),
- );
- const visibleDetail =
- isFailureEvent && containsInternalFailureDiagnostics ? null : event.detail;
+ const visibleDetail = isFailureEvent ? null : event.detail;
+ const publicFailureText =
+ isFailureEvent && typeof event.publicText === 'string'
+ ? event.publicText.trim()
+ : '';
const summary =
+ publicFailureText ||
event.summary ||
visibleDetail ||
(isFailureEvent ? 'Agent Runtime 本轮处理失败。' : event.runId);
@@ -1371,11 +1391,16 @@ export function projectSupervisorRuntimeStatusLabel(
runtime: AgentRuntimeState | null,
runtimeError: string,
) {
+ if (
+ runtime?.status === 'needs-reconciliation' ||
+ runtime?.phase === 'needs-reconciliation'
+ ) {
+ return '待核对';
+ }
if (
runtimeError ||
runtime?.status === 'failed' ||
- runtime?.phase === 'failed' ||
- runtime?.phase === 'needs-reconciliation'
+ runtime?.phase === 'failed'
) {
return '失败';
}
@@ -1641,6 +1666,27 @@ export function projectRuntimeVisibleError(
if (isMudPointInsufficientRuntimeError(message)) {
return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE;
}
+ const codexAppServerKind = visibleMessage.match(
+ /(?:^|[\s::])kind=codex-app-server-([a-z-]+)(?=\s|$)/,
+ )?.[1];
+ if (codexAppServerKind) {
+ const detail = {
+ 'context-window-exceeded': '模型上下文已超限,请缩小任务范围后重试',
+ 'session-budget-exceeded': '本次会话预算已耗尽,请缩小任务范围或新建任务',
+ 'usage-limit-exceeded': 'Codex 用量已达上限,请检查账户额度后重试',
+ unauthorized: 'Codex 鉴权失败,请重新登录或检查 API Key',
+ 'bad-request': 'Codex 请求无效,请检查模型与运行时配置',
+ 'cyber-policy': 'Codex 安全策略拒绝了本次请求,请调整任务内容',
+ 'sandbox-error': 'Codex 隔离环境启动失败,请重试或检查本机环境',
+ 'thread-rollback-failed': 'Codex 会话恢复失败,请新建任务后重试',
+ 'active-turn-not-steerable':
+ '当前 Codex 任务无法追加指令,请等待结束后重试',
+ other: 'Codex 执行失败,请查看运行详情后重试',
+ }[codexAppServerKind];
+ if (detail) {
+ return `${subject} ${detail}`;
+ }
+ }
const exhaustedUpstreamRetry = visibleMessage.match(
/(?:^|[\s::])kind=upstream-(\d{3}) httpStatus=(\d{3}) fingerprint=[0-9a-f]{64} chars=\d+ retryAttempt=(\d+) maxRetries=(\d+) retryState=exhausted\s*$/,
);
@@ -1698,6 +1744,54 @@ export function projectRuntimeVisibleError(
) {
return `${subject} 服务繁忙,请稍后重试`;
}
+ if (
+ normalized.includes('needs-reconciliation') ||
+ normalized.includes('result-unknown') ||
+ normalized.includes('终态未知') ||
+ normalized.includes('需要人工核对')
+ ) {
+ return `${subject} 运行状态需要核对,请打开运行详情后重试`;
+ }
+ if (
+ normalized.includes('budget-exhausted') ||
+ normalized.includes('预算耗尽') ||
+ normalized.includes('预算已耗尽')
+ ) {
+ return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`;
+ }
+ if (
+ normalized.includes('missing expected artifact') ||
+ normalized.includes('expected artifact') ||
+ normalized.includes('缺少预期产物') ||
+ normalized.includes('缺少 expected artifact')
+ ) {
+ return `${subject} 未生成要求的产物,请查看任务要求后重试`;
+ }
+ if (
+ normalized.includes('verification') ||
+ normalized.includes('project.verify') ||
+ normalized.includes('preview.validate') ||
+ normalized.includes('验证未通过') ||
+ normalized.includes('验证失败')
+ ) {
+ return `${subject} 项目验证未通过,请查看运行详情并修复后重试`;
+ }
+ if (
+ normalized.includes('policy') ||
+ normalized.includes('permission') ||
+ normalized.includes('拒绝') ||
+ normalized.includes('禁止') ||
+ normalized.includes('不允许')
+ ) {
+ return `${subject} 被项目权限或安全策略阻止,请检查审批配置`;
+ }
+ if (
+ normalized.includes('落盘失败') ||
+ normalized.includes('持久化失败') ||
+ normalized.includes('写入失败')
+ ) {
+ return `${subject} 保存运行记录失败,请检查项目目录后重试`;
+ }
const containsInternalDiagnostics =
normalized.includes('agentllm.') ||
/(?:^|[\s::])kind=/.test(normalized) ||
@@ -1725,7 +1819,8 @@ export function projectRuntimeVisibleError(
export function projectSupervisorVisibleConversationText(
message: string,
- role: ChatMessage['role'] = 'assistant',
+ role: ChatMessage['role'] | 'tool' = 'assistant',
+ subject = '项目总控 Agent',
) {
const failurePrefix = '后台任务失败:';
if (role !== 'assistant' || !message.startsWith(failurePrefix)) {
@@ -1733,7 +1828,7 @@ export function projectSupervisorVisibleConversationText(
}
return projectRuntimeVisibleError(
message.slice(failurePrefix.length),
- '项目总控 Agent',
+ subject,
true,
);
}
@@ -1848,9 +1943,22 @@ export function formatAgentRecentRuntimeTask(task: AgentRuntimeTaskRecord) {
const goalSource = task.goalId
? `Goal ${task.goalStatus ?? '-'} · revision ${task.goalRevision ?? 0}`
: null;
+ const failed =
+ task.status === 'failed' ||
+ ['failed', 'budget-exhausted', 'needs-reconciliation'].includes(task.phase);
+ const failureDetail = failed
+ ? task.terminalDetail?.trim() || task.error?.trim() || null
+ : null;
+ const failureSummary = failureDetail
+ ? projectRuntimeVisibleError(
+ failureDetail,
+ projectProfessionalAgentLabel(task.agentId),
+ true,
+ )
+ : null;
return `${task.status} / ${task.phase} · ${
task.task || task.currentAction || task.runId
}${goalSource ? ` · ${goalSource}` : ''}${
delegationSource ? ` · ${delegationSource}` : ''
- }`;
+ }${failureSummary ? ` · 失败原因:${failureSummary}` : ''}`;
}
diff --git a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts
index 4e7824c0e..9a23973e1 100644
--- a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts
+++ b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts
@@ -41,7 +41,9 @@ import {
isAgentRuntimeTerminalState,
isGameChatSupervisorRoot,
projectCurrentGameChatRuntimeLineage,
+ projectProfessionalAgentLabel,
projectRuntimeVisibleCurrentWork,
+ projectRuntimeVisibleError,
taskRowsFromManifest,
} from '../agent-runtime';
import {
@@ -157,6 +159,26 @@ export function taskRowsForAgentStatus(
return mergedTasks;
}
+function agentRuntimeFailureDetail(runtime: AgentRuntimeState) {
+ const runtimeError = runtime.error?.trim();
+ if (runtimeError) {
+ return runtimeError;
+ }
+ const terminalTask = [...(runtime.recentTasks ?? [])]
+ .reverse()
+ .find(
+ (task) =>
+ task.runId === runtime.runId &&
+ (task.status === 'failed' ||
+ ['failed', 'budget-exhausted', 'needs-reconciliation'].includes(
+ task.phase,
+ )),
+ );
+ return (
+ terminalTask?.terminalDetail?.trim() || terminalTask?.error?.trim() || null
+ );
+}
+
export function deriveAgentStatusCards(
nextManifest: GameCreationAppManifest,
trace: GameCreationAgentRunTrace | null,
@@ -208,6 +230,7 @@ export function deriveAgentStatusCards(
? (runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase))
: null,
runtimeTask: runtime?.currentTask ?? null,
+ runtimeError: runtime ? agentRuntimeFailureDetail(runtime) : null,
runtimeRunId: runtime?.runId ?? null,
runtimeLoopIteration: runtime?.loopIteration ?? null,
runtimeMaxLoopIterations: runtime?.maxLoopIterations ?? null,
@@ -263,7 +286,12 @@ export function projectAgentRuntimeSummaries(
) {
return 4;
}
- if (runtime.status === 'failed' || runtime.phase === 'failed') {
+ if (
+ runtime.status === 'failed' ||
+ runtime.phase === 'failed' ||
+ runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation'
+ ) {
return 3;
}
if (!isAgentRuntimeTerminalState(runtime)) {
@@ -319,6 +347,9 @@ export function projectAgentRuntimeSummaries(
runtime.phase === 'waiting-for-confirmation',
);
const failed = runtime.status === 'failed' || runtime.phase === 'failed';
+ const needsReconciliation =
+ runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation';
const completed =
runtime.status === 'completed' || runtime.phase === 'completed';
const cancelled =
@@ -327,7 +358,7 @@ export function projectAgentRuntimeSummaries(
const status: ProjectAgentRuntimeSummary['status'] =
waitingForInput || waitingForConfirmation
? 'waiting'
- : failed
+ : failed || needsReconciliation
? 'failed'
: completed
? 'completed'
@@ -345,17 +376,28 @@ export function projectAgentRuntimeSummaries(
? '待回答'
: waitingForConfirmation
? '待确认'
- : failed
- ? '失败'
- : completed
- ? '已完成'
- : cancelled
- ? '已取消'
- : idle
- ? '等待中'
- : runtime.phase === 'planning'
- ? '分析中'
- : '工作中';
+ : needsReconciliation
+ ? '待核对'
+ : failed
+ ? '失败'
+ : completed
+ ? '已完成'
+ : cancelled
+ ? '已取消'
+ : idle
+ ? '等待中'
+ : runtime.phase === 'planning'
+ ? '分析中'
+ : '工作中';
+ const failureDetail = agentRuntimeFailureDetail(runtime);
+ const failureSummary =
+ (failed || needsReconciliation) && failureDetail
+ ? projectRuntimeVisibleError(
+ failureDetail,
+ projectProfessionalAgentLabel(runtime.agentId),
+ true,
+ )
+ : null;
return [
{
@@ -363,6 +405,7 @@ export function projectAgentRuntimeSummaries(
label,
status,
statusLabel,
+ failureSummary,
currentTask:
(activePlanStep && agentRuntimePlanStepText(activePlanStep)) ||
projectRuntimeVisibleCurrentWork(runtime),
@@ -410,6 +453,7 @@ export function sameAgentStatusCard(
left.runtimeWaitingOn === right.runtimeWaitingOn &&
left.runtimeNextStep === right.runtimeNextStep &&
left.runtimeTask === right.runtimeTask &&
+ left.runtimeError === right.runtimeError &&
left.runtimeRunId === right.runtimeRunId &&
left.runtimeLoopIteration === right.runtimeLoopIteration &&
left.runtimeMaxLoopIterations === right.runtimeMaxLoopIterations &&
@@ -478,6 +522,7 @@ export function sameAgentRuntimeTasks(
task.task === other.task &&
task.currentAction === other.currentAction &&
task.terminalDetail === other.terminalDetail &&
+ task.error === other.error &&
task.updatedAt === other.updatedAt
);
})
@@ -924,8 +969,22 @@ export function formatAgentCardRuntimeStatus(agent: AgentStatusCard) {
agent.runtimeMaxLoopIterations ?? 3
}`
: null;
+ const needsAttention =
+ agent.runtimeStatus === 'failed' ||
+ agent.runtimePhase === 'failed' ||
+ agent.runtimeStatus === 'needs-reconciliation' ||
+ agent.runtimePhase === 'needs-reconciliation';
+ const failureSummary =
+ needsAttention && agent.runtimeError
+ ? projectRuntimeVisibleError(
+ agent.runtimeError,
+ projectProfessionalAgentLabel(agent.id),
+ true,
+ )
+ : null;
return [
`Runtime:${agent.runtimeStatus} / ${agent.runtimePhase ?? '-'}`,
+ failureSummary,
loopProgress,
agent.runtimeAction,
agent.runtimeWaitingOn ? `等待 ${agent.runtimeWaitingOn}` : null,
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx
index d823404ca..5b6db7c21 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/AgentConversationOverlay.tsx
@@ -16,7 +16,11 @@ import type {
AgentStatusCard,
LocalConversationMessageRecord,
} from '../../app/types';
-import { AgentRuntimeStatusPanel } from '../agent-runtime';
+import {
+ AgentRuntimeStatusPanel,
+ projectProfessionalAgentLabel,
+ projectSupervisorVisibleConversationText,
+} from '../agent-runtime';
import { commandDraftFromSuggestedToolCall } from '../project-summary/agentPresentation';
import {
agentTaskGraphStateLabels,
@@ -268,7 +272,11 @@ export function AgentConversationOverlay({
key={`${message.updatedAt}-${index}`}
className={`message message--${message.role}`}
>
- {message.content}
+ {projectSupervisorVisibleConversationText(
+ message.content,
+ message.role,
+ projectProfessionalAgentLabel(selectedAgent.id),
+ )}
))}
>
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
index 7cc9d262d..220a31edf 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx
@@ -13,7 +13,10 @@ import type {
PendingUiConfirmation,
} from '../../app/types';
import {
+ projectProfessionalAgentLabel,
+ projectRuntimeVisibleError,
ProjectSupervisorRuntimePanel,
+ projectSupervisorVisibleConversationText,
projectWorkspaceStatusForDisplay,
} from '../agent-runtime';
import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation';
@@ -77,7 +80,10 @@ export function ProjectSupervisorView({
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
- {message.text}
+ {projectSupervisorVisibleConversationText(
+ message.text,
+ message.role,
+ )}
))}
{transientReply ? (
@@ -151,6 +157,15 @@ export function ProjectSupervisorView({
taskStatusLabels[agent.status]}
{agent.runtimeTask ? {agent.runtimeTask} : null}
+ {agent.runtimeError ? (
+
+ {projectRuntimeVisibleError(
+ agent.runtimeError,
+ projectProfessionalAgentLabel(agent.id),
+ true,
+ )}
+
+ ) : null}
))}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
index cc52bccb3..5dd8eca5f 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx
@@ -303,12 +303,20 @@ export function formatGameChatStageRecord(
) {
const terminalStatus =
runtime.status === 'failed' || runtime.phase === 'failed'
- ? progress.interruptionText || '本轮失败'
- : runtime.status === 'cancelled' || runtime.phase === 'cancelled'
- ? '本轮已取消'
- : runtime.status === 'completed' || runtime.phase === 'completed'
- ? '本轮已完成'
- : projectSupervisorChatRuntimeStatus(runtime);
+ ? progress.interruptionText ||
+ (runtime.error
+ ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true)
+ : '本轮失败')
+ : runtime.status === 'needs-reconciliation' ||
+ runtime.phase === 'needs-reconciliation'
+ ? runtime.error
+ ? projectRuntimeVisibleError(runtime.error, '项目总控 Agent', true)
+ : '项目总控 Agent 运行状态需要核对,请打开运行详情后重试'
+ : runtime.status === 'cancelled' || runtime.phase === 'cancelled'
+ ? '本轮已取消'
+ : runtime.status === 'completed' || runtime.phase === 'completed'
+ ? '本轮已完成'
+ : projectSupervisorChatRuntimeStatus(runtime);
const lines = [
GAME_CHAT_STAGE_RECORD_PREFIX,
`${progress.title.replace('Supervisor 进度播报 · ', '')} · ${terminalStatus}`,
@@ -990,7 +998,10 @@ export function SupervisorChatOnlyView({
const running = Boolean(
chatAgentBusy ||
synchronizingAcceptedRun ||
- (projectedRuntime && (!runtimeTerminal || descendantsStillActive)),
+ (projectedRuntime &&
+ projectedRuntime.status !== 'needs-reconciliation' &&
+ projectedRuntime.phase !== 'needs-reconciliation' &&
+ (!runtimeTerminal || descendantsStillActive)),
);
const gameChatInterruptionText =
gameChatMode && projectedRuntime
@@ -1123,6 +1134,12 @@ export function SupervisorChatOnlyView({
if (runtimeError) {
return '运行异常';
}
+ if (
+ runtime?.status === 'needs-reconciliation' ||
+ runtime?.phase === 'needs-reconciliation'
+ ) {
+ return '待人工核对';
+ }
if (synchronizingAcceptedRun) {
return '正在启动';
}
diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
index c8c9fdd23..df6e2469e 100644
--- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
+++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx
@@ -1,5 +1,19 @@
-import { Plus, Trash2, Zap } from 'lucide-react';
-import { type FormEvent, Fragment, useEffect, useRef, useState } from 'react';
+import {
+ Bot,
+ Cable,
+ CheckCircle2,
+ CircleAlert,
+ LoaderCircle,
+ Plus,
+ RotateCcw,
+ Save,
+ Settings2,
+ SlidersHorizontal,
+ Trash2,
+ X,
+ Zap,
+} from 'lucide-react';
+import { type FormEvent, useEffect, useRef, useState } from 'react';
import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
@@ -82,6 +96,40 @@ const runtimeAgentModes = new Set([
'provider',
]);
+type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced';
+
+const runtimeSettingsSections = [
+ {
+ id: 'general',
+ label: '常用设置',
+ description: '运行方式与默认模型',
+ icon: Settings2,
+ },
+ {
+ id: 'agents',
+ label: 'Agent 模型',
+ description: '按角色覆盖默认模型',
+ icon: Bot,
+ },
+ {
+ id: 'connections',
+ label: '连接与工具',
+ description: 'MCP 与外部服务',
+ icon: Cable,
+ },
+ {
+ id: 'advanced',
+ label: '高级参数',
+ description: '上下文、超时与重试',
+ icon: SlidersHorizontal,
+ },
+] as const satisfies ReadonlyArray<{
+ id: RuntimeSettingsSection;
+ label: string;
+ description: string;
+ icon: typeof Settings2;
+}>;
+
const defaultRuntimeMcpServerConfig: GameCreatorMcpServerConfig = {
enabled: true,
required: false,
@@ -534,6 +582,9 @@ export function RuntimeConfigDialog({
const [runtimeConfigDraft, setRuntimeConfigDraft] =
useState(defaultRuntimeConfigDraft);
const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false);
+ const [activeSection, setActiveSection] =
+ useState('general');
+ const [expandedAgentIds, setExpandedAgentIds] = useState([]);
const [newMcpServerId, setNewMcpServerId] = useState('');
const [mcpStructuredDrafts, setMcpStructuredDrafts] = useState<
Record
@@ -858,6 +909,24 @@ export function RuntimeConfigDialog({
}
}
+ const selectedSection =
+ runtimeSettingsSections.find((section) => section.id === activeSection) ??
+ runtimeSettingsSections[0];
+ const configuredAgentCount = Object.values(
+ runtimeConfigDraft.agentLlm ?? {},
+ ).filter((config) =>
+ Object.values(config).some((value) => value !== undefined && value !== ''),
+ ).length;
+ const runtimeConfigStatusTone = runtimeConfigBusy
+ ? 'busy'
+ : /^(已保存|已读取|已恢复默认|已添加|已移除|MCP 已连接)/.test(
+ runtimeConfigStatus,
+ )
+ ? 'success'
+ : runtimeConfigStatus === '未读取'
+ ? 'neutral'
+ : 'warning';
+
return (
closeDialogOnBackdropMouseDown(event, onClose)}
>