取消待发消息时清掉它的埋点句柄

- 控制器在 `remove_direct_project_pending_turn` 返回 `removed` 时删除该 `clientTurnId` 的埋点句柄
- 被取消的待发消息永远不会放行、也就不会有自己的 `turn.completed`,句柄留着只会泄漏
- 留着还会挤进"身份缺失"的兜底结算候选(挑认领表里最早那一条),把下一次结算算到没跑过的回合头上
- 补一条 appSurface 用例钉住这条不变式:回放没有身份的失败终态时,兜底结算只落到还能收口的那一条
This commit is contained in:
2026-09-30 11:42:57 +08:00
parent 3e8954548a
commit f4558475a0
2 changed files with 61 additions and 0 deletions
@@ -335,6 +335,12 @@ export function useDirectProjectChatController({
{ projectPath: nextProjectPath, clientTurnId },
);
if (projectPathRef.current !== nextProjectPath) return;
if (outcome === 'removed') {
// 这一条不会再被放行,也就不会再有 `turn.completed` 来结算它:句柄只能在这里清掉。
// 留着不但泄漏,还会挤进"身份缺失"的兜底结算的候选里(`settlePendingRunAnalytics`
// 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。
pendingRunAnalyticsRef.current.delete(clientTurnId);
}
setComposerNotice(
outcome === 'notFound'
? '这条待发消息已经不在队列里'
@@ -647,6 +647,61 @@ export function registerChatComposerControlTests() {
).toEqual(['第一条消息', '第二条消息', '第三条消息']);
});
it('不再认领被取消那一条的埋点句柄:身份缺失的兜底结算只落到还能收口的那一条', async () => {
// 取消掉的待发消息永远不会被放行,也就永远不会有它自己的 `turn.completed`:句柄必须在取消时清掉。
// 留着的话,"身份缺失"的兜底结算(认领表里**最早**那一条)会先认出它——取消一条消息等于把下一次
// 结算算到一条根本没跑过的回合头上,真正跑着的那条反而没人结算。
let host: ReturnType<typeof hostQueue> | null = null;
const { invoke, surface, harness } = await openDirectCodexSurface({
enqueue_direct_codex_turn: (args) => host?.enqueue(args),
remove_direct_project_pending_turn: (args) => host?.cancel(args),
});
// 线程上已有一条(恢复出来的)回合在跑:发出去的两条都只会排队,事件流里也不会走 `turn.started`。
host = hostQueue(harness, { running: true });
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await setComposerText(composer, '会被取消的消息');
submitComposerForm(composer);
await setComposerText(composer, '留下来的消息');
submitComposerForm(composer);
const queue = await within(surface).findByLabelText('待发送消息队列');
await waitFor(() => {
expect(within(queue).getAllByRole('listitem')).toHaveLength(2);
});
fireEvent.click(
within(queue).getByRole('button', {
name: '取消待发消息 会被取消的消息',
}),
);
await waitFor(() => {
expect(within(queue).getAllByRole('listitem')).toHaveLength(1);
});
const attempts = invoke.mock.calls
.filter(([command]) => command === 'enqueue_direct_codex_turn')
.map(([, args]) => String(args?.analyticsAttemptId ?? ''));
expect(attempts).toHaveLength(2);
const settledAttemptIds = () =>
invoke.mock.calls
.filter(([command]) => command === 'settle_direct_run_analytics')
.map(([, args]) => String(args?.attemptId ?? ''));
// 回放一条**没有身份**的失败终态:订阅重建时的生命周期锚点就是这个形状(订阅在新回合开始前
// 断过,开局那条 `turn.started` 不在手里)。兜底结算此刻只能按"认领表里最早那一条"认人——
// 被取消的那条若还留在表里,就会顶替掉真正该结算的那一条。
act(() => {
harness.emitDirectThreadEvents({
type: 'turn.completed',
status: 'failed',
failure: { message: '重连之前那一轮已经失败' },
at: 4,
});
});
await waitFor(() => {
expect(settledAttemptIds()).toHaveLength(1);
});
expect(settledAttemptIds()).toEqual([attempts[1]]);
});
it('tells the user when the chip they tried to cancel has already been dispatched', async () => {
// 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。
const { invoke, surface, harness } = await openDirectCodexSurface({