@csntgao/uni-base 0.6.3 → 0.6.6

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.
Files changed (35) hide show
  1. package/AGENTS.md +10 -8
  2. package/CLAUDE.md +10 -8
  3. package/README.md +27 -21
  4. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +46 -0
  5. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +1 -1
  6. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +1 -1
  7. package/examples/vanilla/main.js +1 -1
  8. package/examples/vanilla/package.json +3 -3
  9. package/package.json +12 -13
  10. package/packages/core/package.json +2 -2
  11. package/packages/core/src/assistant-actions.ts +120 -0
  12. package/packages/core/src/customer-support-types.ts +15 -0
  13. package/packages/core/src/index.ts +8 -0
  14. package/packages/core/src/types.ts +6 -0
  15. package/packages/core/tests/assistant-actions.test.ts +76 -0
  16. package/packages/web-components/package.json +3 -3
  17. package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
  18. package/packages/web-components/src/chat.ts +267 -15
  19. package/packages/web-components/src/customer-support-chat.ts +1 -1
  20. package/packages/web-components/src/customer-support-transport.ts +118 -16
  21. package/packages/web-components/src/index.ts +14 -2
  22. package/packages/web-components/src/notification-center.ts +1 -1
  23. package/packages/web-components/src/notification-popover.ts +1 -1
  24. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +1 -1
  25. package/packages/web-components/tests/chat.test.ts +418 -10
  26. package/packages/web-components/tests/customer-support-chat.test.ts +1 -1
  27. package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
  28. package/packages/web-components/tests/notification-center.test.ts +1 -1
  29. package/packages/web-components/tests/notification-popover.test.ts +1 -1
  30. package/packages/web-components/tsup.config.ts +1 -1
  31. package/packages/web-components/tsup.runtime.config.ts +1 -1
  32. package/restart +0 -0
  33. package/tsconfig.base.json +2 -2
  34. package/vitest.config.ts +2 -2
  35. package//344/272/244/346/216/245/346/226/207/346/241/243.md +21 -26
@@ -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,
8
- } from '@uniplat/ai-employee-client'
7
+ type CustomerSupportHandoffRequest,
8
+ AiEmployeeTransportError,
9
+ type CustomerSupportHandoffResult,
10
+ type CustomerSupportSession,
11
+ type CustomerSupportTransport,
12
+ } from '@csntgao/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({
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import type { CustomerSupportSession, CustomerSupportTransport } from '@uniplat/ai-employee-client'
4
+ import type { CustomerSupportSession, CustomerSupportTransport } from '@csntgao/ai-employee-client'
5
5
  import '../src'
6
6
  import { UniplatCustomerSupportChat } from '../src'
7
7
 
@@ -1,7 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { describe, expect, it, vi } from 'vitest'
4
- import { createHrSaasCustomerSupportTransport } from '../src'
4
+ import { createHrSaasCustomerSupportTransport, HrSaasCustomerSupportTransportError } from '../src'
5
+
6
+ const actionTransactionId = '8f0b790c-1c5f-4f8c-95cc-4ead9d9cfb1a'
5
7
 
