@csntgao/uni-base 0.1.0

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 (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. 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 +110 -0
  7. 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 +79 -0
  8. 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 +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,760 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ AiEmployeeClient,
4
+ AiEmployeeTransportError,
5
+ type AiEmployeeFirstMessageRequestStore,
6
+ type AiEmployeeMessage,
7
+ type AiEmployeeTransport,
8
+ } from '../src'
9
+ import { FakeWebSocket } from './fake-websocket'
10
+
11
+ function createTransport(history: AiEmployeeMessage[] = []) {
12
+ return {
13
+ createSessionWithFirstMessage: vi.fn(async () => ({
14
+ sessionId: 'atomic-session-default',
15
+ messageId: 'atomic-message-default',
16
+ executionId: 'atomic-execution-default',
17
+ status: 'running' as const,
18
+ startedAt: '2026-08-18T08:00:00.000Z',
19
+ aeCode: 'recruit',
20
+ agentCode: 'default',
21
+ conversationType: 'default',
22
+ })),
23
+ listMessages: vi.fn(async () => history),
24
+ sendMessage: vi.fn(async () => undefined),
25
+ getAccessToken: vi.fn(async () => 'header.payload.signature'),
26
+ } satisfies AiEmployeeTransport
27
+ }
28
+
29
+ function createHarness(
30
+ transport = createTransport(),
31
+ options: {
32
+ draft?: boolean
33
+ firstMessageRequestStore?: AiEmployeeFirstMessageRequestStore | null
34
+ requestIdFactory?: () => string
35
+ } = {},
36
+ ) {
37
+ const sockets: FakeWebSocket[] = []
38
+ const client = new AiEmployeeClient({
39
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
40
+ aeCode: 'recruit',
41
+ agentCode: 'default',
42
+ transport,
43
+ ...(options.draft ? {} : { sessionId: 'session-1' }),
44
+ reconnect: { initialDelayMs: 1_000, maximumDelayMs: 4_000, jitter: 0 },
45
+ minimumHeartbeatMs: 1,
46
+ requestIdFactory: options.requestIdFactory ?? (() => '6afdb617-5997-46cf-aef1-f043c22c32de'),
47
+ firstMessageRequestStore: options.firstMessageRequestStore ?? null,
48
+ webSocketFactory: (url) => {
49
+ const socket = new FakeWebSocket(url)
50
+ sockets.push(socket)
51
+ return socket.asWebSocket()
52
+ },
53
+ })
54
+ return { client, sockets, transport }
55
+ }
56
+
57
+ async function authenticateAndReady(socket: FakeWebSocket): Promise<void> {
58
+ socket.open()
59
+ await Promise.resolve()
60
+ socket.receive({
61
+ type: 'realtime_ready',
62
+ channels: { ai_employee: { available: true } },
63
+ heartbeat_interval_seconds: 25,
64
+ })
65
+ }
66
+
67
+ describe('AiEmployeeClient', () => {
68
+ beforeEach(() => vi.useFakeTimers())
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers()
72
+ })
73
+
74
+ it('loads history, sends JWT only in the first frame, then subscribes', async () => {
75
+ const history: AiEmployeeMessage[] = [
76
+ {
77
+ id: 'history-1',
78
+ sessionId: 'session-1',
79
+ role: 'assistant',
80
+ content: '历史消息',
81
+ createdAt: '2026-08-03T00:00:00.000Z',
82
+ },
83
+ ]
84
+ const { client, sockets, transport } = createHarness(createTransport(history))
85
+ const states: string[] = []
86
+ client.on('connection-state-change', ({ current }) => states.push(current))
87
+
88
+ const result = await client.start()
89
+ expect(result.messages).toEqual(history)
90
+ expect(sockets).toHaveLength(1)
91
+ expect(sockets[0]?.url).toBe('wss://kanban.example.com/api/user-realtime/ws')
92
+ expect(sockets[0]?.url).not.toContain('header.payload.signature')
93
+
94
+ await authenticateAndReady(sockets[0]!)
95
+
96
+ expect(transport.getAccessToken).toHaveBeenCalledTimes(1)
97
+ expect(sockets[0]?.sent.map((item) => JSON.parse(item))).toEqual([
98
+ {
99
+ type: 'authenticate',
100
+ protocol_version: 1,
101
+ access_token: 'header.payload.signature',
102
+ requested_channels: ['ai_employee'],
103
+ cursors: {},
104
+ },
105
+ {
106
+ action: 'subscribe',
107
+ channel: 'ai_employee',
108
+ resource_type: 'session',
109
+ resource_id: 'session-1',
110
+ replay_history: false,
111
+ },
112
+ ])
113
+ expect(states).toEqual(['connecting', 'authenticating', 'subscribing', 'connected'])
114
+
115
+ await vi.advanceTimersByTimeAsync(25_000)
116
+ expect(JSON.parse(sockets[0]!.sent.at(-1)!)).toMatchObject({ action: 'ping' })
117
+ client.destroy()
118
+ })
119
+
120
+ it('accepts an anonymous principal without exposing its JWT or identity context', async () => {
121
+ const anonymousToken = 'anonymous.test.jwt'
122
+ const transport = createTransport()
123
+ transport.getAccessToken.mockResolvedValue(anonymousToken)
124
+ const { client, sockets } = createHarness(transport)
125
+ const readyDetails: unknown[] = []
126
+ const errorDetails: unknown[] = []
127
+ client.on('ready', (detail) => readyDetails.push(detail))
128
+ client.on('error', (detail) => errorDetails.push(detail))
129
+
130
+ await client.start()
131
+ sockets[0]?.open()
132
+ await Promise.resolve()
133
+ sockets[0]?.receive({
134
+ type: 'realtime_ready',
135
+ principal_type: 'anonymous_user',
136
+ scope: { anonymous_user_id: 'anonymous-user-17' },
137
+ channels: { ai_employee: { available: true } },
138
+ heartbeat_interval_seconds: 25,
139
+ })
140
+
141
+ expect(client.connectionState).toBe('connected')
142
+ expect(JSON.parse(sockets[0]!.sent[0]!)).toMatchObject({
143
+ type: 'authenticate',
144
+ access_token: anonymousToken,
145
+ })
146
+ expect(sockets[0]?.url).not.toContain(anonymousToken)
147
+ expect(JSON.stringify(readyDetails)).not.toContain(anonymousToken)
148
+ expect(JSON.stringify(readyDetails)).not.toContain('anonymous-user-17')
149
+ expect(JSON.stringify(errorDetails)).not.toContain(anonymousToken)
150
+ expect(JSON.stringify(errorDetails)).not.toContain('anonymous-user-17')
151
+ client.destroy()
152
+ })
153
+
154
+ it('deduplicates repeated realtime messages', async () => {
155
+ const { client, sockets } = createHarness()
156
+ const received: string[] = []
157
+ client.on('message', ({ message }) => received.push(message.id))
158
+ await client.start()
159
+ await authenticateAndReady(sockets[0]!)
160
+
161
+ const frame = {
162
+ type: 'realtime_event',
163
+ channel: 'ai_employee',
164
+ event: {
165
+ type: 'session_message',
166
+ session_id: 'session-1',
167
+ payload: {
168
+ JsonPatch: [
169
+ {
170
+ path: '/0',
171
+ value: {
172
+ ws_message_id: 'ws-message-1',
173
+ entry_type: { type: 'assistant_message' },
174
+ content: JSON.stringify({ text: '实时回复' }),
175
+ },
176
+ },
177
+ ],
178
+ },
179
+ },
180
+ }
181
+ sockets[0]?.receive(frame)
182
+ sockets[0]?.receive(frame)
183
+
184
+ expect(received).toEqual(['ws-message-1'])
185
+ expect(client.messages.filter((message) => message.id === 'ws-message-1')).toHaveLength(1)
186
+ })
187
+
188
+ it('uses a top-level result only when the current turn has no assistant message', async () => {
189
+ const { client, sockets } = createHarness()
190
+ const received: string[] = []
191
+ client.on('message', ({ message }) => received.push(message.content))
192
+ await client.start()
193
+ await authenticateAndReady(sockets[0]!)
194
+
195
+ sockets[0]?.receive({
196
+ type: 'realtime_event',
197
+ channel: 'ai_employee',
198
+ event: {
199
+ type: 'session_message',
200
+ session_id: 'session-1',
201
+ payload: {
202
+ Ready: true,
203
+ JsonPatch: [
204
+ {
205
+ path: '/0',
206
+ value: {
207
+ ws_message_id: 'assistant-message-1',
208
+ entry_type: { type: 'assistant_message' },
209
+ content: JSON.stringify({ text: '同一份回复' }),
210
+ },
211
+ },
212
+ ],
213
+ },
214
+ },
215
+ })
216
+ sockets[0]?.receive({
217
+ type: 'realtime_event',
218
+ channel: 'ai_employee',
219
+ event: {
220
+ type: 'session_message',
221
+ session_id: 'session-1',
222
+ payload: { result: '同一份回复' },
223
+ },
224
+ })
225
+
226
+ expect(received).toEqual(['同一份回复'])
227
+ expect(client.messages.filter((message) => message.role === 'assistant')).toHaveLength(1)
228
+
229
+ sockets[0]?.receive({
230
+ type: 'realtime_event',
231
+ channel: 'ai_employee',
232
+ event: {
233
+ type: 'session_message',
234
+ session_id: 'session-1',
235
+ payload: { Ready: true },
236
+ },
237
+ })
238
+ sockets[0]?.receive({
239
+ type: 'realtime_event',
240
+ channel: 'ai_employee',
241
+ event: {
242
+ type: 'session_message',
243
+ session_id: 'session-1',
244
+ payload: { result: '仅有结果的兼容回复' },
245
+ },
246
+ })
247
+
248
+ expect(received).toEqual(['同一份回复', '仅有结果的兼容回复'])
249
+ client.destroy()
250
+ })
251
+
252
+ it('reloads history and obtains a fresh JWT after an expired-token close', async () => {
253
+ const transport = createTransport()
254
+ transport.getAccessToken
255
+ .mockResolvedValueOnce('anonymous.first.jwt')
256
+ .mockResolvedValueOnce('anonymous.refreshed.jwt')
257
+ const { client, sockets } = createHarness(transport)
258
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
259
+ client.on('error', (detail) => errors.push(detail))
260
+ await client.start()
261
+ await authenticateAndReady(sockets[0]!)
262
+
263
+ sockets[0]?.serverClose(4401)
264
+ expect(client.connectionState).toBe('reconnecting')
265
+ await vi.advanceTimersByTimeAsync(1_000)
266
+ expect(sockets).toHaveLength(2)
267
+ expect(transport.listMessages).toHaveBeenCalledTimes(2)
268
+
269
+ await authenticateAndReady(sockets[1]!)
270
+ expect(transport.getAccessToken).toHaveBeenCalledTimes(2)
271
+ expect(JSON.parse(sockets[1]!.sent[0]!)).toMatchObject({
272
+ type: 'authenticate',
273
+ access_token: 'anonymous.refreshed.jwt',
274
+ })
275
+ expect(errors).toContainEqual({
276
+ code: 'AUTHENTICATION_FAILED',
277
+ message: '连接凭证已失效,正在重新连接。',
278
+ recoverable: true,
279
+ })
280
+ expect(JSON.stringify(errors)).not.toContain('登录')
281
+ })
282
+
283
+ it('maps transport throttling to a stable safe error without leaking its cause', async () => {
284
+ const transport = createTransport()
285
+ transport.sendMessage.mockRejectedValueOnce(
286
+ Object.assign(new AiEmployeeTransportError('TOO_MANY_REQUESTS'), {
287
+ unsafeResponse: 'anonymous JWT and backend stack must stay private',
288
+ }),
289
+ )
290
+ const { client } = createHarness(transport)
291
+ const errors: unknown[] = []
292
+ client.on('error', (detail) => errors.push(detail))
293
+ await client.start()
294
+
295
+ await expect(client.sendMessage('请帮我查询服务')).rejects.toMatchObject({
296
+ code: 'RATE_LIMITED',
297
+ message: '请求过于频繁,请稍后再试。',
298
+ })
299
+ expect(errors).toEqual([
300
+ {
301
+ code: 'RATE_LIMITED',
302
+ message: '请求过于频繁,请稍后再试。',
303
+ recoverable: false,
304
+ },
305
+ ])
306
+ expect(JSON.stringify(errors)).not.toContain('anonymous JWT')
307
+ expect(JSON.stringify(errors)).not.toContain('backend stack')
308
+ client.destroy()
309
+ })
310
+
311
+ it('keeps service authentication failures distinct from expired JWT and bounds retries', async () => {
312
+ const { client, sockets } = createHarness()
313
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
314
+ client.on('error', (detail) => errors.push(detail))
315
+ await client.start()
316
+
317
+ for (let attempt = 0; attempt <= 3; attempt += 1) {
318
+ const socket = sockets.at(-1)!
319
+ socket.open()
320
+ await Promise.resolve()
321
+ socket.receive({ type: 'authentication_error', code: 'SERVICE_SIGNATURE_INVALID' })
322
+ socket.serverClose(4401)
323
+
324
+ if (attempt < 3) {
325
+ expect(client.connectionState).toBe('reconnecting')
326
+ await vi.advanceTimersByTimeAsync(1_000 * 2 ** attempt)
327
+ }
328
+ }
329
+
330
+ expect(client.connectionState).toBe('disconnected')
331
+ expect(sockets).toHaveLength(4)
332
+ expect(errors).toContainEqual({
333
+ code: 'REALTIME_UNAVAILABLE',
334
+ message: '实时鉴权服务暂不可用,正在重试。',
335
+ recoverable: true,
336
+ })
337
+ expect(errors).toContainEqual({
338
+ code: 'REALTIME_UNAVAILABLE',
339
+ message: '实时鉴权服务持续不可用,请稍后重试。',
340
+ recoverable: false,
341
+ })
342
+ expect(JSON.stringify(errors)).not.toContain('登录凭证已失效')
343
+ })
344
+
345
+ it('does not retry or overwrite an organization-context authentication error', async () => {
346
+ const { client, sockets } = createHarness()
347
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
348
+ client.on('error', (detail) => errors.push(detail))
349
+ await client.start()
350
+ sockets[0]?.open()
351
+ await Promise.resolve()
352
+ sockets[0]?.receive({ type: 'authentication_error', code: 'ORGANIZATION_CONTEXT_REQUIRED' })
353
+ sockets[0]?.serverClose(4403)
354
+
355
+ expect(client.connectionState).toBe('disconnected')
356
+ expect(vi.getTimerCount()).toBe(0)
357
+ expect(errors).toEqual([
358
+ {
359
+ code: 'ORGANIZATION_CONTEXT_REQUIRED',
360
+ message: '请选择企业/团队后再开始咨询。',
361
+ recoverable: false,
362
+ },
363
+ ])
364
+ })
365
+
366
+ it('stops on an invalid authentication argument without reporting a credential error', async () => {
367
+ const { client, sockets } = createHarness()
368
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
369
+ client.on('error', (detail) => errors.push(detail))
370
+ await client.start()
371
+ sockets[0]?.open()
372
+ await Promise.resolve()
373
+ sockets[0]?.receive({ type: 'authentication_error', code: 'INVALID_ARGUMENT' })
374
+ sockets[0]?.serverClose(4400)
375
+
376
+ expect(client.connectionState).toBe('disconnected')
377
+ expect(vi.getTimerCount()).toBe(0)
378
+ expect(errors).toEqual([
379
+ {
380
+ code: 'PROTOCOL_ERROR',
381
+ message: '实时连接协议不受支持。',
382
+ recoverable: false,
383
+ },
384
+ ])
385
+ })
386
+
387
+ it('does not treat an unknown authentication error frame as an expired JWT', async () => {
388
+ const { client, sockets, transport } = createHarness()
389
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
390
+ client.on('error', (detail) => errors.push(detail))
391
+ await client.start()
392
+ sockets[0]?.open()
393
+ await Promise.resolve()
394
+ sockets[0]?.receive({ type: 'authentication_error', code: 'UNRECOGNIZED_SERVER_CODE' })
395
+ sockets[0]?.serverClose(4401)
396
+
397
+ expect(client.connectionState).toBe('disconnected')
398
+ expect(vi.getTimerCount()).toBe(0)
399
+ expect(transport.getAccessToken).toHaveBeenCalledTimes(1)
400
+ expect(errors).toEqual([
401
+ {
402
+ code: 'AUTHENTICATION_FAILED',
403
+ message: '实时连接认证失败。',
404
+ recoverable: false,
405
+ },
406
+ ])
407
+ expect(JSON.stringify(errors)).not.toContain('UNRECOGNIZED_SERVER_CODE')
408
+ })
409
+
410
+ it('treats a bare service-unavailable close as bounded availability recovery', async () => {
411
+ const { client, sockets } = createHarness()
412
+ const errors: Array<{ code: string; message: string; recoverable: boolean }> = []
413
+ client.on('error', (detail) => errors.push(detail))
414
+ await client.start()
415
+ sockets[0]?.serverClose(1013)
416
+
417
+ expect(client.connectionState).toBe('reconnecting')
418
+ expect(errors).toEqual([
419
+ {
420
+ code: 'REALTIME_UNAVAILABLE',
421
+ message: '实时鉴权服务暂不可用,正在重试。',
422
+ recoverable: true,
423
+ },
424
+ ])
425
+ })
426
+
427
+ it('keeps an atomic new conversation local until the first message is accepted', async () => {
428
+ const transport = {
429
+ ...createTransport(),
430
+ createSessionWithFirstMessage: vi.fn(async () => ({
431
+ sessionId: 'atomic-session-1',
432
+ messageId: 'atomic-message-1',
433
+ executionId: 'atomic-execution-1',
434
+ status: 'running' as const,
435
+ startedAt: '2026-08-18T08:30:00.000Z',
436
+ aeCode: 'recruit',
437
+ agentCode: 'default',
438
+ conversationType: 'default',
439
+ title: '新对话',
440
+ createdAt: '2026-08-18T08:30:00.000Z',
441
+ })),
442
+ } satisfies AiEmployeeTransport
443
+ const { client, sockets } = createHarness(transport, { draft: true })
444
+ const result = await client.start()
445
+
446
+ expect(result).toEqual({ sessionId: null, messages: [] })
447
+ expect(client.sessionId).toBeNull()
448
+ expect(transport.listMessages).not.toHaveBeenCalled()
449
+ expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
450
+ expect(sockets).toHaveLength(1)
451
+
452
+ await authenticateAndReady(sockets[0]!)
453
+ expect(sockets[0]?.sent.map((item) => JSON.parse(item))).toEqual([
454
+ {
455
+ type: 'authenticate',
456
+ protocol_version: 1,
457
+ access_token: 'header.payload.signature',
458
+ requested_channels: ['ai_employee'],
459
+ cursors: {},
460
+ },
461
+ ])
462
+
463
+ const message = await client.sendMessage(' 帮我梳理招聘需求 ', {
464
+ attachments: [{ transactionId: 'upload-1', fspath: 'fs/recruit/job.pdf' }],
465
+ trustedContext: { source: 'job-draft' },
466
+ })
467
+
468
+ expect(transport.createSessionWithFirstMessage).toHaveBeenCalledWith({
469
+ requestId: '6afdb617-5997-46cf-aef1-f043c22c32de',
470
+ aeCode: 'recruit',
471
+ agentCode: 'default',
472
+ content: '帮我梳理招聘需求',
473
+ attachments: [{ transactionId: 'upload-1', fspath: 'fs/recruit/job.pdf' }],
474
+ trustedContext: { source: 'job-draft' },
475
+ })
476
+ expect(transport.sendMessage).not.toHaveBeenCalled()
477
+ expect(message).toMatchObject({
478
+ id: 'atomic-message-1',
479
+ sessionId: 'atomic-session-1',
480
+ role: 'user',
481
+ content: '帮我梳理招聘需求',
482
+ })
483
+ expect(client.sessionId).toBe('atomic-session-1')
484
+ expect(sockets[0]?.sent.map((item) => JSON.parse(item)).at(-1)).toEqual({
485
+ action: 'subscribe',
486
+ channel: 'ai_employee',
487
+ resource_type: 'session',
488
+ resource_id: 'atomic-session-1',
489
+ replay_history: true,
490
+ })
491
+
492
+ sockets[0]?.receive({
493
+ type: 'realtime_event',
494
+ channel: 'ai_employee',
495
+ event: {
496
+ type: 'session_message',
497
+ session_id: 'atomic-session-1',
498
+ payload: {
499
+ JsonPatch: [
500
+ {
501
+ path: '/0',
502
+ value: {
503
+ message_id: 'atomic-message-1',
504
+ ws_message_id: 'atomic-websocket-message-1',
505
+ entry_type: { type: 'user_message' },
506
+ content: JSON.stringify({ text: '帮我梳理招聘需求' }),
507
+ create_time: '2026-08-18T08:30:00.000Z',
508
+ },
509
+ },
510
+ ],
511
+ },
512
+ },
513
+ })
514
+ expect(client.messages.filter((item) => item.id === 'atomic-message-1')).toHaveLength(1)
515
+ client.destroy()
516
+ })
517
+
518
+ it('reuses the first-message request id after a failed atomic attempt', async () => {
519
+ const transport = {
520
+ ...createTransport(),
521
+ createSessionWithFirstMessage: vi
522
+ .fn()
523
+ .mockRejectedValueOnce(new Error('unsafe transient response'))
524
+ .mockResolvedValueOnce({
525
+ sessionId: 'atomic-session-retry',
526
+ messageId: 'atomic-message-retry',
527
+ executionId: 'atomic-execution-retry',
528
+ status: 'queued' as const,
529
+ startedAt: '2026-08-18T08:35:00.000Z',
530
+ aeCode: 'recruit',
531
+ agentCode: 'default',
532
+ conversationType: 'default',
533
+ }),
534
+ } satisfies AiEmployeeTransport
535
+ const { client } = createHarness(transport, { draft: true })
536
+ await client.start()
537
+
538
+ await expect(client.sendMessage('重新提交首条消息')).rejects.toMatchObject({
539
+ code: 'SEND_FAILED',
540
+ })
541
+ expect(client.sessionId).toBeNull()
542
+ expect(client.messages).toEqual([])
543
+
544
+ const message = await client.sendMessage('重新提交首条消息')
545
+ expect(message.id).toBe('atomic-message-retry')
546
+ expect(transport.createSessionWithFirstMessage).toHaveBeenCalledTimes(2)
547
+ expect(
548
+ transport.createSessionWithFirstMessage.mock.calls.map(([request]) => request.requestId),
549
+ ).toEqual(['6afdb617-5997-46cf-aef1-f043c22c32de', '6afdb617-5997-46cf-aef1-f043c22c32de'])
550
+ client.destroy()
551
+ })
552
+
553
+ it('reuses the persisted request id after the client is recreated', async () => {
554
+ let storedState: { fingerprint: string; requestId: string } | null = null
555
+ const store: AiEmployeeFirstMessageRequestStore = {
556
+ load: vi.fn(async () => storedState),
557
+ save: vi.fn(async (_scope, state) => {
558
+ storedState = { ...state }
559
+ }),
560
+ clear: vi.fn(async () => {
561
+ storedState = null
562
+ }),
563
+ }
564
+ const firstTransport = createTransport()
565
+ firstTransport.createSessionWithFirstMessage.mockRejectedValueOnce(
566
+ new Error('unsafe transient response'),
567
+ )
568
+ const first = createHarness(firstTransport, {
569
+ draft: true,
570
+ firstMessageRequestStore: store,
571
+ requestIdFactory: () => '6afdb617-5997-46cf-aef1-f043c22c32de',
572
+ })
573
+ await first.client.start()
574
+
575
+ await expect(first.client.sendMessage('刷新后继续重试')).rejects.toMatchObject({
576
+ code: 'SEND_FAILED',
577
+ })
578
+ expect(storedState).toMatchObject({
579
+ requestId: '6afdb617-5997-46cf-aef1-f043c22c32de',
580
+ })
581
+ expect(JSON.stringify(storedState)).not.toContain('刷新后继续重试')
582
+ first.client.destroy()
583
+
584
+ const secondTransport = createTransport()
585
+ const second = createHarness(secondTransport, {
586
+ draft: true,
587
+ firstMessageRequestStore: store,
588
+ requestIdFactory: () => 'ef6d946d-4186-4af5-8dc7-1be256afd711',
589
+ })
590
+ await second.client.start()
591
+ await second.client.sendMessage('刷新后继续重试')
592
+
593
+ expect(secondTransport.createSessionWithFirstMessage).toHaveBeenCalledWith(
594
+ expect.objectContaining({ requestId: '6afdb617-5997-46cf-aef1-f043c22c32de' }),
595
+ )
596
+ expect(storedState).toBeNull()
597
+ second.client.destroy()
598
+ })
599
+
600
+ it('maps an idempotency conflict to one stable non-recoverable public error', async () => {
601
+ const transport = createTransport()
602
+ transport.createSessionWithFirstMessage.mockRejectedValueOnce(
603
+ Object.assign(new AiEmployeeTransportError('IDEMPOTENCY_CONFLICT'), {
604
+ unsafeResponse: 'original content and backend stack',
605
+ }),
606
+ )
607
+ const { client } = createHarness(transport, { draft: true })
608
+ const errors: unknown[] = []
609
+ client.on('error', (detail) => errors.push(detail))
610
+ await client.start()
611
+
612
+ await expect(client.sendMessage('发生幂等冲突')).rejects.toMatchObject({
613
+ code: 'FIRST_MESSAGE_CONFLICT',
614
+ message: '首条消息内容已变化,请开启新对话后重试。',
615
+ })
616
+ expect(errors).toEqual([
617
+ {
618
+ code: 'FIRST_MESSAGE_CONFLICT',
619
+ message: '首条消息内容已变化,请开启新对话后重试。',
620
+ recoverable: false,
621
+ },
622
+ ])
623
+ expect(JSON.stringify(errors)).not.toContain('original content')
624
+ expect(JSON.stringify(errors)).not.toContain('backend stack')
625
+ client.destroy()
626
+ })
627
+
628
+ it('rejects an atomic response for a different AI employee without binding its session', async () => {
629
+ const transport = createTransport()
630
+ transport.createSessionWithFirstMessage.mockResolvedValueOnce({
631
+ sessionId: 'foreign-session',
632
+ messageId: 'foreign-message',
633
+ executionId: 'foreign-execution',
634
+ status: 'running',
635
+ startedAt: '2026-08-18T09:00:00.000Z',
636
+ aeCode: 'another-employee',
637
+ agentCode: 'default',
638
+ conversationType: 'default',
639
+ })
640
+ const { client } = createHarness(transport, { draft: true })
641
+ await client.start()
642
+
643
+ await expect(client.sendMessage('不能绑定错误响应')).rejects.toMatchObject({
644
+ code: 'SEND_FAILED',
645
+ message: '首条消息受理响应与当前数字员工不匹配。',
646
+ })
647
+ expect(client.sessionId).toBeNull()
648
+ expect(client.messages).toEqual([])
649
+ client.destroy()
650
+ })
651
+
652
+ it('clears a persisted retry operation when the user starts another conversation', async () => {
653
+ let storedState: { fingerprint: string; requestId: string } | null = {
654
+ fingerprint: 'v1:stale',
655
+ requestId: '6afdb617-5997-46cf-aef1-f043c22c32de',
656
+ }
657
+ const store: AiEmployeeFirstMessageRequestStore = {
658
+ load: vi.fn(async () => storedState),
659
+ save: vi.fn(async (_scope, state) => {
660
+ storedState = { ...state }
661
+ }),
662
+ clear: vi.fn(async () => {
663
+ storedState = null
664
+ }),
665
+ }
666
+ const { client } = createHarness(createTransport(), {
667
+ draft: true,
668
+ firstMessageRequestStore: store,
669
+ })
670
+ await client.start()
671
+
672
+ await client.startNewConversation()
673
+
674
+ expect(storedState).toBeNull()
675
+ expect(store.clear).toHaveBeenCalledTimes(1)
676
+ client.destroy()
677
+ })
678
+
679
+ it('starts a new atomic conversation as a local draft without creating a server session', async () => {
680
+ const transport = createTransport()
681
+ const { client } = createHarness(transport)
682
+ await client.start()
683
+
684
+ const result = await client.startNewConversation()
685
+
686
+ expect(result).toEqual({ sessionId: null, messages: [] })
687
+ expect(client.sessionId).toBeNull()
688
+ expect(transport.createSessionWithFirstMessage).not.toHaveBeenCalled()
689
+ client.destroy()
690
+ })
691
+
692
+ it('rejects a transport that only exposes the removed empty-session capability', () => {
693
+ const legacyTransport = {
694
+ getOrCreateSession: vi.fn(async () => ({ sessionId: 'legacy-session' })),
695
+ listMessages: vi.fn(async () => []),
696
+ sendMessage: vi.fn(async () => undefined),
697
+ getAccessToken: vi.fn(async () => 'header.payload.signature'),
698
+ }
699
+
700
+ expect(
701
+ () =>
702
+ new AiEmployeeClient({
703
+ realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
704
+ aeCode: 'recruit',
705
+ agentCode: 'default',
706
+ transport: legacyTransport as unknown as AiEmployeeTransport,
707
+ }),
708
+ ).toThrow(/首条消息原子创建能力/)
709
+ expect(legacyTransport.getOrCreateSession).not.toHaveBeenCalled()
710
+ })
711
+
712
+ it('closes the socket and cancels heartbeat and reconnect work on destroy', async () => {
713
+ const { client, sockets, transport } = createHarness()
714
+ await client.start()
715
+ await authenticateAndReady(sockets[0]!)
716
+ expect(vi.getTimerCount()).toBe(1)
717
+
718
+ client.destroy()
719
+ expect(client.connectionState).toBe('destroyed')
720
+ expect(sockets[0]?.closeCount).toBe(1)
721
+ expect(vi.getTimerCount()).toBe(0)
722
+
723
+ await vi.advanceTimersByTimeAsync(60_000)
724
+ expect(sockets).toHaveLength(1)
725
+ expect(transport.getAccessToken).toHaveBeenCalledTimes(1)
726
+ })
727
+
728
+ it('rejects credential-bearing URLs and empty messages', async () => {
729
+ expect(
730
+ () =>
731
+ new AiEmployeeClient({
732
+ realtimeUrl: 'wss://example.com/api/user-realtime/ws?ticket=secret',
733
+ aeCode: 'recruit',
734
+ agentCode: 'default',
735
+ transport: createTransport(),
736
+ }),
737
+ ).toThrow(/不允许包含查询参数/)
738
+
739
+ const { client } = createHarness()
740
+ await client.start()
741
+ await expect(client.sendMessage(' ')).rejects.toMatchObject({ code: 'EMPTY_MESSAGE' })
742
+ })
743
+
744
+ it('does not leak a rejected token provider error', async () => {
745
+ const transport = createTransport()
746
+ transport.getAccessToken.mockRejectedValueOnce(
747
+ new Error('access_token=very-secret-jwt and backend stack'),
748
+ )
749
+ const { client, sockets } = createHarness(transport)
750
+ const errors: string[] = []
751
+ client.on('error', (detail) => errors.push(JSON.stringify(detail)))
752
+ await client.start()
753
+ sockets[0]?.open()
754
+ await Promise.resolve()
755
+
756
+ expect(errors.join('')).not.toContain('very-secret-jwt')
757
+ expect(errors.join('')).not.toContain('backend stack')
758
+ client.destroy()
759
+ })
760
+ })