@antelopejs/dms-ai 0.1.2 → 0.1.4

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 (145) 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/pages/settings/page.js.map +1 -1
  39. package/dist/routes/channels.d.ts +8 -0
  40. package/dist/routes/channels.js +52 -0
  41. package/dist/routes/channels.js.map +1 -0
  42. package/dist/routes/chatbox.d.ts +7 -0
  43. package/dist/routes/chatbox.js +47 -0
  44. package/dist/routes/chatbox.js.map +1 -0
  45. package/dist/routes/index.d.ts +2 -0
  46. package/dist/routes/index.js +2 -0
  47. package/dist/routes/index.js.map +1 -1
  48. package/dist/routes/sidecar-info.js +0 -2
  49. package/dist/routes/sidecar-info.js.map +1 -1
  50. package/frontend-vue/app/components/SettingsView.vue +19 -22
  51. package/frontend-vue/app/plugins/ai.client.ts +137 -58
  52. package/frontend-vue/app/runtime/channel-client.ts +265 -0
  53. package/frontend-vue/app/runtime/chat-transport.ts +82 -0
  54. package/frontend-vue/app/runtime/constants.ts +31 -15
  55. package/frontend-vue/app/runtime/current-page.ts +9 -5
  56. package/frontend-vue/app/runtime/event-stream.ts +75 -0
  57. package/frontend-vue/app/runtime/overlay.ts +23 -6
  58. package/frontend-vue/app/runtime/sidecar-status.ts +10 -25
  59. package/frontend-vue/app/runtime/theme-bridge.ts +16 -4
  60. package/frontend-vue/package.json +3 -0
  61. package/frontend-vue/tests/backoff.test.ts +15 -15
  62. package/frontend-vue/tests/channel-client.test.ts +247 -0
  63. package/frontend-vue/tests/chat-transport.test.ts +67 -0
  64. package/frontend-vue/tests/sidecar-status.test.ts +39 -0
  65. package/package.json +5 -3
  66. package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +1 -0
  67. package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +52 -0
  68. package/sidecar/chatbox/dist/index.html +2 -2
  69. package/sidecar/dist/agent/disposals.d.ts +5 -0
  70. package/sidecar/dist/agent/disposals.js +17 -0
  71. package/sidecar/dist/agent/disposals.js.map +1 -0
  72. package/sidecar/dist/agent/effective-mode.d.ts +2 -0
  73. package/sidecar/dist/agent/effective-mode.js +20 -0
  74. package/sidecar/dist/agent/effective-mode.js.map +1 -0
  75. package/sidecar/dist/agent/provider.d.ts +2 -2
  76. package/sidecar/dist/agent/runner-events.d.ts +9 -1
  77. package/sidecar/dist/agent/runner.d.ts +2 -2
  78. package/sidecar/dist/agent/runner.js +11 -5
  79. package/sidecar/dist/agent/runner.js.map +1 -1
  80. package/sidecar/dist/agent/session.d.ts +3 -3
  81. package/sidecar/dist/agent/session.js +54 -34
  82. package/sidecar/dist/agent/session.js.map +1 -1
  83. package/sidecar/dist/agent/switching-runner.js +16 -14
  84. package/sidecar/dist/agent/switching-runner.js.map +1 -1
  85. package/sidecar/dist/agent/turn-end-reasons.d.ts +3 -0
  86. package/sidecar/dist/agent/turn-end-reasons.js +24 -0
  87. package/sidecar/dist/agent/turn-end-reasons.js.map +1 -0
  88. package/sidecar/dist/constants/agent.d.ts +6 -1
  89. package/sidecar/dist/constants/agent.js +13 -1
  90. package/sidecar/dist/constants/agent.js.map +1 -1
  91. package/sidecar/dist/constants/claude.d.ts +27 -1
  92. package/sidecar/dist/constants/claude.js +49 -0
  93. package/sidecar/dist/constants/claude.js.map +1 -1
  94. package/sidecar/dist/constants/codex.d.ts +6 -0
  95. package/sidecar/dist/constants/codex.js +13 -0
  96. package/sidecar/dist/constants/codex.js.map +1 -1
  97. package/sidecar/dist/constants/settings.js.map +1 -1
  98. package/sidecar/dist/constants/timing.d.ts +1 -0
  99. package/sidecar/dist/constants/timing.js +1 -0
  100. package/sidecar/dist/constants/timing.js.map +1 -1
  101. package/sidecar/dist/protocol/events.d.ts +36 -0
  102. package/sidecar/dist/protocol/events.js +13 -0
  103. package/sidecar/dist/protocol/events.js.map +1 -1
  104. package/sidecar/dist/providers/claude/activity.d.ts +5 -0
  105. package/sidecar/dist/providers/claude/activity.js +63 -0
  106. package/sidecar/dist/providers/claude/activity.js.map +1 -0
  107. package/sidecar/dist/providers/claude/adapter.js +21 -13
  108. package/sidecar/dist/providers/claude/adapter.js.map +1 -1
  109. package/sidecar/dist/providers/claude/config.d.ts +2 -1
  110. package/sidecar/dist/providers/claude/config.js +4 -0
  111. package/sidecar/dist/providers/claude/config.js.map +1 -1
  112. package/sidecar/dist/providers/claude/provider.js +18 -25
  113. package/sidecar/dist/providers/claude/provider.js.map +1 -1
  114. package/sidecar/dist/providers/claude/result.d.ts +3 -0
  115. package/sidecar/dist/providers/claude/result.js +61 -0
  116. package/sidecar/dist/providers/claude/result.js.map +1 -0
  117. package/sidecar/dist/providers/claude/safe-mode.d.ts +4 -0
  118. package/sidecar/dist/providers/claude/safe-mode.js +24 -0
  119. package/sidecar/dist/providers/claude/safe-mode.js.map +1 -0
  120. package/sidecar/dist/providers/codex/adapter.js +8 -2
  121. package/sidecar/dist/providers/codex/adapter.js.map +1 -1
  122. package/sidecar/dist/providers/codex/client.d.ts +1 -0
  123. package/sidecar/dist/providers/codex/client.js +1 -0
  124. package/sidecar/dist/providers/codex/client.js.map +1 -1
  125. package/sidecar/dist/providers/codex/config.js +2 -1
  126. package/sidecar/dist/providers/codex/config.js.map +1 -1
  127. package/sidecar/dist/providers/codex/process.js +113 -33
  128. package/sidecar/dist/providers/codex/process.js.map +1 -1
  129. package/sidecar/dist/providers/codex/provider.js +6 -4
  130. package/sidecar/dist/providers/codex/provider.js.map +1 -1
  131. package/sidecar/dist/server/routing.js +39 -21
  132. package/sidecar/dist/server/routing.js.map +1 -1
  133. package/sidecar/dist/server/turn-progress.d.ts +13 -0
  134. package/sidecar/dist/server/turn-progress.js +72 -0
  135. package/sidecar/dist/server/turn-progress.js.map +1 -0
  136. package/sidecar/dist/server/ws.js +4 -2
  137. package/sidecar/dist/server/ws.js.map +1 -1
  138. package/sidecar/dist/state/types.d.ts +2 -1
  139. package/sidecar/dist/state/types.js +1 -0
  140. package/sidecar/dist/state/types.js.map +1 -1
  141. package/sidecar/package.json +1 -1
  142. package/frontend-vue/app/runtime/ws-client.ts +0 -196
  143. package/frontend-vue/tests/sidecar-auth.test.ts +0 -87
  144. package/sidecar/chatbox/dist/assets/index-CEZ1NLp9.js +0 -52
  145. package/sidecar/chatbox/dist/assets/index-D_51Vqqk.css +0 -1
