修复 DirectProject 回合恢复与消息排队
统一恢复回合的忙碌态与终止按钮显示 按 turn.completed 推进 DirectProject 消息队列并补充回归测试
This commit is contained in:
@@ -754,6 +754,12 @@ export function App({
|
||||
/** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */
|
||||
const supervisorChatBusy =
|
||||
chatAgentBusy || (directCodexProductRuntime && directTurnRunning);
|
||||
const chatAgentBusyRef = useRef(chatAgentBusy);
|
||||
const directTurnRunningRef = useRef(directTurnRunning);
|
||||
chatAgentBusyRef.current = chatAgentBusy;
|
||||
directTurnRunningRef.current = directTurnRunning;
|
||||
const previousDirectTurnRunningRef = useRef(directTurnRunning);
|
||||
const directTurnCompletionPendingRef = useRef(false);
|
||||
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -6691,9 +6697,14 @@ export function App({
|
||||
}
|
||||
} finally {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
setDirectThreadChat((state) =>
|
||||
state.turnRunning ? { ...state, turnRunning: false } : state,
|
||||
);
|
||||
setChatAgentBusy(false);
|
||||
setDirectCodexTurnCancelling(false);
|
||||
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
|
||||
directTurnRunningRef.current = false;
|
||||
chatAgentBusyRef.current = false;
|
||||
// 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。
|
||||
dispatchNextQueuedChatTurn();
|
||||
}
|
||||
}
|
||||
@@ -12102,8 +12113,12 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
/** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */
|
||||
/** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */
|
||||
function dispatchNextQueuedChatTurn() {
|
||||
if (chatAgentBusyRef.current || directTurnRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
directTurnCompletionPendingRef.current = false;
|
||||
const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current);
|
||||
if (!next) {
|
||||
return;
|
||||
@@ -12121,6 +12136,32 @@ export function App({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!directCodexProductRuntime) {
|
||||
directTurnCompletionPendingRef.current = false;
|
||||
previousDirectTurnRunningRef.current = directTurnRunning;
|
||||
return;
|
||||
}
|
||||
const wasRunning = previousDirectTurnRunningRef.current;
|
||||
previousDirectTurnRunningRef.current = directTurnRunning;
|
||||
if (wasRunning && !directTurnRunning) {
|
||||
directTurnCompletionPendingRef.current = true;
|
||||
}
|
||||
if (
|
||||
directTurnCompletionPendingRef.current &&
|
||||
!chatAgentBusy &&
|
||||
!directTurnRunning
|
||||
) {
|
||||
directTurnCompletionPendingRef.current = false;
|
||||
dispatchNextQueuedChatTurn();
|
||||
}
|
||||
// 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]);
|
||||
|
||||
/** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */
|
||||
async function handleCancelDirectCodexTurn() {
|
||||
if (directCodexTurnCancelling) {
|
||||
@@ -12195,7 +12236,7 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (chatAgentBusy) {
|
||||
if (supervisorChatBusy) {
|
||||
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
|
||||
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
|
||||
if (directCodexProductRuntime) {
|
||||
@@ -12521,7 +12562,9 @@ export function App({
|
||||
runtime={projectSupervisorRuntime}
|
||||
error={projectSupervisorRuntimeError}
|
||||
runtimeByAgentId={agentRuntimeById}
|
||||
controlBusy={chatAgentBusy}
|
||||
controlBusy={
|
||||
directCodexProductRuntime ? supervisorChatBusy : chatAgentBusy
|
||||
}
|
||||
readOnly={directCodexProductRuntime}
|
||||
versions={chatProjectVersions}
|
||||
professionalResultsByAgentId={professionalAgentResultsById}
|
||||
|
||||
@@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) {
|
||||
}
|
||||
|
||||
/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */
|
||||
async function openDirectCodexSurface(overrides: InvokeOverrides = {}) {
|
||||
async function openDirectCodexSurface(
|
||||
overrides: InvokeOverrides = {},
|
||||
beforeOpen?: (
|
||||
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
|
||||
) => void,
|
||||
) {
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
beforeOpen?.(supervisorHarness);
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'输入盒控件项目',
|
||||
@@ -479,6 +485,59 @@ export function registerChatComposerControlTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => {
|
||||
const pending: Array<{ resolve: (value: string) => void }> = [];
|
||||
const { invoke, surface, harness } = await openDirectCodexSurface(
|
||||
{
|
||||
chat_with_game_creator_direct_codex: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
pending.push({ resolve });
|
||||
}),
|
||||
},
|
||||
(directHarness) => {
|
||||
directHarness.emitDirectThreadEvents({ type: 'turn.started' });
|
||||
},
|
||||
);
|
||||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||||
|
||||
expect(
|
||||
await within(surface).findByRole('button', { name: '终止' }),
|
||||
).not.toBeNull();
|
||||
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
||||
|
||||
await setComposerText(composer, '恢复后排队的消息');
|
||||
submitComposerForm(composer);
|
||||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||||
expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
harness.emitDirectThreadEvents({
|
||||
type: 'turn.completed',
|
||||
status: 'completed',
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
expect.objectContaining({ prompt: '恢复后排队的消息' }),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'chat_with_game_creator_direct_codex',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
pending[0]?.resolve('排队回合回复');
|
||||
});
|
||||
});
|
||||
|
||||
it('terminates the running turn and returns the composer to the idle state', async () => {
|
||||
const pending: Array<{
|
||||
resolve: (value: string) => void;
|
||||
|
||||
Reference in New Issue
Block a user