@marimo-team/frontend 0.25.2 → 0.25.3-dev12

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 (178) hide show
  1. package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-DVOHpdeJ.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-CM13wrd8.js} +2 -2
  4. package/dist/assets/JsonOutput-Cx1iyibB.js +11 -0
  5. package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
  6. package/dist/assets/MarimoErrorOutput-s_5GOX8k.js +10 -0
  7. package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-DX6i99Op.js} +1 -1
  8. package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-BKoh0zYG.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-BpD_790A.js} +11 -11
  10. package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-B1BXCjex.js} +2 -2
  11. package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-CDFUc1uu.js} +5 -5
  12. package/dist/assets/ai-model-dropdown-i1vfGeVi.js +13 -0
  13. package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
  14. package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-AdV-QDD7.js} +1 -1
  15. package/dist/assets/{blob-D5nfNHOo.js → blob-BG8yoQmx.js} +1 -1
  16. package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-BKJ06H5F.js} +9 -9
  17. package/dist/assets/cell-link-8Auigi8P.js +1 -0
  18. package/dist/assets/{cells-BRkCaSsh.js → cells-C6YGYGdi.js} +56 -56
  19. package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-DhgI2MS9.js} +1 -1
  20. package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-Cau71Ygr.js} +2 -2
  21. package/dist/assets/{chat-ui-Df8Vcj6n.js → chat-ui-C5ql1Up-.js} +1 -1
  22. package/dist/assets/code-visibility-BJImdpDi.js +1 -0
  23. package/dist/assets/coerce-DWjFeXOY.js +1 -0
  24. package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-BZ_iNhtJ.js} +1 -1
  25. package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-DDC3kBet.js} +1 -1
  26. package/dist/assets/{common-Cu6CDbuw.js → common-Sl3uthjg.js} +1 -1
  27. package/dist/assets/{components-CGw16R_N.js → components-3cou6Mka.js} +1 -1
  28. package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
  29. package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
  30. package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
  31. package/dist/assets/config-DRIpOKqh.js +1 -0
  32. package/dist/assets/config-Dy28fDwn.js +1 -0
  33. package/dist/assets/{datasource-Cq6x_5ML.js → datasource-DfbNb83e.js} +1 -1
  34. package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
  35. package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
  36. package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-D7S8JIc2.js} +1 -1
  37. package/dist/assets/{download-DCJXy66H.js → download-C03FOElq.js} +1 -1
  38. package/dist/assets/edit-page-D_Rg0gQO.js +9 -0
  39. package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
  40. package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-CLnPDv7b.js} +1 -1
  41. package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-D2xrYLue.js} +1 -1
  42. package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
  43. package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-DzPunIqU.js} +2 -2
  44. package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
  45. package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
  46. package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-B4fRaQkI.js} +1 -1
  47. package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-B5MaM1JS.js} +1 -1
  48. package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-BefvdsM7.js} +1 -1
  49. package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-C3eaYlXg.js} +1 -1
  50. package/dist/assets/{home-page-DMHnsRxm.js → home-page-hmvKlc1w.js} +1 -1
  51. package/dist/assets/{hooks-93IwHd2u.js → hooks-DzML9nez.js} +1 -1
  52. package/dist/assets/index-6b4mf8Ai.css +2 -0
  53. package/dist/assets/index-ptK1gxmo.js +30 -0
  54. package/dist/assets/{layout-DmcNEu3P.js → layout-Ck1RoL1B.js} +5 -5
  55. package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-BRytaWaO.js} +1 -1
  56. package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-DX0Xrrfy.js} +1 -1
  57. package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
  58. package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-DlpTvPLa.js} +1 -1
  59. package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-B2WIpz-1.js} +1 -1
  60. package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-CI5ff9b-.js} +1 -1
  61. package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-BqSH5ttk.js} +1 -1
  62. package/dist/assets/panels-BCcZ_yLK.js +107 -0
  63. package/dist/assets/{process-output-D0KGq04R.js → process-output-f3C3PD0Z.js} +1 -1
  64. package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-MnMEoYW5.js} +1 -1
  65. package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-ggs4hAzC.js} +1 -1
  66. package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
  67. package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-PiV1eQk9.js} +1 -1
  68. package/dist/assets/run-page-AfZhtEh1.js +1 -0
  69. package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-CoL-p0MC.js} +1 -1
  70. package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
  71. package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-D2rWg4k8.js} +1 -1
  72. package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
  73. package/dist/assets/session-panel-CnANhcTo.js +1 -0
  74. package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-ClGHtEKQ.js} +1 -1
  75. package/dist/assets/state-CbjBtkdd.js +3 -0
  76. package/dist/assets/{state-XsPO8hNi.js → state-DtGB7xeJ.js} +1 -1
  77. package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
  78. package/dist/assets/{tracing-Di6pUmX8.js → tracing-Dz2rYOFL.js} +1 -1
  79. package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-DqQY9V91.js} +2 -2
  80. package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-DnUokmva.js} +1 -1
  81. package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-IN24thff.js} +1 -1
  82. package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
  83. package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-DmaFZ4oF.js} +1 -1
  84. package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-fQlM46Y2.js} +1 -1
  85. package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
  86. package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
  87. package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-DHt3s-ia.js} +1 -1
  88. package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-DSJPXE-j.js} +1 -1
  89. package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-DQyMF5gd.js} +1 -1
  90. package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
  91. package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
  92. package/dist/index.html +34 -35
  93. package/package.json +5 -2
  94. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  95. package/src/__tests__/host-imports.test.ts +100 -0
  96. package/src/__tests__/mount.test.ts +28 -8
  97. package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
  98. package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
  99. package/src/components/data-table/__tests__/export-actions.test.tsx +329 -6
  100. package/src/components/data-table/context-menu.tsx +125 -26
  101. package/src/components/data-table/date-filter-inputs.tsx +15 -42
  102. package/src/components/data-table/export-actions.tsx +192 -116
  103. package/src/components/data-table/schemas.ts +14 -36
  104. package/src/components/data-table/utils.ts +18 -0
  105. package/src/components/editor/Output.tsx +9 -2
  106. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  107. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  108. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  109. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  110. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  111. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
  112. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  113. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
  114. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  115. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
  116. package/src/components/editor/errors/send-error-report-button.tsx +69 -0
  117. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  118. package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
  119. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  120. package/src/components/editor/output/ansi-reduce.ts +6 -0
  121. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  122. package/src/core/attachments/state.ts +21 -0
  123. package/src/core/attachments/toast.ts +15 -0
  124. package/src/core/cells/__tests__/cells.test.ts +52 -0
  125. package/src/core/cells/__tests__/error-report.test.ts +139 -0
  126. package/src/core/cells/__tests__/session.test.ts +50 -1
  127. package/src/core/cells/error-report.ts +63 -0
  128. package/src/core/cells/session.ts +28 -1
  129. package/src/core/config/pair.ts +5 -3
  130. package/src/core/edit-app.tsx +2 -2
  131. package/src/core/host/AGENTS.md +6 -0
  132. package/src/core/host/__tests__/events.test.ts +46 -0
  133. package/src/core/host/__tests__/notebook-client.test.ts +191 -0
  134. package/src/core/host/__tests__/protocol.test.ts +214 -0
  135. package/src/core/host/connection.ts +81 -0
  136. package/src/core/host/events.ts +60 -0
  137. package/src/core/host/notebook-client.ts +226 -0
  138. package/src/core/host/protocol.ts +1943 -0
  139. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  140. package/src/core/islands/bootstrap.ts +1 -0
  141. package/src/core/run-app.tsx +2 -2
  142. package/src/core/runtime/__tests__/runtime.test.ts +55 -11
  143. package/src/core/runtime/runtime.ts +14 -1
  144. package/src/core/runtime/types.ts +2 -0
  145. package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
  146. package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
  147. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
  148. package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +352 -65
  149. package/src/core/websocket/connection-state.ts +70 -0
  150. package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
  151. package/src/core/websocket/transports/create.ts +31 -0
  152. package/src/core/websocket/transports/transport.ts +12 -23
  153. package/src/core/websocket/types.ts +9 -39
  154. package/src/core/websocket/useNotebookClient.ts +80 -0
  155. package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +67 -225
  156. package/src/mount.tsx +7 -3
  157. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
  158. package/src/utils/__tests__/dates.test.ts +116 -0
  159. package/src/utils/dates.ts +94 -0
  160. package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
  161. package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
  162. package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
  163. package/dist/assets/api-BeC_VSGv.js +0 -1
  164. package/dist/assets/cell-link-LSth_8hT.js +0 -1
  165. package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
  166. package/dist/assets/coerce-CpE6kUdX.js +0 -1
  167. package/dist/assets/config-BOKzA3tF.js +0 -1
  168. package/dist/assets/config-DX8Y2WbJ.js +0 -1
  169. package/dist/assets/edit-page-ChcSgpke.js +0 -9
  170. package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
  171. package/dist/assets/index-Bcdzex-3.css +0 -2
  172. package/dist/assets/index-s-M1OXjs.js +0 -29
  173. package/dist/assets/pair-CaQyK6En.js +0 -1
  174. package/dist/assets/panels-CxVG8-v2.js +0 -4
  175. package/dist/assets/run-page-DF3c5cDk.js +0 -1
  176. package/dist/assets/session-panel-VdDhEBa-.js +0 -1
  177. package/dist/assets/state-_Qai_QyX.js +0 -3
  178. package/src/core/websocket/useWebSocket.tsx +0 -88
