过程卡思考阶段显示正在思考并保留具体执行命令
Project CI / Native shell tests (pull_request) Failing after 12m28s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m31s
Project CI / Backend tests (pull_request) Failing after 9s

- preparing 活动词文案由正在理解需求改为正在思考中
- Codex 计划与推理通知只收敛为 thinking 活动,不再把原始推理正文送入 UI
- 推理期 thinking 活动按 1.2 秒限流,避免重复事件刷屏
- command-exec 等执行细节不再依赖回复流开关,stream=false 也保留具体命令
- 同一活动的心跳事件不再用通用文案覆盖正在执行的具体命令
- 补充 Rust 纯函数单测与 AppSurface 思考态、命令心跳回归
- 决策记录同步 thinking 与执行细节展示规则
This commit is contained in:
2026-09-02 18:24:14 +08:00
parent c668964d2f
commit 5008691975
5 changed files with 184 additions and 29 deletions
@@ -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<String>,
safe_activity: Option<&'static str>,
) -> Option<CodexTurnEvent> {
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,
&params,
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(),
@@ -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_filegame/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());
+23 -2
View File
@@ -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<string | null>(null);
const recoveredDirectCodexTurnClaimsRef = useRef(new Set<string>());
const directCodexClaimReleaseOnConversationWriteFailureRef = useRef(
new Map<string, string>(),
@@ -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) {
@@ -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:先完成正式客户端玩法拆解');
@@ -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 是 streamingAppSurface 覆盖接受态、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`
---