@veltra/ai 2.2.1 → 2.2.2

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 (37) hide show
  1. package/dist/chat/fold.js +14 -2
  2. package/dist/chat/fold.js.map +1 -1
  3. package/dist/chat/session.d.ts +13 -6
  4. package/dist/chat/session.js.map +1 -1
  5. package/dist/chat/use-chat-session.js +3 -13
  6. package/dist/chat/use-chat-session.js.map +1 -1
  7. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  8. package/dist/components/ai-chat/approval-banner.js.map +1 -1
  9. package/dist/components/ai-chat/ask-question.js.map +1 -1
  10. package/dist/components/ai-chat/chat-input.js.map +1 -1
  11. package/dist/components/ai-chat/job-bar.js.map +1 -1
  12. package/dist/components/ai-chat/message-item.js.map +1 -1
  13. package/dist/components/ai-chat/message-list.js.map +1 -1
  14. package/dist/components/ai-chat/model-picker.js.map +1 -1
  15. package/dist/components/ai-chat/queue-list.js.map +1 -1
  16. package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
  17. package/dist/components/ai-chat/side-panel.js.map +1 -1
  18. package/dist/components/ai-chat/tool-call.js.map +1 -1
  19. package/dist/components/ai-chat/tool-json.js.map +1 -1
  20. package/dist/components/ai-chat/turn-process.js.map +1 -1
  21. package/dist/components/ai-orb/ai-orb.js.map +1 -1
  22. package/package.json +3 -3
  23. package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
  24. package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
  25. package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
  26. package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
  27. package/src/chat/__test__/fold.test.ts +26 -1
  28. package/src/chat/__test__/session.test.ts +1 -1
  29. package/src/chat/__test__/use-chat.test.ts +6 -3
  30. package/src/chat/fold.ts +16 -1
  31. package/src/chat/session.ts +13 -6
  32. package/src/chat/transports/__test__/openai.test.ts +1 -1
  33. package/src/chat/use-chat-session.ts +4 -5
  34. package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
  35. package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
  36. package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
  37. package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Vendored from bedrock web/src/api(自包含快照,避免测试依赖仓库外绝对路径)。
