Direct 过程卡改为按回合阶段状态驱动并移除合成打字机
- DirectProject observer 只把真实回复增量标记 streaming,工具中间文本与活动一律 running - 删除最终回复的合成打字机回放,改由真实事件驱动流式展示 - App 事件投影新增回合状态与 processKey,小字统一补充正在前缀 - 过程卡标题只由回合状态决定,展开状态在同一回合内保持 - 失败与接受态文案改为当前阶段描述,旧直接活动词映射移除 - AppSurface 回归覆盖接受态、展开保持、流式标题与失败态断言 - 决策记录与 Direct 审计账本同步新的状态口径
This commit is contained in:
@@ -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", "做方案")]
|
||||
|
||||
@@ -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={
|
||||
|
||||
+51
-11
@@ -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 是 streaming;AppSurface 覆盖接受态、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`。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user