修复 DirectProject 回合恢复与消息排队

统一恢复回合的忙碌态与终止按钮显示

按 turn.completed 推进 DirectProject 消息队列并补充回归测试
This commit is contained in:
2026-09-18 00:14:37 +08:00
parent bb772c7422
commit de7211e8f2
2 changed files with 107 additions and 5 deletions
+47 -4
View File
@@ -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;