3
+ * 上游更新时同步此处;依赖面:@veltra/ai 类型 + ./http + ./types。
4
+ */
5
+ import { storage, storageKey } from '@cat-kit/fe'
6
+ import { HTTPClient, TokenPlugin, type HTTPClientPlugin } from '@cat-kit/http'
7
+
8
+ import type { ApiEnvelope } from './types'
9
+
10
+ const ACCESS_KEY = storageKey<string>('access_token')
11
+ /** Legacy keys cleaned on set/clear (refresh is HttpOnly cookie from server). */
12
+ const LEGACY_REFRESH_KEY = storageKey<string>('refresh_token')
13
+
14
+ export function getAccessToken(): string | null {
15
+ return storage.local.get(ACCESS_KEY)
16
+ }
17
+
18
+ export function setAccessToken(access: string): void {
19
+ storage.local.set(ACCESS_KEY, access)
20
+ storage.local.remove(LEGACY_REFRESH_KEY)
21
+ }
22
+
23
+ export function clearTokens(): void {
24
+ storage.local.remove(ACCESS_KEY)
25
+ storage.local.remove(LEGACY_REFRESH_KEY)
26
+ }
27
+
28
+ function isApiEnvelope(body: unknown): body is ApiEnvelope {
29
+ if (body === null || typeof body !== 'object') return false
30
+ const o = body as Record<string, unknown>
31
+ return typeof o.code === 'number' && typeof o.message === 'string'
32
+ }
33
+
34
+ function envelopeErrorMessage(envelope: ApiEnvelope | undefined, fallback: string): string {
35
+ const msg = envelope?.message || fallback
36
+ return envelope?.request_id ? `${msg} [${envelope.request_id}]` : msg
37
+ }
38
+
39
+ /**
40
+ * Unwraps Bedrock `{ code, message, data?, request_id? }` envelopes.
41
+ * Successful calls resolve with `response.body === data`.
42
+ * Must run after TokenPlugin so 401 refresh/retry happens first.
43
+ */
44
+ function BedrockEnvelopePlugin(): HTTPClientPlugin {
45
+ return {
46
+ name: 'bedrock-envelope',
47
+ afterRespond({ response }) {
48
+ const { code, body } = response
49
+
50
+ // Already unwrapped (e.g. TokenPlugin retry returned a processed response).
51
+ if (!isApiEnvelope(body)) {
52
+ if (code < 200 || code >= 300) {
53
+ throw new Error(`HTTP ${code}`)
54
+ }
55
+ return
56
+ }
57
+
58
+ if (code < 200 || code >= 300) {
59
+ throw new Error(envelopeErrorMessage(body, `HTTP ${code}`))
60
+ }
61
+ if (body.code !== 0) {
62
+ throw new Error(envelopeErrorMessage(body, 'request failed'))
63
+ }
64
+
65
+ return { ...response, body: body.data }
66
+ }
67
+ }
68
+ }
69
+
70
+ const envelopePlugin = BedrockEnvelopePlugin()
71
+
72
+ /** Bare client for login/refresh (no Bearer injection). credentials so Set-Cookie / Cookie work. */
73
+ export const bareHttp = new HTTPClient('/api/v1', {
74
+ timeout: 30_000,
75
+ credentials: true,
76
+ plugins: [envelopePlugin]
77
+ })
78
+
79
+ let onAuthExpired: (() => void) | null = null
80
+
81
+ export function setOnAuthExpired(cb: (() => void) | null): void {
82
+ onAuthExpired = cb
83
+ }
84
+
85
+ function expireSession(): void {
86
+ clearTokens()
87
+ onAuthExpired?.()
88
+ }
89
+
90
+ /** 401 → POST /auth/refresh (cookie) → write new access_token; TokenPlugin retries the request. */
91
+ async function refreshAccessToken(): Promise<void> {
92
+ try {
93
+ const { body } = await bareHttp.post<{ access_token: string }>('/auth/refresh', {})
94
+ if (!body?.access_token) {
95
+ throw new Error('refresh failed')
96
+ }
97
+ setAccessToken(body.access_token)
98
+ } catch (err) {
99
+ expireSession()
100
+ throw err
101
+ }
102
+ }
103
+
104
+ export const http = new HTTPClient('/api/v1', {
105
+ timeout: 30_000,
106
+ credentials: true,
107
+ plugins: [
108
+ TokenPlugin({
109
+ getter: () => getAccessToken(),
110
+ authType: 'Bearer',
111
+ maxRetries: 1,
112
+ onRefresh: refreshAccessToken,
113
+ shouldRefresh: (response) => response.code === 401
114
+ }),
115
+ envelopePlugin
116
+ ]
117
+ })
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Vendored from bedrock web/src/api(自包含快照,避免测试依赖仓库外绝对路径)。
3
+ * 上游更新时同步此处;依赖面:@veltra/ai 类型 + ./http + ./types。
4
+ */
5
+ /**
6
+ * bedrock web 端 API 类型的最小子集(仅本仿真测试消费的 PageResult)。
7
+ * 来源:bedrock 仓 web/src/api/types.ts,同步以字段演化为准。
8
+ */
9
+ export interface PageResult<T> {
10
+ items: T[]
11
+ total: number
12
+ page: number
13
+ page_size: number
14
+ total_pages: number
15
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vite-plus/test'
2
2
 
3
3
  import { createFoldState, foldSessionEvent } from '../fold'
4
4
  import type { ChatSessionEvent } from '../session'
@@ -45,6 +45,31 @@ describe('foldSessionEvent', () => {
45
45
  })
46
46
  })
47
47
 
