Direct 过程卡改为按回合阶段状态驱动并移除合成打字机
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Failing after 13m20s

- DirectProject observer 只把真实回复增量标记 streaming,工具中间文本与活动一律 running
- 删除最终回复的合成打字机回放,改由真实事件驱动流式展示
- App 事件投影新增回合状态与 processKey,小字统一补充正在前缀
- 过程卡标题只由回合状态决定,展开状态在同一回合内保持
- 失败与接受态文案改为当前阶段描述,旧直接活动词映射移除
- AppSurface 回归覆盖接受态、展开保持、流式标题与失败态断言
- 决策记录与 Direct 审计账本同步新的状态口径
This commit is contained in:
2026-09-02 16:49:42 +08:00
parent bbf9500bea
commit 8f7017f6c0
6 changed files with 298 additions and 139 deletions
@@ -3696,6 +3696,20 @@ fn project_direct_codex_accumulated_text(
project_direct_codex_visible_text(accumulated_text)
}
/// 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
/// user-visible reply body.
fn direct_codex_observation_status(
observation: &DirectCodexTurnObservation,
stream_enabled: bool,
) -> &'static str {
match observation {
DirectCodexTurnObservation::AccumulatedText(_) if stream_enabled => "streaming",
_ => "running",
}
}
pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result<String, String> {
let controlled_web_search =
load_game_creator_app_config().map(|config| config.llm.web_search_enabled)?;
@@ -3897,42 +3911,32 @@ async fn run_direct_game_creator_turn_inner(
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let live_streamed_text = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reply = if let Some(emitter) = turn_emitter {
let emitter = emitter.clone();
let live_streamed_text_for_observer = std::sync::Arc::clone(&live_streamed_text);
let mut has_streamed = false;
let mut latest_accumulated_text = None;
let mut observer = move |observation: DirectCodexTurnObservation| match observation {
DirectCodexTurnObservation::AccumulatedText(accumulated_text) => {
let visible_text =
project_direct_codex_accumulated_text(stream_enabled, &accumulated_text);
if visible_text.is_none() {
return;
let mut observer = move |observation: DirectCodexTurnObservation| {
let status = direct_codex_observation_status(&observation, stream_enabled);
match observation {
DirectCodexTurnObservation::AccumulatedText(accumulated_text) => {
let visible_text =
project_direct_codex_accumulated_text(stream_enabled, &accumulated_text);
if visible_text.is_none() {
return;
}
emitter.emit(status, None, visible_text);
}
has_streamed = true;
live_streamed_text_for_observer.store(true, std::sync::atomic::Ordering::Relaxed);
latest_accumulated_text = visible_text.clone();
emitter.emit("streaming", None, visible_text);
}
DirectCodexTurnObservation::IntermediateText(intermediate_text) => {
let visible_text = if stream_enabled {
project_direct_codex_visible_text(&intermediate_text)
} else {
None
};
if let Some(visible_text) = visible_text {
has_streamed = true;
latest_accumulated_text = Some(visible_text.clone());
emitter.emit("streaming", None, Some(visible_text));
DirectCodexTurnObservation::IntermediateText(intermediate_text) => {
let visible_text = if stream_enabled {
project_direct_codex_visible_text(&intermediate_text)
} else {
None
};
if let Some(visible_text) = visible_text {
emitter.emit(status, None, Some(visible_text));
}
}
DirectCodexTurnObservation::Activity(activity) => {
emitter.emit(status, Some(activity), None);
}
}
DirectCodexTurnObservation::Activity(activity) => {
emitter.emit(
if has_streamed { "streaming" } else { "running" },
Some(activity),
latest_accumulated_text.clone(),
);
}
};
direct_game_creator_codex_chat_at_with_optional_observer(
@@ -3961,31 +3965,6 @@ async fn run_direct_game_creator_turn_inner(
)
})?;
if let Some(emitter) = turn_emitter {
// The Router/Responses upstream frequently buffers the whole agent
// reply and only delivers it with the terminal item, so real
// agentMessage deltas never arrive while tools run. When no live
// delta reached the UI, replay the final reply as a bounded typewriter
// stream so the chat shows progressive text instead of one jump from
// activity status to the completed message.
if stream_enabled
&& !live_streamed_text.load(std::sync::atomic::Ordering::Relaxed)
&& !visible_reply.is_empty()
{
const TYPEWRITER_CHUNK_CHARS: usize = 24;
const TYPEWRITER_CHUNK_DELAY_MS: u64 = 40;
let text = visible_reply.as_str();
let mut offset = 0usize;
while offset < text.len() {
let mut end = (offset + TYPEWRITER_CHUNK_CHARS).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end += 1;
}
emitter.emit("streaming", None, Some(text[..end].trim_end().to_string()));
offset = end;
tokio::time::sleep(std::time::Duration::from_millis(TYPEWRITER_CHUNK_DELAY_MS))
.await;
}
}
emitter.emit(
"finalizing",
Some("response-finalization"),
@@ -4701,6 +4680,27 @@ mod tests {
);
}
#[test]
fn direct_observation_status_separates_reply_stream_from_work_activity() {
let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string());
let intermediate = DirectCodexTurnObservation::IntermediateText("正在调用工具".to_string());
let activity = DirectCodexTurnObservation::Activity("command-exec");
assert_eq!(
direct_codex_observation_status(&accumulated, true),
"streaming"
);
assert_eq!(
direct_codex_observation_status(&accumulated, false),
"running"
);
assert_eq!(
direct_codex_observation_status(&intermediate, true),
"running"
);
assert_eq!(direct_codex_observation_status(&activity, true), "running");
}
#[test]
fn direct_creation_type_is_a_bounded_structured_hint_not_user_prompt_text() {
for (creation_type, label) in [("game", "做游戏"), ("art", "做素材"), ("doc", "做方案")]
+121 -46
View File
@@ -254,34 +254,6 @@ const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
'direct-codex-turn-already-running:';
function directCodexActivityText(activity: string | null | undefined) {
switch (activity) {
case 'request-accepted':
return '已接收需求';
case 'preparing':
return '正在准备';
case 'file-read':
return '读取文件';
case 'file-write':
return '写入文件';
case 'game-verify':
return '验证游戏';
case 'command-exec':
return '执行命令';
case 'controlled-tool':
return '执行工具';
case 'web-search':
return '搜索资料';
case 'context-compaction':
return '整理上下文';
case 'response-finalization':
return '整理回复';
case 'none':
default:
return '陶泥儿正在处理';
}
}
const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
'accepted',
'running',
@@ -291,6 +263,94 @@ const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([
'failed',
]);
function ensureDirectProcessPrefix(text: string) {
const trimmed = text.trim();
if (!trimmed) {
return '';
}
if (trimmed.startsWith('正在')) {
return trimmed;
}
if (/^(?:||||||||)/u.test(trimmed)) {
return `正在${trimmed}`;
}
return `正在处理:${trimmed}`;
}
function directCodexActivityDetail(
activity: string | null | undefined,
status: string | null | undefined,
) {
switch (activity) {
case 'request-accepted':
return '正在等待陶泥儿开始';
case 'preparing':
return '正在理解需求';
case 'file-read':
return '正在读取文件';
case 'file-write':
return status === 'finalizing' ? '正在同步项目文件' : '正在写入文件';
case 'game-verify':
return '正在验证游戏';
case 'command-exec':
return '正在执行命令';
case 'controlled-tool':
return '正在调用工具';
case 'web-search':
return '正在搜索资料';
case 'context-compaction':
return '正在整理上下文';
case 'response-finalization':
return '正在整理回复';
case 'none':
default:
switch (status) {
case 'accepted':
return '正在等待陶泥儿开始';
case 'finalizing':
return '正在整理结果';
case 'completed':
return '正在提交回复';
case 'failed':
return '正在记录失败原因';
default:
return '正在处理任务';
}
}
}
function directCodexProcessDetail({
accumulatedText,
activity,
status,
}: {
accumulatedText?: string | null;
activity?: string | null;
status: string;
}) {
if (status === 'completed') {
return '正在提交回复';
}
if (status === 'failed') {
return '正在记录失败原因';
}
if (status === 'streaming') {
const text = accumulatedText?.trim();
if (text) {
return `正在生成回复:${text}`;
}
return directCodexActivityDetail(activity, status);
}
if (status === 'finalizing') {
return directCodexActivityDetail(activity, status);
}
const text = accumulatedText?.trim();
if (text) {
return ensureDirectProcessPrefix(text);
}
return directCodexActivityDetail(activity, status);
}
function directCodexConversationMessageId(
turnId: string,
role: ChatMessage['role'],
@@ -541,6 +601,10 @@ export function App({
);
const [chatAgentBusy, setChatAgentBusy] = useState(false);
const [directCodexProgress, setDirectCodexProgress] = useState('');
const [directCodexStatus, setDirectCodexStatus] = useState<
GameCreatorDirectTurnUpdateEvent['status'] | null
>(null);
const [directCodexProcessKey, setDirectCodexProcessKey] = useState('');
const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] =
useState<number | null>(null);
const [directCodexTransientReply, setDirectCodexTransientReply] =
@@ -582,6 +646,8 @@ export function App({
function resetDirectCodexTurn() {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('');
setDirectCodexStatus(null);
setDirectCodexProcessKey('');
setDirectCodexProgressUpdatedAt(null);
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
@@ -1233,25 +1299,21 @@ export function App({
Number.isFinite(payload.updatedAt) && payload.updatedAt > 0
? payload.updatedAt
: Date.now();
const processDetail = directCodexProcessDetail(payload);
if (payload.status === 'failed') {
activeDirectCodexTurnRef.current = null;
setDirectCodexProgress('处理失败,正在同步错误');
setDirectCodexStatus(payload.status);
setDirectCodexProgress(processDetail);
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReply(processDetail);
setDirectCodexTransientReplyUpdatedAt(null);
return;
}
if (payload.status === 'completed') {
setDirectCodexProgress('回复已生成,正在提交');
setDirectCodexProgressUpdatedAt(updatedAt);
} else if (payload.activity != null) {
setDirectCodexProgress(directCodexActivityText(payload.activity));
setDirectCodexProgressUpdatedAt(updatedAt);
}
if (typeof payload.accumulatedText === 'string') {
setDirectCodexTransientReply(payload.accumulatedText);
setDirectCodexTransientReplyUpdatedAt(updatedAt);
}
setDirectCodexStatus(payload.status);
setDirectCodexProgress(processDetail);
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply(processDetail);
setDirectCodexTransientReplyUpdatedAt(updatedAt);
},
)
.then((unlisten) => {
@@ -1293,7 +1355,10 @@ export function App({
) {
return;
}
setDirectCodexProgress(event.payload.message);
const progressDetail = ensureDirectProcessPrefix(event.payload.message);
setDirectCodexStatus('running');
setDirectCodexProgress(progressDetail);
setDirectCodexTransientReply(progressDetail);
setDirectCodexProgressUpdatedAt(Date.now());
return;
}
@@ -5469,9 +5534,11 @@ export function App({
receivedDirectUpdate: false,
};
setChatAgentBusy(true);
setDirectCodexProgress('已发送消息,正在等待陶泥儿回复');
setDirectCodexStatus('accepted');
setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`);
setDirectCodexProgress('正在等待陶泥儿开始');
setDirectCodexTransientReply('正在等待陶泥儿开始');
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
try {
@@ -5540,7 +5607,9 @@ export function App({
setMessages((current) =>
appendDirectAssistantMessage(current, reply),
);
setDirectCodexProgress('正在刷新项目状态');
setDirectCodexStatus('finalizing');
setDirectCodexProgress('正在同步项目文件');
setDirectCodexTransientReply('正在同步项目文件');
setDirectCodexProgressUpdatedAt(Date.now());
await refreshDirectProjectManifest(directProjectPath);
}
@@ -5589,6 +5658,8 @@ export function App({
}
if (localProjectPathRef.current === directProjectPath) {
clearDirectCodexTransientReply(directProjectPath, clientTurnId);
setDirectCodexStatus('failed');
setDirectCodexProgress('正在记录失败原因');
setProjectSupervisorRuntimeError(visibleMessage);
setMessages((current) =>
appendDirectAssistantMessage(current, visibleMessage),
@@ -10900,7 +10971,11 @@ export function App({
<ProjectSupervisorView
chatInput={chatInput}
directCodex={directCodexProductRuntime}
directActivity={directCodexProductRuntime ? directCodexProgress : ''}
directStatus={directCodexProductRuntime ? directCodexStatus : null}
directProcessDetail={
directCodexProductRuntime ? directCodexProgress : ''
}
directProcessKey={directCodexProcessKey}
hiddenConversationCount={hiddenConversationCount}
messagesRef={supervisorChatMessagesRef}
needsUserInput={
@@ -12,6 +12,7 @@ import { useEffect, useState } from 'react';
import type {
AgentStatusCard,
ChatMessage,
GameCreatorDirectTurnUpdateStatus,
PendingCommand,
PendingUiConfirmation,
PlanGddDecisionAction,
@@ -37,10 +38,31 @@ import { resolvePendingCommandProjectPath } from './projectCommandPolicy';
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
function directStatusTitle(status: string | null | undefined) {
switch (status) {
case 'accepted':
return '需求已接收';
case 'running':
return '任务执行中';
case 'streaming':
return '回复生成中';
case 'finalizing':
return '结果整理中';
case 'completed':
return '回复已生成';
case 'failed':
return '处理失败';
default:
return '任务执行中';
}
}
type ProjectSupervisorViewProps = RuntimePanelProps & {
chatInput: string;
directCodex?: boolean;
directActivity?: string;
directStatus?: GameCreatorDirectTurnUpdateStatus | null;
directProcessDetail?: string;
directProcessKey?: string;
hiddenConversationCount: number;
messagesRef: RefObject<HTMLDivElement | null>;
needsUserInput: boolean;
@@ -75,7 +97,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
export function ProjectSupervisorView({
chatInput,
directCodex = false,
directActivity = '',
directStatus = null,
directProcessDetail = '',
directProcessKey = '',
hiddenConversationCount,
messagesRef,
needsUserInput,
@@ -104,10 +128,14 @@ export function ProjectSupervisorView({
onMakeGameFromApprovedGdd,
...runtimePanelProps
}: ProjectSupervisorViewProps) {
const [processDetailExpanded, setProcessDetailExpanded] = useState(false);
const [expandedProcessKey, setExpandedProcessKey] = useState<string | null>(
null,
);
useEffect(() => {
setProcessDetailExpanded(false);
}, [directActivity, transientReply]);
setExpandedProcessKey(null);
}, [directProcessKey]);
const processDetailExpanded =
Boolean(directProcessKey) && expandedProcessKey === directProcessKey;
const submitLabel = needsUserInput
? '等待回答'
@@ -169,9 +197,13 @@ export function ProjectSupervisorView({
>
<header>
<span aria-hidden="true" />
<strong>{directActivity || '陶泥儿正在处理'}</strong>
<strong>
{directCodex
? directStatusTitle(directStatus)
: '陶泥儿正在处理'}
</strong>
</header>
{transientReply ? (
{(directCodex ? directProcessDetail : transientReply) ? (
<div className="project-supervisor-process-detail">
<p
className={
@@ -179,16 +211,24 @@ export function ProjectSupervisorView({
}
aria-label="陶泥儿正在执行的内容"
>
{transientReply}
{directCodex ? directProcessDetail : transientReply}
</p>
{transientReply.includes('\n') ||
transientReply.length > 96 ? (
{(directCodex
? directProcessDetail
: transientReply
).includes('\n') ||
(directCodex ? directProcessDetail : transientReply).length >
96 ? (
<button
type="button"
className="project-supervisor-process-toggle"
aria-expanded={processDetailExpanded}
onClick={() =>
setProcessDetailExpanded((expanded) => !expanded)
setExpandedProcessKey((current) =>
current === directProcessKey
? null
: directProcessKey,
)
}
>
{processDetailExpanded ? '收起' : '展开'}
@@ -5580,7 +5580,7 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(
within(supervisorSurface).getByLabelText('陶泥儿执行过程'),
).getByText('已发送消息,正在等待陶泥儿回复'),
).getByText('需求已接收'),
).not.toBeNull(),
);
const directMessageList =
@@ -5594,11 +5594,12 @@ export function registerProjectSupervisorSurfaceTests() {
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(waitingProcessCard.parentElement).toBe(directMessageList);
expect(
within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'),
within(waitingProcessCard).getByText('正在等待陶泥儿开始'),
).not.toBeNull();
expect(
within(waitingProcessCard).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在等待陶泥儿开始');
const firstDirectCall = invoke.mock.calls.find(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
@@ -5621,8 +5622,7 @@ export function registerProjectSupervisorSurfaceTests() {
turnId: firstTurnId,
sequence: 0,
status: 'accepted',
activity: 'understanding',
accumulatedText: 'DIRECT_STREAM:先完成',
activity: 'request-accepted',
updatedAt: 1000,
},
});
@@ -5631,6 +5631,28 @@ export function registerProjectSupervisorSurfaceTests() {
projectPath,
turnId: firstTurnId,
sequence: 2,
status: 'running',
activity: 'file-read',
accumulatedText: `正在读取 game/index.html${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`,
updatedAt: 1500,
},
});
});
const runningProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull();
expect(
within(runningProcessCard).getByRole('button', { name: '展开' }),
).not.toBeNull();
fireEvent.click(
within(runningProcessCard).getByRole('button', { name: '展开' }),
);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 3,
status: 'streaming',
activity: 'controlled-tool',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
@@ -5643,7 +5665,7 @@ export function registerProjectSupervisorSurfaceTests() {
turnId: firstTurnId,
sequence: 1,
status: 'streaming',
activity: 'file-change',
activity: 'file-write',
accumulatedText: '乱序事件不能回退正文',
updatedAt: 1500,
},
@@ -5674,12 +5696,11 @@ export function registerProjectSupervisorSurfaceTests() {
});
const streamingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull();
expect(
within(streamingProcessCard).getByText('正在执行受控工具'),
).not.toBeNull();
expect(
within(streamingProcessCard).getByLabelText('陶泥儿实时回复').textContent,
).toContain('DIRECT_STREAM:先完成正式客户端玩法拆解');
within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toContain('正在生成回复:DIRECT_STREAM:先完成正式客户端玩法拆解');
expect(directMessageList.scrollTop).toBe(640);
expect(within(supervisorSurface).queryByText(//u)).toBeNull();
expect(
@@ -5692,18 +5713,25 @@ export function registerProjectSupervisorSurfaceTests() {
payload: {
projectPath,
turnId: firstTurnId,
sequence: 3,
status: 'streaming',
activity: 'validation',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
sequence: 4,
status: 'running',
accumulatedText: `正在执行 npm test${'工具输出很长时需要保持展开状态。'.repeat(10)}`,
updatedAt: 4500,
},
});
});
expect(directMessageList.scrollTop).toBe(100);
expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull();
expect(
within(streamingProcessCard).getByText('正在验证结果'),
within(streamingProcessCard).getByText(/ npm test/u),
).not.toBeNull();
expect(
(
within(streamingProcessCard).getByRole('button', {
name: '收起',
}) as HTMLButtonElement
).getAttribute('aria-expanded'),
).toBe('true');
await act(async () => {
firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
});
@@ -5714,7 +5742,7 @@ export function registerProjectSupervisorSurfaceTests() {
),
).toHaveLength(1);
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'),
).toBeNull();
expect(
within(supervisorSurface).queryByText(
@@ -5775,12 +5803,15 @@ export function registerProjectSupervisorSurfaceTests() {
const failedTurnProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(failedTurnProcessCard).getByText('陶泥儿正在处理'),
within(failedTurnProcessCard).getByText('回复生成中'),
).not.toBeNull();
expect(
within(failedTurnProcessCard).getByLabelText('陶泥儿实时回复')
within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toContain('即将失败的临时正文');
).toContain('正在生成回复:即将失败的临时正文');
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
await act(async () => {
directTurnUpdateHandler?.({
payload: {
@@ -5797,12 +5828,15 @@ export function registerProjectSupervisorSurfaceTests() {
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
const failedStatusProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(directMessageList).getByLabelText('陶泥儿执行过程'),
within(failedStatusProcessCard).getByText('处理失败'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('处理失败,正在同步错误'),
).not.toBeNull();
within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容')
.textContent,
).toBe('正在记录失败原因');
await act(async () => {
secondDirectReply.reject(new Error('模拟 direct 失败'));
});
@@ -15,6 +15,16 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-09-02 Direct 过程卡按回合阶段状态驱动
- 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。
- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。移除合成打字机回放;工具说明/中间文本不再触发 streaming。展开/收起是同一 `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 标题与小字、同一回合后续 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`
---
## 2026-08-31 DirectProject 客户端扩展按独立 Skill/MCP 导入
- 背景:DirectProject 需要使用用户在 AGC 客户端导入的市面原生 Skill、MCP 和 Plugin 内容,但第三方内容不应直接安装到运行时 Codex,也不应要求用户转换为 AGC 自定义格式。
@@ -292,7 +292,7 @@ chat_with_game_creator_direct_codex
`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject``audit` 为 Some 时抽取。
`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`
`run_direct_game_creator_turn_inner` 的 UI observer `AccumulatedText`(仅开启流式时)映射为 `streaming``IntermediateText``Activity` 一律映射为 `running`,只向前端暴露安全活动词与可见正文,不携带原始 item JSON
回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`