diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 330dca949..e3c2c6f9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -334,6 +334,7 @@ fn begin_design_turn(session: &mut DesignSession, id: &str) { pending: true, request_index: 0, attempt: 0, + model_selection: None, }); session.last_error = None; session.updated_at = unix_timestamp(); @@ -444,6 +445,45 @@ fn prepare_design_decision( Ok(approved) } +fn select_design_turn_model( + session: &mut DesignSession, + config: &GameCreatorAppConfig, +) -> Result<(), String> { + let turn = session.turn.as_mut().ok_or("缺少当前回合")?; + session.model_id = if config.selected_model_id.trim().is_empty() { + config.llm.model.clone() + } else { + config.selected_model_id.clone() + }; + turn.model_selection = Some(DesignModelSelection { + model: session.model_id.clone(), + reasoning_effort: config.llm.reasoning_effort.clone(), + }); + Ok(()) +} + +fn resolve_design_turn_llm_config( + session: &mut DesignSession, + config: &GameCreatorAppConfig, +) -> Result { + let turn = session.turn.as_mut().ok_or("缺少当前回合")?; + // 旧活动回合恢复时保留已知模型;缺失的推理档只能从当前配置补齐一次。 + let selection = turn + .model_selection + .get_or_insert_with(|| DesignModelSelection { + model: if session.model_id.trim().is_empty() { + config.llm.model.clone() + } else { + session.model_id.clone() + }, + reasoning_effort: config.llm.reasoning_effort.clone(), + }); + let mut llm = resolve_game_creator_llm_config_for_agent(config, "design-agent"); + llm.model = selection.model.clone(); + llm.reasoning_effort = selection.reasoning_effort.clone(); + Ok(llm) +} + fn checkpoint_design(root: &Path, session: &DesignSession) -> Result<(), String> { let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?; @@ -696,10 +736,7 @@ async fn request_design_provider( return request_scripted_design_provider(root, session, emit).await; } let config = load_game_creator_app_config()?; - let mut llm = resolve_game_creator_llm_config_for_agent(&config, "design-agent"); - if !session.model_id.trim().is_empty() { - llm.model = session.model_id.clone(); - } + let mut llm = resolve_design_turn_llm_config(session, &config)?; // 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。 let max_retries = llm.max_retries; llm.max_retries = 0; @@ -1003,6 +1040,14 @@ async fn finish_design_command( run: bool, mut emit: impl FnMut(DesignEvent) + Send, ) -> Result { + if run + && session + .turn + .as_ref() + .is_some_and(|turn| turn.model_selection.is_none()) + { + resolve_design_turn_llm_config(&mut session, &load_game_creator_app_config()?)?; + } checkpoint_design(root, &session)?; let turn_id = session .turn @@ -1071,15 +1116,15 @@ pub(crate) async fn continue_design_agent_at( .ok_or("策划 Agent 当前正在工作")?; let mut session = match read_design_session(root)? { Some(session) => session, - None => new_design_session( - &project_id, - &load_game_creator_app_config()?.selected_model_id, - ), + None => new_design_session(&project_id, ""), }; if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } let run = prepare_design_input(&mut session, id, input)?; + if run { + select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + } finish_design_command(root, resources, session, active, run, emit).await } @@ -1110,6 +1155,9 @@ pub(crate) async fn decide_design_phase_at( return Err("策划会话与当前项目不匹配".into()); } let run = prepare_design_decision(&mut session, id, request_id, approved)?; + if run { + select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + } finish_design_command(root, resources, session, active, run, emit).await } @@ -1541,6 +1589,223 @@ mod tests { fs::write(root.join("design_artifacts/project/速览卡.md"), "速览").expect("write"); } + #[test] + fn design_turn_selection_survives_config_changes_and_legacy_recovery() { + let mut config = GameCreatorAppConfig::default(); + config.selected_model_id = "chosen-model".into(); + config.llm.reasoning_effort = "max".into(); + config.agent_llm.insert( + "design-agent".into(), + serde_json::from_value(json!({ + "model": "agent-override", "reasoningEffort": "low" + })) + .unwrap(), + ); + let mut session = new_design_session("project", "old-model"); + begin_design_turn(&mut session, "new-turn"); + select_design_turn_model(&mut session, &config).unwrap(); + let saved = serde_json::to_value(&session).unwrap(); + assert_eq!( + saved["turn"]["modelSelection"], + json!({ + "model": "chosen-model", "reasoningEffort": "max" + }) + ); + config.selected_model_id = "later-model".into(); + config.llm.model = "later-model".into(); + config.llm.reasoning_effort = "medium".into(); + let mut restored: DesignSession = serde_json::from_value(saved.clone()).unwrap(); + let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap(); + assert_eq!(llm.model, "chosen-model"); + let request = build_design_request(&restored, &pack(), &llm).unwrap(); + assert_eq!( + request.response_reasoning_effort, + Some(platform_llm::LlmResponseReasoningEffort::Max) + ); + + let mut legacy = saved; + legacy["turn"] + .as_object_mut() + .unwrap() + .remove("modelSelection"); + let mut restored: DesignSession = serde_json::from_value(legacy).unwrap(); + let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap(); + assert_eq!(llm.model, "chosen-model"); + assert_eq!(llm.reasoning_effort, "medium"); + config.llm.reasoning_effort = "low".into(); + assert_eq!( + resolve_design_turn_llm_config(&mut restored, &config) + .unwrap() + .reasoning_effort, + "medium" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_user_actions_apply_selection_and_tool_requests_keep_the_turn_snapshot() { + let (_temp, root, resources) = init_design_project(); + let config_dir = tempfile::tempdir().unwrap(); + let _config_guard = crate::tests::use_test_runtime_config_dir(config_dir.path().into()); + let (sender, receiver) = std::sync::mpsc::channel(); + let tool_response = json!({"id":"tool-response", "status":"completed", "output":[{ + "type":"function_call", "id":"tool-item", "call_id":"status-call", + "name":"get_workflow_status", "arguments":"{}" + }]}); + let final_response = json!({"id":"final-response", "status":"completed", "output":[{ + "type":"message", "id":"reply", "role":"assistant", "status":"completed", + "content":[{"type":"output_text", "text":"完成", "annotations":[]}] + }]}); + let base_url = crate::tests::spawn_mock_llm_raw_responses_with_capture( + vec![ + tool_response, + final_response.clone(), + final_response.clone(), + final_response.clone(), + final_response.clone(), + final_response, + ], + Some(sender), + ); + let save_selection = |model: &str, effort: &str| { + fs::write( + config_dir.path().join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::to_vec(&json!({ + "selectedModelId": model, "selectedModelIsDefault": false, + "llm": {"customEnabled":true, "visibleModels":["model-a","model-b"], + "apiKey":"test-design-key", "baseUrl":base_url, "model":model, + "apiKind":"openai_responses", "reasoningEffort":effort, + "stream":false, "maxRetries":0, "requestTimeoutMs":10000} + })) + .unwrap(), + ) + .unwrap(); + }; + let expect_request = |model: &str, effort: Option<&str>| { + let raw = receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + let body: Value = serde_json::from_str(raw.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body["model"], model); + assert_eq!(body["reasoning"]["effort"].as_str(), effort); + }; + save_selection("model-a", "high"); + let mut changed = false; + let first = continue_design_agent_at( + &root, + &resources, + "first", + DesignInput::Message { + text: "需求".into(), + }, + |event| { + if !changed && event.text.as_deref() == Some("正在请求 Provider…") { + save_selection("model-b", "low"); + changed = true; + } + }, + ) + .await + .unwrap(); + assert!(first.session.last_error.is_none()); + assert!(changed); + expect_request("model-a", Some("high")); + expect_request("model-a", Some("high")); + + let second = continue_design_agent_at( + &root, + &resources, + "second", + DesignInput::Message { + text: "继续".into(), + }, + |_| {}, + ) + .await + .unwrap(); + assert!(second.session.last_error.is_none()); + expect_request("model-b", Some("low")); + let mut session = read_design_session(&root).unwrap().unwrap(); + let session_id = session.session_id.clone(); + session.pending_clarification = Some(DesignClarificationRequest { + request_id: "question".into(), + question: "平台?".into(), + options: vec!["PC".into()], + created_at: 1, + }); + write_design_session(&root, &session).unwrap(); + save_selection("model-a", "medium"); + let answered = continue_design_agent_at( + &root, + &resources, + "answer", + DesignInput::Clarification { + request_id: "question".into(), + option_index: Some(0), + text: None, + }, + |_| {}, + ) + .await + .unwrap(); + assert!(answered.session.last_error.is_none()); + expect_request("model-a", Some("medium")); + + let mut session = read_design_session(&root).unwrap().unwrap(); + session.turn.as_mut().unwrap().pending = true; + session.last_error = Some("provider failure".into()); + write_design_session(&root, &session).unwrap(); + save_selection("model-b", "max"); + let retried = + continue_design_agent_at(&root, &resources, "retry", DesignInput::Retry, |_| {}) + .await + .unwrap(); + assert!(retried.session.last_error.is_none()); + expect_request("model-b", Some("max")); + + let mut session = read_design_session(&root).unwrap().unwrap(); + concept_artifacts(&root); + let approval = submit_design_phase_for_approval(&root, &mut session).unwrap(); + write_design_session(&root, &session).unwrap(); + save_selection("model-a", "default"); + let approved = decide_design_phase_at( + &root, + &resources, + "approve", + &approval.request_id, + true, + |_| {}, + ) + .await + .unwrap(); + assert!(approved.session.last_error.is_none()); + assert_eq!(approved.session.current_phase, "top_design"); + assert_eq!(approved.session.session_id, session_id); + expect_request("model-a", None); + let saved = read_design_session(&root).unwrap().unwrap(); + assert!(saved + .history + .iter() + .any(|item| item["call_id"] == "status-call")); + assert!(!serde_json::to_string(&saved) + .unwrap() + .contains("test-design-key")); + + // 已处理的审批命令不采样新配置,也不重新请求 Provider。 + save_selection("model-b", "low"); + decide_design_phase_at( + &root, + &resources, + "approve", + &approval.request_id, + true, + |_| {}, + ) + .await + .unwrap(); + assert_eq!( + read_design_session(&root).unwrap().unwrap().turn, + saved.turn + ); + } + #[test] fn approval_submission_skips_remaining_tools() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1552,6 +1817,7 @@ mod tests { pending: true, request_index: 0, attempt: 0, + model_selection: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![ @@ -2202,6 +2468,7 @@ mod tests { pending: true, request_index: 0, attempt: 0, + model_selection: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![call], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs index 7d6b8f147..10f26a650 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs @@ -81,7 +81,7 @@ pub(crate) struct DesignSession { pub(crate) engine: String, pub(crate) session_id: String, pub(crate) project_id: String, - /// 入口选择的 AGC 模型目录 ID;同一会话内保持稳定,不保存上游真实模型名。 + /// 最近一次用户执行采用的模型标识;旧活动回合缺快照时也据此恢复。 #[serde(default)] pub(crate) model_id: String, pub(crate) current_phase: String, @@ -124,6 +124,16 @@ pub(crate) struct DesignTurn { pub(crate) pending: bool, pub(crate) request_index: u64, pub(crate) attempt: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model_selection: Option, +} + +/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesignModelSelection { + pub(crate) model: String, + pub(crate) reasoning_effort: String, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index e01062cdc..4035d98a2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -280,7 +280,7 @@ pub(crate) struct TestConfigGuard { previous: Option>, } -struct TestRuntimeConfigDirGuard { +pub(crate) struct TestRuntimeConfigDirGuard { _lock: StdMutexGuard<'static, ()>, previous: Option, } @@ -1325,7 +1325,7 @@ fn test_local_config_defaults_mock_provider_to_non_streaming_and_preserves_expli assert_eq!(defaulted["llm"]["stream"], false); } -fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { +pub(crate) fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { let lock = TEST_CONFIG_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -2477,7 +2477,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction( (base_url, handle) } -fn spawn_mock_llm_raw_responses_with_capture( +pub(crate) fn spawn_mock_llm_raw_responses_with_capture( response_bodies: Vec, request_sender: Option>, ) -> String { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index be5429c8d..72658093c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -267,6 +267,7 @@ export function ProjectSupervisorView({ ...runtimePanelProps }: ProjectSupervisorViewProps) { const designAgentActive = Boolean(designView || onDesignApprove); + const showModelControls = directCodex || designAgentActive; const [settingsOpen, setSettingsOpen] = useState(false); // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 const [voiceNotice, setVoiceNotice] = useState(''); @@ -772,44 +773,46 @@ export function ProjectSupervisorView({ } onChange={onChatInputChange} /> - {directCodex ? ( + {showModelControls ? (
-
- -
+ {directCodex ? ( +
+ +
+ ) : null}
- {/* 推理档放在模型选择器旁边(Codex 的「高」那个位置):写回的是客户端 - 配置,只影响后续回合;当前回合的行为不受影响。 */} + {/* 两种对话共用客户端配置控件;运行时何时采用选择由各自入口负责。 */} - - composerRef?.current?.insertText(text) - } - onNotice={setVoiceNotice} - /> - {submitting && onCancelTurn ? ( + {directCodex ? ( + + composerRef?.current?.insertText(text) + } + onNotice={setVoiceNotice} + /> + ) : null} + {directCodex && submitting && onCancelTurn ? ( , +) { + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + return render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); +} + +async function expectDesignModelReady() { + await waitFor(() => { + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('高质量'); + }); +} + function designApprovalView() { return { session: { @@ -97,6 +123,105 @@ function designHistoryView() { } export function registerDesignAgentSurfaceTests() { + it('persists Design Agent model and reasoning controls and reads them on reentry', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designConversationView(), + }); + const first = renderDesignAgent(harness); + await expectDesignModelReady(); + const reasoning = screen.getByRole('button', { name: '推理档' }); + expect( + reasoning.closest('.project-supervisor-composer-controls'), + ).not.toBeNull(); + fireEvent.click(reasoning); + fireEvent.click(screen.getByRole('option', { name: '低' })); + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith( + 'select_game_creator_reasoning_effort', + { effort: 'low' }, + ); + expect( + screen.getByRole('button', { name: '推理档' }).textContent, + ).toContain('低'); + }); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(await screen.findByRole('option', { name: '快速' })); + await waitFor(() => { + expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', { + modelId: 'fast', + isDefault: false, + }); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'); + }); + first.unmount(); + renderDesignAgent(harness); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '推理档' }).textContent, + ).toContain('低'); + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'); + }); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'continue_design_agent_session', + ), + ).toBe(false); + }); + + it('allows Design Agent model changes while running without submitting a new turn', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: { ...designConversationView(), running: true }, + }); + renderDesignAgent(harness); + await expectDesignModelReady(); + expect( + (screen.getByRole('button', { name: '推理档' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + fireEvent.click(await screen.findByRole('option', { name: '快速' })); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '对话模型' }).textContent, + ).toContain('快速'); + }); + const input = screen.getByLabelText('项目需求'); + expect(input.closest('[data-disabled="true"]')).not.toBeNull(); + const send = screen.getByRole('button', { name: '思考中' }); + expect(send).toHaveProperty('disabled', true); + fireEvent.click(send); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'continue_design_agent_session', + ), + ).toBe(false); + }); + + it('shows Design Agent reasoning save failures and preserves the saved selection', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designConversationView(), + }); + const invoke = harness.invoke.getMockImplementation()!; + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'select_game_creator_reasoning_effort') { + throw new Error('save failed'); + } + return invoke(command, args); + }); + renderDesignAgent(harness); + await expectDesignModelReady(); + const reasoning = screen.getByRole('button', { name: '推理档' }); + await waitFor(() => expect(reasoning.textContent).toContain('高')); + fireEvent.click(reasoning); + fireEvent.click(screen.getByRole('option', { name: '低' })); + await screen.findByText('推理档保存失败'); + expect(reasoning.textContent).toContain('高'); + }); + it('resumes following Design Agent messages when sending after scrolling up', async () => { const harness = createProjectSupervisorRuntimeHarness({ designAgentView: designHistoryView(), diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 3b38bd2fe..25f28895e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -534,6 +534,8 @@ function createProjectSupervisorRuntimeHarness({ ); let messageSequence = 0; let selectedModelId = 'quality'; + let selectedModelIsDefault = false; + let reasoningEffort = 'high'; let steerSequence = 0; let sessionExists = initialSessionExists; let currentProjectRevision = initialProjectRevision; @@ -605,9 +607,11 @@ function createProjectSupervisorRuntimeHarness({ }) => void) | null = null; let designAgentUpdateHandler: - ((event: { payload: Record }) => void) | null = null; + | ((event: { payload: Record }) => void) + | null = null; let directThreadNotifyHandler: - ((event: { payload: { subscriptionId: string } }) => void) | null = null; + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; let directThreadSubscriptionId: string | null = null; let directThreadSubscriptionSequence = 0; // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, @@ -672,22 +676,29 @@ function createProjectSupervisorRuntimeHarness({ async (command: string, args?: Record) => { if ( command === 'read_game_creator_app_config' || - command === 'select_game_creator_model' + command === 'select_game_creator_model' || + command === 'select_game_creator_reasoning_effort' ) { - if (command === 'select_game_creator_model') + if (command === 'select_game_creator_model') { selectedModelId = String(args?.modelId); + selectedModelIsDefault = args?.isDefault === true; + } + if (command === 'select_game_creator_reasoning_effort') { + reasoningEffort = String(args?.effort); + } return { path: '/tmp/test-game-creator-config.json', config: { schemaVersion: 'game-creator-config.v2', agentMode: 'codex_app_server', selectedModelId, + selectedModelIsDefault, llm: { apiKey: '', baseUrl: '', model: 'quality', apiKind: 'openai_responses', - reasoningEffort: 'high', + reasoningEffort, stream: true, webSearchEnabled: true, contextWindowTokens: 128000, diff --git a/docs/project-memory/plans/【实施计划】策划Agent回合模型选择生效-2026-09-20.md b/docs/project-memory/plans/【实施计划】策划Agent回合模型选择生效-2026-09-20.md new file mode 100644 index 000000000..c357c46ad --- /dev/null +++ b/docs/project-memory/plans/【实施计划】策划Agent回合模型选择生效-2026-09-20.md @@ -0,0 +1,45 @@ +# 策划 Agent 回合模型选择生效实施计划 + +| 字段 | 值 | +| --- | --- | +| Milestone | [回合模型选择生效里程碑](./【里程碑】策划Agent回合模型选择生效-2026-09-20.md) | +| Status | implemented(待验收) | +| Owner | 当前任务 Agent | + +## 修改边界与顺序 + +1. 在本地 `DesignTurn` 中追加可缺省的模型/推理档选择快照,仅存两个非敏感字段。现有 UI 只投影会话摘要,无需扩展前端 DTO。 +2. 策划普通发送、澄清回答、用户重试和批准阶段实际开始执行时,从现有配置加载结果获取全局模型和推理档,覆盖上一轮选择;重复命令、拒绝审批和纯读取不重新采样。 +3. Provider 请求使用当前回合快照,工具循环与自动重试期间不随全局选择变化。保留其它连接配置的原解析,策划专属配置不能覆盖这两个用户选择字段。 +4. 自动恢复沿用活动回合快照;旧记录缺快照时保留已有会话模型,并从当前全局配置补推理档,再由原检查点保存。保持工具幂等和审批流程。 +5. 补充既有 Rust 测试,验证请求字段、新旧会话、重试、审批/澄清和恢复;更新主规范与稳定项目记忆。 + +## 非目标 + +不改两个前端控件、GameAgent、模型目录、默认值、供应商适配;不新增模型可用性检查、自动换模型或提交门禁。不保存凭据或完整配置,不引入通用快照框架。 + +## 验证 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_runtime::tests -- --test-threads=1` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_session -- --test-threads=1` +- 改动文件 Rust 格式检查;文档索引、编码检查和 `git diff --check`。 +- 优先本地 HTTP fixture 核对发送给 Provider 的模型与推理档,真实 Provider 未运行时明确记录。 + +## 风险与回滚 + +模型选择快照必须在首个副作用前持久化,配置读取失败不能改变既有持久状态;旧记录自动恢复不推断历史推理档。新增可缺省字段支持现有会话读取,回滚不删除会话或产物。 + +时间盒:先完成最小采样/请求闭环和定向测试,再核对恢复与文档。仅在影响本里程碑判据时扩大范围。 + +## 验证记录 + +| 验证 | 结果 | +| --- | --- | +| 改动文件 Rust 格式检查、diff 检查 | 通过 | +| 文档索引、编码检查 | 通过 | +| Rust 定向测试 | 已尝试编译,未执行测试用例;不记为通过 | +| 本地 HTTP fixture | 已补充真实请求字段断言,尚未执行 | +| 桌面/真实 Provider smoke | 未运行 | +| 独立静态审查 | 已检查采样入口、重放/恢复边界、请求循环和测试辅助函数可见性,无可操作发现 | + +新增用例验证回合快照与旧数据读取,以及普通消息、跨工具请求、澄清回答、用户重试、审批批准、重复审批的配置生效边界。已有恢复用例继续覆盖不确定文件操作不重复执行。控件和 GameAgent 无本步修改。 diff --git a/docs/project-memory/plans/【实施计划】策划Agent模型与推理档控件接入-2026-09-20.md b/docs/project-memory/plans/【实施计划】策划Agent模型与推理档控件接入-2026-09-20.md new file mode 100644 index 000000000..43edb6f30 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】策划Agent模型与推理档控件接入-2026-09-20.md @@ -0,0 +1,49 @@ +# 策划 Agent 模型与推理档控件接入实施计划 + +| 字段 | 值 | +| --- | --- | +| Milestone | [控件接入里程碑](./【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) | +| Status | accepted | +| Owner | 当前任务 Agent | + +## 修改边界 + +- 在 `ProjectSupervisorView.tsx` 扩展既有控件排的适用范围,直接复用 `ConversationModelSelect` 与 `ComposerReasoningEffortSelect`。素材引用、语音、消息队列和停止按钮仍按原 GameAgent 条件显示。 +- 策划发送、澄清、审批和重试回调保持原样,不新增模型校验和提交门禁;GameAgent 的原校验保持不变。 +- 必要时局部调整策划宿主 CSS,避免控件换行或弹层裁切。组件内部逻辑保持不变。 +- 在现有前端测试体系补齐策划集成场景,回归已有 GameAgent 控件用例。 +- 不修改 Rust、模型生效逻辑、配置默认值、Provider 或 GameAgent 组件算法。 + +## 实现顺序 + +1. 核对共享对话容器,接入同一组控件,保留原提交路径。 +2. 补充策划控件读写、保存失败和忙态测试,回归原策划和 GameAgent 行为。 +3. 运行定向测试和类型检查,检查宽窄布局,回写证据与未验证项。 + +## 验证命令 + +- `npm test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`,先按用例名称过滤控件及相关策划场景。 +- 策划宿主集成用例使用现有 Vitest / Testing Library,按实际测试文件定向运行。 +- `npm run ai-game-creator-shell:typecheck` +- `npm run check:doc-index` +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚点 + +- 防止扩大 `directCodex` 条件时带入语音、队列或改变 GameAgent 行为:仅共享控件排,保留其它分支。 +- 模型目录失败通过原选择器反馈,不给策划宿主增加前置检查,也不改造控件已有逻辑。 +- 本次回滚仅撤回前端接入、相关用例和文档,不涉及持久化数据迁移。 +- 时间盒:一个工作时段内先完成接入与定向测试,随后检查类型和布局;仅影响里程碑验收的发现纳入修复。 +- 当前已知限制:策划旧会话仍可能使用创建时模型;本次不将控件读写成功视为运行时已切换模型。 + +## 验收证据 + +| 项目 | 结果 | +| --- | --- | +| 源码边界核对 | 两个控件文件、策划动作回调、GameAgent 提交校验及 Rust 均无修改;仅扩展控件排适用条件与策划布局 | +| 定向 ESLint | `ProjectSupervisorView.tsx`、`design-agent.suite.ts`、`harness.ts` 通过 | + +定向测试命令:`npm test -- apps/ai-game-creator-shell/tests/appSurface.test.ts -t 'Design Agent|design session|clarification free text|reasoning effort control|selecting the model|model dropdown|chat composer an inset|current turn reasoning|historical reasoning'`。 + +用户已授权提交第一步、进入第二步。 diff --git a/docs/project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md b/docs/project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md new file mode 100644 index 000000000..6b43a4417 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md @@ -0,0 +1,48 @@ +# 策划 Agent 回合模型选择生效 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented(待验收) | +| Date | 2026-09-20 | +| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) | + +## 目标 + +策划已有会话在后续执行时真正采用控件保存的模型和推理档,执行途中保持选择稳定,恢复不破坏既有工具副作用边界。 + +## 范围 + +- 新会话、旧会话后续消息、回答澄清、阶段审批继续和用户主动重试的选择生效。 +- 同一次执行中的工具循环、自动重试固定使用开始时的选择。 +- 活动回合自动恢复所需的最小模型、推理档快照及旧会话缺字段读取。 +- 模型切换保留策划上下文、阶段、审批和产物。 + +## 不在范围内 + +- 不改变 GameAgent Runtime 或既有配置控件算法。 +- 不调整模型目录、默认档位、供应商协议、提示词和阶段规则。 +- 不建立 Agent 专属设置、通用快照框架、历史转换器或平行账本。 + +## 依赖与前置条件 + +- [控件接入里程碑](./【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 已验收。 +- 主规范和本里程碑已评审,实施前编写仅覆盖本步的实现计划。 +- 生效时机、主动重试与自动恢复的区分遵循主规范,不通过重建会话实现切换。 + +## 验收标准 + +- [ ] 新策划会话和旧策划会话下一次执行都使用当前已保存选择;旧模型不再永久覆盖新选择。 +- [ ] 发送、澄清、审批继续与主动重试的实际 Provider 请求使用新选择。 +- [ ] 执行中改配置不影响本次后续工具请求或瞬态自动重试,下一次用户执行才生效。 +- [ ] 自动恢复优先使用已有活动回合快照;旧记录缺推理档时按主规范补齐,不重复已执行工具副作用。 +- [ ] 切换后上下文、阶段、产物和审批身份保留,纯读取不改历史模型信息。 +- [ ] 配置/Provider 失败沿用可见错误,不静默降级、换模型或清空历史。 +- [ ] GameAgent 既有选择和运行行为通过兼容回归,没有借机修正其本征不足。 + +## 证据要求 + +- 自动化:本地 Provider fixture 捕获请求模型和推理档;覆盖跨工具调用、自动重试、用户重试、审批/澄清继续及旧会话恢复;运行相关 Rust 定向测试、必要类型检查、编码和文档索引检查。 +- 运行时:在现有策划项目选择另一模型及推理档,触发下一次执行核对实际请求;真实供应商是否接受跨模型历史需按环境记录实测或未验证。 +- 边界:验证恢复幂等和凭据不进入新增快照;无需新增通用权限或供应商测试体系。 +- 全部验收后将主规范提案改为当前行为,更新稳定项目记忆并删除本次已完成临时计划。 diff --git a/docs/project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md b/docs/project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md new file mode 100644 index 000000000..692ae4ba8 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md @@ -0,0 +1,53 @@ +# 策划 Agent 模型与推理档控件接入 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | accepted(用户已授权提交、进入第二步) | +| Date | 2026-09-20 | +| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) | + +## 目标 + +策划对话复用已有模型和推理档控件,能够读取和保存全局选择,保持 GameAgent 兼容性。 + +## 范围 + +- 输入区域中控件的显隐、位置、策划面板布局与必要的宿主接线。 +- 复用原有目录、默认模型、保存和错误提示,不复制逻辑。 +- 策划发送、澄清、审批继续和主动重试保持原流程;不新增模型可用性检查或提交门禁。 +- 重新进入策划时显示保存值;执行中可以调整后续选择,但原输入与提交忙态维持不变。 + +## 不在范围内 + +- 不修改策划 Provider 的模型选择和推理档采样逻辑,不声称旧会话已能切模型。 +- 不检查、修复或重构 GameAgent 组件本征不足,不引入通用缓存、同步、保存、下拉或配置框架改造。 +- 不为策划引入队列、语音或素材引用,不更改默认推理档。 + +## 依赖与前置条件 + +- 主规范本节与本里程碑完成评审。 +- 实施前仅为本里程碑编写实现计划,确认现有 GameAgent 对应回归用例与策划宿主入口。 +- 本步作为内部接入结果;第二步完成前不单独发布为完整可切模型功能。 + +## 验收标准 + +- [ ] 策划入口显示原模型及推理档控件,没有重复实现或第二份设置。 +- [ ] 选择走原保存通道;重新进入显示已保存值;保存失败显示原有错误反馈。 +- [ ] 策划提交和报错流程保持原样,没有新增模型检查;原控件内部行为不改造。 +- [ ] 策划忙态、待审批/澄清行为保持;新增布局在宽/窄面板可操作。 +- [ ] GameAgent 原控件的显示、选择、发送校验、忙态和运行行为通过相关回归。 +- [ ] 交付明确记录旧策划会话仍沿用旧模型,留待下一里程碑解决。 + +## 证据要求 + +- 自动化:现有界面测试中增加策划入口的集成场景,复用 GameAgent 回归;运行 AGC 类型检查、文档索引、编码和 diff 检查。 +- 运行时:桌面与窄面板选择、保存、重新进入和一次策划发送 smoke;缺失环境如实标注。 +- 边界:只验证接入所需的失败和忙态,不扩展为现有组件全量审计。 +- 通过本步验收后,才准备并执行模型生效里程碑的实现计划。 + +## 当前交付 + +已接入原控件及策划布局,按用户最终要求保留策划提交、审批、澄清、重试与报错流程,没有新增模型检查。原控件与 GameAgent 的提交逻辑未修改;旧策划会话固定模型的限制仍由第二步解决。 + +新增三条控件集成用例(保存重进、运行中选择、保存失败反馈)。用户已授权提交第一步、进入第二步。 diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 24df4716a..db96020cc 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -22,6 +22,8 @@ - 策划 Agent 的顾问态由用户指示驱动,不自主推进项目、主动安排下一步或提交阶段审批;完成单次请求不结束顾问态。五个策划阶段的审批用于检阅已完成产物,关键选择先问询;过程文档按需记录且不重复正式正文。顶层设计按需保留易混淆方向及排除理由,提示词精简应保留这些行为与设计边界。详见策划 Agent 生产迁移与工作区浏览方案。 +- 策划 Agent 复用现有模型/推理档控件,宿主不另加模型检查或自动换模型。用户发起执行时采样全局选择,同轮工具循环和自动重试固定使用回合快照;自动恢复复用该快照,旧记录保留已知模型并补齐一次推理档。只持久化模型和档位,不保存连接凭据;GameAgent 保持原逻辑。详见策划 Agent 生产迁移与工作区浏览方案 §4.1。 + - 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 c2801edee..45c4755bf 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -116,7 +116,63 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退 会话/回合/工具调用身份用于生产恢复和重复请求处理,与 Agent 自行写在策划文档头部的版本号无关。 -策划会话在创建时保存入口选择的 AGC 模型目录 ID(例如 `quality`、`fast`),同一会话后续回合沿用该 ID;客户端不保存或推断上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。 +策划每次用户发起执行时采用客户端当前保存的模型和推理档,保存在当前回合中;同一执行内的工具调用与自动重试保持该选择,后续用户执行重新读取。官方模式保存 AGC 模型目录 ID(例如 `quality`、`fast`),自定义模式保存已选择的型号;客户端不推断官方上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。 + +### 4.1 策划对话模型与推理档选择 + +**交付结果**:策划 Agent 对话复用 GameAgent 的模型、推理档选择控件及配置通道,用户的新选择对后续策划回合实际生效,并保持 GameAgent 原有行为兼容。 + +本节是本次变更的唯一主规范。控件接入已完成并提交,运行时模型生效逻辑已实现、待验收。拆分与验收见 [控件接入里程碑](../project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 和 [模型生效里程碑](../project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md)。 + +#### 范围与非目标 + +- 必须项:复用已有控件及客户端配置读写;补齐策划入口;修正旧策划会话固定使用创建时模型的行为;验证 GameAgent 兼容性。 +- 风险项:界面选择与实际请求不一致、执行中途切换配置、恢复旧会话,以及策划窄面板新增控件的布局。 +- 可选项:无。发现与上述验收无关的问题,只记录发现,不扩展本次实现。 +- 明确不做:不评估或重构 GameAgent 已有控件的本征不足,不重做模型目录、缓存、配置同步、下拉交互、通用保存队列或错误处理;不调整 GameAgent Runtime、推理默认值、供应商适配、提示词或阶段审批规则;不新建 Agent 专属设置、模型管理页、配置框架或测试框架。 +- 优先在现有共享对话容器中复用同一组组件,不复制控件源码,不因新增一个使用方迁移整套配置型组件。若需要局部接口扩展,默认调用必须保持 GameAgent 现有行为。 + +#### 控件和配置合同 + +1. 策划对话输入框附近显示与 GameAgent 相同的模型和推理档控件,读取、选项、保存、错误反馈沿用已有能力;只调整策划宿主必要的显隐和布局。按用户确认,控件保持原样,不给策划发送、审批、澄清或重试新增模型可用性检查,也不以模型目录状态新增提交门禁;GameAgent 已有提交逻辑保持原样。 +2. 继续使用客户端全局模型选择与全局推理档,不新增第二份策划设置。它们是客户端偏好,可能影响其它后续对话;进入或重开策划界面时显示当前保存值。既有 GameAgent 配置解析保持不变,不承诺新增跨窗口实时同步。 +3. 策划运行时最终以选择器保存的全局模型和推理档作为本次用户选择;策划专属底层配置不得在这两个字段上静默覆盖选择。连接、鉴权、超时等其余字段继续按既有生产配置解析,不清理或改写用户其它配置。 +4. 模型的官方目录别名、自定义模型目录、默认项跟随及失效项处理复用已有能力;不新增供应商能力探测或模型自动降级。推理档的枚举与默认值维持现状。 +5. 可以在执行中修改后续选择;不会中断或重启正在执行的策划回合。策划保留原有输入、发送、审批和澄清忙态规则,不引入 GameAgent 的消息队列、素材引用或语音功能。 + +#### 生效边界 + +| 触发 | 目标行为 | +| --- | --- | +| 首次发送、后续普通发送 | 读取最新已保存的选择并开始新回合,不新增提交前模型检查 | +| 回答澄清、批准/拒绝阶段后实际继续调用 Provider | 同样使用本次继续前保存的选择;不更改审批结果和请求身份语义 | +| 用户主动点击失败重试 | 使用最新选择开始本次执行;继续现有工具恢复规则,不重复已完成副作用 | +| 同一执行内的工具循环、HTTP/流式瞬态自动重试 | 始终使用该次执行开始时确定的模型和推理档,不在每次 Provider 调用前重新采样这两个字段 | +| 纯读取、展示历史、未触发 Provider 的操作 | 不创建新回合,不覆盖历史使用的模型信息 | +| 进程中断后的自动续跑 | 优先沿用持久化的活动回合模型和推理档;不把它当成用户重新选模型后的新回合,不重复文件副作用 | + +“后续回合生效”按上表定义,不以底层函数是否创建新 turn ID 为判断依据。无需重建会话或清空历史才能切模型,正式策划阶段、产物和上下文继续保留。 + +#### 失败、兼容与数据约束 + +- 控件保存失败和目录不可用沿用控件现有提示;策划发送、审批、澄清、重试和 Provider 报错流程保持原样。原控件内部的目录同步与默认模型处理不在本次改造范围,也不在策划宿主另加同类逻辑。 +- Provider 不接受所选模型或历史上下文时,沿用现有可见错误和重试;不静默换模型、清历史或另建跨模型上下文转换系统。 +- 已有设计会话无需离线迁移:下次用户发起执行时采用新选择。自动恢复的旧活动回合优先保留已有模型;若尚无推理档快照,使用当次有效配置补齐一次并固定,不伪称还原了历史档位。 +- 持久化在当前回合追加可缺省的 `modelSelection`,只含 `model` 和 `reasoningEffort`;已有会话 `modelId` 表示最近一次用户执行采用的模型,兼作旧活动回合恢复依据。旧记录缺字段可读;不保存完整配置、端点凭据、Token 或 API Key。不增加平行会话账本,不改模型目录 ID 与真实型号的边界。 +- 本次不涉及公开 HTTP API、OpenAPI 或 SpacetimeDB schema。若本地设计会话投影需要新增字段,同步其现有 Rust/TypeScript 定义和恢复用例。 + +#### 两步交付与验收 + +| 步骤 | 交付边界 | 完成证据 | +| --- | --- | --- | +| 第一步:控件接入 | 策划宿主显示并使用原控件,选择写入现有全局配置;保留策划原提交流程;不改策划请求的模型解析与快照逻辑 | 策划控件读写、忙态与失败提示定向测试;GameAgent 相关现有用例;宽/窄面板 smoke | +| 第二步:模型生效 | 在策划执行边界采样并固定模型与推理档,旧会话下次执行采用新选择,自动恢复保留活动回合选择 | 本地 Provider fixture 验证真实请求字段、跨工具调用一致性、重试与恢复;界面到请求 smoke;GameAgent 兼容回归 | + +第一步是内部可验收的接入结果,不能宣称“策划旧会话切模型已生效”,也不能作为完整功能单独发布。第二步验收前保持这一已知限制明确。 + +两个步骤分别形成最小闭环;检查点分别为“控件与兼容验收”和“请求与恢复验收”。实现中的新增发现只有影响本节交付判据时才扩大范围。每步先用一个工作时段完成定向实现与验证;超过时段仍未闭环时,说明剩余阻碍并重估,不以顺手改造组件扩大任务。 + +验收至少覆盖:保存后重新进入策划显示一致;旧会话切换模型后实际请求改变;当前执行不被中途改档;下一次发送/澄清/审批继续/主动重试生效;自动恢复不重复工具副作用;GameAgent 原有选择、发送和运行行为不回归。真实 Provider 或桌面环境缺失时明确记为未验证,不用 fixture 冒充实机结果。 ## 5. Agent Runtime