|
@@ -733,10 +733,9 @@ describe('ChatService', () => {
|
|
|
it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
|
|
it('第二次发送带上一条消息的 event_id(m.in_reply_to 降级用)', async () => {
|
|
|
await svc.sendText('第一条');
|
|
await svc.sendText('第一条');
|
|
|
/*
|
|
/*
|
|
|
- * 2026-11-14:连发会被判忙拦住(新行为),这里模拟容器已回、
|
|
|
|
|
- * 第一轮真正结束,以便验证「第二次发送」的 event_id 逻辑本身。
|
|
|
|
|
|
|
+ * 2026-11-14:发送不再拦截(用户裁决「永远不拦」),
|
|
|
|
|
+ * 这里不必再模拟「第一轮结束」,直接发第二条即可。
|
|
|
*/
|
|
*/
|
|
|
- (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince.set(0);
|
|
|
|
|
mxStub.sendText.calls.reset();
|
|
mxStub.sendText.calls.reset();
|
|
|
await svc.sendText('第二条');
|
|
await svc.sendText('第二条');
|
|
|
// 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
|
|
// 第一条是本地乐观 id(local-*),不是真实 event,故 lastEventId 应为 undefined
|
|
@@ -1651,12 +1650,13 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
|
|
|
expect(mine[0].id).toBe('$e1');
|
|
expect(mine[0].id).toBe('$e1');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- it('🔴 连点两次发送 → 第二条被拦截进待发区(2026-11-14 行为变更)', async () => {
|
|
|
|
|
|
|
+ it('🔴 连点两次发送 → 两条都真正发出(2026-11-14 起不再拦截)', async () => {
|
|
|
/*
|
|
/*
|
|
|
- * 用户裁决:连点也和「立刻再发」一样拦住,行为保持一致。
|
|
|
|
|
|
|
+ * 用户裁决(2026-11-14):**永远不拦,直接发**。
|
|
|
*
|
|
*
|
|
|
- * 原期望「两条都发出」,但新逻辑下第一条发出后立刻判忙
|
|
|
|
|
- * (`_awaitingReplySince` 填补真空期),第二条应进 heldMessage。
|
|
|
|
|
|
|
+ * 原行为是连点第二条进待发区(持有条),用户实测「容器已经结束返回了,
|
|
|
|
|
+ * 再发一条还是进等待状态」,认为拦截带来的困扰大于打断代价。
|
|
|
|
|
+ * 现在两条都必须真正发出。
|
|
|
*/
|
|
*/
|
|
|
await svc.open('agent-node-lj-test');
|
|
await svc.open('agent-node-lj-test');
|
|
|
mxStub.sendText.and.callFake(
|
|
mxStub.sendText.and.callFake(
|
|
@@ -1665,10 +1665,34 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
|
|
|
await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
|
|
await Promise.all([svc.sendText('第一条'), svc.sendText('第二条')]);
|
|
|
|
|
|
|
|
const mine = svc.messages().filter((m) => m.sender === 'me');
|
|
const mine = svc.messages().filter((m) => m.sender === 'me');
|
|
|
- expect(mine.length).withContext('只有第一条真正发出').toBe(1);
|
|
|
|
|
- expect(svc.heldMessage()?.text)
|
|
|
|
|
- .withContext('第二条应进待发区,等用户确认')
|
|
|
|
|
- .toBe('第二条');
|
|
|
|
|
|
|
+ expect(mine.length).withContext('两条都要真正发出').toBe(2);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('🔴 本会话在跑时发送不被拦截(2026-11-14 用户裁决)', async () => {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * `open()` 必须在塞入「已发出未回复」标记**之前**完成:
|
|
|
|
|
+ * 它会复位该标记,放在后面会把前置条件冲掉。
|
|
|
|
|
+ */
|
|
|
|
|
+ await svc.open('agent-node-lj-test');
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 制造「本会话正在跑」:`_awaitingReplySince` 非 0 时 `sessionBusy()` 为真。
|
|
|
|
|
+ * 这正是用户实测的场景 —— 上一轮的忙判还没消退,新消息被吞进持有条。
|
|
|
|
|
+ */
|
|
|
|
|
+ (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince.set(
|
|
|
|
|
+ Date.now(),
|
|
|
|
|
+ );
|
|
|
|
|
+ expect(svc.sessionBusy()).withContext('前置:判忙').toBeTrue();
|
|
|
|
|
+
|
|
|
|
|
+ /* 本组的 sendText 桩是「挂了不 resolve」的,且首发要先 await 落库(见上一条用例),
|
|
|
|
|
+ 所以必须显式放开、并给足微任务轮次,否则 await 卡死超时 */
|
|
|
|
|
+ const p = svc.sendText('打断它');
|
|
|
|
|
+ for (let k = 0; k < 5; k++) await Promise.resolve();
|
|
|
|
|
+ await new Promise((r) => setTimeout(r, 0));
|
|
|
|
|
+ releaseSend();
|
|
|
|
|
+ await p;
|
|
|
|
|
+
|
|
|
|
|
+ const mine = svc.messages().filter((m) => m.sender === 'me');
|
|
|
|
|
+ expect(mine.map((m) => m.text)).withContext('忙时发送也要直接发出').toContain('打断它');
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
|
|
it('失败消息在实时流到达后仍保留(可重试,AC-04.8)', async () => {
|
|
@@ -1733,67 +1757,23 @@ describe('ChatService · 新建会话期间不得回填历史(2026-11-08)',
|
|
|
expect(svc.turnSettledLocally()).withContext('前置:上一轮已结束').toBeTrue();
|
|
expect(svc.turnSettledLocally()).withContext('前置:上一轮已结束').toBeTrue();
|
|
|
|
|
|
|
|
/*
|
|
/*
|
|
|
- * 不 await `sendText`:新逻辑下发出后会判忙,`sendText` 内部会走
|
|
|
|
|
- * 等待/挂起路径,await 会一直挂着。这里只验证**发出的同步副作用**。
|
|
|
|
|
|
|
+ * 2026-11-14:发送不再拦截,「不 await 以免挂起」的顾虑已不成立,
|
|
|
|
|
+ * 但仍然只验证发出的同步副作用。
|
|
|
*/
|
|
*/
|
|
|
void svc.sendText('新的一句话');
|
|
void svc.sendText('新的一句话');
|
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
expect(svc.turnEndedAfter())
|
|
expect(svc.turnEndedAfter())
|
|
|
- .withContext('发出新消息后旧的结束信号必须清掉,否则会误判为空闲而不拦截')
|
|
|
|
|
|
|
+ .withContext('发出新消息后旧的结束信号必须清掉,否则会误判为空闲')
|
|
|
.toBeNull();
|
|
.toBeNull();
|
|
|
expect(svc.turnSettledLocally()).toBeFalse();
|
|
expect(svc.turnSettledLocally()).toBeFalse();
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
/*
|
|
/*
|
|
|
- * 持有条在会话空闲后自动清除(2026-11-14 用户实测修复)。
|
|
|
|
|
- *
|
|
|
|
|
- * 用户截图:会话早已结束、成果都出来了,输入框上方仍挂着
|
|
|
|
|
- * 「任务执行中 · 确认发送将中断当前任务」的持有条。
|
|
|
|
|
- *
|
|
|
|
|
- * 根因:「不点不发」改动移除了 `armHeldAutoSend()`,但没加替代的清理机制。
|
|
|
|
|
|
|
+ * 「持有条自动清除」用例已随持有条机制一并删除(2026-11-14 用户裁决「永远不拦」)。
|
|
|
|
|
+ * 相关验证见上方「本会话在跑时发送不被拦截」。
|
|
|
*/
|
|
*/
|
|
|
- describe('持有条自动清除', () => {
|
|
|
|
|
- const awaiting = (): { set(v: number): void } =>
|
|
|
|
|
- (svc as unknown as { _awaitingReplySince: { set(v: number): void } })._awaitingReplySince;
|
|
|
|
|
- const held = (): { set(v: unknown): void } =>
|
|
|
|
|
- (svc as unknown as { _heldMessage: { set(v: unknown): void } })._heldMessage;
|
|
|
|
|
-
|
|
|
|
|
- it('🔴 会话空闲后,持有条自动撤回(回到草稿,不自动发出)', async () => {
|
|
|
|
|
- (environment as { useMockData: boolean }).useMockData = false;
|
|
|
|
|
- mxStub.timelineToMessages.and.returnValue([]);
|
|
|
|
|
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
|
|
|
|
|
- await svc.open('agent-node-lj-test');
|
|
|
|
|
-
|
|
|
|
|
- awaiting().set(Date.now());
|
|
|
|
|
- held().set({ text: '被拦住的那条', at: Date.now() });
|
|
|
|
|
- expect(svc.sessionBusy()).withContext('前置:判忙').toBeTrue();
|
|
|
|
|
-
|
|
|
|
|
- awaiting().set(0);
|
|
|
|
|
- await new Promise((r) => setTimeout(r));
|
|
|
|
|
-
|
|
|
|
|
- expect(svc.heldMessage()).withContext('空闲后持有条必须消失').toBeNull();
|
|
|
|
|
- expect(svc.draft()).withContext('内容回到输入框,不自动发出').toBe('被拦住的那条');
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- it('草稿已有新内容时,旧持有内容直接丢弃不覆盖', async () => {
|
|
|
|
|
- (environment as { useMockData: boolean }).useMockData = false;
|
|
|
|
|
- mxStub.timelineToMessages.and.returnValue([]);
|
|
|
|
|
- apiStub.fetchChatThreads.and.returnValue(Promise.resolve([]));
|
|
|
|
|
- await svc.open('agent-node-lj-test');
|
|
|
|
|
-
|
|
|
|
|
- awaiting().set(Date.now());
|
|
|
|
|
- held().set({ text: '旧的那条', at: Date.now() });
|
|
|
|
|
- svc.setDraft('用户正在写的新内容');
|
|
|
|
|
-
|
|
|
|
|
- awaiting().set(0);
|
|
|
|
|
- await new Promise((r) => setTimeout(r));
|
|
|
|
|
-
|
|
|
|
|
- expect(svc.heldMessage()).toBeNull();
|
|
|
|
|
- expect(svc.draft()).withContext('不能覆盖用户正在写的内容').toBe('用户正在写的新内容');
|
|
|
|
|
- });
|
|
|
|
|
- });
|
|
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
/**
|
|
/**
|