Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f983663caf | |||
| 9f225b7a41 | |||
| 5087b600a2 | |||
| 8fd85d0426 | |||
| b840973d82 | |||
| ca82e6b745 | |||
| 3e657e6a6a |
@@ -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)]
|
||||
|
||||
+14
-249
@@ -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
Reference in New Issue
Block a user