jupyterlab-codex-sidebar 0.1.5 → 0.1.7

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 (41) hide show
  1. package/.jupyterlab-playwright.log +43 -0
  2. package/README.md +19 -3
  3. package/jupyterlab_codex/labextension/package.json +2 -2
  4. package/jupyterlab_codex/labextension/static/737.58bcf09100c9bc7bd90d.js +1 -0
  5. package/jupyterlab_codex/labextension/static/{remoteEntry.c1e865f207776f7f24ff.js → remoteEntry.34af8a0df422b4d029c3.js} +1 -1
  6. package/jupyterlab_codex/sessions.py +1 -1
  7. package/lib/codexChat.js +148 -52
  8. package/lib/codexChat.js.map +1 -1
  9. package/lib/codexChatAttachmentLimit.d.ts +12 -2
  10. package/lib/codexChatAttachmentLimit.js +43 -30
  11. package/lib/codexChatAttachmentLimit.js.map +1 -1
  12. package/lib/codexChatAttachmentState.d.ts +15 -0
  13. package/lib/codexChatAttachmentState.js +16 -0
  14. package/lib/codexChatAttachmentState.js.map +1 -0
  15. package/lib/codexChatDocumentUtils.d.ts +4 -1
  16. package/lib/codexChatDocumentUtils.js +71 -19
  17. package/lib/codexChatDocumentUtils.js.map +1 -1
  18. package/lib/codexChatPrimitives.d.ts +4 -1
  19. package/lib/codexChatPrimitives.js +4 -0
  20. package/lib/codexChatPrimitives.js.map +1 -1
  21. package/package.json +1 -1
  22. package/playwright.config.cjs +4 -1
  23. package/pyproject.toml +1 -1
  24. package/src/codexChat.tsx +234 -75
  25. package/src/codexChatAttachmentLimit.ts +59 -33
  26. package/src/codexChatAttachmentState.ts +37 -0
  27. package/src/codexChatDocumentUtils.ts +89 -21
  28. package/src/codexChatPrimitives.tsx +25 -1
  29. package/style/index.css +96 -40
  30. package/test-results/.last-run.json +4 -0
  31. package/test.py +0 -0
  32. package/tests/e2e/cell-output-error-tail.spec.js +165 -0
  33. package/tests/e2e/codex-ui-test-helpers.js +138 -0
  34. package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
  35. package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
  36. package/tests/e2e/mock-codex-cli-prompt-echo.py +92 -0
  37. package/tests/unit/codexChatAttachmentLimit.spec.ts +33 -8
  38. package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
  39. package/tests/unit/codexChatDocumentUtils.spec.ts +78 -0
  40. package/tsconfig.tsbuildinfo +1 -1
  41. package/jupyterlab_codex/labextension/static/855.d20f6158cd81bb4c9056.js +0 -1
