@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,124 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { caretDownIcon, caretRightIcon } from '@jupyterlab/ui-components';
|
|
3
|
+
import { ArrowBendDownRight } from '@phosphor-icons/react';
|
|
4
|
+
|
|
5
|
+
import '../style/index.css';
|
|
6
|
+
import { IDatasource } from './DatameshWidget';
|
|
7
|
+
|
|
8
|
+
export interface IDatasourceItemProps {
|
|
9
|
+
datasource: IDatasource;
|
|
10
|
+
insertDatasource: (datasource: IDatasource) => void;
|
|
11
|
+
onMouseDown: (
|
|
12
|
+
e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
|
|
13
|
+
datasource: IDatasource
|
|
14
|
+
) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A React component for expandable containers.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const DatasourceItem: React.FC<IDatasourceItemProps> = ({
|
|
22
|
+
datasource,
|
|
23
|
+
insertDatasource,
|
|
24
|
+
onMouseDown
|
|
25
|
+
}) => {
|
|
26
|
+
const [expanded, setExpandedValue] = useState(false);
|
|
27
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
+
|
|
29
|
+
const handleToggleExpand = (): void => {
|
|
30
|
+
setExpandedValue(!expanded);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const startDrag = (e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
|
|
34
|
+
setIsDragging(true);
|
|
35
|
+
onMouseDown(e, datasource);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={expanded ? 'expanded' : ''}>
|
|
40
|
+
<div key={datasource.id} className="datasource-item-title">
|
|
41
|
+
<button
|
|
42
|
+
title={expanded ? 'Hide Details' : 'Show Details'}
|
|
43
|
+
onClick={handleToggleExpand}
|
|
44
|
+
className="datasource-item-expand"
|
|
45
|
+
>
|
|
46
|
+
{expanded ? (
|
|
47
|
+
<caretDownIcon.react
|
|
48
|
+
tag="span"
|
|
49
|
+
elementPosition="center"
|
|
50
|
+
width="20px"
|
|
51
|
+
/>
|
|
52
|
+
) : (
|
|
53
|
+
<caretRightIcon.react
|
|
54
|
+
tag="span"
|
|
55
|
+
elementPosition="center"
|
|
56
|
+
width="20px"
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</button>
|
|
60
|
+
<span
|
|
61
|
+
title={datasource.description}
|
|
62
|
+
className={
|
|
63
|
+
isDragging
|
|
64
|
+
? 'datasource-item-name'
|
|
65
|
+
: 'datasource-item-name draggable'
|
|
66
|
+
}
|
|
67
|
+
onClick={handleToggleExpand}
|
|
68
|
+
onMouseDown={startDrag}
|
|
69
|
+
onMouseUp={() => setIsDragging(false)}
|
|
70
|
+
>
|
|
71
|
+
{datasource.description}
|
|
72
|
+
</span>
|
|
73
|
+
|
|
74
|
+
<button
|
|
75
|
+
title="Insert code to load datasource"
|
|
76
|
+
onClick={handleToggleExpand}
|
|
77
|
+
className="datasource-item-action"
|
|
78
|
+
>
|
|
79
|
+
<ArrowBendDownRight
|
|
80
|
+
size={18}
|
|
81
|
+
onClick={e => insertDatasource(datasource)}
|
|
82
|
+
/>
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
{expanded && (
|
|
86
|
+
<div className={'datasource-item-details'}>
|
|
87
|
+
<div className="datasource-query-field">
|
|
88
|
+
Datasource id: <span>{datasource.datasource}</span>
|
|
89
|
+
</div>
|
|
90
|
+
{datasource.geofilter && (
|
|
91
|
+
<div className="datasource-query-field">
|
|
92
|
+
Geofilter type:<span>{datasource.geofilter.type}</span>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{datasource.variables && (
|
|
96
|
+
<div className="datasource-query-field">
|
|
97
|
+
Variables:<span>{datasource.variables.join(',')}</span>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
{datasource.timefilter && (
|
|
101
|
+
<div className="datasource-query-field">
|
|
102
|
+
Timefilter:<span>{datasource.timefilter.times[0]}</span>
|
|
103
|
+
<span>to</span>
|
|
104
|
+
<span>{datasource.timefilter.times[1]}</span>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
{datasource.spatialref && (
|
|
108
|
+
<div className="datasource-query-field">
|
|
109
|
+
Spatial ref:<span>{datasource.spatialref}</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
<div>
|
|
113
|
+
<a
|
|
114
|
+
href={`https://oceanum.io/datasets/${datasource.datasource}`}
|
|
115
|
+
target="_blank"
|
|
116
|
+
>
|
|
117
|
+
Datasource details
|
|
118
|
+
</a>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { runChatLoop, STOPPED, LoopDeps } from '../aiLoop';
|
|
2
|
+
import type { OceanumResponse } from '../chatRouter';
|
|
3
|
+
import type { ObservedRun } from '../notebookRun';
|
|
4
|
+
|
|
5
|
+
const code = (content: string) => ({ type: 'code' as const, content });
|
|
6
|
+
const reply = (message: string, ...blocks: OceanumResponse['blocks']) => ({
|
|
7
|
+
message,
|
|
8
|
+
blocks
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A fake notebook: `place` records what it was asked to place and reports one
|
|
13
|
+
* ok run per code block, unless a script says a given code should fail.
|
|
14
|
+
*/
|
|
15
|
+
function fakeDeps(
|
|
16
|
+
first: OceanumResponse,
|
|
17
|
+
observations: OceanumResponse[],
|
|
18
|
+
failing: string[] = []
|
|
19
|
+
) {
|
|
20
|
+
const placed: { blocks: string[]; autoRun: boolean; replace: boolean }[] = [];
|
|
21
|
+
const observed: ObservedRun[][] = [];
|
|
22
|
+
const pending = [...observations];
|
|
23
|
+
|
|
24
|
+
const deps: LoopDeps = {
|
|
25
|
+
route: async () => ({ response: first, hasCodeCellSelected: true }),
|
|
26
|
+
observe: async (_p, _h, runs) => {
|
|
27
|
+
observed.push(runs.map(r => ({ ...r })));
|
|
28
|
+
const next = pending.shift();
|
|
29
|
+
if (!next) {
|
|
30
|
+
throw new Error('observe called more times than scripted');
|
|
31
|
+
}
|
|
32
|
+
return next;
|
|
33
|
+
},
|
|
34
|
+
place: async (response, opts) => {
|
|
35
|
+
placed.push({
|
|
36
|
+
blocks: response.blocks.map(b => b.content),
|
|
37
|
+
autoRun: opts.autoRun,
|
|
38
|
+
replace: opts.replaceCodeCell
|
|
39
|
+
});
|
|
40
|
+
const runs: ObservedRun[] = opts.autoRun
|
|
41
|
+
? response.blocks
|
|
42
|
+
.filter(b => b.type === 'code')
|
|
43
|
+
.map(b => ({
|
|
44
|
+
code: b.content,
|
|
45
|
+
status: failing.includes(b.content) ? 'error' : 'ok',
|
|
46
|
+
stdout: `ran ${b.content}`,
|
|
47
|
+
error: failing.includes(b.content) ? 'Boom' : null,
|
|
48
|
+
message: response.message
|
|
49
|
+
}))
|
|
50
|
+
: [];
|
|
51
|
+
return { message: response.message, runs };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return { deps, placed, observed };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const opts = (over: Partial<Parameters<typeof runChatLoop>[3]> = {}) => ({
|
|
58
|
+
autoRunCode: false,
|
|
59
|
+
iterate: false,
|
|
60
|
+
maxRounds: 5,
|
|
61
|
+
signal: new AbortController().signal,
|
|
62
|
+
...over
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('runChatLoop: the three workflows', () => {
|
|
66
|
+
it('workflow 3: places blocks and does not run them', async () => {
|
|
67
|
+
const { deps, placed, observed } = fakeDeps(
|
|
68
|
+
reply('Here.', code('a()')),
|
|
69
|
+
[]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const text = await runChatLoop('q', [], deps, opts());
|
|
73
|
+
|
|
74
|
+
expect(text).toBe('Here.');
|
|
75
|
+
expect(placed).toEqual([
|
|
76
|
+
{ blocks: ['a()'], autoRun: false, replace: true }
|
|
77
|
+
]);
|
|
78
|
+
expect(observed).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('workflow 1: places and runs, but never observes', async () => {
|
|
82
|
+
const { deps, placed, observed } = fakeDeps(
|
|
83
|
+
reply('Here.', code('a()')),
|
|
84
|
+
[]
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await runChatLoop('q', [], deps, opts({ autoRunCode: true }));
|
|
88
|
+
|
|
89
|
+
expect(placed[0].autoRun).toBe(true);
|
|
90
|
+
expect(observed).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('workflow 2: runs, observes, places the follow-up, and stops when no code comes back', async () => {
|
|
94
|
+
const { deps, placed, observed } = fakeDeps(
|
|
95
|
+
reply('Step one.', code('a()')),
|
|
96
|
+
[reply('Step two.', code('b()')), reply('Done: the peak is in January.')]
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const text = await runChatLoop(
|
|
100
|
+
'q',
|
|
101
|
+
[],
|
|
102
|
+
deps,
|
|
103
|
+
opts({ autoRunCode: true, iterate: true })
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(placed.map(p => p.blocks)).toEqual([['a()'], ['b()'], []]);
|
|
107
|
+
// Every observation carries EVERY run so far, with the agent's message
|
|
108
|
+
// attached to the code it explained.
|
|
109
|
+
expect(observed.map(o => o.map(r => r.code))).toEqual([
|
|
110
|
+
['a()'],
|
|
111
|
+
['a()', 'b()']
|
|
112
|
+
]);
|
|
113
|
+
expect(observed[1][0].message).toBe('Step one.');
|
|
114
|
+
expect(observed[1][1].message).toBe('Step two.');
|
|
115
|
+
// The chain reads as its steps, not just the last one.
|
|
116
|
+
expect(text).toBe(
|
|
117
|
+
'Step one.\n\nStep two.\n\nDone: the peak is in January.'
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('iterate without autoRun is inert', async () => {
|
|
122
|
+
const { deps, observed } = fakeDeps(reply('Here.', code('a()')), []);
|
|
123
|
+
|
|
124
|
+
await runChatLoop('q', [], deps, opts({ iterate: true }));
|
|
125
|
+
|
|
126
|
+
expect(observed).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a failed run is observed so the agent can repair it', async () => {
|
|
130
|
+
const { deps, observed } = fakeDeps(
|
|
131
|
+
reply('Try.', code('bad()')),
|
|
132
|
+
[reply('Fixed.', code('good()')), reply('Done.')],
|
|
133
|
+
['bad()']
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await runChatLoop(
|
|
137
|
+
'q',
|
|
138
|
+
[],
|
|
139
|
+
deps,
|
|
140
|
+
opts({ autoRunCode: true, iterate: true })
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
expect(observed[0][0]).toMatchObject({
|
|
144
|
+
code: 'bad()',
|
|
145
|
+
status: 'error',
|
|
146
|
+
error: 'Boom'
|
|
147
|
+
});
|
|
148
|
+
expect(observed[1][1]).toMatchObject({ code: 'good()', status: 'ok' });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('a cell that could not run at all ends the loop without observing', async () => {
|
|
152
|
+
const { deps, observed } = fakeDeps(reply('Try.', code('a()')), [
|
|
153
|
+
reply('Again.', code('b()'))
|
|
154
|
+
]);
|
|
155
|
+
const place = deps.place;
|
|
156
|
+
deps.place = async (response, o) => ({
|
|
157
|
+
...(await place(response, o)),
|
|
158
|
+
halted: true
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const text = await runChatLoop(
|
|
162
|
+
'q',
|
|
163
|
+
[],
|
|
164
|
+
deps,
|
|
165
|
+
opts({ autoRunCode: true, iterate: true })
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(observed).toEqual([]);
|
|
169
|
+
expect(text).toBe('Try.');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('runChatLoop: limits', () => {
|
|
174
|
+
it('stops at maxRounds even if the agent keeps sending code', async () => {
|
|
175
|
+
const forever = Array.from({ length: 10 }, (_, i) =>
|
|
176
|
+
reply(`More ${i}.`, code(`s${i}()`))
|
|
177
|
+
);
|
|
178
|
+
const { deps, observed } = fakeDeps(reply('Start.', code('s()')), forever);
|
|
179
|
+
|
|
180
|
+
await runChatLoop(
|
|
181
|
+
'q',
|
|
182
|
+
[],
|
|
183
|
+
deps,
|
|
184
|
+
opts({ autoRunCode: true, iterate: true, maxRounds: 3 })
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// The cap is on observe requests, checked after each one so the server's
|
|
188
|
+
// own at-cap explanation turn is always requested: 3 observes, then the
|
|
189
|
+
// round they produced is placed and the loop ends.
|
|
190
|
+
expect(observed).toHaveLength(3);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('a code-free answer (what the server sends at its cap) ends the loop', async () => {
|
|
194
|
+
const { deps, observed } = fakeDeps(reply('Start.', code('s()')), [
|
|
195
|
+
reply('Capped: here is what happened.')
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const text = await runChatLoop(
|
|
199
|
+
'q',
|
|
200
|
+
[],
|
|
201
|
+
deps,
|
|
202
|
+
opts({ autoRunCode: true, iterate: true, maxRounds: 1 })
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
expect(observed).toHaveLength(1);
|
|
206
|
+
expect(text).toBe('Start.\n\nCapped: here is what happened.');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('Stop between rounds halts the loop and says so', async () => {
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
const { deps, observed } = fakeDeps(reply('Start.', code('s()')), [
|
|
212
|
+
reply('More.', code('t()'))
|
|
213
|
+
]);
|
|
214
|
+
// Abort as soon as the first placement happens.
|
|
215
|
+
const original = deps.place;
|
|
216
|
+
deps.place = async (r, o) => {
|
|
217
|
+
const out = await original(r, o);
|
|
218
|
+
controller.abort();
|
|
219
|
+
return out;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const text = await runChatLoop(
|
|
223
|
+
'q',
|
|
224
|
+
[],
|
|
225
|
+
deps,
|
|
226
|
+
opts({ autoRunCode: true, iterate: true, signal: controller.signal })
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(observed).toEqual([]);
|
|
230
|
+
expect(text).toBe(`Start.\n\n${STOPPED}`);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('Stop while an observe request is in flight keeps what was already placed', async () => {
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
const { deps, placed } = fakeDeps(reply('Step one.', code('a()')), []);
|
|
236
|
+
// The real router rejects with the fetch AbortError once Stop is pressed.
|
|
237
|
+
deps.observe = async () => {
|
|
238
|
+
controller.abort();
|
|
239
|
+
throw new DOMException('The user aborted a request.', 'AbortError');
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const text = await runChatLoop(
|
|
243
|
+
'q',
|
|
244
|
+
[],
|
|
245
|
+
deps,
|
|
246
|
+
opts({ autoRunCode: true, iterate: true, signal: controller.signal })
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(placed).toHaveLength(1);
|
|
250
|
+
expect(text).toBe(`Step one.\n\n${STOPPED}`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('an observe failure that is not a Stop still propagates', async () => {
|
|
254
|
+
const { deps } = fakeDeps(reply('Step one.', code('a()')), []);
|
|
255
|
+
deps.observe = async () => {
|
|
256
|
+
throw new Error('Backend error: 500');
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
await expect(
|
|
260
|
+
runChatLoop('q', [], deps, opts({ autoRunCode: true, iterate: true }))
|
|
261
|
+
).rejects.toThrow('Backend error: 500');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('Stop during the first request returns Stopped without placing anything', async () => {
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const { deps, placed } = fakeDeps(reply('Start.', code('s()')), []);
|
|
267
|
+
deps.route = async () => {
|
|
268
|
+
controller.abort();
|
|
269
|
+
return {
|
|
270
|
+
response: reply('Start.', code('s()')),
|
|
271
|
+
hasCodeCellSelected: false
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const text = await runChatLoop(
|
|
276
|
+
'q',
|
|
277
|
+
[],
|
|
278
|
+
deps,
|
|
279
|
+
opts({ signal: controller.signal })
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(text).toBe(STOPPED);
|
|
283
|
+
expect(placed).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('Stop that rejects the first request returns Stopped, not an error', async () => {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const { deps, placed } = fakeDeps(reply('Start.', code('s()')), []);
|
|
289
|
+
deps.route = async () => {
|
|
290
|
+
controller.abort();
|
|
291
|
+
throw new DOMException('The user aborted a request.', 'AbortError');
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const text = await runChatLoop(
|
|
295
|
+
'q',
|
|
296
|
+
[],
|
|
297
|
+
deps,
|
|
298
|
+
opts({ signal: controller.signal })
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(text).toBe(STOPPED);
|
|
302
|
+
expect(placed).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|