@veltra/ai 2.2.1 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/fold.js +14 -2
- package/dist/chat/fold.js.map +1 -1
- package/dist/chat/session.d.ts +13 -6
- package/dist/chat/session.js.map +1 -1
- package/dist/chat/transports/openai.d.ts +2 -1
- package/dist/chat/transports/openai.js +20 -213
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/use-chat-session.js +3 -13
- package/dist/chat/use-chat-session.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/approval-banner.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/job-bar.js.map +1 -1
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/tool-json.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js.map +1 -1
- package/package.json +3 -3
- package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
- package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
- package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
- package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
- package/src/chat/__test__/fold.test.ts +26 -1
- package/src/chat/__test__/session.test.ts +1 -1
- package/src/chat/__test__/use-chat.test.ts +6 -3
- package/src/chat/fold.ts +16 -1
- package/src/chat/session.ts +13 -6
- package/src/chat/transports/__test__/openai.test.ts +1 -1
- package/src/chat/transports/openai.ts +25 -288
- package/src/chat/use-chat-session.ts +4 -5
- package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
- package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
- package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
- package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 复现床架(bedrock/opencode)会话面板「消息数量跳变 / 完成与思考中来回翻转」的仿真测试。
|
|
3
|
+
*
|
|
4
|
+
* 驱动源是 opencode 1.18.31 实测事件序列(慢工具 + 排队续问),经真实的
|
|
5
|
+
* bedrock adapter(/api/harness.ts)→ createServerTransport → fold → message-list
|
|
6
|
+
* turns 分组逐帧检查 UI 可见状态。
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
|
9
|
+
|
|
10
|
+
vi.mock('./fixtures/bedrock/http', () => {
|
|
11
|
+
return {
|
|
12
|
+
getAccessToken: () => 'test-token',
|
|
13
|
+
http: {
|
|
14
|
+
get: (...args: unknown[]) => mockHttpGet(...(args as [string, never])),
|
|
15
|
+
post: (...args: unknown[]) => mockHttpPost(...(args as [string, never]))
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line import/first
|
|
21
|
+
import { createFoldState, foldSessionEvent, type ChatFoldState } from '../fold'
|
|
22
|
+
// eslint-disable-next-line import/first
|
|
23
|
+
import { createServerTransport } from '../session'
|
|
24
|
+
// eslint-disable-next-line import/first
|
|
25
|
+
import { createHarnessSessionAdapter, type HarnessFrame } from './fixtures/bedrock/harness'
|
|
26
|
+
|
|
27
|
+
// ---- mock 网络层 ----
|
|
28
|
+
|
|
29
|
+
type HistoryMessage = {
|
|
30
|
+
id: string
|
|
31
|
+
role: string
|
|
32
|
+
content?: {
|
|
33
|
+
type: string
|
|
34
|
+
id?: string
|
|
35
|
+
text?: string
|
|
36
|
+
callID?: string
|
|
37
|
+
name?: string
|
|
38
|
+
state?: unknown
|
|
39
|
+
}[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const historyStore: HistoryMessage[] = []
|
|
43
|
+
let mockHttpGet: (
|
|
44
|
+
path: string,
|
|
45
|
+
opts?: { query?: Record<string, unknown> }
|
|
46
|
+
) => Promise<{ body: unknown }> = async () => ({ body: {} })
|
|
47
|
+
let mockHttpPost: (path: string, body?: unknown) => Promise<{ body: unknown }> = async () => ({
|
|
48
|
+
body: {}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
function setHistory(list: HistoryMessage[]): void {
|
|
52
|
+
historyStore.length = 0
|
|
53
|
+
historyStore.push(...list)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- mock WebSocket ----
|
|
57
|
+
|
|
58
|
+
interface MockSocket {
|
|
59
|
+
url: string
|
|
60
|
+
onmessage: ((e: { data: string }) => void) | null
|
|
61
|
+
onclose: (() => void) | null
|
|
62
|
+
onerror: (() => void) | null
|
|
63
|
+
close(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let sockets: MockSocket[] = []
|
|
67
|
+
let serverReplay: ((after: number) => HarnessFrame[]) | null = null
|
|
68
|
+
|
|
69
|
+
class MockWebSocket implements MockSocket {
|
|
70
|
+
url: string
|
|
71
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
72
|
+
onclose: (() => void) | null = null
|
|
73
|
+
onerror: (() => void) | null = null
|
|
74
|
+
closed = false
|
|
75
|
+
|
|
76
|
+
constructor(url: string) {
|
|
77
|
+
this.url = url
|
|
78
|
+
sockets.push(this)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close(): void {
|
|
82
|
+
if (this.closed) return
|
|
83
|
+
this.closed = true
|
|
84
|
+
this.onclose?.()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 测试端注入一帧 */
|
|
88
|
+
serverPush(frame: HarnessFrame): void {
|
|
89
|
+
this.onmessage?.({ data: JSON.stringify(frame) })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 模拟服务端:回放 after 之后的持久帧 */
|
|
93
|
+
serverConnect(): void {
|
|
94
|
+
const after = Number(new URL(this.url).searchParams.get('after') ?? '0')
|
|
95
|
+
for (const frame of serverReplay?.(after) ?? []) this.serverPush(frame)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- message-list 的 turns 分组(照抄 message-list.vue) ----
|
|
100
|
+
|
|
101
|
+
interface ChatMessageLike {
|
|
102
|
+
id: string
|
|
103
|
+
role: string
|
|
104
|
+
content: string
|
|
105
|
+
status?: string
|
|
106
|
+
reasoning?: string
|
|
107
|
+
toolCalls?: unknown[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isFinalStatus = (msg?: ChatMessageLike) =>
|
|
111
|
+
msg?.status === 'done' || msg?.status === 'error' || msg?.status === 'aborted'
|
|
112
|
+
|
|
113
|
+
/** 返回 UI 可见结构: 每轮 { user, processCount | 已完成fold, answer } */
|
|
114
|
+
function uiSnapshot(messages: ChatMessageLike[], running: boolean) {
|
|
115
|
+
const visible = messages.filter((m) => m.role !== 'tool')
|
|
116
|
+
const grouped: { key: string; userMsg?: ChatMessageLike; assistants: ChatMessageLike[] }[] = []
|
|
117
|
+
for (const msg of visible) {
|
|
118
|
+
if (msg.role === 'user') {
|
|
119
|
+
grouped.push({ key: msg.id, userMsg: msg, assistants: [] })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
const last = grouped[grouped.length - 1]
|
|
123
|
+
if (last) last.assistants.push(msg)
|
|
124
|
+
else grouped.push({ key: msg.id, assistants: [msg] })
|
|
125
|
+
}
|
|
126
|
+
let visibleItems = 0
|
|
127
|
+
const parts: string[] = []
|
|
128
|
+
grouped.forEach((turn, index) => {
|
|
129
|
+
const processMsgs = turn.assistants.slice(0, -1)
|
|
130
|
+
const answerMsg = turn.assistants[turn.assistants.length - 1]
|
|
131
|
+
const isLastTurn = index === grouped.length - 1
|
|
132
|
+
const processCollapsed =
|
|
133
|
+
processMsgs.length > 0 && (!isLastTurn || (!running && isFinalStatus(answerMsg)))
|
|
134
|
+
if (turn.userMsg) visibleItems += 1
|
|
135
|
+
if (processCollapsed) {
|
|
136
|
+
visibleItems += 1
|
|
137
|
+
parts.push(`fold(${processMsgs.length})`)
|
|
138
|
+
} else {
|
|
139
|
+
visibleItems += processMsgs.length
|
|
140
|
+
parts.push(...processMsgs.map((m) => `proc:${m.status}`))
|
|
141
|
+
}
|
|
142
|
+
if (answerMsg) {
|
|
143
|
+
visibleItems += 1
|
|
144
|
+
const thinking = answerMsg.status === 'streaming' && !answerMsg.content
|
|
145
|
+
parts.push(`answer:${answerMsg.status}${thinking ? '(思考中…)' : ''}`)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
return { running, visibleItems, parts }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- 帧构造工具(对齐 opencode 实测 wire) ----
|
|
152
|
+
|
|
153
|
+
const SES = 'ses_test'
|
|
154
|
+
let seq = 0
|
|
155
|
+
const nextSeq = () => ++seq
|
|
156
|
+
|
|
157
|
+
function statusFrame(name: string, extra: Record<string, unknown> = {}): HarnessFrame {
|
|
158
|
+
return { seq: 0, sessionId: SES, kind: 'status', status: { name, ...extra } as never }
|
|
159
|
+
}
|
|
160
|
+
function durable(frame: HarnessFrame, s: number): HarnessFrame {
|
|
161
|
+
frame.seq = s
|
|
162
|
+
return frame
|
|
163
|
+
}
|
|
164
|
+
function textDelta(msgId: string, delta: string): HarnessFrame {
|
|
165
|
+
return {
|
|
166
|
+
seq: 0,
|
|
167
|
+
sessionId: SES,
|
|
168
|
+
kind: 'message_delta',
|
|
169
|
+
messageDelta: { assistantMessageId: msgId, textId: 'text-0', delta }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function reasoningDelta(msgId: string, delta: string): HarnessFrame {
|
|
173
|
+
return {
|
|
174
|
+
seq: 0,
|
|
175
|
+
sessionId: SES,
|
|
176
|
+
kind: 'reasoning_delta',
|
|
177
|
+
reasoningDelta: { assistantMessageId: msgId, delta }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function textEnded(msgId: string, text: string): HarnessFrame {
|
|
181
|
+
return {
|
|
182
|
+
seq: nextSeq(),
|
|
183
|
+
sessionId: SES,
|
|
184
|
+
kind: 'message_text',
|
|
185
|
+
messageText: { assistantMessageId: msgId, textId: 'text-0', text }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function toolCall(msgId: string, callId: string): HarnessFrame {
|
|
189
|
+
return {
|
|
190
|
+
seq: nextSeq(),
|
|
191
|
+
sessionId: SES,
|
|
192
|
+
kind: 'tool_call',
|
|
193
|
+
toolCall: { assistantMessageId: msgId, callId, tool: 'bash', input: { command: 'sleep 8' } }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function toolResult(callId: string): HarnessFrame {
|
|
197
|
+
return {
|
|
198
|
+
seq: nextSeq(),
|
|
199
|
+
sessionId: SES,
|
|
200
|
+
kind: 'tool_result',
|
|
201
|
+
toolResult: { assistantMessageId: 'msg_A', callId, output: 'slow-done' }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- 测试驱动 ----
|
|
206
|
+
|
|
207
|
+
describe('bedrock harness 会话面板仿真', () => {
|
|
208
|
+
let state: ChatFoldState
|
|
209
|
+
let dispose: (() => void) | undefined
|
|
210
|
+
let userMsgSeq = 100 // opencode 用户消息历史 id 序列
|
|
211
|
+
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
vi.useFakeTimers()
|
|
214
|
+
sockets = []
|
|
215
|
+
serverReplay = null
|
|
216
|
+
seq = 0
|
|
217
|
+
setHistory([])
|
|
218
|
+
mockHttpGet = async (path: string) => {
|
|
219
|
+
if (path.endsWith('/messages')) {
|
|
220
|
+
return { body: { items: [...historyStore], total_pages: 1 } }
|
|
221
|
+
}
|
|
222
|
+
return { body: {} }
|
|
223
|
+
}
|
|
224
|
+
mockHttpPost = async () => ({ body: { id: '', admitted_seq: 0 } })
|
|
225
|
+
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
dispose?.()
|
|
230
|
+
vi.unstubAllGlobals()
|
|
231
|
+
vi.useRealTimers()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
function attach(): void {
|
|
235
|
+
const adapter = createHarnessSessionAdapter(SES, { initialPrompt: '跑起来' })
|
|
236
|
+
const transport = createServerTransport(adapter)
|
|
237
|
+
state = createFoldState()
|
|
238
|
+
dispose = transport.open({
|
|
239
|
+
onEvent(event) {
|
|
240
|
+
state = foldSessionEvent(state, event)
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function flush(): Promise<void> {
|
|
246
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
247
|
+
await Promise.resolve()
|
|
248
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
it('场景A: 健康单轮(慢工具+思考) 不应出现状态来回翻转', async () => {
|
|
252
|
+
attach()
|
|
253
|
+
// 订阅内部 fetchHistory 完成
|
|
254
|
+
await flush()
|
|
255
|
+
const ws = sockets[0]!
|
|
256
|
+
ws.serverConnect()
|
|
257
|
+
|
|
258
|
+
const flips: string[] = []
|
|
259
|
+
const record = (label: string) => {
|
|
260
|
+
const snap = uiSnapshot(state.messages as unknown as ChatMessageLike[], state.running)
|
|
261
|
+
flips.push(
|
|
262
|
+
`${label} => running=${snap.running} items=${snap.visibleItems} [${snap.parts.join(', ')}]`
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
setHistory([{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] }])
|
|
267
|
+
await flush()
|
|
268
|
+
|
|
269
|
+
ws.serverPush(durable(statusFrame('prompt_admitted', { messageId: 'msg_user' }), nextSeq()))
|
|
270
|
+
ws.serverPush(durable(statusFrame('prompted', { messageId: 'msg_user' }), nextSeq()))
|
|
271
|
+
record('prompted')
|
|
272
|
+
|
|
273
|
+
// step1: msg_A 思考 + 工具调用(慢工具 8s,期间无事件)
|
|
274
|
+
ws.serverPush(durable(statusFrame('step_started', { assistantMessageId: 'msg_A' }), nextSeq()))
|
|
275
|
+
ws.serverPush(reasoningDelta('msg_A', '正在思考工具调用...'))
|
|
276
|
+
record('step1-reasoning')
|
|
277
|
+
ws.serverPush(durable(toolCall('msg_A', 'call_1'), nextSeq()))
|
|
278
|
+
record('step1-toolcall')
|
|
279
|
+
// 8s 工具静默期
|
|
280
|
+
await vi.advanceTimersByTimeAsync(8000)
|
|
281
|
+
ws.serverPush(durable(toolResult('call_1'), nextSeq()))
|
|
282
|
+
ws.serverPush(durable(statusFrame('step_ended', { assistantMessageId: 'msg_A' }), nextSeq()))
|
|
283
|
+
setHistory([
|
|
284
|
+
{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] },
|
|
285
|
+
{
|
|
286
|
+
id: 'msg_A',
|
|
287
|
+
role: 'assistant',
|
|
288
|
+
content: [
|
|
289
|
+
{
|
|
290
|
+
type: 'tool',
|
|
291
|
+
callID: 'call_1',
|
|
292
|
+
name: 'bash',
|
|
293
|
+
state: { status: 'completed', input: {}, output: 'slow-done' }
|
|
294
|
+
}
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
])
|
|
298
|
+
record('step1-ended')
|
|
299
|
+
|
|
300
|
+
// step2: msg_B 正文流式
|
|
301
|
+
ws.serverPush(durable(statusFrame('step_started', { assistantMessageId: 'msg_B' }), nextSeq()))
|
|
302
|
+
ws.serverPush(textDelta('msg_B', '命令'))
|
|
303
|
+
record('step2-delta1')
|
|
304
|
+
ws.serverPush(textDelta('msg_B', '输出为 slow-done'))
|
|
305
|
+
record('step2-delta2')
|
|
306
|
+
ws.serverPush(durable(textEnded('msg_B', '命令输出为 slow-done'), nextSeq()))
|
|
307
|
+
ws.serverPush(durable(statusFrame('step_ended', { assistantMessageId: 'msg_B' }), nextSeq()))
|
|
308
|
+
setHistory([
|
|
309
|
+
{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] },
|
|
310
|
+
{
|
|
311
|
+
id: 'msg_A',
|
|
312
|
+
role: 'assistant',
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: 'tool',
|
|
316
|
+
callID: 'call_1',
|
|
317
|
+
name: 'bash',
|
|
318
|
+
state: { status: 'completed', input: {}, output: 'slow-done' }
|
|
319
|
+
}
|
|
320
|
+
]
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: 'msg_B',
|
|
324
|
+
role: 'assistant',
|
|
325
|
+
content: [{ type: 'text', id: 'text-0', text: '命令输出为 slow-done' }]
|
|
326
|
+
}
|
|
327
|
+
])
|
|
328
|
+
record('turn1-end')
|
|
329
|
+
|
|
330
|
+
// 桥 2s 后合成 idle
|
|
331
|
+
ws.serverPush(statusFrame('idle'))
|
|
332
|
+
record('idle')
|
|
333
|
+
|
|
334
|
+
// 健康路径: idle 前不应出现 fold(已完成) 与 running=false
|
|
335
|
+
const beforeIdle = flips.slice(0, -1)
|
|
336
|
+
for (const line of beforeIdle) {
|
|
337
|
+
expect(line).not.toMatch(/running=false/)
|
|
338
|
+
expect(line).not.toMatch(/fold/)
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('场景B: WS 断线重连(流式中途) 观察状态翻转与内容重复', async () => {
|
|
343
|
+
attach()
|
|
344
|
+
await flush()
|
|
345
|
+
const ws = sockets[0]!
|
|
346
|
+
ws.serverConnect()
|
|
347
|
+
setHistory([{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] }])
|
|
348
|
+
await flush()
|
|
349
|
+
|
|
350
|
+
const flips: string[] = []
|
|
351
|
+
const record = (label: string) => {
|
|
352
|
+
const snap = uiSnapshot(state.messages as unknown as ChatMessageLike[], state.running)
|
|
353
|
+
flips.push(
|
|
354
|
+
`${label} => running=${snap.running} items=${snap.visibleItems} [${snap.parts.join(', ')}]`
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
ws.serverPush(durable(statusFrame('prompt_admitted', { messageId: 'msg_user' }), nextSeq()))
|
|
359
|
+
ws.serverPush(durable(statusFrame('prompted', { messageId: 'msg_user' }), nextSeq()))
|
|
360
|
+
ws.serverPush(durable(statusFrame('step_started', { assistantMessageId: 'msg_B' }), nextSeq()))
|
|
361
|
+
ws.serverPush(textDelta('msg_B', '命令输出'))
|
|
362
|
+
record('streaming')
|
|
363
|
+
|
|
364
|
+
// 流式中途断线: 历史里已有部分文本(opencode 持久化 text part 的中间快照)
|
|
365
|
+
setHistory([
|
|
366
|
+
{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] },
|
|
367
|
+
{
|
|
368
|
+
id: 'msg_B',
|
|
369
|
+
role: 'assistant',
|
|
370
|
+
content: [{ type: 'text', id: 'text-0', text: '命令输出' }]
|
|
371
|
+
}
|
|
372
|
+
])
|
|
373
|
+
ws.close()
|
|
374
|
+
// onDisconnect → fetchHistory(历史快照可能落后); 重连退避(翻倍到 2s)
|
|
375
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
376
|
+
record('disconnected(补拉后)')
|
|
377
|
+
await vi.advanceTimersByTimeAsync(2500)
|
|
378
|
+
const ws2 = sockets[1]!
|
|
379
|
+
// 服务端回放 after 之后无新帧; 直播继续
|
|
380
|
+
serverReplay = () => []
|
|
381
|
+
ws2.serverConnect()
|
|
382
|
+
ws2.serverPush(textDelta('msg_B', '为 slow-done'))
|
|
383
|
+
record('重连续流')
|
|
384
|
+
ws2.serverPush(durable(textEnded('msg_B', '命令输出为 slow-done'), nextSeq()))
|
|
385
|
+
record('text-ended')
|
|
386
|
+
|
|
387
|
+
const msgB = () =>
|
|
388
|
+
(state.messages as unknown as ChatMessageLike[]).find((m) => m.id === 'msg_B')
|
|
389
|
+
// 断线补拉不得把流式消息标记 done(状态不翻转)
|
|
390
|
+
expect(flips[1]).toMatch(/answer:streaming/)
|
|
391
|
+
// 重连续流仍为 streaming, 且终态帧落地为 done、内容为全文
|
|
392
|
+
expect(flips[2]).toMatch(/answer:streaming/)
|
|
393
|
+
expect(flips[3]).toMatch(/answer:done/)
|
|
394
|
+
expect(msgB()?.content).toBe('命令输出为 slow-done')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('场景C: 运行中 history 补拉(落后空快照)不得清空直播内容或翻转 done', async () => {
|
|
398
|
+
attach()
|
|
399
|
+
await flush()
|
|
400
|
+
const ws = sockets[0]!
|
|
401
|
+
ws.serverConnect()
|
|
402
|
+
const flips: string[] = []
|
|
403
|
+
const flipsContent: string[] = []
|
|
404
|
+
const record = (label: string) => {
|
|
405
|
+
const snap = uiSnapshot(state.messages as unknown as ChatMessageLike[], state.running)
|
|
406
|
+
flips.push(
|
|
407
|
+
`${label} => running=${snap.running} items=${snap.visibleItems} [${snap.parts.join(', ')}]`
|
|
408
|
+
)
|
|
409
|
+
flipsContent.push(
|
|
410
|
+
String(
|
|
411
|
+
(state.messages as unknown as ChatMessageLike[]).find((m) => m.id === 'msg_B')?.content ??
|
|
412
|
+
''
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
setHistory([{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] }])
|
|
418
|
+
await flush()
|
|
419
|
+
ws.serverPush(durable(statusFrame('prompted', { messageId: 'msg_user' }), nextSeq()))
|
|
420
|
+
ws.serverPush(durable(statusFrame('step_started', { assistantMessageId: 'msg_B' }), nextSeq()))
|
|
421
|
+
ws.serverPush(textDelta('msg_B', '部分'))
|
|
422
|
+
record('live-delta')
|
|
423
|
+
|
|
424
|
+
// 模拟运行中一次补拉(onDisconnect 路径), 历史快照落后于直播流
|
|
425
|
+
setHistory([
|
|
426
|
+
{ id: 'msg_user', role: 'user', content: [{ type: 'text', text: '跑起来' }] },
|
|
427
|
+
{ id: 'msg_B', role: 'assistant', content: [] }
|
|
428
|
+
])
|
|
429
|
+
ws.close()
|
|
430
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
431
|
+
record('补拉(空快照)')
|
|
432
|
+
// record 同时快照当时内容
|
|
433
|
+
// 重连后直播继续
|
|
434
|
+
await vi.advanceTimersByTimeAsync(2500)
|
|
435
|
+
const ws2 = sockets[1]!
|
|
436
|
+
serverReplay = () => []
|
|
437
|
+
ws2.serverConnect()
|
|
438
|
+
ws2.serverPush(textDelta('msg_B', '文本'))
|
|
439
|
+
record('重连续流')
|
|
440
|
+
|
|
441
|
+
// 落后空快照不得清空直播内容、不得翻转 done
|
|
442
|
+
const contentAt = (i: number) => flipsContent[i]
|
|
443
|
+
expect(flips[1]).toMatch(/answer:streaming/)
|
|
444
|
+
expect(contentAt(1)).toBe('部分')
|
|
445
|
+
expect(flips[2]).toMatch(/answer:streaming/)
|
|
446
|
+
expect(contentAt(2)).toBe('部分文本')
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('场景D: 挂载早于 prompt 入库时, 合成用户轮次不被 prompt_admitted 重复', async () => {
|
|
450
|
+
// 空历史挂载(pending 窗口): 合成 initialPrompt 种子
|
|
451
|
+
attach()
|
|
452
|
+
await flush()
|
|
453
|
+
const ws = sockets[0]!
|
|
454
|
+
ws.serverConnect()
|
|
455
|
+
await flush()
|
|
456
|
+
|
|
457
|
+
const userTurns = () =>
|
|
458
|
+
(state.messages as unknown as ChatMessageLike[]).filter((m) => m.role === 'user')
|
|
459
|
+
expect(userTurns()).toHaveLength(1)
|
|
460
|
+
|
|
461
|
+
// prompt_admitted 到达(消息已入队但历史仍空): 不得再发第二条用户消息
|
|
462
|
+
ws.serverPush(durable(statusFrame('prompt_admitted', { messageId: 'msg_user' }), nextSeq()))
|
|
463
|
+
ws.serverPush(durable(statusFrame('prompted', { messageId: 'msg_user' }), nextSeq()))
|
|
464
|
+
expect(userTurns()).toHaveLength(1)
|
|
465
|
+
expect(state.running).toBe(true)
|
|
466
|
+
})
|
|
467
|
+
})
|