合并最新主分支并清理新增策划耦合
Project CI / AI game creator shell Rust shard 4/4 (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
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled

合入 master 的聊天界面、回合控制及运行时更新
解决七处合并冲突,保持退役策划 V1/V2 链路删除
将新增界面判断连接到 Design Agent 状态并移除旧 GDD 测试依赖
保留现役文档建造指令、回合终止及推理档设置
This commit is contained in:
2026-09-17 02:37:48 +00:00
198 changed files with 19532 additions and 2688 deletions
+4
View File
@@ -7,6 +7,10 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: project-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@"
@@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({
interface MockIntersectionObserverController {
enter: (target: Element) => void;
enterAll: (targets: Element[]) => void;
isObserved: (target: Element) => boolean;
}
@@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
);
});
},
enterAll(targets) {
act(() => {
for (const target of targets) {
const record = observed.get(target);
if (!record) {
throw new Error('目标缩略图尚未进入 IntersectionObserver');
}
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
}
});
},
isObserved(target) {
return observed.has(target);
},
@@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
const thumbnails = entries.map((entry) =>
thumbnailElementForLabel(entry.label),
);
thumbnails.forEach((thumbnail) => {
for (const thumbnail of thumbnails) {
expect(observer.isObserved(thumbnail)).toBe(true);
observer.enter(thumbnail);
});
}
observer.enterAll(thumbnails);
await act(async () => {
await Promise.resolve();
});
@@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
await vi.advanceTimersByTimeAsync(200);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
});
}, 10_000);
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
const observer = installIntersectionObserverMock();
@@ -2,6 +2,8 @@
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.29",
"version": "0.1.47",
"type": "module",
"scripts": {
"dev": "node scripts/start-tauri-dev.mjs",
@@ -57,6 +57,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
+1 -1
View File
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]]
name = "genarrative-ai-game-creator-shell"
version = "0.1.29"
version = "0.1.47"
dependencies = [
"agent-runtime-core",
"axum",
@@ -1,6 +1,6 @@
[package]
name = "genarrative-ai-game-creator-shell"
version = "0.1.29"
version = "0.1.47"
edition = "2021"
publish = false
@@ -2,7 +2,7 @@
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
@@ -13,6 +13,7 @@ Use `agc_browser_playtest` from the `agc_tools` MCP server. Do not replace it wi
2. Inspect both desktop and mobile results, including page readiness, visible text, screenshots, console errors, exceptions, failed requests, Canvas probes, blocked actions, and interaction evidence.
3. Compare screenshots with the user's request. Check that the active game fills its intended area, HUD elements do not cover gameplay, controls are visible, and requested platform art appears in the core experience.
4. If evidence exposes a defect, edit the actual game files and call the tool again when that is useful. The client enforces its own execution and resource bounds; do not invent a fixed repair loop in the response.
Feed the structured diagnostics, console errors, failed requests, and exception text back to the same LLM repair turn before reporting the playtest as failed. Treat the evidence as debugging input and rerun the affected stage after a real code or project change.
5. Treat browser infrastructure failure, an unloaded page, an unhandled exception, or missing evidence as a failed validation. Do not claim success from a partial result.
6. Use game-specific reasoning for quality. Do not require a fixed board, fixed text, fixed number of slices, or a legacy harness scenario; the tool result is evidence for Codex to interpret.
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -27,6 +27,8 @@ For a small edit to an existing game where the brief and suitable assets are unc
When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output.
Every repairable failure must be fed back to the current LLM as the next debugging context before the stage is considered failed. Preserve the redacted tool or command error, the stage, the attempted input, and the evidence already collected; ask the LLM to inspect the current project, make the smallest real repair, and rerun the failed stage. A client-side `isError` tool result or a failed verification is feedback for the LLM, not by itself a terminal user-facing result. Do not silently swallow the error, replace it with a placeholder, or stop after the first failed attempt. Authentication, permission, billing, project identity, corrupted history, transport loss, cancellation, and uncertain paid-operation state remain terminal safety boundaries.
Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered.
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.15",
"version": "2026-08-26.17",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -22,7 +22,7 @@
"agents/openai.yaml",
"references/workflow-contract.md"
],
"sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c"
"sha256": "f25e5bd27e8fc82c61b08dc66366b5b253ee8d16d7fa72dbf2c94d2462f4e7fc"
},
{
"name": "agc-project-structure",
@@ -98,7 +98,7 @@
"agents/openai.yaml",
"references/browser-evidence-contract.md"
],
"sha256": "4437cd8a927a1c79a5faf4bcd40e9946676c08a3b460ab171298cabf899f49ad"
"sha256": "92ecce42d6589e034d32b75bcd155c1fee34a8c7b843eea5780c0577300ed521"
},
{
"name": "agc-client-projection",
@@ -123,7 +123,7 @@
"agents/openai.yaml",
"references/projection-contract.md"
],
"sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1"
}
]
}
@@ -22,7 +22,9 @@ mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_stream;
mod generation;
mod interaction;
mod prompt;
@@ -36,8 +38,9 @@ mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -53,7 +56,9 @@ pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -7,17 +7,89 @@ use super::direct_project_history_injection_oversize_error;
use serde_json::Value;
use std::path::Path;
const DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES: usize = 8 * 1024 * 1024;
const DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT: &str =
"[历史图片预览已省略:本次恢复图片预算已用尽]";
fn omit_image_block(object: &mut serde_json::Map<String, Value>, text_type: &str) {
object.clear();
object.insert("type".to_string(), Value::String(text_type.to_string()));
object.insert(
"text".to_string(),
Value::String(DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT.to_string()),
);
}
fn compact_history_images(value: &mut Value, remaining_bytes: &mut usize) {
match value {
Value::Array(values) => values
.iter_mut()
.for_each(|value| compact_history_images(value, remaining_bytes)),
Value::Object(object) => {
let is_image_block = object.get("type").and_then(Value::as_str) == Some("image");
if is_image_block {
if let Some(data) = object.get("data").and_then(Value::as_str) {
if let Some((preview, mime_type)) = crate::agent::compact_mcp_image_data(data) {
if preview.len() > *remaining_bytes {
omit_image_block(object, "text");
} else {
*remaining_bytes -= preview.len();
object.insert("data".to_string(), Value::String(preview));
object.insert(
"mimeType".to_string(),
Value::String(mime_type.to_string()),
);
}
}
}
}
if object.get("type").and_then(Value::as_str) == Some("input_image") {
if let Some(url) = object
.get("image_url")
.and_then(Value::as_str)
.map(str::to_string)
{
if let Some((header, data)) = url.split_once(",") {
if header.ends_with(";base64") {
if let Some((preview, mime_type)) =
crate::agent::compact_mcp_image_data(data)
{
if preview.len() > *remaining_bytes {
omit_image_block(object, "input_text");
} else {
*remaining_bytes -= preview.len();
object.insert(
"image_url".to_string(),
Value::String(format!("data:{mime_type};base64,{preview}")),
);
}
}
}
}
}
}
object
.values_mut()
.for_each(|value| compact_history_images(value, remaining_bytes));
}
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
}
}
pub(super) fn build_direct_project_history_injection_params(
history_root: &Path,
thread_id: &str,
) -> Result<Value, platform_llm::LlmError> {
let canonical_items = read_direct_project_history_items_at(history_root)
.map_err(platform_llm::LlmError::InvalidRequest)?;
let mut remaining_image_bytes = DIRECT_PROJECT_HISTORY_IMAGE_TOTAL_MAX_BYTES;
let items = canonical_items
.iter()
.map(|item| {
direct_codex_user_item_to_response_item(history_root, item)
.map_err(platform_llm::LlmError::InvalidRequest)
let mut projected = direct_codex_user_item_to_response_item(history_root, item)
.map_err(platform_llm::LlmError::InvalidRequest)?;
compact_history_images(&mut projected, &mut remaining_image_bytes);
Ok(projected)
})
.collect::<Result<Vec<_>, _>>()?;
let params = serde_json::json!({"threadId": thread_id, "items": items});
@@ -30,3 +102,27 @@ pub(super) fn build_direct_project_history_injection_params(
}
Ok(params)
}
#[cfg(test)]
mod tests {
use super::{compact_history_images, DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT};
use serde_json::json;
#[test]
fn history_image_budget_omits_only_wire_preview_when_exhausted() {
let mut item = json!({
"type": "function_call_output",
"output": {"content": [{
"type": "image",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"mimeType": "image/png"
}]}
});
let mut remaining = 1;
compact_history_images(&mut item, &mut remaining);
let block = &item["output"]["content"][0];
assert_eq!(block["type"], "text");
assert_eq!(block["text"], DIRECT_PROJECT_HISTORY_IMAGE_OMITTED_TEXT);
assert_eq!(remaining, 1);
}
}
File diff suppressed because it is too large Load Diff
@@ -527,10 +527,6 @@ fn process_design_batch(
let result = if uncertain {
Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string())
} else {
let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"design.tool",
)?;
execute_design_tool(root, resources, session, &call)
};
let error = result
@@ -1021,6 +1017,15 @@ pub(crate) async fn continue_design_agent_at(
finish_design_command(root, resources, session, active, run, emit).await
}
async fn recover_uncertain_design_batch(
root: &Path,
resources: &DesignResources,
session: DesignSession,
active: File,
) -> Result<DesignView, String> {
finish_design_command(root, resources, session, active, true, |_| {}).await
}
pub(crate) async fn decide_design_phase_at(
root: &Path,
resources: &DesignResources,
@@ -1053,7 +1058,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> {
}
#[tauri::command]
pub(crate) fn hydrate_design_agent_session(
pub(crate) async fn hydrate_design_agent_session(
app: tauri::AppHandle,
project_path: String,
) -> Result<Option<DesignView>, String> {
let root = Path::new(project_path.trim());
@@ -1079,8 +1085,33 @@ pub(crate) fn hydrate_design_agent_session(
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
}
let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?;
Ok(Some(design_view(&session, active.is_none())))
let Some(active) =
try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?
else {
return Ok(Some(design_view(&session, true)));
};
if design_session_has_uncertain_batch(&session) {
let resources = DesignResources::new(resolve_design_resources_root(&app)?)?;
let view = recover_uncertain_design_batch(root, &resources, session, active).await?;
return Ok(Some(view));
}
drop(active);
Ok(Some(design_view(&session, false)))
}
fn design_session_has_uncertain_batch(session: &DesignSession) -> bool {
let Some(batch) = session.pending_batch.as_ref() else {
return false;
};
if !batch.executing || batch.cursor >= batch.calls.len() {
return false;
}
let call_id = batch.calls[batch.cursor].id.as_str();
session.turn.as_ref().is_some_and(|turn| turn.pending)
&& !session.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})
}
fn design_session_error_is_recoverable(error: &str) -> bool {
@@ -1953,4 +1984,94 @@ mod tests {
.any(|message| message.text.contains("重试后继续")));
assert!(next.session.last_error.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() {
let (_temp, root, resources) = init_design_project();
execute_design_file_tool(
&root,
"write_file",
&json!({"path":"project/00_concept/design.md","content":"概念"}),
)
.expect("write concept");
let mut session = new_design_session("design-fake", "quality");
let call = platform_llm::LlmToolCall {
id: "interrupted-call".into(),
name: "patch_file".into(),
arguments: json!({
"path":"project/00_concept/design.md",
"old_text":"概念",
"new_text":"概念设计"
})
.to_string(),
};
session.history.push(json!({
"type":"function_call",
"call_id":call.id,
"name":call.name,
"arguments":call.arguments,
}));
session.messages = vec![DesignMessage {
id: "turn:user".into(),
role: "user".into(),
text: "继续".into(),
}];
session.turn = Some(DesignTurn {
id: "turn-recovery".into(),
pending: true,
request_index: 0,
attempt: 0,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![call],
cursor: 0,
executing: true,
});
assert!(design_session_has_uncertain_batch(&session));
write_design_session(&root, &session).expect("write interrupted session");
let _fake = fake_provider::install(
vec![Ok(fake_response(
"recovered-after-uncertain-tool",
"已读取文件并确认。",
Vec::new(),
))],
0,
);
let view = recover_uncertain_design_batch(&root, &resources, session, {
try_open_game_creator_agent_runtime_task_lock_file(
&root,
".agent/design-agent/active.lock",
)
.expect("open active lock")
.expect("active lock is free")
})
.await
.expect("recover uncertain batch");
assert!(!view.running);
assert!(view.session.last_error.is_none());
let restored = read_design_session(&root)
.expect("read restored")
.expect("session");
assert!(restored.pending_batch.is_none());
assert!(!restored.turn.expect("turn").pending);
assert!(restored.history.iter().any(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some("interrupted-call")
&& item
.get("output")
.and_then(Value::as_str)
.is_some_and(|output| output.contains("执行结果未保存"))
}));
assert!(restored.history.iter().any(|item| {
item.get("role").and_then(Value::as_str) == Some("assistant")
&& item.get("content").is_some()
}));
assert!(
fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md"))
.expect("read target")
== "概念"
);
}
}
@@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool(
})
.collect::<Vec<_>>();
let mut matches = Vec::new();
let mut edit_errors = Vec::new();
let mut valid_edits = 0;
for (index, (old, new)) in normalized.iter().enumerate() {
if old == new {
edit_errors.push(format!(
"edits[{index}] new_text 与 old_text 相同,不会产生修改"
));
continue;
}
let count = content.matches(old).count();
if count == 0 {
return Err(format!("edits[{index}] 原文未找到:{display}"));
edit_errors.push(format!(
"edits[{index}] 原文未找到:{}{}",
display,
design_patch_location_hint(&content, old)
));
continue;
}
if count != 1 {
return Err(format!(
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
let start = content.find(old).expect("count checked");
let line = design_patch_line_number(&content, start);
edit_errors.push(format!(
"edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line}"
));
continue;
}
let start = content.find(old).expect("count checked");
let end = start + old.len();
@@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool(
.iter()
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
{
return Err(format!(
"edits[{index}] 与 edits[{other_index}] 修改范围重叠{display}"
edit_errors.push(format!(
"edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text"
));
continue;
}
matches.push((index, start, end));
valid_edits += 1;
let _ = new;
}
if !edit_errors.is_empty() {
let shown = edit_errors.len().min(4);
let mut details = edit_errors[..shown].to_vec();
if shown < edit_errors.len() {
details.push(format!(
"另有 {} 个 edit 校验失败(详情省略)",
edit_errors.len() - shown
));
}
if valid_edits > 0 {
details.push(format!(
"其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件"
));
} else {
details.push("本次未写入文件".to_string());
}
return Err(details.join("\n"));
}
let mut updated = content.clone();
for (index, start, end) in matches.into_iter().rev() {
let (_, new) = &normalized[index];
@@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool(
}
}
fn design_patch_line_number(content: &str, start: usize) -> usize {
1 + content[..start]
.bytes()
.filter(|byte| *byte == b'\n')
.count()
}
fn design_patch_visible_line(line: &str) -> String {
line.replace('\t', "\\t").chars().take(180).collect()
}
fn design_patch_location_hint(content: &str, old: &str) -> String {
let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else {
return String::new();
};
let mut candidates = content
.lines()
.enumerate()
.filter(|(_, line)| line.trim() == anchor)
.map(|(index, line)| (index + 1, line))
.collect::<Vec<_>>();
if candidates.is_empty() {
let token = anchor.split_whitespace().find(|token| token.len() >= 3);
if let Some(token) = token {
candidates = content
.lines()
.enumerate()
.filter(|(_, line)| line.trim().contains(token))
.map(|(index, line)| (index + 1, line))
.collect();
}
}
if candidates.is_empty() {
return format!(
";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)",
content.lines().count()
);
}
let details = candidates
.iter()
.take(2)
.map(|(line, text)| format!("{line} 行:{}", design_patch_visible_line(text)))
.collect::<Vec<_>>()
.join("");
let suffix = if candidates.len() > 2 {
format!("{}", candidates.len())
} else {
String::new()
};
format!(";old_text 首个非空行可能对应 {details}{suffix}tab 显示为 \\t")
}
pub(crate) fn list_design_workspace_files(
root: &Path,
) -> Result<Vec<DesignWorkspaceEntry>, String> {
@@ -693,6 +783,22 @@ mod tests {
)
.expect_err("escape");
assert!(escaped.contains("路径"));
let mismatch = execute_design_file_tool(
root,
"patch_file",
&json!({
"path":"notes/design.md",
"edits":[
{"old_text":" 游戏设计","new_text":"游戏概念"},
{"old_text":"设计","new_text":"方案"}
]
}),
)
.expect_err("report all patch failures");
assert!(mismatch.contains("edits[0] 原文未找到"));
assert!(mismatch.contains("第 1 行:游戏设计"));
assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配"));
assert!(mismatch.contains("本次未写入文件"));
let patched = execute_design_file_tool(
root,
"patch_file",
@@ -5,6 +5,7 @@ use crate::project::{
};
use crate::{LocalConversationMessageRecord, LocalConversationResult};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
@@ -91,6 +92,10 @@ fn record(item: &Value) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
"payload": item,
"recordedAt": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0),
}))
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
}
@@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st
}
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
Ok(read_direct_project_history_entries_at(root)?
.into_iter()
.map(|(item, _)| item)
.collect())
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
@@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
if is_direct_project_internal_context_item(&item) {
continue;
}
items.push(item);
items.push((
item,
parsed
.get("recordedAt")
.and_then(Value::as_u64)
.unwrap_or(0),
));
}
Ok(items)
}
@@ -516,18 +534,30 @@ pub(crate) fn read_direct_project_history_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool), String> {
let items = read_direct_project_history_items_at(root)?;
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let items = read_direct_project_history_entries_at(root)?;
let end = match before_item_id {
Some(item_id) => items
.iter()
.position(|item| item.get("id").and_then(Value::as_str) == Some(item_id))
.position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id))
.ok_or_else(|| format!("DirectProject 历史中不存在 item{item_id}"))?,
None => items.len(),
};
let bounded_limit = limit.clamp(1, 200);
let start = end.saturating_sub(bounded_limit);
Ok((items[start..end].to_vec(), start > 0))
let slice = &items[start..end];
let timestamps = slice
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
slice.iter().map(|(item, _)| item.clone()).collect(),
start > 0,
timestamps,
))
}
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
@@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at(
root: &Path,
) -> Result<LocalConversationResult, String> {
let path = history_path(root);
let items = read_direct_project_history_items_at(root)?;
let items = read_direct_project_history_entries_at(root)?;
let messages = items
.into_iter()
.filter_map(|item| {
.filter_map(|(item, recorded_at)| {
let role = item.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant") {
return None;
@@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at(
content,
agent_id: None,
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
updated_at: 0,
updated_at: recorded_at,
})
})
.collect();
@@ -610,6 +640,40 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
let item = json!({
"type": "message", "role": "user", "id": "sent-message",
"content": [{"type": "input_text", "text": "修改游戏"}],
});
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (items, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(items, vec![item.clone()]);
assert!(timestamps["sent-message"] > 0);
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (_, _, reloaded) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(timestamps, reloaded);
}
#[test]
fn old_history_without_envelope_time_stays_unknown() {
let root = init_history_project("history-unknown-time");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
let (_, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(timestamps.is_empty());
assert_eq!(
read_direct_project_chat_history_at(root.path())
.unwrap()
.messages[0]
.updated_at,
0
);
}
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
@@ -22,6 +22,14 @@ impl DirectProjectHistoryAccumulator {
}
}
/// 某个 assistant item 目前累计到的全文。
///
/// 回合流按 item 分段:同一个 item 的后续 delta 是同一段的增长,item 变了才是新的一段。
/// 没有这条 item(非 DirectProject 工作区、或已经 complete)时返回 `None`。
pub(crate) fn accumulated_text_for(&self, item_id: &str) -> Option<String> {
self.text_by_item_id.get(item_id).cloned()
}
fn take_partial_items(&mut self) -> impl Iterator<Item = Value> + '_ {
std::mem::take(&mut self.text_by_item_id)
.into_iter()
File diff suppressed because one or more lines are too long
@@ -31,7 +31,7 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
user_item: DirectCodexUserItem,
mut user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
@@ -50,6 +50,17 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false);
error
@@ -81,6 +92,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
}
};
audit.finish(true);
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
}
@@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult {
pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
}
#[derive(Clone, Debug)]
@@ -19,6 +19,8 @@ const DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000;
const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000;
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_PROMPT_CHARS: usize = 32_000;
const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024;
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES: usize = 256 * 1024;
const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400;
const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5;
const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss";
@@ -694,14 +696,44 @@ fn direct_tool_bridge_state_with_search(
})
}
/// 将 MCP 图片 block 限制为可安全回显和持久化的预览。
///
/// 工具结果会被 Codex 原样写入 DirectProject 历史;这里保留小图的原始
/// PNG,大图则缩放并转成 JPEG。项目文件中的原图不受影响,历史恢复仍有
/// 可见证据,但不会把多张几 MiB 的截图永久复制进上下文。
pub(crate) fn compact_mcp_image_data(data: &str) -> Option<(String, &'static str)> {
let bytes = BASE64_STANDARD.decode(data).ok()?;
if bytes.is_empty() {
return None;
}
if bytes.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
return Some((data.to_string(), "image/png"));
}
let image = image::load_from_memory(&bytes).ok()?;
let mut preview = image.thumbnail(
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION,
);
for (dimension, quality) in [(1024, 78), (768, 70), (512, 60), (384, 50)] {
if preview.width() > dimension || preview.height() > dimension {
preview = image.thumbnail(dimension, dimension);
}
let mut encoded = Vec::new();
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, quality);
preview.write_with_encoder(encoder).ok()?;
if encoded.len() <= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES {
return Some((BASE64_STANDARD.encode(encoded), "image/jpeg"));
}
}
None
}
fn bridge_tool_result(text: String, images: Vec<String>, is_error: bool) -> Value {
let mut content = vec![json!({ "type": "text", "text": text })];
content.extend(images.into_iter().map(|data| {
json!({
"type": "image",
"data": data,
"mimeType": "image/png"
})
content.extend(images.into_iter().filter_map(|data| {
let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png"));
Some(json!({ "type": "image", "data": data, "mimeType": mime_type }))
}));
json!({ "content": content, "isError": is_error })
}
@@ -1855,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async {
bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?;
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
@@ -1864,6 +1896,8 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
"assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?;
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
let manifest = read_existing_manifest_for_project(&state.root)?;
let source_asset = manifest
.assets
@@ -1888,22 +1922,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.map_err(|_| "创建抠图服务连接失败".to_string())?;
let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
let fingerprint = background_removal_request_fingerprint(
&source_asset_id,
&asset_name,
background_mode,
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker(
client
.post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
})),
.json(&request_body),
)
.send()
.await
@@ -1940,6 +1986,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
}
}
fn background_removal_request_fingerprint(
source: &str,
name: &str,
mode: Option<&str>,
color: Option<&str>,
) -> String {
let mode = mode.unwrap_or("complex");
if mode == "complex" && color.is_none() {
format!("{source}\0{name}")
} else {
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
}
}
fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object();
json!({
@@ -2671,8 +2731,31 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)]
mod tests {
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", None, None),
legacy
);
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
legacy
);
let mut identities = std::collections::HashSet::new();
identities.insert(legacy.to_string());
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
let id =
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
assert_eq!(
id,
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
);
assert!(identities.insert(id));
}
}
use super::*;
use std::io::{Read, Write};
use std::io::{Cursor, Read, Write};
#[tokio::test]
async fn controlled_search_client_omits_agc_marker() {
@@ -2766,6 +2849,35 @@ mod tests {
assert!(bridge_search_max_results(&json!({ "maxResults": 6 })).is_err());
}
#[test]
fn large_mcp_images_are_reduced_to_bounded_jpeg_previews() {
let image = image::RgbaImage::from_fn(1600, 1200, |x, y| {
image::Rgba([
(x % 251) as u8,
(y % 251) as u8,
((x.wrapping_mul(31) + y.wrapping_mul(17)) % 251) as u8,
u8::MAX,
])
});
let mut png = Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut png, image::ImageFormat::Png)
.expect("encode image fixture");
assert!(png.get_ref().len() > DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES);
let (preview, mime_type) =
compact_mcp_image_data(&BASE64_STANDARD.encode(png.into_inner()))
.expect("large valid image should produce preview");
assert_eq!(mime_type, "image/jpeg");
assert!(
BASE64_STANDARD
.decode(preview)
.expect("preview base64")
.len()
<= DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_BYTES
);
}
#[test]
fn search_parser_accepts_only_bounded_public_https_results() {
let body = r#"<rss><channel><item><title>Tauri &amp; Rust</title><link>https://tauri.app/</link><description>&lt;b&gt;Cross-platform apps&lt;/b&gt;</description></item><item><title>Private</title><link>http://127.0.0.1:8082/private</link><description>private</description></item><item><title>Credentials</title><link>https://user:pass@example.test/path</link><description>private</description></item><item><title>Loopback host</title><link>https://localhost/private</link><description>private</description></item><item><title>Local host</title><link>https://service.internal/private</link><description>private</description></item></channel></rss>"#;

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