@oceanum/oceanumlab 4.6.0
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/LICENSE +29 -0
- package/README.md +116 -0
- package/lib/DatameshUI.d.ts +5 -0
- package/lib/DatameshUI.js +8 -0
- package/lib/DatameshUI.js.map +1 -0
- package/lib/DatameshWidget.d.ts +59 -0
- package/lib/DatameshWidget.js +573 -0
- package/lib/DatameshWidget.js.map +1 -0
- package/lib/DatasourceItem.d.ts +12 -0
- package/lib/DatasourceItem.js +47 -0
- package/lib/DatasourceItem.js.map +1 -0
- package/lib/aiLoop.d.ts +60 -0
- package/lib/aiLoop.js +85 -0
- package/lib/aiLoop.js.map +1 -0
- package/lib/chatRouter.d.ts +64 -0
- package/lib/chatRouter.js +225 -0
- package/lib/chatRouter.js.map +1 -0
- package/lib/constants.d.ts +14 -0
- package/lib/constants.js +15 -0
- package/lib/constants.js.map +1 -0
- package/lib/conversationPin.d.ts +68 -0
- package/lib/conversationPin.js +120 -0
- package/lib/conversationPin.js.map +1 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/handler.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.js +273 -0
- package/lib/index.js.map +1 -0
- package/lib/kernelHandoff.d.ts +61 -0
- package/lib/kernelHandoff.js +236 -0
- package/lib/kernelHandoff.js.map +1 -0
- package/lib/notebookContext.d.ts +48 -0
- package/lib/notebookContext.js +118 -0
- package/lib/notebookContext.js.map +1 -0
- package/lib/notebookHost.d.ts +6 -0
- package/lib/notebookHost.js +72 -0
- package/lib/notebookHost.js.map +1 -0
- package/lib/notebookRun.d.ts +37 -0
- package/lib/notebookRun.js +70 -0
- package/lib/notebookRun.js.map +1 -0
- package/lib/progress.d.ts +43 -0
- package/lib/progress.js +103 -0
- package/lib/progress.js.map +1 -0
- package/lib/sse.d.ts +26 -0
- package/lib/sse.js +112 -0
- package/lib/sse.js.map +1 -0
- package/package.json +151 -0
- package/schema/datamesh-connect.json +35 -0
- package/src/DatameshUI.tsx +15 -0
- package/src/DatameshWidget.tsx +909 -0
- package/src/DatasourceItem.tsx +124 -0
- package/src/__tests__/aiLoop.spec.ts +304 -0
- package/src/__tests__/chatRouter.spec.ts +315 -0
- package/src/__tests__/conversationPin.spec.ts +236 -0
- package/src/__tests__/kernelHandoff.spec.ts +106 -0
- package/src/__tests__/notebookContext.spec.ts +97 -0
- package/src/__tests__/notebookHost.spec.ts +195 -0
- package/src/__tests__/notebookRun.spec.ts +95 -0
- package/src/__tests__/notebookSnapshot.spec.ts +62 -0
- package/src/__tests__/oceanumlab.spec.ts +9 -0
- package/src/__tests__/sse.spec.ts +304 -0
- package/src/aiLoop.ts +140 -0
- package/src/chatRouter.ts +329 -0
- package/src/constants.ts +16 -0
- package/src/conversationPin.ts +148 -0
- package/src/global.d.ts +5 -0
- package/src/handler.ts +46 -0
- package/src/index.ts +327 -0
- package/src/kernelHandoff.ts +284 -0
- package/src/notebookContext.ts +144 -0
- package/src/notebookHost.ts +97 -0
- package/src/notebookRun.ts +104 -0
- package/src/progress.ts +121 -0
- package/src/sse.ts +127 -0
- package/src/svg.d.ts +4 -0
- package/style/base.css +5 -0
- package/style/icons/oceanum-dark.svg +1 -0
- package/style/icons/oceanum-light.svg +1 -0
- package/style/icons/oceanum.png +0 -0
- package/style/icons/oceanum.svg +1 -0
- package/style/index.css +477 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { ChatRouter } from '../chatRouter';
|
|
2
|
+
import type { INotebookSnapshot } from '../notebookContext';
|
|
3
|
+
|
|
4
|
+
const settings = {
|
|
5
|
+
get: () => ({ composite: 'a-token' })
|
|
6
|
+
} as unknown as ConstructorParameters<typeof ChatRouter>[0];
|
|
7
|
+
|
|
8
|
+
const snapshot = (
|
|
9
|
+
cells: INotebookSnapshot['cells'],
|
|
10
|
+
selected: INotebookSnapshot['selected'] = null
|
|
11
|
+
): INotebookSnapshot => ({ cells, selected });
|
|
12
|
+
|
|
13
|
+
let sent: Record<string, unknown> | undefined;
|
|
14
|
+
let sentHeaders: Record<string, string> | undefined;
|
|
15
|
+
|
|
16
|
+
interface IRequestInit {
|
|
17
|
+
body: string;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Answer every request with what `response` builds from it. */
|
|
23
|
+
function answering(response: (init: IRequestInit) => unknown): void {
|
|
24
|
+
(globalThis as any).fetch = jest.fn(
|
|
25
|
+
async (_url: string, init: IRequestInit) => {
|
|
26
|
+
sent = JSON.parse(init.body);
|
|
27
|
+
sentHeaders = init.headers;
|
|
28
|
+
return response(init);
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
sent = undefined;
|
|
35
|
+
sentHeaders = undefined;
|
|
36
|
+
answering(() => ({
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 200,
|
|
39
|
+
headers: { get: () => 'application/json' },
|
|
40
|
+
json: async (): Promise<unknown> => ({ message: 'ok', blocks: [] })
|
|
41
|
+
}));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const frame = (event: string, data: unknown): string =>
|
|
45
|
+
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A 200 whose body streams `frames` and ends -- or, with `hang`, then stays
|
|
49
|
+
* open until `signal` aborts, failing the read in flight as fetch does.
|
|
50
|
+
*/
|
|
51
|
+
function streamed(frames: string[], signal?: AbortSignal, hang = false) {
|
|
52
|
+
const encoder = new TextEncoder();
|
|
53
|
+
const chunks = frames.map(f => encoder.encode(f));
|
|
54
|
+
let index = 0;
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: { get: () => 'text/event-stream' },
|
|
59
|
+
json: async (): Promise<unknown> => {
|
|
60
|
+
throw new Error('a stream is not JSON');
|
|
61
|
+
},
|
|
62
|
+
body: {
|
|
63
|
+
getReader: () => ({
|
|
64
|
+
read: (): Promise<{ done: boolean; value?: Uint8Array }> => {
|
|
65
|
+
if (index < chunks.length) {
|
|
66
|
+
return Promise.resolve({ done: false, value: chunks[index++] });
|
|
67
|
+
}
|
|
68
|
+
if (!hang) {
|
|
69
|
+
return Promise.resolve({ done: true, value: undefined });
|
|
70
|
+
}
|
|
71
|
+
return new Promise((_ok, fail) => {
|
|
72
|
+
signal?.addEventListener('abort', () =>
|
|
73
|
+
fail(new DOMException('Aborted', 'AbortError'))
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
releaseLock: (): void => undefined
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('ChatRouter: the notebook sent with a request', () => {
|
|
84
|
+
it('is the conversation notebook, code and markdown', async () => {
|
|
85
|
+
const router = new ChatRouter(settings, async () =>
|
|
86
|
+
snapshot([
|
|
87
|
+
{ kind: 'code', source: 'x = 1' },
|
|
88
|
+
{ kind: 'markdown', source: '## Notes' },
|
|
89
|
+
{ kind: 'code', source: 'y = x' }
|
|
90
|
+
])
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
await router.route('what does this do?');
|
|
94
|
+
|
|
95
|
+
expect(sent?.notebookCells).toEqual([
|
|
96
|
+
'x = 1',
|
|
97
|
+
'# %% [markdown]\n# ## Notes',
|
|
98
|
+
'y = x'
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('carries the selected cell of that notebook, which a code answer may replace', async () => {
|
|
103
|
+
const router = new ChatRouter(settings, async () =>
|
|
104
|
+
snapshot([{ kind: 'code', source: 'df.head()' }], {
|
|
105
|
+
source: 'df.head()',
|
|
106
|
+
isCode: true
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const result = await router.route('fix this');
|
|
111
|
+
|
|
112
|
+
expect(sent?.codeContext).toBe('df.head()');
|
|
113
|
+
expect(result.hasCodeCellSelected).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('asks for the notebook on every request, so it follows the conversation', async () => {
|
|
117
|
+
let current = snapshot([{ kind: 'code', source: 'a = 1' }]);
|
|
118
|
+
const router = new ChatRouter(settings, async () => current);
|
|
119
|
+
|
|
120
|
+
await router.route('one');
|
|
121
|
+
expect(sent?.notebookCells).toEqual(['a = 1']);
|
|
122
|
+
|
|
123
|
+
current = snapshot([{ kind: 'code', source: 'b = 2' }]);
|
|
124
|
+
await router.route('two');
|
|
125
|
+
expect(sent?.notebookCells).toEqual(['b = 2']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('sends no notebook when there is none', async () => {
|
|
129
|
+
const router = new ChatRouter(settings, async () => null);
|
|
130
|
+
|
|
131
|
+
await router.route('hello');
|
|
132
|
+
|
|
133
|
+
expect(sent?.notebookCells).toBeUndefined();
|
|
134
|
+
expect(sent?.codeContext).toBeUndefined();
|
|
135
|
+
expect(sent?.context).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('still sends the question when the notebook cannot be read', async () => {
|
|
139
|
+
// Context is best-effort; a notebook that could not be read must not
|
|
140
|
+
// cost the user the answer.
|
|
141
|
+
const router = new ChatRouter(settings, async () => {
|
|
142
|
+
throw new Error('could not read');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await router.route('hello');
|
|
146
|
+
|
|
147
|
+
expect(sent?.prompt).toBe('hello');
|
|
148
|
+
expect(sent?.notebookCells).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('ChatRouter: the streamed answer', () => {
|
|
153
|
+
const noNotebook = async (): Promise<null> => null;
|
|
154
|
+
|
|
155
|
+
it('asks for the stream only when someone is listening', async () => {
|
|
156
|
+
// Without a listener the request is byte-identical to what it always was.
|
|
157
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
158
|
+
|
|
159
|
+
await router.route('hello');
|
|
160
|
+
expect(sentHeaders?.Accept).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
await router.route('hello', [], undefined, () => undefined);
|
|
163
|
+
expect(sentHeaders?.Accept).toBe('text/event-stream');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('reports each phase, and takes the answer from done', async () => {
|
|
167
|
+
// The events before `done` are for the user to look at. Assembled from
|
|
168
|
+
// them, the answer would come out short whenever a frame went missing.
|
|
169
|
+
answering(() =>
|
|
170
|
+
streamed([
|
|
171
|
+
frame('status', { phase: 'generating' }),
|
|
172
|
+
': keepalive\n\n',
|
|
173
|
+
frame('status', { phase: 'tool', tool: 'search_catalog' }),
|
|
174
|
+
frame('message', { message: 'partial' }),
|
|
175
|
+
frame('done', {
|
|
176
|
+
message: 'The whole answer.',
|
|
177
|
+
blocks: [{ type: 'code', content: 'x = 1' }]
|
|
178
|
+
})
|
|
179
|
+
])
|
|
180
|
+
);
|
|
181
|
+
const progress: unknown[] = [];
|
|
182
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
183
|
+
|
|
184
|
+
const result = await router.route('find waves', [], undefined, p =>
|
|
185
|
+
progress.push(p)
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(progress).toEqual([
|
|
189
|
+
{ phase: 'generating' },
|
|
190
|
+
{ phase: 'tool', tool: 'search_catalog' }
|
|
191
|
+
]);
|
|
192
|
+
expect(result.response).toEqual({
|
|
193
|
+
message: 'The whole answer.',
|
|
194
|
+
blocks: [{ type: 'code', content: 'x = 1' }]
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('reads JSON when the server answers with JSON anyway', async () => {
|
|
199
|
+
// An older backend, or a proxy that buffered the stream away.
|
|
200
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
201
|
+
|
|
202
|
+
const result = await router.route('hello', [], undefined, () => undefined);
|
|
203
|
+
|
|
204
|
+
expect(result.response).toEqual({ message: 'ok', blocks: [] });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('turns an error event into the error the chat shows', async () => {
|
|
208
|
+
// The status was already 200, so the failure can only arrive as an event.
|
|
209
|
+
answering(() =>
|
|
210
|
+
streamed([
|
|
211
|
+
frame('status', { phase: 'generating' }),
|
|
212
|
+
frame('error', {
|
|
213
|
+
detail: 'Internal error',
|
|
214
|
+
status_code: 500,
|
|
215
|
+
code: 'internal_error'
|
|
216
|
+
})
|
|
217
|
+
])
|
|
218
|
+
);
|
|
219
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
220
|
+
|
|
221
|
+
await expect(
|
|
222
|
+
router.route('hi', [], undefined, () => undefined)
|
|
223
|
+
).rejects.toMatchObject({
|
|
224
|
+
name: 'ChatRouterError',
|
|
225
|
+
message: 'Backend error: Internal error',
|
|
226
|
+
statusCode: 500
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('fails a stream that ends without its answer', async () => {
|
|
231
|
+
// A dropped connection: a failure, not a shorter answer.
|
|
232
|
+
answering(() => streamed([frame('status', { phase: 'generating' })]));
|
|
233
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
234
|
+
|
|
235
|
+
await expect(
|
|
236
|
+
router.route('hi', [], undefined, () => undefined)
|
|
237
|
+
).rejects.toThrow('The response ended before it was complete.');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('reports a connection lost mid-answer as an error, not silence', async () => {
|
|
241
|
+
// A redeploy or a network drop fails the read in flight with a bare
|
|
242
|
+
// TypeError. Unwrapped, it reached the command as unexpected, and the chat
|
|
243
|
+
// showed an empty answer.
|
|
244
|
+
let reads = 0;
|
|
245
|
+
answering(() => ({
|
|
246
|
+
...streamed([]),
|
|
247
|
+
body: {
|
|
248
|
+
getReader: () => ({
|
|
249
|
+
read: (): Promise<{ done: boolean; value?: Uint8Array }> => {
|
|
250
|
+
reads += 1;
|
|
251
|
+
return reads === 1
|
|
252
|
+
? Promise.resolve({
|
|
253
|
+
done: false,
|
|
254
|
+
value: new TextEncoder().encode(
|
|
255
|
+
frame('status', { phase: 'generating' })
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
: Promise.reject(new TypeError('terminated'));
|
|
259
|
+
},
|
|
260
|
+
releaseLock: (): void => undefined
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
}));
|
|
264
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
265
|
+
|
|
266
|
+
await expect(
|
|
267
|
+
router.route('hi', [], undefined, () => undefined)
|
|
268
|
+
).rejects.toMatchObject({
|
|
269
|
+
name: 'ChatRouterError',
|
|
270
|
+
message:
|
|
271
|
+
'The connection to Oceanum AI was lost before the response was complete.'
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('streams the observe round too', async () => {
|
|
276
|
+
answering(() =>
|
|
277
|
+
streamed([
|
|
278
|
+
frame('status', { phase: 'interpreting' }),
|
|
279
|
+
frame('done', { message: 'Read it.', blocks: [] })
|
|
280
|
+
])
|
|
281
|
+
);
|
|
282
|
+
const progress: unknown[] = [];
|
|
283
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
284
|
+
|
|
285
|
+
const response = await router.observe(
|
|
286
|
+
'hi',
|
|
287
|
+
[],
|
|
288
|
+
[{ code: 'x', status: 'ok', stdout: '', error: null, message: 'm' }],
|
|
289
|
+
undefined,
|
|
290
|
+
p => progress.push(p)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(sentHeaders?.Accept).toBe('text/event-stream');
|
|
294
|
+
expect(progress).toEqual([{ phase: 'interpreting' }]);
|
|
295
|
+
expect(response).toEqual({ message: 'Read it.', blocks: [] });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('stops in the middle of the stream when the request is aborted', async () => {
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
answering(init =>
|
|
301
|
+
streamed([frame('status', { phase: 'generating' })], init.signal, true)
|
|
302
|
+
);
|
|
303
|
+
const progress: unknown[] = [];
|
|
304
|
+
const router = new ChatRouter(settings, noNotebook);
|
|
305
|
+
|
|
306
|
+
const pending = router.route('hi', [], controller.signal, p =>
|
|
307
|
+
progress.push(p)
|
|
308
|
+
);
|
|
309
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
310
|
+
controller.abort();
|
|
311
|
+
|
|
312
|
+
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
|
|
313
|
+
expect(progress).toEqual([{ phase: 'generating' }]);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
2
|
+
import { ConversationPin, INotebookHost } from '../conversationPin';
|
|
3
|
+
|
|
4
|
+
type Slot = (sender: unknown, path: string) => void;
|
|
5
|
+
|
|
6
|
+
interface IFakePanel {
|
|
7
|
+
id: string;
|
|
8
|
+
isDisposed: boolean;
|
|
9
|
+
title: { label: string };
|
|
10
|
+
context: {
|
|
11
|
+
path: string;
|
|
12
|
+
pathChanged: {
|
|
13
|
+
slots: Array<[Slot, unknown]>;
|
|
14
|
+
connect(slot: Slot, thisArg: unknown): void;
|
|
15
|
+
disconnect(slot: Slot, thisArg: unknown): void;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const panel = (path: string): IFakePanel => {
|
|
21
|
+
const slots: Array<[Slot, unknown]> = [];
|
|
22
|
+
return {
|
|
23
|
+
id: `id:${path}`,
|
|
24
|
+
isDisposed: false,
|
|
25
|
+
title: { label: path.split('/').pop() ?? path },
|
|
26
|
+
context: {
|
|
27
|
+
path,
|
|
28
|
+
pathChanged: {
|
|
29
|
+
slots,
|
|
30
|
+
connect: (slot, thisArg) => slots.push([slot, thisArg]),
|
|
31
|
+
disconnect: (slot, thisArg) => {
|
|
32
|
+
const at = slots.findIndex(([s, t]) => s === slot && t === thisArg);
|
|
33
|
+
if (at >= 0) {
|
|
34
|
+
slots.splice(at, 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** JupyterLab renaming or moving an open notebook. */
|
|
43
|
+
const rename = (p: IFakePanel, path: string): void => {
|
|
44
|
+
p.context.path = path;
|
|
45
|
+
p.title.label = path.split('/').pop() ?? path;
|
|
46
|
+
for (const [slot, thisArg] of [...p.context.pathChanged.slots]) {
|
|
47
|
+
slot.call(thisArg, p.context, path);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const asPanel = (p: IFakePanel | null) => p as unknown as NotebookPanel | null;
|
|
52
|
+
|
|
53
|
+
describe('ConversationPin', () => {
|
|
54
|
+
let active: IFakePanel | null;
|
|
55
|
+
let created: number;
|
|
56
|
+
let onDisk: Set<string>;
|
|
57
|
+
let host: jest.Mocked<INotebookHost>;
|
|
58
|
+
let pin: ConversationPin;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
active = null;
|
|
62
|
+
created = 0;
|
|
63
|
+
onDisk = new Set();
|
|
64
|
+
host = {
|
|
65
|
+
activeTab: jest.fn(() => asPanel(active)),
|
|
66
|
+
// Like notebook:create-new: the new notebook becomes the active tab.
|
|
67
|
+
create: jest.fn(async () => {
|
|
68
|
+
created += 1;
|
|
69
|
+
active = panel(`Untitled${created}.ipynb`);
|
|
70
|
+
return asPanel(active);
|
|
71
|
+
}),
|
|
72
|
+
reopen: jest.fn(async (path: string) =>
|
|
73
|
+
asPanel(onDisk.has(path) ? panel(path) : null)
|
|
74
|
+
),
|
|
75
|
+
activate: jest.fn()
|
|
76
|
+
};
|
|
77
|
+
pin = new ConversationPin(host);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('pins the notebook in the active tab when a conversation starts', async () => {
|
|
81
|
+
active = panel('work/a.ipynb');
|
|
82
|
+
await expect(pin.start()).resolves.toBe('a.ipynb');
|
|
83
|
+
expect(host.create).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('creates a notebook when the active tab is not one, and pins it', async () => {
|
|
87
|
+
await expect(pin.start()).resolves.toBe('Untitled1.ipynb');
|
|
88
|
+
expect(host.create).toHaveBeenCalledTimes(1);
|
|
89
|
+
await expect(pin.notebook()).resolves.toBe(active);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('starts an unstarted conversation at its first message, the same way', async () => {
|
|
93
|
+
await expect(pin.ensure()).resolves.toBe('Untitled1.ipynb');
|
|
94
|
+
await expect(pin.ensure()).resolves.toBe('Untitled1.ipynb');
|
|
95
|
+
expect(host.create).toHaveBeenCalledTimes(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('keeps the pin for the whole conversation, whatever tab is active', async () => {
|
|
99
|
+
const a = panel('a.ipynb');
|
|
100
|
+
active = a;
|
|
101
|
+
await pin.start();
|
|
102
|
+
|
|
103
|
+
active = panel('b.ipynb');
|
|
104
|
+
await expect(pin.ensure()).resolves.toBe('a.ipynb');
|
|
105
|
+
await expect(pin.current()).resolves.toBe(a);
|
|
106
|
+
await expect(pin.notebook()).resolves.toBe(a);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('creates one notebook for two New chats in quick succession', async () => {
|
|
110
|
+
// The second must find the notebook the first created as the active tab.
|
|
111
|
+
const [first, second] = await Promise.all([pin.start(), pin.start()]);
|
|
112
|
+
expect(host.create).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(first).toBe('Untitled1.ipynb');
|
|
114
|
+
expect(second).toBe('Untitled1.ipynb');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not reopen a closed notebook just to be asked about', async () => {
|
|
118
|
+
// Re-opening the tab is for an answer that has cells to place.
|
|
119
|
+
const a = panel('work/a.ipynb');
|
|
120
|
+
onDisk.add('work/a.ipynb');
|
|
121
|
+
active = a;
|
|
122
|
+
await pin.start();
|
|
123
|
+
|
|
124
|
+
a.isDisposed = true;
|
|
125
|
+
await expect(pin.current()).resolves.toBeNull();
|
|
126
|
+
expect(pin.path()).toBe('work/a.ipynb');
|
|
127
|
+
expect(host.reopen).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('opens a closed notebook again, from where it was, to place an answer', async () => {
|
|
131
|
+
const a = panel('work/a.ipynb');
|
|
132
|
+
onDisk.add('work/a.ipynb');
|
|
133
|
+
active = a;
|
|
134
|
+
await pin.start();
|
|
135
|
+
|
|
136
|
+
a.isDisposed = true;
|
|
137
|
+
active = null;
|
|
138
|
+
const reopened = await pin.notebook();
|
|
139
|
+
|
|
140
|
+
expect(host.reopen).toHaveBeenCalledWith('work/a.ipynb');
|
|
141
|
+
expect(reopened).not.toBe(a);
|
|
142
|
+
expect(pin.name()).toBe('a.ipynb');
|
|
143
|
+
expect(host.create).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('reports the last known name while the notebook is closed', async () => {
|
|
147
|
+
const a = panel('work/a.ipynb');
|
|
148
|
+
active = a;
|
|
149
|
+
await pin.start();
|
|
150
|
+
a.isDisposed = true;
|
|
151
|
+
expect(pin.name()).toBe('a.ipynb');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('follows a rename made while the notebook was open', async () => {
|
|
155
|
+
// Renamed and then closed with no request in between: found under the
|
|
156
|
+
// new name, not replaced by a new notebook.
|
|
157
|
+
const a = panel('work/a.ipynb');
|
|
158
|
+
onDisk.add('work/renamed.ipynb');
|
|
159
|
+
active = a;
|
|
160
|
+
await pin.start();
|
|
161
|
+
|
|
162
|
+
rename(a, 'work/renamed.ipynb');
|
|
163
|
+
a.isDisposed = true;
|
|
164
|
+
await pin.notebook();
|
|
165
|
+
|
|
166
|
+
expect(host.reopen).toHaveBeenCalledWith('work/renamed.ipynb');
|
|
167
|
+
expect(host.create).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('stops following a notebook once the conversation has moved on', async () => {
|
|
171
|
+
const a = panel('work/a.ipynb');
|
|
172
|
+
active = a;
|
|
173
|
+
await pin.start();
|
|
174
|
+
active = panel('work/b.ipynb');
|
|
175
|
+
await pin.start();
|
|
176
|
+
|
|
177
|
+
rename(a, 'work/elsewhere.ipynb');
|
|
178
|
+
expect(pin.path()).toBe('work/b.ipynb');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('replaces a notebook that cannot be opened again with a new one', async () => {
|
|
182
|
+
const a = panel('work/gone.ipynb');
|
|
183
|
+
active = a;
|
|
184
|
+
await pin.start();
|
|
185
|
+
|
|
186
|
+
a.isDisposed = true;
|
|
187
|
+
active = null;
|
|
188
|
+
const replacement = await pin.notebook();
|
|
189
|
+
|
|
190
|
+
expect(host.create).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(replacement).toBe(active);
|
|
192
|
+
expect(pin.name()).toBe('Untitled1.ipynb');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('brings the notebook to the front when shown, opening it first if closed', async () => {
|
|
196
|
+
const a = panel('work/a.ipynb');
|
|
197
|
+
onDisk.add('work/a.ipynb');
|
|
198
|
+
active = a;
|
|
199
|
+
await pin.start();
|
|
200
|
+
active = panel('b.ipynb');
|
|
201
|
+
|
|
202
|
+
await pin.show();
|
|
203
|
+
expect(host.activate).toHaveBeenLastCalledWith(a);
|
|
204
|
+
|
|
205
|
+
a.isDisposed = true;
|
|
206
|
+
const reopened = await pin.show();
|
|
207
|
+
expect(host.activate).toHaveBeenLastCalledWith(reopened);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('retries a notebook that could not be created', async () => {
|
|
211
|
+
host.create.mockResolvedValueOnce(null);
|
|
212
|
+
await expect(pin.start()).resolves.toBeNull();
|
|
213
|
+
|
|
214
|
+
// Read `active` only after notebook() has run: it is the notebook the
|
|
215
|
+
// retry created.
|
|
216
|
+
const created = await pin.notebook();
|
|
217
|
+
expect(created).not.toBeNull();
|
|
218
|
+
expect(created).toBe(active);
|
|
219
|
+
expect(host.create).toHaveBeenCalledTimes(2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('keeps working after a step fails', async () => {
|
|
223
|
+
// One failure is its caller's to report; it must not block the queue.
|
|
224
|
+
const a = panel('work/a.ipynb');
|
|
225
|
+
onDisk.add('work/a.ipynb');
|
|
226
|
+
active = a;
|
|
227
|
+
await pin.start();
|
|
228
|
+
a.isDisposed = true;
|
|
229
|
+
|
|
230
|
+
host.reopen.mockRejectedValueOnce(new Error('server went away'));
|
|
231
|
+
await expect(pin.notebook()).rejects.toThrow('server went away');
|
|
232
|
+
|
|
233
|
+
await expect(pin.notebook()).resolves.not.toBeNull();
|
|
234
|
+
await expect(pin.ensure()).resolves.toBe('a.ipynb');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { CodeCell } from '@jupyterlab/cells';
|
|
2
|
+
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
3
|
+
import { NotebookActions } from '@jupyterlab/notebook';
|
|
4
|
+
import { KernelHandoff } from '../kernelHandoff';
|
|
5
|
+
|
|
6
|
+
// The real modules pull in ES-module UI code that jest cannot load. Stand-ins
|
|
7
|
+
// with just what placing a code block without running it touches.
|
|
8
|
+
jest.mock('@jupyterlab/notebook', () => ({
|
|
9
|
+
NotebookActions: { insertBelow: jest.fn(), changeCellType: jest.fn() },
|
|
10
|
+
NotebookPanel: class NotebookPanel {}
|
|
11
|
+
}));
|
|
12
|
+
jest.mock('@jupyterlab/cells', () => ({ CodeCell: class CodeCell {} }));
|
|
13
|
+
|
|
14
|
+
/** A code cell holding `source`; `text()` reads what it holds now. */
|
|
15
|
+
function codeCell(source: string): { cell: CodeCell; text: () => string } {
|
|
16
|
+
let text = source;
|
|
17
|
+
const cell = Object.create(CodeCell.prototype) as CodeCell;
|
|
18
|
+
Object.defineProperty(cell, 'isDisposed', { value: false });
|
|
19
|
+
Object.defineProperty(cell, 'model', {
|
|
20
|
+
value: {
|
|
21
|
+
sharedModel: {
|
|
22
|
+
setSource: (value: string) => {
|
|
23
|
+
text = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
return { cell, text: () => text };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A notebook panel with `selected` selected; inserting selects `inserted`. */
|
|
32
|
+
function panelWith(selected: CodeCell, inserted: CodeCell): NotebookPanel {
|
|
33
|
+
const content = { activeCell: selected };
|
|
34
|
+
(NotebookActions.insertBelow as jest.Mock).mockImplementation(() => {
|
|
35
|
+
content.activeCell = inserted;
|
|
36
|
+
});
|
|
37
|
+
return { content, sessionContext: {} } as unknown as NotebookPanel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const answer = {
|
|
41
|
+
message: 'Fixed.',
|
|
42
|
+
blocks: [{ type: 'code' as const, content: 'x = 2' }]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('KernelHandoff: which notebook it touches', () => {
|
|
46
|
+
it('leaves the tabs alone when an answer has nothing to place', async () => {
|
|
47
|
+
// The conversation's notebook is re-opened for an answer with cells to
|
|
48
|
+
// place, not for one that is only words.
|
|
49
|
+
const target = jest.fn(async (): Promise<null> => null);
|
|
50
|
+
const handoff = new KernelHandoff(target, () => null);
|
|
51
|
+
|
|
52
|
+
const placed = await handoff.inject({ message: 'Just words.', blocks: [] });
|
|
53
|
+
|
|
54
|
+
expect(target).not.toHaveBeenCalled();
|
|
55
|
+
expect(placed.message).toBe('Just words.');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('asks for the conversation notebook once when there are cells to place', async () => {
|
|
59
|
+
const target = jest.fn(async (): Promise<null> => null);
|
|
60
|
+
const handoff = new KernelHandoff(target, () => null);
|
|
61
|
+
|
|
62
|
+
const placed = await handoff.inject({
|
|
63
|
+
message: 'Here.',
|
|
64
|
+
blocks: [{ type: 'code', content: 'x = 1' }]
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(target).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(placed.message).toContain('(Could not open a notebook)');
|
|
69
|
+
expect(placed.runs).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('KernelHandoff: replacing the selected cell', () => {
|
|
74
|
+
it('replaces it in the notebook the request read it from', async () => {
|
|
75
|
+
const selected = codeCell('x = 1');
|
|
76
|
+
const inserted = codeCell('');
|
|
77
|
+
const panel = panelWith(selected.cell, inserted.cell);
|
|
78
|
+
const handoff = new KernelHandoff(
|
|
79
|
+
async () => panel,
|
|
80
|
+
() => panel
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await handoff.inject(answer, { replaceCodeCell: true });
|
|
84
|
+
|
|
85
|
+
expect(selected.text()).toBe('x = 2');
|
|
86
|
+
expect(inserted.text()).toBe('');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('never replaces a cell in a notebook opened again since', async () => {
|
|
90
|
+
// Closed while the answer was on its way and opened again: its selected
|
|
91
|
+
// cell is not the one the user chose, but some other work of theirs.
|
|
92
|
+
const selected = codeCell('users_latest_work()');
|
|
93
|
+
const inserted = codeCell('');
|
|
94
|
+
const reopened = panelWith(selected.cell, inserted.cell);
|
|
95
|
+
const readFrom = {} as NotebookPanel;
|
|
96
|
+
const handoff = new KernelHandoff(
|
|
97
|
+
async () => reopened,
|
|
98
|
+
() => readFrom
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await handoff.inject(answer, { replaceCodeCell: true });
|
|
102
|
+
|
|
103
|
+
expect(selected.text()).toBe('users_latest_work()');
|
|
104
|
+
expect(inserted.text()).toBe('x = 2');
|
|
105
|
+
});
|
|
106
|
+
});
|