Compare commits

..

7 Commits

Author SHA1 Message Date
lhk229 f983663caf 合并最新master到埋点开发分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Failing after 2m1s
Project CI / Backend tests (pull_request) Successful in 5m0s
Project CI / Frontend tests (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m44s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m10s
Project CI / Repository checks (pull_request) Failing after 2m31s
同步主分支至2d61a4c05并保留客户端本地埋点实现
合并决策记录顶部新增内容,保留双方条目
验证客户端Rust编译、TypeScript、编码与文档索引通过
2026-09-21 12:25:38 +00:00
lhk229 9f225b7a41 实现客户端本地埋点与创作链路采集
新增统一事件合同、非阻塞明文队列、批次封存及保留清理
接入会话前台时长、项目创建打开、创作提交和双智能体运行结果
按策划阶段推进及已接入成果路径记录变化、预览就绪与保存
补齐身份隔离、异常会话和同一宿主创作链路验证
同步团队方案与验收证据,本期不上传、不加密、不扩展资源操作采集
2026-09-21 12:18:47 +00:00
lhk229 5087b600a2 合并最新master到埋点开发分支
同步origin/master最新变更。

合并决策记录中的双方新增条目。
2026-09-21 08:03:52 +00:00
lhk229 8fd85d0426 合并最新master到埋点开发分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
同步macOS单架构发布及渠道测试修复
保留埋点方案与macOS发布两项共享决策记录
2026-09-21 04:55:52 +00:00
lhk229 b840973d82 Merge remote-tracking branch 'origin/master' into feat/agc_track
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
合并最新master
2026-09-21 04:18:38 +00:00
lhk229 ca82e6b745 合并最新 master 到埋点方案分支
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
同步主分支最新客户端和运维变更。
解决共享决策记录顶部冲突,保留双方新增记录。
2026-09-21 04:16:32 +00:00
lhk229 3e657e6a6a 文档:纳入客户端本地埋点方案与需求来源
新增客户端本地埋点与后续主站入库契约,明确本期不加密、不上传。
保存原始需求为仓库内历史来源,消除对个人文档目录的引用依赖。
更新文档入口、生命周期索引和团队共享决策。
2026-09-21 04:14:57 +00:00
266 changed files with 66508 additions and 8049 deletions
-8
View File
@@ -174,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -185,12 +185,7 @@ test('nextVersion 只在 patch 位递增', () => {
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: [
'cp',
'--force',
'/tmp/a.json',
'oss://agc-dev/agc/global-version.json',
],
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',
@@ -107,6 +107,10 @@ 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',
@@ -131,45 +135,6 @@ 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',
@@ -199,26 +164,6 @@ 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',
@@ -1525,7 +1470,13 @@ const eventCapability = JSON.parse(
);
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
for (const windowLabel of ['client', 'main', 'launcher']) {
for (const windowLabel of [
'client',
'developer',
'main',
'launcher',
'supervisor-chat',
]) {
if (!eventCapabilityWindows.has(windowLabel)) {
throw new Error(
`AI game creator shell event capability missing window: ${windowLabel}`,
@@ -1908,6 +1859,20 @@ 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',
@@ -1940,17 +1905,31 @@ 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(
@@ -1192,7 +1192,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
@@ -0,0 +1,7 @@
{
"$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", "main", "launcher"],
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"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", "main", "launcher"],
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"permissions": [
"core:window:allow-close",
"core:window:allow-is-maximized",
@@ -1180,6 +1180,7 @@ fn direct_codex_thread_delta_event(
) -> DirectThreadEvent {
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
}
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
///
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
@@ -3357,22 +3358,8 @@ impl CodexAppServerConnection {
codex_app_server_text_prompt(&request)
.map_err(platform_llm::LlmError::InvalidRequest)?
};
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if let Some(item) = direct_user_item {
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
direct_codex_user_item_to_codex_turn_input(
&self.inner.workspace_path,
&canonical,
self.inner._skill_roots.as_deref().unwrap_or_default(),
)
.map_err(platform_llm::LlmError::InvalidRequest)?
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
}
} else {
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
};
let mut input =
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 {
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,9 @@
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
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 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;
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::{
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
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,
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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserMessageItem {
pub(crate) role: DirectCodexUserRole,
pub(crate) content: Vec<DirectCodexUserContentPart>,
@@ -21,43 +21,26 @@ pub(crate) struct DirectCodexUserMessageItem {
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/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/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) label: String,
#[serde(default)]
@@ -4,20 +4,15 @@ 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<GameCreationAppManifest, String> {
) -> Result<(), String> {
let DirectCodexUserItem::Message(message) = item;
if !matches!(message.role, DirectCodexUserRole::User) {
return Err("DirectProject 只接受 user message item".to_string());
@@ -25,98 +20,38 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string());
}
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
if !content_has_meaningful_input(&message.content) {
if message.content.is_empty() {
return Err("聊天内容不能为空".to_string());
}
let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize;
let mut attachment_count = 0usize;
let mut skill_count = 0usize;
let mut has_effective_content = false;
for part in &message.content {
match part {
DirectCodexUserContentPart::InputText { .. } => {}
DirectCodexUserContentPart::InputText { text } => {
if !text.trim().is_empty() {
has_effective_content = true;
}
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?;
}
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());
}
has_effective_content = true;
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?;
}
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());
}
has_effective_content = true;
}
}
}
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
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,
})
if !has_effective_content {
return Err("聊天内容不能为空".to_string());
}
Ok(())
}
pub(crate) fn validate_resource_id_and_manifest(
@@ -157,173 +92,3 @@ 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,12 +1,6 @@
use super::model::{
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
};
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
use super::validation::validate_direct_codex_user_item;
use crate::agent::{
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
sanitize_attachment_name, GameCreationAppManifest,
};
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,
@@ -61,90 +55,54 @@ 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 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
let manifest = validate_direct_codex_user_item(root, item)?;
validate_direct_codex_user_item(root, item)?;
let manifest = read_manifest_for_project(root)?;
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 } => {
resource_reference_summary(&manifest, resource_id)?
}
DirectCodexUserContentPart::AgcSkillReference { name } => {
format!("${}", name.trim())
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()
)
}
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
runtime_region_summary(reference)
}
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
attachment_reference_summary(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
}
};
input.push(serde_json::json!({ "type": "text", "text": text }));
@@ -152,55 +110,6 @@ 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,
@@ -209,10 +118,17 @@ 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())?
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>();
.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>>()
})?;
if let Some(code_context) = render_ui_design_code_context(root, message)? {
prompt.push('\n');
prompt.push_str(&code_context);
@@ -283,9 +199,8 @@ fn render_ui_design_code_context(
mod tests {
use super::{
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
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::{
@@ -368,83 +283,6 @@ 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!({
@@ -459,23 +297,6 @@ 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");
@@ -624,97 +445,79 @@ mod tests {
}
#[test]
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"));
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 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}");
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 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(" "));
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 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}");
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}");
}
}
@@ -93,10 +93,17 @@ pub(super) struct ExecutionLedger {
pub(super) plan: Option<Value>,
#[serde(default)]
pub(super) last_failed_write_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
}
struct SessionData {
ledger: ExecutionLedger,
analytics_capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_output_revision: Option<u64>,
started: Instant,
initial_elapsed_ms: u64,
lease_started: BTreeMap<String, Instant>,
@@ -153,6 +160,8 @@ fn executor_digest(path: &Path) -> Result<String, String> {
pub(super) struct ExecutionSession {
pub(super) root: PathBuf,
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
pub(super) newly_accepted: bool,
state_path: PathBuf,
_owner: File,
data: Mutex<SessionData>,
@@ -216,6 +225,16 @@ pub(crate) struct WritePermit {
id: String,
}
impl WritePermit {
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
self.session
.record_analytics_revision(revision, change_kind, files_changed_count);
}
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let mut data = self.session.lock()?;
self.session.tick_locked(&mut data)?;
@@ -401,6 +420,7 @@ pub(super) async fn begin(
prompt: &str,
requires_contract: bool,
config: DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<ExecutionSessionGuard, String> {
let root = root.to_path_buf();
let prompt_hash = hash(prompt.as_bytes());
@@ -408,13 +428,14 @@ pub(super) async fn begin(
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
let host = crate::game_creator_runtime_config_dir()
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
open_at(
open_with_analytics_at(
&host.join("direct-executions"),
&root,
&turn,
&prompt_hash,
requires_contract,
&config,
analytics_run,
)
})
.await
@@ -462,6 +483,26 @@ pub(super) fn open_at(
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
) -> Result<Arc<ExecutionSession>, String> {
open_with_analytics_at(
host,
root,
turn,
request_hash,
requires_contract,
config,
None,
)
}
pub(super) fn open_with_analytics_at(
host: &Path,
root: &Path,
turn: &str,
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<Arc<ExecutionSession>, String> {
config.validate()?;
let root = root
@@ -521,6 +562,7 @@ pub(super) fn open_at(
};
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
let is_new = existing.is_none();
let mut newly_accepted = is_new;
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
schema_version: SCHEMA.into(),
client_turn_id: turn.into(),
@@ -546,6 +588,7 @@ pub(super) fn open_at(
delivery_reviews: 0,
plan: None,
last_failed_write_revision: None,
analytics_run,
});
if is_new {
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
@@ -558,6 +601,8 @@ pub(super) fn open_at(
512 * 1024,
)?;
if let Some(legacy) = legacy {
newly_accepted = false;
ledger.analytics_run = None;
let used = legacy["usedRuns"]
.as_u64()
.and_then(|n| u32::try_from(n).ok());
@@ -614,10 +659,13 @@ pub(super) fn open_at(
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
let session = Arc::new(ExecutionSession {
root,
newly_accepted,
state_path,
_owner: owner,
data: Mutex::new(SessionData {
ledger,
analytics_capture: None,
analytics_output_revision: None,
started: Instant::now(),
initial_elapsed_ms,
lease_started: BTreeMap::new(),
@@ -738,6 +786,10 @@ impl ExecutionSession {
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.cancellation)
}
pub(super) fn was_aborted(&self) -> bool {
self.abort_requested
.load(std::sync::atomic::Ordering::Acquire)
}
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
let mut data = self.lock()?;
if data.ledger.phase.is_terminal() {
@@ -765,6 +817,74 @@ impl ExecutionSession {
self.commit(&mut data, next)?;
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
}
pub(super) fn set_analytics_capture(
&self,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) {
let Ok(mut data) = self.data.try_lock() else {
return;
};
data.analytics_capture = capture.and_then(|(mut context, writer)| {
// 恢复或账号切换后仍归属于真实受理的原 run。
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
Some((context, writer))
});
}
pub(super) fn analytics_capture(
&self,
) -> Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)> {
self.data.try_lock().ok()?.analytics_capture.clone()
}
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
if files_changed_count == 0 {
return;
}
let Ok(mut data) = self.data.try_lock() else {
return;
};
if data.ledger.analytics_run.is_none() {
return;
}
data.analytics_output_revision =
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
let capture = data.analytics_capture.clone();
let project_id = data.ledger.project_id.clone();
drop(data);
crate::analytics::project::revision(
capture,
&project_id,
Source::Direct,
RevisionCreated {
revision_id: revision.to_string(),
revision_source: RevisionSource::Agent,
change_kind,
files_changed_count: Some(files_changed_count),
},
);
}
pub(super) fn analytics_output_revision(&self) -> Option<String> {
self.data
.try_lock()
.ok()?
.analytics_output_revision
.map(|revision| revision.to_string())
}
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
let data = self.lock()?;
let mut state = data.ledger.clone();
@@ -1,5 +1,74 @@
use super::*;
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
use crate::analytics::contract::{Context, Route, RunSource, Source};
crate::analytics::run::Metadata::new(
Context {
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
},
Source::Direct,
RunSource::UserSubmit,
)
}
#[test]
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
let original = analytics_metadata("A");
let host = temp.path().join("host");
let session = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(original.clone()),
)
.unwrap();
assert!(session.newly_accepted);
assert_eq!(
session.snapshot().unwrap().analytics_run,
Some(original.clone())
);
drop(session);
let replay = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(!replay.newly_accepted);
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
}
#[test]
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
let (temp, session) = fixture(Default::default());
let root = session.root.clone();
assert!(session.snapshot().unwrap().analytics_run.is_none());
drop(session);
let replay = open_with_analytics_at(
&temp.path().join("host"),
&root,
"turn-test",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(replay.snapshot().unwrap().analytics_run.is_none());
}
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -13,6 +82,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
&config,
)
.unwrap();
assert!(session.newly_accepted);
session
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
.unwrap();
@@ -159,6 +229,7 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
)
.unwrap();
let state = reopened.snapshot().unwrap();
assert!(!reopened.newly_accepted);
assert_eq!(state.delivery_reviews, 1);
assert_eq!(
(
@@ -278,6 +349,7 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
&Default::default(),
)
.unwrap();
assert!(!session.newly_accepted);
assert!(session.admit(EffectKind::Execute, None).is_err());
let state = session.snapshot().unwrap();
assert_eq!(state.used_passes, 2);
@@ -229,6 +229,40 @@ fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<Strin
.collect()
}
fn analytics_patch_changes(
before: &BTreeMap<String, Option<String>>,
after: &BTreeMap<String, Option<String>>,
) -> Option<(crate::analytics::contract::ChangeKind, u64)> {
use crate::analytics::contract::ChangeKind;
let mut result = None;
for (path, before) in before {
let Some((before, after)) = before
.as_ref()
.zip(after.get(path).and_then(Option::as_ref))
else {
continue;
};
if before == after {
continue;
}
let Some(kind) = crate::analytics::project::file_change_kind(path) else {
continue;
};
result = Some(match result {
None => (kind, 1),
Some((current, count)) => (
if current == kind {
current
} else {
ChangeKind::Mixed
},
count + 1,
),
});
}
result
}
fn run_transaction(
root: &Path,
parsed: codex_patch_parser::ApplyPatchArgs,
@@ -319,6 +353,13 @@ fn run_transaction(
None
};
lease.finish(passed && !uncertain, changed, None)?;
if passed && !uncertain {
if let (Some(revision), Some((kind, count))) =
(revision, analytics_patch_changes(&before, &after))
{
session.record_analytics_revision(revision, kind, count);
}
}
Ok(json!({
"status": if passed && !uncertain { "completed" } else { "failed" },
"changedPaths": if started { changed_paths } else { BTreeSet::new() },
@@ -351,6 +392,47 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result<Value, Strin
mod tests {
use super::*;
#[test]
fn analytics_patch_counts_only_known_changed_outputs() {
let fingerprints = |entries: &[(&str, Option<&str>)]| {
entries
.iter()
.map(|(path, value)| (path.to_string(), value.map(str::to_string)))
.collect()
};
let before = fingerprints(&[
("game/a.js", Some("a")),
("game/same.js", Some("same")),
("game/unknown.js", None),
("game/unreadable.js", Some("old")),
(".agent/state.json", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("b")),
("game/same.js", Some("same")),
("game/unknown.js", Some("new")),
("game/unreadable.js", None),
(".agent/state.json", Some("new")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Code, 1))
);
assert_eq!(analytics_patch_changes(&before, &before), None);
let before = fingerprints(&[
("game/a.js", Some("missing")),
("assets/a.png", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("new")),
("assets/a.png", Some("missing")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Mixed, 2))
);
}
fn project() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -415,15 +497,20 @@ mod tests {
#[tokio::test]
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
let (temp, root) = project();
let session = direct_execution::open_at(
let config = temp.path().join("analytics-config");
let (metadata, context, writer) =
super::super::direct_tool_bridge::analytics_test_writer(&config);
let session = direct_execution::open_with_analytics_at(
&temp.path().join("host"),
&root,
"patch-roundtrip",
&format!("{:x}", Sha256::digest(b"request")),
false,
&direct_validation::DirectValidationConfig::default(),
Some(metadata),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
session
.freeze_contract(json!({"fixture":"patch protocol only"}))
.unwrap();
@@ -499,6 +586,29 @@ mod tests {
"writes do not invent execution passes"
);
assert!(session.snapshot().unwrap().active.is_empty());
assert_eq!(
session.analytics_output_revision(),
None,
"text fixture files are not classified as成果"
);
let outputs = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/result.js\n+const result = 1;\n*** Add File: game/style.css\n+body { color: red; }\n*** End Patch"})).await.unwrap();
assert_eq!(outputs["status"], "completed", "{outputs}");
let revision = outputs["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let partial_output = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/partial.js\n+const partial = 1;\n*** Update File: game/absent.js\n@@\n-old\n+new\n*** End Patch"})).await.unwrap();
assert_eq!(partial_output["status"], "failed");
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let events = super::super::direct_tool_bridge::drain_analytics_test_writer(
&config, &context, &writer,
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
assert_eq!(revisions[0]["properties"]["files_changed_count"], 2);
session.interrupt("fixture stopped".into()).unwrap();
assert!(apply(
&root,
@@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BrowserValidationResult, String> {
run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
advisory_interaction,
cancellation,
None,
)
.await
}
pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
root: &Path,
evidence_root: PathBuf,
scenario: Option<crate::browser::BrowserPlaytestScenario>,
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) -> Result<BrowserValidationResult, String> {
let observation = crate::analytics::preview::prepare(
root,
capture,
crate::analytics::contract::Source::Direct,
crate::analytics::contract::PreviewSource::Agent,
);
let (_analytics_lease, observation) = match observation {
Some((lease, observation)) => (Some(lease), Some(observation)),
None => (None, None),
};
let (preview, stop_sender) = start_local_game_preview_for_project(root)?;
if let Some(observation) = observation {
observation
.with_cancellation(cancellation.clone())
.schedule(preview.port);
}
let validation = crate::browser::validate_local_preview_in_browser_with_cancellation(
BrowserValidationInput {
url: preview.url,
@@ -4261,6 +4298,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
cancellation,
)
.await;
drop(_analytics_lease);
let _ = stop_sender.send(());
validation
}
@@ -4781,6 +4819,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
None,
None,
None,
None,
None,
)
.await
}
@@ -4792,6 +4832,11 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -4814,6 +4859,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter,
audit,
direct_user_item,
capture,
analytics_attempt_id,
)
.await
{
@@ -5014,6 +5061,11 @@ async fn run_direct_game_creator_turn_inner(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, DirectCodexTurnFailure> {
let requires_contract = super::direct_delivery::requires_new_web_contract(
root,
@@ -5035,16 +5087,49 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?
.validation;
let execution_guard =
super::direct_execution::begin(root, prompt, requires_contract, execution_config)
.await
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let analytics_run = capture.as_ref().map(|(context, _)| {
crate::analytics::run::Metadata::new(
context.clone(),
crate::analytics::contract::Source::Direct,
crate::analytics::contract::RunSource::UserSubmit,
)
});
let execution_guard = super::direct_execution::begin(
root,
prompt,
requires_contract,
execution_config,
analytics_run,
)
.await
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
let execution_session = execution_guard.session();
execution_session.set_analytics_capture(capture.clone());
let started = execution_session
.newly_accepted
.then(std::time::Instant::now);
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) {
crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata);
}
}
}
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, DirectCodexTurnFailure> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
return Ok(report);
}
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
crate::analytics::goal::accepted(
capture.clone(),
root,
&ledger.project_id,
crate::analytics::contract::Source::Direct,
);
}
}
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("preparing"), None, None);
@@ -5334,6 +5419,118 @@ async fn run_direct_game_creator_turn_inner(
})?;
}
Ok(visible_reply)
}.await;
if let Ok(ledger) = execution_session.snapshot() {
if let (Some(metadata), Some((end_reason, error_code))) = (
&ledger.analytics_run,
direct_analytics_outcome(
ledger.phase,
ledger.requires_contract || ledger.contract.is_some(),
result.is_err(),
execution_session.was_aborted(),
),
) {
let output_revision = execution_session.analytics_output_revision();
crate::analytics::run::direct_finished(
capture,
root,
&ledger.project_id,
metadata,
analytics_attempt_id,
crate::analytics::run::Outcome {
turn_id: Some(ledger.client_turn_id.clone()),
end_reason,
error_code,
duration_ms: started
.and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()),
output_change_detected: output_revision.as_ref().map(|_| true),
revision_id: output_revision,
},
);
}
}
result
}
fn direct_analytics_outcome(
phase: super::direct_execution::ExecutionPhase,
has_contract: bool,
failed: bool,
aborted: bool,
) -> Option<(
crate::analytics::contract::RunEndReason,
Option<crate::analytics::contract::ErrorCode>,
)> {
use super::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
if phase == ExecutionPhase::Interrupted || aborted {
return None;
}
if phase == ExecutionPhase::Exhausted {
return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)));
}
if failed {
// Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。
return Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified),
));
}
if phase == ExecutionPhase::Completed || !has_contract {
return Some((RunEndReason::Finished, None));
}
None
}
#[cfg(test)]
mod direct_analytics_tests {
use super::*;
use crate::agent::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
#[test]
fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false),
Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)))
);
for failed in [true, false] {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false),
None
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, failed, true),
None
);
}
}
#[test]
fn complete_delivery_does_not_hide_later_projection_failure() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, true, false),
Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified)
))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, false, false),
Some((RunEndReason::Finished, None))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, false, false),
Some((RunEndReason::Finished, None))
);
for phase in [
ExecutionPhase::Working,
ExecutionPhase::Draining,
ExecutionPhase::Sealing,
] {
assert_eq!(direct_analytics_outcome(phase, true, false, false), None);
}
}
}
#[cfg(test)]
@@ -30,10 +30,14 @@ pub(crate) fn normalize_direct_client_turn_id(
#[tauri::command]
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
user_item: DirectCodexUserItem,
prompt: String,
mut user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
@@ -41,33 +45,76 @@ 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());
validate_direct_codex_user_item(root, &user_item)?;
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
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);
}
};
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) => return Err(redact_agent_runtime_error(root, &error, 1800)),
Err(error) => {
audit.finish(false);
audit.flush().await;
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),
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
Some(&mut audit),
canonical_user_item,
capture,
analytics_attempt_id.as_deref(),
)
.await
{
Ok(reply) => reply,
Err(error) => return Err(error),
Err(error) => {
audit.finish(false);
audit.flush().await;
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/view/project-development/chat/generated/`
//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/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/view/project-development/chat/generated/"))]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectThreadHistorySlice {
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
pub(crate) items: Vec<DirectThreadItem>,
@@ -1600,6 +1600,15 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
bridge_write_file_with_permit(root, arguments, None)
}
fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option<bool> {
crate::analytics::project::file_content_changed(
root,
path,
content,
DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64,
)
}
fn bridge_write_file_with_permit(
root: &Path,
arguments: &Value,
@@ -1641,6 +1650,11 @@ fn bridge_write_file_with_permit(
"direct-codex.file.write",
)?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let analytics_change_kind = write_permit.and_then(|_| {
let kind = crate::analytics::project::file_change_kind(&path)?;
(bridge_file_content_changed(root, &path, content.as_bytes()) == Some(true))
.then_some(kind)
});
let write_started = std::time::Instant::now();
let commit = || {
let written = write_local_project_file_at(root, &path, content)?;
@@ -1653,6 +1667,9 @@ fn bridge_write_file_with_permit(
Some(permit) => permit.run(commit)?,
None => commit()?,
};
if let (Some(permit), Some(kind)) = (write_permit, analytics_change_kind) {
permit.record_analytics_revision(revision, kind, 1);
}
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
@@ -3473,6 +3490,82 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test(
.await
}
#[cfg(test)]
pub(super) fn analytics_test_writer(
config: &Path,
) -> (
crate::analytics::run::Metadata,
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
) {
use crate::analytics::{
contract::{Context, Route, RunSource, Source},
run,
store::AnalyticsWriter,
};
let mut context = Context {
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
};
fs::create_dir_all(config).expect("create analytics test config directory");
let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit);
context.route.user_id = Some("B".into());
let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone());
(metadata, context, writer)
}
#[cfg(test)]
pub(super) fn drain_analytics_test_writer(
config: &Path,
context: &crate::analytics::contract::Context,
writer: &crate::analytics::store::AnalyticsWriter,
) -> Vec<Value> {
use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source};
let marker = context
.capture(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
None,
)
.unwrap();
let marker_id = marker.event_id.clone();
assert!(writer.try_record(context.route.clone(), marker, marker_id.clone()));
assert!(writer.flush());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let events: Vec<Value> = fs::read_dir(&batches)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok())
.flat_map(|contents| {
contents
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect::<Vec<_>>()
})
.collect();
if events.iter().any(|event| event["event_id"] == marker_id) {
return events;
}
assert!(
std::time::Instant::now() < deadline,
"analytics FIFO sentinel timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
@@ -4262,6 +4355,293 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
}
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
contract::{ErrorCode, RunEndReason},
run,
};
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().join("project");
let config = temporary.path().join("config");
let (metadata, context, writer) = analytics_test_writer(&config);
let original_capture = Some((metadata.context.clone(), writer.clone()));
let mut lifecycle = crate::analytics::gui::LifecycleFixture::start(
metadata.context.clone(),
writer.clone(),
);
lifecycle.create_and_open(&root, "direct-analytics");
let session = super::super::direct_execution::open_with_analytics_at(
&temporary.path().join("host"),
&root,
"analytics-write",
&format!("{:x}", Sha256::digest(b"request")),
false,
&Default::default(),
Some(metadata.clone()),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
super::super::direct_delivery::register_contract(
&root,
&session,
&json!({
"scope": "核对当前项目宿主写入的成果采集",
"changeKind": "project",
"requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}]
}),
)
.await
.expect("freeze a validated delivery contract before writing");
let project_id = session.snapshot().unwrap().project_id;
run::accepted(&writer, &root, &project_id, &metadata);
crate::analytics::goal::accepted(
original_capture.clone(),
&root,
&project_id,
crate::analytics::contract::Source::Direct,
);
let lease = session
.admit(super::super::direct_execution::EffectKind::Write, None)
.unwrap();
let permit = lease.write_permit().unwrap();
let arguments = json!({"path":"game/index.html", "content":"<!doctype html><html><body>真实预览</body></html>"});
let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit));
assert_eq!(changed["isError"], false);
let payload: Value =
serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap();
let revision = payload["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
assert_eq!(
bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"],
false
);
assert_eq!(
bridge_write_file_with_permit(
&root,
&json!({"path":"../bad.js","content":"bad"}),
Some(&permit)
)["isError"],
true
);
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
lease.finish(true, true, None).unwrap();
let attempt = uuid::Uuid::new_v4().to_string();
run::direct_finished(
Some((context.clone(), writer.clone())),
&root,
&project_id,
&metadata,
Some(&attempt),
run::Outcome {
turn_id: Some("analytics-write".into()),
end_reason: RunEndReason::Failed,
error_code: Some(ErrorCode::RuntimeErrorUnclassified),
duration_ms: None,
output_change_detected: Some(true),
revision_id: session.analytics_output_revision(),
},
);
run::settle(Some((context.clone(), writer.clone())), &attempt, false);
// 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。
let served_root = crate::project_game_root(&root);
fs::create_dir_all(&served_root).unwrap();
fs::write(
served_root.join("index.html"),
"<!doctype html><html><body>真实预览构建</body></html>",
)
.unwrap();
let (preview_lease, observation) = crate::analytics::preview::prepare(
&root,
original_capture.clone(),
crate::analytics::contract::Source::Editor,
crate::analytics::contract::PreviewSource::User,
)
.unwrap();
let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap();
observation.observe(preview.port).await;
let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root)
.unwrap()
.revision
.to_string();
drop(preview_lease);
let _ = stop.send(());
let checkpoint = crate::commands::checkpoint_with_capture_for_test(
root.to_string_lossy().into_owned(),
original_capture,
)
.unwrap();
lifecycle.exit();
let events = drain_analytics_test_writer(&config, &context, &writer);
let ids: std::collections::HashSet<_> = events
.iter()
.map(|event| event["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), events.len());
let chain: Vec<_> = events
.iter()
.filter(|event| event["user_id"] == "A")
.collect();
for name in [
"editor_session_start",
"editor_focus_start",
"project_create_success",
"project_open",
"creative_task_submit",
"project_revision_created",
"agent_run_failed",
"preview_ready",
"project_save",
"editor_focus_end",
"editor_session_end",
] {
assert_eq!(
chain
.iter()
.filter(|event| event["event_name"] == name)
.count(),
1,
"{name}"
);
}
for event in &chain {
assert_eq!(
event["editor_session_id"],
metadata.context.editor_session_id
);
if !event["project_id"].is_null() {
assert_eq!(event["project_id"], project_id);
}
if !event["creative_task_id"].is_null() {
assert_eq!(event["creative_task_id"], project_id);
}
if !event["agent_run_id"].is_null() {
assert_eq!(event["agent_run_id"], metadata.run_id);
}
let name = event["event_name"].as_str().unwrap();
if matches!(
name,
"project_create_success"
| "project_open"
| "creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["project_id"], project_id,
"{name} must identify its project"
);
}
if matches!(
name,
"creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["creative_task_id"], project_id,
"{name} must identify its goal"
);
}
if name == "agent_run_failed" {
assert_eq!(event["agent_run_id"], metadata.run_id);
assert_eq!(event["agent_turn_id"], "analytics-write");
}
}
let save = chain
.iter()
.find(|event| event["event_name"] == "project_save")
.unwrap();
assert_eq!(save["properties"]["save_source"], "checkpoint");
assert!(Path::new(&checkpoint.checkpoint_path).is_dir());
let mut digest = Sha256::new();
digest.update(serde_json::to_vec(&metadata.context.route).unwrap());
digest.update([0]);
digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes());
let checkpoint_fact = format!("{:x}", digest.finalize());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let checkpoint_fact_matches = fs::read_dir(batches)
.unwrap()
.flatten()
.filter_map(|entry| fs::read(entry.path().join("meta.json")).ok())
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"])
.count();
assert_eq!(
checkpoint_fact_matches, 1,
"save fact must use the actual checkpoint ID"
);
let ready = chain
.iter()
.find(|event| event["event_name"] == "preview_ready")
.unwrap();
assert_eq!(ready["properties"]["preview_version"], preview_revision);
let focus_start = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_start")
.unwrap();
let focus_end = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_end")
.unwrap();
assert_eq!(
focus_start["properties"]["focus_interval_id"],
focus_end["properties"]["focus_interval_id"]
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
let failed: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "agent_run_failed")
.collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0]["user_id"], "A");
assert_eq!(failed[0]["properties"]["revision_id"], revision);
assert_eq!(failed[0]["properties"]["output_change_detected"], true);
}
#[test]
fn analytics_file_comparison_requires_known_bounded_content() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(true)
);
fs::write(root.join("new.js"), b"new").unwrap();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(false)
);
assert_eq!(
bridge_file_content_changed(root, "new.js", b"changed"),
Some(true)
);
fs::create_dir(root.join("directory.js")).unwrap();
assert_eq!(
bridge_file_content_changed(root, "directory.js", b"new"),
None
);
fs::write(
root.join("large.js"),
vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1],
)
.unwrap();
assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None);
}
#[test]
fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() {
let temporary = tempfile::tempdir().expect("create direct write root");
@@ -453,12 +453,15 @@ async fn run_browser_with_budget(
sequence,
),
)?;
let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_at(
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
false,
reservation.as_ref().map(|r| r.session.cancel_flag()),
reservation
.as_ref()
.and_then(|r| r.session.analytics_capture()),
)
.await;
let (result, passed) = match evidence {
@@ -126,6 +126,9 @@ pub(crate) struct DesignTurn {
pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>,
/// 仅持久化埋点关联,旧回合缺失时不补历史执行。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) analytics: Option<crate::analytics::run::Metadata>,
}
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
@@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
@@ -143,13 +143,6 @@ 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()
@@ -263,21 +256,6 @@ 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!(
@@ -372,19 +350,6 @@ 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 {
File diff suppressed because it is too large Load Diff

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