diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index f2f206f72..05b25f127 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,7 +13,7 @@ mod codex_app_server; mod codex_cli; mod codex_provider_proxy; mod design_runtime; -mod design_tools; +pub(crate) mod design_tools; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index cf9501888..460ac0dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; use tauri::Manager; const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts"; +const DESIGN_REFERENCES_ROOT: &str = "references"; const SEARCH_HIT_LIMIT: usize = 200; #[derive(Clone, Debug, Serialize)] @@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result, +) -> Result { + let root = PathBuf::from(project_path.trim()); + let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; + let _ = app.emit( + "design-agent-update", + serde_json::json!({ + "projectPath": root.to_string_lossy(), + "clientTurnId": "", + "kind": "workspace", + "messageId": null, + "text": null, + "view": null, + }), + ); + Ok(relative_path) +} + +fn import_design_workspace_file_at( + root: &Path, + file_name: &str, + bytes: &[u8], +) -> Result { + enforce_project_permission_policy(root, "conversation.write")?; + read_existing_manifest_for_project(root)?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + + if file_name.contains(['/', '\\']) { + return Err("附件文件名必须是单个文件名,不能包含目录".to_string()); + } + let normalized_name = + normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?; + if normalized_name != file_name { + return Err("附件文件名无效".to_string()); + } + + let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?; + crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?; + crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?; + + let mut sequence = 1_u64; + loop { + let candidate_name = design_reference_file_name(&normalized_name, sequence); + let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}"); + let (_, target) = resolve_design_workspace_path(root, &relative_path)?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = match options.open(&target) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + sequence = sequence + .checked_add(1) + .ok_or_else(|| "无法为同名附件分配新序号".to_string())?; + continue; + } + Err(error) => { + return Err(format!( + "创建策划参考附件失败:{}: {error}", + target.display() + )); + } + }; + if let Err(error) = + crate::harden_new_game_creator_private_path(&target, false, "策划参考附件") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + let write_result = file.write_all(bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&target); + return Err(format!( + "写入策划参考附件失败:{}: {error}", + target.display() + )); + } + return Ok(relative_path); + } +} + +fn design_reference_file_name(file_name: &str, sequence: u64) -> String { + if sequence == 1 { + return file_name.to_string(); + } + let path = Path::new(file_name); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or(file_name); + match path.extension().and_then(|value| value.to_str()) { + Some(extension) if !extension.is_empty() => { + format!("{stem} ({sequence}).{extension}") + } + _ => format!("{stem} ({sequence})"), + } +} + fn load_design_catalog(root: &Path) -> Result, String> { let catalog_path = root.join("resources/catalog.json"); let data: DesignCatalogFile = serde_json::from_str( @@ -756,6 +875,13 @@ mod tests { tempfile::tempdir().expect("tempdir") } + fn initialized_test_root() -> tempfile::TempDir { + let temp = test_root(); + init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试") + .expect("init project"); + temp + } + fn pack_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent") } @@ -868,6 +994,138 @@ mod tests { ); } + #[test] + fn imported_reference_is_visible_to_workspace_list_and_read() { + let temp = initialized_test_root(); + let root = temp.path(); + let manifest_before = read_existing_manifest_for_project(root).expect("read manifest"); + let revision_before = + read_game_creator_agent_runtime_project_revision(root).expect("read revision"); + + let relative = + import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes()) + .expect("import text reference"); + + assert_eq!(relative, "references/玩法构想.txt"); + assert!(list_design_workspace_files(root) + .expect("list workspace") + .iter() + .any(|entry| entry.path == relative && entry.kind == "file")); + assert_eq!( + read_design_workspace_file_at(root, &relative).expect("read imported reference"), + "横版解谜\n" + ); + assert_eq!( + read_existing_manifest_for_project(root).expect("read manifest after import"), + manifest_before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read revision after import"), + revision_before + ); + } + + #[test] + fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() { + let temp = initialized_test_root(); + let root = temp.path(); + + let first = import_design_workspace_file_at(root, "brief.md", b"first") + .expect("import first reference"); + let second = import_design_workspace_file_at(root, "brief.md", b"second") + .expect("import repeated reference"); + let empty = import_design_workspace_file_at(root, "empty.bin", b"") + .expect("import empty reference"); + let binary_bytes = [0_u8, 0xff, 0x10, 0x80]; + let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes) + .expect("import binary reference"); + + assert_eq!(first, "references/brief.md"); + assert_eq!(second, "references/brief (2).md"); + assert_eq!(empty, "references/empty.bin"); + assert_eq!(binary, "references/bytes.bin"); + assert_eq!( + fs::read(root.join("design_artifacts").join(&first)).expect("read first"), + b"first" + ); + assert_eq!( + fs::read(root.join("design_artifacts").join(&second)).expect("read second"), + b"second" + ); + assert!(fs::read(root.join("design_artifacts").join(&empty)) + .expect("read empty") + .is_empty()); + assert_eq!( + fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"), + binary_bytes + ); + } + + #[test] + fn import_rejects_unsafe_names_and_denied_project_permission() { + let temp = initialized_test_root(); + let root = temp.path(); + for file_name in [ + "../outside.txt", + "nested/file.txt", + r"nested\file.txt", + "C:stream", + ] { + let error = import_design_workspace_file_at(root, file_name, b"blocked") + .expect_err("reject unsafe file name"); + assert!(error.contains("文件名"), "unexpected error: {error}"); + } + assert!(!root.join("outside.txt").exists()); + + let mut policy = ProjectPermissionPolicy::default(); + policy + .denied_commands + .push("conversation.write".to_string()); + write_project_permission_policy_at(root, policy).expect("deny conversation write"); + let error = import_design_workspace_file_at(root, "denied.txt", b"blocked") + .expect_err("respect project permission policy"); + assert!(error.contains("conversation.write")); + assert!(!root.join("design_artifacts/references/denied.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn import_rejects_linked_references_directory() { + use std::os::unix::fs::symlink; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + symlink(outside.path(), root.join("design_artifacts/references")) + .expect("link references directory"); + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + + #[cfg(windows)] + #[test] + fn import_rejects_windows_linked_references_directory_when_supported() { + use std::os::windows::fs::symlink_dir; + + let temp = initialized_test_root(); + let root = temp.path(); + let outside = tempfile::tempdir().expect("outside tempdir"); + fs::create_dir_all(root.join("design_artifacts")).expect("create workspace"); + if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() { + return; + } + + let error = import_design_workspace_file_at(root, "escape.txt", b"blocked") + .expect_err("reject linked references directory"); + assert!(error.contains("符号链接") || error.contains("reparse point")); + assert!(!outside.path().join("escape.txt").exists()); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 905311f77..151a18cfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -157,6 +157,7 @@ mod tool_plan_handoff; mod user_input; mod windows; +use agent::design_tools::*; use agent::*; use agent_native_tools::*; use asset_generation_tasks::*; @@ -2613,6 +2614,7 @@ fn main() { debug_fast_forward_design_session, continue_design_agent_session, decide_design_phase, + import_design_workspace_file, list_design_workspace, read_design_workspace_file, start_game_creator_agent_runtime_task, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7c47aeb5e..2403d3913 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -76,6 +76,7 @@ import { projectPathHasControlCharacter, } from './features/project-summary/projectSummary'; import { parseAgentRunTrace } from './features/project-workspace/agentRunTrace'; +import { importDesignFiles } from './features/project-workspace/importDesignFiles'; import { parseRememberInput } from './features/project-workspace/memoryCommands'; import { isAgentTraceFilePath, @@ -171,6 +172,7 @@ type AppProps = { initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; + onDesignFilesImportingChange?: ProjectChatComponentProps['onDesignFilesImportingChange']; playRequest?: ProjectChatComponentProps['playRequest']; onPlayRequestHandled?: ProjectChatComponentProps['onPlayRequestHandled']; onManifestChange?: ( @@ -206,6 +208,7 @@ export function App({ initialPlanningPromptClaimScope = '', initialCreationType = null, initialAttachments = [], + onDesignFilesImportingChange, playRequest = null, onPlayRequestHandled, onManifestChange, @@ -683,6 +686,8 @@ export function App({ } } + const [chatFilesImporting, setChatFilesImporting] = useState(false); + const [chatFileImportNotice, setChatFileImportNotice] = useState(''); const planningChatMessagesRef = useRef(null); const planningChatShouldFollowLatestRef = useRef(true); const directProjectChatRef = useRef(null); @@ -1311,6 +1316,8 @@ export function App({ * 策划会话与设计 Agent 视图都属于上一个项目;项目身份一变就不能留到下一个项目里。 */ function resetChatState() { + setChatFilesImporting(false); + setChatFileImportNotice(''); setProjectChatError(''); setDesignAgentTransientReplyTarget(''); designAgentPendingViewRef.current = null; @@ -2166,6 +2173,34 @@ export function App({ distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; } + /** + * 策划输入盒导入本地文件:文件直接写进策划工作区(`references/`),既不进游戏资源 + * 清单,也不生成回合附件;导入期间通知工作台壳暂缓切换游戏运行态。 + */ + async function handleDesignComposerUploadFiles(files: readonly File[]) { + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke || !nextProjectPath) { + setChatFileImportNotice('需要先打开本地项目,才能导入文件'); + return; + } + if (chatFilesImporting || files.length === 0) { + return; + } + setChatFilesImporting(true); + onDesignFilesImportingChange?.(nextProjectPath, true); + setChatFileImportNotice('正在导入文件'); + try { + const notice = await importDesignFiles(invoke, nextProjectPath, files); + if (localProjectPathRef.current === nextProjectPath) { + setChatFileImportNotice(notice); + } + } finally { + setChatFilesImporting(false); + onDesignFilesImportingChange?.(nextProjectPath, false); + } + } + function handlePlanningChatSubmit(event: FormEvent) { event.preventDefault(); const content = chatComposerRef.current?.getDraft().content ?? []; @@ -2345,6 +2380,9 @@ export function App({ } error={projectChatError} controlBusy={chatAgentBusy} + attachmentNotice={chatFileImportNotice} + importingFiles={chatFilesImporting} + onUploadFiles={(files) => void handleDesignComposerUploadFiles(files)} versions={chatProjectVersions} /> {runtimeConfigOpen ? ( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 37ba7f3a2..210cbd489 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -143,6 +143,8 @@ export type LauncherProjectContext = { startMode: ProjectStartMode | null; initialPrompt: string; attachments: LauncherImportedAttachment[]; + /** 仅用于工作区界面,不进入 Agent 消息。 */ + fileImportNotice?: string; recentRunStatus: string | null; recentRunStopReason: string | null; createdAt: number; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index e81a2b9eb..d9c3e4a62 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -607,7 +607,28 @@ export function WorkspaceLauncherShell({ }); } + /** + * 策划文件导入工作区是「写进项目目录」的动作,导入期间切游戏运行态会读到半份工作区; + * 这里只记导入中的项目,切运行时先挡一次。 + */ + const designImportProjectRef = useRef(null); + const handleDesignFilesImportingChange = useCallback( + (projectPath: string, importing: boolean) => { + if (importing) designImportProjectRef.current = projectPath; + else if (designImportProjectRef.current === projectPath) + designImportProjectRef.current = null; + }, + [], + ); + async function switchToGameRuntime(nextProjectPath: string) { + if (designImportProjectRef.current === nextProjectPath) { + setLauncherNotice({ + title: '正在导入文件', + message: '文件导入完成后,再做成游戏。', + }); + return; + } const invoke = resolveTauriInvoke(); if (!invoke) throw new Error('需要在陶泥儿客户端内运行'); await invoke('set_design_agent_runtime_mode', { @@ -712,6 +733,25 @@ export function WorkspaceLauncherShell({ /> ) : launcherView === 'project-development' && currentProjectContext ? ( <> + {currentProjectContext.fileImportNotice ? ( +
+
+ {currentProjectContext.fileImportNotice} + +
+
+ ) : null} {manifestMergeNotice ? (
} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 653678a21..ff0a5acda 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -42,6 +42,11 @@ export type ProjectChatComponentProps = { initialPlanningPromptClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; + /** 策划文件导入工作区的进行态:导入期间工作台壳暂缓切换游戏运行态。 */ + onDesignFilesImportingChange?: ( + projectPath: string, + importing: boolean, + ) => void; planningStartMode?: boolean; /** * C7 当前游戏版本:由工作台壳持有,策划聊天里的 `@` 面板按它切「当前版本素材」。 diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 2f691cfe9..185fc16a7 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -47,6 +47,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { importDesignFiles } from '../project-workspace/importDesignFiles'; import { ensureHomeWebCreationEnvironment, HOME_WEB_PREFLIGHT_FAILURE, @@ -320,11 +321,18 @@ export function useHomeProjectCreation({ activeRuntime: 'design', }); } - const importedAttachments = await importHomeAttachments( - invoke, - result.projectPath, - attachments, - ); + const fileImportNotice = + startMode === 'planning' + ? await importDesignFiles( + invoke, + result.projectPath, + attachments.map((item) => item.file), + ) + : undefined; + const importedAttachments = + startMode === 'planning' + ? [] + : await importHomeAttachments(invoke, result.projectPath, attachments); await enterProjectDevelopment({ projectPath: result.projectPath, projectName: @@ -339,10 +347,11 @@ export function useHomeProjectCreation({ startMode, initialPrompt: prompt.trim() || - (attachments.length > 0 + (attachments.length > 0 && startMode !== 'planning' ? '用户上传了参考附件,等待后续补充需求。' : ''), attachments: importedAttachments, + fileImportNotice, recentRunStatus: null, recentRunStopReason: null, createdAt: Date.now(), diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts b/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts new file mode 100644 index 000000000..f8c4bf9f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/importDesignFiles.ts @@ -0,0 +1,30 @@ +import type { TauriInvoke } from '../../app/types'; + +/** 策划附件只写工作区;返回界面提示,不生成回合附件或资源清单。 */ +export async function importDesignFiles( + invoke: TauriInvoke, + projectPath: string, + files: readonly File[], +): Promise { + if (files.length === 0) return ''; + let imported = 0; + const failures: string[] = []; + for (const file of files) { + try { + await invoke('import_design_workspace_file', { + projectPath, + fileName: file.name, + bytes: Array.from(new Uint8Array(await file.arrayBuffer())), + }); + imported += 1; + } catch (error) { + failures.push( + `${file.name}:${error instanceof Error ? error.message : String(error)}`, + ); + } + } + const summary = `已导入 ${imported} 个文件到策划工作区`; + return failures.length > 0 + ? `${summary};${failures.length} 个文件导入失败:${failures.join(';')}。可重新选择失败文件重试。` + : summary; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0a672ea4b..4a3f31494 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -10585,6 +10585,7 @@ button.design-workspace-tree__entry:hover, pointer-events: auto; } +.game-workbench-layout--design .project-chat-composer-controls-left, .game-workbench-layout--design .project-chat-composer-controls-right, .game-workbench-chat .project-chat-surface.is-direct-codex @@ -10665,6 +10666,9 @@ button.design-workspace-tree__entry:hover, /* 控制排三只方钮(`+` 附件 / `@` 引用 / 发送)共用一套尺寸。发送钮单独加圆角与主色, 见下面两条规则。 */ +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-attachment-trigger, .game-workbench-layout--design .project-chat-composer-controls .project-chat-submit-button, @@ -10698,6 +10702,9 @@ button.design-workspace-tree__entry:hover, place-items: center; } +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-attachment-trigger, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -10710,6 +10717,12 @@ button.design-workspace-tree__entry:hover, color: var(--platform-text-soft); } +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-attachment-trigger:hover:not(:disabled), +.game-workbench-layout--design + .project-chat-composer-controls + .project-chat-attachment-trigger:focus-visible, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex @@ -11006,6 +11019,7 @@ button.design-workspace-tree__entry:hover, font-variant-numeric: tabular-nums; } +.game-workbench-layout--design .project-chat-composer-notice, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-composer.is-direct-codex diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 7dd54d5a4..81f8fe86d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -1,5 +1,6 @@ -import { ArrowUp, Loader2 } from 'lucide-react'; +import { ArrowUp, FileUp, Loader2 } from 'lucide-react'; import type { FormEventHandler, RefObject, UIEventHandler } from 'react'; +import { useRef } from 'react'; import { AgentMessageContent } from '../../../../../../packages/shared/src/components/AgentMessageContent'; import type { @@ -59,6 +60,11 @@ type PlanningChatViewProps = { composerRef?: RefObject; controlBusy: boolean; error: string; + /** 导入工作区的提示文案(导入中与结果共用一条;空串不渲染)。 */ + attachmentNotice?: string; + /** 工作区文件导入进行中:发送与阶段操作都等它结束。 */ + importingFiles?: boolean; + onUploadFiles?: (files: readonly File[]) => void; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; initialPlanningPrompt?: string; @@ -95,6 +101,9 @@ export function PlanningChatView({ composerRef, controlBusy, error, + attachmentNotice = '', + importingFiles = false, + onUploadFiles, hiddenConversationCount, hasEarlierConversationMessages = false, initialPlanningPrompt = '', @@ -120,6 +129,7 @@ export function PlanningChatView({ onDesignClarify, onDesignRetry, }: PlanningChatViewProps) { + const designFileInputRef = useRef(null); let submitLabel = '发送'; if (needsUserInput) { submitLabel = '等待回答'; @@ -130,12 +140,13 @@ export function PlanningChatView({ // 输入区与提交按钮共用同一个禁用判据,避免两处条件各改一半而漂移。 const composerDisabled = controlBusy || + importingFiles || needsUserInput || Boolean(designView?.session.pendingApproval) || Boolean(designView?.session.pendingClarification); // 顶部状态条与底部待办区渲染的是同一份 Design Agent 状态,判据与回调兜底必须同源。 const designMode = Boolean(designView || onDesignApprove); - const designActionsBusy = controlBusy; + const designActionsBusy = controlBusy || importingFiles; const handleDesignApprove = onDesignApprove ?? (() => undefined); const handleDesignClarify = onDesignClarify ?? (() => undefined); const handleDesignRetry = onDesignRetry ?? (() => undefined); @@ -279,7 +290,17 @@ export function PlanningChatView({ onRetry={handleDesignRetry} /> ) : null} -
+ { + // 导入没结束就提交,等于让 Agent 读半份工作区;挡住这一次提交。 + if (importingFiles) { + event.preventDefault(); + return; + } + onSubmit(event); + }} + >
+ {onUploadFiles ? ( +
+ { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) onUploadFiles(files); + }} + /> + +
+ ) : null}
{/* 两条产品路径共用行为中立的配置控件;策划入口不接管提交校验。 */} @@ -320,6 +371,11 @@ export function PlanningChatView({
+ {attachmentNotice ? ( +

+ {attachmentNotice} +

+ ) : null}
diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 87aba74b5..393e85953 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -1,4 +1,5 @@ import { + act, App, createProjectChatRuntimeHarness, expect, @@ -121,6 +122,140 @@ function designHistoryView() { } export function registerDesignAgentSurfaceTests() { + it('imports planning chat files into the workspace and retries failed files without turn attachments', async () => { + const harness = createProjectChatRuntimeHarness({ + designAgentView: designConversationView(), + designAgentContinueView: designConversationView(), + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let failImport = true; + let finishImport!: () => void; + const importGate = new Promise((resolve) => { + finishImport = resolve; + }); + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'import_design_workspace_file') { + await importGate; + if (args?.fileName === '补充.md' && failImport) + throw new Error('磁盘写入失败'); + return `references/${args?.fileName}`; + } + return originalInvoke(command, args); + }); + renderDesignAgent(harness); + await expectDesignModelReady(); + const button = screen.getByRole('button', { name: '导入文件到策划工作区' }); + expect(button).not.toBeNull(); + const input = document.querySelector( + '[data-chat-composer-upload]', + )!; + const files = ['需求.md', '补充.md'].map((name) => { + const file = new File(['# 参考'], name, { type: 'text/markdown' }); + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new Uint8Array([35, 32, 65]).buffer, + }); + return file; + }); + const manifestReads = () => + harness.invoke.mock.calls.filter( + ([command]) => command === 'get_local_game_manifest', + ).length; + const readsBefore = manifestReads(); + fireEvent.change(input, { target: { files } }); + await screen.findByText('正在导入文件'); + const composer = screen.getByLabelText('项目需求'); + await setComposerText(composer, '阅读工作区文件'); + expect( + screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), + ).toBe(true); + fireEvent.submit(composer.closest('form')!); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'continue_design_agent_session', + ), + ).toBe(false); + await act(async () => finishImport()); + await screen.findByText( + '已导入 1 个文件到策划工作区;1 个文件导入失败:补充.md:磁盘写入失败。可重新选择失败文件重试。', + ); + expect(harness.invoke).toHaveBeenCalledWith( + 'import_design_workspace_file', + { + projectPath: harness.projectPath, + fileName: '需求.md', + bytes: [35, 32, 65], + }, + ); + expect(manifestReads()).toBe(readsBefore); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'upload_local_asset', + ), + ).toBe(false); + expect(screen.queryByLabelText('待发送附件')).toBeNull(); + + failImport = false; + fireEvent.change(input, { target: { files: [files[1]] } }); + await screen.findByText('已导入 1 个文件到策划工作区'); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'import_design_workspace_file', + ), + ).toHaveLength(3); + + await setComposerText(composer, '阅读工作区文件'); + fireEvent.submit(composer.closest('form')!); + await waitFor(() => + expect(harness.invoke).toHaveBeenCalledWith( + 'continue_design_agent_session', + expect.objectContaining({ + input: { type: 'message', text: '阅读工作区文件' }, + }), + ), + ); + const turn = harness.invoke.mock.calls.find( + ([command]) => command === 'continue_design_agent_session', + ); + expect(JSON.stringify(turn?.[1])).not.toContain('references/'); + expect(turn?.[1]).not.toHaveProperty('attachments'); + }); + + it('waits for planning file imports before allowing phase approval', async () => { + const harness = createProjectChatRuntimeHarness({ + designAgentView: designApprovalView(), + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + harness.invoke.mockImplementation(async (command, args) => + command === 'import_design_workspace_file' + ? 'references/需求.md' + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + const approve = await screen.findByRole('button', { name: '批准' }); + const file = new File(['# 参考'], '需求.md'); + let finishRead!: (value: ArrayBuffer) => void; + Object.defineProperty(file, 'arrayBuffer', { + value: () => + new Promise((resolve) => { + finishRead = resolve; + }), + }); + fireEvent.change(document.querySelector('[data-chat-composer-upload]')!, { + target: { files: [file] }, + }); + await screen.findByText('正在导入文件'); + expect(approve.hasAttribute('disabled')).toBe(true); + fireEvent.click(approve); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'decide_design_phase', + ), + ).toBe(false); + await act(async () => finishRead(new Uint8Array([65]).buffer)); + await screen.findByText('已导入 1 个文件到策划工作区'); + expect(approve.hasAttribute('disabled')).toBe(false); + }); + it('persists Design Agent model and reasoning controls and reads them on reentry', async () => { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 737782c86..339508627 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1945,99 +1945,136 @@ export function registerHomeProjectCreationTests() { }, ); - it('keeps 做方案 first turn on the planning lane without a Direct attachment sidecar', async () => { - const projectPath = '/tmp/home-planning-attachment'; - const manifest = createGameCreationAppManifest( - 'home-planning-attachment', - '首页策划附件', - ); - const chatHarness = createProjectChatRuntimeHarness({ - projectPath, - designAgentContinueView: homeDesignContinueView({ - prompt: '整理一份可玩原型', - }), - }); - const fileBytes = Array.from(new TextEncoder().encode('png')); - const invoke = vi.fn( - async (command: string, args?: Record) => { - if (command === 'create_automatic_local_game_project') { - return { - projectPath, - manifestPath: `${projectPath}/.agent/manifest.json`, - manifest, - }; - } - if (command === 'upload_local_asset') { - return { - id: 'asset-upload-plan-1', - localPath: 'assets/uploads/reference.png', - absolutePath: `${projectPath}/assets/uploads/reference.png`, - manifestPath: `${projectPath}/.agent/manifest.json`, - }; - } - return chatHarness.invoke(command, args); - }, - ); - window.__TAURI__ = { - core: { invoke }, - event: { listen: chatHarness.listen }, - }; - renderLauncherAt('/?launcher', 'home', true); - - fireEvent.click(screen.getByRole('button', { name: '做方案' })); - const fileInput = - document.querySelector('input[type="file"]'); - expect(fileInput).not.toBeNull(); - const attachment = new File(['png'], '角色参考.png', { - type: 'image/png', - lastModified: 1, - }); - Object.defineProperty(attachment, 'arrayBuffer', { - value: async () => new Uint8Array(fileBytes).buffer, - }); - fireEvent.change(fileInput!, { target: { files: [attachment] } }); - - const promptInput = screen.getByLabelText('创作想法'); - nativeClipboardMock.text = '整理一份可玩原型'; - fireEvent.paste(promptInput); - await waitFor(() => { - expect(promptInput.textContent).toContain('整理一份可玩原型'); - expect(promptInput.textContent).toContain('角色参考.png'); - }); - fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); - - await waitFor(() => { - expect(invoke).toHaveBeenCalledWith( - 'continue_design_agent_session', - expect.objectContaining({ - projectPath, - input: expect.objectContaining({ type: 'message' }), - }), + it.each(['success', 'partial-failure', 'files-only'])( + 'imports 做方案 references into the workspace: %s', + async (scenario) => { + const partialFailure = scenario === 'partial-failure'; + const filesOnly = scenario === 'files-only'; + const projectPath = `/tmp/home-planning-attachment-${scenario}`; + const manifest = createGameCreationAppManifest( + 'home-planning-attachment', + '首页策划附件', ); - }); - expect(invoke).toHaveBeenCalledWith('upload_local_asset', { - projectPath, - fileName: '角色参考.png', - mediaType: 'image/png', - bytes: fileBytes, - }); - const startCall = invoke.mock.calls.find( - ([command]) => command === 'continue_design_agent_session', - ); - expect(startCall?.[1]).not.toHaveProperty('attachments'); - expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); - expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( - 'assets/uploads/reference.png', - ); - expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_direct_codex', - expect.anything(), - ); - expect(invoke).not.toHaveBeenCalledWith( - 'chat_with_game_creator_agent', - expect.anything(), - ); - }); + const chatHarness = createProjectChatRuntimeHarness({ + projectPath, + designAgentContinueView: homeDesignContinueView({ + prompt: '整理一份可玩原型', + }), + }); + const fileBytes = Array.from(new TextEncoder().encode('png')); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'import_design_workspace_file') { + if (args?.fileName === '失败.txt') throw new Error('磁盘写入失败'); + return 'references/角色参考.png'; + } + return chatHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: chatHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const fileInput = + document.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + const attachment = new File(['png'], '角色参考.png', { + type: 'image/png', + lastModified: 1, + }); + Object.defineProperty(attachment, 'arrayBuffer', { + value: async () => new Uint8Array(fileBytes).buffer, + }); + const failedAttachment = new File(['失败'], '失败.txt'); + Object.defineProperty(failedAttachment, 'arrayBuffer', { + value: async () => new Uint8Array([1]).buffer, + }); + fireEvent.change(fileInput!, { + target: { + files: partialFailure ? [attachment, failedAttachment] : [attachment], + }, + }); + + const promptInput = screen.getByLabelText('创作想法'); + if (!filesOnly) { + nativeClipboardMock.text = '整理一份可玩原型'; + fireEvent.paste(promptInput); + } + await waitFor(() => { + if (!filesOnly) + expect(promptInput.textContent).toContain('整理一份可玩原型'); + expect(promptInput.textContent).toContain('角色参考.png'); + }); + fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); + + await screen.findByText( + partialFailure + ? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。' + : '已导入 1 个文件到策划工作区', + ); + if (!filesOnly) + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'continue_design_agent_session', + expect.objectContaining({ + projectPath, + input: expect.objectContaining({ type: 'message' }), + }), + ); + }); + expect(invoke).toHaveBeenCalledWith('import_design_workspace_file', { + projectPath, + fileName: '角色参考.png', + bytes: fileBytes, + }); + expect( + invoke.mock.calls.some(([command]) => command === 'upload_local_asset'), + ).toBe(false); + expect( + screen.getByText( + partialFailure + ? '已导入 1 个文件到策划工作区;1 个文件导入失败:失败.txt:磁盘写入失败。可重新选择失败文件重试。' + : '已导入 1 个文件到策划工作区', + ), + ).not.toBeNull(); + const commands = invoke.mock.calls.map(([command]) => command); + if (filesOnly) { + expect(commands).not.toContain('continue_design_agent_session'); + } else { + expect( + commands.lastIndexOf('import_design_workspace_file'), + ).toBeLessThan(commands.indexOf('continue_design_agent_session')); + } + const startCall = invoke.mock.calls.find( + ([command]) => command === 'continue_design_agent_session', + ); + expect(startCall?.[1] ?? {}).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + '本轮用户附件', + ); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + 'references/角色参考.png', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); + }, + ); it('surfaces the planning clarification card after 做方案 creates the project from home', async () => { // 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run diff --git a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx index bc5db7f75..55f9b4082 100644 --- a/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx +++ b/apps/ai-game-creator-shell/tests/designWorkspaceDebug.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -22,6 +23,60 @@ afterEach(() => { delete window.__TAURI__; }); +it('shows imported references after a workspace event without reloading the design session', async () => { + let imported = false; + let onUpdate: + | ((event: { payload: { projectPath: string; kind: string } }) => void) + | undefined; + const invoke = vi.fn(async (command: string) => { + if (command === 'is_design_agent_debug_enabled') return false; + if (command === 'hydrate_design_agent_session') return null; + if (command === 'list_design_workspace') + return imported + ? [ + { path: 'references', kind: 'directory' }, + { path: 'references/需求.md', kind: 'file' }, + ] + : []; + if (command === 'read_design_workspace_file') return '# 导入的需求'; + throw new Error(`Unexpected command: ${command}`); + }); + window.__TAURI__ = { + core: { invoke }, + event: { + listen: vi.fn(async (_name, callback) => { + onUpdate = callback; + return () => undefined; + }), + }, + } as unknown as typeof window.__TAURI__; + render(); + await waitFor(() => expect(onUpdate).toBeDefined()); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('list_design_workspace', { + projectPath: 'imported-project', + }), + ); + imported = true; + act(() => + onUpdate!({ + payload: { projectPath: 'imported-project', kind: 'workspace' }, + }), + ); + fireEvent.click(await screen.findByRole('button', { name: '需求.md' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_design_workspace_file', { + projectPath: 'imported-project', + path: 'references/需求.md', + }), + ); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'hydrate_design_agent_session', + ), + ).toHaveLength(1); +}); + it('prepares debug fixtures from the header and refreshes the phase and files without a manual refresh', async () => { let prepared = false; const invoke = vi.fn(async (command: string) => { diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index 168f1f403..ea48aa7f5 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -157,6 +157,37 @@ async function pushDivergentEqualRevisionSnapshot() { } describe('清单快照被拒收时的用户可见性与恢复', () => { + it('keeps the planning workspace open until reference imports finish', async () => { + const invoke = installInvokeMock(); + const originalInvoke = invoke.getMockImplementation()!; + invoke.mockImplementation(async (command, args) => { + if (command === 'get_design_agent_runtime_mode') + return { activeRuntime: 'design' }; + if (command === 'set_design_agent_runtime_mode') + return { activeRuntime: 'game' }; + return originalInvoke(command, args); + }); + await openProjectThroughLauncher(); + await act(async () => { + captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, true); + await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH); + }); + expect(screen.getByText('文件导入完成后,再做成游戏。')).not.toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => command === 'set_design_agent_runtime_mode', + ), + ).toBe(false); + await act(async () => { + captured.chatProps?.onDesignFilesImportingChange?.(PROJECT_PATH, false); + await captured.chatProps?.onSwitchToGameRuntime?.(PROJECT_PATH); + }); + expect(invoke).toHaveBeenCalledWith('set_design_agent_runtime_mode', { + projectPath: PROJECT_PATH, + activeRuntime: 'game', + }); + }); + /** * 每一轮都重新查一次节点。 * diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 41778637a..1b4c30784 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -29,6 +29,8 @@ - 策划 Agent 复用现有模型/推理档控件,宿主不另加模型检查或自动换模型。用户发起执行时采样全局选择,同轮工具循环和自动重试固定使用回合快照;自动恢复复用该快照,旧记录保留已知模型并补齐一次推理档。只持久化模型和档位,不保存连接凭据;GameAgent 保持原逻辑。详见策划 Agent 生产迁移与工作区浏览方案 §4.1。 +- 策划附件通过独立文件导入命令保存到 `design_artifacts/references/`,同名另存;首页和聊天框只展示导入结果,不登记游戏资产、不修改清单或 revision、不注入回合附件及路径。Agent 通过现有工作区工具自行发现,只有文件时不合成消息。详见策划 Agent 生产迁移与工作区浏览方案 §4.2。 + - AGC 思考与执行入口共用共享单行摘要骨架;Markdown 在展开正文走既有安全渲染,折叠预览使用纯文本。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色。 - Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。 diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index 45c4755bf..5aae2efe7 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -1,6 +1,6 @@ # 策划 Agent 生产迁移与工作区浏览方案 -更新时间:2026-09-20 +更新时间:2026-09-21 状态:已完成(2026-09-18) > 现状说明(2026-09-18):本文记录的迁移已完成,当前策划入口统一使用 Design Agent。旧 Planning V1/V2 会话、专用命令、审批卡和展示适配已删除;文中提到的 V2 文件仅代表迁移时的参考来源,不得作为现行实现、回退路径或测试迁移目标。 @@ -174,6 +174,18 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退 验收至少覆盖:保存后重新进入策划显示一致;旧会话切换模型后实际请求改变;当前执行不被中途改档;下一次发送/澄清/审批继续/主动重试生效;自动恢复不重复工具副作用;GameAgent 原有选择、发送和运行行为不回归。真实 Provider 或桌面环境缺失时明确记为未验证,不用 fixture 冒充实机结果。 +### 4.2 策划参考附件导入 + +- 首页策划入口与策划聊天框将用户附件按原始字节导入 `design_artifacts/references/`,Agent 通过现有工作区文件工具自行发现。有用户文字时,导入完成后才开始首页首轮策划;只有附件时只进入工作区,不合成消息或自动启动策划。聊天框导入中暂缓发送、阶段批准、澄清与重试,并提示等待导入完成再切换游戏制作,避免新回合读取未完成文件或模式切换丢失导入结果。 +- 文件名保留安全的原始名称;同名自动追加序号,禁止覆盖已有文件。沿用项目权限与工作区路径边界,禁止通过文件名、符号链接或目录链接写出工作区。空文件也可导入。 +- 导入只写策划工作区,不登记游戏资产、不更新项目资源清单或游戏 revision,不触发上传后的清单刷新。不组装回合附件、不把文件路径或清单注入 Agent 消息。 +- 每个文件独立返回结果;部分失败不丢弃成功文件,界面显示成功数量及失败文件和原因。用户可重新选择失败文件重试;成功导入不自动重放,再次手动导入同名文件视为新副本。 +- 游戏入口继续使用现有资产上传链路。已有 `assets/uploads/` 文件不自动搬迁;转入游戏制作时沿用现有策划工作区登记行为。 +- 本次不修改 Agent 提示词、工具或消息协议,不增加 PDF、Word 等二进制文档解析;保存成功不代表格式可由现有 UTF-8 文件读取工具解析。 +- 验收覆盖:首页与聊天框导入、多文件部分失败、同名与空文件、工作区工具可列出并读取文本、路径与链接边界、资源清单和 revision 保持不变、游戏上传兼容。证据由定向前端测试、本地 Rust 文件往返测试、类型检查和编码检查提供;真实桌面交互另行记录。 + +验证证据(2026-09-21):`appSurface.test.ts` 中策划导入、导入中审批、首页游戏附件与模型控件定向用例通过;`designWorkspaceDebug.test.tsx`、`designProjectRestore.test.tsx` 与 `workspaceLauncherManifestMerge.test.tsx` 通过,覆盖文件事件刷新、项目恢复和导入中模式切换。Rust `agent::design_tools::tests::` 8 项通过,覆盖文件往返及写入边界。AGC typecheck(含命令注册检查)、定向 ESLint、Rust 格式、编码、文档索引与差异检查通过。未运行安装包桌面点击验证或真实 Provider;Windows 链接用例在宿主不支持建链接时跳过。 + ## 5. Agent Runtime 新的 Runtime 应提供自由工具循环: diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 7874d5169..078a21468 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -461,6 +461,9 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { .to_bytes(); let list: Value = serde_json::from_slice(&list_body).unwrap(); assert_eq!(list["deployments"], json!([])); + wait_for_persisted_status(&state.config, &id, super::DeploymentStatus::Stopped) + .await + .expect("persisted deployment reaches stopped"); let state_file = state.config.state_file.clone(); let mut recovered_config = test_config(state.config.jenkins_root_url.clone()); recovered_config.state_file = state_file.clone(); @@ -659,6 +662,24 @@ async fn wait_for_status( Err(()) } +async fn wait_for_persisted_status( + config: &Config, + id: &str, + expected: super::DeploymentStatus, +) -> Result<(), ()> { + for _ in 0..100 { + if super::load_deployments(config) + .ok() + .and_then(|deployments| deployments.get(id).map(|record| record.public.status)) + .is_some_and(|status| status == expected) + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + Err(()) +} + #[test] fn public_web_url_is_derived_from_instance_id_and_configured_domain() { assert_eq!(