@marimo-team/frontend 0.25.2-dev114 → 0.25.2-dev115

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 (85) hide show
  1. package/dist/assets/{CellStatus-B4z-D5H1.js → CellStatus-CtjqYbWH.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-R-fjO4Cz.js → ErrorBoundary-F1payLgE.js} +2 -2
  3. package/dist/assets/{JsonOutput-BeLVZDXh.js → JsonOutput-Bhgwb5kV.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-B2Ajs_V-.js → MarimoErrorOutput-4jDnOIVr.js} +1 -1
  5. package/dist/assets/{RenderHTML-Chx9oQ4S.js → RenderHTML-B5VPTO14.js} +1 -1
  6. package/dist/assets/{RunButton-DnSopQoo.js → RunButton-BIzGc4XI.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-9oZmQ0tX.js → add-cell-with-ai-Cmm2fga_.js} +10 -10
  8. package/dist/assets/{add-connection-dialog-C82vMzr5.js → add-connection-dialog-DCpxcHW4.js} +1 -1
  9. package/dist/assets/{agent-panel-8bu-4RMv.js → agent-panel-BsG9PQlJ.js} +5 -5
  10. package/dist/assets/ai-model-dropdown-C4Dg70H_.js +5 -0
  11. package/dist/assets/{api-BJPYk7E_.js → api-BeC_VSGv.js} +1 -1
  12. package/dist/assets/app-config-button-qYCFYvje.js +1 -0
  13. package/dist/assets/{blob-BJi30p8H.js → blob-QvTM0DdL.js} +1 -1
  14. package/dist/assets/{cell-editor-CKwmt_18.js → cell-editor-DdAAUVJx.js} +11 -11
  15. package/dist/assets/{cell-link-BI2FitQ_.js → cell-link-CIHoy1VE.js} +1 -1
  16. package/dist/assets/{cells-D73Y_xmw.js → cells-BWyR7TCJ.js} +1 -1
  17. package/dist/assets/{chat-display-CqK2mMHm.js → chat-display-BzvZ1dG6.js} +1 -1
  18. package/dist/assets/chat-panel-CpLCiU26.js +4 -0
  19. package/dist/assets/{chat-ui-CK-mgi6S.js → chat-ui-t3zRs2SX.js} +1 -1
  20. package/dist/assets/{column-preview-biRDE_Oz.js → column-preview-BJr1QW8R.js} +1 -1
  21. package/dist/assets/command-palette-BQWj0cos.js +1 -0
  22. package/dist/assets/{common-8ezkeamF.js → common-2ZN9KNv-.js} +1 -1
  23. package/dist/assets/{components-FsfAPhUd.js → components-_sJpwVD4.js} +1 -1
  24. package/dist/assets/{datasource-Dniq1d2k.js → datasource-C_fKqG0e.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-Ci62Tv1x.js → dependency-graph-panel-DSol2xZ3.js} +1 -1
  26. package/dist/assets/{documentation-panel-B-0JfpSn.js → documentation-panel-BcxEbDPv.js} +1 -1
  27. package/dist/assets/{download-CEG5vQri.js → download-B5cBmz55.js} +1 -1
  28. package/dist/assets/{edit-page-VV7jun46.js → edit-page-DMQmQsPo.js} +6 -6
  29. package/dist/assets/{error-entries-Frq0QZL-.js → error-entries-PrZ4UIKa.js} +1 -1
  30. package/dist/assets/{error-panel-BeSB6k3e.js → error-panel-Cuohhvyw.js} +1 -1
  31. package/dist/assets/feedback-button-5oy5W7Kx.js +1 -0
  32. package/dist/assets/{feedback-button-DALE40Cq.js → feedback-button-aEZd8wqo.js} +1 -1
  33. package/dist/assets/{file-explorer-panel-DkhJMce7.js → file-explorer-panel-DMrCYHTB.js} +1 -1
  34. package/dist/assets/{file-name-input-DsLb3kUE.js → file-name-input-CRkt1da3.js} +1 -1
  35. package/dist/assets/{floating-outline-Bivo15Ee.js → floating-outline-BnO_h00w.js} +1 -1
  36. package/dist/assets/{gallery-page-BipSEe8i.js → gallery-page-g3iLOyQO.js} +1 -1
  37. package/dist/assets/{glide-data-editor-DWxXZHhh.js → glide-data-editor-WZMQjVJd.js} +1 -1
  38. package/dist/assets/{home-page-4xyvW9LC.js → home-page-vBOIWWG_.js} +1 -1
  39. package/dist/assets/{hooks-B7y2bbmR.js → hooks-DlgJEnzq.js} +1 -1
  40. package/dist/assets/{index-DLi7reLY.js → index-DTa3czvL.js} +4 -4
  41. package/dist/assets/{layout-B5DZz9ah.js → layout-88dS_AsT.js} +3 -3
  42. package/dist/assets/{logs-panel-CswvaDqE.js → logs-panel-Ct5o61k4.js} +1 -1
  43. package/dist/assets/{markdown-renderer-CErbddaa.js → markdown-renderer-bBVFObv0.js} +1 -1
  44. package/dist/assets/{name-cell-input-DXf9tZ25.js → name-cell-input-RN-BHucn.js} +1 -1
  45. package/dist/assets/{outline-panel-BPlLwpVm.js → outline-panel-B_GILrNI.js} +1 -1
  46. package/dist/assets/{packages-panel-CoIvCGdp.js → packages-panel-r_YSs3uG.js} +1 -1
  47. package/dist/assets/{panel-accordion-state-B50Y_3hb.js → panel-accordion-state-BF3wKXuD.js} +1 -1
  48. package/dist/assets/{panels-j8cWK9B8.js → panels-DNYjDBgD.js} +1 -1
  49. package/dist/assets/{process-output-Bfoxbqpj.js → process-output-C_JmjPkt.js} +1 -1
  50. package/dist/assets/{readonly-code-display-BusLGSPF.js → readonly-code-display-Bsv-J4Xx.js} +1 -1
  51. package/dist/assets/{readonly-python-code-CTbOf95g.js → readonly-python-code-bOYKcbg4.js} +1 -1
  52. package/dist/assets/{reveal-component-D3wlU8v1.js → reveal-component-CLJ6dooc.js} +1 -1
  53. package/dist/assets/{run-page-B03JZs0U.js → run-page-FdfJRz8z.js} +1 -1
  54. package/dist/assets/{runtime-details-state-Ddpsoriz.js → runtime-details-state-ZkKdmmAJ.js} +1 -1
  55. package/dist/assets/{scratchpad-panel-B9RdvhAM.js → scratchpad-panel-D3bf52na.js} +1 -1
  56. package/dist/assets/{session-panel-D3E0Ag30.js → session-panel-DJ2aIlDv.js} +1 -1
  57. package/dist/assets/{snippets-panel-Bby1zIJE.js → snippets-panel-C-lvTkYT.js} +1 -1
  58. package/dist/assets/{state-ENE4B_JH.js → state-C1RQhToR.js} +1 -1
  59. package/dist/assets/{state-zdyODrb0.js → state-DMZ8VJcq.js} +1 -1
  60. package/dist/assets/{tracing-De9k9D6u.js → tracing-f3Wv0MqG.js} +1 -1
  61. package/dist/assets/{tracing-panel-DvISFiEI.js → tracing-panel-BDSbtHAO.js} +2 -2
  62. package/dist/assets/{tree-actions-D5HC2yfb.js → tree-actions-PNciswRG.js} +1 -1
  63. package/dist/assets/{useAddCell-DAJ9Wd-E.js → useAddCell-mcL2PJTv.js} +1 -1
  64. package/dist/assets/{useCellActionButton-CWhycKft.js → useCellActionButton-CYjo2N8X.js} +1 -1
  65. package/dist/assets/{useDeleteCell-8ByYNceO.js → useDeleteCell-hqk8CkzR.js} +1 -1
  66. package/dist/assets/{useDependencyPanelTab-Dz37hZtr.js → useDependencyPanelTab-rJGeCoZQ.js} +1 -1
  67. package/dist/assets/useNotebookActions-BEpiNBZu.js +1 -0
  68. package/dist/assets/{useRunCells-BVoQp_ne.js → useRunCells-DIY1IVXx.js} +1 -1
  69. package/dist/assets/{useSplitCell-Ba_Zm0ID.js → useSplitCell-BGcgAf0Z.js} +1 -1
  70. package/dist/index.html +20 -20
  71. package/package.json +3 -3
  72. package/src/core/codemirror/copilot/__tests__/completion-integration.test.ts +447 -0
  73. package/src/core/codemirror/copilot/__tests__/inline-completion.test.ts +395 -0
  74. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +511 -2
  75. package/src/core/codemirror/copilot/__tests__/rpc-cancellation.test.ts +201 -0
  76. package/src/core/codemirror/copilot/extension.ts +22 -11
  77. package/src/core/codemirror/copilot/language-server.ts +109 -60
  78. package/src/core/network/__tests__/api.test.ts +114 -1
  79. package/src/core/network/api.ts +3 -2
  80. package/dist/assets/ai-model-dropdown-BKQARUae.js +0 -5
  81. package/dist/assets/app-config-button-DKoDBgwu.js +0 -1
  82. package/dist/assets/chat-panel-BgJcxa4q.js +0 -4
  83. package/dist/assets/command-palette-CHUS5_VG.js +0 -1
  84. package/dist/assets/feedback-button-CrqeO2wx.js +0 -1
  85. package/dist/assets/useNotebookActions-CYbHF8MU.js +0 -1
