@antelopejs/dms-ai 0.1.2 → 0.1.3

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 (144) hide show
  1. package/README.md +49 -0
  2. package/dist/channels/bridge.d.ts +22 -0
  3. package/dist/channels/bridge.js +87 -0
  4. package/dist/channels/bridge.js.map +1 -0
  5. package/dist/channels/channel-service.d.ts +5 -0
  6. package/dist/channels/channel-service.js +68 -0
  7. package/dist/channels/channel-service.js.map +1 -0
  8. package/dist/channels/message-budget.d.ts +3 -0
  9. package/dist/channels/message-budget.js +29 -0
  10. package/dist/channels/message-budget.js.map +1 -0
  11. package/dist/channels/registry.d.ts +7 -0
  12. package/dist/channels/registry.js +47 -0
  13. package/dist/channels/registry.js.map +1 -0
  14. package/dist/channels/sse-stream.d.ts +19 -0
  15. package/dist/channels/sse-stream.js +59 -0
  16. package/dist/channels/sse-stream.js.map +1 -0
  17. package/dist/chatbox/passthrough.d.ts +3 -0
  18. package/dist/chatbox/passthrough.js +53 -0
  19. package/dist/chatbox/passthrough.js.map +1 -0
  20. package/dist/constants/channels.d.ts +30 -0
  21. package/dist/constants/channels.js +38 -0
  22. package/dist/constants/channels.js.map +1 -0
  23. package/dist/constants/chatbox.d.ts +10 -0
  24. package/dist/constants/chatbox.js +35 -0
  25. package/dist/constants/chatbox.js.map +1 -0
  26. package/dist/constants/sidecar.d.ts +4 -0
  27. package/dist/constants/sidecar.js +3 -1
  28. package/dist/constants/sidecar.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +5 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lifecycle/sidecar-socket.d.ts +2 -0
  33. package/dist/lifecycle/sidecar-socket.js +37 -0
  34. package/dist/lifecycle/sidecar-socket.js.map +1 -0
  35. package/dist/lifecycle/sidecar-unavailable-error.d.ts +3 -0
  36. package/dist/lifecycle/sidecar-unavailable-error.js +10 -0
  37. package/dist/lifecycle/sidecar-unavailable-error.js.map +1 -0
  38. package/dist/routes/channels.d.ts +8 -0
  39. package/dist/routes/channels.js +52 -0
  40. package/dist/routes/channels.js.map +1 -0
  41. package/dist/routes/chatbox.d.ts +7 -0
  42. package/dist/routes/chatbox.js +47 -0
  43. package/dist/routes/chatbox.js.map +1 -0
  44. package/dist/routes/index.d.ts +2 -0
  45. package/dist/routes/index.js +2 -0
  46. package/dist/routes/index.js.map +1 -1
  47. package/dist/routes/sidecar-info.js +0 -2
  48. package/dist/routes/sidecar-info.js.map +1 -1
  49. package/frontend-vue/app/components/SettingsView.vue +8 -0
  50. package/frontend-vue/app/plugins/ai.client.ts +137 -58
  51. package/frontend-vue/app/runtime/channel-client.ts +265 -0
  52. package/frontend-vue/app/runtime/chat-transport.ts +82 -0
  53. package/frontend-vue/app/runtime/constants.ts +30 -14
  54. package/frontend-vue/app/runtime/current-page.ts +9 -5
  55. package/frontend-vue/app/runtime/event-stream.ts +75 -0
  56. package/frontend-vue/app/runtime/overlay.ts +23 -6
  57. package/frontend-vue/app/runtime/sidecar-status.ts +10 -25
  58. package/frontend-vue/app/runtime/theme-bridge.ts +16 -4
  59. package/frontend-vue/package.json +3 -0
  60. package/frontend-vue/tests/backoff.test.ts +15 -15
  61. package/frontend-vue/tests/channel-client.test.ts +247 -0
  62. package/frontend-vue/tests/chat-transport.test.ts +67 -0
  63. package/frontend-vue/tests/sidecar-status.test.ts +39 -0
  64. package/package.json +5 -3
  65. package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +1 -0
  66. package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +52 -0
  67. package/sidecar/chatbox/dist/index.html +2 -2
  68. package/sidecar/dist/agent/disposals.d.ts +5 -0
  69. package/sidecar/dist/agent/disposals.js +17 -0
  70. package/sidecar/dist/agent/disposals.js.map +1 -0
  71. package/sidecar/dist/agent/effective-mode.d.ts +2 -0
  72. package/sidecar/dist/agent/effective-mode.js +20 -0
  73. package/sidecar/dist/agent/effective-mode.js.map +1 -0
  74. package/sidecar/dist/agent/provider.d.ts +2 -2
  75. package/sidecar/dist/agent/runner-events.d.ts +9 -1
  76. package/sidecar/dist/agent/runner.d.ts +2 -2
  77. package/sidecar/dist/agent/runner.js +11 -5
  78. package/sidecar/dist/agent/runner.js.map +1 -1
  79. package/sidecar/dist/agent/session.d.ts +3 -3
  80. package/sidecar/dist/agent/session.js +54 -34
  81. package/sidecar/dist/agent/session.js.map +1 -1
  82. package/sidecar/dist/agent/switching-runner.js +16 -14
  83. package/sidecar/dist/agent/switching-runner.js.map +1 -1
  84. package/sidecar/dist/agent/turn-end-reasons.d.ts +3 -0
  85. package/sidecar/dist/agent/turn-end-reasons.js +24 -0
  86. package/sidecar/dist/agent/turn-end-reasons.js.map +1 -0
  87. package/sidecar/dist/constants/agent.d.ts +6 -1
  88. package/sidecar/dist/constants/agent.js +13 -1
  89. package/sidecar/dist/constants/agent.js.map +1 -1
  90. package/sidecar/dist/constants/claude.d.ts +27 -1
  91. package/sidecar/dist/constants/claude.js +49 -0
  92. package/sidecar/dist/constants/claude.js.map +1 -1
  93. package/sidecar/dist/constants/codex.d.ts +6 -0
  94. package/sidecar/dist/constants/codex.js +13 -0
  95. package/sidecar/dist/constants/codex.js.map +1 -1
  96. package/sidecar/dist/constants/settings.js.map +1 -1
  97. package/sidecar/dist/constants/timing.d.ts +1 -0
  98. package/sidecar/dist/constants/timing.js +1 -0
  99. package/sidecar/dist/constants/timing.js.map +1 -1
  100. package/sidecar/dist/protocol/events.d.ts +36 -0
  101. package/sidecar/dist/protocol/events.js +13 -0
  102. package/sidecar/dist/protocol/events.js.map +1 -1
  103. package/sidecar/dist/providers/claude/activity.d.ts +5 -0
  104. package/sidecar/dist/providers/claude/activity.js +63 -0
  105. package/sidecar/dist/providers/claude/activity.js.map +1 -0
  106. package/sidecar/dist/providers/claude/adapter.js +21 -13
  107. package/sidecar/dist/providers/claude/adapter.js.map +1 -1
  108. package/sidecar/dist/providers/claude/config.d.ts +2 -1
  109. package/sidecar/dist/providers/claude/config.js +4 -0
  110. package/sidecar/dist/providers/claude/config.js.map +1 -1
  111. package/sidecar/dist/providers/claude/provider.js +18 -25
  112. package/sidecar/dist/providers/claude/provider.js.map +1 -1
  113. package/sidecar/dist/providers/claude/result.d.ts +3 -0
  114. package/sidecar/dist/providers/claude/result.js +61 -0
  115. package/sidecar/dist/providers/claude/result.js.map +1 -0
  116. package/sidecar/dist/providers/claude/safe-mode.d.ts +4 -0
  117. package/sidecar/dist/providers/claude/safe-mode.js +24 -0
  118. package/sidecar/dist/providers/claude/safe-mode.js.map +1 -0
  119. package/sidecar/dist/providers/codex/adapter.js +8 -2
  120. package/sidecar/dist/providers/codex/adapter.js.map +1 -1
  121. package/sidecar/dist/providers/codex/client.d.ts +1 -0
  122. package/sidecar/dist/providers/codex/client.js +1 -0
  123. package/sidecar/dist/providers/codex/client.js.map +1 -1
  124. package/sidecar/dist/providers/codex/config.js +2 -1
  125. package/sidecar/dist/providers/codex/config.js.map +1 -1
  126. package/sidecar/dist/providers/codex/process.js +113 -33
  127. package/sidecar/dist/providers/codex/process.js.map +1 -1
  128. package/sidecar/dist/providers/codex/provider.js +6 -4
  129. package/sidecar/dist/providers/codex/provider.js.map +1 -1
  130. package/sidecar/dist/server/routing.js +39 -21
  131. package/sidecar/dist/server/routing.js.map +1 -1
  132. package/sidecar/dist/server/turn-progress.d.ts +13 -0
  133. package/sidecar/dist/server/turn-progress.js +72 -0
  134. package/sidecar/dist/server/turn-progress.js.map +1 -0
  135. package/sidecar/dist/server/ws.js +4 -2
  136. package/sidecar/dist/server/ws.js.map +1 -1
  137. package/sidecar/dist/state/types.d.ts +2 -1
  138. package/sidecar/dist/state/types.js +1 -0
  139. package/sidecar/dist/state/types.js.map +1 -1
  140. package/sidecar/package.json +1 -1
  141. package/frontend-vue/app/runtime/ws-client.ts +0 -196
  142. package/frontend-vue/tests/sidecar-auth.test.ts +0 -87
  143. package/sidecar/chatbox/dist/assets/index-CEZ1NLp9.js +0 -52
  144. package/sidecar/chatbox/dist/assets/index-D_51Vqqk.css +0 -1
