jupyterlab-codex-sidebar 0.1.4 → 0.1.6

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 (153) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/unit-tests.yml +27 -0
  3. package/.jupyterlab-playwright.log +0 -0
  4. package/README.md +83 -9
  5. package/docs/images/codex-sidebar-screenshot.png +0 -0
  6. package/jupyterlab_codex/handlers.py +938 -297
  7. package/jupyterlab_codex/labextension/package.json +13 -3
  8. package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
  9. package/jupyterlab_codex/labextension/static/737.e7de3ad9dd6ded798340.js +1 -0
  10. package/jupyterlab_codex/labextension/static/remoteEntry.6ef5e7167763a316c000.js +1 -0
  11. package/jupyterlab_codex/protocol.py +297 -0
  12. package/jupyterlab_codex/runner.py +58 -15
  13. package/jupyterlab_codex/sessions.py +582 -97
  14. package/lib/codexChat.d.ts +13 -0
  15. package/lib/codexChat.js +2506 -0
  16. package/lib/codexChat.js.map +1 -0
  17. package/lib/codexChatAttachmentDedup.d.ts +10 -0
  18. package/lib/codexChatAttachmentDedup.js +35 -0
  19. package/lib/codexChatAttachmentDedup.js.map +1 -0
  20. package/lib/codexChatAttachmentLimit.d.ts +18 -0
  21. package/lib/codexChatAttachmentLimit.js +50 -0
  22. package/lib/codexChatAttachmentLimit.js.map +1 -0
  23. package/lib/codexChatAttachmentState.d.ts +15 -0
  24. package/lib/codexChatAttachmentState.js +16 -0
  25. package/lib/codexChatAttachmentState.js.map +1 -0
  26. package/lib/codexChatDocumentUtils.d.ts +70 -0
  27. package/lib/codexChatDocumentUtils.js +506 -0
  28. package/lib/codexChatDocumentUtils.js.map +1 -0
  29. package/lib/codexChatFormatting.d.ts +11 -0
  30. package/lib/codexChatFormatting.js +83 -0
  31. package/lib/codexChatFormatting.js.map +1 -0
  32. package/lib/codexChatNotice.d.ts +3 -0
  33. package/lib/codexChatNotice.js +74 -0
  34. package/lib/codexChatNotice.js.map +1 -0
  35. package/lib/codexChatPersistence.d.ts +35 -0
  36. package/lib/codexChatPersistence.js +158 -0
  37. package/lib/codexChatPersistence.js.map +1 -0
  38. package/lib/codexChatPrimitives.d.ts +44 -0
  39. package/lib/codexChatPrimitives.js +156 -0
  40. package/lib/codexChatPrimitives.js.map +1 -0
  41. package/lib/codexChatRender.d.ts +24 -0
  42. package/lib/codexChatRender.js +293 -0
  43. package/lib/codexChatRender.js.map +1 -0
  44. package/lib/codexChatSessionFactory.d.ts +15 -0
  45. package/lib/codexChatSessionFactory.js +45 -0
  46. package/lib/codexChatSessionFactory.js.map +1 -0
  47. package/lib/codexChatSessionKey.d.ts +3 -0
  48. package/lib/codexChatSessionKey.js +14 -0
  49. package/lib/codexChatSessionKey.js.map +1 -0
  50. package/lib/codexChatStorage.d.ts +4 -0
  51. package/lib/codexChatStorage.js +37 -0
  52. package/lib/codexChatStorage.js.map +1 -0
  53. package/lib/codexSessionResolver.d.ts +12 -0
  54. package/lib/codexSessionResolver.js +38 -0
  55. package/lib/codexSessionResolver.js.map +1 -0
  56. package/lib/handlers/activitySummarizer.d.ts +15 -0
  57. package/lib/handlers/activitySummarizer.js +327 -0
  58. package/lib/handlers/activitySummarizer.js.map +1 -0
  59. package/lib/handlers/codexMessageTypes.d.ts +30 -0
  60. package/lib/handlers/codexMessageTypes.js +2 -0
  61. package/lib/handlers/codexMessageTypes.js.map +1 -0
  62. package/lib/handlers/codexMessageUtils.d.ts +46 -0
  63. package/lib/handlers/codexMessageUtils.js +144 -0
  64. package/lib/handlers/codexMessageUtils.js.map +1 -0
  65. package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
  66. package/lib/handlers/handleCodexSocketMessage.js +78 -0
  67. package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
  68. package/lib/handlers/sessionSyncHandler.d.ts +34 -0
  69. package/lib/handlers/sessionSyncHandler.js +181 -0
  70. package/lib/handlers/sessionSyncHandler.js.map +1 -0
  71. package/lib/hooks/useCodexSocket.d.ts +15 -0
  72. package/lib/hooks/useCodexSocket.js +84 -0
  73. package/lib/hooks/useCodexSocket.js.map +1 -0
  74. package/lib/index.js +1 -1
  75. package/lib/index.js.map +1 -1
  76. package/lib/panel.d.ts +1 -11
  77. package/lib/panel.js +1 -2815
  78. package/lib/panel.js.map +1 -1
  79. package/lib/protocol.d.ts +235 -0
  80. package/lib/protocol.js +278 -0
  81. package/lib/protocol.js.map +1 -0
  82. package/package.json +13 -3
  83. package/playwright.config.cjs +27 -0
  84. package/playwright.unit.config.cjs +19 -0
  85. package/pyproject.toml +1 -1
  86. package/release.sh +52 -14
  87. package/scripts/run_playwright_e2e.sh +96 -0
  88. package/scripts/run_playwright_freeze_repro.sh +58 -0
  89. package/scripts/run_playwright_queue_repro.sh +60 -0
  90. package/scripts/run_playwright_repro.sh +55 -0
  91. package/src/codexChat.tsx +3914 -0
  92. package/src/codexChatAttachmentDedup.ts +47 -0
  93. package/src/codexChatAttachmentLimit.ts +81 -0
  94. package/src/codexChatAttachmentState.ts +37 -0
  95. package/src/codexChatDocumentUtils.ts +644 -0
  96. package/src/codexChatFormatting.ts +94 -0
  97. package/src/codexChatNotice.ts +95 -0
  98. package/src/codexChatPersistence.ts +191 -0
  99. package/src/codexChatPrimitives.tsx +446 -0
  100. package/src/codexChatRender.tsx +376 -0
  101. package/src/codexChatSessionFactory.ts +79 -0
  102. package/src/codexChatSessionKey.ts +16 -0
  103. package/src/codexChatStorage.ts +36 -0
  104. package/src/codexSessionResolver.ts +56 -0
  105. package/src/handlers/activitySummarizer.ts +369 -0
  106. package/src/handlers/codexMessageTypes.ts +34 -0
  107. package/src/handlers/codexMessageUtils.ts +217 -0
  108. package/src/handlers/handleCodexSocketMessage.ts +204 -0
  109. package/src/handlers/sessionSyncHandler.ts +308 -0
  110. package/src/hooks/useCodexSocket.ts +109 -0
  111. package/src/index.ts +1 -1
  112. package/src/panel.tsx +1 -4184
  113. package/src/protocol.ts +582 -0
  114. package/style/index.css +480 -11
  115. package/test-results/.last-run.json +4 -0
  116. package/test.py +0 -0
  117. package/tests/e2e/cell-output-error-tail.spec.js +156 -0
  118. package/tests/e2e/codex-ui-test-helpers.js +138 -0
  119. package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
  120. package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
  121. package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
  122. package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
  123. package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
  124. package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
  125. package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
  126. package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
  127. package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
  128. package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
  129. package/tests/e2e/mock-codex-cli-flood.py +127 -0
  130. package/tests/e2e/mock-codex-cli-prompt-echo.py +88 -0
  131. package/tests/e2e/mock-codex-cli.py +95 -0
  132. package/tests/e2e/queue-multitab-repro.spec.js +189 -0
  133. package/tests/test_handlers.py +116 -0
  134. package/tests/test_protocol.py +169 -0
  135. package/tests/test_session_store_limits.py +50 -0
  136. package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
  137. package/tests/unit/codexChatAttachmentLimit.spec.ts +57 -0
  138. package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
  139. package/tests/unit/codexChatDocumentUtils.spec.ts +63 -0
  140. package/tests/unit/codexChatLimit.spec.ts +18 -0
  141. package/tests/unit/codexChatNotice.spec.ts +45 -0
  142. package/tests/unit/codexChatPersistence.spec.ts +199 -0
  143. package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
  144. package/tests/unit/codexChatSessionKey.spec.ts +18 -0
  145. package/tests/unit/codexMessageUtils.spec.ts +89 -0
  146. package/tests/unit/codexSessionResolver.spec.ts +92 -0
  147. package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
  148. package/tsconfig.tsbuildinfo +1 -1
  149. package/webpack.config.js +6 -0
  150. package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
  151. package/jupyterlab_codex/labextension/static/972.8e856719e40acc1ef4cb.js +0 -1
  152. package/jupyterlab_codex/labextension/static/remoteEntry.a2982f776a1f0f515640.js +0 -1
  153. /package/jupyterlab_codex/labextension/static/{504.335f3447c84ba3d74517.js.LICENSE.txt → 525.224526d045c727069de6.js.LICENSE.txt} +0 -0
