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,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
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import {
4
+ ACTIVE_CELL_OUTPUT_MAX_CHARS,
5
+ summarizeJupyterOutputs,
6
+ toMessageSelectionPreview
7
+ } from '../../src/codexChatDocumentUtils';
8
+
9
+ test('toMessageSelectionPreview keeps line labels for jupytext text-editor selections', () => {
10
+ expect(
11
+ toMessageSelectionPreview(
12
+ { kind: 'line', number: 7, text: 'print("hello")\nprint("world")' },
13
+ null,
14
+ 'jupytext_py',
15
+ 'print("hello")\nprint("world")'
16
+ )
17
+ ).toEqual({
18
+ locationLabel: 'Line 7',
19
+ previewText: 'print("hello")\nprint("world")'
20
+ });
21
+ });
22
+
23
+ test('toMessageSelectionPreview uses the attached text after payload truncation', () => {
24
+ expect(
25
+ toMessageSelectionPreview(
26
+ { kind: 'cell', number: 3, text: 'original content that was longer' },
27
+ null,
28
+ 'ipynb',
29
+ 'trimmed content'
30
+ )
31
+ ).toEqual({
32
+ locationLabel: 'Cell 3',
33
+ previewText: 'trimmed content'
34
+ });
35
+ });
36
+
37
+ test('summarizeJupyterOutputs keeps the start of non-error output when truncating', () => {
38
+ const output = summarizeJupyterOutputs([
39
+ {
40
+ output_type: 'stream',
41
+ name: 'stdout',
42
+ text: 'A'.repeat(ACTIVE_CELL_OUTPUT_MAX_CHARS + 100)
43
+ }
44
+ ]);
45
+
46
+ expect(output.startsWith('A'.repeat(16))).toBeTruthy();
47
+ expect(output.endsWith('... (truncated)')).toBeTruthy();
48
+ expect(output.length).toBeLessThanOrEqual(ACTIVE_CELL_OUTPUT_MAX_CHARS);
49
+ });
50
+
51
+ test('summarizeJupyterOutputs keeps the end of error output when truncating', () => {
52
+ const tail = 'ValueError: final failure details';
53
+ const output = summarizeJupyterOutputs([
54
+ {
55
+ output_type: 'error',
56
+ traceback: ['trace line', 'B'.repeat(ACTIVE_CELL_OUTPUT_MAX_CHARS), tail]
57
+ }
58
+ ]);
59
+
60
+ expect(output.startsWith('... (truncated)')).toBeTruthy();
61
+ expect(output.endsWith(tail)).toBeTruthy();
62
+ expect(output.length).toBeLessThanOrEqual(ACTIVE_CELL_OUTPUT_MAX_CHARS);
63
+ });
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { expect, test } from '@playwright/test';
4
+
5
+ test('codex chat session history cap is set to 100 entries', () => {
6
+ const source = readFileSync(resolve(__dirname, '../../src/codexChat.tsx'), 'utf8');
7
+ const match = source.match(/const MAX_SESSION_MESSAGES = (\d+);/);
8
+
9
+ expect(match).not.toBeNull();
10
+ expect(Number(match?.[1])).toBe(100);
11
+ });
12
+
13
+ test('codex chat trims session history using the configured max entry count', () => {
14
+ const source = readFileSync(resolve(__dirname, '../../src/codexChat.tsx'), 'utf8');
15
+
16
+ expect(source).toContain('if (messages.length <= MAX_SESSION_MESSAGES) {');
17
+ expect(source).toContain('return messages.slice(messages.length - MAX_SESSION_MESSAGES);');
18
+ });
@@ -0,0 +1,45 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { isSessionStartNotice, normalizeSessionStartedNotice } from '../../src/codexChatNotice';
4
+
5
+ test('normalizeSessionStartedNotice keeps English start notice format', () => {
6
+ const normalized = normalizeSessionStartedNotice('Session started (10:00 AM)');
7
+ expect(normalized).toBe('Session started (10:00 AM)');
8
+ });
9
+
10
+ test('normalizeSessionStartedNotice keeps Korean start notice', () => {
11
+ const normalized = normalizeSessionStartedNotice('세션이 시작되었습니다');
12
+ expect(normalized).toBe('세션이 시작되었습니다');
13
+ });
14
+
15
+ test('normalizeSessionStartedNotice accepts Korean start notice with timestamp', () => {
16
+ const normalized = normalizeSessionStartedNotice('새 스레드 시작됨 (14:00)');
17
+ expect(normalized).toBe('새 스레드 시작됨 (14:00)');
18
+ });
19
+
20
+ test('normalizeSessionStartedNotice accepts Japanese/Chinese locale forms', () => {
21
+ expect(normalizeSessionStartedNotice('会話を開始しました (21:45)')).toBe('会話を開始しました (21:45)');
22
+ expect(normalizeSessionStartedNotice('对话开始')).toBe('对话开始');
23
+ });
24
+
25
+ test('isSessionStartNotice accepts locale variants', () => {
26
+ expect(isSessionStartNotice('Session start: resumed')).toBe(true);
27
+ expect(isSessionStartNotice('세션 시작 됨')).toBe(true);
28
+ expect(isSessionStartNotice('세션이 시작되었습니다')).toBe(true);
29
+ expect(isSessionStartNotice('new thread started')).toBe(true);
30
+ expect(isSessionStartNotice('会話を開始しました')).toBe(true);
31
+ expect(isSessionStartNotice('会话开始')).toBe(true);
32
+ expect(isSessionStartNotice('スレッドが開始')).toBe(true);
33
+ expect(isSessionStartNotice('セッション開始')).toBe(true);
34
+ expect(isSessionStartNotice('Nueva sesión iniciada')).toBe(true);
35
+ expect(isSessionStartNotice('任意 메세지', 'new')).toBe(true);
36
+ expect(isSessionStartNotice('irrelevant text', 'mapping')).toBe(true);
37
+ });
38
+
39
+ test('isSessionStartNotice rejects unrelated system messages', () => {
40
+ expect(isSessionStartNotice('No start token found')).toBe(false);
41
+ expect(isSessionStartNotice('Authentication required')).toBe(false);
42
+ expect(isSessionStartNotice('Session is running normally')).toBe(false);
43
+ expect(isSessionStartNotice('会話は進行中です')).toBe(false);
44
+ expect(isSessionStartNotice('Session already running', null)).toBe(false);
45
+ });
@@ -0,0 +1,199 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import {
4
+ coerceSessionThreadSyncEvent,
5
+ buildSessionThreadSyncEvent,
6
+ getStoredSessionThreadCount,
7
+ hasDeleteAllPending,
8
+ hasStoredDeleteAllPending,
9
+ markDeleteAllPending,
10
+ parseSessionKey,
11
+ persistStoredSessionThreads,
12
+ readStoredSessionThreads,
13
+ readStoredThreadId,
14
+ STORAGE_KEY_SESSION_THREADS,
15
+ STORAGE_KEY_SESSION_THREADS_EVENT,
16
+ writeSessionThreadSyncEvent,
17
+ clearDeleteAllPending,
18
+ STORAGE_KEY_DELETE_ALL_PENDING
19
+ } from '../../src/codexChatPersistence';
20
+
21
+ type FakeStorage = {
22
+ clear: () => void;
23
+ getItem: (key: string) => string | null;
24
+ removeItem: (key: string) => void;
25
+ setItem: (key: string, value: string) => void;
26
+ };
27
+
28
+ function createInMemoryStorage(): FakeStorage {
29
+ const data = new Map<string, string>();
30
+ return {
31
+ clear: () => {
32
+ data.clear();
33
+ },
34
+ getItem: key => data.get(key) ?? null,
35
+ removeItem: key => {
36
+ data.delete(key);
37
+ },
38
+ setItem: (key, value) => {
39
+ data.set(key, value);
40
+ }
41
+ };
42
+ }
43
+
44
+ const fakeStorage = createInMemoryStorage();
45
+ (globalThis as any).window = {
46
+ localStorage: fakeStorage
47
+ } as any;
48
+
49
+ test.beforeEach(() => {
50
+ fakeStorage.clear();
51
+ });
52
+
53
+ function separatorText(): string {
54
+ return '\u0000';
55
+ }
56
+
57
+ test('parseSessionKey returns raw session path when no separator exists', () => {
58
+ const parsed = parseSessionKey('/tmp/notebook.ipynb');
59
+ expect(parsed.path).toBe('/tmp/notebook.ipynb');
60
+ });
61
+
62
+ test('parseSessionKey extracts path before first separator when present', () => {
63
+ const path = '/tmp/notebook.ipynb';
64
+ const key = `${path}${separatorText()}run`;
65
+ expect(parseSessionKey(key).path).toBe(path);
66
+ });
67
+
68
+ test('getStoredSessionThreadCount dedupes by normalized path', () => {
69
+ fakeStorage.clear();
70
+ const path = '/tmp/notebook.ipynb';
71
+ fakeStorage.setItem(
72
+ STORAGE_KEY_SESSION_THREADS,
73
+ JSON.stringify({
74
+ [`${path}${separatorText()}abc`]: 'thread-abc',
75
+ [`${path}${separatorText()}def`]: 'thread-def',
76
+ [`other${separatorText()}x`]: 'thread-x'
77
+ })
78
+ );
79
+
80
+ expect(getStoredSessionThreadCount()).toBe(2);
81
+ });
82
+
83
+ test('readStoredThreadId uses exact session key first then fallback by path', () => {
84
+ fakeStorage.clear();
85
+ fakeStorage.setItem(
86
+ STORAGE_KEY_SESSION_THREADS,
87
+ JSON.stringify({
88
+ [`/tmp/foo${separatorText()}one`]: 'thread-one',
89
+ [`/tmp/foo${separatorText()}two`]: 'thread-two'
90
+ })
91
+ );
92
+
93
+ expect(readStoredThreadId('/tmp/foo', `/tmp/foo${separatorText()}two`)).toBe('thread-two');
94
+ expect(readStoredThreadId('/tmp/foo', '/tmp/foo:missing')).toBe('thread-one');
95
+ expect(readStoredThreadId('/tmp/bar', `/tmp/bar${separatorText()}x`)).toBe('');
96
+ });
97
+
98
+ test('persistStoredSessionThreads stores only valid session keys with thread ids', () => {
99
+ fakeStorage.clear();
100
+ const sessions = new Map([
101
+ ['doc-a', { threadId: 'thread-a' }],
102
+ ['', { threadId: 'ignored' }],
103
+ ['doc-b', {} as { threadId?: string }],
104
+ ['doc-c', { threadId: 'thread-c' }]
105
+ ]);
106
+ persistStoredSessionThreads(sessions);
107
+
108
+ const raw = fakeStorage.getItem(STORAGE_KEY_SESSION_THREADS);
109
+ expect(raw).toBeTruthy();
110
+ const parsed = raw ? JSON.parse(raw) : {};
111
+ expect(parsed).toEqual({
112
+ 'doc-a': 'thread-a',
113
+ 'doc-c': 'thread-c'
114
+ });
115
+ });
116
+
117
+ test('readStoredSessionThreads parses valid json object', () => {
118
+ fakeStorage.clear();
119
+ fakeStorage.setItem(STORAGE_KEY_SESSION_THREADS, JSON.stringify({ docA: 'thread-a', docB: 1 }));
120
+ expect(readStoredSessionThreads()).toEqual({ docA: 'thread-a' });
121
+ });
122
+
123
+ test('coerceSessionThreadSyncEvent parses and validates payload', () => {
124
+ const payload = JSON.stringify({
125
+ kind: 'new-thread',
126
+ sessionKey: ' docA ',
127
+ notebookPath: ' /tmp/file.ipynb ',
128
+ threadId: ' t1 ',
129
+ source: 'local-tab',
130
+ id: 'evt-1',
131
+ issuedAt: 1719852800000
132
+ });
133
+ const event = coerceSessionThreadSyncEvent(payload);
134
+ expect(event).toEqual({
135
+ kind: 'new-thread',
136
+ sessionKey: 'docA',
137
+ notebookPath: '/tmp/file.ipynb',
138
+ threadId: 't1',
139
+ source: 'local-tab',
140
+ id: 'evt-1',
141
+ issuedAt: 1719852800000
142
+ });
143
+ });
144
+
145
+ test('coerceSessionThreadSyncEvent returns null for malformed payload', () => {
146
+ expect(coerceSessionThreadSyncEvent('not-json')).toBeNull();
147
+ expect(coerceSessionThreadSyncEvent(JSON.stringify({ kind: 'wrong', sessionKey: 'a', notebookPath: 'b', threadId: 'c', id: 'd' }))).toBeNull();
148
+ });
149
+
150
+ test('buildSessionThreadSyncEvent normalizes values and sets issue time', () => {
151
+ const issuedAt = Date.now();
152
+ const event = buildSessionThreadSyncEvent({
153
+ sessionKey: ' /tmp/doc ',
154
+ notebookPath: '/tmp/doc ',
155
+ threadId: ' thread-1 ',
156
+ source: ' local ',
157
+ createEventId: () => 'evt-1'
158
+ });
159
+ expect(event).toMatchObject({
160
+ kind: 'new-thread',
161
+ sessionKey: '/tmp/doc',
162
+ notebookPath: '/tmp/doc',
163
+ threadId: 'thread-1',
164
+ source: 'local',
165
+ id: 'evt-1'
166
+ });
167
+ expect(event.issuedAt).toBeGreaterThanOrEqual(issuedAt);
168
+ });
169
+
170
+ test('writeSessionThreadSyncEvent persists serialized event', () => {
171
+ fakeStorage.clear();
172
+ const event = buildSessionThreadSyncEvent({
173
+ sessionKey: 'doc',
174
+ notebookPath: '/tmp/doc',
175
+ threadId: 't1',
176
+ source: 'local',
177
+ createEventId: () => 'evt-1'
178
+ });
179
+ writeSessionThreadSyncEvent(event);
180
+
181
+ const saved = fakeStorage.getItem(STORAGE_KEY_SESSION_THREADS_EVENT);
182
+ expect(saved).toBeTruthy();
183
+ expect(saved).toEqual(JSON.stringify(event));
184
+ });
185
+
186
+ test('delete-all pending helpers track persistence state', () => {
187
+ fakeStorage.clear();
188
+ expect(hasDeleteAllPending()).toBe(false);
189
+ expect(hasStoredDeleteAllPending()).toBe(false);
190
+
191
+ markDeleteAllPending();
192
+ expect(hasDeleteAllPending()).toBe(true);
193
+ expect(hasStoredDeleteAllPending()).toBe(true);
194
+ expect(fakeStorage.getItem(STORAGE_KEY_DELETE_ALL_PENDING)).toBe('1');
195
+
196
+ clearDeleteAllPending();
197
+ expect(hasDeleteAllPending()).toBe(false);
198
+ expect(hasStoredDeleteAllPending()).toBe(false);
199
+ });
@@ -0,0 +1,94 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { createSession, createThreadResetSession } from '../../src/codexChatSessionFactory';
4
+
5
+ const defaultDeps = {
6
+ readStoredThreadId(path: string, sessionKey: string): string {
7
+ return sessionKey === 'stored' && path === '/tmp/notebook' ? 'stored-thread' : '';
8
+ },
9
+ readDefaultModelOption(): string {
10
+ return '__config__';
11
+ },
12
+ readDefaultReasoningEffortOption(): string {
13
+ return 'medium';
14
+ },
15
+ readDefaultSandboxModeOption(): string {
16
+ return 'workspace-write';
17
+ },
18
+ normalizeSystemText(role: 'user' | 'assistant' | 'system', text: string): string {
19
+ return role === 'system' && text.startsWith('Session started') ? `normalized:${text}` : text;
20
+ }
21
+ };
22
+
23
+ test('createSession falls back to stored thread id when thread id missing', () => {
24
+ const session = createSession<{ threadId: string; messages: Array<{ role: string; text: string }>; [key: string]: unknown }>(
25
+ '/tmp/notebook',
26
+ 'Session started',
27
+ { sessionKey: 'stored' },
28
+ defaultDeps
29
+ );
30
+ expect(session.threadId).toBe('stored-thread');
31
+ });
32
+
33
+ test('createSession uses explicit thread id when provided', () => {
34
+ const session = createSession<{ threadId: string; messages: Array<{ role: string; text: string }>; [key: string]: unknown }>(
35
+ '/tmp/notebook',
36
+ 'Session started',
37
+ { threadId: 'explicit-thread' },
38
+ defaultDeps
39
+ );
40
+ expect(session.threadId).toBe('explicit-thread');
41
+ });
42
+
43
+ test('createSession seeds selected defaults and system text normalized', () => {
44
+ const session = createSession<{
45
+ threadId: string;
46
+ messages: Array<{ kind: 'text'; role: 'system'; text: string }>;
47
+ selectedModelOption: string;
48
+ selectedReasoningEffort: string;
49
+ selectedSandboxMode: string;
50
+ effectiveSandboxMode: unknown;
51
+ conversationMode: 'resume' | 'fallback';
52
+ notebookMode: null;
53
+ runState: 'ready' | 'running';
54
+ activeRunId: string | null;
55
+ runStartedAt: number | null;
56
+ progress: string;
57
+ progressKind: '' | 'reasoning';
58
+ pairedOk: boolean | null;
59
+ pairedPath: string;
60
+ pairedOsPath: string;
61
+ pairedMessage: string;
62
+ }>(
63
+ '/tmp/notebook',
64
+ 'Session started',
65
+ { sessionKey: '' },
66
+ defaultDeps
67
+ );
68
+ expect(session.selectedModelOption).toBe('__config__');
69
+ expect(session.selectedReasoningEffort).toBe('medium');
70
+ expect(session.selectedSandboxMode).toBe('workspace-write');
71
+ expect(session.conversationMode).toBe('resume');
72
+ expect(session.messages[0].text).toBe('normalized:Session started');
73
+ expect(session.progress).toBe('');
74
+ expect(session.runState).toBe('ready');
75
+ });
76
+
77
+ test('createThreadResetSession uses time label and forced thread id', () => {
78
+ const originalToLocaleTimeString = Date.prototype.toLocaleTimeString;
79
+ Date.prototype.toLocaleTimeString = () => '14:00:00';
80
+ const session = createThreadResetSession<{
81
+ threadId: string;
82
+ messages: Array<{ kind: 'text'; role: string; text: string }>;
83
+ [key: string]: unknown;
84
+ }>(
85
+ '/tmp/notebook',
86
+ 'doc-key',
87
+ 'thread-reset',
88
+ defaultDeps
89
+ );
90
+ expect(session.threadId).toBe('thread-reset');
91
+ expect(session.messages[0].text).toBe('normalized:Session started (14:00:00)');
92
+ Date.prototype.toLocaleTimeString = originalToLocaleTimeString;
93
+ });
94
+
@@ -0,0 +1,18 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { makeSessionKey, resolveCurrentSessionKey, resolveSessionKey } from '../../src/codexChatSessionKey';
4
+
5
+ test('makeSessionKey trims and keeps empty fallback', () => {
6
+ expect(makeSessionKey(' /tmp/notebook.ipynb ')).toBe('/tmp/notebook.ipynb');
7
+ expect(makeSessionKey(' ')).toBe('');
8
+ });
9
+
10
+ test('resolveSessionKey normalizes via makeSessionKey', () => {
11
+ expect(resolveSessionKey('/tmp/notebook.ipynb')).toBe('/tmp/notebook.ipynb');
12
+ expect(resolveSessionKey(' ')).toBe('');
13
+ });
14
+
15
+ test('resolveCurrentSessionKey delegates to resolveSessionKey', () => {
16
+ expect(resolveCurrentSessionKey('/tmp/other.ipynb')).toBe('/tmp/other.ipynb');
17
+ });
18
+
@@ -0,0 +1,89 @@
1
+ import { test, expect } from '@playwright/test';
2
+
3
+ import {
4
+ coerceMessageContextPreview,
5
+ coerceRateLimitsSnapshot,
6
+ coerceSelectionPreview,
7
+ coerceSessionHistory,
8
+ splitStoredMessagePreview,
9
+ truncateEnd
10
+ } from '../../src/handlers/codexMessageUtils';
11
+
12
+ test('coerceSelectionPreview enforces required fields', () => {
13
+ expect(coerceSelectionPreview({ locationLabel: 'Cell 1', previewText: 'hello' })).toEqual({
14
+ locationLabel: 'Cell 1',
15
+ previewText: 'hello'
16
+ });
17
+ expect(coerceSelectionPreview({ locationLabel: '', previewText: 'hello' })).toBeUndefined();
18
+ expect(truncateEnd('abcdef', 3)).toBe('abc');
19
+ expect(truncateEnd('abcdef', 6)).toBe('abcdef');
20
+ });
21
+
22
+ test('coerceMessageContextPreview supports legacy and nested shapes', () => {
23
+ expect(
24
+ coerceMessageContextPreview({
25
+ selectionPreview: { locationLabel: 'Selection', previewText: 'abc' },
26
+ cellOutputPreview: { locationLabel: 'Output', previewText: 'xyz' }
27
+ })
28
+ ).toEqual({
29
+ selectionPreview: { locationLabel: 'Selection', previewText: 'abc' },
30
+ cellOutputPreview: { locationLabel: 'Output', previewText: 'xyz' }
31
+ });
32
+
33
+ expect(coerceMessageContextPreview({ locationLabel: 'Legacy', previewText: 'text' })).toEqual({
34
+ selectionPreview: { locationLabel: 'Legacy', previewText: 'text' }
35
+ });
36
+ });
37
+
38
+ test('coerceSessionHistory drops invalid entries', () => {
39
+ expect(
40
+ coerceSessionHistory([
41
+ { role: 'user', content: 'hello', selectionPreview: { locationLabel: 'Cell', previewText: 'x' } },
42
+ { role: 'invalid', content: 'bad' },
43
+ { role: 'assistant', content: 'ok', extra: 'x' }
44
+ ])
45
+ ).toEqual([
46
+ { role: 'user', content: 'hello', selectionPreview: { locationLabel: 'Cell', previewText: 'x' }, cellOutputPreview: undefined },
47
+ { role: 'assistant', content: 'ok', selectionPreview: undefined, cellOutputPreview: undefined }
48
+ ]);
49
+ });
50
+
51
+ test('coerceRateLimitsSnapshot normalizes nested windows and context window', () => {
52
+ const snapshot = coerceRateLimitsSnapshot({
53
+ updatedAt: '2024-01-01T00:00:00Z',
54
+ primary: { usedPercent: '20.1', windowMinutes: '10.2', resetsAt: '99.9' },
55
+ secondary: { usedPercent: '12.2', windowMinutes: '5', resetsAt: '40' },
56
+ contextWindow: { windowTokens: '10.5', usedTokens: '5.2', leftTokens: '4.8', usedPercent: '50.5' }
57
+ });
58
+ expect(snapshot).toEqual({
59
+ updatedAt: '2024-01-01T00:00:00Z',
60
+ primary: { usedPercent: 20.1, windowMinutes: 10, resetsAt: 100 },
61
+ secondary: { usedPercent: 12.2, windowMinutes: 5, resetsAt: 40 },
62
+ contextWindow: { windowTokens: 11, usedTokens: 5, leftTokens: 5, usedPercent: 50.5 }
63
+ });
64
+ });
65
+
66
+ test('coerceRateLimitsSnapshot treats blank numeric strings as null', () => {
67
+ const snapshot = coerceRateLimitsSnapshot({
68
+ updatedAt: '2024-01-01T00:00:00Z',
69
+ primary: { usedPercent: '', windowMinutes: ' ', resetsAt: 'NaN' },
70
+ contextWindow: { windowTokens: '', usedTokens: ' ', leftTokens: 'none', usedPercent: '50' }
71
+ });
72
+ expect(snapshot).toEqual({
73
+ updatedAt: '2024-01-01T00:00:00Z',
74
+ primary: { usedPercent: null, windowMinutes: null, resetsAt: null },
75
+ secondary: null,
76
+ contextWindow: { windowTokens: null, usedTokens: null, leftTokens: null, usedPercent: 50 }
77
+ });
78
+ });
79
+
80
+ test('splitStoredMessagePreview handles nested and legacy payloads', () => {
81
+ expect(splitStoredMessagePreview({ selectionPreview: { a: 1 }, cellOutputPreview: { b: 2 } })).toEqual({
82
+ selectionPreview: { a: 1 },
83
+ cellOutputPreview: { b: 2 }
84
+ });
85
+
86
+ expect(splitStoredMessagePreview({ locationLabel: 'x', previewText: 'y' })).toEqual({
87
+ selectionPreview: { locationLabel: 'x', previewText: 'y' }
88
+ });
89
+ });