@@ -1,7 +1,8 @@
1
1
  // Theme bridge (host → chatbox iframe). The host DMS is the source of truth for
2
2
  // the active Nuxt UI primary palette and the light/dark mode; the chatbox iframe
3
- // is a separate document (different origin) so it cannot inherit them through
4
- // CSS — we forward them explicitly here.
3
+ // is a separate document, so it cannot inherit them through CSS — we forward
4
+ // them explicitly here. Both documents share the dashboard's origin, which is
5
+ // the only one either side posts to or accepts messages from.
5
6
  //
6
7
  // Protocol:
7
8
  // iframe → host : { type: "dms-ai:ready" } (chatbox announces mount)
@@ -113,6 +114,17 @@ interface MessageData {
113
114
 
114
115
  type ThemePush = (force: boolean) => void
115
116
 
117
+ /** True for a message the chatbox frame itself posted, from our own origin. */
118
+ export function isFromFrame(
119
+ event: MessageEvent,
120
+ iframe: HTMLIFrameElement,
121
+ ): boolean {
122
+ return (
123
+ event.source === iframe.contentWindow &&
124
+ event.origin === globalThis.location.origin
125
+ )
126
+ }
127
+
116
128
  // Only answer the handshake from our own iframe, never an arbitrary frame.
117
129
  function answerReadyHandshake(
118
130
  iframe: HTMLIFrameElement,
@@ -120,7 +132,7 @@ function answerReadyHandshake(
120
132
  ): void {
121
133
  globalThis.addEventListener('message', (event: MessageEvent) => {
122
134
  const type = (event.data as MessageData)?.type
123
- if (type === READY_MESSAGE_TYPE && event.source === iframe.contentWindow) {
135
+ if (type === READY_MESSAGE_TYPE && isFromFrame(event, iframe)) {
124
136
  post(true)
125
137
  }
126
138
  })
@@ -165,7 +177,7 @@ export function installThemeBridge(
165
177
  onTheme?.(theme)
166
178
  iframe.contentWindow?.postMessage(
167
179
  { type: THEME_MESSAGE_TYPE, ...theme },
168
- '*',
180
+ globalThis.location.origin,
169
181
  )
170
182
  }
171
183
 
@@ -5,6 +5,9 @@
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "packageManager": "pnpm@10.6.5",
8
+ "engines": {
9
+ "@antelopejs/dms-frontend": ">=0.2.8 <0.4.0"
10
+ },
8
11
  "scripts": {
9
12
  "build": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" build",
10
13
  "typecheck": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" typecheck",
@@ -4,41 +4,41 @@ import {
4
4
  nextDelay,
5
5
  resetBackoff,
6
6
  } from '../app/runtime/backoff'
7
- import { WS_RECONNECT_DELAYS_MS } from '../app/runtime/constants'
7
+ import { CHANNEL_RECONNECT_DELAYS_MS } from '../app/runtime/constants'
8
8
 
9
9
  describe('backoff helper', () => {
10
10
  it('returns each schedule entry in order then caps at the last value', () => {
11
11
  const state = createBackoff()
12
12
  const observed: number[] = []
13
- for (let i = 0; i < WS_RECONNECT_DELAYS_MS.length + 3; i += 1) {
14
- observed.push(nextDelay(state, WS_RECONNECT_DELAYS_MS))
13
+ for (let i = 0; i < CHANNEL_RECONNECT_DELAYS_MS.length + 3; i += 1) {
14
+ observed.push(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS))
15
15
  }
16
16
  const expected = [
17
- ...WS_RECONNECT_DELAYS_MS,
18
- WS_RECONNECT_DELAYS_MS[WS_RECONNECT_DELAYS_MS.length - 1],
19
- WS_RECONNECT_DELAYS_MS[WS_RECONNECT_DELAYS_MS.length - 1],
20
- WS_RECONNECT_DELAYS_MS[WS_RECONNECT_DELAYS_MS.length - 1],
17
+ ...CHANNEL_RECONNECT_DELAYS_MS,
18
+ CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
19
+ CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
20
+ CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1],
21
21
  ]
22
22
  expect(observed).toEqual(expected)
23
23
  })
24
24
 
25
25
  it('resetBackoff returns the schedule to schedule[0]', () => {
26
26
  const state = createBackoff()
27
- nextDelay(state, WS_RECONNECT_DELAYS_MS)
28
- nextDelay(state, WS_RECONNECT_DELAYS_MS)
27
+ nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
28
+ nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
29
29
  resetBackoff(state)
30
- expect(nextDelay(state, WS_RECONNECT_DELAYS_MS)).toBe(
31
- WS_RECONNECT_DELAYS_MS[0],
30
+ expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(
31
+ CHANNEL_RECONNECT_DELAYS_MS[0],
32
32
  )
33
33
  })
34
34
 
35
35
  it('uses the configured 30s ceiling on the last delay', () => {
36
36
  const state = createBackoff()
37
- const lastIndex = WS_RECONNECT_DELAYS_MS.length - 1
37
+ const lastIndex = CHANNEL_RECONNECT_DELAYS_MS.length - 1
38
38
  for (let i = 0; i < lastIndex; i += 1) {
39
- nextDelay(state, WS_RECONNECT_DELAYS_MS)
39
+ nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)
40
40
  }
41
- expect(nextDelay(state, WS_RECONNECT_DELAYS_MS)).toBe(30_000)
42
- expect(nextDelay(state, WS_RECONNECT_DELAYS_MS)).toBe(30_000)
41
+ expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(30_000)
42
+ expect(nextDelay(state, CHANNEL_RECONNECT_DELAYS_MS)).toBe(30_000)
43
43
  })
44
44
  })
@@ -0,0 +1,247 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ type ChannelStatus,
4
+ createChannelClient,
5
+ } from '../app/runtime/channel-client'
6
+ import { CHANNEL_RECONNECT_DELAYS_MS } from '../app/runtime/constants'
7
+
8
+ interface FakeStream {
9
+ path: string
10
+ emit: (name: string, data: string) => void
11
+ end: () => void
12
+ }
13
+
14
+ interface Harness {
15
+ streams: FakeStream[]
16
+ posts: Array<{ path: string; body: unknown }>
17
+ statuses: ChannelStatus[]
18
+ ready: ReturnType<typeof vi.fn>
19
+ frames: string[]
20
+ }
21
+
22
+ const encoder = new TextEncoder()
23
+ const READY = '{"connections":{"host":"h-1","chat":"c 1"}}'
24
+
25
+ function openFakeStream(
26
+ streams: FakeStream[],
27
+ path: string,
28
+ signal: AbortSignal,
29
+ ): Promise<Response> {
30
+ let controller!: ReadableStreamDefaultController<Uint8Array>
31
+ const body = new ReadableStream<Uint8Array>({
32
+ start: (c) => {
33
+ controller = c
34
+ },
35
+ })
36
+ signal.addEventListener('abort', () =>
37
+ controller.error(new DOMException('aborted', 'AbortError')),
38
+ )
39
+ streams.push({
40
+ path,
41
+ emit: (name, data) =>
42
+ controller.enqueue(encoder.encode(`event: ${name}\ndata: ${data}\n\n`)),
43
+ end: () => controller.close(),
44
+ })
45
+ return Promise.resolve(new Response(body, { status: 200 }))
46
+ }
47
+
48
+ function startClient(post = vi.fn(async () => undefined)) {
49
+ const harness: Harness = {
50
+ streams: [],
51
+ posts: [],
52
+ statuses: [],
53
+ ready: vi.fn(),
54
+ frames: [],
55
+ }
56
+ const client = createChannelClient({
57
+ channels: ['host', 'chat'],
58
+ post: async (path, body) => {
59
+ harness.posts.push({ path, body })
60
+ return post(path, body)
61
+ },
62
+ openStream: (path, signal) => openFakeStream(harness.streams, path, signal),
63
+ onReady: harness.ready,
64
+ onFrame: (channel, raw) => harness.frames.push(`${channel} ${raw}`),
65
+ onStatusChange: (status) => harness.statuses.push(status),
66
+ })
67
+ client.start()
68
+ return { client, harness }
69
+ }
70
+
71
+ async function flush(): Promise<void> {
72
+ for (let i = 0; i < 10; i += 1) await Promise.resolve()
73
+ await new Promise((resolve) => setTimeout(resolve, 0))
74
+ }
75
+
76
+ afterEach(() => {
77
+ vi.useRealTimers()
78
+ })
79
+
80
+ describe('channel client', () => {
81
+ it('stays closed until started', async () => {
82
+ const streams: FakeStream[] = []
83
+ const client = createChannelClient({
84
+ channels: ['host', 'chat'],
85
+ post: vi.fn(),
86
+ openStream: (path, signal) => openFakeStream(streams, path, signal),
87
+ })
88
+ await flush()
89
+ expect(streams).toEqual([])
90
+ expect(client.getStatus()).toBe('disconnected')
91
+ })
92
+
93
+ it('opens one stream for every channel, on the dashboard origin, with no credential', async () => {
94
+ const { client, harness } = startClient()
95
+ await flush()
96
+ expect(harness.streams.map((stream) => stream.path)).toEqual([
97
+ '/ai/channels/host,chat/events',
98
+ ])
99
+ client.stop()
100
+ })
101
+
102
+ it('announces the connection, then routes each frame by channel, raw', async () => {
103
+ const { client, harness } = startClient()
104
+ await flush()
105
+ harness.streams[0]?.emit('ready', READY)
106
+ harness.streams[0]?.emit('host', '{"type":"host_command_navigate","path":"/x"}')
107
+ harness.streams[0]?.emit('chat', '{"type":"run_done"}')
108
+ harness.streams[0]?.emit('unknown', '{}')
109
+ await flush()
110
+ expect(harness.ready).toHaveBeenCalledTimes(1)
111
+ expect(client.isConnected()).toBe(true)
112
+ expect(harness.frames).toEqual([
113
+ 'host {"type":"host_command_navigate","path":"/x"}',
114
+ 'chat {"type":"run_done"}',
115
+ ])
116
+ client.stop()
117
+ })
118
+
119
+ it('posts one message at a time, in order, to the connection of its channel', async () => {
120
+ const pending: Array<() => void> = []
121
+ const post = vi.fn(
122
+ () => new Promise<void>((resolve) => pending.push(() => resolve())),
123
+ )
124
+ const { client, harness } = startClient(post)
125
+ await flush()
126
+ harness.streams[0]?.emit('ready', READY)
127
+ await flush()
128
+ client.send('chat', { n: 1 })
129
+ client.send('host', { n: 2 })
130
+ await flush()
131
+ expect(harness.posts).toEqual([
132
+ { path: '/ai/channels/c%201/messages', body: { n: 1 } },
133
+ ])
134
+ pending.shift()?.()
135
+ await flush()
136
+ expect(harness.posts.at(-1)).toEqual({
137
+ path: '/ai/channels/h-1/messages',
138
+ body: { n: 2 },
139
+ })
140
+ client.stop()
141
+ })
142
+
143
+ it('drops a message sent before the connection is ready, and says so', async () => {
144
+ const { client, harness } = startClient()
145
+ expect(client.send('chat', { n: 1 })).toBe(false)
146
+ await flush()
147
+ expect(harness.posts).toEqual([])
148
+ harness.streams[0]?.emit('ready', READY)
149
+ await flush()
150
+ expect(client.send('chat', { n: 2 })).toBe(true)
151
+ client.stop()
152
+ })
153
+
154
+ it('refuses a message for a channel the stream does not carry', async () => {
155
+ const { client, harness } = startClient()
156
+ await flush()
157
+ harness.streams[0]?.emit('ready', READY)
158
+ await flush()
159
+ expect(client.send('unknown', { n: 1 })).toBe(false)
160
+ await flush()
161
+ expect(harness.posts).toEqual([])
162
+ client.stop()
163
+ })
164
+
165
+ it('reconnects with backoff when the stream ends, and announces the new connection', async () => {
166
+ vi.useFakeTimers()
167
+ const { client, harness } = startClient()
168
+ await vi.advanceTimersByTimeAsync(0)
169
+ harness.streams[0]?.emit('ready', READY)
170
+ await vi.advanceTimersByTimeAsync(0)
171
+ harness.streams[0]?.end()
172
+ await vi.advanceTimersByTimeAsync(0)
173
+ expect(client.isConnected()).toBe(false)
174
+ expect(harness.statuses).toEqual(['connecting', 'connected', 'reconnecting'])
175
+ await vi.advanceTimersByTimeAsync(CHANNEL_RECONNECT_DELAYS_MS[0])
176
+ harness.streams[1]?.emit('ready', READY)
177
+ await vi.advanceTimersByTimeAsync(0)
178
+ expect(harness.streams).toHaveLength(2)
179
+ expect(harness.ready).toHaveBeenCalledTimes(2)
180
+ expect(harness.statuses.at(-1)).toBe('connected')
181
+ client.stop()
182
+ })
183
+
184
+ it('drops the connection and reconnects when a post fails', async () => {
185
+ vi.useFakeTimers()
186
+ const post = vi.fn(async () => {
187
+ throw new Error('404')
188
+ })
189
+ const { client, harness } = startClient(post)
190
+ await vi.advanceTimersByTimeAsync(0)
191
+ harness.streams[0]?.emit('ready', READY)
192
+ await vi.advanceTimersByTimeAsync(0)
193
+ client.send('chat', { n: 1 })
194
+ await vi.advanceTimersByTimeAsync(0)
195
+ expect(client.getStatus()).toBe('reconnecting')
196
+ await vi.advanceTimersByTimeAsync(CHANNEL_RECONNECT_DELAYS_MS[0])
197
+ expect(harness.streams).toHaveLength(2)
198
+ client.stop()
199
+ })
200
+
201
+ it('says connecting while reconnectNow replaces a live stream, until the new one is ready', async () => {
202
+ const { client, harness } = startClient()
203
+ await flush()
204
+ harness.streams[0]?.emit('ready', READY)
205
+ await flush()
206
+ client.reconnectNow()
207
+ await flush()
208
+ expect(client.getStatus()).toBe('connecting')
209
+ expect(client.send('chat', { n: 1 })).toBe(false)
210
+ harness.streams[1]?.emit('ready', READY)
211
+ await flush()
212
+ expect(harness.statuses).toEqual(['connecting', 'connected', 'connecting', 'connected'])
213
+ expect(harness.ready).toHaveBeenCalledTimes(2)
214
+ client.stop()
215
+ })
216
+
217
+ it('skips the backoff on reconnectNow', async () => {
218
+ vi.useFakeTimers()
219
+ const { client, harness } = startClient()
220
+ await vi.advanceTimersByTimeAsync(0)
221
+ harness.streams[0]?.end()
222
+ await vi.advanceTimersByTimeAsync(0)
223
+ client.reconnectNow()
224
+ await vi.advanceTimersByTimeAsync(0)
225
+ expect(harness.streams).toHaveLength(2)
226
+ client.stop()
227
+ })
228
+
229
+ it('stops reconnecting once stopped, and opens again on start', async () => {
230
+ vi.useFakeTimers()
231
+ const { client, harness } = startClient()
232
+ await vi.advanceTimersByTimeAsync(0)
233
+ client.stop()
234
+ await vi.advanceTimersByTimeAsync(
235
+ CHANNEL_RECONNECT_DELAYS_MS[CHANNEL_RECONNECT_DELAYS_MS.length - 1] * 2,
236
+ )
237
+ expect(harness.streams).toHaveLength(1)
238
+ expect(client.getStatus()).toBe('disconnected')
239
+ client.reconnectNow()
240
+ await vi.advanceTimersByTimeAsync(0)
241
+ expect(harness.streams).toHaveLength(1)
242
+ client.start()
243
+ await vi.advanceTimersByTimeAsync(0)
244
+ expect(harness.streams).toHaveLength(2)
245
+ client.stop()
246
+ })
247
+ })
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createChatTransport,
4
+ exposeChatTransport,
5
+ } from '../app/runtime/chat-transport'
6
+ import { CHAT_TRANSPORT_KEY } from '../app/runtime/constants'
7
+
8
+ function hub() {
9
+ const actions = {
10
+ send: vi.fn(() => true),
11
+ reconnect: vi.fn(),
12
+ getStatus: vi.fn(() => 'connected' as const),
13
+ }
14
+ return { actions, chat: createChatTransport(actions) }
15
+ }
16
+
17
+ describe('chat transport', () => {
18
+ it('lends the chat its channel: frames, readiness and status in, messages out', () => {
19
+ const { actions, chat } = hub()
20
+ const frames: string[] = []
21
+ const statuses: string[] = []
22
+ const ready = vi.fn()
23
+ chat.transport.onFrame((raw) => frames.push(raw))
24
+ chat.transport.onStatus((status) => statuses.push(status))
25
+ chat.transport.onReady(ready)
26
+ chat.deliverFrame('{"type":"run_done"}')
27
+ chat.announceStatus('reconnecting')
28
+ chat.announceReady()
29
+ expect(chat.transport.send({ type: 'hello' })).toBe(true)
30
+ chat.transport.reconnect()
31
+ expect(frames).toEqual(['{"type":"run_done"}'])
32
+ expect(statuses).toEqual(['reconnecting'])
33
+ expect(ready).toHaveBeenCalledTimes(1)
34
+ expect(actions.send).toHaveBeenCalledWith({ type: 'hello' })
35
+ expect(actions.reconnect).toHaveBeenCalledTimes(1)
36
+ expect(chat.transport.getStatus()).toBe('connected')
37
+ })
38
+
39
+ it('stops delivering to a listener once it unsubscribed', () => {
40
+ const { chat } = hub()
41
+ const frames: string[] = []
42
+ const unsubscribe = chat.transport.onFrame((raw) => frames.push(raw))
43
+ unsubscribe()
44
+ chat.deliverFrame('{}')
45
+ expect(frames).toEqual([])
46
+ })
47
+
48
+ it('keeps delivering when one listener throws, as a gone chat document would', () => {
49
+ const { chat } = hub()
50
+ const frames: string[] = []
51
+ const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
52
+ chat.transport.onFrame(() => {
53
+ throw new Error('dead document')
54
+ })
55
+ chat.transport.onFrame((raw) => frames.push(raw))
56
+ chat.deliverFrame('{}')
57
+ expect(frames).toEqual(['{}'])
58
+ error.mockRestore()
59
+ })
60
+
61
+ it('publishes itself where the same-origin chat document looks', () => {
62
+ const { chat } = hub()
63
+ exposeChatTransport(chat.transport)
64
+ expect(Reflect.get(globalThis, CHAT_TRANSPORT_KEY)).toBe(chat.transport)
65
+ Reflect.deleteProperty(globalThis, CHAT_TRANSPORT_KEY)
66
+ })
67
+ })
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSidecarStatusController } from '../app/runtime/sidecar-status'
3
+
4
+ describe('sidecar status', () => {
5
+ it('reports connected from the status alone, and keeps no port or credential', async () => {
6
+ const controller = createSidecarStatusController(async () => ({
7
+ isRunning: true,
8
+ }))
9
+ expect(await controller.init()).toBe(true)
10
+ expect(controller.getStatus()).toBe('connected')
11
+ expect(Object.keys(controller)).not.toContain('getPort')
12
+ expect(Object.keys(controller)).not.toContain('getClientToken')
13
+ controller.dispose()
14
+ })
15
+
16
+ it('re-probes when the channel drops, and reports connected once the sidecar answers', async () => {
17
+ const fetchInfo = vi
18
+ .fn()
19
+ .mockResolvedValueOnce({ isRunning: true })
20
+ .mockResolvedValueOnce({ isRunning: true })
21
+ const statuses: string[] = []
22
+ const controller = createSidecarStatusController(fetchInfo)
23
+ await controller.init()
24
+ controller.subscribe((status) => statuses.push(status))
25
+ controller.reportChannelDown()
26
+ await vi.waitFor(() => expect(statuses.at(-1)).toBe('connected'))
27
+ expect(statuses).toEqual(['connected', 'reviving', 'connected'])
28
+ expect(fetchInfo).toHaveBeenCalledTimes(2)
29
+ controller.dispose()
30
+ })
31
+
32
+ it('does not show the assistant when spawning is disabled', async () => {
33
+ const controller = createSidecarStatusController(async () => ({
34
+ disabled: true,
35
+ }))
36
+ expect(await controller.init()).toBe(false)
37
+ controller.dispose()
38
+ })
39
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-ai",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,12 +34,13 @@
34
34
  "prepack": "pnpm run build && pnpm run build:sidecar",
35
35
  "frontend:dev": "cd playground && pnpm frontend:dev",
36
36
  "dev": "cd playground && pnpm dev",
37
- "lint": "oxlint --disable-nested-config && oxfmt --disable-nested-config --check . && cd frontend-vue && pnpm lint && cd ../sidecar && pnpm lint",
37
+ "lint": "oxlint --disable-nested-config && oxfmt --disable-nested-config --check . && cd frontend-vue && pnpm lint && cd ../sidecar && pnpm lint && antelopejs-check-interface-ranges",
38
38
  "lint:fix": "oxlint --disable-nested-config --fix --max-warnings=1000000 && oxfmt --disable-nested-config . && cd frontend-vue && pnpm lint:fix && cd ../sidecar && pnpm lint:fix",
39
39
  "typecheck": "tsc --noEmit && cd frontend-vue && pnpm typecheck && cd ../sidecar && pnpm typecheck",
40
40
  "test": "cd sidecar && pnpm test && cd ../frontend-vue && pnpm test",
41
41
  "test:sidecar-smoke": "pnpm run build:sidecar && node --test tests/sidecar-build.test.cjs",
42
42
  "test:frontend-registration": "pnpm run build && node --test tests/frontend-registration.test.cjs",
43
+ "test:channels": "pnpm run build && node --test tests/channels.test.cjs tests/channel-limits.test.cjs",
43
44
  "test:spawn-args": "node --test tests/spawn-args.test.cjs",
44
45
  "test:release-script": "node --test tests/release-script.test.cjs",
45
46
  "test:e2e": "cd sidecar && pnpm test:e2e",
@@ -59,8 +60,9 @@
59
60
  "zod": "^4.0.0"
60
61
  },
61
62
  "devDependencies": {
62
- "@antelopejs/tooling-configs": ">=0.0.6 <1.0.0",
63
+ "@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
63
64
  "@types/node": "^24.0.0",
65
+ "@types/ws": "^8.5.12",
64
66
  "knip": "^6.34.0",
65
67
  "oxfmt": "^0.66.0",
66
68
  "oxlint": "1.81.0",