Commit 273cc67f9f80ff5ceb6fe9de10a7dc7a0f34f4c2
1 parent
e9a21412
feat(agent-chat): 排队等待期间发 SSE queued/queue_timeout 信号,前端精确显示排队提示
之前排队等待(上条改动)期间前端只有通用的"思考中",最长可能等到 60s 都看不出 是在排队还是在正常生成,容易让人以为卡死了。 - agent-session-lock.js 新增 isLockBusy(agentId):判断此刻是不是真被占着(僵尸锁 不算),供路由层在真正排队前 peek 一眼。 - agent-chat.js 的 /:agentId/chat:锁被占用时提前建立 SSE 连接、先发一个 queued 事件(activeConvId + waitBudgetMs),前端能精确显示"排队等待中";没被占用的 常见路径完全跳过这段,不引入多余的建连时机差异。头一旦按 200 SSE 发出去就不能 再改状态码,排队超时也改用 queue_timeout 事件收尾,不再是独立的 429 JSON 响应 (没被占用、一开始就抢到锁的路径不受影响,仍是普通 SSE)。 - agentChatEvents.js 的 handleEvent 认识这两个新事件类型:queued 把 thinkingText 换成带等待预算的提示;queue_timeout 跟 error 分支一样落一条可见的助手消息, 不会像原来的静默 429 那样挂在界面上没反应。 - 相应更新 agent-chat-interrupt.test.js(断言 SSE 里真的有 queued/queue_timeout 事件,超时分支状态码从 429 改为 200)+ agentChatEvents.test.js 新增两条用例。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwGcvMfUcgCcweN5vjhTkn
Showing
5 changed files
with
120 additions
and
17 deletions
server/routes/agent-chat.js
| @@ -2565,7 +2565,7 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { | @@ -2565,7 +2565,7 @@ router.get('/:agentId/uploads/:convId/:filename', (req, res) => { | ||
| 2565 | // 同一 convId 的多轮对话不受限制(ACP 进程复用) | 2565 | // 同一 convId 的多轮对话不受限制(ACP 进程复用) |
| 2566 | // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配 | 2566 | // 32 号文档方案 B:这把锁挪到了共享模块 agent-session-lock.js,company.js 的招聘装配 |
| 2567 | // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。 | 2567 | // 重试逻辑也要用同一把——两条路径各自维护一份的话,各自都以为自己没有并发。 |
| 2568 | -const { agentChatLocks, waitForLockRelease, acquireLockWithWait } = require('../src/core/agent-session-lock'); | 2568 | +const { agentChatLocks, waitForLockRelease, acquireLockWithWait, isLockBusy } = require('../src/core/agent-session-lock'); |
| 2569 | 2569 | ||
| 2570 | // 排队等待的预算:master/ceo 这类 agent 大部分并发碰撞是 TaskCallback 心跳唤醒跟人类 | 2570 | // 排队等待的预算:master/ceo 这类 agent 大部分并发碰撞是 TaskCallback 心跳唤醒跟人类 |
| 2571 | // 消息撞在一起,占用窗口通常几秒到几十秒——等一等就能自然衔接上,不需要立刻 429。 | 2571 | // 消息撞在一起,占用窗口通常几秒到几十秒——等一等就能自然衔接上,不需要立刻 429。 |
| @@ -2601,24 +2601,45 @@ router.post('/:agentId/chat', async (req, res) => { | @@ -2601,24 +2601,45 @@ router.post('/:agentId/chat', async (req, res) => { | ||
| 2601 | // 那几条路径是 fire-and-forget,429 就等于这次唤醒直接消失、只能靠别处巡检兜底 | 2601 | // 那几条路径是 fire-and-forget,429 就等于这次唤醒直接消失、只能靠别处巡检兜底 |
| 2602 | // (见 docs 里 selfChat 429 漏单的教训)。改成排队等待:锁被占用先原地等一段预算, | 2602 | // (见 docs 里 selfChat 429 漏单的教训)。改成排队等待:锁被占用先原地等一段预算, |
| 2603 | // 大部分碰撞是几秒到几十秒的短占用,等一等就能自然衔接上;等满预算仍抢不到才真正 429。 | 2603 | // 大部分碰撞是几秒到几十秒的短占用,等一等就能自然衔接上;等满预算仍抢不到才真正 429。 |
| 2604 | + // | ||
| 2605 | + // 排队期间前端只看得到通用的"思考中"提示,跟正常生成没有区别,等上几十秒容易让人 | ||
| 2606 | + // 以为卡死了。这里被占用时提前把 SSE 连接建起来发一个 queued 事件(带 activeConvId + | ||
| 2607 | + // 等待预算),前端能精确显示"排队等待中";没被占用的常见路径完全跳过这段,不引入 | ||
| 2608 | + // 多余的建连时机差异。headers 一旦按 200 SSE 发出去就不能再改状态码,所以这条路径下 | ||
| 2609 | + // 排队超时也只能用专门的 queue_timeout 事件通知前端,不能再退回 429。 | ||
| 2610 | + const busyLock = isLockBusy(agentId); | ||
| 2611 | + let sendSSE = null; | ||
| 2612 | + if (busyLock) { | ||
| 2613 | + sendSSE = setupSSE(res); | ||
| 2614 | + sendSSE('queued', { | ||
| 2615 | + activeConvId: busyLock.convId, | ||
| 2616 | + waitBudgetMs: AGENT_CHAT_QUEUE_WAIT_MS, | ||
| 2617 | + }); | ||
| 2618 | + } | ||
| 2619 | + | ||
| 2604 | const acquireResult = await acquireLockWithWait(agentId, convId, AGENT_CHAT_QUEUE_WAIT_MS); | 2620 | const acquireResult = await acquireLockWithWait(agentId, convId, AGENT_CHAT_QUEUE_WAIT_MS); |
| 2605 | if (!acquireResult.acquired) { | 2621 | if (!acquireResult.acquired) { |
| 2606 | const { lock } = acquireResult; | 2622 | const { lock } = acquireResult; |
| 2607 | const elapsed = Date.now() - lock.startedAt; | 2623 | const elapsed = Date.now() - lock.startedAt; |
| 2608 | console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 排队等待 ${(AGENT_CHAT_QUEUE_WAIT_MS / 1000).toFixed(0)}s 后仍被占用 (会话 ${lock.convId},已占 ${(elapsed / 1000).toFixed(0)}s)`); | 2624 | console.log(`[AgentChat] 🔒 拒绝 ${agentId} 并发请求 — 排队等待 ${(AGENT_CHAT_QUEUE_WAIT_MS / 1000).toFixed(0)}s 后仍被占用 (会话 ${lock.convId},已占 ${(elapsed / 1000).toFixed(0)}s)`); |
| 2609 | - return res.status(429).json({ | 2625 | + const payload = { |
| 2610 | error: `Agent ${agentId} 正在执行另一个会话 (${lock.convId}),请稍后重试`, | 2626 | error: `Agent ${agentId} 正在执行另一个会话 (${lock.convId}),请稍后重试`, |
| 2611 | activeConvId: lock.convId, | 2627 | activeConvId: lock.convId, |
| 2612 | elapsedMs: elapsed, | 2628 | elapsedMs: elapsed, |
| 2613 | sameConversation: lock.convId === convId, | 2629 | sameConversation: lock.convId === convId, |
| 2614 | - }); | 2630 | + }; |
| 2631 | + if (sendSSE) { | ||
| 2632 | + sendSSE('queue_timeout', payload); | ||
| 2633 | + return res.end(); | ||
| 2634 | + } | ||
| 2635 | + return res.status(429).json(payload); | ||
| 2615 | } | 2636 | } |
| 2616 | if (acquireResult.staleLock) { | 2637 | if (acquireResult.staleLock) { |
| 2617 | console.warn(`[AgentChat] ⚠️ 强制释放超时锁: ${agentId} / ${acquireResult.staleLock.convId}`); | 2638 | console.warn(`[AgentChat] ⚠️ 强制释放超时锁: ${agentId} / ${acquireResult.staleLock.convId}`); |
| 2618 | } | 2639 | } |
| 2619 | 2640 | ||
| 2620 | const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`; | 2641 | const msgId = `msg-${Date.now()}-${randomUUID().substring(0, 8)}`; |
| 2621 | - const sendSSE = setupSSE(res); | 2642 | + if (!sendSSE) sendSSE = setupSSE(res); |
| 2622 | const identity = loadIdentity(agentDir); | 2643 | const identity = loadIdentity(agentDir); |
| 2623 | 2644 | ||
| 2624 | const kind = resolveKind(req.body); | 2645 | const kind = resolveKind(req.body); |
server/src/core/agent-session-lock.js
| @@ -32,6 +32,18 @@ function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) { | @@ -32,6 +32,18 @@ function waitForLockRelease(agentId, budgetMs = 3000, pollMs = 100) { | ||
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | /** | 34 | /** |
| 35 | + * 判断某个 agent 此刻是不是真被占着(僵尸锁——占用超过 AGENT_LOCK_TIMEOUT——不算占用)。 | ||
| 36 | + * 占用中返回锁本身(供调用方取 convId/startedAt 用于提示),否则返回 null。 | ||
| 37 | + * 供路由层在真正排队前先 peek 一眼,决定要不要提前给前端发"排队中"信号。 | ||
| 38 | + */ | ||
| 39 | +function isLockBusy(agentId) { | ||
| 40 | + const existing = agentChatLocks.get(agentId); | ||
| 41 | + if (!existing) return null; | ||
| 42 | + if (Date.now() - existing.startedAt >= AGENT_LOCK_TIMEOUT) return null; | ||
| 43 | + return existing; | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +/** | ||
| 35 | * 排队等待版加锁:锁被占用时不立刻让调用方判定失败,而是按 pollMs 轮询, | 47 | * 排队等待版加锁:锁被占用时不立刻让调用方判定失败,而是按 pollMs 轮询, |
| 36 | * 在 budgetMs 窗口内锁一空出来就同步 check-and-set(中间没有 await, | 48 | * 在 budgetMs 窗口内锁一空出来就同步 check-and-set(中间没有 await, |
| 37 | * 多个等待者不会同时都以为自己抢到),谁先摸到谁进;budget 耗尽仍抢不到才 | 49 | * 多个等待者不会同时都以为自己抢到),谁先摸到谁进;budget 耗尽仍抢不到才 |
| @@ -43,16 +55,16 @@ function acquireLockWithWait(agentId, convId, budgetMs, pollMs = 200) { | @@ -43,16 +55,16 @@ function acquireLockWithWait(agentId, convId, budgetMs, pollMs = 200) { | ||
| 43 | const deadline = Date.now() + budgetMs; | 55 | const deadline = Date.now() + budgetMs; |
| 44 | const tryAcquire = () => { | 56 | const tryAcquire = () => { |
| 45 | const existing = agentChatLocks.get(agentId); | 57 | const existing = agentChatLocks.get(agentId); |
| 46 | - const stale = !!existing && Date.now() - existing.startedAt >= AGENT_LOCK_TIMEOUT; | ||
| 47 | - if (!existing || stale) { | 58 | + const busy = isLockBusy(agentId); |
| 59 | + if (!busy) { | ||
| 48 | agentChatLocks.set(agentId, { convId, startedAt: Date.now() }); | 60 | agentChatLocks.set(agentId, { convId, startedAt: Date.now() }); |
| 49 | - return resolve({ acquired: true, staleLock: stale ? existing : null }); | 61 | + return resolve({ acquired: true, staleLock: existing && !busy ? existing : null }); |
| 50 | } | 62 | } |
| 51 | - if (Date.now() >= deadline) return resolve({ acquired: false, lock: existing }); | 63 | + if (Date.now() >= deadline) return resolve({ acquired: false, lock: busy }); |
| 52 | setTimeout(tryAcquire, pollMs); | 64 | setTimeout(tryAcquire, pollMs); |
| 53 | }; | 65 | }; |
| 54 | tryAcquire(); | 66 | tryAcquire(); |
| 55 | }); | 67 | }); |
| 56 | } | 68 | } |
| 57 | 69 | ||
| 58 | -module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease, acquireLockWithWait }; | 70 | +module.exports = { agentChatLocks, AGENT_LOCK_TIMEOUT, waitForLockRelease, acquireLockWithWait, isLockBusy }; |
server/tests/unit/agent-chat-interrupt.test.js
| @@ -7,8 +7,11 @@ | @@ -7,8 +7,11 @@ | ||
| 7 | * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断 | 7 | * 2. `stop` 端点现在要等锁真正释放(或 3 秒预算到期)才返回,`cleaned` 字段供前端判断 |
| 8 | * 能不能安全发第二条。 | 8 | * 能不能安全发第二条。 |
| 9 | * 3. 后续改造:并发碰撞不再"立刻 429",改成排队等待 `AGENT_CHAT_QUEUE_WAIT_MS` 预算—— | 9 | * 3. 后续改造:并发碰撞不再"立刻 429",改成排队等待 `AGENT_CHAT_QUEUE_WAIT_MS` 预算—— |
| 10 | - * 等到锁释放就正常执行;等满预算仍抢不到才 429。这里用一个较短的测试专用预算 | ||
| 11 | - * (500ms)验证两条分支:等得到 → 200,等不到 → 429。 | 10 | + * 等到锁释放就正常执行;等满预算仍抢不到才拒绝。这里用一个较短的测试专用预算 |
| 11 | + * (500ms)验证两条分支:等得到 → 正常执行,等不到 → 拒绝。 | ||
| 12 | + * 4. 排队期间前端不能只看到通用的"思考中":锁被占用时会先把 SSE 连接建起来发一个 | ||
| 13 | + * `queued` 事件;等满预算仍抢不到,因为响应头已经按 200 SSE 发出去、不能再改状态码, | ||
| 14 | + * 改用 `queue_timeout` 事件收尾(不再是独立的 429 JSON 响应)。 | ||
| 12 | */ | 15 | */ |
| 13 | process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; | 16 | process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; |
| 14 | process.env.AGENT_SESSION_IDLE_MS = '60000'; | 17 | process.env.AGENT_SESSION_IDLE_MS = '60000'; |
| @@ -56,6 +59,19 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | @@ -56,6 +59,19 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | ||
| 56 | 59 | ||
| 57 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | 60 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 58 | 61 | ||
| 62 | + /** 把 SSE 响应体解析成 [{event, data}],供断言排队/超时事件是否真的发出来了 */ | ||
| 63 | + function parseSSE(text) { | ||
| 64 | + const events = []; | ||
| 65 | + let lastEvent = null; | ||
| 66 | + for (const line of text.split('\n')) { | ||
| 67 | + if (line.startsWith('event: ')) lastEvent = line.slice(7).trim(); | ||
| 68 | + else if (line.startsWith('data: ')) { | ||
| 69 | + try { events.push({ event: lastEvent, data: JSON.parse(line.slice(6)) }); } catch { /* 忽略解析不了的行 */ } | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | + return events; | ||
| 73 | + } | ||
| 74 | + | ||
| 59 | it('同一 convId 的第二个请求在锁释放预算内排队等待,等第一个跑完再执行,不会两个循环同时跑', async () => { | 75 | it('同一 convId 的第二个请求在锁释放预算内排队等待,等第一个跑完再执行,不会两个循环同时跑', async () => { |
| 60 | const conv = 'race-same-conv'; | 76 | const conv = 'race-same-conv'; |
| 61 | const { ClaudeCliProtocol } = require_('@mas/acp-core'); | 77 | const { ClaudeCliProtocol } = require_('@mas/acp-core'); |
| @@ -80,18 +96,24 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | @@ -80,18 +96,24 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | ||
| 80 | }); | 96 | }); |
| 81 | 97 | ||
| 82 | expect(secondRes.status).toBe(200); // 排队等到了,不是瞬间 429 | 98 | expect(secondRes.status).toBe(200); // 排队等到了,不是瞬间 429 |
| 83 | - await secondRes.text(); | 99 | + const secondEvents = parseSSE(await secondRes.text()); |
| 100 | + // 排队期间要先发一条 queued,前端才能显示"排队等待中"而不是通用的"思考中" | ||
| 101 | + const queuedEvent = secondEvents.find(e => e.event === 'queued'); | ||
| 102 | + expect(queuedEvent).toBeTruthy(); | ||
| 103 | + expect(queuedEvent.data.activeConvId).toBe(conv); | ||
| 104 | + // 等到锁之后要继续正常跑完这轮对话,不是发完 queued 就结束 | ||
| 105 | + expect(secondEvents.some(e => e.event === 'session')).toBe(true); | ||
| 84 | await first; | 106 | await first; |
| 85 | 107 | ||
| 86 | expect(calls.length).toBe(2); | 108 | expect(calls.length).toBe(2); |
| 87 | expect(calls[1].start).toBeGreaterThanOrEqual(calls[0].end); // 严格先后,没有重叠 | 109 | expect(calls[1].start).toBeGreaterThanOrEqual(calls[0].end); // 严格先后,没有重叠 |
| 88 | }); | 110 | }); |
| 89 | 111 | ||
| 90 | - it('排队等待预算耗尽仍抢不到锁时返回 429', async () => { | 112 | + it('排队等待预算耗尽仍抢不到锁时以 queue_timeout 事件收尾(响应头已按 200 SSE 发出,改不了状态码)', async () => { |
| 91 | const conv = 'race-timeout'; | 113 | const conv = 'race-timeout'; |
| 92 | const { ClaudeCliProtocol } = require_('@mas/acp-core'); | 114 | const { ClaudeCliProtocol } = require_('@mas/acp-core'); |
| 93 | const original = ClaudeCliProtocol.prototype.sendPrompt; | 115 | const original = ClaudeCliProtocol.prototype.sendPrompt; |
| 94 | - // 延迟(900ms) > 测试预算(500ms):排队等满预算还是抢不到,只能 429。 | 116 | + // 延迟(900ms) > 测试预算(500ms):排队等满预算还是抢不到。 |
| 95 | vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) { | 117 | vi.spyOn(ClaudeCliProtocol.prototype, 'sendPrompt').mockImplementation(function (...args) { |
| 96 | return sleep(900).then(() => original.apply(this, args)); | 118 | return sleep(900).then(() => original.apply(this, args)); |
| 97 | }); | 119 | }); |
| @@ -106,9 +128,13 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | @@ -106,9 +128,13 @@ describe('agent-chat · 打断-排队(40 号文档)', () => { | ||
| 106 | body: JSON.stringify({ message: 'second', conversationId: conv }), | 128 | body: JSON.stringify({ message: 'second', conversationId: conv }), |
| 107 | }); | 129 | }); |
| 108 | 130 | ||
| 109 | - expect(second.status).toBe(429); | ||
| 110 | - const body = await second.json(); | ||
| 111 | - expect(body.sameConversation).toBe(true); | 131 | + expect(second.status).toBe(200); // 头已经按 SSE 发出去了,不再是独立的 429 JSON 响应 |
| 132 | + const events = parseSSE(await second.text()); | ||
| 133 | + expect(events.some(e => e.event === 'queued')).toBe(true); | ||
| 134 | + const timeoutEvent = events.find(e => e.event === 'queue_timeout'); | ||
| 135 | + expect(timeoutEvent).toBeTruthy(); | ||
| 136 | + expect(timeoutEvent.data.sameConversation).toBe(true); | ||
| 137 | + expect(timeoutEvent.data.activeConvId).toBe(conv); | ||
| 112 | 138 | ||
| 113 | await first; | 139 | await first; |
| 114 | }); | 140 | }); |
web/src/composables/agentChatEvents.js
| @@ -80,6 +80,22 @@ export function createAgentChatEventHandler({ | @@ -80,6 +80,22 @@ export function createAgentChatEventHandler({ | ||
| 80 | st.thinkingText = data.message || '思考中...'; | 80 | st.thinkingText = data.message || '思考中...'; |
| 81 | } | 81 | } |
| 82 | break; | 82 | break; |
| 83 | + // 429 排队等待改造:锁被占用时后端会先发这条,让"思考中"从通用文案变成 | ||
| 84 | + // 能看出真实原因的排队提示,而不是干等到超时/开始输出都看不出区别。 | ||
| 85 | + case 'queued': { | ||
| 86 | + const waitSec = Math.round((data.waitBudgetMs || 0) / 1000); | ||
| 87 | + st.thinkingText = `⏳ 排队等待中 — Agent 正在处理另一个会话,最多等待 ${waitSec} 秒`; | ||
| 88 | + if (isCurrent) scrollToBottom(); | ||
| 89 | + break; | ||
| 90 | + } | ||
| 91 | + // 排队等到预算耗尽:这条路径的响应头已经按 200 SSE 发出去了,后端没法再改状态码, | ||
| 92 | + // 只能用这个专门的事件类型收尾——跟 'error' 分支一样直接落一条可见的助手消息。 | ||
| 93 | + case 'queue_timeout': | ||
| 94 | + st.messages.push({ role: 'assistant', content: `⏳ ${data.error || 'Agent 排队等待超时,请稍后重试'}`, timestamp: new Date().toISOString() }); | ||
| 95 | + st.thinkingText = ''; | ||
| 96 | + st.thinkingContent = ''; | ||
| 97 | + if (isCurrent) scrollToBottom(); | ||
| 98 | + break; | ||
| 83 | case 'agent_output': | 99 | case 'agent_output': |
| 84 | st.streaming = true; | 100 | st.streaming = true; |
| 85 | pushChunk(st, data.message || ''); | 101 | pushChunk(st, data.message || ''); |
web/src/composables/agentChatEvents.test.js
| @@ -87,6 +87,34 @@ describe('agentChatEvents · 串台加固', () => { | @@ -87,6 +87,34 @@ describe('agentChatEvents · 串台加固', () => { | ||
| 87 | }); | 87 | }); |
| 88 | }); | 88 | }); |
| 89 | 89 | ||
| 90 | +describe('agentChatEvents · 排队等待提示(429 排队等待改造)', () => { | ||
| 91 | + const handleEvent = createAgentChatEventHandler({ | ||
| 92 | + getCurrentAgentId: () => 'agent-A', | ||
| 93 | + onNewConversation: vi.fn(), | ||
| 94 | + scrollToBottom: vi.fn(), | ||
| 95 | + scrollAcpLog: vi.fn(), | ||
| 96 | + scrollSubLogs: vi.fn(), | ||
| 97 | + }); | ||
| 98 | + | ||
| 99 | + it('queued 事件把思考文案换成带等待预算的排队提示,而不是通用的"思考中"', () => { | ||
| 100 | + const st = makeState(); | ||
| 101 | + handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 60000 }); | ||
| 102 | + expect(st.thinkingText).toContain('排队等待中'); | ||
| 103 | + expect(st.thinkingText).toContain('60'); | ||
| 104 | + }); | ||
| 105 | + | ||
| 106 | + it('queue_timeout 事件落一条可见的助手消息,而不是像原来 429 那样静默无回复', () => { | ||
| 107 | + const st = makeState(); | ||
| 108 | + handleEvent(st, 'agent-A', 'queued', { activeConvId: 'conv-busy', waitBudgetMs: 500 }); | ||
| 109 | + handleEvent(st, 'agent-A', 'queue_timeout', { error: 'Agent 正在执行另一个会话 (conv-busy),请稍后重试' }); | ||
| 110 | + | ||
| 111 | + expect(st.messages).toHaveLength(1); | ||
| 112 | + expect(st.messages[0].role).toBe('assistant'); | ||
| 113 | + expect(st.messages[0].content).toContain('conv-busy'); | ||
| 114 | + expect(st.thinkingText).toBe(''); // 收尾清空,不留一个卡住的"排队等待中" | ||
| 115 | + }); | ||
| 116 | +}); | ||
| 117 | + | ||
| 90 | describe('agentChatEvents · 打字机渲染缓冲', () => { | 118 | describe('agentChatEvents · 打字机渲染缓冲', () => { |
| 91 | it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => { | 119 | it('文本按固定节奏匀速吐出,而不是一次性整段落地', () => { |
| 92 | vi.useFakeTimers(); | 120 | vi.useFakeTimers(); |