6
8
  function response(data: unknown): Response {
7
9
  return new Response(JSON.stringify({ rescode: 0, data }), {
@@ -76,4 +78,90 @@ describe('createHrSaasCustomerSupportTransport', () => {
76
78
  expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer customer.support.jwt')
77
79
  }
78
80
  })
81
+
82
+ it('creates an idempotent handoff with only the source session and trusted action id', async () => {
83
+ const fetchImplementation = vi.fn<typeof fetch>(async (input, init) => {
84
+ void input
85
+ void init
86
+ return response({
87
+ session: {
88
+ session_id: 'support-81',
89
+ title: '职责越界转接',
90
+ status: 'waiting',
91
+ unread_count: 0,
92
+ },
93
+ initial_message: {
94
+ message_id: 'support-message-91',
95
+ session_id: 'support-81',
96
+ sequence: '1',
97
+ sender_type: 'customer',
98
+ content: '来自数字员工会话的转接',
99
+ created_at: '2026-09-09T08:00:00Z',
100
+ },
101
+ created: true,
102
+ repaired: false,
103
+ })
104
+ })
105
+ const transport = createHrSaasCustomerSupportTransport({
106
+ serviceBaseUrl: 'https://hr.example.com/general/project/hr_saas/service/customer_support_api',
107
+ getAccessToken: () => 'customer.support.jwt',
108
+ fetch: fetchImplementation as typeof fetch,
109
+ })
110
+
111
+ const result = await transport.createHandoffSession?.({
112
+ sourceSessionId: 'ai-session-31',
113
+ actionTransactionId,
114
+ })
115
+
116
+ expect(result).toMatchObject({
117
+ session: { id: 'support-81', status: 'waiting' },
118
+ initialMessage: { id: 'support-message-91', sessionId: 'support-81' },
119
+ created: true,
120
+ repaired: false,
121
+ })
122
+ const [url, init] = fetchImplementation.mock.calls[0]!
123
+ expect(String(url)).toBe(
124
+ 'https://hr.example.com/general/project/hr_saas/service/customer_support_api/user_session_handoff_create',
125
+ )
126
+ expect(JSON.parse(String(init?.body))).toEqual({
127
+ source_session_id: 'ai-session-31',
128
+ action_transaction_id: actionTransactionId,
129
+ })
130
+ expect(String(init?.body)).not.toContain('ae_code')
131
+ expect(String(init?.body)).not.toContain('agent_code')
132
+ expect(String(init?.body)).not.toContain('org_id')
133
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer customer.support.jwt')
134
+ })
135
+
136
+ it('maps handoff failures to stable safe errors without exposing backend text', async () => {
137
+ const fetchImplementation = vi.fn(
138
+ async () =>
139
+ new Response(
140
+ JSON.stringify({
141
+ rescode: 1,
142
+ error_code: 'HANDOFF_ACTION_MISMATCH',
143
+ msg: 'private upstream details',
144
+ }),
145
+ { status: 409, headers: { 'content-type': 'application/json' } },
146
+ ),
147
+ )
148
+ const transport = createHrSaasCustomerSupportTransport({
149
+ serviceBaseUrl: 'https://hr.example.com/general/project/hr_saas/service/customer_support_api',
150
+ getAccessToken: () => 'customer.support.jwt',
151
+ fetch: fetchImplementation as typeof fetch,
152
+ })
153
+
154
+ const request = transport.createHandoffSession?.({
155
+ sourceSessionId: 'ai-session-31',
156
+ actionTransactionId,
157
+ })
158
+
159
+ await expect(request).rejects.toMatchObject({
160
+ name: 'HrSaasCustomerSupportTransportError',
161
+ code: 'HANDOFF_ACTION_MISMATCH',
162
+ message: 'Customer support request failed',
163
+ })
164
+ await expect(request).rejects.not.toThrow('private upstream details')
165
+ expect(HrSaasCustomerSupportTransportError).toBeTypeOf('function')
166
+ })
79
167
  })
@@ -5,7 +5,7 @@ import type {
5
5
  NotificationCenterTransport,
6
6
  NotificationDto,
7
7
  NotificationRealtimeChange,
8
- } from '@uniplat/ai-employee-client'
8
+ } from '@csntgao/ai-employee-client'
9
9
  import '../src'
10
10
  import { UniplatNotificationCenter } from '../src'
11
11
 
@@ -5,7 +5,7 @@ import type {
5
5
  NotificationCenterTransport,
6
6
  NotificationDto,
7
7
  NotificationRealtimeChange,
8
- } from '@uniplat/ai-employee-client'
8
+ } from '@csntgao/ai-employee-client'
9
9
  import '../src'
10
10
  import { UniplatNotificationPopover } from '../src'
11
11
 
@@ -8,5 +8,5 @@ export default defineConfig({
8
8
  clean: true,
9
9
  target: 'es2022',
10
10
  treeshake: true,
11
- external: ['lit', '@uniplat/ai-employee-client'],
11
+ external: ['lit', '@csntgao/ai-employee-client'],
12
12
  })
@@ -33,5 +33,5 @@ export default defineConfig({
33
33
  sourcemap: true,
34
34
  clean: true,
35
35
  treeshake: true,
36
- noExternal: ['lit', '@uniplat/ai-employee-client'],
36
+ noExternal: ['lit', '@csntgao/ai-employee-client'],
37
37
  })
package/restart CHANGED
File without changes
@@ -14,8 +14,8 @@
14
14
  "skipLibCheck": true,
15
15
  "baseUrl": ".",
16
16
  "paths": {
17
- "@uniplat/ai-employee-client": ["packages/core/src/index.ts"],
18
- "@uniplat/ai-employee-web-components": ["packages/web-components/src/index.ts"]
17
+ "@csntgao/ai-employee-client": ["packages/core/src/index.ts"],
18
+ "@csntgao/ai-employee-web-components": ["packages/web-components/src/index.ts"]
19
19
  }
20
20
  }
21
21
  }
package/vitest.config.ts CHANGED
@@ -4,10 +4,10 @@ import { defineConfig } from 'vitest/config'
4
4
  export default defineConfig({
5
5
  resolve: {
6
6
  alias: {
7
- '@uniplat/ai-employee-client': fileURLToPath(
7
+ '@csntgao/ai-employee-client': fileURLToPath(
8
8
  new URL('./packages/core/src/index.ts', import.meta.url),
9
9
  ),
10
- '@uniplat/ai-employee-web-components': fileURLToPath(
10
+ '@csntgao/ai-employee-web-components': fileURLToPath(
11
11
  new URL('./packages/web-components/src/index.ts', import.meta.url),
12
12
  ),
13
13
  },