@veltra/ai 2.1.5 → 2.1.7

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 (74) hide show
  1. package/README.md +42 -0
  2. package/dist/chat/fold.d.ts +41 -0
  3. package/dist/chat/fold.js +208 -0
  4. package/dist/chat/fold.js.map +1 -0
  5. package/dist/chat/index.d.ts +4 -2
  6. package/dist/chat/session.d.ts +123 -0
  7. package/dist/chat/session.js +79 -0
  8. package/dist/chat/session.js.map +1 -0
  9. package/dist/chat/types.d.ts +38 -1
  10. package/dist/chat/use-chat-helpers.js +39 -0
  11. package/dist/chat/use-chat-helpers.js.map +1 -0
  12. package/dist/chat/use-chat-session.js +68 -0
  13. package/dist/chat/use-chat-session.js.map +1 -0
  14. package/dist/chat/use-chat.d.ts +50 -2
  15. package/dist/chat/use-chat.js +73 -39
  16. package/dist/chat/use-chat.js.map +1 -1
  17. package/dist/components/ai-chat/ai-chat.js +49 -14
  18. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  19. package/dist/components/ai-chat/approval-banner.js +54 -0
  20. package/dist/components/ai-chat/approval-banner.js.map +1 -0
  21. package/dist/components/ai-chat/ask-question.js +32 -51
  22. package/dist/components/ai-chat/ask-question.js.map +1 -1
  23. package/dist/components/ai-chat/chat-input.js +9 -3
  24. package/dist/components/ai-chat/chat-input.js.map +1 -1
  25. package/dist/components/ai-chat/di.js.map +1 -1
  26. package/dist/components/ai-chat/job-bar.js +48 -0
  27. package/dist/components/ai-chat/job-bar.js.map +1 -0
  28. package/dist/components/ai-chat/message-list.js +5 -2
  29. package/dist/components/ai-chat/message-list.js.map +1 -1
  30. package/dist/components/ai-chat/queue-list.js +9 -3
  31. package/dist/components/ai-chat/queue-list.js.map +1 -1
  32. package/dist/components/ai-chat/side-panel.js +12 -30
  33. package/dist/components/ai-chat/side-panel.js.map +1 -1
  34. package/dist/components/ai-chat/style2.css +150 -8
  35. package/dist/components/ai-chat/tool-call.js +51 -40
  36. package/dist/components/ai-chat/tool-call.js.map +1 -1
  37. package/dist/components/ai-chat/tool-icons.js +31 -0
  38. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  39. package/dist/components/ai-chat/tool-json.js +40 -0
  40. package/dist/components/ai-chat/tool-json.js.map +1 -0
  41. package/dist/components/ai-orb/style2.css +1 -1
  42. package/dist/index.d.ts +6 -4
  43. package/dist/index.js +3 -1
  44. package/dist/tools/ask-question/ask-question.js +48 -2
  45. package/dist/tools/ask-question/ask-question.js.map +1 -1
  46. package/dist/types/ai-chat.d.ts +17 -6
  47. package/package.json +7 -7
  48. package/src/chat/__test__/fold.test.ts +136 -0
  49. package/src/chat/__test__/session.test.ts +191 -0
  50. package/src/chat/__test__/use-chat.test.ts +268 -1
  51. package/src/chat/fold.ts +260 -0
  52. package/src/chat/index.ts +2 -0
  53. package/src/chat/session.ts +168 -0
  54. package/src/chat/types.ts +40 -0
  55. package/src/chat/use-chat-helpers.ts +39 -0
  56. package/src/chat/use-chat-session.ts +87 -0
  57. package/src/chat/use-chat.ts +90 -44
  58. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  59. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  60. package/src/components/ai-chat/ai-chat.vue +39 -4
  61. package/src/components/ai-chat/approval-banner.vue +37 -0
  62. package/src/components/ai-chat/ask-question.vue +38 -54
  63. package/src/components/ai-chat/chat-input.vue +10 -2
  64. package/src/components/ai-chat/di.ts +6 -4
  65. package/src/components/ai-chat/job-bar.vue +41 -0
  66. package/src/components/ai-chat/message-list.vue +4 -1
  67. package/src/components/ai-chat/queue-list.vue +3 -1
  68. package/src/components/ai-chat/side-panel.vue +15 -13
  69. package/src/components/ai-chat/style.scss +162 -6
  70. package/src/components/ai-chat/tool-call.vue +46 -34
  71. package/src/components/ai-chat/tool-icons.ts +33 -0
  72. package/src/components/ai-chat/tool-json.vue +52 -0
  73. package/src/tools/ask-question/ask-question.ts +53 -4
  74. package/src/types/ai-chat.ts +17 -5
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { createFoldState, foldSessionEvent } from '../fold'
4
+ import * as chat from '../index'
5
+ import {
6
+ createServerTransport,
7
+ isServerTransport,
8
+ type ChatSessionAdapter,
9
+ type ChatSessionEvent
10
+ } from '../session'
11
+
12
+ function createFakeAdapter(options?: {
13
+ catchUp?: ChatSessionEvent[]
14
+ }): ChatSessionAdapter & {
15
+ emit: (event: ChatSessionEvent) => void
16
+ disconnect: () => void
17
+ unsubscribed: () => boolean
18
+ } {
19
+ let listener: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void } | undefined
20
+ let unsubscribed = false
21
+
22
+ const adapter: ChatSessionAdapter = {
23
+ subscribe(handlers) {
24
+ listener = handlers
25
+ return () => {
26
+ unsubscribed = true
27
+ listener = undefined
28
+ }
29
+ },
30
+ send: vi.fn(async () => {}),
31
+ cancel: vi.fn(async () => {}),
32
+ respond: vi.fn(async () => {}),
33
+ fetchHistory: vi.fn(async () => ({ events: options?.catchUp ?? [], hasMore: false })),
34
+ selectModel: vi.fn(async () => {})
35
+ }
36
+
37
+ return Object.assign(adapter, {
38
+ emit(event: ChatSessionEvent) {
39
+ listener?.onEvent(event)
40
+ },
41
+ disconnect() {
42
+ listener?.onDisconnect?.()
43
+ },
44
+ unsubscribed: () => unsubscribed
45
+ })
46
+ }
47
+
48
+ describe('isServerTransport', () => {
49
+ it('仅对 kind: session 的对象为 true', () => {
50
+ const transport = createServerTransport(createFakeAdapter())
51
+ expect(transport.kind).toBe('session')
52
+ expect(isServerTransport(transport)).toBe(true)
53
+ expect(isServerTransport({ kind: 'session' })).toBe(true)
54
+ expect(isServerTransport(() => {})).toBe(false)
55
+ expect(isServerTransport(null)).toBe(false)
56
+ expect(isServerTransport(undefined)).toBe(false)
57
+ expect(isServerTransport({})).toBe(false)
58
+ expect(isServerTransport({ kind: 'client' })).toBe(false)
59
+ })
60
+ })
61
+
62
+ describe('createServerTransport', () => {
63
+ it('主入口导出 A1 符号且不导出 createBedrockTransport', () => {
64
+ expect(chat.createServerTransport).toBeTypeOf('function')
65
+ expect(chat.isServerTransport).toBeTypeOf('function')
66
+ expect(chat.foldSessionEvent).toBeTypeOf('function')
67
+ expect(chat.createFoldState).toBeTypeOf('function')
68
+ expect('createBedrockTransport' in chat).toBe(false)
69
+ })
70
+
71
+ it('后到的更小 seq 丢弃且 console.warn,不打乱已折叠状态', () => {
72
+ const adapter = createFakeAdapter()
73
+ const transport = createServerTransport(adapter)
74
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
75
+ let state = createFoldState()
76
+ transport.open({
77
+ onEvent(event) {
78
+ state = foldSessionEvent(state, event)
79
+ }
80
+ })
81
+
82
+ adapter.emit({ type: 'user/message', messageId: 'u2', seq: 2, content: '先到' })
83
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: '乱序' })
84
+
85
+ expect(warn).toHaveBeenCalledTimes(1)
86
+ expect(warn.mock.calls[0]?.[0]).toMatch(/丢弃乱序事件/)
87
+ expect(state.messages).toHaveLength(1)
88
+ expect(state.messages[0]).toMatchObject({ id: 'u2', content: '先到' })
89
+ warn.mockRestore()
90
+ })
91
+
92
+ it('open 的 disposer 释放订阅', () => {
93
+ const adapter = createFakeAdapter()
94
+ const transport = createServerTransport(adapter)
95
+ const onEvent = vi.fn()
96
+ const dispose = transport.open({ onEvent })
97
+
98
+ adapter.emit({ type: 'finish' })
99
+ expect(onEvent).toHaveBeenCalledTimes(1)
100
+
101
+ dispose()
102
+ expect(adapter.unsubscribed()).toBe(true)
103
+ adapter.emit({ type: 'finish' })
104
+ adapter.disconnect()
105
+ expect(onEvent).toHaveBeenCalledTimes(1)
106
+ expect(adapter.fetchHistory).not.toHaveBeenCalled()
107
+ })
108
+
109
+ it('断开后 fetchHistory(lastSeq) 补拉再 fold', async () => {
110
+ const catchUp: ChatSessionEvent[] = [
111
+ { type: 'user/message', messageId: 'u2', seq: 2, content: '补拉' }
112
+ ]
113
+ const adapter = createFakeAdapter({ catchUp })
114
+ const transport = createServerTransport(adapter)
115
+ let state = createFoldState()
116
+ const onDisconnect = vi.fn()
117
+ transport.open({
118
+ onEvent(event) {
119
+ state = foldSessionEvent(state, event)
120
+ },
121
+ onDisconnect
122
+ })
123
+
124
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: '已有' })
125
+ adapter.disconnect()
126
+
127
+ await vi.waitFor(() => {
128
+ expect(adapter.fetchHistory).toHaveBeenCalledWith(1)
129
+ expect(state.messages.map((m) => m.id)).toEqual(['u1', 'u2'])
130
+ expect(onDisconnect).toHaveBeenCalledTimes(1)
131
+ })
132
+ })
133
+
134
+ it('send / cancel / respond / selectModel 同一动作未完成时忽略重复调用', async () => {
135
+ const adapter = createFakeAdapter()
136
+ let releaseSend!: () => void
137
+ let releaseCancel!: () => void
138
+ let releaseRespond!: () => void
139
+ let releaseSelect!: () => void
140
+ adapter.send = vi.fn(
141
+ () =>
142
+ new Promise<void>((resolve) => {
143
+ releaseSend = resolve
144
+ })
145
+ )
146
+ adapter.cancel = vi.fn(
147
+ () =>
148
+ new Promise<void>((resolve) => {
149
+ releaseCancel = resolve
150
+ })
151
+ )
152
+ adapter.respond = vi.fn(
153
+ () =>
154
+ new Promise<void>((resolve) => {
155
+ releaseRespond = resolve
156
+ })
157
+ )
158
+ adapter.selectModel = vi.fn(
159
+ () =>
160
+ new Promise<void>((resolve) => {
161
+ releaseSelect = resolve
162
+ })
163
+ )
164
+
165
+ const transport = createServerTransport(adapter)
166
+
167
+ const send1 = transport.send('a')
168
+ const send2 = transport.send('b')
169
+ expect(adapter.send).toHaveBeenCalledTimes(1)
170
+ releaseSend()
171
+ await Promise.all([send1, send2])
172
+
173
+ const cancel1 = transport.cancel()
174
+ const cancel2 = transport.cancel()
175
+ expect(adapter.cancel).toHaveBeenCalledTimes(1)
176
+ releaseCancel()
177
+ await Promise.all([cancel1, cancel2])
178
+
179
+ const respond1 = transport.respond('rpc-1', true)
180
+ const respond2 = transport.respond('rpc-2', false)
181
+ expect(adapter.respond).toHaveBeenCalledTimes(1)
182
+ releaseRespond()
183
+ await Promise.all([respond1, respond2])
184
+
185
+ const select1 = transport.selectModel('p', 'm1')
186
+ const select2 = transport.selectModel('p', 'm2')
187
+ expect(adapter.selectModel).toHaveBeenCalledTimes(1)
188
+ releaseSelect()
189
+ await Promise.all([select1, select2])
190
+ })
191
+ })
@@ -1,7 +1,14 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { nextTick, reactive } from 'vue'
2
+ import { effectScope, nextTick, reactive } from 'vue'
3
3
 
