权限被拒不再抹掉下一轮的忙态:出队统一交给 finally

- startTurn 的权限被拒分支不再自己 endTurnCommand + dispatchNextQueuedTurn,改为标记 queueAdvance 交给 finally 收口
- finally 固定顺序:刷新清单 → endTurnCommand() → 出队,保证清忙态早于出队,不再清掉下一轮刚设上的忙态与 pendingUserItemId
- 模块注释把这条顺序写成契约,并说明权限被拒这种「本轮从未发出但要继续出队」的走法
- 新增 appSurface 用例:权限被拒 + 后面排着消息时,被出队那一轮在飞期间输入区仍是终止态;改前该用例红
This commit is contained in:
2026-09-22 15:20:38 +08:00
parent 67d2f08421
commit cab31613f2
2 changed files with 75 additions and 6 deletions
@@ -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(