@@ -41,7 +41,7 @@ import { viewStateAtom } from "./mode";
41
41
  import { useRequestClient } from "./network/requests";
42
42
  import { useFilename } from "./saving/filename";
43
43
  import { lastSavedNotebookAtom } from "./saving/state";
44
- import { useMarimoKernelConnection } from "./websocket/useMarimoKernelConnection";
44
+ import { useNotebookSession } from "./websocket/useNotebookSession";
45
45
 
46
46
  interface AppProps {
47
47
  /**
@@ -83,7 +83,7 @@ export const EditApp: React.FC<AppProps> = ({
83
83
  };
84
84
  }, []);
85
85
 
86
- const { connection, reconnect } = useMarimoKernelConnection({
86
+ const { connection, reconnect } = useNotebookSession({
87
87
  autoInstantiate: userConfig.runtime.auto_instantiate,
88
88
  setCells: (cells, layout) => {
89
89
  setCells(cells);
@@ -0,0 +1,6 @@
1
+ # Host core
2
+
3
+ Production code may import host modules, `jotai/vanilla`, `zod`, and
4
+ `@/utils/Logger`. Enforced by `marimo/host-imports`. Tests are exempt.
5
+
6
+ Define interfaces here and inject application behavior through adapters.
@@ -0,0 +1,46 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { z } from "zod";
5
+ import { parseNotebookEvent } from "../events";
6
+
7
+ describe("notebook messages", () => {
8
+ it("normalizes session and host envelopes for native marimo messages", () => {
9
+ const notification = {
10
+ op: "variables",
11
+ variables: [{ name: "x", value: Number.NaN }],
12
+ };
13
+ const host = { event: "marimo", data: { marimo: notification } };
14
+ expect(parseNotebookEvent({ data: notification })).toEqual(host);
15
+ expect(parseNotebookEvent({ ...host, id: "42" })).toEqual({
16
+ ...host,
17
+ id: "42",
18
+ });
19
+ });
20
+
21
+ it("retains unknown event cursors without interpreting their payloads", () => {
22
+ expect(
23
+ parseNotebookEvent({ event: "future", id: "42", data: null }),
24
+ ).toEqual({ event: "unknown", name: "future", id: "42" });
25
+ });
26
+
27
+ it("decodes known events outside the client's subscription filters", () => {
28
+ const event = {
29
+ event: "run.removed",
30
+ id: "42",
31
+ data: { id: "r1", time: "2026-10-19T12:00:00Z" },
32
+ };
33
+ expect(parseNotebookEvent(event)).toEqual(event);
34
+ });
35
+
36
+ it.each([
37
+ { event: "cell", id: "42", data: { cell: { id: "c1" } } },
38
+ { event: "run.removed", id: "42", data: {} },
39
+ { event: "ready", data: {} },
40
+ { event: "marimo", data: {} },
41
+ {},
42
+ null,
43
+ ])("rejects malformed known messages: %j", (message) => {
44
+ expect(() => parseNotebookEvent(message)).toThrow(z.ZodError);
45
+ });
46
+ });
@@ -0,0 +1,191 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { createStore } from "jotai";
6
+ import { ConnectionState } from "../../websocket/types";
7
+ import { Deferred } from "@/utils/Deferred";
8
+ import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
9
+ import { classifyCloseEvent } from "../../websocket/connection-state";
10
+ import { createNotebookClient } from "../notebook-client";
11
+
12
+ function setup() {
13
+ const events = new EventTarget();
14
+ const healthy = new Deferred<void>();
15
+ const transport = {
16
+ readyState: WebSocket.CLOSED,
17
+ reconnect: vi.fn(),
18
+ close: vi.fn(() => events.dispatchEvent(new CloseEvent("close"))),
19
+ send: vi.fn(),
20
+ addEventListener: events.addEventListener.bind(events),
21
+ removeEventListener: events.removeEventListener.bind(events),
22
+ };
23
+ const onNotification = vi.fn();
24
+ const checkHealth = vi.fn(async () => true);
25
+ const store = createStore();
26
+ const controller = createNotebookClient(store);
27
+ const client = controller.client;
28
+ const connect = (signal: AbortSignal) =>
29
+ controller.connect({
30
+ createTransport: () => transport,
31
+ url: () => "ws://localhost/ws",
32
+ messageFormat: "host",
33
+ signal,
34
+ waitUntilReady: () => healthy.promise,
35
+ checkHealth,
36
+ onNotification: (notification) => onNotification({ data: notification }),
37
+ onMessageError: vi.fn(),
38
+ decodeJson: jsonParseWithSpecialChar,
39
+ classifyClose: classifyCloseEvent,
40
+ });
41
+ return {
42
+ events,
43
+ healthy,
44
+ transport,
45
+ client,
46
+ connect,
47
+ onNotification,
48
+ store,
49
+ checkHealth,
50
+ };
51
+ }
52
+
53
+ describe("NotebookClient lifetime", () => {
54
+ it.each(["{broken", JSON.stringify({ event: "ready", data: {} })])(
55
+ "stops consuming a malformed host stream: %s",
56
+ (data) => {
57
+ const { connect, events, client, store, transport, onNotification } =
58
+ setup();
59
+ const lifetime = new AbortController();
60
+ connect(lifetime.signal);
61
+ events.dispatchEvent(new Event("open"));
62
+ events.dispatchEvent(new MessageEvent("message", { data }));
63
+ events.dispatchEvent(
64
+ new MessageEvent("message", {
65
+ data: JSON.stringify({
66
+ event: "marimo",
67
+ data: { marimo: { op: "reconnected" } },
68
+ }),
69
+ }),
70
+ );
71
+ expect(store.get(client.connectionAtom)).toEqual({
72
+ state: ConnectionState.CLOSED,
73
+ code: "KERNEL_DISCONNECTED",
74
+ reason: "Failed to decode notebook message",
75
+ });
76
+ expect(transport.close).toHaveBeenCalledOnce();
77
+ expect(onNotification).not.toHaveBeenCalled();
78
+ lifetime.abort();
79
+ },
80
+ );
81
+
82
+ it.each(["MARIMO_UNAUTHORIZED", "MARIMO_TRANSPORT_EXHAUSTED"])(
83
+ "stops automatic recovery after %s but permits an explicit reconnect",
84
+ async (reason) => {
85
+ const {
86
+ connect,
87
+ client,
88
+ store,
89
+ events,
90
+ transport,
91
+ healthy,
92
+ checkHealth,
93
+ } = setup();
94
+ const lifetime = new AbortController();
95
+ connect(lifetime.signal);
96
+ healthy.resolve();
97
+ await healthy.promise;
98
+ events.dispatchEvent(new CloseEvent("close", { reason }));
99
+ events.dispatchEvent(new Event("error"));
100
+ expect(store.get(client.connectionAtom).state).toBe(
101
+ ConnectionState.CLOSED,
102
+ );
103
+ expect(transport.reconnect).toHaveBeenCalledOnce();
104
+ await client.reconnect();
105
+ expect(checkHealth).toHaveBeenCalledOnce();
106
+ expect(transport.reconnect).toHaveBeenCalledTimes(2);
107
+ lifetime.abort();
108
+ },
109
+ );
110
+
111
+ it("only resets the automatic retry budget after an open event", () => {
112
+ const { connect, events, transport } = setup();
113
+ const lifetime = new AbortController();
114
+ connect(lifetime.signal);
115
+ events.dispatchEvent(new CloseEvent("close"));
116
+ events.dispatchEvent(new Event("error"));
117
+ events.dispatchEvent(new CloseEvent("close"));
118
+ expect(transport.reconnect).toHaveBeenCalledOnce();
119
+ events.dispatchEvent(new Event("open"));
120
+ events.dispatchEvent(new CloseEvent("close"));
121
+ expect(transport.reconnect).toHaveBeenCalledTimes(2);
122
+ lifetime.abort();
123
+ });
124
+
125
+ it("does not revive an aborted lifetime when a manual health check finishes", async () => {
126
+ const { connect, client, transport, checkHealth } = setup();
127
+ const health = new Deferred<boolean>();
128
+ checkHealth.mockReturnValue(health.promise);
129
+ const lifetime = new AbortController();
130
+ connect(lifetime.signal);
131
+ const attempt = client.reconnect();
132
+ lifetime.abort();
133
+ health.resolve(true);
134
+ await attempt;
135
+ expect(transport.reconnect).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("does not connect after abort, even when the health check finishes", async () => {
139
+ const { client, connect, healthy, transport } = setup();
140
+ const controller = new AbortController();
141
+ connect(controller.signal);
142
+ controller.abort();
143
+ healthy.resolve();
144
+ await healthy.promise;
145
+ client.reconnect();
146
+ expect(transport.reconnect).not.toHaveBeenCalled();
147
+ expect(transport.close).toHaveBeenCalledOnce();
148
+ });
149
+
150
+ it("detaches listeners before closing so cleanup cannot trigger a session retry", () => {
151
+ const { connect, client, store, events, transport, onNotification } =
152
+ setup();
153
+ const controller = new AbortController();
154
+ connect(controller.signal);
155
+ controller.abort();
156
+ events.dispatchEvent(
157
+ new MessageEvent("message", {
158
+ data: JSON.stringify({ data: { op: "reconnected" } }),
159
+ }),
160
+ );
161
+ expect(transport.reconnect).not.toHaveBeenCalled();
162
+ expect(store.get(client.connectionAtom)).toEqual({
163
+ state: ConnectionState.NOT_STARTED,
164
+ });
165
+ expect(onNotification).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("can reconnect after effect cleanup without reviving the earlier client attempt", async () => {
169
+ const { connect, healthy, transport, events, onNotification } = setup();
170
+ const first = new AbortController();
171
+ connect(first.signal);
172
+ first.abort();
173
+ const second = new AbortController();
174
+ connect(second.signal);
175
+ healthy.resolve();
176
+ await healthy.promise;
177
+ expect(transport.reconnect).toHaveBeenCalledOnce();
178
+ events.dispatchEvent(
179
+ new MessageEvent("message", {
180
+ data: JSON.stringify({
181
+ event: "marimo",
182
+ data: { marimo: { op: "reconnected" } },
183
+ }),
184
+ }),
185
+ );
186
+ expect(onNotification).toHaveBeenCalledExactlyOnceWith({
187
+ data: { op: "reconnected" },
188
+ });
189
+ second.abort();
190
+ });
191
+ });
@@ -0,0 +1,214 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import NodeFs from "node:fs";
4
+ import NodeUrl from "node:url";
5
+ import { describe, expect, expectTypeOf, it } from "vitest";
6
+ import * as Protocol from "../protocol";
7
+
8
+ const fixtures = new NodeUrl.URL(
9
+ "../../../../../protocols/host/fixtures/",
10
+ import.meta.url,
11
+ );
12
+
13
+ function readFixture(name: string): unknown {
14
+ return JSON.parse(
15
+ NodeFs.readFileSync(new NodeUrl.URL(name, fixtures), "utf8"),
16
+ );
17
+ }
18
+
19
+ const records = {
20
+ "record.json": Protocol.HostRecord,
21
+ "cell.json": Protocol.Cell,
22
+ "analysis.json": Protocol.Analysis,
23
+ "diagnostic.json": Protocol.Error,
24
+ "edit.json": Protocol.EditRequest,
25
+ "edit-conflict-request.json": Protocol.EditRequest,
26
+ "edit-response.json": Protocol.EditResponse,
27
+ "execution-restored.json": Protocol.Execution,
28
+ "run-cells-request.json": Protocol.RunRequest,
29
+ };
30
+
31
+ describe("host protocol fixtures", () => {
32
+ it.each(Object.entries(records))("parses %s unchanged", (name, schema) => {
33
+ const input = readFixture(name);
34
+ const original = structuredClone(input);
35
+ expect(schema.parse(input)).toEqual(original);
36
+ expect(input).toEqual(original);
37
+ });
38
+
39
+ it.each(NodeFs.readdirSync(new NodeUrl.URL("problems/", fixtures)))(
40
+ "parses problem %s",
41
+ (name) => {
42
+ const input = readFixture(`problems/${name}`);
43
+ expect(Protocol.Problem.parse(input)).toEqual(input);
44
+ },
45
+ );
46
+
47
+ it.each(NodeFs.readdirSync(fixtures).filter((name) => name.endsWith(".sse")))(
48
+ "parses events in %s unchanged",
49
+ (name) => {
50
+ const stream = NodeFs.readFileSync(
51
+ new NodeUrl.URL(name, fixtures),
52
+ "utf8",
53
+ );
54
+ let count = 0;
55
+ for (const frame of stream.split(/\r?\n\r?\n/)) {
56
+ const event = /^event: (.+)$/m.exec(frame)?.[1];
57
+ if (!event && !/^data:/m.test(frame)) {
58
+ continue;
59
+ }
60
+ const data = frame
61
+ .split(/\r?\n/)
62
+ .filter((line) => line.startsWith("data:"))
63
+ .map((line) => line.slice(5).trimStart())
64
+ .join("\n");
65
+ const id = /^id: (.+)$/m.exec(frame)?.[1];
66
+ const payload: unknown = JSON.parse(data);
67
+ const input = { event, data: payload, ...(id ? { id } : {}) };
68
+ const original = structuredClone(input);
69
+ expect(Protocol.Event.parse(input)).toEqual(original);
70
+ expect(input).toEqual(original);
71
+ count += 1;
72
+ }
73
+ expect(count).toBeGreaterThan(0);
74
+ },
75
+ );
76
+ });
77
+
78
+ describe("host protocol bindings", () => {
79
+ it("preserves native marimo payloads without interpreting them", () => {
80
+ expectTypeOf<Protocol.MarimoEvent["data"]["marimo"]>().toBeUnknown();
81
+ const input = {
82
+ event: "marimo",
83
+ id: "42",
84
+ data: {
85
+ marimo: {
86
+ future_payload: { value: Number.NaN },
87
+ },
88
+ },
89
+ };
90
+ expect(Protocol.Event.parse(input)).toEqual(input);
91
+ });
92
+
93
+ it("accepts additive fields and preserves arbitrary notebook settings", () => {
94
+ const cell = Protocol.Cell.parse(readFixture("cell.json"));
95
+ const input = {
96
+ ...cell,
97
+ future: { enabled: true },
98
+ config: { ...cell.config, future: 42 },
99
+ };
100
+ const original = structuredClone(input);
101
+ expect(Protocol.Cell.parse(input)).toEqual(cell);
102
+ expect(input).toEqual(original);
103
+
104
+ const { document } = Protocol.EditResponse.parse(
105
+ readFixture("edit-response.json"),
106
+ );
107
+ const configured = {
108
+ ...document,
109
+ app_config: { extension: { values: [1, null, false] } },
110
+ };
111
+ expect(Protocol.Document.parse(configured)).toEqual(configured);
112
+ });
113
+
114
+ it("applies the defaults declared for cell inputs", () => {
115
+ expect(
116
+ Protocol.CellInput.parse({ id: "c3", code: "x = 1", config: {} }),
117
+ ).toEqual({
118
+ id: "c3",
119
+ code: "x = 1",
120
+ name: "_",
121
+ config: {
122
+ column: null,
123
+ disabled: false,
124
+ hide_code: false,
125
+ expand_output: false,
126
+ },
127
+ });
128
+ });
129
+
130
+ it.each(["run_id", "runtime_id", "caused_by"] as const)(
131
+ "requires the nullable execution field %s",
132
+ (field) => {
133
+ const execution = Protocol.Execution.parse(
134
+ readFixture("execution-restored.json"),
135
+ );
136
+ const missing = Object.fromEntries(
137
+ Object.entries(execution).filter(([key]) => key !== field),
138
+ );
139
+ expect(Protocol.Execution.safeParse(missing).success).toBe(false);
140
+ expect(
141
+ Protocol.Execution.safeParse({ ...execution, [field]: null }).success,
142
+ ).toBe(true);
143
+ },
144
+ );
145
+
146
+ it("preserves snapshot metadata omissions and validates live timestamps", () => {
147
+ const cell = Protocol.Cell.parse(readFixture("cell.json"));
148
+ expect(Protocol.CellMessage.parse({ cell })).toEqual({ cell });
149
+ const message = { cell, time: "2026-01-01T12:00:00+05:30" };
150
+ expect(Protocol.CellMessage.parse(message)).toEqual(message);
151
+ for (const time of [
152
+ null,
153
+ "yesterday",
154
+ "2026-02-30T12:00:00Z",
155
+ "2026-01-01T12:00:00",
156
+ ]) {
157
+ expect(Protocol.CellMessage.safeParse({ cell, time }).success).toBe(
158
+ false,
159
+ );
160
+ }
161
+ });
162
+
163
+ it("rejects invalid cell fields without coercing their values", () => {
164
+ const cell = Protocol.Cell.parse(readFixture("cell.json"));
165
+ for (const patch of [
166
+ { id: "" },
167
+ { code: 42 },
168
+ { etag: 42 },
169
+ { version: "2" },
170
+ { version: 0 },
171
+ { version: 1.5 },
172
+ { config: { ...cell.config, disabled: "false" } },
173
+ ]) {
174
+ expect(Protocol.Cell.safeParse({ ...cell, ...patch }).success).toBe(
175
+ false,
176
+ );
177
+ }
178
+ const result = Protocol.Cell.safeParse({ ...cell, code: 42 });
179
+ expect(result.success).toBe(false);
180
+ if (result.success) {
181
+ throw new Error("Expected an invalid cell");
182
+ }
183
+ expect(result.error.issues).toEqual([
184
+ expect.objectContaining({ path: ["code"], code: "invalid_type" }),
185
+ ]);
186
+ });
187
+
188
+ it("requires the fields of the selected change kind", () => {
189
+ const change = { kind: "delete_cell", id: "c3", base_etag: "*" };
190
+ expect(Protocol.Change.parse(change)).toEqual(change);
191
+ expect(
192
+ Protocol.Change.safeParse({ kind: "delete_cell", id: "c3" }).success,
193
+ ).toBe(false);
194
+ expect(
195
+ Protocol.Change.safeParse({ ...change, kind: "unknown" }).success,
196
+ ).toBe(false);
197
+ });
198
+
199
+ it("requires custom payloads while allowing any JSON value", () => {
200
+ for (const data of [
201
+ null,
202
+ false,
203
+ 42,
204
+ "text",
205
+ { nested: [1, null, false] },
206
+ ]) {
207
+ const custom = { type: "example.event", data };
208
+ expect(Protocol.Custom.parse(custom)).toEqual(custom);
209
+ }
210
+ expect(Protocol.Custom.safeParse({ type: "example.event" }).success).toBe(
211
+ false,
212
+ );
213
+ });
214
+ });
@@ -0,0 +1,81 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ export const ConnectionState = {
4
+ NOT_STARTED: "NOT_STARTED",
5
+ CONNECTING: "CONNECTING",
6
+ OPEN: "OPEN",
7
+ CLOSING: "CLOSING",
8
+ CLOSED: "CLOSED",
9
+ } as const;
10
+
11
+ export type ConnectionState =
12
+ (typeof ConnectionState)[keyof typeof ConnectionState];
13
+
14
+ export const ConnectionClosedReason = {
15
+ KERNEL_DISCONNECTED: "KERNEL_DISCONNECTED",
16
+ KERNEL_STARTUP_ERROR: "KERNEL_STARTUP_ERROR",
17
+ } as const;
18
+
19
+ export type ConnectionClosedReason =
20
+ (typeof ConnectionClosedReason)[keyof typeof ConnectionClosedReason];
21
+
22
+ export type ConnectionStatus =
23
+ | {
24
+ state: typeof ConnectionState.CLOSED;
25
+ code: ConnectionClosedReason;
26
+ reason: string;
27
+ }
28
+ | {
29
+ state: typeof ConnectionState.CONNECTING;
30
+ }
31
+ | {
32
+ state:
33
+ | typeof ConnectionState.OPEN
34
+ | typeof ConnectionState.CLOSING
35
+ | typeof ConnectionState.NOT_STARTED;
36
+ };
37
+
38
+ /** The recovery policy for a closed connection. */
39
+ export interface CloseDecision {
40
+ kind: "terminal" | "gave-up" | "retry";
41
+ status: ConnectionStatus;
42
+ }
43
+
44
+ export type ConnectionEvent = "open" | "close" | "message" | "error";
45
+
46
+ export interface IConnectionTransportMap {
47
+ open: WebSocketEventMap["open"];
48
+ message: WebSocketEventMap["message"];
49
+ close: WebSocketEventMap["close"];
50
+ error: WebSocketEventMap["error"];
51
+ }
52
+
53
+ export type ConnectionTransportCallback<T extends ConnectionEvent> = (
54
+ data: IConnectionTransportMap[T],
55
+ ) => void;
56
+
57
+ /** Transport readiness, using the WebSocket readyState values. */
58
+ export const TransportReadyState = {
59
+ CONNECTING: 0,
60
+ OPEN: 1,
61
+ CLOSING: 2,
62
+ CLOSED: 3,
63
+ } as const;
64
+
65
+ export type TransportReadyState =
66
+ (typeof TransportReadyState)[keyof typeof TransportReadyState];
67
+
68
+ /** A reconnectable source of notebook messages. */
69
+ export interface NotebookTransport {
70
+ reconnect(code?: number | undefined, reason?: string | undefined): void;
71
+ close(): void;
72
+ addEventListener<T extends ConnectionEvent>(
73
+ event: T,
74
+ callback: ConnectionTransportCallback<T>,
75
+ ): void;
76
+ removeEventListener<T extends ConnectionEvent>(
77
+ event: T,
78
+ callback: ConnectionTransportCallback<T>,
79
+ ): void;
80
+ readyState: TransportReadyState;
81
+ }
@@ -0,0 +1,60 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { z } from "zod";
4
+ import * as Protocol from "./protocol";
5
+
6
+ /**
7
+ * Notebook stream events, including native marimo notifications.
8
+ *
9
+ * Known events follow the generated protocol. Unknown events retain their name
10
+ * and cursor so clients can skip them without losing their place in the stream.
11
+ */
12
+
13
+ /** Event kinds requested by the client; removals and controls are implicit. */
14
+ export const NOTEBOOK_EVENT_TYPES = [
15
+ "notebook",
16
+ "document",
17
+ "cell",
18
+ "marimo",
19
+ ] as const satisfies readonly (Protocol.ObjectType | "marimo")[];
20
+
21
+ const envelope = z.object({
22
+ event: z.string().min(1),
23
+ data: z.unknown(),
24
+ id: z.string().min(1).optional(),
25
+ });
26
+
27
+ const knownNames = new Set<string>(
28
+ Protocol.Event.options.flatMap((schema) => schema.shape.event.options),
29
+ );
30
+
31
+ /** A decoded event, or the name and cursor of an unrecognized event. */
32
+ export type NotebookEvent =
33
+ | Protocol.EventOutput
34
+ | {
35
+ readonly event: "unknown";
36
+ readonly name: string;
37
+ readonly id?: string;
38
+ };
39
+
40
+ const sessionEnvelope = z.object({ data: Protocol.MarimoNotification });
41
+
42
+ /**
43
+ * Decode a host event or a legacy `{ data: notification }` session message.
44
+ *
45
+ * Throws if the envelope or a known event is malformed. Native notification
46
+ * payloads are passed through for the session handler to interpret.
47
+ */
48
+ export function parseNotebookEvent(value: unknown): NotebookEvent {
49
+ if (typeof value === "object" && value !== null && !("event" in value)) {
50
+ return {
51
+ event: "marimo",
52
+ data: { marimo: sessionEnvelope.parse(value).data },
53
+ };
54
+ }
55
+ const parsed = envelope.parse(value);
56
+ if (knownNames.has(parsed.event)) {
57
+ return Protocol.Event.parse(parsed);
58
+ }
59
+ return { event: "unknown", name: parsed.event, id: parsed.id };
60
+ }