From 5008691975c88a922b6d9892553886465bf0ca36 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 18:24:14 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=87=E7=A8=8B=E5=8D=A1=E6=80=9D=E8=80=83?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E6=98=BE=E7=A4=BA=E6=AD=A3=E5=9C=A8=E6=80=9D?= =?UTF-8?q?=E8=80=83=E5=B9=B6=E4=BF=9D=E7=95=99=E5=85=B7=E4=BD=93=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preparing 活动词文案由正在理解需求改为正在思考中 - Codex 计划与推理通知只收敛为 thinking 活动,不再把原始推理正文送入 UI - 推理期 thinking 活动按 1.2 秒限流,避免重复事件刷屏 - command-exec 等执行细节不再依赖回复流开关,stream=false 也保留具体命令 - 同一活动的心跳事件不再用通用文案覆盖正在执行的具体命令 - 补充 Rust 纯函数单测与 AppSurface 思考态、命令心跳回归 - 决策记录同步 thinking 与执行细节展示规则 --- .../src-tauri/src/agent/codex_app_server.rs | 106 ++++++++++++++---- .../src-tauri/src/agent/direct_runtime.rs | 32 +++++- apps/ai-game-creator-shell/src/App.tsx | 25 ++++- .../appSurface/project-development.suite.ts | 46 ++++++++ .../shared-memory/decision-log.md | 4 +- 5 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index de736c993..00066ccfd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -31,6 +31,8 @@ const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000; const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); +const DIRECT_CODEX_PREPARING_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(1200); const DIRECT_CODEX_INTERMEDIATE_TEXT_MAX_CHARS: usize = 240; const DIRECT_CODEX_INTERMEDIATE_TEXT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120); @@ -699,9 +701,13 @@ fn should_emit_direct_codex_activity( activity: &'static str, ) -> bool { let now = std::time::Instant::now(); + let min_interval = if activity == "preparing" { + DIRECT_CODEX_PREPARING_ACTIVITY_EMIT_MIN_INTERVAL + } else { + DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + }; if last_activity.is_some_and(|(previous, observed_at)| { - previous == activity - && now.saturating_duration_since(observed_at) < DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + previous == activity && now.saturating_duration_since(observed_at) < min_interval }) { return false; } @@ -709,6 +715,36 @@ fn should_emit_direct_codex_activity( true } +fn direct_codex_notification_event( + method: &str, + params: &serde_json::Value, + intermediate_text: Option, + safe_activity: Option<&'static str>, +) -> Option { + let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { + (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), + _ => (safe_activity, intermediate_text), + }; + if let Some(text) = intermediate_text { + return Some(CodexTurnEvent::IntermediateText(text)); + } + if let Some(activity) = activity { + return Some(CodexTurnEvent::Activity(activity)); + } + match method { + "item/agentMessage/delta" => params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())), + "item/started" | "item/completed" => Some(CodexTurnEvent::Item { + completed: method == "item/completed", + params: params.clone(), + }), + _ => Some(CodexTurnEvent::Terminal(params.clone())), + } +} + fn is_terminal_client_mcp_startup_status(status: Option<&str>) -> bool { matches!(status, Some("ready") | Some("failed") | Some("cancelled")) } @@ -3019,28 +3055,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else if let Some(activity) = safe_activity { - CodexTurnEvent::Activity(activity) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - CodexTurnEvent::AgentMessageDelta(delta.to_string()) - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - _ => CodexTurnEvent::Terminal(params), - } + let event = match direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + ) { + Some(event) => event, + None => continue, }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -3594,6 +3616,42 @@ mod tests { assert_eq!(direct_codex_item_intermediate_text(&reasoning), None); } + #[test] + fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { + let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); + assert!(matches!( + direct_codex_notification_event( + "item/reasoning/textDelta", + &reasoning, + Some("hidden reasoning must not leak".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::Activity("preparing")) + )); + + let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); + assert!(matches!( + direct_codex_notification_event( + "turn/plan/updated", + &plan, + Some("private plan text must not leak".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::Activity("preparing")) + )); + + let command_output = serde_json::json!({ "delta": "Bearer secret-command-output" }); + assert!(matches!( + direct_codex_notification_event( + "item/commandExecution/outputDelta", + &command_output, + None, + Some("command-exec"), + ), + Some(CodexTurnEvent::Activity("command-exec")) + )); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index a1df967a3..c8f3f2063 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3696,6 +3696,17 @@ fn project_direct_codex_accumulated_text( project_direct_codex_visible_text(accumulated_text) } +fn is_direct_codex_item_started_work_detail(value: &str) -> bool { + const PREFIXES: [&str; 5] = [ + "正在执行:", + "正在调用", + "正在修改:", + "正在联网搜索", + "正在整理上下文", + ]; + PREFIXES.iter().any(|prefix| value.starts_with(prefix)) +} + /// Resolve the UI lifecycle status for one DirectProject observation. Only a /// real agent-message delta is `streaming`; plan, reasoning, tool output, and /// item activity remain `running` because they describe work rather than the @@ -3925,7 +3936,9 @@ async fn run_direct_game_creator_turn_inner( emitter.emit(status, None, visible_text); } DirectCodexTurnObservation::IntermediateText(intermediate_text) => { - let visible_text = if stream_enabled { + let visible_text = if stream_enabled + || is_direct_codex_item_started_work_detail(&intermediate_text) + { project_direct_codex_visible_text(&intermediate_text) } else { None @@ -4680,6 +4693,23 @@ mod tests { ); } + #[test] + fn direct_item_started_work_detail_survives_stream_disabled() { + assert!(is_direct_codex_item_started_work_detail( + "正在执行:npm run build" + )); + assert!(is_direct_codex_item_started_work_detail( + "正在调用 agc_read_file:game/index.html" + )); + assert!(is_direct_codex_item_started_work_detail( + "正在修改:game/player.gd" + )); + assert!(!is_direct_codex_item_started_work_detail("阶段性回复")); + assert!(!is_direct_codex_item_started_work_detail( + "hidden reasoning must not leak" + )); + } + #[test] fn direct_observation_status_separates_reply_stream_from_work_activity() { let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string()); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 264e9692f..04f33762a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -285,7 +285,7 @@ function directCodexActivityDetail( case 'request-accepted': return '正在等待陶泥儿开始'; case 'preparing': - return '正在理解需求'; + return '正在思考中'; case 'file-read': return '正在读取文件'; case 'file-write': @@ -347,6 +347,10 @@ function directCodexProcessDetail({ return directCodexActivityDetail(activity, status); } +function isDirectCodexSpecificWorkDetail(text: string) { + return /^(?:正在执行:|正在调用|正在修改:)/u.test(text); +} + function directCodexTransientReplyText({ accumulatedText, status, @@ -633,6 +637,7 @@ export function App({ lastSequence: number; receivedDirectUpdate: boolean; } | null>(null); + const lastDirectCodexActivityRef = useRef(null); const recoveredDirectCodexTurnClaimsRef = useRef(new Set()); const directCodexClaimReleaseOnConversationWriteFailureRef = useRef( new Map(), @@ -659,6 +664,7 @@ export function App({ function resetDirectCodexTurn() { activeDirectCodexTurnRef.current = null; + lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); setDirectCodexStatus(null); setDirectCodexProcessKey(''); @@ -1316,6 +1322,7 @@ export function App({ const processDetail = directCodexProcessDetail(payload); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; + lastDirectCodexActivityRef.current = null; setDirectCodexStatus(payload.status); setDirectCodexProgress(processDetail); setDirectCodexProgressUpdatedAt(updatedAt); @@ -1324,7 +1331,21 @@ export function App({ return; } setDirectCodexStatus(payload.status); - setDirectCodexProgress(processDetail); + const genericActivity = payload.activity ?? null; + const previousActivity = lastDirectCodexActivityRef.current; + setDirectCodexProgress((current) => { + const heartbeatWouldDowngrade = + genericActivity !== null && + !payload.accumulatedText?.trim() && + payload.status === 'running' && + previousActivity === genericActivity && + current !== processDetail && + isDirectCodexSpecificWorkDetail(current); + return heartbeatWouldDowngrade ? current : processDetail; + }); + if (genericActivity !== null) { + lastDirectCodexActivityRef.current = genericActivity; + } setDirectCodexProgressUpdatedAt(updatedAt); const transientReply = directCodexTransientReplyText(payload); if (transientReply !== null) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 7eaf924ca..f890d531b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5626,6 +5626,25 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 1000, }, }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 1, + status: 'running', + activity: 'preparing', + updatedAt: 1200, + }, + }); + }); + const thinkingProcessCard = + within(directMessageList).getByLabelText('陶泥儿执行过程'); + expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); + expect( + within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') + .textContent, + ).toBe('正在思考中'); + await act(async () => { directTurnUpdateHandler?.({ payload: { projectPath, @@ -5728,6 +5747,33 @@ export function registerProjectSupervisorSurfaceTests() { expect( within(streamingProcessCard).getByText(/正在执行 npm test/u), ).not.toBeNull(); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 5, + status: 'running', + activity: 'command-exec', + accumulatedText: `正在执行:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, + updatedAt: 5000, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 6, + status: 'running', + activity: 'command-exec', + updatedAt: 5100, + }, + }); + }); + expect( + within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), + ).not.toBeNull(); + expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); expect( within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 13e60d322..c894edf5b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 -- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 +- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节(例如“正在执行:<命令>”)与回复流解耦,`stream=false` 时仍展示在过程卡;同一 command-exec 后续无正文的活动心跳不得用通用“正在执行命令”覆盖已展示的具体命令。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。 -- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming;AppSurface 覆盖接受态、running 长文本展开、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 +- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 心跳不覆盖具体命令、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 ---