@@ -0,0 +1,189 @@
1
+ const path = require('path');
2
+ const { test, expect } = require('@playwright/test');
3
+
4
+ const NOTEBOOKS = [
5
+ { path: 'tests/e2e/fixtures/notebooks/tab1.ipynb', name: 'tab1.ipynb' },
6
+ { path: 'tests/e2e/fixtures/notebooks/tab2.ipynb', name: 'tab2.ipynb' },
7
+ { path: 'tests/e2e/fixtures/notebooks/tab3.ipynb', name: 'tab3.ipynb' }
8
+ ];
9
+
10
+ function escapeRegExp(value) {
11
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ }
13
+
14
+ function buildInitialNotebookUrl(baseLabUrl, notebookPath) {
15
+ const parsed = new URL(baseLabUrl);
16
+ const basePath = parsed.pathname.replace(/\/$/, '');
17
+ parsed.pathname = `${basePath}/tree/${notebookPath}`;
18
+ parsed.searchParams.set('reset', '1');
19
+ return parsed.toString();
20
+ }
21
+
22
+ async function dismissBlockingDialogs(page) {
23
+ for (let attempt = 0; attempt < 8; attempt += 1) {
24
+ const dialog = page.locator('dialog.jp-Dialog, [role="dialog"].jp-Dialog, [role="dialog"]').first();
25
+ if (!(await dialog.count()) || !(await dialog.isVisible())) {
26
+ return;
27
+ }
28
+
29
+ const noKernelButton = page.getByRole('button', { name: /^No Kernel$/ }).first();
30
+ if ((await noKernelButton.count()) > 0 && (await noKernelButton.isVisible())) {
31
+ await noKernelButton.click();
32
+ await page.waitForTimeout(150);
33
+ continue;
34
+ }
35
+
36
+ const selectKernelButton = page.getByRole('button', { name: /^Select Kernel$/ }).first();
37
+ if ((await selectKernelButton.count()) > 0 && (await selectKernelButton.isVisible())) {
38
+ await selectKernelButton.click();
39
+ await page.waitForTimeout(150);
40
+ continue;
41
+ }
42
+
43
+ await page.keyboard.press('Escape');
44
+ await page.waitForTimeout(150);
45
+ }
46
+ }
47
+
48
+ async function ensureFileBrowserVisible(page) {
49
+ await dismissBlockingDialogs(page);
50
+ const fileBrowserTab = page.getByRole('tab', { name: /File Browser/i }).first();
51
+ if (await fileBrowserTab.count()) {
52
+ const isActiveBefore = await fileBrowserTab.evaluate(
53
+ element =>
54
+ element.getAttribute('aria-selected') === 'true' ||
55
+ (typeof element.className === 'string' && element.className.includes('lm-mod-current'))
56
+ );
57
+ if (!isActiveBefore) {
58
+ await fileBrowserTab.click();
59
+ }
60
+ const isActiveAfter = await fileBrowserTab.evaluate(
61
+ element =>
62
+ element.getAttribute('aria-selected') === 'true' ||
63
+ (typeof element.className === 'string' && element.className.includes('lm-mod-current'))
64
+ );
65
+ if (!isActiveAfter) {
66
+ await fileBrowserTab.click();
67
+ }
68
+ }
69
+ await expect(page.locator('.jp-BreadCrumbs-home')).toBeVisible({ timeout: 10000 });
70
+ }
71
+
72
+ async function openNotebookFromFileBrowser(page, notebookName) {
73
+ await ensureFileBrowserVisible(page);
74
+ let item = page
75
+ .getByRole('listitem', { name: new RegExp(`\\b${escapeRegExp(notebookName)}\\b`, 'i') })
76
+ .first();
77
+ if (!(await item.count())) {
78
+ item = page.locator('.jp-DirListing-item', { hasText: notebookName }).first();
79
+ }
80
+ if (!(await item.count())) {
81
+ item = page.locator('.jp-FileBrowser').getByText(notebookName, { exact: true }).first();
82
+ }
83
+ await expect(item).toBeVisible({ timeout: 20000 });
84
+ await item.dblclick();
85
+ await dismissBlockingDialogs(page);
86
+ }
87
+
88
+ async function activateNotebookTab(page, notebookName) {
89
+ await dismissBlockingDialogs(page);
90
+ const tab = page.getByRole('tab', { name: new RegExp(`^${escapeRegExp(notebookName)}$`) }).first();
91
+ await expect(tab).toBeVisible({ timeout: 20000 });
92
+ await tab.click();
93
+ await expect(page.locator('.jp-CodexChat-notebook')).toHaveText(notebookName, { timeout: 20000 });
94
+ }
95
+
96
+ async function ensureCodexPanel(page) {
97
+ await dismissBlockingDialogs(page);
98
+ const composer = page.locator('.jp-CodexPanel .jp-CodexComposer textarea');
99
+ if ((await composer.count()) > 0 && (await composer.first().isVisible())) {
100
+ return;
101
+ }
102
+
103
+ const codexSideTab = page
104
+ .locator('.jp-SideBar.jp-mod-right .lm-TabBar-tab[title="Codex"], .jp-SideBar.jp-mod-right .lm-TabBar-tab')
105
+ .filter({ hasText: 'Codex' })
106
+ .first();
107
+ await expect(codexSideTab).toBeVisible({ timeout: 20000 });
108
+ await codexSideTab.click();
109
+
110
+ if ((await composer.count()) > 0) {
111
+ await expect(composer.first()).toBeVisible({ timeout: 20000 });
112
+ return;
113
+ }
114
+ throw new Error('Codex panel could not be activated from right sidebar tab.');
115
+ }
116
+
117
+ async function sendMessage(page, text) {
118
+ await dismissBlockingDialogs(page);
119
+ const composer = page.locator('.jp-CodexComposer textarea');
120
+ const sendBtn = page.locator('.jp-CodexSendBtn');
121
+
122
+ await expect(composer).toBeVisible();
123
+ await composer.fill(text);
124
+ await expect(sendBtn).toBeEnabled({ timeout: 15000 });
125
+ await sendBtn.click();
126
+ await expect(page.locator('.jp-CodexSendBtn.is-stop')).toBeVisible({ timeout: 15000 });
127
+ }
128
+
129
+ test('rapid 3-tab send while in-flight does not freeze Codex sidebar', async ({ page, baseURL }) => {
130
+ const codexCommandPath =
131
+ process.env.PLAYWRIGHT_CODEX_COMMAND || path.resolve(__dirname, 'mock-codex-cli.py');
132
+ const targetUrl = baseURL || process.env.JUPYTERLAB_URL || 'http://127.0.0.1:8888/lab';
133
+ const initialNotebookUrl = buildInitialNotebookUrl(targetUrl, NOTEBOOKS[0].path);
134
+ const pageErrors = [];
135
+ const codexConsoleErrors = [];
136
+
137
+ page.on('pageerror', err => {
138
+ pageErrors.push(String(err?.message || err));
139
+ });
140
+ page.on('console', msg => {
141
+ if (msg.type() !== 'error') {
142
+ return;
143
+ }
144
+ const text = msg.text();
145
+ if (text.includes('[Codex] onSocketMessage failed') || text.includes('WebSocketClosedError')) {
146
+ codexConsoleErrors.push(text);
147
+ }
148
+ });
149
+
150
+ await page.addInitScript(commandPath => {
151
+ window.localStorage.setItem('jupyterlab-codex:command-path', commandPath);
152
+ }, codexCommandPath);
153
+
154
+ await page.goto(initialNotebookUrl, { waitUntil: 'domcontentloaded' });
155
+ await page.waitForSelector('main[aria-label="Main Content"], .jp-LabShell, .lm-DockPanel', {
156
+ timeout: 30000
157
+ });
158
+ await dismissBlockingDialogs(page);
159
+ await ensureCodexPanel(page);
160
+
161
+ for (const notebook of NOTEBOOKS.slice(1)) {
162
+ await openNotebookFromFileBrowser(page, notebook.name);
163
+ }
164
+
165
+ let sendIndex = 1;
166
+ for (const notebook of NOTEBOOKS) {
167
+ await activateNotebookTab(page, notebook.name);
168
+ await sendMessage(page, `playwright-multitab-repro-${sendIndex}`);
169
+ sendIndex += 1;
170
+ }
171
+
172
+ // While runs are in-flight, switch tabs rapidly to stress session/socket mapping.
173
+ for (let round = 0; round < 2; round += 1) {
174
+ for (const notebook of NOTEBOOKS) {
175
+ await activateNotebookTab(page, notebook.name);
176
+ await page.waitForTimeout(120);
177
+ }
178
+ }
179
+
180
+ for (const notebook of NOTEBOOKS) {
181
+ await activateNotebookTab(page, notebook.name);
182
+ await expect(page.locator('.jp-CodexSendBtn.is-stop')).toHaveCount(0, { timeout: 45000 });
183
+ }
184
+
185
+ await expect(page.locator('.jp-CodexChat-reconnectNotice')).toHaveCount(0);
186
+ await expect(page.getByText('Internal UI error while processing a server message')).toHaveCount(0);
187
+ expect(pageErrors).toEqual([]);
188
+ expect(codexConsoleErrors).toEqual([]);
189
+ });
@@ -0,0 +1,116 @@
1
+ import json
2
+ import re
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from jupyterlab_codex.handlers import CodexWSHandler, _coerce_session_id
7
+
8
+
9
+ _SAFE_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
10
+
11
+
12
+ class _DummySessionStore:
13
+ def __init__(self, resolved_session_id: str = ""):
14
+ self.resolved_session_id = resolved_session_id
15
+ self.ensure_calls: list[tuple[str, str, str]] = []
16
+
17
+ def resolve_session_for_notebook(self, notebook_path: str, notebook_os_path: str = "") -> str:
18
+ return self.resolved_session_id
19
+
20
+ def has_session(self, session_id: str) -> bool:
21
+ return False
22
+
23
+ def session_matches_notebook(
24
+ self,
25
+ session_id: str,
26
+ notebook_path: str,
27
+ notebook_os_path: str = "",
28
+ ) -> bool:
29
+ return False
30
+
31
+ def ensure_session(self, session_id: str, notebook_path: str, notebook_os_path: str = "") -> None:
32
+ self.ensure_calls.append((session_id, notebook_path, notebook_os_path))
33
+
34
+ def update_notebook_path(
35
+ self, session_id: str, notebook_path: str, notebook_os_path: str = ""
36
+ ) -> None:
37
+ pass
38
+
39
+ def load_messages(self, session_id: str):
40
+ return []
41
+
42
+
43
+ class TestSessionIdCoercion(unittest.TestCase):
44
+ def test_coerce_session_id_accepts_safe_characters(self):
45
+ self.assertEqual(_coerce_session_id("thread_01-alpha"), "thread_01-alpha")
46
+
47
+ def test_coerce_session_id_rejects_path_elements(self):
48
+ self.assertEqual(_coerce_session_id("../thread-1"), "")
49
+ self.assertEqual(_coerce_session_id("..\\thread-1"), "")
50
+ self.assertEqual(_coerce_session_id("thread/../1"), "")
51
+ self.assertEqual(_coerce_session_id("thread.1"), "")
52
+
53
+ def test_coerce_session_id_rejects_non_string(self):
54
+ self.assertEqual(_coerce_session_id(None), "")
55
+ self.assertEqual(_coerce_session_id(123), "")
56
+
57
+
58
+ class TestHandleStartSessionSessionId(unittest.IsolatedAsyncioTestCase):
59
+ def _make_handler(self, resolved_session_id: str) -> CodexWSHandler:
60
+ handler = CodexWSHandler.__new__(CodexWSHandler)
61
+ handler._store = _DummySessionStore(resolved_session_id=resolved_session_id)
62
+ handler._runner = None
63
+ handler._active_runs = {}
64
+ handler._messages: list[str] = []
65
+ handler._safe_write_message = handler._messages.append
66
+ handler._send_model_catalog = lambda *args, **kwargs: None
67
+ handler._resolve_notebook_os_path = lambda path: ""
68
+ return handler
69
+
70
+ async def test_start_session_uses_sanitized_mapped_session_id_for_invalid_payload_id(self):
71
+ handler = self._make_handler(resolved_session_id="mapped_thread")
72
+
73
+ payload = {
74
+ "sessionId": "../evil/session",
75
+ "notebookPath": "/tmp/project/notebook.ipynb",
76
+ "sessionContextKey": "ctx",
77
+ "forceNewThread": False,
78
+ "commandPath": "",
79
+ }
80
+
81
+ with patch(
82
+ "jupyterlab_codex.handlers._compute_pairing_status",
83
+ return_value=(True, "", "", "", ""),
84
+ ):
85
+ await handler._handle_start_session(payload)
86
+
87
+ status = json.loads(handler._messages[-1])
88
+ self.assertEqual(status["sessionId"], "mapped_thread")
89
+ self.assertEqual(status["sessionResolution"], "mapping")
90
+ self.assertEqual(handler._store.ensure_calls[0][0], "mapped_thread")
91
+
92
+ async def test_start_session_falls_back_to_generated_safe_session_id(self):
93
+ handler = self._make_handler(resolved_session_id="../unsafe/mapped")
94
+
95
+ payload = {
96
+ "sessionId": "../../payload",
97
+ "notebookPath": "",
98
+ "sessionContextKey": "",
99
+ "forceNewThread": False,
100
+ "commandPath": "",
101
+ }
102
+
103
+ with patch(
104
+ "jupyterlab_codex.handlers._compute_pairing_status",
105
+ return_value=(False, "", "", "", ""),
106
+ ):
107
+ await handler._handle_start_session(payload)
108
+
109
+ status = json.loads(handler._messages[-1])
110
+ generated_id = status["sessionId"]
111
+
112
+ self.assertTrue(_SAFE_SESSION_ID_RE.fullmatch(generated_id))
113
+ self.assertNotIn("/", generated_id)
114
+ self.assertNotIn("..", generated_id)
115
+ self.assertNotIn("\\", generated_id)
116
+ self.assertTrue(handler._store.ensure_calls[0][0], _SAFE_SESSION_ID_RE)
@@ -0,0 +1,169 @@
1
+ import unittest
2
+
3
+ from jupyterlab_codex.protocol import (
4
+ ProtocolParseError,
5
+ build_cli_defaults_payload,
6
+ build_delete_all_payload,
7
+ build_done_payload,
8
+ build_error_payload,
9
+ build_event_payload,
10
+ build_output_payload,
11
+ build_rate_limits_payload,
12
+ build_status_payload,
13
+ parse_client_message,
14
+ )
15
+
16
+
17
+ class TestProtocolBuilders(unittest.TestCase):
18
+ def test_parse_client_start_session_trims_fields(self):
19
+ message = {
20
+ "type": "start_session",
21
+ "sessionId": " thread-1 ",
22
+ "notebookPath": " /notebooks/a.ipynb ",
23
+ "sessionContextKey": " key ",
24
+ "forceNewThread": "true",
25
+ "commandPath": " /usr/bin/codex ",
26
+ }
27
+ msg_type, payload = parse_client_message(message)
28
+ self.assertEqual(msg_type, "start_session")
29
+ self.assertEqual(payload["sessionId"], "thread-1")
30
+ self.assertEqual(payload["notebookPath"], "/notebooks/a.ipynb")
31
+ self.assertEqual(payload["sessionContextKey"], "key")
32
+ self.assertTrue(payload["forceNewThread"])
33
+ self.assertEqual(payload["commandPath"], "/usr/bin/codex")
34
+
35
+ def test_parse_client_send_normalizes_fields(self):
36
+ message = {
37
+ "type": "send",
38
+ "sessionId": "thread-1",
39
+ "sessionContextKey": "k",
40
+ "content": " hello ",
41
+ "notebookPath": "",
42
+ "commandPath": 42,
43
+ "model": None,
44
+ "reasoningEffort": "medium",
45
+ "sandbox": " read-only ",
46
+ "selection": "sel",
47
+ "cellOutput": "out",
48
+ "images": [{"a": 1}],
49
+ "uiSelectionPreview": {"locationLabel": "x", "previewText": "y"},
50
+ "uiCellOutputPreview": {"locationLabel": "Cell 8 Output", "previewText": "42"},
51
+ "selectionTruncated": "true",
52
+ "cellOutputTruncated": 0,
53
+ }
54
+ msg_type, payload = parse_client_message(message)
55
+ self.assertEqual(msg_type, "send")
56
+ self.assertEqual(payload["commandPath"], "")
57
+ self.assertEqual(payload["content"], "hello")
58
+ self.assertEqual(payload["sandbox"], "read-only")
59
+ self.assertEqual(payload["model"], "")
60
+ self.assertEqual(payload["selection"], "sel")
61
+ self.assertEqual(payload["images"], [{"a": 1}])
62
+ self.assertEqual(payload["uiCellOutputPreview"], {"locationLabel": "Cell 8 Output", "previewText": "42"})
63
+ self.assertTrue(payload["selectionTruncated"])
64
+ self.assertFalse(payload["cellOutputTruncated"])
65
+
66
+ def test_parse_client_send_preserves_selection_and_cell_output_whitespace(self):
67
+ message = {
68
+ "type": "send",
69
+ "sessionId": "thread-1",
70
+ "sessionContextKey": "k",
71
+ "content": " hello ",
72
+ "selection": " def run():\\n return 1\\n",
73
+ "cellOutput": "\\n>>> foo()\\nbar() \\n",
74
+ }
75
+ msg_type, payload = parse_client_message(message)
76
+ self.assertEqual(msg_type, "send")
77
+ self.assertEqual(payload["selection"], " def run():\\n return 1\\n")
78
+ self.assertEqual(payload["cellOutput"], "\\n>>> foo()\\nbar() \\n")
79
+
80
+ def test_parse_client_invalid_message(self):
81
+ with self.assertRaises(ProtocolParseError):
82
+ parse_client_message({"type": "unknown"})
83
+
84
+ def test_builders_shape(self):
85
+ self.assertEqual(
86
+ build_status_payload(
87
+ state="ready",
88
+ run_id="run-1",
89
+ session_id="thread-1",
90
+ session_context_key="ctx-1",
91
+ notebook_path="/notebooks/a.ipynb",
92
+ run_mode="resume",
93
+ paired_ok=True,
94
+ paired_path="/paired",
95
+ paired_os_path="/paired-os",
96
+ paired_message="paired",
97
+ notebook_mode="ipynb",
98
+ history=[{"role": "user", "content": "hi"}],
99
+ )["type"],
100
+ "status",
101
+ )
102
+
103
+ output = build_output_payload(
104
+ run_id="run-1",
105
+ session_id="thread-1",
106
+ session_context_key="ctx-1",
107
+ notebook_path="/notebooks/a.ipynb",
108
+ text="ok",
109
+ role="system",
110
+ )
111
+ self.assertEqual(output["role"], "system")
112
+
113
+ event = build_event_payload(
114
+ run_id="run-1",
115
+ session_id="thread-1",
116
+ session_context_key="ctx-1",
117
+ notebook_path="/notebooks/a.ipynb",
118
+ payload={"kind": "log"},
119
+ )
120
+ self.assertEqual(event["payload"], {"kind": "log"})
121
+
122
+ done = build_done_payload(
123
+ run_id="run-1",
124
+ session_id="thread-1",
125
+ session_context_key="ctx-1",
126
+ notebook_path="/notebooks/a.ipynb",
127
+ exit_code=0,
128
+ file_changed=False,
129
+ run_mode="resume",
130
+ paired_ok=True,
131
+ paired_path="/paired",
132
+ paired_os_path="/paired-os",
133
+ paired_message="paired",
134
+ notebook_mode="ipynb",
135
+ cancelled=True,
136
+ )
137
+ self.assertTrue(done["cancelled"])
138
+
139
+ error = build_error_payload(
140
+ message="bad",
141
+ run_id="run-1",
142
+ session_id="thread-1",
143
+ session_context_key="ctx-1",
144
+ notebook_path="/notebooks/a.ipynb",
145
+ run_mode="fallback",
146
+ suggested_command_path="/usr/bin/codex",
147
+ paired_ok=True,
148
+ )
149
+ self.assertEqual(error["message"], "bad")
150
+ self.assertEqual(error["runMode"], "fallback")
151
+
152
+ rates = build_rate_limits_payload({"x": 1})
153
+ self.assertEqual(rates["snapshot"], {"x": 1})
154
+
155
+ delete_all = build_delete_all_payload(
156
+ ok=True,
157
+ deleted_count=1,
158
+ failed_count=0,
159
+ message="deleted",
160
+ )
161
+ self.assertTrue(delete_all["ok"])
162
+ self.assertEqual(delete_all["message"], "deleted")
163
+
164
+ defaults = build_cli_defaults_payload(model="o4-mini", reasoning_effort="low")
165
+ self.assertEqual(defaults["model"], "o4-mini")
166
+
167
+
168
+ if __name__ == "__main__":
169
+ unittest.main()
@@ -0,0 +1,50 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+
5
+ from jupyterlab_codex.sessions import SessionStore
6
+
7
+
8
+ class TestSessionStoreMessageLimits(unittest.TestCase):
9
+ def test_append_message_trims_to_default_max_of_100(self):
10
+ previous = os.environ.pop("JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES", None)
11
+ try:
12
+ with tempfile.TemporaryDirectory() as base_dir:
13
+ store = SessionStore(base_dir=base_dir)
14
+ session_id = "session-default-limit"
15
+
16
+ for index in range(150):
17
+ store.append_message(session_id, "user", f"message-{index}")
18
+
19
+ messages = store.load_messages(session_id)
20
+
21
+ self.assertEqual(len(messages), 100)
22
+ self.assertEqual(messages[0]["content"], "message-50")
23
+ self.assertEqual(messages[-1]["content"], "message-149")
24
+ finally:
25
+ if previous is None:
26
+ os.environ.pop("JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES", None)
27
+ else:
28
+ os.environ["JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES"] = previous
29
+
30
+ def test_custom_max_environment_variable_is_respected(self):
31
+ previous = os.environ.get("JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES")
32
+ try:
33
+ os.environ["JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES"] = "12"
34
+ with tempfile.TemporaryDirectory() as base_dir:
35
+ store = SessionStore(base_dir=base_dir)
36
+ session_id = "session-custom-limit"
37
+
38
+ for index in range(20):
39
+ store.append_message(session_id, "user", f"message-{index}")
40
+
41
+ messages = store.load_messages(session_id)
42
+
43
+ self.assertEqual(len(messages), 12)
44
+ self.assertEqual(messages[0]["content"], "message-8")
45
+ self.assertEqual(messages[-1]["content"], "message-19")
46
+ finally:
47
+ if previous is None:
48
+ os.environ.pop("JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES", None)
49
+ else:
50
+ os.environ["JUPYTERLAB_CODEX_SESSION_MAX_MESSAGES"] = previous
@@ -0,0 +1,56 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import {
4
+ buildActiveCellOutputSignature,
5
+ buildActiveCellSelectionSignature,
6
+ isDuplicateActiveCellAttachmentSignature,
7
+ makeActiveCellAttachmentDedupKey
8
+ } from '../../src/codexChatAttachmentDedup';
9
+
10
+ test('makeActiveCellAttachmentDedupKey prefers thread id', () => {
11
+ expect(makeActiveCellAttachmentDedupKey('/notebook.ipynb', 'thread-123')).toBe('thread:thread-123');
12
+ expect(makeActiveCellAttachmentDedupKey('/notebook.ipynb', '')).toBe('session:/notebook.ipynb');
13
+ });
14
+
15
+ test('buildActiveCellSelectionSignature normalizes text and includes location labels', () => {
16
+ const signatureA = buildActiveCellSelectionSignature({
17
+ notebookMode: 'ipynb',
18
+ text: 'print(1)\r\n',
19
+ locationLabel: 'Cell 1'
20
+ });
21
+ const signatureB = buildActiveCellSelectionSignature({
22
+ notebookMode: 'ipynb',
23
+ text: 'print(1)\n',
24
+ locationLabel: 'Cell 1'
25
+ });
26
+ const signatureDifferentCell = buildActiveCellSelectionSignature({
27
+ notebookMode: 'ipynb',
28
+ text: 'print(1)\n',
29
+ locationLabel: 'Cell 2'
30
+ });
31
+
32
+ expect(signatureA).toBe(signatureB);
33
+ expect(signatureA).not.toBe(signatureDifferentCell);
34
+ });
35
+
36
+ test('selection and output signatures are independent', () => {
37
+ const selectionSignature = buildActiveCellSelectionSignature({
38
+ notebookMode: 'ipynb',
39
+ text: 'value = 1',
40
+ locationLabel: 'Cell 3'
41
+ });
42
+ const outputSignature = buildActiveCellOutputSignature({
43
+ notebookMode: 'ipynb',
44
+ text: '1',
45
+ locationLabel: 'Cell 3 Output'
46
+ });
47
+
48
+ expect(selectionSignature).not.toBe(outputSignature);
49
+ });
50
+
51
+ test('isDuplicateActiveCellAttachmentSignature requires non-empty equal signatures', () => {
52
+ expect(isDuplicateActiveCellAttachmentSignature('a', 'a')).toBe(true);
53
+ expect(isDuplicateActiveCellAttachmentSignature('a', 'b')).toBe(false);
54
+ expect(isDuplicateActiveCellAttachmentSignature(undefined, 'a')).toBe(false);
55
+ expect(isDuplicateActiveCellAttachmentSignature('a', '')).toBe(false);
56
+ });
@@ -0,0 +1,57 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import {
4
+ buildAttachmentTruncationNotice,
5
+ limitActiveCellAttachmentPayload,
6
+ resolveSentAttachmentTruncation
7
+ } from '../../src/codexChatAttachmentLimit';
8
+
9
+ test('limitActiveCellAttachmentPayload keeps payload unchanged when within max', () => {
10
+ const result = limitActiveCellAttachmentPayload('abc', 'def', 10, 10);
11
+ expect(result).toEqual({
12
+ selection: 'abc',
13
+ cellOutput: 'def',
14
+ selectionTruncated: false,
15
+ cellOutputTruncated: false
16
+ });
17
+ });
18
+
19
+ test('limitActiveCellAttachmentPayload applies separate limits to input and output', () => {
20
+ const result = limitActiveCellAttachmentPayload('12345', 'abcdef', 2, 6);
21
+ expect(result).toEqual({
22
+ selection: '12',
23
+ cellOutput: 'abcdef',
24
+ selectionTruncated: true,
25
+ cellOutputTruncated: false
26
+ });
27
+ });
28
+
29
+ test('limitActiveCellAttachmentPayload truncates output independently when output exceeds max', () => {
30
+ const result = limitActiveCellAttachmentPayload('12345', 'abcdefghij', 4, 4);
31
+ expect(result).toEqual({
32
+ selection: '1234',
33
+ cellOutput: 'abcd',
34
+ selectionTruncated: true,
35
+ cellOutputTruncated: true
36
+ });
37
+ });
38
+
39
+ test('buildAttachmentTruncationNotice includes source hint when input is truncated', () => {
40
+ const notice = buildAttachmentTruncationNotice(true, false, 4000, 20000);
41
+ expect(notice).toContain('source file/cell');
42
+ expect(notice).toContain('4000');
43
+ });
44
+
45
+ test('resolveSentAttachmentTruncation only reports truncation for attachments that were sent', () => {
46
+ expect(
47
+ resolveSentAttachmentTruncation({
48
+ includeSelection: false,
49
+ includeCellOutput: true,
50
+ selectionTruncated: true,
51
+ cellOutputTruncated: true
52
+ })
53
+ ).toEqual({
54
+ selectionTruncated: false,
55
+ cellOutputTruncated: true
56
+ });
57
+ });