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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/unit-tests.yml +27 -0
- package/.jupyterlab-playwright.log +0 -0
- package/README.md +83 -9
- package/docs/images/codex-sidebar-screenshot.png +0 -0
- package/jupyterlab_codex/handlers.py +938 -297
- package/jupyterlab_codex/labextension/package.json +13 -3
- package/jupyterlab_codex/labextension/static/525.224526d045c727069de6.js +2 -0
- package/jupyterlab_codex/labextension/static/737.e7de3ad9dd6ded798340.js +1 -0
- package/jupyterlab_codex/labextension/static/remoteEntry.6ef5e7167763a316c000.js +1 -0
- package/jupyterlab_codex/protocol.py +297 -0
- package/jupyterlab_codex/runner.py +58 -15
- package/jupyterlab_codex/sessions.py +582 -97
- package/lib/codexChat.d.ts +13 -0
- package/lib/codexChat.js +2506 -0
- package/lib/codexChat.js.map +1 -0
- package/lib/codexChatAttachmentDedup.d.ts +10 -0
- package/lib/codexChatAttachmentDedup.js +35 -0
- package/lib/codexChatAttachmentDedup.js.map +1 -0
- package/lib/codexChatAttachmentLimit.d.ts +18 -0
- package/lib/codexChatAttachmentLimit.js +50 -0
- package/lib/codexChatAttachmentLimit.js.map +1 -0
- package/lib/codexChatAttachmentState.d.ts +15 -0
- package/lib/codexChatAttachmentState.js +16 -0
- package/lib/codexChatAttachmentState.js.map +1 -0
- package/lib/codexChatDocumentUtils.d.ts +70 -0
- package/lib/codexChatDocumentUtils.js +506 -0
- package/lib/codexChatDocumentUtils.js.map +1 -0
- package/lib/codexChatFormatting.d.ts +11 -0
- package/lib/codexChatFormatting.js +83 -0
- package/lib/codexChatFormatting.js.map +1 -0
- package/lib/codexChatNotice.d.ts +3 -0
- package/lib/codexChatNotice.js +74 -0
- package/lib/codexChatNotice.js.map +1 -0
- package/lib/codexChatPersistence.d.ts +35 -0
- package/lib/codexChatPersistence.js +158 -0
- package/lib/codexChatPersistence.js.map +1 -0
- package/lib/codexChatPrimitives.d.ts +44 -0
- package/lib/codexChatPrimitives.js +156 -0
- package/lib/codexChatPrimitives.js.map +1 -0
- package/lib/codexChatRender.d.ts +24 -0
- package/lib/codexChatRender.js +293 -0
- package/lib/codexChatRender.js.map +1 -0
- package/lib/codexChatSessionFactory.d.ts +15 -0
- package/lib/codexChatSessionFactory.js +45 -0
- package/lib/codexChatSessionFactory.js.map +1 -0
- package/lib/codexChatSessionKey.d.ts +3 -0
- package/lib/codexChatSessionKey.js +14 -0
- package/lib/codexChatSessionKey.js.map +1 -0
- package/lib/codexChatStorage.d.ts +4 -0
- package/lib/codexChatStorage.js +37 -0
- package/lib/codexChatStorage.js.map +1 -0
- package/lib/codexSessionResolver.d.ts +12 -0
- package/lib/codexSessionResolver.js +38 -0
- package/lib/codexSessionResolver.js.map +1 -0
- package/lib/handlers/activitySummarizer.d.ts +15 -0
- package/lib/handlers/activitySummarizer.js +327 -0
- package/lib/handlers/activitySummarizer.js.map +1 -0
- package/lib/handlers/codexMessageTypes.d.ts +30 -0
- package/lib/handlers/codexMessageTypes.js +2 -0
- package/lib/handlers/codexMessageTypes.js.map +1 -0
- package/lib/handlers/codexMessageUtils.d.ts +46 -0
- package/lib/handlers/codexMessageUtils.js +144 -0
- package/lib/handlers/codexMessageUtils.js.map +1 -0
- package/lib/handlers/handleCodexSocketMessage.d.ts +107 -0
- package/lib/handlers/handleCodexSocketMessage.js +78 -0
- package/lib/handlers/handleCodexSocketMessage.js.map +1 -0
- package/lib/handlers/sessionSyncHandler.d.ts +34 -0
- package/lib/handlers/sessionSyncHandler.js +181 -0
- package/lib/handlers/sessionSyncHandler.js.map +1 -0
- package/lib/hooks/useCodexSocket.d.ts +15 -0
- package/lib/hooks/useCodexSocket.js +84 -0
- package/lib/hooks/useCodexSocket.js.map +1 -0
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/panel.d.ts +1 -11
- package/lib/panel.js +1 -2815
- package/lib/panel.js.map +1 -1
- package/lib/protocol.d.ts +235 -0
- package/lib/protocol.js +278 -0
- package/lib/protocol.js.map +1 -0
- package/package.json +13 -3
- package/playwright.config.cjs +27 -0
- package/playwright.unit.config.cjs +19 -0
- package/pyproject.toml +1 -1
- package/release.sh +52 -14
- package/scripts/run_playwright_e2e.sh +96 -0
- package/scripts/run_playwright_freeze_repro.sh +58 -0
- package/scripts/run_playwright_queue_repro.sh +60 -0
- package/scripts/run_playwright_repro.sh +55 -0
- package/src/codexChat.tsx +3914 -0
- package/src/codexChatAttachmentDedup.ts +47 -0
- package/src/codexChatAttachmentLimit.ts +81 -0
- package/src/codexChatAttachmentState.ts +37 -0
- package/src/codexChatDocumentUtils.ts +644 -0
- package/src/codexChatFormatting.ts +94 -0
- package/src/codexChatNotice.ts +95 -0
- package/src/codexChatPersistence.ts +191 -0
- package/src/codexChatPrimitives.tsx +446 -0
- package/src/codexChatRender.tsx +376 -0
- package/src/codexChatSessionFactory.ts +79 -0
- package/src/codexChatSessionKey.ts +16 -0
- package/src/codexChatStorage.ts +36 -0
- package/src/codexSessionResolver.ts +56 -0
- package/src/handlers/activitySummarizer.ts +369 -0
- package/src/handlers/codexMessageTypes.ts +34 -0
- package/src/handlers/codexMessageUtils.ts +217 -0
- package/src/handlers/handleCodexSocketMessage.ts +204 -0
- package/src/handlers/sessionSyncHandler.ts +308 -0
- package/src/hooks/useCodexSocket.ts +109 -0
- package/src/index.ts +1 -1
- package/src/panel.tsx +1 -4184
- package/src/protocol.ts +582 -0
- package/style/index.css +480 -11
- package/test-results/.last-run.json +4 -0
- package/test.py +0 -0
- package/tests/e2e/cell-output-error-tail.spec.js +156 -0
- package/tests/e2e/codex-ui-test-helpers.js +138 -0
- package/tests/e2e/fixtures/notebooks/error-output-tail.ipynb +58 -0
- package/tests/e2e/fixtures/notebooks/error-output-tail.py +19 -0
- package/tests/e2e/fixtures/notebooks/tab1.ipynb +322 -0
- package/tests/e2e/fixtures/notebooks/tab1.py +272 -0
- package/tests/e2e/fixtures/notebooks/tab2.ipynb +252 -0
- package/tests/e2e/fixtures/notebooks/tab2.py +231 -0
- package/tests/e2e/fixtures/notebooks/tab3.ipynb +403 -0
- package/tests/e2e/fixtures/notebooks/tab3.py +331 -0
- package/tests/e2e/fixtures/notebooks/tab4.py +339 -0
- package/tests/e2e/freeze-notebook-tabs-repro.spec.js +295 -0
- package/tests/e2e/mock-codex-cli-flood.py +127 -0
- package/tests/e2e/mock-codex-cli-prompt-echo.py +88 -0
- package/tests/e2e/mock-codex-cli.py +95 -0
- package/tests/e2e/queue-multitab-repro.spec.js +189 -0
- package/tests/test_handlers.py +116 -0
- package/tests/test_protocol.py +169 -0
- package/tests/test_session_store_limits.py +50 -0
- package/tests/unit/codexChatAttachmentDedup.spec.ts +56 -0
- package/tests/unit/codexChatAttachmentLimit.spec.ts +57 -0
- package/tests/unit/codexChatAttachmentState.spec.ts +71 -0
- package/tests/unit/codexChatDocumentUtils.spec.ts +63 -0
- package/tests/unit/codexChatLimit.spec.ts +18 -0
- package/tests/unit/codexChatNotice.spec.ts +45 -0
- package/tests/unit/codexChatPersistence.spec.ts +199 -0
- package/tests/unit/codexChatSessionFactory.spec.ts +94 -0
- package/tests/unit/codexChatSessionKey.spec.ts +18 -0
- package/tests/unit/codexMessageUtils.spec.ts +89 -0
- package/tests/unit/codexSessionResolver.spec.ts +92 -0
- package/tests/unit/handleCodexSocketMessage.spec.ts +476 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/webpack.config.js +6 -0
- package/jupyterlab_codex/labextension/static/504.335f3447c84ba3d74517.js +0 -2
- package/jupyterlab_codex/labextension/static/972.8e856719e40acc1ef4cb.js +0 -1
- package/jupyterlab_codex/labextension/static/remoteEntry.a2982f776a1f0f515640.js +0 -1
- /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
|
+
});
|