过程卡思考阶段显示正在思考并保留具体执行命令
- preparing 活动词文案由正在理解需求改为正在思考中 - Codex 计划与推理通知只收敛为 thinking 活动,不再把原始推理正文送入 UI - 推理期 thinking 活动按 1.2 秒限流,避免重复事件刷屏 - command-exec 等执行细节不再依赖回复流开关,stream=false 也保留具体命令 - 同一活动的心跳事件不再用通用文案覆盖正在执行的具体命令 - 补充 Rust 纯函数单测与 AppSurface 思考态、命令心跳回归 - 决策记录同步 thinking 与执行细节展示规则
This commit is contained in:
@@ -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,
|
||||
¶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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 是 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`。
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user