修复 DirectProject 回合恢复与消息排队
统一恢复回合的忙碌态与终止按钮显示 按 turn.completed 推进 DirectProject 消息队列并补充回归测试
This commit is contained in:
@@ -754,6 +754,12 @@ export function App({
|
|||||||
/** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */
|
/** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */
|
||||||
const supervisorChatBusy =
|
const supervisorChatBusy =
|
||||||
chatAgentBusy || (directCodexProductRuntime && directTurnRunning);
|
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<
|
const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -6691,9 +6697,14 @@ export function App({
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (localProjectPathRef.current === directProjectPath) {
|
if (localProjectPathRef.current === directProjectPath) {
|
||||||
|
setDirectThreadChat((state) =>
|
||||||
|
state.turnRunning ? { ...state, turnRunning: false } : state,
|
||||||
|
);
|
||||||
setChatAgentBusy(false);
|
setChatAgentBusy(false);
|
||||||
setDirectCodexTurnCancelling(false);
|
setDirectCodexTurnCancelling(false);
|
||||||
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
|
directTurnRunningRef.current = false;
|
||||||
|
chatAgentBusyRef.current = false;
|
||||||
|
// 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。
|
||||||
dispatchNextQueuedChatTurn();
|
dispatchNextQueuedChatTurn();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12102,8 +12113,12 @@ export function App({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */
|
/** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */
|
||||||
function dispatchNextQueuedChatTurn() {
|
function dispatchNextQueuedChatTurn() {
|
||||||
|
if (chatAgentBusyRef.current || directTurnRunningRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
directTurnCompletionPendingRef.current = false;
|
||||||
const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current);
|
const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
return;
|
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 复位。 */
|
/** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */
|
||||||
async function handleCancelDirectCodexTurn() {
|
async function handleCancelDirectCodexTurn() {
|
||||||
if (directCodexTurnCancelling) {
|
if (directCodexTurnCancelling) {
|
||||||
@@ -12195,7 +12236,7 @@ export function App({
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chatAgentBusy) {
|
if (supervisorChatBusy) {
|
||||||
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
|
// 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后
|
||||||
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
|
// 依次发出;其它面板保持原有"运行中不接受新输入"的行为。
|
||||||
if (directCodexProductRuntime) {
|
if (directCodexProductRuntime) {
|
||||||
@@ -12521,7 +12562,9 @@ export function App({
|
|||||||
runtime={projectSupervisorRuntime}
|
runtime={projectSupervisorRuntime}
|
||||||
error={projectSupervisorRuntimeError}
|
error={projectSupervisorRuntimeError}
|
||||||
runtimeByAgentId={agentRuntimeById}
|
runtimeByAgentId={agentRuntimeById}
|
||||||
controlBusy={chatAgentBusy}
|
controlBusy={
|
||||||
|
directCodexProductRuntime ? supervisorChatBusy : chatAgentBusy
|
||||||
|
}
|
||||||
readOnly={directCodexProductRuntime}
|
readOnly={directCodexProductRuntime}
|
||||||
versions={chatProjectVersions}
|
versions={chatProjectVersions}
|
||||||
professionalResultsByAgentId={professionalAgentResultsById}
|
professionalResultsByAgentId={professionalAgentResultsById}
|
||||||
|
|||||||
@@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */
|
/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */
|
||||||
async function openDirectCodexSurface(overrides: InvokeOverrides = {}) {
|
async function openDirectCodexSurface(
|
||||||
|
overrides: InvokeOverrides = {},
|
||||||
|
beforeOpen?: (
|
||||||
|
harness: ReturnType<typeof createProjectSupervisorRuntimeHarness>,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||||
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
||||||
initialSessionExists: false,
|
initialSessionExists: false,
|
||||||
});
|
});
|
||||||
|
beforeOpen?.(supervisorHarness);
|
||||||
const manifest = createGameCreationAppManifest(
|
const manifest = createGameCreationAppManifest(
|
||||||
'local-project-draft',
|
'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 () => {
|
it('terminates the running turn and returns the composer to the idle state', async () => {
|
||||||
const pending: Array<{
|
const pending: Array<{
|
||||||
resolve: (value: string) => void;
|
resolve: (value: string) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user