@seed-ship/mcp-ui-solid 2.3.0 → 2.5.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 (39) hide show
  1. package/dist/components/ChatPrompt.cjs +271 -0
  2. package/dist/components/ChatPrompt.cjs.map +1 -0
  3. package/dist/components/ChatPrompt.d.ts +33 -0
  4. package/dist/components/ChatPrompt.d.ts.map +1 -0
  5. package/dist/components/ChatPrompt.js +271 -0
  6. package/dist/components/ChatPrompt.js.map +1 -0
  7. package/dist/hooks/useChatBus.cjs +28 -0
  8. package/dist/hooks/useChatBus.cjs.map +1 -0
  9. package/dist/hooks/useChatBus.d.ts +56 -0
  10. package/dist/hooks/useChatBus.d.ts.map +1 -0
  11. package/dist/hooks/useChatBus.js +28 -0
  12. package/dist/hooks/useChatBus.js.map +1 -0
  13. package/dist/index.cjs +9 -0
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +5 -1
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +9 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/services/chat-bus.cjs +118 -0
  21. package/dist/services/chat-bus.cjs.map +1 -0
  22. package/dist/services/chat-bus.d.ts +43 -0
  23. package/dist/services/chat-bus.d.ts.map +1 -0
  24. package/dist/services/chat-bus.js +118 -0
  25. package/dist/services/chat-bus.js.map +1 -0
  26. package/dist/services/index.d.ts +1 -0
  27. package/dist/services/index.d.ts.map +1 -1
  28. package/dist/types/chat-bus.d.ts +286 -0
  29. package/dist/types/chat-bus.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/src/components/ChatPrompt.test.tsx +280 -0
  32. package/src/components/ChatPrompt.tsx +263 -0
  33. package/src/hooks/useChatBus.tsx +81 -0
  34. package/src/index.ts +34 -0
  35. package/src/services/chat-bus.test.ts +306 -0
  36. package/src/services/chat-bus.ts +183 -0
  37. package/src/services/index.ts +2 -0
  38. package/src/types/chat-bus.ts +320 -0
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Tests for Chat Bus — createEventEmitter + createCommandHandler + createChatBus
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
6
+ import { createEventEmitter, createCommandHandler, createChatBus } from './chat-bus'
7
+ import type { ChatEvents, ChatCommands } from '../types/chat-bus'
8
+
9
+ describe('createEventEmitter', () => {
10
+ it('emits events to subscribed listeners', () => {
11
+ const emitter = createEventEmitter()
12
+ const handler = vi.fn()
13
+
14
+ emitter.on('onToken', handler)
15
+ emitter.emit('onToken', { streamKey: 'abc', token: 'hello' })
16
+
17
+ expect(handler).toHaveBeenCalledWith({ streamKey: 'abc', token: 'hello' })
18
+ })
19
+
20
+ it('supports multiple listeners for the same event', () => {
21
+ const emitter = createEventEmitter()
22
+ const handler1 = vi.fn()
23
+ const handler2 = vi.fn()
24
+
25
+ emitter.on('onStreamStart', handler1)
26
+ emitter.on('onStreamStart', handler2)
27
+ emitter.emit('onStreamStart', { streamKey: 'abc' })
28
+
29
+ expect(handler1).toHaveBeenCalledOnce()
30
+ expect(handler2).toHaveBeenCalledOnce()
31
+ })
32
+
33
+ it('returns unsubscribe function', () => {
34
+ const emitter = createEventEmitter()
35
+ const handler = vi.fn()
36
+
37
+ const unsub = emitter.on('onToken', handler)
38
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
39
+ expect(handler).toHaveBeenCalledOnce()
40
+
41
+ unsub()
42
+ emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
43
+ expect(handler).toHaveBeenCalledOnce() // still 1, not 2
44
+ })
45
+
46
+ it('clear() removes all listeners', () => {
47
+ const emitter = createEventEmitter()
48
+ const handler = vi.fn()
49
+
50
+ emitter.on('onToken', handler)
51
+ emitter.on('onStreamEnd', handler)
52
+ emitter.clear()
53
+
54
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
55
+ emitter.emit('onStreamEnd', { streamKey: 'abc', metadata: {} as any })
56
+
57
+ expect(handler).not.toHaveBeenCalled()
58
+ })
59
+
60
+ it('does not throw when emitting with no listeners', () => {
61
+ const emitter = createEventEmitter()
62
+ expect(() => emitter.emit('onToken', { streamKey: 'abc', token: 'a' })).not.toThrow()
63
+ })
64
+
65
+ it('catches errors in handlers without breaking other listeners', () => {
66
+ const emitter = createEventEmitter()
67
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
68
+ const badHandler = vi.fn(() => { throw new Error('boom') })
69
+ const goodHandler = vi.fn()
70
+
71
+ emitter.on('onToken', badHandler)
72
+ emitter.on('onToken', goodHandler)
73
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
74
+
75
+ expect(badHandler).toHaveBeenCalled()
76
+ expect(goodHandler).toHaveBeenCalled()
77
+ expect(errorSpy).toHaveBeenCalled()
78
+
79
+ errorSpy.mockRestore()
80
+ })
81
+
82
+ describe('streamKey filtering', () => {
83
+ it('filters events by streamKey when option is set', () => {
84
+ const emitter = createEventEmitter()
85
+ const handler = vi.fn()
86
+
87
+ emitter.on('onToken', handler, { streamKey: 'stream-1' })
88
+
89
+ emitter.emit('onToken', { streamKey: 'stream-1', token: 'a' })
90
+ emitter.emit('onToken', { streamKey: 'stream-2', token: 'b' })
91
+
92
+ expect(handler).toHaveBeenCalledOnce()
93
+ expect(handler).toHaveBeenCalledWith({ streamKey: 'stream-1', token: 'a' })
94
+ })
95
+
96
+ it('does not filter when no streamKey option', () => {
97
+ const emitter = createEventEmitter()
98
+ const handler = vi.fn()
99
+
100
+ emitter.on('onToken', handler)
101
+
102
+ emitter.emit('onToken', { streamKey: 'stream-1', token: 'a' })
103
+ emitter.emit('onToken', { streamKey: 'stream-2', token: 'b' })
104
+
105
+ expect(handler).toHaveBeenCalledTimes(2)
106
+ })
107
+
108
+ it('filters onCustomEvent by streamKey in second arg', () => {
109
+ const emitter = createEventEmitter()
110
+ const handler = vi.fn()
111
+
112
+ emitter.on('onCustomEvent', handler, { streamKey: 'stream-1' })
113
+
114
+ // onCustomEvent signature: (type: string, event: ChatEventBase & { data })
115
+ emitter.emit('onCustomEvent', 'my_event', { streamKey: 'stream-1', data: 'yes' })
116
+ emitter.emit('onCustomEvent', 'my_event', { streamKey: 'stream-2', data: 'no' })
117
+
118
+ expect(handler).toHaveBeenCalledOnce()
119
+ expect(handler).toHaveBeenCalledWith('my_event', { streamKey: 'stream-1', data: 'yes' })
120
+ })
121
+ })
122
+
123
+ describe('throttle', () => {
124
+ beforeEach(() => { vi.useFakeTimers() })
125
+ afterEach(() => { vi.useRealTimers() })
126
+
127
+ it('throttles handler calls', () => {
128
+ const emitter = createEventEmitter()
129
+ const handler = vi.fn()
130
+
131
+ emitter.on('onToken', handler, { throttle: 100 })
132
+
133
+ // Rapid fire
134
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
135
+ emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
136
+ emitter.emit('onToken', { streamKey: 'abc', token: 'c' })
137
+
138
+ // First call goes through immediately
139
+ expect(handler).toHaveBeenCalledOnce()
140
+ expect(handler).toHaveBeenCalledWith({ streamKey: 'abc', token: 'a' })
141
+
142
+ // After throttle period, last call fires
143
+ vi.advanceTimersByTime(100)
144
+ expect(handler).toHaveBeenCalledTimes(2)
145
+ expect(handler).toHaveBeenLastCalledWith({ streamKey: 'abc', token: 'c' })
146
+ })
147
+
148
+ it('cancels pending throttle timer on unsubscribe', () => {
149
+ const emitter = createEventEmitter()
150
+ const handler = vi.fn()
151
+
152
+ const unsub = emitter.on('onToken', handler, { throttle: 100 })
153
+
154
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
155
+ expect(handler).toHaveBeenCalledOnce() // immediate
156
+
157
+ emitter.emit('onToken', { streamKey: 'abc', token: 'b' }) // queued
158
+ unsub() // should cancel the queued call
159
+
160
+ vi.advanceTimersByTime(200)
161
+ expect(handler).toHaveBeenCalledOnce() // still 1, queued was cancelled
162
+ })
163
+
164
+ it('catches errors in throttled deferred calls', () => {
165
+ const emitter = createEventEmitter()
166
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
167
+ const badHandler = vi.fn(() => { throw new Error('throttle boom') })
168
+
169
+ emitter.on('onToken', badHandler, { throttle: 50 })
170
+
171
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' }) // immediate — caught by emit
172
+ emitter.emit('onToken', { streamKey: 'abc', token: 'b' }) // deferred
173
+
174
+ vi.advanceTimersByTime(50) // fires deferred — should catch
175
+ expect(errorSpy).toHaveBeenCalled()
176
+
177
+ errorSpy.mockRestore()
178
+ })
179
+
180
+ it('non-throttled listeners fire immediately', () => {
181
+ const emitter = createEventEmitter()
182
+ const handler = vi.fn()
183
+
184
+ emitter.on('onToken', handler) // no throttle
185
+
186
+ emitter.emit('onToken', { streamKey: 'abc', token: 'a' })
187
+ emitter.emit('onToken', { streamKey: 'abc', token: 'b' })
188
+ emitter.emit('onToken', { streamKey: 'abc', token: 'c' })
189
+
190
+ expect(handler).toHaveBeenCalledTimes(3)
191
+ })
192
+ })
193
+ })
194
+
195
+ describe('createCommandHandler', () => {
196
+ it('executes registered command handlers', () => {
197
+ const commands = createCommandHandler()
198
+ const handler = vi.fn()
199
+
200
+ commands.handle('injectPrompt', handler)
201
+ commands.exec('injectPrompt', 'Hello')
202
+
203
+ expect(handler).toHaveBeenCalledWith('Hello')
204
+ })
205
+
206
+ it('returns handler result', () => {
207
+ const commands = createCommandHandler()
208
+
209
+ commands.handle('sendPrompt', (text: string) => `corr-${text}`)
210
+ const result = commands.exec('sendPrompt', 'test')
211
+
212
+ expect(result).toBe('corr-test')
213
+ })
214
+
215
+ it('warns when executing unregistered command', () => {
216
+ const commands = createCommandHandler()
217
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
218
+
219
+ commands.exec('injectPrompt', 'test')
220
+
221
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('injectPrompt'))
222
+ warnSpy.mockRestore()
223
+ })
224
+
225
+ it('replaces handler when handle() is called again', () => {
226
+ const commands = createCommandHandler()
227
+ const handler1 = vi.fn()
228
+ const handler2 = vi.fn()
229
+
230
+ commands.handle('injectPrompt', handler1)
231
+ commands.handle('injectPrompt', handler2)
232
+ commands.exec('injectPrompt', 'test')
233
+
234
+ expect(handler1).not.toHaveBeenCalled()
235
+ expect(handler2).toHaveBeenCalledWith('test')
236
+ })
237
+ })
238
+
239
+ describe('createChatBus', () => {
240
+ it('creates a bus with events and commands', () => {
241
+ const bus = createChatBus()
242
+
243
+ expect(bus.events).toBeDefined()
244
+ expect(bus.events.on).toBeTypeOf('function')
245
+ expect(bus.events.emit).toBeTypeOf('function')
246
+ expect(bus.events.clear).toBeTypeOf('function')
247
+
248
+ expect(bus.commands).toBeDefined()
249
+ expect(bus.commands.handle).toBeTypeOf('function')
250
+ expect(bus.commands.exec).toBeTypeOf('function')
251
+ })
252
+
253
+ it('events and commands work together', () => {
254
+ const bus = createChatBus()
255
+ const responses: string[] = []
256
+
257
+ // Agent subscribes to stream end
258
+ bus.events.on('onStreamEnd', (event) => {
259
+ if ((event.metadata as any).needs_period) {
260
+ // Agent sends a command back
261
+ bus.commands.exec('injectPrompt', 'DVF 93 2024')
262
+ }
263
+ })
264
+
265
+ // App handles the command
266
+ bus.commands.handle('injectPrompt', (text) => {
267
+ responses.push(text)
268
+ })
269
+
270
+ // Simulate stream end
271
+ bus.events.emit('onStreamEnd', {
272
+ streamKey: 'abc',
273
+ metadata: { needs_period: true } as any,
274
+ })
275
+
276
+ expect(responses).toEqual(['DVF 93 2024'])
277
+ })
278
+
279
+ it('correlationId flows through events→commands→events cycle', () => {
280
+ const bus = createChatBus()
281
+ const receivedCorrelations: (string | undefined)[] = []
282
+
283
+ // App handles sendPrompt — returns correlationId
284
+ bus.commands.handle('sendPrompt', (_text: string) => {
285
+ return 'corr-123'
286
+ })
287
+
288
+ // Agent listens for stream end with correlation
289
+ bus.events.on('onStreamEnd', (event) => {
290
+ receivedCorrelations.push(event.correlationId)
291
+ })
292
+
293
+ // Agent sends prompt
294
+ const corrId = bus.commands.exec('sendPrompt', 'test query')
295
+ expect(corrId).toBe('corr-123')
296
+
297
+ // App bridges stream end with correlation
298
+ bus.events.emit('onStreamEnd', {
299
+ streamKey: 'stream-1',
300
+ correlationId: corrId,
301
+ metadata: {} as any,
302
+ })
303
+
304
+ expect(receivedCorrelations).toEqual(['corr-123'])
305
+ })
306
+ })
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Chat Bus — Event Emitter + Command Handler
3
+ * v2.4.0: Core primitives for the chat event/command bus
4
+ *
5
+ * @experimental — This API may change without major bump until v2.5.0.
6
+ */
7
+
8
+ import type {
9
+ ChatEvents,
10
+ ChatCommands,
11
+ ChatEventEmitter,
12
+ ChatCommandHandler,
13
+ ChatBus,
14
+ EventSubscribeOptions,
15
+ } from '../types/chat-bus'
16
+
17
+ // ─── Event Emitter ───────────────────────────────────────────
18
+
19
+ interface Listener<F extends (...args: any[]) => any> {
20
+ handler: F
21
+ options?: EventSubscribeOptions
22
+ throttledHandler?: F
23
+ }
24
+
25
+ /**
26
+ * Create a typed event emitter with throttle and streamKey filtering support.
27
+ *
28
+ * @experimental
29
+ *
30
+ * @example
31
+ * const emitter = createEventEmitter<ChatEvents>()
32
+ * const unsub = emitter.on('onToken', (event) => console.log(event.token), { throttle: 100 })
33
+ * emitter.emit('onToken', { streamKey: 'abc', token: 'hello' })
34
+ * unsub()
35
+ */
36
+ export function createEventEmitter(): ChatEventEmitter {
37
+ const listeners = new Map<string, Set<Listener<any>>>()
38
+
39
+ interface ThrottledFn<F> {
40
+ fn: F
41
+ cancel: () => void
42
+ }
43
+
44
+ function createThrottled<F extends (...args: any[]) => void>(fn: F, ms: number): ThrottledFn<F> {
45
+ let lastCall = 0
46
+ let timer: ReturnType<typeof setTimeout> | null = null
47
+ let lastArgs: any[] | null = null
48
+ let cancelled = false
49
+
50
+ const throttled = ((...args: any[]) => {
51
+ if (cancelled) return
52
+ lastArgs = args
53
+ const now = Date.now()
54
+ const remaining = ms - (now - lastCall)
55
+
56
+ if (remaining <= 0) {
57
+ if (timer) { clearTimeout(timer); timer = null }
58
+ lastCall = now
59
+ fn(...args)
60
+ } else if (!timer) {
61
+ timer = setTimeout(() => {
62
+ lastCall = Date.now()
63
+ timer = null
64
+ if (lastArgs && !cancelled) {
65
+ try { fn(...lastArgs) } catch (err) { console.error('[ChatBus] Error in throttled handler:', err) }
66
+ }
67
+ }, remaining)
68
+ }
69
+ }) as F
70
+
71
+ return {
72
+ fn: throttled,
73
+ cancel: () => { cancelled = true; if (timer) { clearTimeout(timer); timer = null } },
74
+ }
75
+ }
76
+
77
+ return {
78
+ on(event, handler, options) {
79
+ if (!listeners.has(event as string)) {
80
+ listeners.set(event as string, new Set())
81
+ }
82
+
83
+ const listener: Listener<typeof handler> = { handler, options }
84
+
85
+ // Apply throttle if requested
86
+ let throttleHandle: ThrottledFn<typeof handler> | null = null
87
+ if (options?.throttle && options.throttle > 0) {
88
+ throttleHandle = createThrottled(handler, options.throttle)
89
+ listener.throttledHandler = throttleHandle.fn
90
+ }
91
+
92
+ listeners.get(event as string)!.add(listener)
93
+
94
+ // Return unsubscribe function — cancels pending throttle timers
95
+ return () => {
96
+ throttleHandle?.cancel()
97
+ listeners.get(event as string)?.delete(listener)
98
+ }
99
+ },
100
+
101
+ emit(event, ...args) {
102
+ const set = listeners.get(event as string)
103
+ if (!set) return
104
+
105
+ for (const listener of set) {
106
+ // StreamKey filtering: skip if listener wants a specific streamKey
107
+ // For most events args[0] has streamKey; for onCustomEvent args[1] has it
108
+ if (listener.options?.streamKey) {
109
+ let streamKeyArg: unknown
110
+ for (const arg of args) {
111
+ if (arg && typeof arg === 'object' && 'streamKey' in (arg as any)) {
112
+ streamKeyArg = (arg as any).streamKey
113
+ break
114
+ }
115
+ }
116
+ if (streamKeyArg !== undefined && streamKeyArg !== listener.options.streamKey) continue
117
+ }
118
+
119
+ const fn = listener.throttledHandler || listener.handler
120
+ try {
121
+ fn(...args)
122
+ } catch (err) {
123
+ console.error(`[ChatBus] Error in ${event as string} handler:`, err)
124
+ }
125
+ }
126
+ },
127
+
128
+ clear() {
129
+ listeners.clear()
130
+ },
131
+ } as ChatEventEmitter
132
+ }
133
+
134
+ // ─── Command Handler ─────────────────────────────────────────
135
+
136
+ /**
137
+ * Create a typed command handler. The host app registers handlers,
138
+ * agents execute commands.
139
+ *
140
+ * @experimental
141
+ *
142
+ * @example
143
+ * const commands = createCommandHandler<ChatCommands>()
144
+ * commands.handle('injectPrompt', (text) => setInputValue(text))
145
+ * commands.exec('injectPrompt', 'Hello world')
146
+ */
147
+ export function createCommandHandler(): ChatCommandHandler {
148
+ const handlers = new Map<string, (...args: any[]) => any>()
149
+
150
+ return {
151
+ handle(command, handler) {
152
+ handlers.set(command as string, handler)
153
+ },
154
+
155
+ exec(command, ...args) {
156
+ const handler = handlers.get(command as string)
157
+ if (!handler) {
158
+ console.warn(`[ChatBus] No handler registered for command: ${command as string}`)
159
+ return undefined as any
160
+ }
161
+ return handler(...args)
162
+ },
163
+ } as ChatCommandHandler
164
+ }
165
+
166
+ // ─── Chat Bus Factory ────────────────────────────────────────
167
+
168
+ /**
169
+ * Create a complete ChatBus with events + commands.
170
+ *
171
+ * @experimental
172
+ *
173
+ * @example
174
+ * const bus = createChatBus()
175
+ * bus.events.on('onStreamEnd', (event) => { ... })
176
+ * bus.commands.handle('sendPrompt', (text) => { ... })
177
+ */
178
+ export function createChatBus(): ChatBus {
179
+ return {
180
+ events: createEventEmitter(),
181
+ commands: createCommandHandler(),
182
+ }
183
+ }
@@ -15,3 +15,5 @@ export {
15
15
  } from './validation'
16
16
 
17
17
  export { ComponentRegistry } from './component-registry'
18
+
19
+ export { createEventEmitter, createCommandHandler, createChatBus } from './chat-bus'