4
+ import * as toolsModule from '../../tools'
4
5
  import type { AiChatEmits, AiChatProps } from '../../types'
6
+ import {
7
+ createServerTransport,
8
+ isServerTransport,
9
+ type ChatSessionAdapter,
10
+ type ChatSessionEvent
11
+ } from '../session'
5
12
  import type {
6
13
  ChatMessage,
7
14
  ChatTool,
@@ -635,3 +642,263 @@ describe('useChat', () => {
635
642
  expect(chat.lastTurnUsage.value).toBeNull()
636
643
  })
637
644
  })
645
+
646
+ function createFakeAdapter(options?: {
647
+ history?: ChatSessionEvent[]
648
+ }): ChatSessionAdapter & {
649
+ emit: (event: ChatSessionEvent) => void
650
+ disconnect: () => void
651
+ unsubscribed: () => boolean
652
+ } {
653
+ let listener: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void } | undefined
654
+ let unsubscribed = false
655
+
656
+ const adapter: ChatSessionAdapter = {
657
+ subscribe(handlers) {
658
+ listener = handlers
659
+ return () => {
660
+ unsubscribed = true
661
+ listener = undefined
662
+ }
663
+ },
664
+ send: vi.fn(async () => {}),
665
+ cancel: vi.fn(async () => {}),
666
+ respond: vi.fn(async () => {}),
667
+ fetchHistory: vi.fn(async () => ({ events: options?.history ?? [], hasMore: false })),
668
+ selectModel: vi.fn(async () => {})
669
+ }
670
+
671
+ return Object.assign(adapter, {
672
+ emit(event: ChatSessionEvent) {
673
+ listener?.onEvent(event)
674
+ },
675
+ disconnect() {
676
+ listener?.onDisconnect?.()
677
+ },
678
+ unsubscribed: () => unsubscribed
679
+ })
680
+ }
681
+
682
+ async function setupSession(options?: {
683
+ history?: ChatSessionEvent[]
684
+ tools?: AiChatProps['tools']
685
+ models?: AiChatProps['models']
686
+ model?: string
687
+ }) {
688
+ const adapter = createFakeAdapter({ history: options?.history })
689
+ const props = reactive<AiChatProps>({
690
+ transport: createServerTransport(adapter),
691
+ tools: options?.tools,
692
+ models: options?.models,
693
+ model: options?.model
694
+ })
695
+ const emit = createEmit()
696
+ const chat = useChat({ props, emit })
697
+ await vi.waitFor(() => {
698
+ expect(adapter.fetchHistory).toHaveBeenCalled()
699
+ })
700
+ return { adapter, props, emit, chat }
701
+ }
702
+
703
+ describe('useChat session', () => {
704
+ it('函数 transport 时 isServerTransport 为 false', () => {
705
+ const emit = createEmit()
706
+ const transport = textTransport('x')
707
+ useChat({ props: { transport }, emit })
708
+ expect(isServerTransport(transport)).toBe(false)
709
+ })
710
+
711
+ it('open + fetchHistory 播种 messages;函数 transport 不调用 open/fetchHistory', async () => {
712
+ const { chat, adapter } = await setupSession({
713
+ history: [{ type: 'user/message', messageId: 'u1', seq: 1, content: '历史' }]
714
+ })
715
+ expect(isServerTransport(createServerTransport(adapter))).toBe(true)
716
+ expect(chat.messages.value).toEqual([{ id: 'u1', role: 'user', content: '历史' }])
717
+
718
+ const fnTransport = textTransport('ok')
719
+ const fetchHistory = vi.fn()
720
+ const open = vi.fn()
721
+ useChat({
722
+ props: { transport: Object.assign(fnTransport, { open, fetchHistory }) },
723
+ emit: createEmit()
724
+ })
725
+ await Promise.resolve()
726
+ expect(open).not.toHaveBeenCalled()
727
+ expect(fetchHistory).not.toHaveBeenCalled()
728
+ })
729
+
730
+ it('transport 更换或卸载时调用 disposer', async () => {
731
+ const adapter1 = createFakeAdapter()
732
+ const adapter2 = createFakeAdapter()
733
+ const props = reactive<AiChatProps>({ transport: createServerTransport(adapter1) })
734
+ const scope = effectScope()
735
+ scope.run(() => {
736
+ useChat({ props, emit: createEmit() })
737
+ })
738
+ await vi.waitFor(() => expect(adapter1.fetchHistory).toHaveBeenCalled())
739
+ expect(adapter1.unsubscribed()).toBe(false)
740
+
741
+ props.transport = createServerTransport(adapter2)
742
+ await vi.waitFor(() => expect(adapter2.fetchHistory).toHaveBeenCalled())
743
+ expect(adapter1.unsubscribed()).toBe(true)
744
+
745
+ scope.stop()
746
+ expect(adapter2.unsubscribed()).toBe(true)
747
+ })
748
+
749
+ it('send 只调 session.send,不带全量历史、不执行 execute、不注入内置工具', async () => {
750
+ const spy = vi.spyOn(toolsModule, 'createBuiltinTools')
751
+ const execute = vi.fn()
752
+ const { chat, adapter } = await setupSession({
753
+ tools: [{ name: 'add', description: '加法', parameters: {}, execute }]
754
+ })
755
+ spy.mockClear()
756
+
757
+ chat.send('hello')
758
+ expect(adapter.send).toHaveBeenCalledWith('hello', undefined)
759
+ expect(chat.messages.value).toHaveLength(0)
760
+
761
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: 'hello' })
762
+ adapter.emit({ type: 'tool/call', callId: 'c1', name: 'add', arguments: '{"a":1}', seq: 2 })
763
+ expect(execute).not.toHaveBeenCalled()
764
+ expect(spy).not.toHaveBeenCalled()
765
+ spy.mockRestore()
766
+ })
767
+
768
+ it('abort 调 session.cancel;running 由事件与 onDisconnect 驱动', async () => {
769
+ const { chat, adapter } = await setupSession()
770
+ expect(chat.running.value).toBe(false)
771
+
772
+ adapter.emit({ type: 'running', running: true })
773
+ expect(chat.running.value).toBe(true)
774
+
775
+ chat.abort()
776
+ expect(adapter.cancel).toHaveBeenCalledTimes(1)
777
+
778
+ adapter.emit({ type: 'finish' })
779
+ expect(chat.running.value).toBe(false)
780
+
781
+ adapter.emit({ type: 'running', running: true })
782
+ adapter.emit({ type: 'error', code: 'x', message: '失败' })
783
+ expect(chat.running.value).toBe(false)
784
+
785
+ adapter.emit({ type: 'running', running: true })
786
+ adapter.disconnect()
787
+ await vi.waitFor(() => {
788
+ expect(chat.running.value).toBe(false)
789
+ })
790
+ })
791
+
792
+ it('队列只认 snapshot;enqueue / startQueued / removeQueued / regenerate 为 no-op', async () => {
793
+ const { chat, adapter } = await setupSession()
794
+ adapter.emit({ type: 'queue/snapshot', items: [{ id: 'q1', content: '排队' }] })
795
+ expect(chat.queue.value).toEqual([{ id: 'q1', content: '排队' }])
796
+
797
+ chat.enqueue('本地')
798
+ chat.startQueued('q1')
799
+ expect(chat.removeQueued('q1')).toBeUndefined()
800
+ expect(chat.queue.value).toEqual([{ id: 'q1', content: '排队' }])
801
+ expect(adapter.send).not.toHaveBeenCalled()
802
+
803
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: '问' })
804
+ adapter.emit({ type: 'assistant/message', messageId: 'a1', seq: 2, content: '答' })
805
+ chat.regenerate()
806
+ expect(chat.messages.value).toHaveLength(2)
807
+ expect(adapter.send).not.toHaveBeenCalled()
808
+ })
809
+
810
+ it('clear 只清本地渲染面,不调 cancel', async () => {
811
+ const { chat, adapter } = await setupSession()
812
+ adapter.emit({ type: 'user/message', messageId: 'u1', seq: 1, content: 'hi' })
813
+ adapter.emit({
814
+ type: 'jobs/snapshot',
815
+ jobs: [{ id: 'j1', kind: 'bash', label: '跑', status: 'running' }]
816
+ })
817
+ adapter.emit({
818
+ type: 'projection',
819
+ key: 'tokenUsage',
820
+ seq: 2,
821
+ value: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }
822
+ })
823
+ adapter.emit({ type: 'projection', key: 'title', seq: 3, value: '标题' })
824
+ adapter.emit({ type: 'projection', key: 'other', seq: 4, value: 1 })
825
+
826
+ expect(chat.jobs.value).toHaveLength(1)
827
+ expect(chat.title.value).toBe('标题')
828
+ expect(chat.projections.value.other).toBe(1)
829
+ expect(chat.tokenUsage.value?.totalTokens).toBe(3)
830
+
831
+ chat.clear()
832
+ expect(adapter.cancel).not.toHaveBeenCalled()
833
+ expect(chat.messages.value).toHaveLength(0)
834
+ expect(chat.queue.value).toHaveLength(0)
835
+ expect(chat.jobs.value).toHaveLength(0)
836
+ expect(chat.tokenUsage.value).toBeNull()
837
+ expect(chat.projections.value).toEqual({})
838
+ expect(chat.title.value).toBeNull()
839
+ })
840
+
841
+ it('approval 与 question 走 session.respond,不执行客户端工具', async () => {
842
+ const execute = vi.fn()
843
+ const { chat, adapter } = await setupSession({
844
+ tools: [{ name: 'del', description: '删', parameters: {}, execute }]
845
+ })
846
+
847
+ adapter.emit({ type: 'tool/call', callId: 'c1', name: 'del', arguments: '{}', seq: 1 })
848
+ adapter.emit({
849
+ type: 'approval/requested',
850
+ approvalId: 'ap1',
851
+ toolName: 'del',
852
+ callId: 'c1',
853
+ rpcId: 'rpc-call'
854
+ })
855
+ expect(chat.messages.value[0]?.toolCalls?.[0]?.status).toBe('awaiting-confirm')
856
+ chat.respondToolCall('c1', true)
857
+ await vi.waitFor(() => {
858
+ expect(adapter.respond).toHaveBeenCalledWith('rpc-call', true, undefined)
859
+ })
860
+ expect(execute).not.toHaveBeenCalled()
861
+
862
+ adapter.emit({
863
+ type: 'approval/requested',
864
+ approvalId: 'ap2',
865
+ toolName: 'del',
866
+ rpcId: 'rpc-banner'
867
+ })
868
+ expect(chat.pendingApprovals.value.some((a) => a.rpcId === 'rpc-banner' && !a.callId)).toBe(
869
+ true
870
+ )
871
+ chat.respondSession('rpc-banner', false)
872
+ await vi.waitFor(() => {
873
+ expect(adapter.respond).toHaveBeenCalledWith('rpc-banner', false, undefined)
874
+ })
875
+
876
+ adapter.emit({
877
+ type: 'question/requested',
878
+ rpcId: 'rpc-q',
879
+ questions: [{ question: '选一个', options: ['A', 'B'] }]
880
+ })
881
+ expect(chat.pendingQuestion.value?.rpcId).toBe('rpc-q')
882
+ chat.respondSession('rpc-q', true, [{ question: '选一个', answer: 'A' }])
883
+ await vi.waitFor(() => {
884
+ expect(adapter.respond).toHaveBeenCalledWith('rpc-q', true, [
885
+ { question: '选一个', answer: 'A' }
886
+ ])
887
+ })
888
+ })
889
+
890
+ it('已 open 时模型切换走 selectModel(providerId, modelId)', async () => {
891
+ const { chat, adapter } = await setupSession({
892
+ models: [
893
+ { id: 'm1', providerId: 'p1' },
894
+ { id: 'm2', providerId: 'p1' }
895
+ ],
896
+ model: 'm1'
897
+ })
898
+ expect(adapter.selectModel).not.toHaveBeenCalled()
899
+
900
+ chat.model.value = 'm2'
901
+ await nextTick()
902
+ expect(adapter.selectModel).toHaveBeenCalledWith('p1', 'm2')
903
+ })
904
+ })