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,92 @@
1
+ import { expect, test } from '@playwright/test';
2
+
3
+ import { resolveMessageSessionKey } from '../../src/codexSessionResolver';
4
+
5
+ test('resolveMessageSessionKey returns sessionContextKey when provided', () => {
6
+ const runToSessionKey = new Map<string, string>();
7
+ const activeSessionKeyByPath = new Map<string, string>();
8
+ const result = resolveMessageSessionKey({
9
+ message: {
10
+ sessionContextKey: 'ctx-1',
11
+ runId: 'run-1',
12
+ notebookPath: '/ignored'
13
+ },
14
+ runToSessionKey,
15
+ activeSessionKeyByPath,
16
+ currentSessionKey: 'current'
17
+ });
18
+ expect(result).toBe('ctx-1');
19
+ expect(runToSessionKey.size).toBe(0);
20
+ });
21
+
22
+ test('resolveMessageSessionKey resolves mapped run id', () => {
23
+ const runToSessionKey = new Map([['run-1', 'mapped-session']]);
24
+ const activeSessionKeyByPath = new Map<string, string>();
25
+ const result = resolveMessageSessionKey({
26
+ message: {
27
+ runId: 'run-1',
28
+ notebookPath: '/tmp/foo.ipynb'
29
+ },
30
+ runToSessionKey,
31
+ activeSessionKeyByPath,
32
+ currentSessionKey: 'current'
33
+ });
34
+ expect(result).toBe('mapped-session');
35
+ });
36
+
37
+ test('resolveMessageSessionKey caches active session by run id when available', () => {
38
+ const runToSessionKey = new Map<string, string>();
39
+ const activeSessionKeyByPath = new Map([['/tmp/foo.ipynb', 'session-foo']]);
40
+ const result = resolveMessageSessionKey({
41
+ message: {
42
+ runId: 'run-2',
43
+ notebookPath: '/tmp/foo.ipynb'
44
+ },
45
+ runToSessionKey,
46
+ activeSessionKeyByPath,
47
+ currentSessionKey: 'current'
48
+ });
49
+ expect(result).toBe('session-foo');
50
+ expect(runToSessionKey.get('run-2')).toBe('session-foo');
51
+ });
52
+
53
+ test('resolveMessageSessionKey returns active session by path when no run map exists', () => {
54
+ const runToSessionKey = new Map<string, string>();
55
+ const activeSessionKeyByPath = new Map([['/tmp/foo.ipynb', 'session-foo']]);
56
+ const result = resolveMessageSessionKey({
57
+ message: {
58
+ notebookPath: '/tmp/foo.ipynb'
59
+ },
60
+ runToSessionKey,
61
+ activeSessionKeyByPath,
62
+ currentSessionKey: 'current'
63
+ });
64
+ expect(result).toBe('session-foo');
65
+ });
66
+
67
+ test('resolveMessageSessionKey returns makeSessionKey result when path has no active session', () => {
68
+ const runToSessionKey = new Map<string, string>();
69
+ const activeSessionKeyByPath = new Map<string, string>();
70
+ const result = resolveMessageSessionKey({
71
+ message: {
72
+ notebookPath: ' /tmp/foo.ipynb '
73
+ },
74
+ runToSessionKey,
75
+ activeSessionKeyByPath,
76
+ currentSessionKey: 'current'
77
+ });
78
+ expect(result).toBe('/tmp/foo.ipynb');
79
+ });
80
+
81
+ test('resolveMessageSessionKey falls back to current session key', () => {
82
+ const runToSessionKey = new Map<string, string>();
83
+ const activeSessionKeyByPath = new Map<string, string>();
84
+ const result = resolveMessageSessionKey({
85
+ message: {},
86
+ runToSessionKey,
87
+ activeSessionKeyByPath,
88
+ currentSessionKey: 'current'
89
+ });
90
+ expect(result).toBe('current');
91
+ });
92
+
@@ -0,0 +1,476 @@
1
+ import { test, expect } from '@playwright/test';
2
+
3
+ import { parseModelCatalog } from '../../src/protocol';
4
+ import { handleCodexSocketMessage } from '../../src/handlers/handleCodexSocketMessage';
5
+ import { coerceRateLimitsSnapshot, coerceSessionHistory, truncateEnd, coerceSelectionPreview } from '../../src/handlers/codexMessageUtils';
6
+
7
+ type TextRole = 'user' | 'assistant' | 'system';
8
+
9
+ interface ChatMessage {
10
+ sessionKey: string;
11
+ role: TextRole;
12
+ text: string;
13
+ selectionPreview?: unknown;
14
+ cellOutputPreview?: unknown;
15
+ }
16
+
17
+ interface TestState {
18
+ messages: ChatMessage[];
19
+ sessionPairs: Map<string, { runState: 'ready' | 'running'; activeRunId: string | null }>;
20
+ pairings: Map<
21
+ string,
22
+ {
23
+ pairedOk: boolean | null;
24
+ pairedPath: string;
25
+ pairedOsPath: string;
26
+ pairedMessage: string;
27
+ }
28
+ >;
29
+ progress: Map<string, { progress: string; kind: string }>;
30
+ doneNotices: Array<{ sessionKey: string; notebookPath: string; cancelled: boolean; exitCode: number | null }>;
31
+ refreshCalls: string[];
32
+ rateLimits: unknown[];
33
+ deleteAllPending: boolean;
34
+ deleteAllCalls: number;
35
+ commandPath: string;
36
+ sessions: Map<string, Record<string, unknown>>;
37
+ runToSessionKey: Map<string, string>;
38
+ }
39
+
40
+ function createFixture(sessionKey = 'doc:test') {
41
+ const state: TestState = {
42
+ messages: [],
43
+ sessionPairs: new Map(),
44
+ pairings: new Map(),
45
+ progress: new Map(),
46
+ doneNotices: [],
47
+ refreshCalls: [],
48
+ rateLimits: [],
49
+ deleteAllPending: false,
50
+ deleteAllCalls: 0,
51
+ commandPath: '',
52
+ sessions: new Map(),
53
+ runToSessionKey: new Map()
54
+ };
55
+
56
+ const context = {
57
+ appendMessage(sessionKey: string, role: TextRole, text: string): void {
58
+ state.messages.push({ sessionKey, role, text });
59
+ },
60
+ clearDeleteAllPending(): void {
61
+ state.deleteAllPending = false;
62
+ },
63
+ coerceModelCatalog(rawModels: unknown) {
64
+ return parseModelCatalog(rawModels);
65
+ },
66
+ coerceReasoningEffort(value: string): string {
67
+ return value.trim().toLowerCase();
68
+ },
69
+ coerceRateLimitsSnapshot(raw: unknown) {
70
+ return coerceRateLimitsSnapshot(raw) as any;
71
+ },
72
+ coerceSessionHistory(raw: unknown) {
73
+ return coerceSessionHistory(raw);
74
+ },
75
+ coerceNotebookMode(rawMode: unknown) {
76
+ return rawMode === 'ipynb' || rawMode === 'jupytext_py' || rawMode === 'plain_py' || rawMode === 'unsupported'
77
+ ? (rawMode as 'ipynb' | 'jupytext_py' | 'plain_py' | 'unsupported')
78
+ : null;
79
+ },
80
+ createSession(path: string, intro: string, options?: { sessionKey?: string; threadId?: string; reuseStoredThread?: boolean }) {
81
+ const session = {
82
+ threadId:
83
+ options?.threadId?.trim() ||
84
+ (options?.reuseStoredThread === false ? '' : path ? `${path}:stored` : '') ||
85
+ `new-thread-${path || 'default'}`,
86
+ messages: [
87
+ {
88
+ kind: 'text',
89
+ id: 't-intro',
90
+ role: 'system',
91
+ text: intro
92
+ }
93
+ ],
94
+ runState: 'ready' as const,
95
+ activeRunId: null as string | null,
96
+ runStartedAt: null as number | null,
97
+ progress: '',
98
+ progressKind: '' as '',
99
+ pairedOk: null as boolean | null,
100
+ pairedPath: '',
101
+ pairedOsPath: '',
102
+ pairedMessage: '',
103
+ notebookMode: null as 'ipynb' | 'jupytext_py' | 'plain_py' | 'unsupported' | null,
104
+ selectedModelOption: '__config__' as const,
105
+ selectedReasoningEffort: 'medium' as const,
106
+ selectedSandboxMode: 'workspace-write' as const,
107
+ effectiveSandboxMode: null as string | null,
108
+ conversationMode: 'resume' as 'resume' | 'fallback'
109
+ };
110
+ const targetKey = options?.sessionKey || sessionKey;
111
+ state.sessions.set(targetKey, session);
112
+ return session;
113
+ },
114
+ deleteAllSessionsOnServer(): boolean {
115
+ state.deleteAllCalls += 1;
116
+ return true;
117
+ },
118
+ getCommandPath: () => state.commandPath,
119
+ getCurrentSessionKey: () => sessionKey,
120
+ getSessionThreadId(sessionKeyArg: string): string {
121
+ const session = state.sessions.get(sessionKeyArg) as { threadId?: string } | undefined;
122
+ return session?.threadId?.trim() || '';
123
+ },
124
+ getStoredSelectionPreviews: () => {
125
+ const map = new Map<string, Array<{ contentHash: string; preview: any | null }>>();
126
+ map.set('thread-restore', [
127
+ { contentHash: 'hello', preview: { selectionPreview: { locationLabel: 'Cell 1', previewText: 'stored' } } }
128
+ ]);
129
+ return map;
130
+ },
131
+ hashSelectionPreviewContent(text: string): string {
132
+ return text.trim();
133
+ },
134
+ hasDeleteAllPending: () => state.deleteAllPending,
135
+ isSessionStartNotice(text: string): boolean {
136
+ return text.startsWith('Session started');
137
+ },
138
+ markDeleteAllPending(): void {
139
+ state.deleteAllPending = true;
140
+ },
141
+ normalizeSystemText(_: TextRole, text: string): string {
142
+ return text;
143
+ },
144
+ notifyRunDone(sessionKey: string, notebookPath: string, cancelled: boolean, exitCode: number | null): void {
145
+ state.doneNotices.push({ sessionKey, notebookPath, cancelled, exitCode });
146
+ },
147
+ refreshNotebook(sessionKey: string): Promise<void> {
148
+ state.refreshCalls.push(sessionKey);
149
+ return Promise.resolve();
150
+ },
151
+ resolveMessageSessionKey(message: unknown): string {
152
+ const raw = message as Record<string, unknown>;
153
+ return (
154
+ (typeof raw?.sessionContextKey === 'string' && raw.sessionContextKey.trim()) ||
155
+ (typeof raw?.sessionId === 'string' && raw.sessionId.trim()) ||
156
+ sessionKey
157
+ );
158
+ },
159
+ runToSessionKeyRef: { current: state.runToSessionKey },
160
+ setCliDefaults: (updater: (value: any) => any) => {
161
+ return updater({ model: null, reasoningEffort: null });
162
+ },
163
+ setCommandPath: (commandPath: string): void => {
164
+ state.commandPath = commandPath;
165
+ },
166
+ setRateLimits: (value: unknown) => {
167
+ state.rateLimits.push(value);
168
+ },
169
+ setSessionConversationMode: () => {},
170
+ setSessionPairing(sessionKeyArg: string, payload: unknown) {
171
+ const next = payload as {
172
+ pairedOk: boolean | null;
173
+ pairedPath: string;
174
+ pairedOsPath: string;
175
+ pairedMessage: string;
176
+ };
177
+ state.pairings.set(sessionKeyArg, next);
178
+ },
179
+ setSessionProgress(sessionKeyArg: string, progress: string, progressKind: string) {
180
+ state.progress.set(sessionKeyArg, { progress, kind: progressKind });
181
+ },
182
+ setSessionRunState(sessionKeyArg: string, runState: 'ready' | 'running', activeRunId: string | null) {
183
+ const session = state.sessions.get(sessionKeyArg) as any;
184
+ const nextActiveRunId = runState === 'running' ? activeRunId : null;
185
+ if (session) {
186
+ session.runState = runState;
187
+ session.activeRunId = nextActiveRunId;
188
+ }
189
+ state.sessionPairs.set(sessionKeyArg, { runState, activeRunId: nextActiveRunId });
190
+ },
191
+ appendActivityItem(sessionKeyArg: string, item: { category: string; phase: string; title: string; detail: string; raw: string }) {
192
+ state.messages.push({
193
+ sessionKey: sessionKeyArg,
194
+ role: 'system',
195
+ text: `activity:${item.category}:${item.title}:${item.phase}:${item.detail}:${truncateEnd(item.raw, 20)}`
196
+ });
197
+ },
198
+ syncEffectiveSandboxFromStatus: () => {},
199
+ updateSessions(updater: (value: Map<string, Record<string, unknown>>) => Map<string, Record<string, unknown>>) {
200
+ state.sessions = updater(state.sessions);
201
+ }
202
+ };
203
+
204
+ return { state, context, sessionKey };
205
+ }
206
+
207
+ test('status message restores history and maps thread id', () => {
208
+ const { state, context } = createFixture();
209
+ context.createSession('', 'Session started', { sessionKey: 'doc:test' });
210
+
211
+ handleCodexSocketMessage(
212
+ {
213
+ type: 'status',
214
+ state: 'running',
215
+ sessionContextKey: 'doc:test',
216
+ sessionId: 'thread-restore',
217
+ notebookPath: '/notebook.ipynb',
218
+ sessionResolutionNotice: 'Session already running',
219
+ history: [{ role: 'user', content: 'hello' }, { role: 'system', content: 'ack' }]
220
+ },
221
+ context
222
+ );
223
+
224
+ const session = state.sessions.get('doc:test') as Record<string, any>;
225
+ expect(session).toBeTruthy();
226
+ expect(session.threadId).toBe('thread-restore');
227
+ const restored = session.messages.filter((entry: any) => entry.kind === 'text');
228
+ const userEntry = restored.find((entry: any) => entry.role === 'user' && entry.text === 'hello');
229
+ expect(userEntry).toBeTruthy();
230
+ expect(userEntry.selectionPreview).toEqual(coerceSelectionPreview({ locationLabel: 'Cell 1', previewText: 'stored' }));
231
+ expect(userEntry.cellOutputPreview).toBeUndefined();
232
+ const progress = state.progress.get('doc:test');
233
+ expect(progress).toBeTruthy();
234
+ expect(progress?.progress).toBe('');
235
+ expect(state.sessionPairs.get('doc:test')?.runState).toBe('running');
236
+ });
237
+
238
+ test('status message normalizes intro to canonical start text when sessionResolution is structured', () => {
239
+ const { state, context } = createFixture();
240
+ context.createSession('', 'セッション開始', { sessionKey: 'doc:test' });
241
+
242
+ handleCodexSocketMessage(
243
+ {
244
+ type: 'status',
245
+ state: 'running',
246
+ sessionContextKey: 'doc:test',
247
+ sessionId: 'thread-restore',
248
+ notebookPath: '/notebook.ipynb',
249
+ sessionResolution: 'mapping',
250
+ history: [{ role: 'user', content: 'hello' }]
251
+ },
252
+ context
253
+ );
254
+
255
+ const session = state.sessions.get('doc:test') as Record<string, any>;
256
+ const intro = session?.messages.find((entry: any) => entry.kind === 'text' && entry.role === 'system')?.text;
257
+ expect(intro).toBe('Session started');
258
+ const introEntry = session?.messages.find(
259
+ (entry: any) => entry.kind === 'text' && entry.role === 'system'
260
+ ) as { sessionResolution?: unknown } | undefined;
261
+ expect(introEntry?.sessionResolution).toBe('mapping');
262
+ });
263
+
264
+ test('status message uses existing localized intro when it is detected as a start notice and no structured sessionResolution is present', () => {
265
+ const { state, context } = createFixture();
266
+ context.createSession('', '세션 시작됨', { sessionKey: 'doc:test' });
267
+ context.isSessionStartNotice = (text: string) => text.includes('세션 시작');
268
+
269
+ handleCodexSocketMessage(
270
+ {
271
+ type: 'status',
272
+ state: 'running',
273
+ sessionContextKey: 'doc:test',
274
+ sessionId: 'thread-restore',
275
+ notebookPath: '/notebook.ipynb',
276
+ history: [{ role: 'user', content: 'hello' }]
277
+ },
278
+ context
279
+ );
280
+
281
+ const session = state.sessions.get('doc:test') as Record<string, any>;
282
+ const intro = session?.messages.find((entry: any) => entry.kind === 'text' && entry.role === 'system')?.text;
283
+ expect(intro).toBe('세션 시작됨');
284
+ });
285
+
286
+ test('status message normalizes intro to canonical start text for structured sessionResolution regardless of existing intro text', () => {
287
+ const { state, context } = createFixture();
288
+ context.createSession('', '세션 시작됨', { sessionKey: 'doc:test' });
289
+
290
+ handleCodexSocketMessage(
291
+ {
292
+ type: 'status',
293
+ state: 'running',
294
+ sessionContextKey: 'doc:test',
295
+ sessionId: 'thread-restore',
296
+ notebookPath: '/notebook.ipynb',
297
+ sessionResolution: 'new',
298
+ history: [{ role: 'user', content: 'hello' }]
299
+ },
300
+ context
301
+ );
302
+
303
+ const session = state.sessions.get('doc:test') as Record<string, any>;
304
+ const intro = session?.messages.find((entry: any) => entry.kind === 'text' && entry.role === 'system')?.text;
305
+ expect(intro).toBe('Session started');
306
+ });
307
+
308
+ test('status message treats structured sessionResolution case-insensitively', () => {
309
+ const { state, context } = createFixture();
310
+ context.createSession('', '세션 시작됨', { sessionKey: 'doc:test' });
311
+
312
+ handleCodexSocketMessage(
313
+ {
314
+ type: 'status',
315
+ state: 'running',
316
+ sessionContextKey: 'doc:test',
317
+ sessionId: 'thread-restore',
318
+ notebookPath: '/notebook.ipynb',
319
+ sessionResolution: ' New-On-Mismatch ',
320
+ history: [{ role: 'user', content: 'hello' }]
321
+ },
322
+ context
323
+ );
324
+
325
+ const session = state.sessions.get('doc:test') as Record<string, any>;
326
+ const intro = session?.messages.find((entry: any) => entry.kind === 'text' && entry.role === 'system')?.text;
327
+ expect(intro).toBe('Session started');
328
+ });
329
+
330
+ test('error message saves suggested command path and logs error', () => {
331
+ const { state, context } = createFixture();
332
+ context.createSession('', 'Session started', { sessionKey: 'doc:test' });
333
+
334
+ handleCodexSocketMessage(
335
+ {
336
+ type: 'error',
337
+ runId: 'run-error',
338
+ sessionContextKey: 'doc:test',
339
+ sessionId: 'thread-error',
340
+ message: 'boom',
341
+ suggestedCommandPath: '/usr/bin/codex'
342
+ },
343
+ context
344
+ );
345
+
346
+ expect(state.commandPath).toBe('/usr/bin/codex');
347
+ expect(state.messages.map(item => item.text).join('\n')).toContain('Suggested command path has been saved to settings: /usr/bin/codex');
348
+ expect(state.messages.map(item => item.text).join('\n')).toContain('boom');
349
+ expect(state.sessionPairs.get('doc:test')?.runState).toBe('ready');
350
+ expect(state.runToSessionKey.get('run-error')).toBeUndefined();
351
+ });
352
+
353
+ test('done message marks non-zero exit as failure and refreshes', async () => {
354
+ const { state, context } = createFixture();
355
+ context.createSession('', 'Session started', { sessionKey: 'doc:test', threadId: 'thread-done' });
356
+ state.runToSessionKey.set('run-done', 'doc:test');
357
+
358
+ handleCodexSocketMessage(
359
+ {
360
+ type: 'done',
361
+ runId: 'run-done',
362
+ sessionContextKey: 'doc:test',
363
+ sessionId: 'thread-done',
364
+ notebookPath: '/notebook.ipynb',
365
+ exitCode: 2,
366
+ fileChanged: true,
367
+ runMode: 'resume'
368
+ },
369
+ context
370
+ );
371
+
372
+ const failureText = state.messages.map(item => item.text).join('\n');
373
+ expect(failureText).toContain('Codex run failed (exit 2).');
374
+ expect(state.refreshCalls).toEqual(['doc:test']);
375
+ expect(state.doneNotices).toEqual([
376
+ { sessionKey: 'doc:test', notebookPath: '/notebook.ipynb', cancelled: false, exitCode: 2 }
377
+ ]);
378
+ expect(state.sessionPairs.get('doc:test')).toEqual({ runState: 'ready', activeRunId: null });
379
+ expect(state.runToSessionKey.has('run-done')).toBe(false);
380
+ });
381
+
382
+ test('delete_all_sessions tracks pending state on failure', () => {
383
+ const { state, context } = createFixture();
384
+ context.createSession('', 'Session started', { sessionKey: 'doc:test' });
385
+
386
+ handleCodexSocketMessage(
387
+ {
388
+ type: 'delete_all_sessions',
389
+ ok: false,
390
+ deletedCount: 1,
391
+ failedCount: 2,
392
+ message: 'network'
393
+ },
394
+ context
395
+ );
396
+
397
+ expect(state.deleteAllPending).toBe(true);
398
+ expect(state.deleteAllCalls).toBe(0);
399
+ expect(state.messages.map(item => item.text).join('\n')).toContain('Failed to delete 2 conversations');
400
+ expect(state.messages[state.messages.length - 1].text).toContain('network');
401
+ });
402
+
403
+ test('rate limit payload is normalized', () => {
404
+ const { state, context } = createFixture();
405
+ const payload = {
406
+ updatedAt: '2025-01-01T00:00:00Z',
407
+ primary: { usedPercent: 10.4, windowMinutes: 20.8, resetsAt: 11.8 },
408
+ secondary: { usedPercent: 12.3, windowMinutes: 30, resetsAt: 13 },
409
+ contextWindow: { windowTokens: 100.5, usedTokens: 80.2, leftTokens: 19.8, usedPercent: 75.4 }
410
+ };
411
+
412
+ handleCodexSocketMessage(
413
+ {
414
+ type: 'rate_limits',
415
+ snapshot: payload
416
+ },
417
+ context
418
+ );
419
+
420
+ expect(state.rateLimits).toEqual([coerceRateLimitsSnapshot(payload)]);
421
+ });
422
+
423
+ test('rate limits payload accepts numeric strings from codex server', () => {
424
+ const { state, context } = createFixture();
425
+ const payload = {
426
+ updatedAt: '2026-03-01T00:00:00Z',
427
+ primary: { usedPercent: '10.4', windowMinutes: '20.8', resetsAt: '11.8' },
428
+ secondary: { usedPercent: '12.3', windowMinutes: '30', resetsAt: '13' },
429
+ contextWindow: { windowTokens: '100.5', usedTokens: '80.2', leftTokens: '19.8', usedPercent: '75.4' }
430
+ };
431
+
432
+ handleCodexSocketMessage(
433
+ {
434
+ type: 'rate_limits',
435
+ snapshot: payload
436
+ },
437
+ context
438
+ );
439
+
440
+ expect(state.rateLimits).toEqual([coerceRateLimitsSnapshot(payload)]);
441
+ });
442
+
443
+ test('event noise is ignored and non-noise updates progress', () => {
444
+ const { state, context } = createFixture();
445
+ context.createSession('', 'Session started', { sessionKey: 'doc:test' });
446
+ const beforeNoiseMessages = state.messages.length;
447
+
448
+ handleCodexSocketMessage(
449
+ {
450
+ type: 'event',
451
+ runId: 'run-noise',
452
+ sessionContextKey: 'doc:test',
453
+ sessionId: 'thread',
454
+ notebookPath: '/notebook.ipynb',
455
+ payload: { type: 'thread.started' }
456
+ },
457
+ context
458
+ );
459
+
460
+ expect(state.messages.length).toBe(beforeNoiseMessages);
461
+
462
+ handleCodexSocketMessage(
463
+ {
464
+ type: 'event',
465
+ runId: 'run-noise',
466
+ sessionContextKey: 'doc:test',
467
+ sessionId: 'thread',
468
+ notebookPath: '/notebook.ipynb',
469
+ payload: { type: 'agent_update', detail: 'thinking' }
470
+ },
471
+ context
472
+ );
473
+
474
+ expect(state.messages.some(item => item.text.startsWith('activity:event'))).toBeTruthy();
475
+ expect(state.progress.get('doc:test')?.progress).toContain('Agent Update');
476
+ });