@runtypelabs/persona 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,422 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi } from "vitest";
4
+ import { ContextMentionManager, refFromItem } from "./context-mention-manager";
5
+ import * as mentionLlmFormat from "./mention-llm-format";
6
+ import type {
7
+ AgentWidgetConfig,
8
+ AgentWidgetContextMentionConfig,
9
+ AgentWidgetContextMentionItem,
10
+ AgentWidgetContextMentionPayload,
11
+ AgentWidgetContextMentionSource,
12
+ } from "../types";
13
+
14
+ const tick = () => new Promise((r) => setTimeout(r, 0));
15
+
16
+ const item = (id: string, label = id): AgentWidgetContextMentionItem => ({ id, label });
17
+
18
+ function makeManager(
19
+ overrides: Partial<AgentWidgetContextMentionConfig> = {},
20
+ sourceResolve?: AgentWidgetContextMentionSource["resolve"]
21
+ ) {
22
+ const contextRow = document.createElement("div");
23
+ const mentionConfig: AgentWidgetContextMentionConfig = {
24
+ enabled: true,
25
+ sources: [],
26
+ ...overrides,
27
+ };
28
+ const announce = vi.fn();
29
+ const announceError = vi.fn();
30
+ const manager = new ContextMentionManager({
31
+ mentionConfig,
32
+ contextRow,
33
+ getMessages: () => [],
34
+ getConfig: () => ({}) as AgentWidgetConfig,
35
+ getComposerText: () => "hello",
36
+ announce,
37
+ announceError,
38
+ });
39
+ const source: AgentWidgetContextMentionSource = {
40
+ id: "files",
41
+ label: "Files",
42
+ search: () => [],
43
+ resolve: sourceResolve ?? (async () => ({ llmAppend: "CONTENT" })),
44
+ };
45
+ return { manager, contextRow, mentionConfig, source, announce, announceError };
46
+ }
47
+
48
+ describe("ContextMentionManager", () => {
49
+ it("adds a chip and eagerly resolves on select (cached)", async () => {
50
+ const resolve = vi.fn(async () => ({ llmAppend: "FILE BODY" }));
51
+ const { manager, contextRow, source } = makeManager({}, resolve);
52
+ expect(manager.add(source, item("App.tsx"))).toBe(true);
53
+ expect(contextRow.querySelectorAll("[data-persona-mention-chip]")).toHaveLength(1);
54
+ await tick();
55
+ expect(resolve).toHaveBeenCalledTimes(1);
56
+
57
+ const bundle = await manager.collectForSubmit().finalize();
58
+ expect(resolve).toHaveBeenCalledTimes(1); // re-used the cached payload
59
+ expect(bundle.blocks).toEqual(["```App.tsx\nFILE BODY\n```"]);
60
+ });
61
+
62
+ it("shows the context row on the first add (visibility computed after tracking)", () => {
63
+ const { manager, contextRow, source } = makeManager();
64
+ expect(manager.add(source, item("App.tsx"))).toBe(true);
65
+ expect(contextRow.style.display).toBe("flex");
66
+ });
67
+
68
+ it("formats multiple mentions in order via llmFormat, numbering document blocks 1-based", async () => {
69
+ const { manager } = makeManager({ llmFormat: "document" });
70
+ const source: AgentWidgetContextMentionSource = {
71
+ id: "files",
72
+ label: "Files",
73
+ search: () => [],
74
+ resolve: async (i) => ({ llmAppend: `body of ${i.label}` }),
75
+ };
76
+ manager.add(source, item("a", "A.ts"));
77
+ manager.add(source, item("b", "B.ts"));
78
+ await tick();
79
+ const bundle = await manager.collectForSubmit().finalize();
80
+ expect(bundle.blocks).toEqual([
81
+ '<document index="1">\n<source>A.ts</source>\n<document_content>\nbody of A.ts\n</document_content>\n</document>',
82
+ '<document index="2">\n<source>B.ts</source>\n<document_content>\nbody of B.ts\n</document_content>\n</document>',
83
+ ]);
84
+ });
85
+
86
+ it("reuses the in-flight resolve at submit (no duplicate fetch, survives clear)", async () => {
87
+ // Resolve stays pending until we release it — mirrors a submit that fires
88
+ // before the select-time resolve has settled.
89
+ let release!: (v: AgentWidgetContextMentionPayload) => void;
90
+ const resolve = vi.fn(
91
+ () => new Promise<AgentWidgetContextMentionPayload>((r) => (release = r))
92
+ );
93
+ const { manager, contextRow, source } = makeManager({}, resolve);
94
+ manager.add(source, item("App.tsx"));
95
+ expect(resolve).toHaveBeenCalledTimes(1);
96
+
97
+ // Submit path: collect (detaches ownership) then the UI's post-send clear().
98
+ const { finalize } = manager.collectForSubmit();
99
+ expect(contextRow.querySelectorAll("[data-persona-mention-chip]")).toHaveLength(0);
100
+ manager.clear(); // must NOT abort the collected in-flight resolve
101
+
102
+ // The select-time resolve settles after collect+clear; finalize reuses it.
103
+ release({ llmAppend: "FILE BODY" });
104
+ const bundle = await finalize();
105
+ expect(resolve).toHaveBeenCalledTimes(1); // never re-fetched
106
+ expect(bundle.blocks).toEqual(["```App.tsx\nFILE BODY\n```"]);
107
+ });
108
+
109
+ it("rejects duplicates", () => {
110
+ const onMentionRejected = vi.fn();
111
+ const { manager, source } = makeManager({ onMentionRejected });
112
+ manager.add(source, item("a"));
113
+ expect(manager.add(source, item("a"))).toBe(false);
114
+ expect(onMentionRejected).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }), "duplicate");
115
+ });
116
+
117
+ it("enforces maxMentions", () => {
118
+ const onMentionRejected = vi.fn();
119
+ const { manager, source } = makeManager({ maxMentions: 1, onMentionRejected });
120
+ expect(manager.add(source, item("a"))).toBe(true);
121
+ expect(manager.add(source, item("b"))).toBe(false);
122
+ expect(onMentionRejected).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }), "limit");
123
+ });
124
+
125
+ it("aborts an in-flight resolve when the chip is removed", async () => {
126
+ let captured: AbortSignal | undefined;
127
+ const resolve = vi.fn(
128
+ (_i, ctx) =>
129
+ new Promise<never>(() => {
130
+ captured = ctx.signal;
131
+ })
132
+ );
133
+ const { manager, contextRow, source } = makeManager({}, resolve as never);
134
+ manager.add(source, item("App.tsx"));
135
+ await tick();
136
+ expect(captured?.aborted).toBe(false);
137
+ (contextRow.querySelector(".persona-mention-chip-remove") as HTMLButtonElement).click();
138
+ expect(captured?.aborted).toBe(true);
139
+ expect(manager.hasMentions()).toBe(false);
140
+ });
141
+
142
+ it("removeLast pops the most recent chip", () => {
143
+ const { manager, contextRow, source } = makeManager();
144
+ manager.add(source, item("a"));
145
+ manager.add(source, item("b"));
146
+ expect(contextRow.querySelectorAll(".persona-mention-chip")).toHaveLength(2);
147
+ expect(manager.removeLast()).toBe(true);
148
+ const labels = Array.from(
149
+ contextRow.querySelectorAll(".persona-mention-chip-label")
150
+ ).map((el) => el.textContent);
151
+ expect(labels).toEqual(["a"]);
152
+ });
153
+
154
+ it("defers resolve to submit for resolveOn:'submit' sources", async () => {
155
+ const resolve = vi.fn(async () => ({ llmAppend: "LIVE PAGE" }));
156
+ const { manager } = makeManager();
157
+ const source: AgentWidgetContextMentionSource = {
158
+ id: "page",
159
+ label: "Page",
160
+ search: () => [],
161
+ resolve,
162
+ resolveOn: "submit",
163
+ };
164
+ manager.add(source, item("hero"));
165
+ await tick();
166
+ expect(resolve).not.toHaveBeenCalled(); // deferred
167
+ const bundle = await manager.collectForSubmit().finalize();
168
+ expect(resolve).toHaveBeenCalledTimes(1); // resolved at submit
169
+ expect(bundle.blocks).toEqual(["```hero\nLIVE PAGE\n```"]);
170
+ });
171
+
172
+ it("drops a failed select-resolve and fires onMentionResolveError; still sends", async () => {
173
+ const onMentionResolveError = vi.fn();
174
+ const resolve = vi.fn(async () => {
175
+ throw new Error("boom");
176
+ });
177
+ const { manager } = makeManager({ onMentionResolveError }, resolve);
178
+ manager.add(makeSource(resolve), item("bad"));
179
+ await tick();
180
+ expect(onMentionResolveError).toHaveBeenCalledWith(
181
+ expect.objectContaining({ id: "bad" }),
182
+ expect.any(Error)
183
+ );
184
+ const bundle = await manager.collectForSubmit().finalize();
185
+ expect(bundle.blocks).toEqual([]); // dropped
186
+ });
187
+
188
+ it("announces a select-resolve failure through the assertive region", async () => {
189
+ const resolve = vi.fn(async () => {
190
+ throw new Error("boom");
191
+ });
192
+ const { manager, announceError } = makeManager({}, resolve);
193
+ manager.add(makeSource(resolve), item("bad", "Bad File"));
194
+ await tick();
195
+ expect(announceError).toHaveBeenCalledWith(
196
+ "Couldn't attach Bad File to context"
197
+ );
198
+ });
199
+
200
+ it("forwards the resolved payload to renderMentionChip once ready", async () => {
201
+ const seen: (AgentWidgetContextMentionPayload | undefined)[] = [];
202
+ const renderMentionChip = vi.fn((ctx) => {
203
+ seen.push(ctx.payload);
204
+ const el = document.createElement("span");
205
+ el.className = "custom-chip";
206
+ el.dataset.status = ctx.status;
207
+ return el;
208
+ });
209
+ const resolve = vi.fn(async () => ({ llmAppend: "FILE BODY" }));
210
+ const { manager, source } = makeManager({ renderMentionChip }, resolve);
211
+ manager.add(source, item("App.tsx"));
212
+ // First render is the resolving state, before resolve() settles: no payload.
213
+ expect(seen[0]).toBeUndefined();
214
+ await tick();
215
+ // After resolve, the renderer is re-invoked with status "ready" + the payload.
216
+ expect(seen.at(-1)).toEqual({ llmAppend: "FILE BODY" });
217
+ expect(renderMentionChip).toHaveBeenCalledWith(
218
+ expect.objectContaining({
219
+ status: "ready",
220
+ payload: { llmAppend: "FILE BODY" },
221
+ })
222
+ );
223
+ });
224
+
225
+ it("remove() detaches a custom-rendered chip after setStatus swapped its node", async () => {
226
+ const renderMentionChip = vi.fn((ctx) => {
227
+ const el = document.createElement("span");
228
+ el.className = "custom-chip";
229
+ el.dataset.status = ctx.status;
230
+ return el;
231
+ });
232
+ const resolve = vi.fn(async () => ({ llmAppend: "FILE BODY" }));
233
+ const { manager, contextRow, source } = makeManager({ renderMentionChip }, resolve);
234
+ manager.add(source, item("App.tsx"));
235
+ await tick(); // resolve settles → renderer re-invoked → node replaced
236
+ expect(contextRow.querySelectorAll(".custom-chip")).toHaveLength(1);
237
+
238
+ manager.removeLast();
239
+ expect(contextRow.querySelectorAll(".custom-chip")).toHaveLength(0);
240
+ });
241
+
242
+ it("does not expose a payload to renderMentionChip for resolveOn:'submit' sources", async () => {
243
+ const seen: (AgentWidgetContextMentionPayload | undefined)[] = [];
244
+ const renderMentionChip = vi.fn((ctx) => {
245
+ seen.push(ctx.payload);
246
+ const el = document.createElement("span");
247
+ el.dataset.status = ctx.status;
248
+ return el;
249
+ });
250
+ const { manager } = makeManager({ renderMentionChip });
251
+ const source: AgentWidgetContextMentionSource = {
252
+ id: "page",
253
+ label: "Page",
254
+ search: () => [],
255
+ resolve: async () => ({ llmAppend: "LIVE" }),
256
+ resolveOn: "submit",
257
+ };
258
+ manager.add(source, item("hero"));
259
+ await tick();
260
+ // Submit sources flip straight to "ready" with no payload until send time.
261
+ expect(seen.every((p) => p === undefined)).toBe(true);
262
+ });
263
+
264
+ it("namespaces opt-in context by source + item", async () => {
265
+ const resolve = async () => ({ context: { path: "/x" } });
266
+ const { manager } = makeManager({}, resolve);
267
+ manager.add(makeSource(resolve, "files"), item("App.tsx"));
268
+ await tick();
269
+ const bundle = await manager.collectForSubmit().finalize();
270
+ expect(bundle.context).toEqual({ files: { "App.tsx": { path: "/x" } } });
271
+ });
272
+
273
+ // ---- inline mode (track / admit / refFromItem) ---------------------------
274
+
275
+ it("refFromItem builds the stored ref from source + item", () => {
276
+ const source = makeSource(async () => ({}), "files");
277
+ expect(
278
+ refFromItem(source, {
279
+ id: "app",
280
+ label: "App.tsx",
281
+ iconName: "file",
282
+ color: "#f00",
283
+ })
284
+ ).toEqual({
285
+ sourceId: "files",
286
+ itemId: "app",
287
+ label: "App.tsx",
288
+ iconName: "file",
289
+ color: "#f00",
290
+ });
291
+ });
292
+
293
+ it("admit enforces the limit and fires the rejection hook (inline pre-insert gate)", () => {
294
+ const onMentionRejected = vi.fn();
295
+ const { manager, source } = makeManager({ maxMentions: 1, onMentionRejected });
296
+ manager.track("pmention-1", source, item("a"));
297
+ expect(manager.admit(source, item("b"))).toBe(false);
298
+ expect(onMentionRejected).toHaveBeenCalledWith(
299
+ expect.objectContaining({ id: "b" }),
300
+ "limit"
301
+ );
302
+ });
303
+
304
+ it("admit allows a mention under the limit", () => {
305
+ const { manager, source } = makeManager({ maxMentions: 2 });
306
+ manager.track("pmention-1", source, item("a"));
307
+ expect(manager.admit(source, item("b"))).toBe(true);
308
+ });
309
+
310
+ it("admits duplicate inline mentions and dedupes the resolved payload at submit (Slack/Cursor/Claude)", async () => {
311
+ // Inline mode lets the same item appear as several prose tokens; admit only
312
+ // gates the limit, and finalize dedupes the resolved payload by (source, item)
313
+ // so the LLM sees the content once while every token still renders in the sent
314
+ // bubble (refs lists both). No rejection fires for a duplicate here.
315
+ const onMentionRejected = vi.fn();
316
+ const { manager, source } = makeManager({ onMentionRejected });
317
+ expect(manager.admit(source, item("a"))).toBe(true);
318
+ manager.track("pmention-1", source, item("a"));
319
+ expect(manager.admit(source, item("a"))).toBe(true); // duplicate allowed
320
+ manager.track("pmention-2", source, item("a"));
321
+ expect(onMentionRejected).not.toHaveBeenCalled();
322
+
323
+ await tick();
324
+ const { refs, finalize } = manager.collectForSubmit();
325
+ expect(refs).toHaveLength(2); // both tokens render in the bubble
326
+ const bundle = await finalize();
327
+ expect(bundle.blocks).toEqual(["```a\nCONTENT\n```"]); // deduped
328
+ });
329
+
330
+ it("track reports resolve status onto the inline token (resolved / error)", async () => {
331
+ // Success: reports "resolved" once the select-time resolve settles.
332
+ const ok = makeManager({}, async () => ({ llmAppend: "CONTENT" }));
333
+ const okReports: string[] = [];
334
+ ok.manager.track("pmention-1", ok.source, item("good"), "", (s) =>
335
+ okReports.push(s)
336
+ );
337
+ await tick();
338
+ expect(okReports).toContain("resolved");
339
+
340
+ // Failure: reports "error" so the token surfaces the dropped context.
341
+ const badResolve = vi.fn(async () => {
342
+ throw new Error("boom");
343
+ });
344
+ const bad = makeManager({}, badResolve);
345
+ const badReports: string[] = [];
346
+ bad.manager.track("pmention-2", bad.source, item("bad"), "", (s) =>
347
+ badReports.push(s)
348
+ );
349
+ await tick();
350
+ expect(badReports).toContain("error");
351
+ });
352
+
353
+ it("a throwing block formatter drops that item's block but finalize resolves the others", async () => {
354
+ const resolve = async (i: AgentWidgetContextMentionItem) => ({
355
+ llmAppend: `body of ${i.label}`,
356
+ });
357
+ const { manager } = makeManager({}, resolve);
358
+ manager.add(makeSource(resolve, "files"), item("good", "Good.ts"));
359
+ manager.add(makeSource(resolve, "files"), item("bad", "Bad.ts"));
360
+ await tick();
361
+
362
+ // Force formatMentionBlock to throw for one item: a bad block formatter must
363
+ // drop that block, not reject the whole bundle and lose every mention.
364
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
365
+ const spy = vi
366
+ .spyOn(mentionLlmFormat, "formatMentionBlock")
367
+ .mockImplementation((entry) => {
368
+ if (entry.label === "Bad.ts") throw new Error("format boom");
369
+ return `[[${entry.label}]] ${entry.text}`;
370
+ });
371
+
372
+ const bundle = await manager.collectForSubmit().finalize();
373
+ expect(bundle.blocks).toEqual(["[[Good.ts]] body of Good.ts"]);
374
+
375
+ spy.mockRestore();
376
+ warn.mockRestore();
377
+ });
378
+
379
+ it("a throwing onMentionResolveError drops that item but finalize resolves the others", async () => {
380
+ const onMentionResolveError = vi.fn(() => {
381
+ throw new Error("callback boom");
382
+ });
383
+ const { manager } = makeManager({ onMentionResolveError });
384
+ // Submit-resolve sources: the failing resolve throws inside finalize, so its
385
+ // catch fires onMentionResolveError there (the throw site the guard protects).
386
+ const goodSource: AgentWidgetContextMentionSource = {
387
+ id: "good",
388
+ label: "Good",
389
+ search: () => [],
390
+ resolve: async (i) => ({ llmAppend: `body of ${i.label}` }),
391
+ resolveOn: "submit",
392
+ };
393
+ const badSource: AgentWidgetContextMentionSource = {
394
+ id: "bad",
395
+ label: "Bad",
396
+ search: () => [],
397
+ resolve: async () => {
398
+ throw new Error("resolve boom");
399
+ },
400
+ resolveOn: "submit",
401
+ };
402
+ manager.add(goodSource, item("g", "Good.ts"));
403
+ manager.add(badSource, item("b", "Bad.ts"));
404
+ await tick();
405
+
406
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
407
+ const bundle = await manager.collectForSubmit().finalize();
408
+ expect(onMentionResolveError).toHaveBeenCalledWith(
409
+ expect.objectContaining({ id: "b" }),
410
+ expect.any(Error)
411
+ );
412
+ expect(bundle.blocks).toEqual(["```Good.ts\nbody of Good.ts\n```"]);
413
+ warn.mockRestore();
414
+ });
415
+ });
416
+
417
+ function makeSource(
418
+ resolve: AgentWidgetContextMentionSource["resolve"],
419
+ id = "files"
420
+ ): AgentWidgetContextMentionSource {
421
+ return { id, label: "Files", search: () => [], resolve };
422
+ }