@@ -0,0 +1,165 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { test, expect } = require('@playwright/test');
4
+
5
+ const NOTEBOOK_RELATIVE_PATH = 'tests/e2e/fixtures/notebooks/error-output-tail.ipynb';
6
+ const NOTEBOOK_NAME = 'error-output-tail.ipynb';
7
+ const NOTEBOOK_PY_PATH = path.resolve(__dirname, 'fixtures/notebooks/error-output-tail.py');
8
+ const NOTEBOOK_IPYNB_PATH = path.resolve(__dirname, 'fixtures/notebooks/error-output-tail.ipynb');
9
+ const TRACE_HEAD_MARKER = 'TRACE_HEAD_MARKER_ABCDEFGHIJ';
10
+ const TRACE_TAIL_MARKER = 'TRACE_TAIL_MARKER_ZYXWVUTSRQ';
11
+
12
+ function buildInitialNotebookUrl(baseLabUrl, notebookPath) {
13
+ const parsed = new URL(baseLabUrl);
14
+ const basePath = parsed.pathname.replace(/\/$/, '');
15
+ parsed.pathname = `${basePath}/tree/${notebookPath}`;
16
+ parsed.searchParams.set('reset', '1');
17
+ return parsed.toString();
18
+ }
19
+
20
+ async function dismissBlockingDialogs(page) {
21
+ for (let attempt = 0; attempt < 8; attempt += 1) {
22
+ const dialog = page.locator('dialog.jp-Dialog, [role="dialog"].jp-Dialog, [role="dialog"]').first();
23
+ if (!(await dialog.count()) || !(await dialog.isVisible())) {
24
+ return;
25
+ }
26
+
27
+ const noKernelButton = page.getByRole('button', { name: /^No Kernel$/ }).first();
28
+ if ((await noKernelButton.count()) > 0 && (await noKernelButton.isVisible())) {
29
+ await noKernelButton.click();
30
+ await page.waitForTimeout(150);
31
+ continue;
32
+ }
33
+
34
+ await page.keyboard.press('Escape');
35
+ await page.waitForTimeout(150);
36
+ }
37
+ }
38
+
39
+ async function ensureCodexPanel(page) {
40
+ await dismissBlockingDialogs(page);
41
+ const composer = page.locator('.jp-CodexPanel .jp-CodexComposer textarea');
42
+ if ((await composer.count()) > 0 && (await composer.first().isVisible())) {
43
+ return;
44
+ }
45
+
46
+ const codexSideTab = page
47
+ .locator('.jp-SideBar.jp-mod-right .lm-TabBar-tab[title="Codex"], .jp-SideBar.jp-mod-right .lm-TabBar-tab')
48
+ .filter({ hasText: 'Codex' })
49
+ .first();
50
+ await expect(codexSideTab).toBeVisible({ timeout: 20000 });
51
+ await codexSideTab.click();
52
+ await expect(composer.first()).toBeVisible({ timeout: 20000 });
53
+ }
54
+
55
+ function writeNotebookFixture() {
56
+ const traceback = [
57
+ 'Traceback (most recent call last):',
58
+ ` ${TRACE_HEAD_MARKER}`,
59
+ `${'A'.repeat(22000)}`,
60
+ `ValueError: ${TRACE_TAIL_MARKER}`
61
+ ];
62
+ const notebook = {
63
+ cells: [
64
+ {
65
+ cell_type: 'code',
66
+ execution_count: 1,
67
+ id: 'error-output-tail-cell',
68
+ metadata: {},
69
+ outputs: [
70
+ {
71
+ output_type: 'error',
72
+ ename: 'ValueError',
73
+ evalue: TRACE_TAIL_MARKER,
74
+ traceback
75
+ }
76
+ ],
77
+ source: ['raise ValueError("synthetic traceback for tail truncation test")\n']
78
+ }
79
+ ],
80
+ metadata: {
81
+ jupytext: {
82
+ formats: 'ipynb,py:percent'
83
+ },
84
+ kernelspec: {
85
+ display_name: 'Python 3 (ipykernel)',
86
+ language: 'python',
87
+ name: 'python3'
88
+ },
89
+ language_info: {
90
+ codemirror_mode: {
91
+ name: 'ipython',
92
+ version: 3
93
+ },
94
+ file_extension: '.py',
95
+ mimetype: 'text/x-python',
96
+ name: 'python',
97
+ nbconvert_exporter: 'python',
98
+ pygments_lexer: 'ipython3',
99
+ version: '3.13.9'
100
+ }
101
+ },
102
+ nbformat: 4,
103
+ nbformat_minor: 5
104
+ };
105
+
106
+ fs.writeFileSync(NOTEBOOK_IPYNB_PATH, `${JSON.stringify(notebook, null, 2)}\n`, 'utf8');
107
+ fs.writeFileSync(
108
+ NOTEBOOK_PY_PATH,
109
+ [
110
+ '# %%',
111
+ 'raise ValueError("synthetic traceback for tail truncation test")',
112
+ ''
113
+ ].join('\n'),
114
+ 'utf8'
115
+ );
116
+ }
117
+
118
+ test('error cell output sends the tail of the traceback to Codex', async ({ page, baseURL }) => {
119
+ const originalNotebook = fs.readFileSync(NOTEBOOK_IPYNB_PATH, 'utf8');
120
+ const originalPy = fs.readFileSync(NOTEBOOK_PY_PATH, 'utf8');
121
+ const codexCommandPath =
122
+ process.env.PLAYWRIGHT_CODEX_COMMAND || path.resolve(__dirname, 'mock-codex-cli-prompt-echo.py');
123
+ const targetUrl = baseURL || process.env.JUPYTERLAB_URL || 'http://127.0.0.1:8888/lab';
124
+
125
+ try {
126
+ writeNotebookFixture();
127
+
128
+ await page.addInitScript(commandPath => {
129
+ window.localStorage.setItem('jupyterlab-codex:command-path', commandPath);
130
+ window.localStorage.setItem('jupyterlab-codex:include-active-cell', 'true');
131
+ window.localStorage.setItem('jupyterlab-codex:include-active-cell-output', 'true');
132
+ }, codexCommandPath);
133
+
134
+ await page.goto(buildInitialNotebookUrl(targetUrl, NOTEBOOK_RELATIVE_PATH), { waitUntil: 'domcontentloaded' });
135
+ await page.waitForSelector('main[aria-label="Main Content"], .jp-LabShell, .lm-DockPanel', {
136
+ timeout: 30000
137
+ });
138
+ await dismissBlockingDialogs(page);
139
+ await ensureCodexPanel(page);
140
+
141
+ const cellEditor = page.locator('.jp-Notebook [aria-label="Code Cell Content with Output"] [role="textbox"]');
142
+ await expect(cellEditor).toBeVisible({ timeout: 20000 });
143
+ await cellEditor.click();
144
+
145
+ const composer = page.locator('.jp-CodexComposer textarea');
146
+ const sendBtn = page.locator('.jp-CodexSendBtn');
147
+ await composer.fill('Inspect the attached output.');
148
+ await expect(sendBtn).toBeEnabled({ timeout: 15000 });
149
+ await sendBtn.click();
150
+ await expect(page.locator('.jp-CodexSendBtn.is-stop')).toHaveCount(0, { timeout: 30000 });
151
+
152
+ const assistantMessage = page.locator('.jp-CodexChat-message.jp-CodexChat-assistant').last();
153
+ await expect(assistantMessage).toContainText('PROMPT_TAIL_START', { timeout: 30000 });
154
+ const responseText = await assistantMessage.innerText();
155
+
156
+ const tailSection = responseText.split('PROMPT_TAIL_START\n')[1]?.split('\nPROMPT_TAIL_END')[0] || '';
157
+ expect(responseText).toContain('PROMPT_HAS_CURRENT_CELL_CONTENT=true');
158
+ expect(responseText).toContain('PROMPT_HAS_CURRENT_CELL_OUTPUT=true');
159
+ expect(tailSection).toContain(TRACE_TAIL_MARKER);
160
+ expect(tailSection).not.toContain(TRACE_HEAD_MARKER);
161
+ } finally {
162
+ fs.writeFileSync(NOTEBOOK_IPYNB_PATH, originalNotebook, 'utf8');
163
+ fs.writeFileSync(NOTEBOOK_PY_PATH, originalPy, 'utf8');
164
+ }
165
+ });
@@ -0,0 +1,138 @@
1
+ const path = require('node:path');
2
+
3
+ function buildNotebookUrl(baseLabUrl, notebookPath) {
4
+ const parsed = new URL(baseLabUrl);
5
+ const basePath = parsed.pathname.replace(/\/$/, '');
6
+ parsed.pathname = `${basePath}/tree/${notebookPath}`;
7
+ parsed.searchParams.set('reset', '1');
8
+ return parsed.toString();
9
+ }
10
+
11
+ function escapeRegExp(value) {
12
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ async function dismissBlockingDialogs(page) {
16
+ for (let attempt = 0; attempt < 12; attempt += 1) {
17
+ const dialog = page.locator('dialog.jp-Dialog, [role="dialog"].jp-Dialog, [role="dialog"]').first();
18
+ if (!(await dialog.count()) || !(await dialog.isVisible())) {
19
+ return;
20
+ }
21
+
22
+ const noKernelButton = page.getByRole('button', { name: /^No Kernel$/ }).first();
23
+ if ((await noKernelButton.count()) > 0 && (await noKernelButton.isVisible())) {
24
+ await noKernelButton.click();
25
+ await page.waitForTimeout(120);
26
+ continue;
27
+ }
28
+
29
+ const selectKernelButton = page.getByRole('button', { name: /^Select$/ }).first();
30
+ if ((await selectKernelButton.count()) > 0 && (await selectKernelButton.isVisible())) {
31
+ await selectKernelButton.click();
32
+ await page.waitForTimeout(120);
33
+ continue;
34
+ }
35
+
36
+ const cancelButton = page.getByRole('button', { name: /^Cancel$/ }).first();
37
+ if ((await cancelButton.count()) > 0 && (await cancelButton.isVisible())) {
38
+ await cancelButton.click();
39
+ await page.waitForTimeout(120);
40
+ continue;
41
+ }
42
+
43
+ await page.keyboard.press('Escape');
44
+ await page.waitForTimeout(120);
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 page.getByRole('list', { name: /files/i }).first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
70
+ }
71
+
72
+ async function openDocumentFromFileBrowser(page, documentName) {
73
+ await ensureFileBrowserVisible(page);
74
+ let item = page
75
+ .getByRole('listitem', { name: new RegExp(`\\b${escapeRegExp(documentName)}\\b`, 'i') })
76
+ .first();
77
+ if (!(await item.count())) {
78
+ item = page.locator('.jp-DirListing-item', { hasText: documentName }).first();
79
+ }
80
+ if (!(await item.count())) {
81
+ item = page.locator('.jp-FileBrowser').getByText(documentName, { exact: true }).first();
82
+ }
83
+ await item.first().waitFor({ state: 'visible', timeout: 20000 });
84
+ await item.first().dblclick();
85
+ await dismissBlockingDialogs(page);
86
+ }
87
+
88
+ async function activateDocumentTab(page, documentName) {
89
+ await dismissBlockingDialogs(page);
90
+ const exactTab = page
91
+ .getByRole('tab', { name: new RegExp(`^${escapeRegExp(documentName)}$`) })
92
+ .first();
93
+ await exactTab.click();
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 codexSideTab.click();
108
+ await expect(composer.first()).toBeVisible({ timeout: 20000 });
109
+ }
110
+
111
+ async function openNotebookInCodex(page, baseURL, notebook) {
112
+ const commandPath = notebook.commandPath || path.resolve(__dirname, 'mock-codex-cli.py');
113
+ await page.addInitScript(cmd => {
114
+ window.localStorage.setItem('jupyterlab-codex:command-path', cmd);
115
+ if (!window.localStorage.getItem('jupyterlab-codex:model')) {
116
+ window.localStorage.removeItem('jupyterlab-codex:model');
117
+ }
118
+ }, commandPath);
119
+
120
+ const targetUrl = buildNotebookUrl(baseURL, notebook.path);
121
+ await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
122
+ await page.waitForSelector('main[aria-label="Main Content"], .jp-LabShell, .lm-DockPanel', { timeout: 30000 });
123
+ await dismissBlockingDialogs(page);
124
+ await ensureCodexPanel(page);
125
+
126
+ await openDocumentFromFileBrowser(page, notebook.name);
127
+ await activateDocumentTab(page, notebook.name);
128
+ }
129
+
130
+ module.exports = {
131
+ buildNotebookUrl,
132
+ dismissBlockingDialogs,
133
+ ensureFileBrowserVisible,
134
+ openDocumentFromFileBrowser,
135
+ activateDocumentTab,
136
+ ensureCodexPanel,
137
+ openNotebookInCodex
138
+ };
@@ -0,0 +1,58 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "code",
5
+ "execution_count": 1,
6
+ "id": "error-output-tail-cell",
7
+ "metadata": {},
8
+ "outputs": [
9
+ {
10
+ "ename": "ValueError",
11
+ "evalue": "synthetic traceback for tail truncation test",
12
+ "output_type": "error",
13
+ "traceback": [
14
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
15
+ "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
16
+ "Cell \u001b[0;32mIn[1], line 1\u001b[0m\n\u001b[0;32m----> 1\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m(\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124msynthetic traceback for tail truncation test\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n",
17
+ "\u001b[0;31mValueError\u001b[0m: synthetic traceback for tail truncation test"
18
+ ]
19
+ }
20
+ ],
21
+ "source": [
22
+ "raise ValueError(\"synthetic traceback for tail truncation test\")"
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "code",
27
+ "execution_count": null,
28
+ "id": "05fa12e1-3ea7-4081-b658-7e2799c32d07",
29
+ "metadata": {},
30
+ "outputs": [],
31
+ "source": []
32
+ }
33
+ ],
34
+ "metadata": {
35
+ "jupytext": {
36
+ "formats": "ipynb,py:percent"
37
+ },
38
+ "kernelspec": {
39
+ "display_name": "Python 3 (ipykernel)",
40
+ "language": "python",
41
+ "name": "python3"
42
+ },
43
+ "language_info": {
44
+ "codemirror_mode": {
45
+ "name": "ipython",
46
+ "version": 3
47
+ },
48
+ "file_extension": ".py",
49
+ "mimetype": "text/x-python",
50
+ "name": "python",
51
+ "nbconvert_exporter": "python",
52
+ "pygments_lexer": "ipython3",
53
+ "version": "3.12.2"
54
+ }
55
+ },
56
+ "nbformat": 4,
57
+ "nbformat_minor": 5
58
+ }
@@ -0,0 +1,19 @@
1
+ # ---
2
+ # jupyter:
3
+ # jupytext:
4
+ # formats: ipynb,py:percent
5
+ # text_representation:
6
+ # extension: .py
7
+ # format_name: percent
8
+ # format_version: '1.3'
9
+ # jupytext_version: 1.17.1
10
+ # kernelspec:
11
+ # display_name: Python 3 (ipykernel)
12
+ # language: python
13
+ # name: python3
14
+ # ---
15
+
16
+ # %%
17
+ raise ValueError("synthetic traceback for tail truncation test")
18
+
19
+ # %%
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import json
4
+ import sys
5
+ import uuid
6
+ from typing import Any
7
+
8
+
9
+ def _emit(payload: dict[str, Any]) -> None:
10
+ sys.stdout.write(json.dumps(payload) + "\n")
11
+ sys.stdout.flush()
12
+
13
+
14
+ def _run_app_server() -> int:
15
+ for raw_line in sys.stdin:
16
+ line = raw_line.strip()
17
+ if not line:
18
+ continue
19
+ try:
20
+ req = json.loads(line)
21
+ except json.JSONDecodeError:
22
+ continue
23
+
24
+ req_id = req.get("id")
25
+ method = req.get("method")
26
+ if method == "initialize" and req_id is not None:
27
+ _emit({"jsonrpc": "2.0", "id": req_id, "result": {"capabilities": {}}})
28
+ continue
29
+ if method == "model/list" and req_id is not None:
30
+ _emit(
31
+ {
32
+ "jsonrpc": "2.0",
33
+ "id": req_id,
34
+ "result": {
35
+ "data": [
36
+ {
37
+ "model": "mock-gpt",
38
+ "displayName": "Mock GPT",
39
+ "supportedReasoningEfforts": ["low", "medium", "high"],
40
+ "defaultReasoningEffort": "medium",
41
+ }
42
+ ]
43
+ },
44
+ }
45
+ )
46
+ continue
47
+ if method == "shutdown" and req_id is not None:
48
+ _emit({"jsonrpc": "2.0", "id": req_id, "result": {}})
49
+ continue
50
+
51
+ return 0
52
+
53
+
54
+ def _run_exec_mode() -> int:
55
+ prompt = sys.stdin.read()
56
+ thread_id = f"mock-thread-{uuid.uuid4().hex[:12]}"
57
+ head = prompt[:800]
58
+ tail = prompt[-800:] if prompt else ""
59
+ has_current_cell_content = "Current Cell Content:" in prompt
60
+ has_current_cell_output = "Current Cell Output:" in prompt
61
+
62
+ _emit({"type": "thread.started", "thread_id": thread_id})
63
+ _emit(
64
+ {
65
+ "type": "item.completed",
66
+ "item": {
67
+ "type": "agent_message",
68
+ "text": (
69
+ f"PROMPT_HAS_CURRENT_CELL_CONTENT={str(has_current_cell_content).lower()}\n"
70
+ f"PROMPT_HAS_CURRENT_CELL_OUTPUT={str(has_current_cell_output).lower()}\n"
71
+ "PROMPT_HEAD_START\n"
72
+ f"{head}\n"
73
+ "PROMPT_HEAD_END\n"
74
+ "PROMPT_TAIL_START\n"
75
+ f"{tail}\n"
76
+ "PROMPT_TAIL_END"
77
+ ),
78
+ },
79
+ }
80
+ )
81
+ return 0
82
+
83
+
84
+ def main() -> int:
85
+ argv = sys.argv[1:]
86
+ if len(argv) >= 1 and argv[0] == "app-server":
87
+ return _run_app_server()
88
+ return _run_exec_mode()
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -2,11 +2,12 @@ import { expect, test } from '@playwright/test';
2
2
 
