权限被拒不再抹掉下一轮的忙态:出队统一交给 finally
- startTurn 的权限被拒分支不再自己 endTurnCommand + dispatchNextQueuedTurn,改为标记 queueAdvance 交给 finally 收口 - finally 固定顺序:刷新清单 → endTurnCommand() → 出队,保证清忙态早于出队,不再清掉下一轮刚设上的忙态与 pendingUserItemId - 模块注释把这条顺序写成契约,并说明权限被拒这种「本轮从未发出但要继续出队」的走法 - 新增 appSurface 用例:权限被拒 + 后面排着消息时,被出队那一轮在飞期间输入区仍是终止态;改前该用例红
This commit is contained in:
+17
-6
@@ -131,7 +131,9 @@ export type DirectProjectChatControllerProps = {
|
||||
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
|
||||
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
|
||||
* 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。
|
||||
* 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。
|
||||
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。
|
||||
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
|
||||
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。
|
||||
*
|
||||
* 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写;
|
||||
* 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` /
|
||||
@@ -457,6 +459,9 @@ export function useDirectProjectChatController({
|
||||
);
|
||||
void (async () => {
|
||||
let invoked = false;
|
||||
// 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后:
|
||||
// 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。
|
||||
let queueAdvance = false;
|
||||
try {
|
||||
onRuntimeError('');
|
||||
if (!input.directPolicyChecked) {
|
||||
@@ -470,10 +475,9 @@ export function useDirectProjectChatController({
|
||||
});
|
||||
if (!allowed) {
|
||||
// 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败),
|
||||
// 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把
|
||||
// 忙态放下并继续出队,否则后面的排队消息会永久卡住。
|
||||
endTurnCommand();
|
||||
dispatchNextQueuedTurn();
|
||||
// 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会
|
||||
// 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。
|
||||
queueAdvance = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -495,8 +499,15 @@ export function useDirectProjectChatController({
|
||||
if (invoked) {
|
||||
await refreshDirectManifest(nextProjectPath);
|
||||
}
|
||||
// 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上
|
||||
// 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送,
|
||||
// 下一轮的三态也会因为身份被清空而掉回 finished)。
|
||||
endTurnCommand();
|
||||
if (invoked && projectPathRef.current === nextProjectPath) {
|
||||
// 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出,
|
||||
// 不受项目切换影响,照旧出队。
|
||||
const invokedInSameProject =
|
||||
invoked && projectPathRef.current === nextProjectPath;
|
||||
if (queueAdvance || invokedInSameProject) {
|
||||
dispatchNextQueuedTurn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
createProjectChatRuntimeHarness,
|
||||
emptyProjectPolicy,
|
||||
expect,
|
||||
fireEvent,
|
||||
it,
|
||||
@@ -502,6 +503,63 @@ export function registerChatComposerControlTests() {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
|
||||
const pending: Array<{ resolve: (value: string) => void }> = [];
|
||||
const deferredPolicies: Array<(value: unknown) => void> = [];
|
||||
let policyAllowsWrite = false;
|
||||
const { invoke, surface } = await openDirectCodexSurface({
|
||||
read_project_permission_policy: () => {
|
||||
if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy());
|
||||
return new Promise((resolve) => {
|
||||
deferredPolicies.push(resolve);
|
||||
});
|
||||
},
|
||||
chat_with_game_creator_direct_codex: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
pending.push({ resolve });
|
||||
}),
|
||||
});
|
||||
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
||||
await submitDirectTurn(surface, composer, '被拒的那条');
|
||||
|
||||
// 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。
|
||||
await setComposerText(composer, '后面那条');
|
||||
submitComposerForm(composer);
|
||||
const queue = await within(surface).findByLabelText('待发送消息队列');
|
||||
expect(within(queue).getByText('后面那条')).not.toBeNull();
|
||||
|
||||
// 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑,
|
||||
// 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。
|
||||
policyAllowsWrite = true;
|
||||
await act(async () => {
|
||||
for (const resolve of deferredPolicies.splice(0)) {
|
||||
resolve({
|
||||
path: '.agent/policy.json',
|
||||
policy: {
|
||||
deniedCommands: [],
|
||||
confirmCommands: ['conversation.write'],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'chat_with_game_creator_direct_codex',
|
||||
expectDirectTurnWithText('后面那条'),
|
||||
);
|
||||
});
|
||||
// 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。
|
||||
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
||||
expect(
|
||||
within(surface).getByRole('button', { name: '终止' }),
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
pending[0]?.resolve('回复');
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user