@csntgao/uni-base 0.6.3 → 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.
- package/AGENTS.md +5 -3
- package/CLAUDE.md +5 -3
- package/README.md +6 -2
- 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
- package/package.json +12 -13
- package/packages/core/package.json +1 -1
- package/packages/core/src/assistant-actions.ts +120 -0
- package/packages/core/src/customer-support-types.ts +15 -0
- package/packages/core/src/index.ts +8 -0
- package/packages/core/src/types.ts +6 -0
- package/packages/core/tests/assistant-actions.test.ts +76 -0
- package/packages/web-components/package.json +1 -1
- package/packages/web-components/scripts/verify-runtime-build.mjs +1 -0
- package/packages/web-components/src/chat.ts +266 -14
- package/packages/web-components/src/customer-support-transport.ts +117 -15
- package/packages/web-components/src/index.ts +14 -2
- package/packages/web-components/tests/chat.test.ts +417 -9
- package/packages/web-components/tests/customer-support-transport.test.ts +89 -1
- package/restart +0 -0
|
@@ -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
|
|
1061
|
-
|
|
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,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
|
})
|
package/restart
CHANGED
|
File without changes
|