修复策划回复重复播放并保留伪流式
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Backend tests (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m7s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m18s

按消息 ID 合并实时正文与正式消息,保留逐步显示进度
分离请求收尾与动画结束,隔离旧回合事件及异步返回
独立展示工具状态,保留 Provider 重试的正文重置语义
补充重复终态、整块回复、多消息及回合隔离回归测试
同步更新策划展示规范与排障记录
This commit is contained in:
2026-09-23 10:38:01 +00:00
parent c2ec978199
commit 688cf7a971
7 changed files with 530 additions and 221 deletions
+75 -177
View File
@@ -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<DesignEvent>('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<DesignView>('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<DesignView>('decide_design_phase', {
.then(() => {
if (!isCurrentDesignTurn(nextProjectPath, clientTurnId))
return null;
return invoke<DesignView>('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);
});
}
@@ -31,6 +31,7 @@ import {
DesignAgentPendingActions,
DesignAgentPhaseStatus,
} from './DesignAgentSurface';
import type { DesignReplyAnimation } from './useDesignReplyAnimation';
/** 后台任务失败文案要过一遍运行态错误解释器再给用户看。 */
function planningMessageText(message: Pick<ChatMessage, 'text' | 'role'>) {
@@ -80,7 +81,8 @@ type PlanningChatViewProps = {
onSubmit: FormEventHandler<HTMLFormElement>;
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) => (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<AgentMessageContent tone="body">
<ChatMarkdownMessage
role={message.role}
text={planningMessageText(message)}
/>
</AgentMessageContent>
{showDesignReasoning && message.reasoningText ? (
<AgentReasoning
text={message.reasoningText}
label="策划 Agent 思考过程"
/>
) : null}
{message.role === 'user' && message.updatedAt ? (
<time
className="message-sent-at"
dateTime={new Date(message.updatedAt).toISOString()}
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(message.updatedAt)}
</time>
) : null}
</div>
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 (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
aria-label={streaming ? '策划 Agent 实时回复' : undefined}
aria-live={streaming ? 'polite' : undefined}
data-message-id={message.messageId}
>
<AgentMessageContent tone="body">
<ChatMarkdownMessage
role={message.role}
text={animation ? animation.visible : planningMessageText(message)}
streaming={streaming}
/>
</AgentMessageContent>
{showDesignReasoning && message.reasoningText ? (
<AgentReasoning
text={message.reasoningText}
label="策划 Agent 思考过程"
/>
) : null}
{message.role === 'user' && message.updatedAt ? (
<time
className="message-sent-at"
dateTime={new Date(message.updatedAt).toISOString()}
title={`发送于 ${new Date(message.updatedAt).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(message.updatedAt)}
</time>
) : null}
</div>
);
};
return (
<section className="project-chat-surface" aria-label="立项策划对话">
@@ -251,7 +287,7 @@ export function PlanningChatView({
<ChatMarkdownMessage role="user" text={initialPlanningPrompt} />
</div>
) : null}
{visibleMessages.map(renderMessage)}
{displayedMessages.map(renderMessage)}
{showDesignReasoning
? designReasoningEntries
.filter((entry) => !entry.messageId)
@@ -269,20 +305,9 @@ export function PlanningChatView({
label="策划 Agent 思考过程"
/>
) : null}
{transientReply ? (
<div
className="message message--assistant"
aria-label="策划 Agent 实时回复"
aria-live="polite"
data-runtime-owned="true"
>
<AgentMessageContent>
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</AgentMessageContent>
{designStatus ? (
<div role="status" aria-label="策划 Agent 工具状态">
{designStatus}
</div>
) : null}
</div>
@@ -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<DesignReplyAnimation[]>([]);
const historyIdsRef = useRef(new Set<string>());
const [replies, setReplies] = useState<DesignReplyAnimation[]>([]);
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 };
}
@@ -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<string, unknown>) =>
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<string, unknown>) =>
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<void>((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 () => {
@@ -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([]);
});
@@ -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-宿主继续请求输入修复)。
@@ -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 的对话布局。