48
+ it('history 的 assistant/message 不把流式消息降级为 done、不用更短快照覆盖内容', () => {
49
+ // 断线补拉:直播已流式输出,历史快照落后(甚至为空)
50
+ const lagging = foldAll([
51
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '命令输出' },
52
+ { type: 'assistant/message', messageId: 'a1', content: '', history: true }
53
+ ])
54
+ expect(lagging.messages[0]).toMatchObject({ content: '命令输出', status: 'streaming' })
55
+
56
+ // 断线期间服务端推进更多:历史快照比直播长时采用历史(直播漏了增量)
57
+ const ahead = foldAll([
58
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '命令' },
59
+ { type: 'assistant/message', messageId: 'a1', content: '命令输出为 slow-done', history: true }
60
+ ])
61
+ expect(ahead.messages[0]).toMatchObject({
62
+ content: '命令输出为 slow-done',
63
+ status: 'streaming'
64
+ })
65
+
66
+ // 已终态的消息不受 history 标记影响,正常被历史回放覆盖(首挂载播种路径)
67
+ const seeded = foldAll([
68
+ { type: 'assistant/message', messageId: 'a2', content: '历史答复', history: true }
69
+ ])
70
+ expect(seeded.messages[0]).toMatchObject({ content: '历史答复', status: 'done' })
71
+ })
72
+
48
73
  it('tool/call 追加 pending 的 ChatToolCall 并保留 view', () => {
49
74
  const view = { preview: 'ls' }
50
75
  const state = foldAll([
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
 
3
3
  import { createFoldState, foldSessionEvent } from '../fold'
4
4
  import * as chat from '../index'
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
  import { effectScope, nextTick, reactive } from 'vue'
3
3
 
4
4
  import * as toolsModule from '../../tools'
@@ -765,7 +765,7 @@ describe('useChat session', () => {
765
765
  spy.mockRestore()
766
766
  })
767
767
 
768
- it('abort 调 session.cancel;running 由事件与 onDisconnect 驱动', async () => {
768
+ it('abort 调 session.cancel;running 由事件驱动,断线不翻转', async () => {
769
769
  const { chat, adapter } = await setupSession()
770
770
  expect(chat.running.value).toBe(false)
771
771
 
@@ -782,11 +782,14 @@ describe('useChat session', () => {
782
782
  adapter.emit({ type: 'error', code: 'x', message: '失败' })
783
783
  expect(chat.running.value).toBe(false)
784
784
 
785
+ // 断线 ≠ 停止:running 保持乐观,由重连回放的 prompted / idle 收敛。
786
+ // 曾在此翻转 running=false,步骤间隙断线会让过程块在「已完成 / 思考中」间跳。
785
787
  adapter.emit({ type: 'running', running: true })
786
788
  adapter.disconnect()
787
789
  await vi.waitFor(() => {
788
- expect(chat.running.value).toBe(false)
790
+ expect(adapter.fetchHistory).toHaveBeenCalled()
789
791
  })
792
+ expect(chat.running.value).toBe(true)
790
793
  })
791
794
 
792
795
  it('队列只认 snapshot;enqueue / startQueued / removeQueued / regenerate 为 no-op', async () => {
package/src/chat/fold.ts CHANGED
@@ -161,12 +161,27 @@ export function foldSessionEvent(state: ChatFoldState, event: ChatSessionEvent):
161
161
  break
162
162
  }
163
163
  case 'assistant/message': {
164
+ const current = next.messages.find((m) => m.id === event.messageId)
165
+ if (event.history && current && current.status === 'streaming') {
166
+ // 历史快照命中直播流式中的消息:快照可能落后(甚至为空),不能把消息
167
+ // 降级为 done 或用更短内容覆盖 —— 否则断线补拉会让消息在「完成 / 思考中」
168
+ // 之间来回翻转;终态由直播的 durable 帧(message_text / step 收尾)落定。
169
+ const longer = (a: string | undefined, b: string | undefined) =>
170
+ (b?.length ?? 0) > (a?.length ?? 0) ? b : a
171
+ next.messages = patchMessage(next.messages, event.messageId, {
172
+ role: 'assistant',
173
+ content: longer(current.content, event.content),
174
+ reasoning: longer(current.reasoning, event.reasoning),
175
+ toolCalls: event.toolCalls ?? current.toolCalls
176
+ })
177
+ break
178
+ }
164
179
  next.messages = patchMessage(next.messages, event.messageId, {
165
180
  role: 'assistant',
166
181
  status: 'done',
167
182
  content: event.content,
168
183
  reasoning: event.reasoning,
169
- toolCalls: event.toolCalls ?? next.messages.find((m) => m.id === event.messageId)?.toolCalls
184
+ toolCalls: event.toolCalls ?? current?.toolCalls
170
185
  })
171
186
  break
172
187
  }
@@ -1,36 +1,43 @@
1
1
  import type { AskQuestionItem } from '../tools'
2
2
  import type { ChatAttachment, ChatJob, ChatQueuedMessage, ChatToolCall } from './types'
3
3
 
4
- /** 归一化后的服务端会话事件(协议无关) */
4
+ /** 归一化后的服务端会话事件(协议无关)。
5
+ * seq 为持久帧序号:瞬态/历史回放事件省略(不参与乱序门),durable 终态事件携带。 */
5
6
  export type ChatSessionEvent =
6
7
  | {
7
8
  type: 'user/message'
8
9
  messageId: string
9
- seq: number
10
+ seq?: number
10
11
  content: string
11
12
  attachments?: ChatAttachment[]
12
13
  }
13
14
  | {
14
15
  type: 'assistant/chunk'
15
16
  messageId: string
16
- seq: number
17
+ seq?: number
17
18
  delta: string
18
19
  reasoningDelta?: string
19
20
  }
20
21
  | {
21
22
  type: 'assistant/message'
22
23
  messageId: string
23
- seq: number
24
+ seq?: number
24
25
  content: string
25
26
  reasoning?: string
26
27
  toolCalls?: ChatToolCall[]
28
+ /**
29
+ * 历史回放(REST 拉取/断线补拉)来源标记。
30
+ * 历史快照可能落后于直播流:fold 对流式中的消息不降级为 done、不用更短的
31
+ * 快照覆盖内容;直播(durable 终态帧)始终是状态权威。
32
+ */
33
+ history?: boolean
27
34
  }
28
35
  | {
29
36
  type: 'tool/call'
30
37
  callId: string
31
38
  name: string
32
39
  arguments: string
33
- seq: number
40
+ seq?: number
34
41
  view?: unknown
35
42
  }
36
43
  | {
@@ -39,7 +46,7 @@ export type ChatSessionEvent =
39
46
  status: 'success' | 'error' | 'rejected'
40
47
  result?: string
41
48
  error?: string
42
- seq: number
49
+ seq?: number
43
50
  view?: unknown
44
51
  }
45
52
  | {
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
2
2
 
3
3
  import type { ChatTransportHandlers } from '../../types'
4
4
  import { createOpenAITransport } from '../openai'
@@ -58,14 +58,13 @@ export function createSessionRuntime(surface: SessionChatSurface) {
58
58
  fold = createFoldState()
59
59
  syncFold(fold, surface)
60
60
  let disposed = false
61
+ // 断线 ≠ 会话停止:不注册 onDisconnect 把 running 置 false(保持乐观,由
62
+ // 重连回放的 prompted / step_started / idle 帧收敛)。曾在此置 false ——
63
+ // 步骤间隙断线会把过程块折叠成「已完成」,重连随即又展开,消息列表在
64
+ // 「完成 / 思考中」之间来回跳。
61
65
  const dispose = transport.open({
62
66
  onEvent(event) {
63
67
  if (!disposed) apply(event)
64
- },
65
- onDisconnect() {
66
- if (disposed) return
67
- fold = { ...fold, running: false }
68
- surface.running.value = false
69
68
  }
70
69
  })
71
70
  opened = true
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
  import {
3
3
  createApp,
4
4
  defineComponent,
@@ -1,5 +1,5 @@
1
1
  import { EditPen, Folder, Internet, QuestionFilled, Terminal, Tools } from '@veltra/icons/normal'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it } from 'vite-plus/test'
3
3
  import { defineComponent, h } from 'vue'
4
4
 
5
5
  import { FALLBACK_TOOL_ICON, resolveToolIcon } from '../tool-icons'
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
2
2
  import { createApp, h, nextTick } from 'vue'
3
3
 
4
4
  import UAiOrb from '../ai-orb.vue'
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
  import { createApp, defineComponent, h, nextTick, ref } from 'vue'
3
3
 
4
4
  import type { ChatMessage, ChatTransport } from '../../../chat/types'