Merge remote-tracking branch 'origin/master' into feat/smart-paste
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-09-23 18:50:15 +08:00
7 changed files with 205 additions and 47 deletions
@@ -839,7 +839,7 @@ fn direct_thread_visible_item(
/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id;
/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。
fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
pub(super) fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option<String> {
let client_turn_id = client_turn_id.trim();
(!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user"))
}
@@ -1619,6 +1619,36 @@ async fn stage_codex_app_server_image(
Ok(path)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DirectCodexTurnKind {
User,
HostFeedback,
}
async fn direct_project_turn_input(
request: &LlmRunRequest,
prompt: &str,
workspace_path: &std::path::Path,
turn_kind: DirectCodexTurnKind,
original_user_item: Option<&serde_json::Value>,
skill_roots: &[std::path::PathBuf],
) -> Result<serde_json::Value, platform_llm::LlmError> {
// 原始用户条目一直用于历史和事件关联,但只有首次请求将它作为模型输入。
if turn_kind == DirectCodexTurnKind::User {
if let Some(item) = original_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
return direct_codex_user_item_to_codex_turn_input(
workspace_path,
&canonical,
skill_roots,
)
.map_err(platform_llm::LlmError::InvalidRequest);
}
}
codex_app_server_turn_input(request, prompt, workspace_path).await
}
async fn codex_app_server_turn_input(
request: &LlmRunRequest,
prompt: &str,
@@ -3217,6 +3247,7 @@ impl CodexAppServerConnection {
None,
None,
None,
DirectCodexTurnKind::User,
on_agent_message_delta,
direct_observer,
audit,
@@ -3233,6 +3264,7 @@ impl CodexAppServerConnection {
direct_history_root: Option<&std::path::Path>,
direct_client_turn_id: Option<&str>,
direct_user_item: Option<&serde_json::Value>,
turn_kind: DirectCodexTurnKind,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
@@ -3358,18 +3390,15 @@ impl CodexAppServerConnection {
.map_err(platform_llm::LlmError::InvalidRequest)?
};
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
direct_project_turn_input(
&request,
&prompt,
&self.inner.workspace_path,
turn_kind,
direct_user_item,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.await?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
@@ -5042,6 +5071,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
root,
system_prompt,
user_prompt,
DirectCodexTurnKind::User,
None,
None,
None,
@@ -5060,6 +5090,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
root,
system_prompt,
user_prompt,
DirectCodexTurnKind::User,
None,
Some(observer),
None,
@@ -5072,6 +5103,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
turn_kind: DirectCodexTurnKind,
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
@@ -5171,6 +5203,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
Some(&codex_root),
effective_client_turn_id,
direct_user_item.as_ref(),
turn_kind,
None,
observer,
audit,
@@ -5348,6 +5381,7 @@ mod tests {
Path::new("not-read"),
String::new(),
String::new(),
DirectCodexTurnKind::User,
Some("not-executed"),
None,
None,
@@ -6018,6 +6052,108 @@ mod tests {
}
}
#[tokio::test]
async fn direct_project_turn_input_preserves_user_input_and_sends_host_feedback() {
let temp = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(temp.path(), "feedback-input", "反馈输入")
.expect("init project");
let skill_root = temp.path().join("skills");
let skill_path = skill_root.join("test-skill").join("SKILL.md");
std::fs::create_dir_all(skill_path.parent().unwrap()).expect("create skill directory");
std::fs::write(&skill_path, "# 测试 Skill").expect("write skill");
let user_item = serde_json::json!({
"type": "message",
"role": "user",
"id": "direct-codex:feedback-input:user",
"content": [
{"type": "input_text", "text": "请创建菜单"},
{"type": "agc_skill_reference", "name": "test-skill"}
]
});
let skill_roots = vec![skill_root];
let request = LlmRunRequest::single_turn("系统", "请创建菜单");
let input = direct_project_turn_input(
&request,
&direct_codex_user_prompt(&request),
temp.path(),
DirectCodexTurnKind::User,
Some(&user_item),
&skill_roots,
)
.await
.expect("initial input");
assert_eq!(
input,
serde_json::json!([
{"type": "text", "text": "请创建菜单"},
{"type": "skill", "name": "test-skill", "path": skill_path}
])
);
// 连续验收反馈和执行错误都携带同一个原始条目,但 wire input 必须是当次反馈。
for feedback in [
"宿主验收未通过:缺少移动端玩法证据,请补齐。",
"npm run build 失败:入口不存在,请修复后继续。",
] {
let request = LlmRunRequest::single_turn("系统", feedback);
let input = direct_project_turn_input(
&request,
&direct_codex_user_prompt(&request),
temp.path(),
DirectCodexTurnKind::HostFeedback,
Some(&user_item),
&skill_roots,
)
.await
.expect("host feedback input");
assert_eq!(
input,
serde_json::json!([{"type": "text", "text": feedback}])
);
}
}
#[tokio::test]
async fn direct_project_turn_input_cli_feedback_keeps_one_original_history_item() {
let temp = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(temp.path(), "cli-feedback", "CLI 反馈")
.expect("init project");
let user_item = direct_project_local_message_item(
"user",
"请创建菜单",
direct_codex_user_item_id_for_client_turn_id("direct-cli-feedback").as_deref(),
)
.expect("freeze original CLI input");
for (kind, prompt) in [
(DirectCodexTurnKind::User, "请创建菜单"),
(
DirectCodexTurnKind::HostFeedback,
"宿主验收未通过:缺少玩法证据",
),
(DirectCodexTurnKind::HostFeedback, "构建失败:请修复入口"),
] {
// 与发送入口一样,每次先幂等落盘原始用户条目,再构造本次模型输入。
append_direct_project_user_message_at(temp.path(), &user_item)
.expect("persist original user item without an ID conflict");
let request = LlmRunRequest::single_turn("系统", prompt);
let input = direct_project_turn_input(
&request,
&direct_codex_user_prompt(&request),
temp.path(),
kind,
Some(&user_item),
&[],
)
.await
.expect("CLI turn input");
assert_eq!(input, serde_json::json!([{"type": "text", "text": prompt}]));
}
assert_eq!(
read_direct_project_history_items_at(temp.path()).expect("read history"),
vec![user_item]
);
}
#[tokio::test]
async fn app_server_turn_input_stages_data_url_as_isolated_local_image() {
let temp = tempfile::tempdir().expect("temp dir");
@@ -6035,9 +6171,16 @@ mod tests {
let prompt = codex_app_server_text_prompt(&request).expect("sanitized prompt");
assert!(!prompt.contains("data:image"));
assert!(prompt.contains("原生视觉输入"));
let input = codex_app_server_turn_input(&request, &prompt, temp.path())
.await
.expect("turn input");
let input = direct_project_turn_input(
&request,
&prompt,
temp.path(),
DirectCodexTurnKind::User,
None,
&[],
)
.await
.expect("turn input");
assert_eq!(input[0]["type"], "text");
assert_eq!(input[1]["type"], "localImage");
let staged_path = input[1]["path"].as_str().expect("staged path");
@@ -7717,6 +7860,7 @@ done
Some(&project),
Some("turn-0001"),
Some(&user_item),
DirectCodexTurnKind::User,
None,
Some(&mut observer),
None,
@@ -5115,6 +5115,20 @@ async fn run_direct_game_creator_turn_inner(
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
return Ok(report);
}
// CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID
// 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。
let direct_user_item = match direct_user_item {
Some(item) => item,
None => execution_session.snapshot().and_then(|state| {
direct_project_local_message_item(
"user",
prompt,
direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(),
)
}).map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?,
};
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
crate::analytics::goal::accepted(
@@ -5268,20 +5282,23 @@ async fn run_direct_game_creator_turn_inner(
}
};
let mut feedback_prompt = prompt.to_string();
let mut turn_kind = DirectCodexTurnKind::User;
let mut audit = audit;
let mut attempt = 1;
let reply_result = loop {
match direct_game_creator_codex_chat_at_with_optional_observer(
let result = direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt.clone(),
feedback_prompt.clone(),
turn_kind,
Some(&client_turn_id),
Some(&mut observer),
audit.as_deref_mut(),
direct_user_item.clone(),
Some(direct_user_item.clone()),
)
.await
{
.await;
turn_kind = DirectCodexTurnKind::HostFeedback;
match result {
Ok(value) => match super::direct_delivery::review_reply(root,&execution_session).await {
Ok(Some(report)) => break Ok(report),
Ok(None) => break Ok(value),
@@ -5324,21 +5341,24 @@ async fn run_direct_game_creator_turn_inner(
reply_result
} else {
let mut feedback_prompt = prompt.to_string();
let mut turn_kind = DirectCodexTurnKind::User;
let mut audit = audit;
let mut response = None;
let mut attempt = 1;
loop {
match direct_game_creator_codex_chat_at_with_optional_observer(
let result = direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt.clone(),
feedback_prompt.clone(),
turn_kind,
None,
None,
audit.as_deref_mut(),
direct_user_item.clone(),
Some(direct_user_item.clone()),
)
.await
{
.await;
turn_kind = DirectCodexTurnKind::HostFeedback;
match result {
Ok(value) => {
match super::direct_delivery::review_reply(root,&execution_session).await {
Ok(Some(report)) => { response = Some(report); break; }
@@ -1716,8 +1716,7 @@ export function registerHomeProjectCreationTests() {
'卡住的自动建项',
);
let resolveAutomaticProject:
| ((result: Record<string, unknown>) => void)
| null = null;
((result: Record<string, unknown>) => void) | null = null;
const invoke = vi.fn(async (command: string) => {
if (command === 'preflight_web_game_creation') return { status: 'ready' };
if (command === 'create_automatic_local_game_project') {
@@ -1,5 +1,9 @@
# 踩坑与排障记录
## Direct 宿主继续请求不能重发原始用户条目
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。
## 最近项目一次失败会被钉成终态
- **现象**:AGC 卡住一次后,项目列表每一行都显示「检查失败 + 待识别」,首页「最近项目」变成「暂无最近项目」;现场在后端恢复后逐条复跑 `inspect_local_project_directory`(8 个项目)全部 0ms 成功,界面仍然全红(issue #490)。
@@ -1,5 +1,12 @@
# AI 游戏创作智能体 App 实施计划
## 2026-09-23 Direct 宿主继续请求输入修复
- 首次模型请求使用原始结构化用户输入,保留 Skill 提及及其它引用;未提供结构化输入时沿用请求正文与图片转换。
- 验收反馈和可修复错误触发的后续模型请求,显式标记为宿主继续,只发送本次反馈正文,不再用原始用户条目覆盖。GUI 与 CLI 共用这一规则,验收条件、重试上限和执行预算保持不变。
- 原始用户条目仍用于历史持久化和回合事件关联,沿用同一 clientTurnId/itemId;反馈不是新的用户输入。不要通过丢弃原始条目来修复发送内容。
- 定向回归检查首次结构化输入、验收继续和错误继续的实际 wire input,以及同一用户条目的历史去重。
## 2026-09-21 Godot 工作区发现放宽与内置插件行去掉手动启动
本节覆盖下文“打开项目自动识别 Godot”中的旧口径:判定从「唯一命中」放宽为「确定性命中」,`project.godot` 从「必须是普通文件」放宽为「按链接目标判定」。
@@ -1,26 +1,14 @@
import type { ReactNode } from 'react';
export type PlatformSegmentedTabsColumns =
| 'one'
| 'two'
| 'three'
| 'four'
| 'threeToSix';
'one' | 'two' | 'three' | 'four' | 'threeToSix';
export type PlatformSegmentedTabsGap = 'sm' | 'md';
export type PlatformSegmentedTabsRadius = 'md' | 'lg' | 'xl';
export type PlatformSegmentedTabsSize =
| 'sm'
| 'md'
| 'compact'
| 'choice'
| 'tab';
'sm' | 'md' | 'compact' | 'choice' | 'tab';
export type PlatformSegmentedTabsSurface = 'default' | 'soft' | 'transparent';
export type PlatformSegmentedTabsTone =
| 'neutral'
| 'warm'
| 'rose'
| 'accent'
| 'underline';
'neutral' | 'warm' | 'rose' | 'accent' | 'underline';
export type PlatformSegmentedTabsFrame = 'panel' | 'bare';
export type PlatformSegmentedTabsSemantics = 'segment' | 'tabs';
export type PlatformSegmentedTabsLayout = 'grid' | 'scroll';
@@ -31,9 +31,7 @@ export type GameDistributionDeviceSupport = {
export type GameDistributionInputMode = 'keyboard' | 'mouse' | 'touch';
export type GameDistributionOrientation =
| 'landscape'
| 'portrait'
| 'responsive';
'landscape' | 'portrait' | 'responsive';
export type GameDistributionAuthor = {
id: string;
@@ -54,9 +52,7 @@ export type GameDistributionVersionStatus =
| 'revoked';
export type GameDistributionGameVisibility =
| 'unpublished'
| 'published'
| 'suspended';
'unpublished' | 'published' | 'suspended';
export type GameDistributionVersionSummary = {
id: string;