From 688cf7a971dbf1613b4213c0da30e8cd295d3c47 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 10:38:01 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E9=87=8D=E5=A4=8D=E6=92=AD=E6=94=BE=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E4=BC=AA=E6=B5=81=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按消息 ID 合并实时正文与正式消息,保留逐步显示进度 分离请求收尾与动画结束,隔离旧回合事件及异步返回 独立展示工具状态,保留 Provider 重试的正文重置语义 补充重复终态、整块回复、多消息及回合隔离回归测试 同步更新策划展示规范与排障记录 --- apps/ai-game-creator-shell/src/App.tsx | 252 ++++++------------ .../planning/PlanningChatView.tsx | 113 +++++--- .../planning/useDesignReplyAnimation.ts | 113 ++++++++ .../tests/appSurface/design-agent.suite.ts | 165 ++++++++++++ .../tests/designReplyAnimation.test.tsx | 99 +++++++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 5 + 7 files changed, 530 insertions(+), 221 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts create mode 100644 apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9fe8fc245..8a9c7e24b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -116,6 +116,7 @@ import { type DirectProjectInitialTurn, } from './view/project-development/chat/DirectProjectChatView'; import { PlanningChatView } from './view/project-development/planning/PlanningChatView'; +import { useDesignReplyAnimation } from './view/project-development/planning/useDesignReplyAnimation'; import type { ProjectManifestSnapshotMetadata } from './view/project-development/projectResourceLiveUpdateModel'; function isPersistableDirectCodexConversationMessage(message: ChatMessage) { @@ -366,23 +367,31 @@ export function App({ // 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。 const [gamePublishAllowed, setGamePublishAllowed] = useState(false); const [projectChatError, setProjectChatError] = useState(''); - const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = - useState(''); - const designAgentTransientReplyTargetRef = useRef(''); - const designAgentVisibleReplyRef = useRef(''); - const designAgentPendingViewRef = useRef<{ - clientTurnId: string; - projectPath: string; - view: DesignView; - } | null>(null); + const designReplyAnimation = useDesignReplyAnimation(); + const [designAgentStatus, setDesignAgentStatus] = useState(''); const [designAgentReasoning, setDesignAgentReasoning] = useState(''); - function setDesignAgentTransientReplyTarget(next: string) { - designAgentTransientReplyTargetRef.current = next; - if (!next) { - designAgentVisibleReplyRef.current = ''; - setDesignAgentTransientReplyVisible(''); - } + function isCurrentDesignTurn(projectPath: string, clientTurnId: string) { + const tracked = designAgentTurnRef.current; + return ( + localProjectPathRef.current === projectPath && + tracked?.projectPath === projectPath && + tracked.clientTurnId === clientTurnId + ); + } + + function beginDesignTurn(projectPath: string, clientTurnId: string) { + designAgentTurnRef.current = { projectPath, clientTurnId }; + designAgentReasoningTurnRef.current = { projectPath, clientTurnId }; + designReplyAnimation.reset( + latestMessagesRef.current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + setDesignAgentStatus(''); + setDesignAgentReasoning(''); + setProjectChatError(''); + setChatAgentBusy(true); } function designAgentEventSubscriptionReady() { @@ -407,35 +416,12 @@ export function App({ designAgentEventSubscriptionResolveRef.current = null; } - useEffect(() => { - const timer = window.setInterval(() => { - const target = designAgentTransientReplyTargetRef.current; - setDesignAgentTransientReplyVisible((current) => { - if (!target) { - designAgentVisibleReplyRef.current = ''; - return ''; - } - const prefix = target.startsWith(current) ? current : ''; - if (prefix === target) { - designAgentVisibleReplyRef.current = target; - return target; - } - const remaining = target.length - prefix.length; - const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; - const next = target.slice(0, prefix.length + step); - designAgentVisibleReplyRef.current = next; - return next; - }); - }, 50); - return () => window.clearInterval(timer); - }, []); - /** * 策划 Agent 的实时事件流:本轮流式正文、思考过程和回合中途的视图都靠它推给界面。 * * 订阅建立是异步的,而回合由一个 invoke 发起;`designAgentEventSubscriptionReady()` - * 让回合等监听器挂好再开始,避免开头几个事件丢掉。事件只认当前项目;有在跑的回合时 - * 还要认本轮 `clientTurnId`,迟到的上一轮事件不会画到这一轮上。 + * 让回合等监听器挂好再开始,避免开头几个事件丢掉。正文和视图严格匹配活动回合, + * reasoning 另按原回合接收迟到补充,不能让过期视图重播正文。 */ useEffect(() => { const ready = createDesignAgentEventSubscriptionReady(); @@ -452,19 +438,7 @@ export function App({ let disposed = false; void subscribeTauriEvent('design-agent-update', (event) => { const payload = event.payload; - const tracked = designAgentTurnRef.current; - if ( - payload.projectPath !== localProjectPathRef.current || - (tracked && payload.clientTurnId !== tracked.clientTurnId) - ) { - return; - } - if ( - (payload.kind === 'text' || payload.kind === 'tool') && - payload.text - ) { - setDesignAgentTransientReplyTarget(payload.text); - } + if (payload.projectPath !== localProjectPathRef.current) return; if (payload.reasoningText != null) { const reasoningTurn = designAgentReasoningTurnRef.current; if ( @@ -474,8 +448,21 @@ export function App({ setDesignAgentReasoning(payload.reasoningText); } } + if (!isCurrentDesignTurn(payload.projectPath, payload.clientTurnId)) + return; + if ( + payload.kind === 'text' && + payload.messageId && + payload.text != null + ) { + designReplyAnimation.receiveText(payload.messageId, payload.text); + if (payload.text) setDesignAgentStatus(''); + } + if (payload.kind === 'tool' && payload.text != null) { + setDesignAgentStatus(payload.text); + } if (payload.view) { - applyDesignAgentViewAfterTransient( + applyDesignAgentTurnView( payload.view, payload.projectPath, payload.clientTurnId, @@ -557,67 +544,18 @@ export function App({ latestMessagesRef.current = conversation; } - function commitDesignAgentView(view: DesignView, projectPath: string) { - const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; - designAgentPendingViewRef.current = null; - applyDesignView(view, projectPath); - setDesignAgentReasoning(''); - setDesignAgentTransientReplyTarget(''); - if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { - designAgentTurnRef.current = null; - } - } - - function applyDesignAgentViewAfterTransient( + function applyDesignAgentTurnView( view: DesignView, projectPath: string, clientTurnId: string, ) { - let target = designAgentTransientReplyTargetRef.current; - const tracked = designAgentTurnRef.current; - if (!target.trim() && !view.running) { - const latestAssistantText = [...view.messages] - .reverse() - .find((message) => message.role !== 'user' && message.text.trim()) - ?.text.trim(); - if (latestAssistantText) { - setDesignAgentTransientReplyTarget(latestAssistantText); - target = latestAssistantText; - } - } - if ( - !view.running && - tracked?.clientTurnId === clientTurnId && - target.trim() && - designAgentVisibleReplyRef.current !== target - ) { - designAgentPendingViewRef.current = { - clientTurnId, - projectPath, - view, - }; - return; - } - commitDesignAgentView(view, projectPath); + if (!isCurrentDesignTurn(projectPath, clientTurnId)) return; + designReplyAnimation.receiveView(view); + applyDesignView(view, projectPath); + setDesignAgentReasoning(''); + if (!view.running) setDesignAgentStatus(''); } - useEffect(() => { - const timer = window.setInterval(() => { - const pending = designAgentPendingViewRef.current; - if (!pending) { - return; - } - const target = designAgentTransientReplyTargetRef.current; - if (target && designAgentVisibleReplyRef.current !== target) { - return; - } - commitDesignAgentView(pending.view, pending.projectPath); - }, 50); - return () => window.clearInterval(timer); - // 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - async function hydrateDesignAgentSession(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath.trim()) { @@ -647,32 +585,18 @@ export function App({ setProjectChatError('需要在 Tauri App 内运行。'); return; } - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; + beginDesignTurn(nextProjectPath, clientTurnId); await designAgentEventSubscriptionReady(); - setChatAgentBusy(true); - setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, clientTurnId, input, }); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); + applyDesignAgentTurnView(view, nextProjectPath, clientTurnId); } catch (error) { - if (localProjectPathRef.current !== nextProjectPath) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } const message = error instanceof Error ? error.message : String(error); @@ -680,12 +604,13 @@ export function App({ requestRuntimeConfigOpen(); } setProjectChatError(message); + designReplyAnimation.discardUnpersisted(); } finally { - if (!designAgentPendingViewRef.current) { + if (isCurrentDesignTurn(nextProjectPath, clientTurnId)) { designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); + setDesignAgentStatus(''); + setChatAgentBusy(false); } - setChatAgentBusy(false); } } @@ -876,7 +801,8 @@ export function App({ }, [ messages, projectChatError, - designAgentTransientReply, + designReplyAnimation.replies, + designAgentStatus, designAgentReasoning, designAgentView, pendingUiConfirmation, @@ -1369,8 +1295,8 @@ export function App({ setChatFilesImporting(false); setChatFileImportNotice(''); setProjectChatError(''); - setDesignAgentTransientReplyTarget(''); - designAgentPendingViewRef.current = null; + designReplyAnimation.reset(); + setDesignAgentStatus(''); setDesignAgentReasoning(''); setDesignAgentActive(planningStartMode); designAgentActiveRef.current = planningStartMode; @@ -2295,7 +2221,8 @@ export function App({ pendingConfirmation={pendingUiConfirmation} projectPath={localProject?.projectPath ?? projectPath} conversationMessages={messages} - transientReply={designAgentTransientReply} + replyAnimations={designReplyAnimation.replies} + designStatus={designAgentStatus} showDesignReasoning={designAgentActive} designReasoning={designAgentReasoning} designReasoningEntries={ @@ -2313,66 +2240,37 @@ export function App({ return; } const clientTurnId = createAgentChatRunId('design-agent-turn'); - designAgentTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentReasoningTurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - designAgentPendingViewRef.current = null; - setDesignAgentTransientReplyTarget(''); - setDesignAgentReasoning(''); - setChatAgentBusy(true); + beginDesignTurn(nextProjectPath, clientTurnId); void designAgentEventSubscriptionReady() - .then(() => - invoke('decide_design_phase', { + .then(() => { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) + return null; + return invoke('decide_design_phase', { projectPath: nextProjectPath, clientTurnId, requestId, approved, - }), - ) + }); + }) .then((view) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { - return; - } - applyDesignAgentViewAfterTransient( + if (!view) return; + applyDesignAgentTurnView( view, nextProjectPath, clientTurnId, ); }) .catch((error) => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } setProjectChatError(String(error)); + designReplyAnimation.discardUnpersisted(); }) .finally(() => { - if ( - localProjectPathRef.current !== nextProjectPath || - designAgentTurnRef.current?.projectPath !== - nextProjectPath || - designAgentTurnRef.current?.clientTurnId !== clientTurnId - ) { + if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - } - if (!designAgentPendingViewRef.current) { - designAgentTurnRef.current = null; - setDesignAgentTransientReplyTarget(''); - } + designAgentTurnRef.current = null; + setDesignAgentStatus(''); setChatAgentBusy(false); }); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index d0b7d74d4..645a8e957 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -31,6 +31,7 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import type { DesignReplyAnimation } from './useDesignReplyAnimation'; /** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */ function planningMessageText(message: Pick) { @@ -80,7 +81,8 @@ type PlanningChatViewProps = { onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; projectPath: string; - transientReply: string; + replyAnimations?: DesignReplyAnimation[]; + designStatus?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; @@ -117,7 +119,8 @@ export function PlanningChatView({ onSubmit, pendingConfirmation, projectPath, - transientReply, + replyAnimations = [], + designStatus = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], @@ -169,34 +172,67 @@ export function PlanningChatView({ const handleDesignClarify = onDesignClarify ?? (() => undefined); const handleDesignRetry = onDesignRetry ?? (() => undefined); - const renderMessage = (message: ChatMessage, index: number) => ( -
- - - - {showDesignReasoning && message.reasoningText ? ( - - ) : null} - {message.role === 'user' && message.updatedAt ? ( - - ) : null} -
+ const animationsById = new Map( + replyAnimations.map((reply) => [reply.messageId, reply]), ); + const displayedMessages = [...visibleMessages]; + const conversationIds = new Set( + conversationMessages.map((message) => message.messageId), + ); + for (const reply of replyAnimations) { + if ( + !reply.persisted && + reply.target && + !conversationIds.has(reply.messageId) + ) { + displayedMessages.push({ + role: 'assistant', + messageId: reply.messageId, + text: reply.target, + }); + } + } + const renderMessage = (message: ChatMessage, index: number) => { + const animation = message.messageId + ? animationsById.get(message.messageId) + : undefined; + const streaming = Boolean( + animation && + (!animation.persisted || animation.visible !== animation.target), + ); + return ( +
+ + + + {showDesignReasoning && message.reasoningText ? ( + + ) : null} + {message.role === 'user' && message.updatedAt ? ( + + ) : null} +
+ ); + }; return (
@@ -251,7 +287,7 @@ export function PlanningChatView({ ) : null} - {visibleMessages.map(renderMessage)} + {displayedMessages.map(renderMessage)} {showDesignReasoning ? designReasoningEntries .filter((entry) => !entry.messageId) @@ -269,20 +305,9 @@ export function PlanningChatView({ label="策划 Agent 思考过程" /> ) : null} - {transientReply ? ( -
- - - + {designStatus ? ( +
+ {designStatus}
) : null}
diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts new file mode 100644 index 000000000..d8c6ef578 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/useDesignReplyAnimation.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { DesignView } from '../../../app/types'; + +export type DesignReplyAnimation = { + messageId: string; + target: string; + visible: string; + persisted: boolean; +}; + +/** 正文动画只管理显示进度,不延迟正式会话状态或参与请求收尾。 */ +export function useDesignReplyAnimation() { + const entriesRef = useRef([]); + const historyIdsRef = useRef(new Set()); + const [replies, setReplies] = useState([]); + const publish = useCallback((next: DesignReplyAnimation[]) => { + entriesRef.current = next; + setReplies(next); + }, []); + + const reset = useCallback( + (historyIds: string[] = []) => { + historyIdsRef.current = new Set(historyIds); + publish([]); + }, + [publish], + ); + + const receiveText = useCallback( + (messageId: string, target: string) => { + if (historyIdsRef.current.has(messageId)) return; + const previous = entriesRef.current.find( + (entry) => entry.messageId === messageId, + ); + // 空文本是同一 Provider 请求的 attempt 重置,不能清掉正式回复。 + if (previous?.persisted) return; + const next = { + messageId, + target, + visible: target.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: false, + }; + publish( + previous + ? entriesRef.current.map((entry) => + entry.messageId === messageId ? next : entry, + ) + : [...entriesRef.current, next], + ); + }, + [publish], + ); + + const receiveView = useCallback( + (view: DesignView) => { + let next = [...entriesRef.current]; + for (const message of view.messages) { + if ( + message.role !== 'assistant' || + historyIdsRef.current.has(message.id) + ) + continue; + const index = next.findIndex((entry) => entry.messageId === message.id); + const previous = next[index]; + const entry = { + messageId: message.id, + target: message.text, + visible: message.text.startsWith(previous?.visible ?? '') + ? (previous?.visible ?? '') + : '', + persisted: true, + }; + if (index < 0) next.push(entry); + else next[index] = entry; + } + if (!view.running) { + const persistedIds = new Set( + view.messages.map((message) => message.id), + ); + next = next.filter((entry) => persistedIds.has(entry.messageId)); + } + publish(next); + }, + [publish], + ); + + const discardUnpersisted = useCallback(() => { + publish(entriesRef.current.filter((entry) => entry.persisted)); + }, [publish]); + + useEffect(() => { + const timer = window.setInterval(() => { + let changed = false; + const next = entriesRef.current.map((entry) => { + const remaining = entry.target.length - entry.visible.length; + if (remaining <= 0) return entry; + changed = true; + const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; + return { + ...entry, + visible: entry.target.slice(0, entry.visible.length + step), + }; + }); + if (changed) publish(next); + }, 50); + return () => window.clearInterval(timer); + }, [publish]); + + return { replies, reset, receiveText, receiveView, discardUnpersisted }; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 79f1e49b7..71baf3f5b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -405,11 +405,159 @@ export function registerDesignAgentSurfaceTests() { ); }); + it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => { + const harness = createProjectChatRuntimeHarness({ + designAgentView: designConversationView(), + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: (view: unknown) => void; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + finish = resolve; + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + const input = await screen.findByLabelText('项目需求'); + await expectDesignModelReady(); + await setComposerText(input, '只回复 pong'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => expect(finish).toBeDefined()); + const call = harness.invoke.mock.calls.find( + ([command]) => command === 'continue_design_agent_session', + )!; + const clientTurnId = String( + (call[1] as { clientTurnId: string }).clientTurnId, + ); + const messageId = `${clientTurnId}:response:0`; + const view = { + ...designConversationView(), + messages: [{ id: messageId, role: 'assistant', text: 'pong' }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + ...payload, + }); + act(() => emit({ kind: 'text', messageId, text: 'pong' })); + await screen.findByText('pong'); + act(() => emit({ kind: 'state', view })); + act(() => emit({ kind: 'state', view })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + await act(async () => finish(view)); + expect(screen.getAllByText('pong')).toHaveLength(1); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'continue_design_agent_session', + ), + ).toHaveLength(1); + }); + + for (const firstDelivery of ['event', 'command'] as const) { + it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => { + const history = { id: 'history', role: 'assistant', text: '历史回复' }; + const harness = createProjectChatRuntimeHarness({ + designAgentView: { ...designConversationView(), messages: [history] }, + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + const requests: { + clientTurnId: string; + finish: (view: unknown) => void; + }[] = []; + harness.invoke.mockImplementation((command, args) => + command === 'continue_design_agent_session' + ? new Promise((resolve) => { + requests.push({ + clientTurnId: String(args?.clientTurnId), + finish: resolve, + }); + }) + : originalInvoke(command, args), + ); + renderDesignAgent(harness); + await screen.findByText(history.text); + await expectDesignModelReady(); + const input = screen.getByLabelText('项目需求'); + await setComposerText(input, '继续'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(1)); + const request = requests[0]; + const messageId = `${request.clientTurnId}:response:0`; + const reply = '整块返回也逐步显示'; + const terminal = { + ...designConversationView(), + messages: [history, { id: messageId, role: 'assistant', text: reply }], + }; + const emit = (payload: Record) => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId: request.clientTurnId, + ...payload, + }); + if (firstDelivery === 'event') + act(() => emit({ kind: 'state', view: terminal })); + else await act(async () => request.finish(terminal)); + const bubble = screen.getByLabelText('策划 Agent 实时回复'); + expect(bubble.textContent).not.toBe(reply); + expect(screen.getAllByText(history.text)).toHaveLength(1); + expect( + screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), + ).toBe(false); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + const prefix = bubble.textContent; + expect(prefix?.length).toBeGreaterThan(0); + act(() => { + emit({ kind: 'state', view: terminal }); + emit({ kind: 'state', view: terminal }); + emit({ kind: 'text', messageId, text: '' }); + }); + expect(bubble.textContent).toBe(prefix); + await screen.findByText(reply); + expect(screen.getByText(reply).closest('.message')).toBe(bubble); + expect( + document.querySelectorAll(`[data-message-id="${messageId}"]`), + ).toHaveLength(1); + expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull(); + + // 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。 + await setComposerText(input, '下一轮'); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(requests).toHaveLength(2)); + act(() => { + emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' }); + emit({ kind: 'state', view: terminal }); + }); + await act(async () => request.finish(terminal)); + expect( + screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'), + ).toBe(true); + expect(screen.queryByText('迟到旧回复')).toBeNull(); + await act(async () => requests[1].finish(terminal)); + }); + } + it('follows streamed Design Agent content until the user scrolls up', async () => { const harness = createProjectChatRuntimeHarness({ designAgentView: designConversationView(), designAgentContinueView: designConversationView(), }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finish!: () => void; + const gate = new Promise((resolve) => { + finish = resolve; + }); + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'continue_design_agent_session') await gate; + return originalInvoke(command, args); + }); renderDesignAgent(harness); const input = await screen.findByLabelText('项目需求'); @@ -459,11 +607,26 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏', }), ); await screen.findByLabelText('策划 Agent 实时回复'); await waitFor(() => expect(messageList.scrollTop).toBe(1000)); + const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复'); + const prefixBeforeTool = replyBeforeTool.textContent; + act(() => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'tool', + text: '正在读取方案文件', + }), + ); + expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe( + '正在读取方案文件', + ); + expect(replyBeforeTool.textContent).toBe(prefixBeforeTool); messageList.scrollTop = 120; fireEvent.scroll(messageList); @@ -478,6 +641,7 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'text', + messageId: `${clientTurnId}:response:0`, text: '正在补充关卡节奏与多人规则', }); }); @@ -488,6 +652,7 @@ export function registerDesignAgentSurfaceTests() { ).toContain('多人规则'), ); expect(messageList.scrollTop).toBe(120); + await act(async () => finish()); }); it('shows only known optimistic send times and does not invent persisted times', async () => { diff --git a/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx new file mode 100644 index 000000000..e51bb4fb3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/designReplyAnimation.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { DesignView } from '../src/app/types'; +import { useDesignReplyAnimation } from '../src/view/project-development/planning/useDesignReplyAnimation'; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function view(messages: DesignView['messages'], running = false): DesignView { + return { + session: { + sessionId: 's', + projectId: 'p', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification: null, + turnIndex: 1, + lastError: null, + }, + messages, + running, + canRetry: false, + }; +} + +it('keeps each reply progress across tool snapshots and repeated completion', () => { + const { result } = renderHook(useDesignReplyAnimation); + const history = { id: 'history', role: 'assistant', text: '历史' }; + const first = { id: 't:response:0', role: 'assistant', text: '第一条回复' }; + const second = { id: 't:response:1', role: 'assistant', text: '第二条回复' }; + act(() => { + result.current.reset([history.id]); + result.current.receiveText(first.id, first.text); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => + result.current.receiveView( + view( + [history, first, { id: 'tool', role: 'tool', text: '工具已完成' }], + true, + ), + ), + ); + expect(result.current.replies).toHaveLength(1); + expect(result.current.replies[0].visible).toBe('第一'); + act(() => result.current.receiveText(second.id, second.text)); + const terminal = view([history, first, second]); + act(() => { + result.current.receiveView(terminal); + result.current.receiveView(terminal); + }); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + '第一', + '', + ]); + act(() => vi.advanceTimersByTime(500)); + act(() => result.current.receiveView(terminal)); + expect(result.current.replies.map((reply) => reply.visible)).toEqual([ + first.text, + second.text, + ]); +}); + +it('resets only an unpersisted retry attempt and discards failed partial output', () => { + const { result } = renderHook(useDesignReplyAnimation); + act(() => { + result.current.receiveText('t:response:0', '尝试失败'); + vi.advanceTimersByTime(100); + }); + expect(result.current.replies[0].visible).toBe('尝试'); + act(() => result.current.receiveText('t:response:0', '')); + expect(result.current.replies[0].visible).toBe(''); + const saved = { id: 't:response:0', role: 'assistant', text: '成功' }; + act(() => { + result.current.receiveText(saved.id, saved.text); + result.current.receiveView(view([saved], true)); + vi.advanceTimersByTime(100); + result.current.receiveText(saved.id, ''); + result.current.receiveText('t:response:1', '未被接受的文本'); + result.current.receiveView(view([saved])); + }); + expect(result.current.replies).toEqual([ + { messageId: saved.id, target: '成功', visible: '成功', persisted: true }, + ]); + act(() => { + result.current.receiveText('t:response:2', '连接中断'); + result.current.discardUnpersisted(); + }); + expect(result.current.replies).toHaveLength(1); + act(() => result.current.reset([saved.id])); + expect(result.current.replies).toEqual([]); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 56fa9611a..d9b3ca0f6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,9 @@ # 踩坑与排障记录 +## 策划回复的重复终态不能重新启动伪流式 + +策划 Runtime 会通过状态事件与命令返回交付同一份最终视图。若前端清空临时正文后再拿“最后一条非用户历史消息”回填动画,就会出现正式回复旁又播放一遍、播放后消失的假重试。正文应按 `messageId` 保存显示进度,与正式消息共用一个气泡;请求完成不清动画,不延迟正式业务状态。Provider 自动重试复用消息 ID 并发送空文本,只允许重置未持久化的该条回复。正文、工具状态和 reasoning 分开;事件与异步命令收尾均检查项目及活动回合,旧请求不能覆盖新回合。详见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。 + ## Direct 宿主继续请求不能重发原始用户条目 原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 25451c57d..ea2b95d76 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -255,6 +255,11 @@ Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 - 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。 - 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 - 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 +- 策划正文保留伪流式,实时文本与正式消息按后端 `messageId` 共用一个显示位置;事件归属由项目与 `clientTurnId` 约束。同一消息的重复状态事件及命令返回只更新正文目标,不重置播放进度,不能从最后一条历史消息猜测本轮回复。没有流事件的整块新回复也逐步显示;已加载历史不重播。 +- 请求完成立即应用正式视图、释放业务忙碌状态,未完成的正文动画继续播放;动画完成只改变显示状态。一个回合内多条正文分别保留身份;工具状态独立显示,不能覆盖正文。真实 Provider 重试的空文本事件仅清空尚未持久化的对应消息;已经持久化的消息不受迟到文本重置。新回合开始时收起上轮动画并显示完整历史,切换项目清理临时显示;上轮迟到事件和命令返回不得污染新回合。 +- 终态以正式视图为准,丢弃没有进入正式消息的失败尝试文本,错误继续由既有错误区显示。正文呈现回归使用模拟原生事件与真实 React 组件,覆盖动画已追平/未追平时的重复终态、命令先返回、整块输出、工具间多条正文、重试清空、历史恢复和迟到回合。此项不改 Provider 重试、后端协议或持久数据,无迁移要求。 + +正文呈现验收由 `appSurface/design-agent.suite.ts` 与 `designReplyAnimation.test.tsx` 覆盖:整块返回仍播放、同 ID 气泡原位接管、重复终态与命令返回幂等、多正文与重试空串、工具提示不覆盖正文、上轮请求不能结束新回合。重复终态复现用例在修复前实现上失败;`appSurface.test.ts`、动画 hook 和会话恢复测试合计 217 项通过、9 项原有跳过。App typecheck(含配置检查)、定向 ESLint、编码和文档索引检查通过。本次使用模拟原生事件及 React/jsdom,不含真实 Provider 或安装包 GUI 演练。 - ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。 - 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。