@@ -0,0 +1,75 @@
1
+ export interface StreamEvent {
2
+ name: string
3
+ data: string
4
+ }
5
+
6
+ const DEFAULT_EVENT_NAME = 'message'
7
+ const FRAME_SEPARATOR = /\r?\n\r?\n/
8
+ const LINE_SEPARATOR = /\r?\n/
9
+ const FIELD_SEPARATOR = ':'
10
+ const EVENT_FIELD = 'event'
11
+ const DATA_FIELD = 'data'
12
+
13
+ interface Field {
14
+ name: string
15
+ value: string
16
+ }
17
+
18
+ function parseField(line: string): Field {
19
+ const boundary = line.indexOf(FIELD_SEPARATOR)
20
+ if (boundary === -1) return { name: line, value: '' }
21
+ const raw = line.slice(boundary + FIELD_SEPARATOR.length)
22
+ return {
23
+ name: line.slice(0, boundary),
24
+ value: raw.startsWith(' ') ? raw.slice(1) : raw,
25
+ }
26
+ }
27
+
28
+ function parseFrame(frame: string): StreamEvent | null {
29
+ let name = DEFAULT_EVENT_NAME
30
+ const data: string[] = []
31
+ for (const line of frame.split(LINE_SEPARATOR)) {
32
+ const field = parseField(line)
33
+ if (field.name === EVENT_FIELD) name = field.value
34
+ if (field.name === DATA_FIELD) data.push(field.value)
35
+ }
36
+ if (data.length === 0) return null
37
+ return { name, data: data.join('\n') }
38
+ }
39
+
40
+ function dispatchFrames(
41
+ buffer: string,
42
+ onEvent: (event: StreamEvent) => void,
43
+ ): string {
44
+ const frames = buffer.split(FRAME_SEPARATOR)
45
+ const rest = frames.pop() ?? ''
46
+ for (const frame of frames) {
47
+ const event = parseFrame(frame)
48
+ if (event !== null) onEvent(event)
49
+ }
50
+ return rest
51
+ }
52
+
53
+ /**
54
+ * Delivers every event of a text/event-stream body until it ends. Read over
55
+ * fetch rather than EventSource, like the DMS realtime stream, so a failed
56
+ * request surfaces its status and nothing reconnects behind the caller's back.
57
+ */
58
+ export async function readEventStream(
59
+ body: ReadableStream<Uint8Array>,
60
+ onEvent: (event: StreamEvent) => void,
61
+ ): Promise<void> {
62
+ const reader = body.getReader()
63
+ const decoder = new TextDecoder()
64
+ let buffer = ''
65
+ try {
66
+ while (true) {
67
+ const chunk = await reader.read()
68
+ if (chunk.done) return
69
+ buffer += decoder.decode(chunk.value, { stream: true })
70
+ buffer = dispatchFrames(buffer, onEvent)
71
+ }
72
+ } finally {
73
+ reader.releaseLock()
74
+ }
75
+ }
@@ -4,6 +4,7 @@ import {
4
4
  OVERLAY_DOM_ID,
5
5
  OVERLAY_IFRAME_ID,
6
6
  OVERLAY_MIN_WIDTH_PX,
7
+ OVERLAY_OPEN_CHANGE_EVENT,
7
8
  OVERLAY_OUTSIDE_TOGGLE_SUPPRESS_MS,
8
9
  OVERLAY_PLACEHOLDER_ID,
9
10
  OVERLAY_RESIZE_HANDLE_SIZE_PX,
@@ -21,6 +22,7 @@ import {
21
22
  import {
22
23
  type HostTheme,
23
24
  installThemeBridge,
25
+ isFromFrame,
24
26
  readHostMode,
25
27
  } from './theme-bridge'
26
28
 
@@ -37,7 +39,9 @@ export interface OverlayHandle {
37
39
  // use `toggleOpen` so it is never suppressed.
38
40
  toggleFromLauncher: () => void
39
41
  isOpen: () => boolean
40
- // Point the frame at a (possibly new) sidecar URL. A no-op when the URL is
42
+ /** Called on every open or close, however the panel was toggled. */
43
+ onOpenChange: (listener: (isOpen: boolean) => void) => () => void
44
+ // Point the frame at the chat document. A no-op when the URL is
41
45
  // unchanged and already loaded, so a redundant `connected` notify won't reload
42
46
  // the chatbox; otherwise it reloads and shows a connecting placeholder until
43
47
  // the frame paints.
@@ -106,9 +110,9 @@ function buildContainer(): HTMLDivElement {
106
110
  return container
107
111
  }
108
112
 
109
- // Built with no `src`: the frame is only pointed at the sidecar via `repoint`
110
- // once the status controller confirms a reachable port, so the browser never
111
- // renders its native "can't reach" page for a dead port.
113
+ // Built with no `src`: the frame is only pointed at the chat via `repoint` once
114
+ // the status controller confirms the sidecar runs, so it never renders the
115
+ // error page of a sidecar that is down.
112
116
  function buildIframe(): HTMLIFrameElement {
113
117
  const iframe = document.createElement('iframe')
114
118
  iframe.id = OVERLAY_IFRAME_ID
@@ -291,8 +295,8 @@ function createPlaceholderController(
291
295
  }
292
296
 
293
297
  // Repoint the frame at `url`, deferring the placeholder-hide until it paints so a
294
- // port change reloads the chatbox without flashing the previous dead frame. A
295
- // no-op reload when the URL is already loaded.
298
+ // reload of the chatbox never flashes the previous frame. A no-op reload when
299
+ // the URL is already loaded.
296
300
  function createRepointer(
297
301
  iframe: HTMLIFrameElement,
298
302
  placeholder: PlaceholderController,
@@ -321,9 +325,14 @@ function setOpen(
321
325
  stateRef: OverlayPrefsRef,
322
326
  isOpen: boolean,
323
327
  ): void {
328
+ const hasChanged = stateRef.value.isOpen !== isOpen
324
329
  stateRef.value = { ...stateRef.value, isOpen }
325
330
  applyPrefs(elements, stateRef.value)
326
331
  writePrefsDebounced(stateRef.value)
332
+ if (!hasChanged) return
333
+ elements.container.dispatchEvent(
334
+ new CustomEvent(OVERLAY_OPEN_CHANGE_EVENT, { detail: isOpen }),
335
+ )
327
336
  }
328
337
 
329
338
  export function injectOverlay(): OverlayHandle | null {
@@ -360,6 +369,7 @@ export function injectOverlay(): OverlayHandle | null {
360
369
  // The chatbox header's close button lives inside the iframe; it asks the
361
370
  // host to slide the panel away via postMessage.
362
371
  globalThis.addEventListener('message', (event: MessageEvent) => {
372
+ if (!isFromFrame(event, elements.iframe)) return
363
373
  const type = (event.data as MessageData)?.type
364
374
  if (typeof type === 'string' && CLOSE_MESSAGE_TYPES.has(type)) {
365
375
  setOpen(elements, stateRef, false)
@@ -377,6 +387,13 @@ export function injectOverlay(): OverlayHandle | null {
377
387
  toggle()
378
388
  },
379
389
  isOpen: (): boolean => stateRef.value.isOpen,
390
+ onOpenChange: (listener) => {
391
+ const onChange = (event: Event): void =>
392
+ listener((event as CustomEvent<boolean>).detail)
393
+ elements.container.addEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
394
+ return () =>
395
+ elements.container.removeEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
396
+ },
380
397
  repoint,
381
398
  showPlaceholder: (kind) => placeholder.show(kind),
382
399
  }
@@ -14,8 +14,6 @@ export type SidecarStatus =
14
14
  | typeof SIDECAR_STATUS_UNAVAILABLE
15
15
 
16
16
  interface SidecarInfo {
17
- port: number | null
18
- clientToken: string | null
19
17
  isRunning: boolean
20
18
  hasGivenUp: boolean
21
19
  disabled: boolean
@@ -27,14 +25,14 @@ export interface SidecarStatusController {
27
25
  // is disabled for this process — so the caller skips injection entirely.
28
26
  init: () => Promise<boolean>
29
27
  getStatus: () => SidecarStatus
30
- getPort: () => number | null
31
- getClientToken: () => string | null
32
28
  // Subscribe to status changes; fires immediately with the current status so a
33
29
  // late subscriber (overlay/icon injected after init) reflects it right away.
34
30
  subscribe: (cb: (status: SidecarStatus) => void) => () => void
35
- // The host WebSocket dropped: re-probe now to relearn a possibly-new port
36
- // rather than let the socket keep retrying the dead one.
37
- reportWsDown: () => void
31
+ /**
32
+ * The host channel dropped: re-probe now, which also revives an idle-exited
33
+ * sidecar, and report `connected` again once it answers.
34
+ */
35
+ reportChannelDown: () => void
38
36
  dispose: () => void
39
37
  }
40
38
 
@@ -46,9 +44,6 @@ async function fetchInfo(
46
44
  try {
47
45
  const body = await fetchDetails()
48
46
  return {
49
- port: typeof body.port === 'number' ? body.port : null,
50
- clientToken:
51
- typeof body.clientToken === 'string' ? body.clientToken : null,
52
47
  isRunning: body.isRunning === true,
53
48
  hasGivenUp: body.hasGivenUp === true,
54
49
  disabled: body.disabled === true,
@@ -64,8 +59,6 @@ export function createSidecarStatusController(
64
59
  const backoff = createBackoff()
65
60
  const subscribers = new Set<(status: SidecarStatus) => void>()
66
61
  let status: SidecarStatus = SIDECAR_STATUS_CONNECTING
67
- let port: number | null = null
68
- let clientToken: string | null = null
69
62
  let pollTimer: ReturnType<typeof setTimeout> | null = null
70
63
  let disposed = false
71
64
 
@@ -105,19 +98,13 @@ export function createSidecarStatusController(
105
98
  setStatus(SIDECAR_STATUS_UNAVAILABLE)
106
99
  return
107
100
  }
108
- if (info.isRunning && info.port !== null && info.clientToken) {
109
- port = info.port
110
- clientToken = info.clientToken
101
+ if (info.isRunning) {
111
102
  resetBackoff(backoff)
112
- // A reviving→connected transition notifies via setStatus; a port change
113
- // while already connected leaves the status word unchanged, so force a
114
- // notify there too to drive the overlay/WS onto the new port.
115
- if (status === SIDECAR_STATUS_CONNECTED) notify()
116
- else setStatus(SIDECAR_STATUS_CONNECTED)
103
+ setStatus(SIDECAR_STATUS_CONNECTED)
117
104
  return
118
105
  }
119
106
  // Down but not terminal — the probe already asked the backend to respawn;
120
- // keep polling until it reports a port.
107
+ // keep polling until it reports the sidecar running.
121
108
  setStatus(SIDECAR_STATUS_REVIVING)
122
109
  schedulePoll()
123
110
  }
@@ -131,8 +118,6 @@ export function createSidecarStatusController(
131
118
  return true
132
119
  },
133
120
  getStatus: () => status,
134
- getPort: () => port,
135
- getClientToken: () => clientToken,
136
121
  subscribe: (cb) => {
137
122
  subscribers.add(cb)
138
123
  cb(status)
@@ -140,13 +125,13 @@ export function createSidecarStatusController(
140
125
  subscribers.delete(cb)
141
126
  }
142
127
  },
143
- reportWsDown: () => {
128
+ reportChannelDown: () => {
144
129
  if (disposed) return
145
130
  if (status !== SIDECAR_STATUS_CONNECTED) return
146
131
  // Surface the drop right away (amber dot + spinner) instead of waiting on
147
132
  // the re-probe: that probe awaits a respawn and can take a second or two,
148
133
  // during which the icon would otherwise look healthy. poll() flips back
149
- // to connected once the new port is live.
134
+ // to connected once the sidecar answers again.
150
135
  resetBackoff(backoff)
151
136
  setStatus(SIDECAR_STATUS_REVIVING)
152
137
  void poll()
@@ -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
+ })