Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m12s
Project CI / Backend tests (pull_request) Successful in 9m12s
Project CI / Native shell tests (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 7m1s
Project CI / Repository checks (pull_request) Successful in 5m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m4s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-22 11:54:00 +08:00
225 changed files with 8857 additions and 57184 deletions
+8
View File
@@ -203,6 +203,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
},
],
};
const fetchMock = vi
.fn()
.mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
@@ -107,10 +107,6 @@ const appInvokeSources = readSourceFiles(
new URL('../src/', import.meta.url),
new Set(['.ts', '.tsx']),
);
const appEntrypointSource = fs.readFileSync(
new URL('../src/main.tsx', import.meta.url),
'utf8',
);
const tauriHandlerSource = fs.readFileSync(
new URL('../src-tauri/src/main.rs', import.meta.url),
'utf8',
@@ -135,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
);
const allowedUncalledTauriCommands = [
'append_direct_project_conversation_message',
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
'answer_game_creator_agent_runtime_user_input',
'cancel_game_creator_agent_runtime_task',
'chat_with_game_creator_role_agent',
'chat_with_game_creator_role_agent_stream',
'check_game_creator_llm_config',
'confirm_game_creator_agent_runtime_task',
'diff_local_project_checkpoint',
'get_game_creation_agent_capabilities',
'get_limited_local_commands',
'list_local_project_export_packages',
'read_game_creator_agent_runtime',
'read_local_agent_memory',
'read_local_game_memory',
'reject_game_creator_agent_runtime_task',
'retry_game_creator_agent_runtime_task',
'schedule_game_creator_agent_ready_tasks',
'start_game_creator_agent_runtime_task',
'steer_game_creator_agent_runtime_task',
'write_local_agent_memory',
'write_local_game_memory',
'write_local_project_file',
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
'archive_game_creator_agent_session',
'clear_game_creator_agent_goal',
'compact_game_creator_agent_runtime_context',
'confirm_retry_game_creator_agent_runtime_task',
'create_game_creator_agent_session',
'edit_game_creator_agent_goal',
'fork_game_creator_agent_session',
'list_game_creator_agent_sessions',
'pause_game_creator_agent_goal',
'read_game_creator_agent_goal',
'resume_game_creator_agent_goal',
'set_active_game_creator_agent_session',
'start_game_creator_agent_goal',
'start_game_creator_supervisor_runtime_task',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
@@ -164,6 +199,26 @@ const allowedUncalledTauriCommands = [
'call_agc_plugin',
'read_agc_plugin_panel',
'set_agc_plugin_enabled',
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
// native-only 登记,避免孤儿检查一直报错。
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
'build_local_project_index',
'control_agent_run',
'create_local_project_checkpoint',
'export_local_project_package',
'generate_local_game_draft',
'generate_platform_art_asset',
'import_canvas_asset',
'import_canvas_export',
'open_canvas_project',
'register_local_asset',
'restore_local_project_checkpoint',
'run_limited_local_command',
'sync_canvas_project_assets',
'write_project_permission_policy',
];
const sourceExtensions = new Set([
'.json',
@@ -1470,13 +1525,7 @@ const eventCapability = JSON.parse(
);
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
for (const windowLabel of [
'client',
'developer',
'main',
'launcher',
'supervisor-chat',
]) {
for (const windowLabel of ['client', 'main', 'launcher']) {
if (!eventCapabilityWindows.has(windowLabel)) {
throw new Error(
`AI game creator shell event capability missing window: ${windowLabel}`,
@@ -1859,20 +1908,6 @@ if (
);
}
for (const snippet of [
'import.meta.env.DEV',
'supervisorChatMode',
'supervisorChatOnly',
'open_project_supervisor_chat_window',
'index.html?supervisor-chat&projectPath=',
]) {
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
throw new Error(
`AI game creator shell developer window guardrail drifted: ${snippet}`,
);
}
}
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
throw new Error(
'AI game creator normal startup must not automatically open the developer window',
@@ -1905,31 +1940,17 @@ for (const snippet of [
'官方账号服务(固定)',
'runtime_config.save',
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
"'activate_local_game_preview'",
'已切换到客户端运行视图',
'async function executeRunLocal',
'function needsInitializedChatProject',
'function resolvePendingCommandProjectPath',
'resolveChatProjectPath(localProject) ?? draftProjectPath',
'`permission.cancel ${command.id} missing-project`',
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
'function parseRememberInput',
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
'async function executeAgentTraceChat',
"relativePath: '.agent/logs/command.log'",
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
'className="trace-error"',
'agentRunTrace.taskGraph.repairRoutes.map',
"in: ${step.inputPaths.join(', ') || 'none'}",
"out: ${step.outputPaths.join(', ') || 'none'}",
]) {
if (!appSource.includes(snippet)) {
throw new Error(
@@ -1,7 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "developer",
"description": "开发窗口允许打开本地素材选择对话框。",
"windows": ["developer"],
"permissions": ["dialog:allow-open"]
}
@@ -2,6 +2,6 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "events",
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"windows": ["client", "main", "launcher"],
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
}
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "window-chrome",
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"windows": ["client", "main", "launcher"],
"permissions": [
"core:window:allow-close",
"core:window:allow-is-maximized",
@@ -13,7 +13,7 @@ mod codex_app_server;
mod codex_cli;
mod codex_provider_proxy;
mod design_runtime;
mod design_tools;
pub(crate) mod design_tools;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_user_item;
@@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event(
) -> DirectThreadEvent {
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
}
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
///
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
@@ -3358,8 +3357,22 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)?
};
let mut input =
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
{
if let Some(client_turn_id) = direct_client_turn_id {
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
const DESIGN_REFERENCES_ROOT: &str = "references";
const SEARCH_HIT_LIMIT: usize = 200;
#[derive(Clone, Debug, Serialize)]
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
}
#[tauri::command]
pub(crate) fn import_design_workspace_file(
app: tauri::AppHandle,
project_path: String,
file_name: String,
bytes: Vec<u8>,
) -> Result<String, String> {
let root = PathBuf::from(project_path.trim());
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
let _ = app.emit(
"design-agent-update",
serde_json::json!({
"projectPath": root.to_string_lossy(),
"clientTurnId": "",
"kind": "workspace",
"messageId": null,
"text": null,
"view": null,
}),
);
Ok(relative_path)
}
fn import_design_workspace_file_at(
root: &Path,
file_name: &str,
bytes: &[u8],
) -> Result<String, String> {
enforce_project_permission_policy(root, "conversation.write")?;
read_existing_manifest_for_project(root)?;
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"conversation.write",
)?;
if file_name.contains(['/', '\\']) {
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
}
let normalized_name =
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
if normalized_name != file_name {
return Err("附件文件名无效".to_string());
}
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
let mut sequence = 1_u64;
loop {
let candidate_name = design_reference_file_name(&normalized_name, sequence);
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
let mut file = match options.open(&target) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
sequence = sequence
.checked_add(1)
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
continue;
}
Err(error) => {
return Err(format!(
"创建策划参考附件失败:{}: {error}",
target.display()
));
}
};
if let Err(error) =
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
{
drop(file);
let _ = fs::remove_file(&target);
return Err(error);
}
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
drop(file);
if let Err(error) = write_result {
let _ = fs::remove_file(&target);
return Err(format!(
"写入策划参考附件失败:{}: {error}",
target.display()
));
}
return Ok(relative_path);
}
}
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
if sequence == 1 {
return file_name.to_string();
}
let path = Path::new(file_name);
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(file_name);
match path.extension().and_then(|value| value.to_str()) {
Some(extension) if !extension.is_empty() => {
format!("{stem} ({sequence}).{extension}")
}
_ => format!("{stem} ({sequence})"),
}
}
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
let catalog_path = root.join("resources/catalog.json");
let data: DesignCatalogFile = serde_json::from_str(
@@ -756,6 +875,13 @@ mod tests {
tempfile::tempdir().expect("tempdir")
}
fn initialized_test_root() -> tempfile::TempDir {
let temp = test_root();
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
.expect("init project");
temp
}
fn pack_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
}
@@ -868,6 +994,138 @@ mod tests {
);
}
#[test]
fn imported_reference_is_visible_to_workspace_list_and_read() {
let temp = initialized_test_root();
let root = temp.path();
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
let revision_before =
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
let relative =
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
.expect("import text reference");
assert_eq!(relative, "references/玩法构想.txt");
assert!(list_design_workspace_files(root)
.expect("list workspace")
.iter()
.any(|entry| entry.path == relative && entry.kind == "file"));
assert_eq!(
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
"横版解谜\n"
);
assert_eq!(
read_existing_manifest_for_project(root).expect("read manifest after import"),
manifest_before
);
assert_eq!(
read_game_creator_agent_runtime_project_revision(root)
.expect("read revision after import"),
revision_before
);
}
#[test]
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
let temp = initialized_test_root();
let root = temp.path();
let first = import_design_workspace_file_at(root, "brief.md", b"first")
.expect("import first reference");
let second = import_design_workspace_file_at(root, "brief.md", b"second")
.expect("import repeated reference");
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
.expect("import empty reference");
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
.expect("import binary reference");
assert_eq!(first, "references/brief.md");
assert_eq!(second, "references/brief (2).md");
assert_eq!(empty, "references/empty.bin");
assert_eq!(binary, "references/bytes.bin");
assert_eq!(
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
b"first"
);
assert_eq!(
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
b"second"
);
assert!(fs::read(root.join("design_artifacts").join(&empty))
.expect("read empty")
.is_empty());
assert_eq!(
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
binary_bytes
);
}
#[test]
fn import_rejects_unsafe_names_and_denied_project_permission() {
let temp = initialized_test_root();
let root = temp.path();
for file_name in [
"../outside.txt",
"nested/file.txt",
r"nested\file.txt",
"C:stream",
] {
let error = import_design_workspace_file_at(root, file_name, b"blocked")
.expect_err("reject unsafe file name");
assert!(error.contains("文件名"), "unexpected error: {error}");
}
assert!(!root.join("outside.txt").exists());
let mut policy = ProjectPermissionPolicy::default();
policy
.denied_commands
.push("conversation.write".to_string());
write_project_permission_policy_at(root, policy).expect("deny conversation write");
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
.expect_err("respect project permission policy");
assert!(error.contains("conversation.write"));
assert!(!root.join("design_artifacts/references/denied.txt").exists());
}
#[cfg(unix)]
#[test]
fn import_rejects_linked_references_directory() {
use std::os::unix::fs::symlink;
let temp = initialized_test_root();
let root = temp.path();
let outside = tempfile::tempdir().expect("outside tempdir");
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
symlink(outside.path(), root.join("design_artifacts/references"))
.expect("link references directory");
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
.expect_err("reject linked references directory");
assert!(error.contains("符号链接") || error.contains("reparse point"));
assert!(!outside.path().join("escape.txt").exists());
}
#[cfg(windows)]
#[test]
fn import_rejects_windows_linked_references_directory_when_supported() {
use std::os::windows::fs::symlink_dir;
let temp = initialized_test_root();
let root = temp.path();
let outside = tempfile::tempdir().expect("outside tempdir");
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
return;
}
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
.expect_err("reject linked references directory");
assert!(error.contains("符号链接") || error.contains("reparse point"));
assert!(!outside.path().join("escape.txt").exists());
}
#[test]
fn phase_context_injects_current_skill_only() {
let resources = DesignResources::new(pack_root()).expect("pack");
@@ -2,9 +2,9 @@
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
@@ -5,11 +5,11 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
direct_codex_user_item_to_wire_input,
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
};
@@ -4,7 +4,7 @@ use ts_rs::TS;
/// DirectProject 本轮 user input 的唯一结构化入口。
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserItem {
#[serde(rename = "message")]
Message(DirectCodexUserMessageItem),
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
#[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/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserMessageItem {
pub(crate) role: DirectCodexUserRole,
pub(crate) content: Vec<DirectCodexUserContentPart>,
@@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem {
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserRole {
User,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectCodexUserContentPart {
#[serde(rename = "input_text")]
InputText { text: String },
#[serde(rename = "agc_resource_reference")]
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_skill_reference")]
AgcSkillReference { name: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[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/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) label: String,
#[serde(default)]
@@ -4,15 +4,20 @@ use super::model::{
};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
};
use std::path::Path;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次
// `root/<name>/SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。
pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
pub(crate) fn validate_direct_codex_user_item(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<(), String> {
) -> Result<GameCreationAppManifest, String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
@@ -20,38 +25,98 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string());
}
if message.content.is_empty() {
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
if !content_has_meaningful_input(&message.content) {
return Err("聊天内容不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
let mut has_effective_content = false;
let mut attachment_count = 0usize;
let mut skill_count = 0usize;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
if !text.trim().is_empty() {
has_effective_content = true;
}
}
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
has_effective_content = true;
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
skill_count = skill_count.saturating_add(1);
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
return Err(format!(
"一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill"
));
}
let name = name.trim();
if name.is_empty()
|| name.chars().count() > 120
|| matches!(name, "." | "..")
|| name.chars().any(|character| {
character.is_control()
|| character.is_whitespace()
|| matches!(character, '/' | '\\' | ':' | '$')
})
{
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
}
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
has_effective_content = true;
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
attachment_count = attachment_count.saturating_add(1);
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
return Err(format!(
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
));
}
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
let name = reference.name.trim();
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|| name.chars().any(char::is_control)
{
return Err("附件文件名无效或过长".to_string());
}
let media_type = reference.media_type.trim();
if media_type.is_empty()
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|| media_type.chars().any(|character| {
!(character.is_ascii_alphanumeric()
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
})
{
return Err("附件媒体类型无效或过长".to_string());
}
let status = reference.status.trim();
if status == "imported" && reference.local_path.trim().is_empty() {
return Err("已导入附件缺少项目路径".to_string());
}
if !reference.local_path.trim().is_empty() {
sanitize_attachment_local_path(&reference.local_path)
.ok_or_else(|| "附件项目路径无效".to_string())?;
}
if !matches!(status, "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
if !has_effective_content {
return Err("聊天内容不能为空".to_string());
}
Ok(())
Ok(manifest)
}
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
content.iter().any(|part| match part {
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
_ => true,
})
}
pub(crate) fn validate_resource_id_and_manifest(
@@ -92,3 +157,173 @@ fn validate_runtime_region_reference(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
content_has_meaningful_input, validate_direct_codex_user_item,
MAX_DIRECT_CODEX_SKILL_REFERENCES,
};
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
use serde_json::json;
fn input_text(text: &str) -> DirectCodexUserContentPart {
DirectCodexUserContentPart::InputText {
text: text.to_string(),
}
}
#[test]
fn only_all_blank_content_counts_as_empty_input() {
// 空数组与「整条只有空白」是同一种空输入。
assert!(!content_has_meaningful_input(&[]));
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
assert!(!content_has_meaningful_input(&[
input_text("\n"),
input_text(" "),
]));
}
#[test]
fn whitespace_parts_are_valid_next_to_meaningful_input() {
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
assert!(content_has_meaningful_input(&[
input_text("\n"),
input_text(""),
]));
assert!(content_has_meaningful_input(&[
input_text(""),
input_text("\n\n"),
]));
}
#[test]
fn non_text_parts_always_count_as_input() {
assert!(content_has_meaningful_input(&[
DirectCodexUserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
},
]));
}
#[test]
fn inline_attachment_count_is_bounded_independently() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
.map(|index| {
json!({
"type": "agc_attachment_reference",
"name": format!("attachment-{index}.txt"),
"mediaType": "text/plain",
"size": 1,
"localPath": "",
"status": "failed"
})
})
.collect::<Vec<_>>();
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": content,
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("too many inline attachments must be rejected");
assert!(error.contains("最多携带"), "{error}");
}
#[test]
fn imported_attachment_requires_a_project_path() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [{
"type": "agc_attachment_reference",
"name": "attachment.txt",
"mediaType": "text/plain",
"size": 1,
"localPath": "",
"status": "imported"
}],
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("imported attachment without a project path must fail");
assert!(error.contains("缺少项目路径"), "{error}");
}
#[test]
fn inline_skill_reference_count_is_bounded_independently() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES)
.map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") }))
.collect::<Vec<_>>();
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": content,
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("too many skill references must be rejected");
assert!(error.contains("最多引用"), "{error}");
}
#[test]
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
.expect("init project");
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
let cases = [
(
json!({
"name": "bad\nname.txt",
"mediaType": "text/plain"
}),
"文件名",
),
(
json!({
"name": "ok.txt",
"mediaType": "text/plain\nsecret"
}),
"媒体类型",
),
(
json!({
"name": long_name,
"mediaType": "text/plain"
}),
"文件名",
),
];
for (metadata, expected) in cases {
let mut value = metadata;
value["type"] = json!("agc_attachment_reference");
value["size"] = json!(1);
value["localPath"] = json!("");
value["status"] = json!("failed");
let item = serde_json::from_value(json!({
"type": "message",
"role": "user",
"content": [value],
"id": "turn-1:user"
}))
.expect("deserialize user item");
let error = validate_direct_codex_user_item(root.path(), &item)
.expect_err("invalid attachment metadata must fail");
assert!(error.contains(expected), "{error}");
}
}
}
@@ -1,6 +1,12 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
use super::model::{
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
};
use super::validation::validate_direct_codex_user_item;
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
sanitize_attachment_name, GameCreationAppManifest,
};
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
@@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content(
.collect()
}
fn resource_reference_summary(
manifest: &GameCreationAppManifest,
resource_id: &str,
) -> Result<String, String> {
let resource_id = resource_id.trim();
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
Ok(format!(
"[素材引用 resourceId={resource_id};项目路径={path}]"
))
}
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
}
/// 附件引用的安全摘要。
///
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
let name = sanitize_attachment_name(&reference.name);
let media_type = sanitize_attachment_media_type(&reference.media_type);
let mut summary = format!(
"[附件:名称={name};类型={media_type};大小={} 字节",
reference.size
);
if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) {
summary.push_str(&format!(";项目路径={local_path}"));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
pub(crate) fn direct_codex_user_item_to_wire_input(
root: &Path,
item: &DirectCodexUserItem,
) -> Result<Value, String> {
validate_direct_codex_user_item(root, item)?;
let manifest = read_manifest_for_project(root)?;
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
let text = match part {
DirectCodexUserContentPart::InputText { text } => text.clone(),
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id.trim())
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
format!(
"[素材引用 resourceId={};项目路径={path}]",
resource_id.trim()
)
resource_reference_summary(&manifest, resource_id)?
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
format!("${}", name.trim())
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
let resources = reference
.resource_ids
.iter()
.map(|id| id.trim())
.collect::<Vec<_>>()
.join(",");
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
if let Some(run_id) = reference.run_id.as_deref() {
summary.push_str(&format!("运行标识={} ", run_id.trim()));
}
if let Some(role) = reference.element_role.as_deref() {
summary.push_str(&format!("角色={} ", role.trim()));
}
if let Some(text) = reference.text.as_deref() {
summary.push_str(&format!("文本={} ", text.trim()));
}
if !resources.is_empty() {
summary.push_str(&format!("关联素材={resources}"));
}
summary.push(']');
summary
runtime_region_summary(reference)
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
attachment_reference_summary(reference)
}
};
input.push(serde_json::json!({ "type": "text", "text": text }));
@@ -110,6 +152,55 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
root: &Path,
item: &DirectCodexUserItem,
skill_roots: &[std::path::PathBuf],
) -> Result<Value, String> {
let manifest = validate_direct_codex_user_item(root, item)?;
let DirectCodexUserItem::Message(message) = item;
let mut input = Vec::with_capacity(message.content.len());
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { text } => {
input.push(serde_json::json!({ "type": "text", "text": text }));
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
input.push(serde_json::json!({
"type": "text",
"text": resource_reference_summary(&manifest, resource_id)?,
}));
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
let name = name.trim();
let path = skill_roots
.iter()
.map(|root| root.join(name).join("SKILL.md"))
.find(|path| path.is_file())
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
input.push(serde_json::json!({
"type": "skill",
"name": name,
"path": path,
}));
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": runtime_region_summary(reference),
}));
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
input.push(serde_json::json!({
"type": "text",
"text": attachment_reference_summary(reference),
}));
}
}
}
Ok(Value::Array(input))
}
pub(crate) fn direct_codex_user_item_to_prompt(
root: &Path,
item: &DirectCodexUserItem,
@@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt(
let DirectCodexUserItem::Message(message) = item;
let mut prompt = wire
.as_array()
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
.and_then(|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>>()
})?;
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>();
if let Some(code_context) = render_ui_design_code_context(root, message)? {
prompt.push('\n');
prompt.push_str(&code_context);
@@ -199,8 +283,9 @@ fn render_ui_design_code_context(
mod tests {
use super::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
validate_direct_codex_user_item,
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
};
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
use serde_json::json;
use shared_contracts::game_creation_app::{
@@ -283,6 +368,83 @@ mod tests {
})
}
#[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}");
}
#[test]
fn standard_response_item_passes_through_without_agc_private_parts() {
let item = json!({
@@ -297,6 +459,23 @@ mod tests {
);
}
#[test]
fn response_item_projection_uses_input_text_not_turn_input_text() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [{"type": "input_text", "text": "你好"}]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
assert_eq!(projected["content"][0]["type"], "input_text");
assert_ne!(projected["content"][0]["type"], "text");
}
#[test]
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
let root = tempfile::tempdir().expect("temp project");
@@ -445,79 +624,97 @@ mod tests {
}
#[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}"
);
fn attachment_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
}
#[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 设计代码"
);
fn attachment_metadata_is_sanitized_before_prompt_projection() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [{
"type": "agc_attachment_reference",
"name": "C:\\tmp\\notes.md",
"mediaType": "text/plain",
"size": 4,
"localPath": "assets\\.\\notes.txt",
"status": "imported"
}]
});
let wire = super::direct_codex_user_item_to_wire_input(
root.path(),
&serde_json::from_value(item).expect("deserialize user item"),
)
.expect("attachment metadata should project");
let text = wire[0]["text"].as_str().expect("wire text");
assert!(text.contains("名称=notes.md"), "{text}");
assert!(text.contains("类型=text/plain"), "{text}");
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
}
#[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}");
fn whitespace_only_text_parts_survive_validation() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect("whitespace-only part next to real text must pass");
let parts = wire.as_array().expect("wire input array");
assert_eq!(parts.len(), 3);
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
assert_eq!(parts[2]["text"].as_str(), Some(" "));
}
#[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}");
fn all_blank_content_is_rejected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect_err("all-blank content must fail closed");
assert!(error.contains("不能为空"), "{error}");
}
}
@@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
mut user_item: DirectCodexUserItem,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,74 +41,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context = match render_direct_codex_user_prompt("", &attachments) {
Ok(context) => context,
Err(error) => {
audit.finish(false);
audit.flush().await;
return Err(error);
}
};
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
if let Err(error) = validate_direct_codex_user_item(root, &user_item) {
audit.finish(false);
audit.flush().await;
return Err(error);
}
let user_prompt = match direct_codex_user_item_to_prompt(root, &user_item) {
Ok(prompt) => prompt,
Err(error) => {
audit.finish(false);
audit.flush().await;
return Err(error);
}
};
validate_direct_codex_user_item(root, &user_item)?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
if user_prompt.trim().is_empty() {
audit.finish(false);
audit.flush().await;
return Err("聊天内容不能为空".to_string());
}
let canonical_user_item =
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()).await {
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
.await
{
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
Err(error) => {
audit.finish(false);
audit.flush().await;
return Err(redact_agent_runtime_error(root, &error, 1800));
}
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
};
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
creation_type.as_deref(),
Some(&turn_emitter),
Some(&mut audit),
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
canonical_user_item,
)
.await
{
Ok(reply) => reply,
Err(error) => {
audit.finish(false);
audit.flush().await;
return Err(error);
}
Err(error) => return Err(error),
};
audit.finish(true);
audit.flush().await;
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
Ok(reply)
}
@@ -1,6 +1,6 @@
//! DirectProject 聊天事件的线上模型与投影。
//!
//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`
//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`
//! 与 Rust 定义同源:加一个字段不会只改一边。
//!
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
@@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300;
/// 一条文件变更。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadFileChange {
pub(crate) path: String,
/// `add` | `update` | `delete`
@@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange {
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadItem {
#[serde(rename = "message")]
Message {
@@ -179,7 +179,7 @@ impl DirectThreadItem {
/// 增量正文属于哪类条目。
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadDeltaKind {
/// assistant 正文。
Message,
@@ -190,7 +190,7 @@ pub(crate) enum DirectThreadDeltaKind {
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadRequestKind {
#[serde(rename = "approval.requested")]
ApprovalRequested,
@@ -235,7 +235,7 @@ impl DirectThreadRequestKind {
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) enum DirectThreadEvent {
#[serde(rename = "turn.started")]
TurnStarted {
@@ -398,7 +398,7 @@ impl DirectThreadEvent {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadSubscriptionBootstrap {
pub(crate) subscription_id: String,
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
@@ -410,14 +410,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadConsumeResult {
pub(crate) events: Vec<DirectThreadEvent>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
pub(crate) struct DirectThreadHistorySlice {
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
pub(crate) items: Vec<DirectThreadItem>,
@@ -2,7 +2,8 @@
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar.
//! paths and credentials before writing a bounded diagnostic sidecar, and
//! projects the same bounded diagnosis into the AppData application log.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
@@ -14,6 +15,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
/// 应用日志里 detail / metadata 的字符预算。
///
/// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB
/// 这里的预算留出身份字段与中文摘要的位置,保证被截掉的是诊断正文的尾部,而不是
/// `eventId`、`code` 或 `detailRef`。
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200;
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200;
/// 详情行里 public summary 的字符预算。
///
/// `summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文;这里与 sidecar 侧的摘要
/// 预算同口径(320 字符)截断,并再脱敏一次,避免摘要把整条详情行占满。
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS: usize = 320;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -71,6 +86,27 @@ pub(crate) fn persist_agent_runtime_error(
"detail": safe_detail,
"metadata": metadata,
});
// 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData
// 应用日志,诊断包拿不到 detail。这里先把同一份已脱敏诊断留进应用日志,再落项目文件,
// 于是 sidecar 写失败也仍然留下可提交的诊断。
let app_log_lines = agent_runtime_error_app_log_lines(
root,
&event_id,
client_turn_id,
source,
stage,
code,
retryable,
public_text,
recovery_hint,
&detail_ref,
&safe_detail,
elapsed_ms,
&metadata,
);
for line in app_log_lines {
app_log!("{line}");
}
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
@@ -96,6 +132,78 @@ pub(crate) fn persist_agent_runtime_error(
})
}
/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。
///
/// 传进来的 `detail` 是 sidecar 用的脱敏文本,`metadata` 则从未脱敏过。两行落到 `app_log!`
/// 时都会先按应用日志预算(1200 / 200 字符)再脱敏、再截断:`app_log!` 还会把同一行写到
/// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本
/// `summary` 按 320 字符预算)都在这里过一遍脱敏。
///
/// 已经脱敏过的 `detail` 也照走同一遍流水线,不按「调用方已脱敏」走短路:截断会把
/// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。
///
/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被
/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或
/// 调用方常量字段**eventId / source / stage / code / retryable / clientTurnId / elapsedMs /
/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词
/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。
///
/// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行,
/// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先
/// 过一遍 [`single_line_log_field`],不假设调用方一定给单行文本。
pub(crate) fn agent_runtime_error_app_log_lines(
root: &Path,
event_id: &str,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail_ref: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: &Value,
) -> [String; 2] {
let event_id = single_line_log_field(event_id);
let client_turn_id = single_line_log_field(client_turn_id.unwrap_or("none"));
let source = single_line_log_field(source);
let stage = single_line_log_field(stage);
let code = single_line_log_field(code);
let detail_ref = single_line_log_field(detail_ref);
let public_text = single_line_log_field(&redact_agent_runtime_error(
root,
public_text,
AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS,
));
let elapsed_ms = elapsed_ms
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string());
let identity = format!(
"agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref}"
);
let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS);
let metadata = redact_agent_runtime_error(
root,
&metadata.to_string(),
AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS,
);
let detail_line = format!(
"agent.runtime.error.detail eventId={event_id} hint={} summary={} detail={} metadata={}",
single_line_log_field(recovery_hint),
single_line_log_field(&public_text),
single_line_log_field(&detail),
single_line_log_field(&metadata),
);
[identity, detail_line]
}
/// 应用日志是逐行读取的:落到日志里的自由文本必须先压平换行。
fn single_line_log_field(value: &str) -> String {
value.replace(['\r', '\n'], " ")
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
@@ -153,6 +261,91 @@ mod tests {
assert!(!text.contains("token=secret"));
}
#[test]
fn error_event_app_log_lines_keep_identity_and_redact_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
// 生产路径传进来的是已经脱敏的 safe_detail(8 KiB 口径),这里按同一口径造输入;
// metadata 在生产里从未脱敏,仍按原文传。
let safe_detail = redact_agent_runtime_error(
&root,
"C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断",
AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS,
);
let lines = agent_runtime_error_app_log_lines(
&root,
"error-1-1",
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件\n附带换行",
"查看诊断后重试",
".agent/runtime/errors/error-1-1.json",
&safe_detail,
Some(1200),
&serde_json::json!({"authorization": "Bearer secret"}),
);
for line in &lines {
assert!(!line.contains('\n'), "{line}");
}
// 落盘边界按真实口径核验:应用日志逐行脱敏、逐行截断,两行不会先拼在一起。
let persisted = lines
.iter()
.map(|line| crate::sanitize_diagnostic_message(line, None))
.collect::<Vec<_>>();
for line in &persisted {
assert!(!line.contains("token=secret"), "{line}");
assert!(line.chars().count() <= 2_048, "{line}");
}
let identity = &persisted[0];
assert!(identity.contains("eventId=error-1-1"), "{identity}");
assert!(identity.contains("code=turn-idle-timeout"), "{identity}");
assert!(
identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"),
"{identity}"
);
// 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。
assert!(!identity.contains("summary="), "{identity}");
let detail = &persisted[1];
assert!(
detail.contains("summary=本轮没有收到完成事件 附带换行"),
"{detail}"
);
assert!(
detail.contains("detail=<absolute-path> <redacted-url> 第二行诊断"),
"{detail}"
);
assert!(!detail.contains("Bearer secret"), "{detail}");
// 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。
let marked = agent_runtime_error_app_log_lines(
&root,
"error-3-1",
None,
"agc-tools",
"tool-execution",
"tool-error",
true,
"credential rotation failed",
"查看项目错误诊断后处理",
".agent/runtime/errors/error-3-1.json",
&safe_detail,
None,
&serde_json::json!({"tool": "agc_tools"}),
);
let identity = crate::sanitize_diagnostic_message(&marked[0], None);
assert!(identity.contains("eventId=error-3-1"), "{identity}");
assert!(identity.contains("code=tool-error"), "{identity}");
assert_eq!(
crate::sanitize_diagnostic_message(&marked[1], None),
"<sensitive diagnostic details redacted>"
);
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
@@ -1,4 +1,4 @@
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
@@ -143,6 +143,13 @@ struct AgcSkillManifestEntry {
sha256: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgcSkillCatalogEntry {
pub(crate) name: String,
pub(crate) description: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
@@ -256,6 +263,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
///
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
#[tauri::command]
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
Ok(validated_skill_pack_manifest()?
.skills
.into_iter()
.map(|entry| AgcSkillCatalogEntry {
name: entry.name,
description: entry.purpose,
})
.collect())
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!(
@@ -350,6 +372,19 @@ mod tests {
}
}
#[test]
fn skill_catalog_is_derived_from_the_validated_manifest() {
let catalog = list_agc_skill_catalog().expect("skill catalog");
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
let entry = catalog
.iter()
.find(|entry| entry.name == expected_name)
.expect("expected bundled skill");
assert!(!entry.description.trim().is_empty());
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
@@ -887,37 +887,6 @@ pub(crate) async fn pick_local_project_directory(
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app.dialog().file().set_title("选择本地文件");
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_file(move |path| {
let _ = sender.send(path);
});
let Some(path) = receiver
.await
.map_err(|_| "本地文件选择器意外关闭".to_string())?
else {
return Ok(None);
};
let path = path
.into_path()
.map_err(|error| format!("读取本地文件失败:{error}"))?;
#[cfg(windows)]
crate::register_game_creator_user_selected_path(&path, false);
if let Err(error) =
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件")
{
#[cfg(windows)]
crate::revoke_game_creator_user_selected_path(&path);
return Err(error);
}
Ok(Some(path.to_string_lossy().into_owned()))
}
#[tauri::command]
pub(crate) fn open_local_project_directory(
app: tauri::AppHandle,
@@ -5688,18 +5657,6 @@ pub(crate) fn write_local_project_file(
write_local_project_file_at(root, relative_path.trim(), &content)
}
#[tauri::command]
pub(crate) fn delete_local_project_file(
project_path: String,
relative_path: String,
) -> Result<LocalProjectFileMutationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "file.delete")?;
let _lock = acquire_project_write_lock(root, "file.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_project_file_at(root, relative_path.trim())
}
#[tauri::command]
pub(crate) fn read_local_game_memory(
project_path: String,
@@ -5748,18 +5705,6 @@ pub(crate) fn write_local_game_memory(
write_local_game_memory_at(root, scope.trim(), &content)
}
#[tauri::command]
pub(crate) fn delete_local_game_memory(
project_path: String,
scope: String,
) -> Result<LocalGameMemoryResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.delete")?;
let _lock = acquire_project_write_lock(root, "memory.delete")?;
advance_agent_runtime_project_revision_locked(root)?;
delete_local_game_memory_at(root, scope.trim())
}
#[tauri::command]
pub(crate) fn list_game_creator_agent_sessions(
project_path: String,
@@ -157,6 +157,7 @@ mod tool_plan_handoff;
mod user_input;
mod windows;
use agent::design_tools::*;
use agent::*;
use agent_native_tools::*;
use asset_generation_tasks::*;
@@ -2577,10 +2578,10 @@ fn main() {
rename_local_game_project,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
list_agc_skill_catalog,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
@@ -2613,6 +2614,7 @@ fn main() {
debug_fast_forward_design_session,
continue_design_agent_session,
decide_design_phase,
import_design_workspace_file,
list_design_workspace,
read_design_workspace_file,
start_game_creator_agent_runtime_task,
@@ -2694,12 +2696,10 @@ fn main() {
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
read_local_agent_memory,
write_local_agent_memory,
write_local_game_memory,
delete_local_game_memory,
list_game_creator_agent_sessions,
create_game_creator_agent_session,
fork_game_creator_agent_session,
@@ -2724,7 +2724,6 @@ fn main() {
write_project_permission_policy,
open_game_creator_workspace_window,
open_game_creator_launcher_window,
open_project_supervisor_chat_window,
start_local_game_preview,
activate_local_game_preview,
stop_local_game_preview,
@@ -451,18 +451,6 @@ fn dispatch_static_delegate_plain_repair(
)
}
#[test]
fn supervisor_chat_window_carries_encoded_project_path() {
assert_eq!(
supervisor_chat_window_url("/tmp/AI Game 项目").to_string(),
"index.html?supervisor-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
);
assert_eq!(
supervisor_chat_window_url("/tmp/a&b?#%+c").to_string(),
"index.html?supervisor-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc"
);
}
#[test]
fn project_supervisor_runtime_id_is_normalized_and_collected() {
let root = unique_project_path();
@@ -2777,7 +2777,7 @@ fn agent_run_history_prunes_to_latest_hundred_traces() {
}
#[test]
fn developer_project_file_and_memory_mutations_advance_project_revision() {
fn developer_project_file_and_memory_writes_advance_project_revision() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init");
let project_path = root.to_string_lossy().into_owned();
@@ -2800,16 +2800,11 @@ fn developer_project_file_and_memory_mutations_advance_project_revision() {
"项目记忆".to_string(),
)
.expect("write project memory through command");
delete_local_game_memory(project_path.clone(), "long".to_string())
.expect("delete project memory through command");
delete_local_project_file(project_path, "game/revision.txt".to_string())
.expect("delete project file through command");
assert_eq!(
read_game_creator_agent_runtime_project_revision(&root)
.expect("read developer mutation revision")
.revision,
5
3
);
fs::remove_dir_all(root).ok();
}
@@ -119,13 +119,6 @@ pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
}
pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl {
tauri::WebviewUrl::App(PathBuf::from(format!(
"index.html?supervisor-chat&projectPath={}",
percent_encode_query_value(project_path)
)))
}
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
let project_path = project_path.trim();
if project_path.is_empty() {
@@ -193,52 +186,3 @@ pub(crate) fn open_game_creator_launcher_window(
window.close().map_err(|error| error.to_string())?;
Ok(())
}
#[tauri::command]
pub(crate) fn open_project_supervisor_chat_window(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let project_path = validate_workspace_window_project_path(&project_path)?;
#[cfg(not(debug_assertions))]
{
let _ = app;
let _ = project_path;
return Err("项目总控对话窗口仅在开发构建中可用".to_string());
}
#[cfg(debug_assertions)]
{
if let Some(existing) = app.get_webview_window("supervisor-chat") {
let mut current_url = existing.url().map_err(|error| error.to_string())?;
let current_project_path = current_url
.query_pairs()
.find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned()));
if current_project_path.as_deref() != Some(project_path) {
current_url.set_query(Some(&format!(
"supervisor-chat&projectPath={}",
percent_encode_query_value(project_path)
)));
current_url.set_fragment(None);
existing
.navigate(current_url)
.map_err(|error| error.to_string())?;
}
existing.show().map_err(|error| error.to_string())?;
existing.unminimize().map_err(|error| error.to_string())?;
existing.set_focus().map_err(|error| error.to_string())?;
return Ok(());
}
tauri::WebviewWindowBuilder::new(
&app,
"supervisor-chat",
supervisor_chat_window_url(project_path),
)
.title("项目总控 Agent 对话")
.decorations(false)
.inner_size(820.0, 720.0)
.min_inner_size(560.0, 480.0)
.build()
.map_err(|error| error.to_string())?;
Ok(())
}
}
File diff suppressed because it is too large Load Diff
@@ -9,15 +9,11 @@ export function createLocalProjectId(): string {
return `local-project-${crypto.randomUUID()}`;
}
export const AGENT_RUN_HISTORY_MAX_COUNT = 100;
export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
export const CONVERSATION_VISIBLE_STEP = 20;
/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */
export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5;
export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor';
export const launcherNotifications: Array<{
label: string;
detail: string;

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