@@ -0,0 +1,447 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { EditorView } from "@codemirror/view";
4
+ import {
5
+ languageServerWithClient,
6
+ type JSONRPCMessage,
7
+ type JSONRPCRequest,
8
+ type Transport,
9
+ } from "@marimo-team/codemirror-languageserver";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { API } from "@/core/network/api";
12
+ import { languageAdapterState } from "../../language/extension";
13
+ import { copilotBundle } from "../extension";
14
+ import { getCodes } from "../getCodes";
15
+ import { CopilotLanguageServerClient } from "../language-server";
16
+
17
+ const fixture: { client?: CopilotLanguageServerClient } = vi.hoisted(
18
+ () => ({}),
19
+ );
20
+
21
+ vi.mock("../client", () => ({
22
+ COPILOT_FILENAME: "/__marimo_copilot__.py",
23
+ getCopilotClient: () => fixture.client,
24
+ copilotServer: () =>
25
+ languageServerWithClient({
26
+ client: fixture.client!,
27
+ documentUri: "file:///__marimo_copilot__.py",
28
+ languageId: "python",
29
+ sendIncrementalChanges: false,
30
+ hoverEnabled: false,
31
+ completionEnabled: false,
32
+ diagnosticsEnabled: false,
33
+ signatureHelpEnabled: false,
34
+ }),
35
+ }));
36
+ vi.mock("../state", async (importOriginal) => ({
37
+ ...(await importOriginal<typeof import("../state")>()),
38
+ isCopilotEnabled: () => true,
39
+ }));
40
+ vi.mock("../getCodes", () => ({
41
+ getCodes: vi.fn((code: string) => `import math\n\n${code}`),
42
+ }));
43
+
44
+ describe("Copilot editor integration", () => {
45
+ let view: EditorView;
46
+ let client: CopilotLanguageServerClient;
47
+ let emit: (message: JSONRPCMessage) => void;
48
+ let sent: JSONRPCMessage[];
49
+
50
+ function requests() {
51
+ return sent.filter(
52
+ (message): message is JSONRPCRequest =>
53
+ "id" in message &&
54
+ "method" in message &&
55
+ message.method === "textDocument/inlineCompletion",
56
+ );
57
+ }
58
+ function type(text: string) {
59
+ const from = view.state.selection.main.head;
60
+ view.dispatch({
61
+ changes: { from, insert: text },
62
+ selection: { anchor: from + text.length },
63
+ });
64
+ }
65
+ function respond(text: string) {
66
+ emit({
67
+ jsonrpc: "2.0",
68
+ id: requests().at(-1)!.id,
69
+ result: [{ insertText: text }],
70
+ });
71
+ }
72
+
73
+ function useCustomEditor() {
74
+ view.destroy();
75
+ view = new EditorView({
76
+ parent: document.body,
77
+ doc: "pri",
78
+ selection: { anchor: 3 },
79
+ extensions: [
80
+ languageAdapterState,
81
+ copilotBundle({
82
+ copilot: "custom",
83
+ activate_on_typing: true,
84
+ signature_hint_on_typing: false,
85
+ }),
86
+ ],
87
+ });
88
+ vi.spyOn(view, "hasFocus", "get").mockReturnValue(true);
89
+ }
90
+
91
+ beforeEach(async () => {
92
+ vi.useFakeTimers();
93
+ // copilotBundle intentionally disables itself in ordinary unit tests.
94
+ vi.stubEnv("NODE_ENV", "development");
95
+ sent = [];
96
+ const transport: Transport = {
97
+ connect: vi.fn().mockResolvedValue(undefined),
98
+ close: vi.fn(),
99
+ onMessage: (handler) => {
100
+ emit = handler;
101
+ return vi.fn();
102
+ },
103
+ send: (message) => {
104
+ sent.push(message);
105
+ if (
106
+ "id" in message &&
107
+ "method" in message &&
108
+ ["initialize", "shutdown"].includes(message.method)
109
+ ) {
110
+ emit({
111
+ jsonrpc: "2.0",
112
+ id: message.id,
113
+ result:
114
+ message.method === "initialize"
115
+ ? { capabilities: { textDocumentSync: 1 } }
116
+ : null,
117
+ });
118
+ }
119
+ },
120
+ };
121
+ client = new CopilotLanguageServerClient({
122
+ rootUri: "file:///",
123
+ workspaceFolders: null,
124
+ transport,
125
+ });
126
+ fixture.client = client;
127
+ await client.initializePromise;
128
+ view = new EditorView({
129
+ parent: document.body,
130
+ doc: "pri",
131
+ selection: { anchor: 3 },
132
+ extensions: copilotBundle({
133
+ copilot: "github",
134
+ activate_on_typing: true,
135
+ signature_hint_on_typing: false,
136
+ }),
137
+ });
138
+ vi.spyOn(view, "hasFocus", "get").mockReturnValue(true);
139
+ await vi.advanceTimersByTimeAsync(0);
140
+ sent.length = 0;
141
+ vi.mocked(getCodes).mockClear();
142
+ });
143
+
144
+ afterEach(() => {
145
+ view.destroy();
146
+ client.close();
147
+ fixture.client = undefined;
148
+ vi.useRealTimers();
149
+ vi.unstubAllEnvs();
150
+ vi.restoreAllMocks();
151
+ });
152
+
153
+ it("uploads once and requests the matching version/position at 150ms without an extra sleep", async () => {
154
+ type("n");
155
+ type("t");
156
+ await vi.advanceTimersByTimeAsync(149);
157
+ expect(sent).toEqual([]);
158
+ expect(getCodes).not.toHaveBeenCalled();
159
+ await vi.advanceTimersByTimeAsync(1);
160
+ expect(getCodes).toHaveBeenCalledExactlyOnceWith("print");
161
+ expect(sent).toEqual([
162
+ {
163
+ jsonrpc: "2.0",
164
+ method: "textDocument/didChange",
165
+ params: {
166
+ textDocument: { uri: "file:///__marimo_copilot__.py", version: 2 },
167
+ contentChanges: [{ text: "import math\n\nprint" }],
168
+ },
169
+ },
170
+ {
171
+ jsonrpc: "2.0",
172
+ id: expect.any(Number),
173
+ method: "textDocument/inlineCompletion",
174
+ params: {
175
+ textDocument: { uri: "file:///__marimo_copilot__.py", version: 2 },
176
+ position: { line: 2, character: 5 },
177
+ context: { triggerKind: 2 },
178
+ formattingOptions: { tabSize: 4, insertSpaces: true },
179
+ },
180
+ },
181
+ ]);
182
+ respond("(value)");
183
+ await vi.advanceTimersByTimeAsync(0);
184
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
185
+ "(value)",
186
+ );
187
+ type("(");
188
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
189
+ "value)",
190
+ );
191
+ await vi.advanceTimersByTimeAsync(500);
192
+ expect(requests()).toHaveLength(1);
193
+ expect(getCodes).toHaveBeenCalledTimes(1);
194
+ type("x");
195
+ await vi.advanceTimersByTimeAsync(150);
196
+ expect(getCodes).toHaveBeenLastCalledWith("print(x");
197
+ expect(requests()).toHaveLength(2);
198
+ respond(")");
199
+ await vi.advanceTimersByTimeAsync(0);
200
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
201
+ ")",
202
+ );
203
+ });
204
+
205
+ it("never uploads a superseded snapshot when typing restarts the debounce", async () => {
206
+ const flush = vi.spyOn(client, "flushDocumentChanges");
207
+ type("n");
208
+ await vi.advanceTimersByTimeAsync(149);
209
+ type("t");
210
+ await vi.advanceTimersByTimeAsync(149);
211
+ expect(flush).not.toHaveBeenCalled();
212
+ expect(getCodes).not.toHaveBeenCalled();
213
+ expect(sent).toEqual([]);
214
+ await vi.advanceTimersByTimeAsync(1);
215
+ expect(flush.mock.calls.filter(([code]) => code !== undefined)).toEqual([
216
+ ["print"],
217
+ ]);
218
+ expect(getCodes).toHaveBeenCalledExactlyOnceWith("print");
219
+ expect(
220
+ sent.filter(
221
+ (message) =>
222
+ "method" in message && message.method === "textDocument/didChange",
223
+ ),
224
+ ).toEqual([
225
+ {
226
+ jsonrpc: "2.0",
227
+ method: "textDocument/didChange",
228
+ params: {
229
+ textDocument: { uri: "file:///__marimo_copilot__.py", version: 2 },
230
+ contentChanges: [{ text: "import math\n\nprint" }],
231
+ },
232
+ },
233
+ ]);
234
+ expect(requests()).toHaveLength(1);
235
+ respond("(latest)");
236
+ await vi.advanceTimersByTimeAsync(0);
237
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
238
+ "(latest)",
239
+ );
240
+ });
241
+
242
+ it("does not flush a cancelled callback after moving away and back before its deadline", async () => {
243
+ const flush = vi.spyOn(client, "flushDocumentChanges");
244
+ type("n");
245
+ await vi.advanceTimersByTimeAsync(149);
246
+ view.dispatch({ selection: { anchor: 0 } });
247
+ view.dispatch({ selection: { anchor: 4 } });
248
+ await vi.advanceTimersByTimeAsync(1000);
249
+ expect(flush).not.toHaveBeenCalled();
250
+ expect(getCodes).not.toHaveBeenCalled();
251
+ expect(sent).toEqual([]);
252
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
253
+ });
254
+
255
+ it("cancels on cursor movement and never shows an old reply", async () => {
256
+ type("n");
257
+ await vi.advanceTimersByTimeAsync(150);
258
+ view.dispatch({ selection: { anchor: 0 } });
259
+ await vi.advanceTimersByTimeAsync(0);
260
+ expect(sent.at(-1)).toEqual({
261
+ jsonrpc: "2.0",
262
+ method: "$/cancelRequest",
263
+ params: { id: requests()[0].id },
264
+ });
265
+ respond("stale");
266
+ await vi.advanceTimersByTimeAsync(0);
267
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
268
+ });
269
+
270
+ it("cancels an in-flight suggestion on typing before requesting the next version", async () => {
271
+ type("n");
272
+ await vi.advanceTimersByTimeAsync(150);
273
+ type("t");
274
+ await vi.advanceTimersByTimeAsync(0);
275
+ expect(sent.at(-1)).toEqual({
276
+ jsonrpc: "2.0",
277
+ method: "$/cancelRequest",
278
+ params: { id: requests()[0].id },
279
+ });
280
+ await vi.advanceTimersByTimeAsync(150);
281
+ respond("(new)");
282
+ await vi.advanceTimersByTimeAsync(0);
283
+ emit({
284
+ jsonrpc: "2.0",
285
+ id: requests()[0].id,
286
+ result: [{ insertText: "stale" }],
287
+ });
288
+ await vi.advanceTimersByTimeAsync(0);
289
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
290
+ "(new)",
291
+ );
292
+ });
293
+ it("does not display a response after focus is lost", async () => {
294
+ type("n");
295
+ await vi.advanceTimersByTimeAsync(150);
296
+ vi.spyOn(view, "hasFocus", "get").mockReturnValue(false);
297
+ respond("stale");
298
+ await vi.advanceTimersByTimeAsync(0);
299
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
300
+ });
301
+
302
+ it("passes cancellation through to custom provider HTTP requests", async () => {
303
+ const result = Promise.withResolvers<string>();
304
+ const post = vi.spyOn(API, "post").mockReturnValue(result.promise);
305
+ useCustomEditor();
306
+ type("n");
307
+ await vi.advanceTimersByTimeAsync(150);
308
+ expect(post).toHaveBeenCalledExactlyOnceWith(
309
+ "/ai/inline_completion",
310
+ { prefix: "prin", suffix: "", language: "python" },
311
+ { signal: expect.objectContaining({ aborted: false }) },
312
+ );
313
+ const signal = post.mock.calls[0][2]?.signal;
314
+ type("t");
315
+ expect(signal?.aborted).toBe(true);
316
+ result.resolve("stale");
317
+ await vi.advanceTimersByTimeAsync(0);
318
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
319
+ });
320
+ it.each(["HTTP failure", "AbortError"])(
321
+ "recovers on the next edit after custom provider %s",
322
+ async (failure) => {
323
+ useCustomEditor();
324
+ const error = vi
325
+ .spyOn(console, "error")
326
+ .mockImplementation(() => undefined);
327
+ const post = vi
328
+ .spyOn(API, "post")
329
+ .mockRejectedValueOnce(
330
+ failure === "AbortError"
331
+ ? new DOMException("aborted", "AbortError")
332
+ : new Error("HTTP 503"),
333
+ )
334
+ .mockResolvedValueOnce("(recovered)");
335
+ type("n");
336
+ await vi.advanceTimersByTimeAsync(150);
337
+ expect(post).toHaveBeenCalledTimes(1);
338
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
339
+ expect(error).toHaveBeenCalledTimes(failure === "AbortError" ? 0 : 1);
340
+ type("t");
341
+ await vi.advanceTimersByTimeAsync(150);
342
+ expect(post).toHaveBeenCalledTimes(2);
343
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
344
+ "(recovered)",
345
+ );
346
+ },
347
+ );
348
+
349
+ it.each(["empty document", "empty prefix"])(
350
+ "does not call the custom provider for an %s",
351
+ async (input) => {
352
+ useCustomEditor();
353
+ const post = vi.spyOn(API, "post").mockResolvedValue("unexpected");
354
+ view.dispatch({
355
+ changes: {
356
+ from: 0,
357
+ to: view.state.doc.length,
358
+ insert: input === "empty document" ? "" : "suffix",
359
+ },
360
+ selection: { anchor: 0 },
361
+ });
362
+ await vi.advanceTimersByTimeAsync(150);
363
+ expect(post).not.toHaveBeenCalled();
364
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
365
+ },
366
+ );
367
+
368
+ it("aborts custom provider HTTP work when its editor is destroyed", async () => {
369
+ useCustomEditor();
370
+ const result = Promise.withResolvers<string>();
371
+ const post = vi.spyOn(API, "post").mockReturnValue(result.promise);
372
+ const error = vi
373
+ .spyOn(console, "error")
374
+ .mockImplementation(() => undefined);
375
+ type("n");
376
+ await vi.advanceTimersByTimeAsync(150);
377
+ expect(post).toHaveBeenCalledTimes(1);
378
+ const signal = post.mock.calls[0][2]?.signal;
379
+ expect(signal?.aborted).toBe(false);
380
+ view.destroy();
381
+ expect(signal?.aborted).toBe(true);
382
+ result.reject(new Error("connection closed after destruction"));
383
+ await vi.advanceTimersByTimeAsync(150);
384
+ expect(post).toHaveBeenCalledTimes(1);
385
+ expect(error).not.toHaveBeenCalled();
386
+ });
387
+
388
+ it.each(["server error", "timeout"])(
389
+ "recovers on the next edit after a Copilot %s",
390
+ async (failure) => {
391
+ type("n");
392
+ await vi.advanceTimersByTimeAsync(150);
393
+ expect(requests()).toHaveLength(1);
394
+ const id = requests()[0].id;
395
+ if (failure === "timeout") {
396
+ await vi.advanceTimersByTimeAsync(10_000);
397
+ } else {
398
+ emit({
399
+ jsonrpc: "2.0",
400
+ id,
401
+ error: { code: -32603, message: "unavailable" },
402
+ });
403
+ await vi.advanceTimersByTimeAsync(0);
404
+ }
405
+ expect(
406
+ sent.filter(
407
+ (message) =>
408
+ "method" in message && message.method === "$/cancelRequest",
409
+ ),
410
+ ).toEqual(
411
+ failure === "timeout"
412
+ ? [{ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } }]
413
+ : [],
414
+ );
415
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toBeNull();
416
+ type("t");
417
+ await vi.advanceTimersByTimeAsync(150);
418
+ expect(requests()).toHaveLength(2);
419
+ respond("(recovered)");
420
+ await vi.advanceTimersByTimeAsync(0);
421
+ emit({ jsonrpc: "2.0", id, result: [{ insertText: "stale" }] });
422
+ await vi.advanceTimersByTimeAsync(0);
423
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
424
+ "(recovered)",
425
+ );
426
+ },
427
+ );
428
+
429
+ it("keeps the focused cell last when another cell changes during the debounce", async () => {
430
+ type("n");
431
+ await client.textDocumentDidChange({
432
+ textDocument: { uri: "file:///__marimo_copilot__.py", version: 1 },
433
+ contentChanges: [{ text: "other_cell = 42" }],
434
+ });
435
+ await vi.advanceTimersByTimeAsync(150);
436
+ expect(getCodes).toHaveBeenCalledExactlyOnceWith("prin");
437
+ expect(requests()[0].params).toMatchObject({
438
+ textDocument: { version: 3 },
439
+ position: { line: 2, character: 4 },
440
+ });
441
+ respond("t()");
442
+ await vi.advanceTimersByTimeAsync(0);
443
+ expect(view.dom.querySelector(".cm-inline-suggestion")).toHaveTextContent(
444
+ "t()",
445
+ );
446
+ });
447
+ });