3
3
  import {
4
4
  buildAttachmentTruncationNotice,
5
- limitActiveCellAttachmentPayload
5
+ limitActiveCellAttachmentPayload,
6
+ resolveSentAttachmentTruncation
6
7
  } from '../../src/codexChatAttachmentLimit';
7
8
 
8
9
  test('limitActiveCellAttachmentPayload keeps payload unchanged when within max', () => {
9
- const result = limitActiveCellAttachmentPayload('abc', 'def', 10);
10
+ const result = limitActiveCellAttachmentPayload('abc', 'def', 10, 10);
10
11
  expect(result).toEqual({
11
12
  selection: 'abc',
12
13
  cellOutput: 'def',
@@ -15,8 +16,8 @@ test('limitActiveCellAttachmentPayload keeps payload unchanged when within max',
15
16
  });
16
17
  });
17
18
 
18
- test('limitActiveCellAttachmentPayload preserves output first, then input', () => {
19
- const result = limitActiveCellAttachmentPayload('12345', 'abcdef', 8);
19
+ test('limitActiveCellAttachmentPayload applies separate limits to input and output', () => {
20
+ const result = limitActiveCellAttachmentPayload('12345', 'abcdef', 2, 6);
20
21
  expect(result).toEqual({
21
22
  selection: '12',
22
23
  cellOutput: 'abcdef',
@@ -25,10 +26,20 @@ test('limitActiveCellAttachmentPayload preserves output first, then input', () =
25
26
  });
26
27
  });
27
28
 
28
- test('limitActiveCellAttachmentPayload truncates output first when output exceeds max', () => {
29
- const result = limitActiveCellAttachmentPayload('12345', 'abcdefghij', 4);
29
+ test('limitActiveCellAttachmentPayload truncates long single-line input from the middle', () => {
30
+ const result = limitActiveCellAttachmentPayload(`start-${'x'.repeat(3000)}-end`, '', 4000, 10);
31
+ expect(result.selection).toContain('[long line truncated]');
32
+ expect(result.selection.startsWith('start-')).toBeTruthy();
33
+ expect(result.selection.endsWith('-end')).toBeTruthy();
34
+ expect(result.selection.length).toBeLessThanOrEqual(4000);
35
+ expect(result.selectionTruncated).toBeTruthy();
36
+ expect(result.cellOutputTruncated).toBeFalsy();
37
+ });
38
+
39
+ test('limitActiveCellAttachmentPayload truncates output independently when output exceeds max', () => {
40
+ const result = limitActiveCellAttachmentPayload('12345', 'abcdefghij', 4, 4);
30
41
  expect(result).toEqual({
31
- selection: '',
42
+ selection: '1234',
32
43
  cellOutput: 'abcd',
33
44
  selectionTruncated: true,
34
45
  cellOutputTruncated: true
@@ -36,7 +47,21 @@ test('limitActiveCellAttachmentPayload truncates output first when output exceed
36
47
  });
37
48
 
38
49
  test('buildAttachmentTruncationNotice includes source hint when input is truncated', () => {
39
- const notice = buildAttachmentTruncationNotice(true, false, 4000);
50
+ const notice = buildAttachmentTruncationNotice(true, false, 4000, 20000);
40
51
  expect(notice).toContain('source file/cell');
41
52
  expect(notice).toContain('4000');
42
53
  });
54
+
55
+ test('resolveSentAttachmentTruncation only reports truncation for attachments that were sent', () => {
56
+ expect(
57
+ resolveSentAttachmentTruncation({
58
+ includeSelection: false,
59
+ includeCellOutput: true,
60
+ selectionTruncated: true,
61
+ cellOutputTruncated: true
62
+ })
63
+ ).toEqual({
64
+ selectionTruncated: false,
65
+ cellOutputTruncated: true
66
+ });
67
+ });
@@ -0,0 +1,71 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { resolveCellAttachmentState } from '../../src/codexChatAttachmentState';
4
+
5
+ test('resolveCellAttachmentState shows notebook cell attachment for jupytext notebook editor', () => {
6
+ expect(
7
+ resolveCellAttachmentState({
8
+ includeActiveCell: true,
9
+ includeActiveCellOutput: true,
10
+ notebookMode: 'jupytext_py',
11
+ isNotebookEditor: true,
12
+ currentNotebookPath: '/tmp/example.py',
13
+ pairedOk: true
14
+ })
15
+ ).toEqual({
16
+ showBadge: true,
17
+ contentEnabled: true,
18
+ outputEnabled: true
19
+ });
20
+ });
21
+
22
+ test('resolveCellAttachmentState hides cell attachment for jupytext text editor', () => {
23
+ expect(
24
+ resolveCellAttachmentState({
25
+ includeActiveCell: true,
26
+ includeActiveCellOutput: true,
27
+ notebookMode: 'jupytext_py',
28
+ isNotebookEditor: false,
29
+ currentNotebookPath: '/tmp/example.py',
30
+ pairedOk: true
31
+ })
32
+ ).toEqual({
33
+ showBadge: false,
34
+ contentEnabled: false,
35
+ outputEnabled: false
36
+ });
37
+ });
38
+
39
+ test('resolveCellAttachmentState hides badge for plain python notebook mode', () => {
40
+ expect(
41
+ resolveCellAttachmentState({
42
+ includeActiveCell: true,
43
+ includeActiveCellOutput: true,
44
+ notebookMode: 'plain_py',
45
+ isNotebookEditor: true,
46
+ currentNotebookPath: '/tmp/example.py',
47
+ pairedOk: true
48
+ })
49
+ ).toEqual({
50
+ showBadge: false,
51
+ contentEnabled: false,
52
+ outputEnabled: false
53
+ });
54
+ });
55
+
56
+ test('resolveCellAttachmentState keeps the toggle visible when attachment is off', () => {
57
+ expect(
58
+ resolveCellAttachmentState({
59
+ includeActiveCell: false,
60
+ includeActiveCellOutput: true,
61
+ notebookMode: 'ipynb',
62
+ isNotebookEditor: true,
63
+ currentNotebookPath: '/tmp/example.ipynb',
64
+ pairedOk: true
65
+ })
66
+ ).toEqual({
67
+ showBadge: true,
68
+ contentEnabled: false,
69
+ outputEnabled: false
70
+ });
71
+ });