Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2e6f205d6 | |||
| 30c377d0f0 |
@@ -164,7 +164,6 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
'packages/shared/src/contracts/generated/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
|
||||
@@ -4,8 +4,6 @@ node_modules
|
||||
.codex-logs
|
||||
public/Icons
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
# ts-rs 直接落盘的生成产物:保持生成器原始格式,`cargo test export_bindings` 才可幂等复现。
|
||||
packages/shared/src/contracts/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
media
|
||||
*.log
|
||||
|
||||
+16
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
**运行态事件**:
|
||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||
|
||||
**聊天投影**:
|
||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||
|
||||
## Relationships
|
||||
|
||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||
|
||||
-1
@@ -5016,7 +5016,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -56,7 +56,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -21,6 +21,7 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
@@ -40,7 +41,8 @@ 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, DirectTurnCancelView,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
direct_thread_id_for_project, DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
@@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
+23
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
|
||||
))
|
||||
}
|
||||
|
||||
/// 项目根目录在 Thread Manager 里的线程身份。
|
||||
///
|
||||
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
|
||||
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
|
||||
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
|
||||
///
|
||||
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
|
||||
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
|
||||
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
|
||||
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
|
||||
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
|
||||
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
|
||||
return root.to_string_lossy().into_owned();
|
||||
};
|
||||
canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix(r"\\?\"))
|
||||
.map(std::path::Path::new)
|
||||
.unwrap_or(canonical_root.as_path())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@ mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
|
||||
@@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageEnvelope {
|
||||
pub(crate) item: DirectCodexUserItem,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -115,165 +111,29 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<String, String> {
|
||||
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut prompt = wire
|
||||
.as_array()
|
||||
wire.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.and_then(|parts| {
|
||||
.map(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||
})
|
||||
.collect::<Result<String, String>>()
|
||||
})?;
|
||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||
prompt.push('\n');
|
||||
prompt.push_str(&code_context);
|
||||
}
|
||||
if prompt.trim().is_empty() {
|
||||
return Err("DirectProject user item 不能转换为空 prompt".to_string());
|
||||
}
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的
|
||||
/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。
|
||||
///
|
||||
/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item`
|
||||
/// 的纯投影,不得在这里产生项目写副作用。
|
||||
fn render_ui_design_code_context(
|
||||
root: &Path,
|
||||
message: &DirectCodexUserMessageItem,
|
||||
) -> Result<Option<String>, String> {
|
||||
let referenced_ids = message
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|part| match part {
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
Some(resource_id.trim())
|
||||
}
|
||||
_ => None,
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>()
|
||||
})
|
||||
.and_then(|prompt| {
|
||||
if prompt.trim().is_empty() {
|
||||
Err("DirectProject user item 不能转换为空 prompt".to_string())
|
||||
} else {
|
||||
Ok(prompt)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if referenced_ids.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::new();
|
||||
for resource_id in referenced_ids {
|
||||
let is_ui_design_doc = manifest.assets.iter().any(|asset| {
|
||||
asset.id == resource_id
|
||||
&& asset.kind == UI_DESIGN_DOC_ASSET_KIND
|
||||
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
|
||||
});
|
||||
if !is_ui_design_doc {
|
||||
continue;
|
||||
}
|
||||
lines.push(
|
||||
match generate_ui_design_code_at(GenerateUiDesignCodeInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
asset_id: resource_id.to_string(),
|
||||
}) {
|
||||
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
|
||||
Err(error) => format!("生成代码遇到错误{error}"),
|
||||
},
|
||||
);
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(lines.join("\n")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use super::direct_codex_user_item_to_response_item;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project";
|
||||
|
||||
fn prompt_context_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(
|
||||
directory.path(),
|
||||
PROMPT_CONTEXT_PROJECT_ID,
|
||||
"Direct prompt 上下文测试",
|
||||
)
|
||||
.expect("init project");
|
||||
directory
|
||||
}
|
||||
|
||||
fn register_fixture_asset(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
media_type: &str,
|
||||
) -> String {
|
||||
let absolute_path = crate::resolve_local_project_path(root, relative_path)
|
||||
.expect("resolve fixture asset path");
|
||||
std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent"))
|
||||
.expect("create fixture asset parent");
|
||||
std::fs::write(&absolute_path, b"{}").expect("write fixture asset file");
|
||||
crate::assets::register_local_asset_at(
|
||||
root,
|
||||
relative_path,
|
||||
kind,
|
||||
media_type,
|
||||
"prompt-context-test",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Generated,
|
||||
canvas_project_id: None,
|
||||
resource_id: Some("prompt-context".to_string()),
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register fixture asset")
|
||||
.id
|
||||
}
|
||||
|
||||
fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) {
|
||||
let directory = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
directory.path(),
|
||||
"ui/design.json",
|
||||
GameCreationAppAssetKind::UiDesignDoc,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
);
|
||||
if initialize_state {
|
||||
crate::ui_editor::persistence::initialize_ui_design_state_at(
|
||||
directory.path(),
|
||||
PROMPT_CONTEXT_PROJECT_ID,
|
||||
&asset_id,
|
||||
)
|
||||
.expect("initialize UI design state");
|
||||
}
|
||||
(directory, asset_id)
|
||||
}
|
||||
|
||||
fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "agc_resource_reference", "resourceId": asset_id}],
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
@@ -314,81 +174,4 @@ mod tests {
|
||||
.expect_err("history item without type must fail");
|
||||
assert!(error.contains("缺少 type"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(
|
||||
prompt.contains(&format!(
|
||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||
)),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||
"{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_projection_never_writes_generated_ui_design_code() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item = user_item_with_resource_reference(&asset_id);
|
||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||
let generated_root =
|
||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||
let generated_files = std::fs::read_dir(generated_root)
|
||||
.expect("read ui directory")
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("generated-")
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
generated_files, 0,
|
||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||
let project = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
project.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1215,7 +1215,7 @@ fn bridge_registered_resource(
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
|
||||
"category": game_creation_app_asset_effective_category(asset.kind.as_str(), asset.category),
|
||||
"category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
|
||||
"tags": asset.tags,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
@@ -1256,9 +1256,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
arguments,
|
||||
"kind",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
|
||||
)?
|
||||
.map(|kind| bridge_asset_list_kind_filter(&kind))
|
||||
.transpose()?;
|
||||
)?;
|
||||
let asset_id = bridge_optional_bounded_string(
|
||||
arguments,
|
||||
"assetId",
|
||||
@@ -1278,7 +1276,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
let mut assets = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| kind.is_none_or(|kind| asset.kind == kind))
|
||||
.filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind))
|
||||
.filter(|asset| {
|
||||
asset_id
|
||||
.as_ref()
|
||||
@@ -1344,24 +1342,6 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `asset.list` 的 `kind` 过滤:只接受 canonical 值,认不出的值直接报错。
|
||||
///
|
||||
/// 以前认不出的值会收口成 `unknown` 再参与等值过滤,结果是**静默返回空列表**:调用方(模型)
|
||||
/// 会以为项目里没有这类资产,而不是"你传的 kind 不合法",于是继续按错误前提往下走。
|
||||
/// `unknown` 本身仍是合法输入(确有 kind 未知的资产),只拒绝"既不是 canonical、也不是字面
|
||||
/// `unknown`"的原值。
|
||||
fn bridge_asset_list_kind_filter(raw: &str) -> Result<GameCreationAppAssetKind, String> {
|
||||
let kind = GameCreationAppAssetKind::parse_with_context(raw, "asset.list.kind");
|
||||
if kind == GameCreationAppAssetKind::Unknown
|
||||
&& raw.trim() != GameCreationAppAssetKind::Unknown.as_str()
|
||||
{
|
||||
return Err(format!(
|
||||
"kind 不是已知的 manifest 资源 kind:{raw};请改用 canonical kind(如 image、scene、character、icon、icon-spritesheet、character-animation、audio、video、document)"
|
||||
));
|
||||
}
|
||||
Ok(kind)
|
||||
}
|
||||
|
||||
fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) {
|
||||
let extension = Path::new(path)
|
||||
.extension()
|
||||
@@ -1929,7 +1909,7 @@ async fn bridge_create_or_derive_resource(
|
||||
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
|
||||
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
|
||||
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
|
||||
producer_task_id: source_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.source.task_id.clone()),
|
||||
@@ -2035,7 +2015,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
source_asset_id: Some(source_asset_id.clone()),
|
||||
source_path: Some(source_asset.local_path.clone()),
|
||||
source_media_type: Some(source_asset.media_type.clone()),
|
||||
source_subtype: Some(source_asset.kind.to_string()),
|
||||
source_subtype: Some(source_asset.kind.clone()),
|
||||
producer_task_id: source_asset.source.task_id.clone(),
|
||||
source_version_id: None,
|
||||
prompt: "去除背景".to_string(),
|
||||
@@ -3064,28 +3044,6 @@ mod tests {
|
||||
.contains("x-genarrative-client:"));
|
||||
}
|
||||
|
||||
/// `asset.list` 的 `kind` 过滤必须报错,而不是静默返回空列表:旧值会收口成 `unknown` 再参与
|
||||
/// 等值过滤,匹配不到任何资产,让模型误以为项目里没有这类资产。校验必须发生在项目访问之前。
|
||||
#[test]
|
||||
fn bridge_asset_list_rejects_non_canonical_kind_filter() {
|
||||
let root = tempfile::tempdir().expect("bridge root");
|
||||
let rejected =
|
||||
bridge_list_registered_assets(root.path(), &json!({ "kind": "art-spritesheet-slice" }));
|
||||
assert_eq!(rejected["isError"], true);
|
||||
assert!(
|
||||
rejected.to_string().contains("art-spritesheet-slice"),
|
||||
"错误里必须带上原值,调用方才能自纠:{rejected}"
|
||||
);
|
||||
// 字面 `unknown` 仍是合法过滤值:它应当越过 kind 校验,失败点换成项目侧(此目录未初始化)。
|
||||
let allowed = bridge_list_registered_assets(root.path(), &json!({ "kind": "unknown" }));
|
||||
assert!(
|
||||
!allowed
|
||||
.to_string()
|
||||
.contains("不是已知的 manifest 资源 kind"),
|
||||
"未知 kind 的字面量本身是合法输入:{allowed}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝,
|
||||
/// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。
|
||||
#[test]
|
||||
@@ -3786,7 +3744,7 @@ mod tests {
|
||||
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "local-art-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::Icon,
|
||||
kind: "art-spritesheet-slice".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3843,7 +3801,7 @@ mod tests {
|
||||
fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "animation-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::CharacterAnimation,
|
||||
kind: "character-animation".to_string(),
|
||||
media_type: "video/mp4".to_string(),
|
||||
local_path: "assets/edits/animation.mp4".to_string(),
|
||||
image_sequence_frames: Some(vec![
|
||||
@@ -3894,7 +3852,7 @@ mod tests {
|
||||
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::Spec,
|
||||
kind: "spec".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/specs/hero.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3930,13 +3888,13 @@ mod tests {
|
||||
///
|
||||
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
|
||||
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
|
||||
/// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`,两侧分类口径必须一致。
|
||||
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "ui-design-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::UiDesign,
|
||||
id: "ui-1".to_string(),
|
||||
kind: "ui".to_string(),
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/UI 设计 1.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3959,7 +3917,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "ui-design");
|
||||
assert_eq!(projection["kind"], "ui");
|
||||
assert_eq!(projection["category"], "ui-interaction");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。
|
||||
//! GameAgent 对话「工具调用卡片」的采集与持久化。
|
||||
//!
|
||||
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:
|
||||
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
|
||||
@@ -8,11 +8,9 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use super::direct_thread_wire::sanitize_detail_text;
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -24,7 +22,7 @@ use std::path::{Path, PathBuf};
|
||||
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
|
||||
/// 条目 schema 版本。
|
||||
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
|
||||
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
|
||||
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
|
||||
/// `detail.command` / `detail.output` 的字符上限。
|
||||
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
|
||||
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/tool-calls.jsonl")
|
||||
}
|
||||
|
||||
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
|
||||
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
|
||||
let mut index = start;
|
||||
let mut relative = String::new();
|
||||
while index < value.len() {
|
||||
let character = value[index..].chars().next().unwrap_or_default();
|
||||
if matches!(character, '/' | '\\') {
|
||||
if !relative.is_empty() {
|
||||
relative.push('/');
|
||||
}
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ';'
|
||||
| '|'
|
||||
| '&'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '<'
|
||||
| '>'
|
||||
| ':'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
relative.push(character);
|
||||
index += character.len_utf8();
|
||||
}
|
||||
while relative.ends_with('/') {
|
||||
relative.pop();
|
||||
}
|
||||
(index, relative)
|
||||
}
|
||||
|
||||
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
|
||||
///
|
||||
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
|
||||
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
|
||||
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
|
||||
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
|
||||
let root_text = root.to_string_lossy();
|
||||
let root_text = root_text.trim_end_matches(['/', '\\']);
|
||||
if root_text.is_empty() {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut needles = [
|
||||
root_text.to_string(),
|
||||
root_text.replace('\\', "/"),
|
||||
root_text.replace('/', "\\"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|needle| needle.to_ascii_lowercase())
|
||||
.filter(|needle| !needle.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
needles.sort();
|
||||
needles.dedup();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < value.len() {
|
||||
let mut hit: Option<(usize, usize)> = None;
|
||||
for needle in &needles {
|
||||
let mut search = cursor;
|
||||
while let Some(relative) = lower[search..].find(needle.as_str()) {
|
||||
let start = search + relative;
|
||||
let end = start + needle.len();
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start].chars().next_back().is_some_and(|character| {
|
||||
!character.is_alphanumeric() && character != '_' && character != '-'
|
||||
});
|
||||
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
|
||||
if hit.is_none_or(|(best_start, _)| start < best_start) {
|
||||
hit = Some((start, end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
let Some((start, end)) = hit else {
|
||||
break;
|
||||
};
|
||||
output.push_str(&value[cursor..start]);
|
||||
let (consumed, relative) = project_relative_path_segment(value, end);
|
||||
if relative.is_empty() {
|
||||
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
|
||||
output.push_str("<absolute-path>");
|
||||
} else {
|
||||
output.push_str(&relative);
|
||||
}
|
||||
cursor = consumed;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
|
||||
/// 错误上下文脱敏。
|
||||
///
|
||||
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
|
||||
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
|
||||
///
|
||||
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
|
||||
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
|
||||
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
|
||||
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
|
||||
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
|
||||
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
|
||||
///
|
||||
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
|
||||
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
|
||||
let without_project_root = relativize_project_root_paths(root, value);
|
||||
let without_absolute = redact_absolute_path_tokens(&without_project_root);
|
||||
let without_secret = redact_secret_tokens(&without_absolute);
|
||||
sanitize_error_context(&without_secret)
|
||||
}
|
||||
|
||||
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
@@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
|
||||
normalized
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
|
||||
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
|
||||
let path = tool_calls_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
|
||||
}
|
||||
|
||||
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
|
||||
fn status_certainty(status: &str) -> u8 {
|
||||
match status {
|
||||
@@ -684,10 +545,22 @@ mod tests {
|
||||
use super::{
|
||||
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
|
||||
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
|
||||
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
|
||||
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
|
||||
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
|
||||
DIRECT_TOOL_CALL_SCHEMA_VERSION,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
|
||||
/// 断言因此落在「磁盘上到底写了什么」这一层。
|
||||
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
|
||||
std::fs::read_to_string(tool_calls_path(root))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(tool_call_from_line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 一行合法的落盘信封(回读用例的夹具)。
|
||||
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
|
||||
@@ -812,7 +685,7 @@ mod tests {
|
||||
.expect("completed tool call");
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(calls[0].status, "completed");
|
||||
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
|
||||
@@ -912,85 +785,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
|
||||
#[test]
|
||||
fn tool_call_read_skips_corrupted_lines() {
|
||||
let root = init_tool_call_project("tool-call-corrupt");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let good = serde_json::to_string(&json!({
|
||||
"type": "tool_call_item",
|
||||
"payload": {
|
||||
"schemaVersion": "agc-tool-call.v1",
|
||||
"id": "item-good",
|
||||
"turnId": "turn-1",
|
||||
"kind": "command",
|
||||
"title": "执行命令",
|
||||
"summary": "npm run build",
|
||||
"status": "completed",
|
||||
"detail": {"command": "npm run build"},
|
||||
"startedAt": 1,
|
||||
"updatedAt": 2
|
||||
}
|
||||
}))
|
||||
.expect("serialize good row");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
|
||||
)
|
||||
.expect("write fixture");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_direct_tool_calls_at(missing.path())
|
||||
.expect("missing file is empty")
|
||||
.is_empty(),
|
||||
"历史文件缺失必须返回空数组"
|
||||
);
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
|
||||
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
|
||||
assert_eq!(calls[0].id, "item-good");
|
||||
}
|
||||
|
||||
/// 判据:回读按时间正序,且超出上限时保留最新。
|
||||
#[test]
|
||||
fn tool_call_read_is_ordered_and_capped() {
|
||||
let root = init_tool_call_project("tool-call-cap");
|
||||
let total = DIRECT_TOOL_CALL_LIMIT + 5;
|
||||
let calls = (0..total)
|
||||
.map(|index| {
|
||||
direct_tool_call_from_item(
|
||||
root.path(),
|
||||
&json!({
|
||||
"id": format!("item-{index:04}"),
|
||||
"type": "commandExecution",
|
||||
"command": format!("run {index}"),
|
||||
"startedAtMs": 1000 + index as u64,
|
||||
}),
|
||||
"turn-1",
|
||||
false,
|
||||
1000 + index as u64,
|
||||
)
|
||||
.expect("tool call")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
|
||||
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
|
||||
assert_eq!(
|
||||
read.first().expect("first").id,
|
||||
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
|
||||
"最早被裁掉的是最旧的条目"
|
||||
);
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"回读必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
|
||||
#[test]
|
||||
fn tool_call_file_change_title_counts_unique_paths() {
|
||||
@@ -1189,7 +983,7 @@ mod tests {
|
||||
|
||||
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
|
||||
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
@@ -1201,7 +995,7 @@ mod tests {
|
||||
// 回合末整批落盘那条路径同样不得回退。
|
||||
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
|
||||
.expect("persist stale running batch");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
|
||||
let calls = persisted_tool_calls(root.path());
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"整批落盘路径同样不得把 completed 打回 running"
|
||||
@@ -1209,28 +1003,6 @@ mod tests {
|
||||
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
|
||||
}
|
||||
|
||||
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
|
||||
#[test]
|
||||
fn tool_call_read_merges_duplicate_rows_monotonically() {
|
||||
let root = init_tool_call_project("tool-call-read-monotonic");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
let completed = tool_call_row("item-1", 1000, 2000);
|
||||
let stale_running = tool_call_row("item-1", 1000, 1000)
|
||||
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
|
||||
assert!(stale_running.contains("\"status\":\"running\""));
|
||||
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
|
||||
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
|
||||
assert_eq!(calls.len(), 1, "同 id 归并成一条");
|
||||
assert_eq!(
|
||||
calls[0].status, "completed",
|
||||
"磁盘上更旧的快照不得把状态打回 running"
|
||||
);
|
||||
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
|
||||
assert_eq!(calls[0].started_at, 1000);
|
||||
}
|
||||
|
||||
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
|
||||
#[test]
|
||||
fn tool_call_paths_become_project_relative() {
|
||||
@@ -1283,9 +1055,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。
|
||||
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回,
|
||||
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
|
||||
#[test]
|
||||
fn tool_call_read_skips_invalid_utf8_line() {
|
||||
fn tool_call_pre_read_skips_invalid_utf8_line() {
|
||||
let root = init_tool_call_project("tool-call-invalid-utf8");
|
||||
let path = tool_calls_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
|
||||
@@ -1298,7 +1071,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1318,7 +1091,7 @@ mod tests {
|
||||
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
|
||||
bytes.push(b'\n');
|
||||
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
|
||||
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
|
||||
let calls = read_tool_call_lines(&path);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
@@ -1326,10 +1099,16 @@ mod tests {
|
||||
);
|
||||
assert_eq!(calls[0].id, "item-a");
|
||||
assert_eq!(calls[1].id, "item-c");
|
||||
|
||||
let missing = tempfile::tempdir().expect("missing dir");
|
||||
assert!(
|
||||
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
|
||||
"历史文件缺失时写前读取必须返回空表"
|
||||
);
|
||||
}
|
||||
|
||||
/// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
|
||||
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
|
||||
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
|
||||
#[test]
|
||||
fn tool_call_cap_drops_oldest_turn_cards() {
|
||||
let root = init_tool_call_project("tool-call-cap-oldest");
|
||||
@@ -1366,7 +1145,7 @@ mod tests {
|
||||
.expect("newest tool call");
|
||||
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
|
||||
|
||||
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
|
||||
let read = persisted_tool_calls(root.path());
|
||||
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
|
||||
assert_eq!(
|
||||
read.last().expect("last").id,
|
||||
@@ -1381,7 +1160,7 @@ mod tests {
|
||||
assert!(
|
||||
read.windows(2)
|
||||
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
|
||||
"回读必须按时间正序"
|
||||
"落盘顺序必须按时间正序"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 80,
|
||||
"description": "可选的 manifest 资源 kind 精确过滤,必须是 canonical kind(例如 video、character-animation、sound-effect、background-music、icon-spritesheet);传非 canonical 值会被拒绝并报错,不会静默返回空列表"
|
||||
"description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice"
|
||||
},
|
||||
"assetId": {
|
||||
"type": "string",
|
||||
@@ -2897,7 +2897,7 @@ mod tests {
|
||||
fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry {
|
||||
GameCreationAppAssetManifestEntry {
|
||||
id: id.to_string(),
|
||||
kind: GameCreationAppAssetKind::Image,
|
||||
kind: "image".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: local_path.to_string(),
|
||||
image_sequence_frames: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! GameAgent 对话「回合流」的采集、持久化与回读。
|
||||
//! GameAgent 对话「回合流」的采集与持久化。
|
||||
//!
|
||||
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
|
||||
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
|
||||
@@ -11,7 +11,7 @@
|
||||
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
|
||||
|
||||
use crate::agent::sanitize_detail_text;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::config::write_game_creator_private_file;
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
|
||||
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
|
||||
let path = turn_stream_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(normalize_stream_items(read_stream_lines(&path)))
|
||||
}
|
||||
|
||||
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
|
||||
///
|
||||
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
|
||||
|
||||
@@ -477,28 +477,6 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option
|
||||
})
|
||||
}
|
||||
|
||||
/// 把平台生成适配层收到的 `asset_kind` 映射为 manifest 资产的正式 kind。
|
||||
///
|
||||
/// 生成工具的新输入由 `GameCreationAppAssetKind::CANVAS_ASSET_KINDS` 约束;这里保留对
|
||||
/// 平台请求值和旧生成账本值的显式映射,只在登记边界转换为 manifest canonical kind,
|
||||
/// 避免把平台字段直接写入 `assets[].kind`。
|
||||
///
|
||||
/// 词表外的值不猜:返回 `Unknown`,由调用方按既有未知输入口径处理。
|
||||
pub(crate) fn platform_art_asset_manifest_kind(asset_kind: &str) -> GameCreationAppAssetKind {
|
||||
match asset_kind.trim() {
|
||||
"image" | "game-art" => GameCreationAppAssetKind::Image,
|
||||
"character" | "character-art" => GameCreationAppAssetKind::Character,
|
||||
"spec" | "icon-spec" => GameCreationAppAssetKind::IconSpec,
|
||||
"ui-prototype" | "ui-design" => GameCreationAppAssetKind::UiDesign,
|
||||
"art-spritesheet" | "icon-spritesheet" => GameCreationAppAssetKind::IconSpritesheet,
|
||||
"publication-material" => GameCreationAppAssetKind::PublicationMaterial,
|
||||
"game-background" | "scene" => GameCreationAppAssetKind::Scene,
|
||||
// 其它平台适配输入也只在这里映射到 manifest kind;认不出的原值收口成 `Unknown`
|
||||
// 并留痕(回调接到壳层 `app_log!`),manifest 侧不再维护第二张别名表。
|
||||
_ => GameCreationAppAssetKind::parse_with_context(asset_kind, "canvas.asset_kind"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
||||
root: &Path,
|
||||
pending: &AgentRuntimePendingToolAction,
|
||||
@@ -1642,7 +1620,7 @@ async fn canonical_art_spec_reference_at(
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH
|
||||
&& asset.kind == GameCreationAppAssetKind::IconSpec
|
||||
&& asset.kind == "icon-spec"
|
||||
&& asset.media_type.starts_with("image/")
|
||||
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
|
||||
})
|
||||
@@ -1666,7 +1644,7 @@ async fn canonical_art_spec_reference_at(
|
||||
&source.id,
|
||||
&format!("{:x}", Sha256::digest(&bytes)),
|
||||
&source.media_type,
|
||||
source.kind.as_str(),
|
||||
&source.kind,
|
||||
)?;
|
||||
if let Some(binding) = read_external_editor_resource_binding_at(
|
||||
root,
|
||||
@@ -6969,7 +6947,7 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == registration.local_path)
|
||||
{
|
||||
existing.kind = GameCreationAppAssetKind::Icon;
|
||||
existing.kind = "art-spritesheet-slice".to_string();
|
||||
existing.media_type = registration.media_type.clone();
|
||||
existing.image_sequence_frames = None;
|
||||
existing.image_sequence_duration_ms = None;
|
||||
@@ -6983,14 +6961,12 @@ fn register_platform_art_slice_manifest_entries_at(
|
||||
);
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: id.clone(),
|
||||
kind: GameCreationAppAssetKind::Icon,
|
||||
kind: "art-spritesheet-slice".to_string(),
|
||||
media_type: registration.media_type.clone(),
|
||||
local_path: registration.local_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(
|
||||
GameCreationAppAssetKind::Icon.as_str(),
|
||||
),
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
@@ -7022,7 +6998,7 @@ fn existing_platform_art_slice_registrations_are_complete(
|
||||
return Ok(false);
|
||||
};
|
||||
if candidates.next().is_some()
|
||||
|| asset.kind != GameCreationAppAssetKind::Icon
|
||||
|| asset.kind != "art-spritesheet-slice"
|
||||
|| asset.media_type != "image/png"
|
||||
|| asset.source.kind != GameCreationAppAssetSourceKind::Canvas
|
||||
|| asset.source.generation_route.as_deref()
|
||||
@@ -7111,15 +7087,10 @@ pub(in crate::agent) fn register_existing_platform_art_slices_at(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| "旧项目图集源资源缺少 sourceResourceId".to_string())?;
|
||||
// 判据只看路径:`assets/art-spritesheet-slices/` 是切片专用目录,任何落在这里的登记都是
|
||||
// 切片登记。不能再要求 `kind == Icon`——历史项目里切片刻的 kind 是非 canonical 值
|
||||
// (`art-spritesheet-slice` 等),严格解析后收口成 `unknown`,加上 kind 判据就会漏掉这些
|
||||
// 半成品登记,让下面的回填被静默跳过。
|
||||
let has_partial_or_invalid_registration = manifest.assets.iter().any(|asset| {
|
||||
asset
|
||||
.local_path
|
||||
.starts_with("assets/art-spritesheet-slices/")
|
||||
});
|
||||
let has_partial_or_invalid_registration = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.kind == "art-spritesheet-slice");
|
||||
|
||||
let receipt_path =
|
||||
resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?;
|
||||
@@ -7745,7 +7716,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
let registered = match register_local_asset_entry(
|
||||
root,
|
||||
&local_path,
|
||||
platform_art_asset_manifest_kind(&options.asset_kind),
|
||||
&options.asset_kind,
|
||||
&download.media_type,
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -8031,51 +8002,6 @@ mod canvas_generation_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台请求词汇 → manifest kind 的**唯一**映射点:逐条别名、trim 与未映射值的落点都必须钉住。
|
||||
///
|
||||
/// 未映射值不能悄悄变成某个具体 kind:它走严格解析,认不出就是 `Unknown`(→「待归类」),
|
||||
/// 同时由 reporter 把原始串写进日志——这条用例保证"不猜"。
|
||||
#[test]
|
||||
fn platform_art_asset_manifest_kind_maps_platform_vocabulary_explicitly() {
|
||||
use GameCreationAppAssetKind as Kind;
|
||||
for (platform_kind, expected) in [
|
||||
("image", Kind::Image),
|
||||
("game-art", Kind::Image),
|
||||
("character", Kind::Character),
|
||||
("character-art", Kind::Character),
|
||||
("spec", Kind::IconSpec),
|
||||
("icon-spec", Kind::IconSpec),
|
||||
("ui-prototype", Kind::UiDesign),
|
||||
("ui-design", Kind::UiDesign),
|
||||
("art-spritesheet", Kind::IconSpritesheet),
|
||||
("icon-spritesheet", Kind::IconSpritesheet),
|
||||
("publication-material", Kind::PublicationMaterial),
|
||||
("game-background", Kind::Scene),
|
||||
("scene", Kind::Scene),
|
||||
// 平台词汇这一步会 trim(与 `normalize_platform_art_asset_generation_kind` 同口径)。
|
||||
(" ui-prototype ", Kind::UiDesign),
|
||||
] {
|
||||
assert_eq!(
|
||||
platform_art_asset_manifest_kind(platform_kind),
|
||||
expected,
|
||||
"{platform_kind} 的映射必须显式钉住"
|
||||
);
|
||||
}
|
||||
// canonical 成员即使不在上面的平台别名表里也照常解析(严格等值)。
|
||||
assert_eq!(
|
||||
platform_art_asset_manifest_kind("font"),
|
||||
GameCreationAppAssetKind::Font
|
||||
);
|
||||
// 认不出的词汇不猜、不兜底成具体 kind。
|
||||
for platform_kind in ["", "UI", "ui", "mystery", "unknown-kind"] {
|
||||
assert_eq!(
|
||||
platform_art_asset_manifest_kind(platform_kind),
|
||||
GameCreationAppAssetKind::Unknown,
|
||||
"{platform_kind} 不在映射表内,必须落 unknown 而不是被猜成某个 kind"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
|
||||
// 放行 art-spritesheet 必须真的走到图集提示词与图集请求合同,
|
||||
@@ -11410,7 +11336,7 @@ mod canvas_generation_tests {
|
||||
register_local_asset_at(
|
||||
root,
|
||||
"assets/art-spec.png",
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
"icon-spec",
|
||||
"image/png",
|
||||
"changed-reference-test",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -12987,7 +12913,7 @@ mod canvas_generation_tests {
|
||||
let first_slice_ids = registered
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.map(|asset| {
|
||||
assert_eq!(asset.source.kind, GameCreationAppAssetSourceKind::Canvas);
|
||||
assert_eq!(
|
||||
@@ -13021,7 +12947,7 @@ mod canvas_generation_tests {
|
||||
.clone();
|
||||
mutate_manifest_at(root, |manifest| {
|
||||
manifest.assets.retain(|asset| {
|
||||
asset.kind != GameCreationAppAssetKind::Icon || asset.local_path == retained_path
|
||||
asset.kind != "art-spritesheet-slice" || asset.local_path == retained_path
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
@@ -13035,7 +12961,7 @@ mod canvas_generation_tests {
|
||||
let backfilled_slice_ids = backfilled
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.map(|asset| (asset.local_path.clone(), asset.id.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(backfilled_slice_ids.len(), 4);
|
||||
@@ -13084,7 +13010,7 @@ mod canvas_generation_tests {
|
||||
let replaced_slice_ids = replaced
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.kind == GameCreationAppAssetKind::Icon)
|
||||
.filter(|asset| asset.kind == "art-spritesheet-slice")
|
||||
.map(|asset| {
|
||||
assert_eq!(
|
||||
asset.source.reference_resource_ids,
|
||||
@@ -13117,7 +13043,7 @@ mod canvas_generation_tests {
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
AGENT_RUNTIME_ART_SPRITESHEET_PATH,
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
"art-spritesheet",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -13178,12 +13104,12 @@ mod canvas_generation_tests {
|
||||
assert!(after
|
||||
.assets
|
||||
.iter()
|
||||
.all(|asset| asset.kind != GameCreationAppAssetKind::Icon));
|
||||
.all(|asset| asset.kind != "art-spritesheet-slice"));
|
||||
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
"assets/art-spritesheet-slices/player.png",
|
||||
GameCreationAppAssetKind::Icon,
|
||||
"art-spritesheet-slice",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
@@ -14739,10 +14665,7 @@ mod canvas_generation_tests {
|
||||
let manifest = read_manifest_for_project(root).expect("read replacement manifest");
|
||||
assert_eq!(manifest.assets.len(), 1);
|
||||
assert_eq!(manifest.assets[0].local_path, "assets/art-spritesheet.png");
|
||||
assert_eq!(
|
||||
manifest.assets[0].kind,
|
||||
GameCreationAppAssetKind::IconSpritesheet
|
||||
);
|
||||
assert_eq!(manifest.assets[0].kind, "art-spritesheet");
|
||||
assert_eq!(
|
||||
fs::read_dir(root.join("assets"))
|
||||
.expect("read assets directory")
|
||||
|
||||
@@ -98,7 +98,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, S
|
||||
output.push_str("- ");
|
||||
output.push_str(&asset.id);
|
||||
output.push_str(": ");
|
||||
output.push_str(asset.kind.as_str());
|
||||
output.push_str(&asset.kind);
|
||||
output.push_str(" / ");
|
||||
output.push_str(&asset.media_type);
|
||||
output.push_str(" / ");
|
||||
|
||||
+6
-12
@@ -2061,23 +2061,17 @@ mod tests {
|
||||
.expect("resume first manifest task fixture");
|
||||
|
||||
for (local_path, kind) in [
|
||||
("assets/art-spec.png", GameCreationAppAssetKind::IconSpec),
|
||||
(
|
||||
"assets/ui-prototype.png",
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
),
|
||||
(
|
||||
"assets/art-spritesheet.png",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
),
|
||||
("assets/art-spec.png", "icon-spec"),
|
||||
("assets/ui-prototype.png", "ui-prototype"),
|
||||
("assets/art-spritesheet.png", "art-spritesheet"),
|
||||
] {
|
||||
let (generation_route, generation_kind, reference_resource_ids) = match kind {
|
||||
GameCreationAppAssetKind::IconSpec => (
|
||||
"icon-spec" => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Vec::new(),
|
||||
),
|
||||
GameCreationAppAssetKind::UiDesign => (
|
||||
"ui-prototype" => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"ui-design",
|
||||
vec!["resource-icon-spec".to_string()],
|
||||
@@ -2088,7 +2082,7 @@ mod tests {
|
||||
vec!["resource-icon-spec".to_string()],
|
||||
),
|
||||
};
|
||||
let bytes = if kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
let bytes = if kind == "art-spritesheet" {
|
||||
use image::ImageEncoder;
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes)
|
||||
|
||||
@@ -1216,19 +1216,11 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
|
||||
return None;
|
||||
}
|
||||
let (expected_path, expected_kind, label) = match agent_id {
|
||||
"art-director" => (
|
||||
AGENT_RUNTIME_ART_SPEC_PATH,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
"统一视觉规范图",
|
||||
),
|
||||
"design-foundation" => (
|
||||
"assets/ui-prototype.png",
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
"策划界面原型图",
|
||||
),
|
||||
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
|
||||
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
|
||||
"art-asset-plan" => (
|
||||
"assets/art-spritesheet.png",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
"art-spritesheet",
|
||||
"首版美术素材图",
|
||||
),
|
||||
_ => return None,
|
||||
|
||||
+1
-5
@@ -336,11 +336,7 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
render_game_creator_agent_runtime_steers_for_prompt(root, agent_id, session_id, run_id)?;
|
||||
let loop_index = loop_index.saturating_add(1);
|
||||
let context_preload_notice = game_creator_agent_context_preload_notice(agent_id);
|
||||
let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS
|
||||
.iter()
|
||||
.map(|kind| kind.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("|");
|
||||
let canvas_asset_kind_catalog = AGENT_RUNTIME_CANVAS_ASSET_KINDS.join("|");
|
||||
let limited_command_contract = if runtime_owner_artifact_validation_available {
|
||||
"当前固定 owner 不得调用 command.run_limited 或 project.verify;完成固定正式产物后直接交付,由 Runtime 在收束门内执行结构验证。".to_string()
|
||||
} else {
|
||||
|
||||
+3
-8
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[GameCreationAppAssetKind] =
|
||||
GameCreationAppAssetKind::CANVAS_ASSET_KINDS;
|
||||
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
|
||||
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
|
||||
|
||||
#[cfg(test)]
|
||||
mod canvas_asset_kind_contract_tests {
|
||||
@@ -11,12 +11,7 @@ mod canvas_asset_kind_contract_tests {
|
||||
fn canvas_asset_kind_catalog_preserves_authoritative_contract() {
|
||||
assert_eq!(
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
&[
|
||||
GameCreationAppAssetKind::Image,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
GameCreationAppAssetKind::UiDesign,
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
]
|
||||
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user