@csntgao/uni-base 0.6.2 → 0.6.5

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.
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CustomerSupportAgent,
3
3
  CustomerSupportCreateSessionResult,
4
+ CustomerSupportHandoffResult,
4
5
  CustomerSupportMessage,
5
6
  CustomerSupportMessagePage,
6
7
  CustomerSupportSenderType,
@@ -8,6 +9,7 @@ import type {
8
9
  CustomerSupportSessionStatus,
9
10
  CustomerSupportTransport,
10
11
  } from '@uniplat/ai-employee-client'
12
+ import { isUuidV4 } from '@uniplat/ai-employee-client'
11
13
 
12
14
  export interface HrSaasCustomerSupportTransportOptions {
13
15
  serviceBaseUrl: string
@@ -15,8 +17,30 @@ export interface HrSaasCustomerSupportTransportOptions {
15
17
  fetch?: typeof fetch
16
18
  }
17
19
 
20
+ export type HrSaasCustomerSupportTransportErrorCode =
21
+ | 'AUTH_REQUIRED'
22
+ | 'SOURCE_SESSION_NOT_FOUND'
23
+ | 'SOURCE_SESSION_ACCESS_DENIED'
24
+ | 'HANDOFF_ACTION_NOT_FOUND'
25
+ | 'HANDOFF_ACTION_MISMATCH'
26
+ | 'UNAUTHORIZED'
27
+ | 'CUSTOMER_SUPPORT_UNAVAILABLE'
28
+ | 'REQUEST_FAILED'
29
+ | 'INVALID_RESPONSE'
30
+
31
+ export class HrSaasCustomerSupportTransportError extends Error {
32
+ readonly code: HrSaasCustomerSupportTransportErrorCode
33
+
34
+ constructor(code: HrSaasCustomerSupportTransportErrorCode) {
35
+ super('Customer support request failed')
36
+ this.name = 'HrSaasCustomerSupportTransportError'
37
+ this.code = code
38
+ }
39
+ }
40
+
18
41
  const SESSION_PAGE_ACTION = 'user_session_page'
19
42
  const SESSION_CREATE_ACTION = 'user_session_create'
43
+ const SESSION_HANDOFF_CREATE_ACTION = 'user_session_handoff_create'
20
44
  const SESSION_DETAIL_ACTION = 'user_session_detail'
21
45
  const MESSAGE_PAGE_ACTION = 'user_message_page'
22
46
  const MESSAGE_SEND_ACTION = 'user_message_send'
@@ -63,6 +87,37 @@ export function createHrSaasCustomerSupportTransport(
63
87
  if (parsedInitialMessage) result.initialMessage = parsedInitialMessage
64
88
  return result
65
89
  },
90
+ async createHandoffSession(handoffRequest) {
91
+ const sourceSessionId = requiredText(handoffRequest.sourceSessionId, 128)
92
+ const actionTransactionId = requiredText(handoffRequest.actionTransactionId, 36)
93
+ if (!isUuidV4(actionTransactionId)) {
94
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
95
+ }
96
+ const data = record(
97
+ await request(SESSION_HANDOFF_CREATE_ACTION, {
98
+ source_session_id: sourceSessionId,
99
+ action_transaction_id: actionTransactionId,
100
+ }),
101
+ )
102
+ const session = parseSession(data.session)
103
+ const initialMessage = parseMessage(data.initial_message)
104
+ if (
105
+ !session ||
106
+ !initialMessage ||
107
+ initialMessage.sessionId !== session.id ||
108
+ typeof data.created !== 'boolean' ||
109
+ typeof data.repaired !== 'boolean'
110
+ ) {
111
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
112
+ }
113
+ const result: CustomerSupportHandoffResult = {
114
+ session,
115
+ initialMessage,
116
+ created: data.created,
117
+ repaired: data.repaired,
118
+ }
119
+ return result
120
+ },
66
121
  async getSession(sessionId) {
67
122
  const data = await request(SESSION_DETAIL_ACTION, {
68
123
  session_id: requiredText(sessionId, 128),
@@ -169,27 +224,74 @@ async function requestData(
169
224
  accessToken: string,
170
225
  body: Record<string, unknown>,
171
226
  ): Promise<unknown> {
172
- if (!fetchImplementation) throw new Error('Fetch is unavailable')
173
- const response = await fetchImplementation(endpoint, {
174
- method: 'POST',
175
- credentials: 'include',
176
- headers: {
177
- accept: 'application/json',
178
- authorization: `Bearer ${accessToken}`,
179
- 'content-type': 'application/json',
180
- },
181
- body: JSON.stringify(body),
182
- })
183
- if (!response.ok) throw new Error('Customer support request failed')
184
- const payload: unknown = await response.json()
227
+ if (!fetchImplementation) throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
228
+ let response: Response
229
+ try {
230
+ response = await fetchImplementation(endpoint, {
231
+ method: 'POST',
232
+ credentials: 'include',
233
+ headers: {
234
+ accept: 'application/json',
235
+ authorization: `Bearer ${accessToken}`,
236
+ 'content-type': 'application/json',
237
+ },
238
+ body: JSON.stringify(body),
239
+ })
240
+ } catch {
241
+ throw new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
242
+ }
243
+ let payload: unknown = null
244
+ try {
245
+ payload = await response.json()
246
+ } catch {
247
+ if (!response.ok) throw transportResponseError({}, response.status)
248
+ throw new HrSaasCustomerSupportTransportError('INVALID_RESPONSE')
249
+ }
185
250
  const envelope = record(payload)
251
+ if (!response.ok) throw transportResponseError(envelope, response.status)
186
252
  if ('rescode' in envelope) {
187
- if (Number(envelope.rescode) !== 0) throw new Error('Customer support request failed')
253
+ if (Number(envelope.rescode) !== 0) {
254
+ throw transportResponseError(envelope, response.status)
255
+ }
188
256
  return envelope.data
189
257
  }
190
258
  return payload
191
259
  }
192
260
 
261
+ function transportResponseError(
262
+ envelope: Record<string, unknown>,
263
+ status: number,
264
+ ): HrSaasCustomerSupportTransportError {
265
+ const detail = record(envelope.data)
266
+ const error = record(envelope.error)
267
+ const candidate = [
268
+ envelope.error_code,
269
+ envelope.code,
270
+ detail.error_code,
271
+ detail.code,
272
+ error.error_code,
273
+ error.code,
274
+ ].find((value) => typeof value === 'string' && value.trim())
275
+ const code = typeof candidate === 'string' ? candidate.trim().toUpperCase() : ''
276
+ if (isTransportErrorCode(code)) return new HrSaasCustomerSupportTransportError(code)
277
+ if (status === 401) return new HrSaasCustomerSupportTransportError('UNAUTHORIZED')
278
+ if (status === 503) {
279
+ return new HrSaasCustomerSupportTransportError('CUSTOMER_SUPPORT_UNAVAILABLE')
280
+ }
281
+ return new HrSaasCustomerSupportTransportError('REQUEST_FAILED')
282
+ }
283
+
284
+ function isTransportErrorCode(value: string): value is HrSaasCustomerSupportTransportErrorCode {
285
+ return [
286
+ 'SOURCE_SESSION_NOT_FOUND',
287
+ 'SOURCE_SESSION_ACCESS_DENIED',
288
+ 'HANDOFF_ACTION_NOT_FOUND',
289
+ 'HANDOFF_ACTION_MISMATCH',
290
+ 'UNAUTHORIZED',
291
+ 'CUSTOMER_SUPPORT_UNAVAILABLE',
292
+ ].includes(value)
293
+ }
294
+
193
295
  function validateServiceBaseUrl(value: string): string {
194
296
  const raw = typeof value === 'string' ? value.trim() : ''
195
297
  if (!raw) throw new TypeError('Customer support serviceBaseUrl is required')
@@ -216,7 +318,7 @@ async function resolveAccessToken(provider: () => string | Promise<string>): Pro
216
318
  } catch {
217
319
  // Provider details may contain credentials and are intentionally discarded.
218
320
  }
219
- if (!value) throw new Error('Access token is unavailable')
321
+ if (!value) throw new HrSaasCustomerSupportTransportError('AUTH_REQUIRED')
220
322
  return value
221
323
  }
222
324
 
@@ -77,8 +77,20 @@ export type {
77
77
  CustomerSupportChatConfig,
78
78
  CustomerSupportChatEventMap,
79
79
  } from './customer-support-chat'
80
- export { createHrSaasCustomerSupportTransport } from './customer-support-transport'
81
- export type { HrSaasCustomerSupportTransportOptions } from './customer-support-transport'
80
+ export {
81
+ createHrSaasCustomerSupportTransport,
82
+ HrSaasCustomerSupportTransportError,
83
+ } from './customer-support-transport'
84
+ export type {
85
+ HrSaasCustomerSupportTransportErrorCode,
86
+ HrSaasCustomerSupportTransportOptions,
87
+ } from './customer-support-transport'
88
+ export { isUuidV4, parseAiEmployeeAssistantContent } from '@uniplat/ai-employee-client'
89
+ export type {
90
+ AiEmployeeAssistantAction,
91
+ OpenCustomerSupportAssistantAction,
92
+ ParsedAiEmployeeAssistantContent,
93
+ } from '@uniplat/ai-employee-client'
82
94
  export type {
83
95
  NotificationCenterConfig,
84
96
  NotificationCenterEventMap,
@@ -2,13 +2,32 @@
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import {
5
- AiEmployeeTransportError,
6
5
  type AiEmployeeMessage,
7
6
  type AiEmployeeTransport,
7
+ type CustomerSupportHandoffRequest,
8
+ AiEmployeeTransportError,
9
+ type CustomerSupportHandoffResult,
10
+ type CustomerSupportSession,
11
+ type CustomerSupportTransport,
8
12
  } from '@uniplat/ai-employee-client'
13
+ import { HrSaasCustomerSupportTransportError } from '../src'
9
14
  import '../src'
10
15
  import type { UniplatAiEmployeeChat } from '../src'
11
16
 
17
+ const customerSupportActionTransactionId = '8f0b790c-1c5f-4f8c-95cc-4ead9d9cfb1a'
18
+
19
+ function customerSupportActionContent(): string {
20
+ return `该问题超出我的职责范围。\n\n您可以从‘帮助与支持’菜单项入口发起人工客服。\n\n\`\`\`json
21
+ {
22
+ "type": "assistant_action",
23
+ "action": "open_customer_support",
24
+ "transaction-id": "${customerSupportActionTransactionId}",
25
+ "trigger": "out_of_scope",
26
+ "entry": "help_and_support"
27
+ }
28
+ \`\`\``
29
+ }
30
+
12
31
  class ComponentFakeWebSocket {
13
32
  readonly url: string
14
33
  readyState = 0
@@ -66,6 +85,60 @@ function createTransport(history: AiEmployeeMessage[] = []) {
66
85
  } satisfies AiEmployeeTransport
67
86
  }
68
87
 
88
+ function createCustomerSupportTransport() {
89
+ const session: CustomerSupportSession = {
90
+ id: 'support-session-81',
91
+ sessionNo: 'CS-81',
92
+ title: '职责越界转接',
93
+ status: 'waiting',
94
+ statusText: '等待客服接入',
95
+ primaryAgent: null,
96
+ lastMessage: '已从数字员工转接',
97
+ unreadCount: 0,
98
+ openedAt: '2026-09-09T08:00:00.000Z',
99
+ updatedAt: '2026-09-09T08:00:00.000Z',
100
+ version: '1',
101
+ }
102
+ const handoffResult: CustomerSupportHandoffResult = {
103
+ session,
104
+ initialMessage: {
105
+ id: 'support-message-91',
106
+ sessionId: session.id,
107
+ sequence: '1',
108
+ clientMessageId: '',
109
+ senderType: 'customer',
110
+ senderName: '',
111
+ content: '已从数字员工转接',
112
+ createdAt: '2026-09-09T08:00:00.000Z',
113
+ },
114
+ created: true,
115
+ repaired: false,
116
+ }
117
+ return {
118
+ session,
119
+ handoffResult,
120
+ transport: {
121
+ getAccessToken: vi.fn(async () => 'customer.support.jwt'),
122
+ listSessions: vi.fn(async () => [session]),
123
+ getSession: vi.fn(async () => session),
124
+ createSession: vi.fn(async () => ({ session })),
125
+ createHandoffSession: vi.fn(async (request: CustomerSupportHandoffRequest) => {
126
+ void request
127
+ return handoffResult
128
+ }),
129
+ listMessages: vi.fn(async () => ({
130
+ messages: [],
131
+ latestSequence: '1',
132
+ sessionVersion: '1',
133
+ })),
134
+ sendMessage: vi.fn(async () => {
135
+ throw new Error('not used')
136
+ }),
137
+ markRead: vi.fn(async () => undefined),
138
+ } satisfies CustomerSupportTransport,
139
+ }
140
+ }
141
+
69
142
  async function settle(element: UniplatAiEmployeeChat): Promise<void> {
70
143
  await Promise.resolve()
71
144
  await element.updateComplete
@@ -413,6 +486,254 @@ describe('<uniplat-ai-employee-chat>', () => {
413
486
  expect(chat.shadowRoot?.textContent).toContain('继续咨询招聘方案')
414
487
  })
415
488
 
489
+ it('renders a trusted handoff action from history without exposing its raw JSON', async () => {
490
+ const transport = createTransport([
491
+ {
492
+ id: 'assistant-action-message',
493
+ sessionId: 'session-1',
494
+ role: 'assistant',
495
+ content: customerSupportActionContent(),
496
+ createdAt: '2026-09-09T08:00:00.000Z',
497
+ },
498
+ ])
499
+ const chat = document.createElement('uniplat-ai-employee-chat')
500
+ chat.configure({
501
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
502
+ aeCode: 'payroll',
503
+ agentCode: 'specialist',
504
+ sessionId: 'session-1',
505
+ transport,
506
+ webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
507
+ })
508
+ document.body.append(chat)
509
+ chat.open()
510
+
511
+ await vi.waitFor(() =>
512
+ expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
513
+ )
514
+ expect(chat.shadowRoot?.textContent).toContain('该问题超出我的职责范围。')
515
+ expect(chat.shadowRoot?.textContent).toContain('发起人工客服')
516
+ expect(chat.shadowRoot?.textContent).not.toContain('open_customer_support')
517
+ expect(chat.shadowRoot?.textContent).not.toContain(customerSupportActionTransactionId)
518
+
519
+ chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
520
+ await settle(chat)
521
+ expect(chat.shadowRoot?.querySelector('.session-item-preview')?.textContent).toContain(
522
+ '该问题超出我的职责范围。',
523
+ )
524
+ expect(chat.shadowRoot?.querySelector('.session-item-preview')?.textContent).not.toContain(
525
+ 'open_customer_support',
526
+ )
527
+ })
528
+
529
+ it('submits one handoff request and opens the returned customer support session', async () => {
530
+ const aiSockets: ComponentFakeWebSocket[] = []
531
+ const supportSockets: ComponentFakeWebSocket[] = []
532
+ const transport = createTransport([
533
+ {
534
+ id: 'assistant-action-message',
535
+ sessionId: 'session-1',
536
+ role: 'assistant',
537
+ content: customerSupportActionContent(),
538
+ createdAt: '2026-09-09T08:00:00.000Z',
539
+ },
540
+ ])
541
+ const support = createCustomerSupportTransport()
542
+ const completion = deferred<CustomerSupportHandoffResult>()
543
+ support.transport.createHandoffSession.mockImplementation(() => completion.promise)
544
+ const chat = document.createElement('uniplat-ai-employee-chat')
545
+ chat.configure({
546
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
547
+ aeCode: 'archives',
548
+ agentCode: 'default',
549
+ sessionId: 'session-1',
550
+ transport,
551
+ webSocketFactory: (url) => {
552
+ const socket = new ComponentFakeWebSocket(url)
553
+ aiSockets.push(socket)
554
+ return socket.asWebSocket()
555
+ },
556
+ customerSupport: {
557
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
558
+ transport: support.transport,
559
+ webSocketFactory: (url) => {
560
+ const socket = new ComponentFakeWebSocket(url)
561
+ supportSockets.push(socket)
562
+ return socket.asWebSocket()
563
+ },
564
+ },
565
+ })
566
+ document.body.append(chat)
567
+ chat.open()
568
+ await vi.waitFor(() =>
569
+ expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
570
+ )
571
+
572
+ const button = chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')
573
+ button?.click()
574
+ button?.click()
575
+ await chat.updateComplete
576
+
577
+ expect(support.transport.createHandoffSession).toHaveBeenCalledTimes(1)
578
+ expect(support.transport.createHandoffSession).toHaveBeenCalledWith({
579
+ sourceSessionId: 'session-1',
580
+ actionTransactionId: customerSupportActionTransactionId,
581
+ })
582
+ expect(
583
+ chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.disabled,
584
+ ).toBe(true)
585
+
586
+ completion.resolve(support.handoffResult)
587
+ await vi.waitFor(() =>
588
+ expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).not.toBeNull(),
589
+ )
590
+ await vi.waitFor(() =>
591
+ expect(support.transport.listMessages).toHaveBeenCalledWith('support-session-81'),
592
+ )
593
+
594
+ expect(aiSockets[0]?.closeCount).toBe(1)
595
+ expect(supportSockets).toHaveLength(1)
596
+ expect(
597
+ chat.shadowRoot?.querySelector('uniplat-customer-support-chat')?.shadowRoot?.textContent,
598
+ ).toContain('企业客服人工顾问')
599
+ })
600
+
601
+ it('retries a failed handoff with the same trusted action id', async () => {
602
+ const transport = createTransport([
603
+ {
604
+ id: 'assistant-action-message',
605
+ sessionId: 'session-1',
606
+ role: 'assistant',
607
+ content: customerSupportActionContent(),
608
+ createdAt: '2026-09-09T08:00:00.000Z',
609
+ },
610
+ ])
611
+ const support = createCustomerSupportTransport()
612
+ support.transport.createHandoffSession
613
+ .mockRejectedValueOnce(
614
+ new HrSaasCustomerSupportTransportError('CUSTOMER_SUPPORT_UNAVAILABLE'),
615
+ )
616
+ .mockResolvedValueOnce(support.handoffResult)
617
+ const chat = document.createElement('uniplat-ai-employee-chat')
618
+ const publicErrors: unknown[] = []
619
+ chat.addEventListener('error', (event) =>
620
+ publicErrors.push((event as unknown as CustomEvent).detail),
621
+ )
622
+ chat.configure({
623
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
624
+ aeCode: 'social-security',
625
+ agentCode: 'default',
626
+ sessionId: 'session-1',
627
+ transport,
628
+ webSocketFactory: () => new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
629
+ customerSupport: {
630
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
631
+ transport: support.transport,
632
+ webSocketFactory: () =>
633
+ new ComponentFakeWebSocket('wss://kanban.example.com').asWebSocket(),
634
+ },
635
+ })
636
+ document.body.append(chat)
637
+ chat.open()
638
+ await vi.waitFor(() =>
639
+ expect(chat.shadowRoot?.querySelector('.assistant-action-button')).not.toBeNull(),
640
+ )
641
+
642
+ chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.click()
643
+ await vi.waitFor(() =>
644
+ expect(chat.shadowRoot?.textContent).toContain('人工客服暂时不可用,请稍后重试。'),
645
+ )
646
+ chat.shadowRoot?.querySelector<HTMLButtonElement>('.assistant-action-button')?.click()
647
+ await vi.waitFor(() =>
648
+ expect(chat.shadowRoot?.querySelector('uniplat-customer-support-chat')).not.toBeNull(),
649
+ )
650
+
651
+ expect(support.transport.createHandoffSession).toHaveBeenCalledTimes(2)
652
+ expect(support.transport.createHandoffSession.mock.calls[0]?.[0]).toEqual(
653
+ support.transport.createHandoffSession.mock.calls[1]?.[0],
654
+ )
655
+ expect(publicErrors).toContainEqual({
656
+ code: 'CUSTOMER_SUPPORT_UNAVAILABLE',
657
+ message: '人工客服暂时不可用,请稍后重试。',
658
+ recoverable: true,
659
+ })
660
+ expect(JSON.stringify(publicErrors)).not.toContain(customerSupportActionTransactionId)
661
+ })
662
+
663
+ it('renders the same trusted action from realtime messages and ignores malformed actions', async () => {
664
+ const sockets: ComponentFakeWebSocket[] = []
665
+ const transport = createTransport()
666
+ const chat = document.createElement('uniplat-ai-employee-chat')
667
+ chat.configure({
668
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
669
+ aeCode: 'enterprise-advisor',
670
+ agentCode: 'non-default',
671
+ sessionId: 'session-1',
672
+ transport,
673
+ webSocketFactory: (url) => {
674
+ const socket = new ComponentFakeWebSocket(url)
675
+ sockets.push(socket)
676
+ return socket.asWebSocket()
677
+ },
678
+ })
679
+ document.body.append(chat)
680
+ chat.open()
681
+ await vi.waitFor(() => expect(sockets).toHaveLength(1))
682
+ await authenticateAndReadyComponent(sockets[0]!)
683
+
684
+ sockets[0]?.receive({
685
+ type: 'realtime_event',
686
+ channel: 'ai_employee',
687
+ event: {
688
+ type: 'session_message',
689
+ session_id: 'session-1',
690
+ payload: {
691
+ JsonPatch: [
692
+ {
693
+ path: '/0',
694
+ value: {
695
+ ws_message_id: 'realtime-action-message',
696
+ entry_type: { type: 'assistant_message' },
697
+ content: JSON.stringify({ text: customerSupportActionContent() }),
698
+ },
699
+ },
700
+ ],
701
+ },
702
+ },
703
+ })
704
+ await vi.waitFor(() =>
705
+ expect(chat.shadowRoot?.querySelectorAll('.assistant-action-button')).toHaveLength(1),
706
+ )
707
+
708
+ const malformed = customerSupportActionContent().replace(
709
+ customerSupportActionTransactionId,
710
+ 'not-a-uuid',
711
+ )
712
+ sockets[0]?.receive({
713
+ type: 'realtime_event',
714
+ channel: 'ai_employee',
715
+ event: {
716
+ type: 'session_message',
717
+ session_id: 'session-1',
718
+ payload: {
719
+ JsonPatch: [
720
+ {
721
+ path: '/0',
722
+ value: {
723
+ ws_message_id: 'malformed-action-message',
724
+ entry_type: { type: 'assistant_message' },
725
+ content: JSON.stringify({ text: malformed }),
726
+ },
727
+ },
728
+ ],
729
+ },
730
+ },
731
+ })
732
+ await settle(chat)
733
+
734
+ expect(chat.shadowRoot?.querySelectorAll('.assistant-action-button')).toHaveLength(1)
735
+ })
736
+
416
737
  it('owns the drawer lifecycle and switches to the customer support component', async () => {
417
738
  const sockets: ComponentFakeWebSocket[] = []
418
739
  const transport = createTransport()
@@ -903,7 +1224,7 @@ describe('<uniplat-ai-employee-chat>', () => {
903
1224
  {
904
1225
  path: '/0',
905
1226
  value: {
906
- ws_message_id: '204',
1227
+ ws_message_id: 'ws-204',
907
1228
  entry_type: { type: 'assistant_message' },
908
1229
  content: JSON.stringify({ text: '后来到达的未读回复' }),
909
1230
  create_time: '2026-08-15T01:01:00.000Z',
@@ -921,10 +1242,12 @@ describe('<uniplat-ai-employee-chat>', () => {
921
1242
  expect(chat.shadowRoot?.querySelector('.session-item-unread')).toBeNull()
922
1243
 
923
1244
  chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-item')?.click()
924
- await vi.waitFor(() =>
925
- expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '204'),
926
- )
927
1245
  await settle(chat)
1246
+ // 实时事件只带 ws_message_id,已读同步不得使用它。
1247
+ expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-204')
1248
+ expect(
1249
+ transport.markSessionRead.mock.calls.map((call) => (call as unknown as string[])[1]),
1250
+ ).toEqual(['203'])
928
1251
  expect(chat.shadowRoot?.querySelector('.conversation-unread')).toBeNull()
929
1252
  })
930
1253
 
@@ -1047,7 +1370,7 @@ describe('<uniplat-ai-employee-chat>', () => {
1047
1370
  {
1048
1371
  path: '/0',
1049
1372
  value: {
1050
- ws_message_id: '402',
1373
+ ws_message_id: 'ws-402',
1051
1374
  entry_type: { type: 'assistant_message' },
1052
1375
  content: JSON.stringify({ text: '当前实时回复' }),
1053
1376
  create_time: '2026-08-15T01:02:00.000Z',
@@ -1057,12 +1380,16 @@ describe('<uniplat-ai-employee-chat>', () => {
1057
1380
  },
1058
1381
  },
1059
1382
  })
1060
- await vi.waitFor(() =>
1061
- expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '402'),
1062
- )
1383
+ await settle(chat)
1384
+ // 实时事件的 ws_message_id 不是持久化 ID,服务端会拒绝,不能拿它同步已读。
1385
+ expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-402')
1063
1386
 
1064
1387
  chat.shadowRoot?.querySelector<HTMLButtonElement>('.conversation-mark')?.click()
1065
1388
  await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(1))
1389
+ // 列表返回的 latestMessageId 才是持久化 ID,这时才同步。
1390
+ await vi.waitFor(() =>
1391
+ expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '402'),
1392
+ )
1066
1393
  expect(chat.shadowRoot?.querySelector('.session-item.current .session-item-unread')).toBeNull()
1067
1394
 
1068
1395
  chat.shadowRoot?.querySelector<HTMLButtonElement>('.session-back-button')?.click()
@@ -1070,6 +1397,87 @@ describe('<uniplat-ai-employee-chat>', () => {
1070
1397
  expect(chat.shadowRoot?.querySelector('.conversation-unread')?.textContent).toBe('2')
1071
1398
  })
1072
1399
 
1400
+ it('refetches history after the turn ends and marks read with the persisted id', async () => {
1401
+ const sockets: ComponentFakeWebSocket[] = []
1402
+ let history: AiEmployeeMessage[] = [
1403
+ {
1404
+ id: '501',
1405
+ sessionId: 'session-1',
1406
+ role: 'assistant',
1407
+ content: '旧回复',
1408
+ createdAt: '2026-08-15T01:00:00.000Z',
1409
+ },
1410
+ ]
1411
+ const transport = {
1412
+ ...createTransport(history),
1413
+ listMessages: vi.fn(async () => history),
1414
+ markSessionRead: vi.fn(async () => undefined),
1415
+ } satisfies AiEmployeeTransport
1416
+ const chat = document.createElement('uniplat-ai-employee-chat')
1417
+ chat.configure({
1418
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
1419
+ aeCode: 'recruit',
1420
+ agentCode: 'default',
1421
+ sessionId: 'session-1',
1422
+ transport,
1423
+ webSocketFactory: (url) => {
1424
+ const socket = new ComponentFakeWebSocket(url)
1425
+ sockets.push(socket)
1426
+ return socket.asWebSocket()
1427
+ },
1428
+ })
1429
+ document.body.append(chat)
1430
+ chat.open()
1431
+ await vi.waitFor(() =>
1432
+ expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '501'),
1433
+ )
1434
+ await authenticateAndReadyComponent(sockets[0]!)
1435
+
1436
+ // 服务端持久化后历史里才有数字 ID。
1437
+ history = [
1438
+ ...history,
1439
+ {
1440
+ id: '502',
1441
+ sessionId: 'session-1',
1442
+ role: 'assistant',
1443
+ content: '新回复',
1444
+ createdAt: '2026-08-15T01:02:00.000Z',
1445
+ },
1446
+ ]
1447
+
1448
+ const turnFrame = (processing: boolean) => ({
1449
+ type: 'realtime_event',
1450
+ channel: 'ai_employee',
1451
+ event: {
1452
+ type: 'session_message',
1453
+ session_id: 'session-1',
1454
+ payload: {
1455
+ Ready: processing ? true : undefined,
1456
+ finished: processing ? undefined : true,
1457
+ JsonPatch: [
1458
+ {
1459
+ path: '/0',
1460
+ value: {
1461
+ ws_message_id: 'ws-502',
1462
+ entry_type: { type: 'assistant_message' },
1463
+ content: JSON.stringify({ text: '新回复' }),
1464
+ create_time: '2026-08-15T01:02:00.000Z',
1465
+ },
1466
+ },
1467
+ ],
1468
+ },
1469
+ },
1470
+ })
1471
+ sockets[0]?.receive(turnFrame(true))
1472
+ await settle(chat)
1473
+ sockets[0]?.receive(turnFrame(false))
1474
+
1475
+ await vi.waitFor(() =>
1476
+ expect(transport.markSessionRead).toHaveBeenCalledWith('session-1', '502'),
1477
+ )
1478
+ expect(transport.markSessionRead).not.toHaveBeenCalledWith('session-1', 'ws-502')
1479
+ })
1480
+
1073
1481
  it('shows the current conversation when the Host has not added session listing yet', async () => {
1074
1482
  const chat = document.createElement('uniplat-ai-employee-chat')
1075
1483
  chat.configure({