@forgeax/engine-intelligence 0.0.0-dev.8d955ade1c79
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 +202 -0
- package/README.md +41 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/runtime.test.d.ts +2 -0
- package/dist/__tests__/runtime.test.d.ts.map +1 -0
- package/dist/__tests__/transport.test.d.ts +2 -0
- package/dist/__tests__/transport.test.d.ts.map +1 -0
- package/dist/errors.d.ts +74 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +541 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/transport.d.ts +61 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +56 -0
- package/src/__tests__/runtime.test.ts +867 -0
- package/src/__tests__/transport.test.ts +767 -0
- package/src/errors.ts +154 -0
- package/src/index.ts +36 -0
- package/src/plugin.ts +20 -0
- package/src/runtime.ts +297 -0
- package/src/transport.ts +282 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { IntelligenceError } from '../errors';
|
|
4
|
+
import { createIntelligenceRuntime } from '../runtime';
|
|
5
|
+
import {
|
|
6
|
+
type ActivityId,
|
|
7
|
+
type ActivitySink,
|
|
8
|
+
type ActivitySubmission,
|
|
9
|
+
activityId,
|
|
10
|
+
type IntelligenceProvider,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
function controlledProvider(id = 'test.provider') {
|
|
14
|
+
const sinks = new Map<ActivityId, ActivitySink>();
|
|
15
|
+
let closed = false;
|
|
16
|
+
const provider: IntelligenceProvider = {
|
|
17
|
+
id,
|
|
18
|
+
start(submission, sink) {
|
|
19
|
+
sinks.set(submission.id, sink);
|
|
20
|
+
return ok(undefined);
|
|
21
|
+
},
|
|
22
|
+
cancel(activity) {
|
|
23
|
+
const sink = sinks.get(activity);
|
|
24
|
+
if (sink === undefined) {
|
|
25
|
+
return err(
|
|
26
|
+
new IntelligenceError({
|
|
27
|
+
code: 'intelligence-activity-not-found',
|
|
28
|
+
detail: { activityId: activity },
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
sinks.delete(activity);
|
|
33
|
+
sink.cancelled();
|
|
34
|
+
return ok(undefined);
|
|
35
|
+
},
|
|
36
|
+
async close() {
|
|
37
|
+
closed = true;
|
|
38
|
+
for (const sink of sinks.values()) sink.cancelled();
|
|
39
|
+
sinks.clear();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
provider,
|
|
44
|
+
sinks,
|
|
45
|
+
get closed() {
|
|
46
|
+
return closed;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('IntelligenceRuntime', () => {
|
|
52
|
+
it('delivers ordered bounded deltas and a terminal result without awaiting in poll', () => {
|
|
53
|
+
const controlled = controlledProvider();
|
|
54
|
+
const runtime = createIntelligenceRuntime(controlled.provider, {
|
|
55
|
+
createActivityId: () => activityId('activity-a'),
|
|
56
|
+
createSessionId: () => 'session-a',
|
|
57
|
+
});
|
|
58
|
+
const started = runtime.submit({ input: 'hello' });
|
|
59
|
+
expect(started.ok).toBe(true);
|
|
60
|
+
if (!started.ok) return;
|
|
61
|
+
const sink = controlled.sinks.get(started.value.id);
|
|
62
|
+
expect(sink).toBeDefined();
|
|
63
|
+
sink?.text('hel');
|
|
64
|
+
sink?.text('lo');
|
|
65
|
+
sink?.complete('hello');
|
|
66
|
+
|
|
67
|
+
expect(runtime.poll()).toEqual([
|
|
68
|
+
{ type: 'text-delta', activityId: activityId('activity-a'), sequence: 1, text: 'hel' },
|
|
69
|
+
{ type: 'text-delta', activityId: activityId('activity-a'), sequence: 2, text: 'lo' },
|
|
70
|
+
{
|
|
71
|
+
type: 'completed',
|
|
72
|
+
activityId: activityId('activity-a'),
|
|
73
|
+
sequence: 3,
|
|
74
|
+
session: { providerId: 'test.provider', id: 'session-a' },
|
|
75
|
+
output: 'hello',
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
expect(runtime.poll()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('rejects cross-provider session reuse before provider dispatch', () => {
|
|
82
|
+
const controlled = controlledProvider();
|
|
83
|
+
const runtime = createIntelligenceRuntime(controlled.provider);
|
|
84
|
+
const result = runtime.submit({
|
|
85
|
+
input: 'resume',
|
|
86
|
+
session: { providerId: 'another.provider', id: 'session-a' },
|
|
87
|
+
});
|
|
88
|
+
expect(result.ok).toBe(false);
|
|
89
|
+
if (result.ok) return;
|
|
90
|
+
expect(result.error.code).toBe('intelligence-session-provider-mismatch');
|
|
91
|
+
expect(controlled.sinks.size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('accepts exact-limit input and rejects invalid input before identity, capacity, or provider start', () => {
|
|
95
|
+
const sinks = new Map<string, ActivitySink>();
|
|
96
|
+
let activityIdentityCalls = 0;
|
|
97
|
+
let sessionIdentityCalls = 0;
|
|
98
|
+
let providerStarts = 0;
|
|
99
|
+
const provider: IntelligenceProvider = {
|
|
100
|
+
id: 'input.boundary.provider',
|
|
101
|
+
start(submission, sink) {
|
|
102
|
+
providerStarts += 1;
|
|
103
|
+
sinks.set(submission.id, sink);
|
|
104
|
+
return ok(undefined);
|
|
105
|
+
},
|
|
106
|
+
cancel() {
|
|
107
|
+
return ok(undefined);
|
|
108
|
+
},
|
|
109
|
+
async close() {},
|
|
110
|
+
};
|
|
111
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
112
|
+
limits: { maxInputChars: 4, maxConcurrentActivities: 1 },
|
|
113
|
+
createActivityId: () => {
|
|
114
|
+
activityIdentityCalls += 1;
|
|
115
|
+
return activityId('input-boundary-activity');
|
|
116
|
+
},
|
|
117
|
+
createSessionId: () => {
|
|
118
|
+
sessionIdentityCalls += 1;
|
|
119
|
+
return 'input-boundary-session';
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const exact = runtime.submit({ input: 'abcd' });
|
|
124
|
+
expect(exact.ok).toBe(true);
|
|
125
|
+
if (!exact.ok) return;
|
|
126
|
+
expect(providerStarts).toBe(1);
|
|
127
|
+
expect(activityIdentityCalls).toBe(1);
|
|
128
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
129
|
+
|
|
130
|
+
const empty = runtime.submit({ input: '' });
|
|
131
|
+
expect(empty.ok).toBe(false);
|
|
132
|
+
if (!empty.ok) {
|
|
133
|
+
expect(empty.error.code).toBe('intelligence-invalid-request');
|
|
134
|
+
expect(empty.error.detail).toEqual({ field: 'input', reason: 'input is empty' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const overLimit = runtime.submit({ input: 'abcde' });
|
|
138
|
+
expect(overLimit.ok).toBe(false);
|
|
139
|
+
if (!overLimit.ok) {
|
|
140
|
+
expect(overLimit.error.code).toBe('intelligence-invalid-request');
|
|
141
|
+
expect(overLimit.error.detail).toEqual({
|
|
142
|
+
field: 'input',
|
|
143
|
+
reason: 'input exceeds 4 characters',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
expect(activityIdentityCalls).toBe(1);
|
|
148
|
+
expect(sessionIdentityCalls).toBe(1);
|
|
149
|
+
expect(providerStarts).toBe(1);
|
|
150
|
+
expect(sinks.size).toBe(1);
|
|
151
|
+
|
|
152
|
+
sinks.get(exact.value.id)?.complete('done');
|
|
153
|
+
expect(runtime.poll()).toEqual([
|
|
154
|
+
{
|
|
155
|
+
type: 'completed',
|
|
156
|
+
activityId: exact.value.id,
|
|
157
|
+
sequence: 1,
|
|
158
|
+
session: exact.value.session,
|
|
159
|
+
output: 'done',
|
|
160
|
+
},
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('turns queue overflow into one structured terminal failure and cancels the provider', () => {
|
|
165
|
+
const controlled = controlledProvider();
|
|
166
|
+
const runtime = createIntelligenceRuntime(controlled.provider, {
|
|
167
|
+
limits: { maxPendingEventsPerActivity: 3 },
|
|
168
|
+
createActivityId: () => activityId('overflow'),
|
|
169
|
+
});
|
|
170
|
+
const started = runtime.submit({ input: 'overflow' });
|
|
171
|
+
if (!started.ok) throw started.error;
|
|
172
|
+
const sink = controlled.sinks.get(started.value.id);
|
|
173
|
+
sink?.text('one');
|
|
174
|
+
sink?.text('two');
|
|
175
|
+
sink?.text('three');
|
|
176
|
+
|
|
177
|
+
const events = runtime.poll();
|
|
178
|
+
expect(events).toHaveLength(3);
|
|
179
|
+
expect(events[2]?.type).toBe('failed');
|
|
180
|
+
if (events[2]?.type === 'failed') {
|
|
181
|
+
expect(events[2].error.code).toBe('intelligence-output-overflow');
|
|
182
|
+
}
|
|
183
|
+
expect(controlled.sinks.size).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('fails exactly once when cumulative streamed output crosses the character limit', () => {
|
|
187
|
+
const exactId = activityId('exact-stream');
|
|
188
|
+
const overflowId = activityId('stream-output-overflow');
|
|
189
|
+
const ids = [exactId, overflowId];
|
|
190
|
+
const controlled = controlledProvider('output.stream.provider');
|
|
191
|
+
const runtime = createIntelligenceRuntime(controlled.provider, {
|
|
192
|
+
limits: { maxOutputChars: 5, maxPendingEventsPerActivity: 8 },
|
|
193
|
+
createActivityId: () => {
|
|
194
|
+
const id = ids.shift();
|
|
195
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
196
|
+
return id;
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const exact = runtime.submit({ input: 'exact' });
|
|
201
|
+
const overflow = runtime.submit({ input: 'stream' });
|
|
202
|
+
expect(exact.ok).toBe(true);
|
|
203
|
+
expect(overflow.ok).toBe(true);
|
|
204
|
+
if (!exact.ok || !overflow.ok) return;
|
|
205
|
+
const exactSink = controlled.sinks.get(exact.value.id);
|
|
206
|
+
const overflowSink = controlled.sinks.get(overflow.value.id);
|
|
207
|
+
expect(exactSink).toBeDefined();
|
|
208
|
+
expect(overflowSink).toBeDefined();
|
|
209
|
+
|
|
210
|
+
exactSink?.complete('12345');
|
|
211
|
+
overflowSink?.text('12345');
|
|
212
|
+
overflowSink?.text('!');
|
|
213
|
+
overflowSink?.text('late');
|
|
214
|
+
overflowSink?.complete('duplicate');
|
|
215
|
+
overflowSink?.fail(new Error('duplicate'));
|
|
216
|
+
|
|
217
|
+
const events = runtime.poll();
|
|
218
|
+
expect(events).toEqual([
|
|
219
|
+
{
|
|
220
|
+
type: 'completed',
|
|
221
|
+
activityId: exactId,
|
|
222
|
+
sequence: 1,
|
|
223
|
+
session: exact.value.session,
|
|
224
|
+
output: '12345',
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
type: 'text-delta',
|
|
228
|
+
activityId: overflowId,
|
|
229
|
+
sequence: 1,
|
|
230
|
+
text: '12345',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
type: 'failed',
|
|
234
|
+
activityId: overflowId,
|
|
235
|
+
sequence: 2,
|
|
236
|
+
error: expect.objectContaining({
|
|
237
|
+
code: 'intelligence-output-overflow',
|
|
238
|
+
detail: {
|
|
239
|
+
activityId: overflowId,
|
|
240
|
+
bound: 'output-chars',
|
|
241
|
+
limit: 5,
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
expect(events.filter((event) => event.type === 'failed')).toHaveLength(1);
|
|
247
|
+
expect(controlled.sinks.has(overflowId)).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('fails exactly once when a completion payload exceeds the character limit', () => {
|
|
251
|
+
const exactId = activityId('exact-completion');
|
|
252
|
+
const overflowId = activityId('completion-output-overflow');
|
|
253
|
+
const ids = [exactId, overflowId];
|
|
254
|
+
const controlled = controlledProvider('output.completion.provider');
|
|
255
|
+
const runtime = createIntelligenceRuntime(controlled.provider, {
|
|
256
|
+
limits: { maxOutputChars: 5, maxPendingEventsPerActivity: 8 },
|
|
257
|
+
createActivityId: () => {
|
|
258
|
+
const id = ids.shift();
|
|
259
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
260
|
+
return id;
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const exact = runtime.submit({ input: 'exact' });
|
|
265
|
+
const overflow = runtime.submit({ input: 'completion' });
|
|
266
|
+
expect(exact.ok).toBe(true);
|
|
267
|
+
expect(overflow.ok).toBe(true);
|
|
268
|
+
if (!exact.ok || !overflow.ok) return;
|
|
269
|
+
const exactSink = controlled.sinks.get(exact.value.id);
|
|
270
|
+
const overflowSink = controlled.sinks.get(overflow.value.id);
|
|
271
|
+
expect(exactSink).toBeDefined();
|
|
272
|
+
expect(overflowSink).toBeDefined();
|
|
273
|
+
|
|
274
|
+
exactSink?.complete('12345');
|
|
275
|
+
overflowSink?.complete('123456');
|
|
276
|
+
overflowSink?.text('late');
|
|
277
|
+
overflowSink?.complete('duplicate');
|
|
278
|
+
overflowSink?.cancelled();
|
|
279
|
+
|
|
280
|
+
const events = runtime.poll();
|
|
281
|
+
expect(events).toEqual([
|
|
282
|
+
{
|
|
283
|
+
type: 'completed',
|
|
284
|
+
activityId: exactId,
|
|
285
|
+
sequence: 1,
|
|
286
|
+
session: exact.value.session,
|
|
287
|
+
output: '12345',
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
type: 'failed',
|
|
291
|
+
activityId: overflowId,
|
|
292
|
+
sequence: 1,
|
|
293
|
+
error: expect.objectContaining({
|
|
294
|
+
code: 'intelligence-output-overflow',
|
|
295
|
+
detail: {
|
|
296
|
+
activityId: overflowId,
|
|
297
|
+
bound: 'output-chars',
|
|
298
|
+
limit: 5,
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
expect(events.filter((event) => event.type === 'failed')).toHaveLength(1);
|
|
304
|
+
expect(controlled.sinks.has(overflowId)).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('preserves the provider error category and cause projection', () => {
|
|
308
|
+
const controlled = controlledProvider();
|
|
309
|
+
const runtime = createIntelligenceRuntime(controlled.provider, {
|
|
310
|
+
createActivityId: () => activityId('failed'),
|
|
311
|
+
});
|
|
312
|
+
const started = runtime.submit({ input: 'fail' });
|
|
313
|
+
if (!started.ok) throw started.error;
|
|
314
|
+
controlled.sinks.get(started.value.id)?.fail(new Error('provider exploded'));
|
|
315
|
+
const event = runtime.poll()[0];
|
|
316
|
+
expect(event?.type).toBe('failed');
|
|
317
|
+
if (event?.type === 'failed') {
|
|
318
|
+
expect(event.error.code).toBe('intelligence-provider-failed');
|
|
319
|
+
if (event.error.code === 'intelligence-provider-failed') {
|
|
320
|
+
expect(event.error.detail.cause).toBe('provider exploded');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('closes the provider and rejects later work', async () => {
|
|
326
|
+
const controlled = controlledProvider();
|
|
327
|
+
const runtime = createIntelligenceRuntime(controlled.provider);
|
|
328
|
+
await runtime.close();
|
|
329
|
+
expect(controlled.closed).toBe(true);
|
|
330
|
+
const result = runtime.submit({ input: 'late' });
|
|
331
|
+
expect(result.ok).toBe(false);
|
|
332
|
+
if (!result.ok) expect(result.error.code).toBe('intelligence-closed');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('contains provider close rejection, clears records, and quarantines late callbacks', async () => {
|
|
336
|
+
const activity = activityId('close-rejection');
|
|
337
|
+
let sink: ActivitySink | undefined;
|
|
338
|
+
let closeCalls = 0;
|
|
339
|
+
const provider: IntelligenceProvider = {
|
|
340
|
+
id: 'close.rejection.provider',
|
|
341
|
+
start(_submission, nextSink) {
|
|
342
|
+
sink = nextSink;
|
|
343
|
+
return ok(undefined);
|
|
344
|
+
},
|
|
345
|
+
cancel() {
|
|
346
|
+
return ok(undefined);
|
|
347
|
+
},
|
|
348
|
+
async close() {
|
|
349
|
+
closeCalls += 1;
|
|
350
|
+
throw new Error('sentinel provider close failure');
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
354
|
+
createActivityId: () => activity,
|
|
355
|
+
});
|
|
356
|
+
const started = runtime.submit({ input: 'close me' });
|
|
357
|
+
expect(started.ok).toBe(true);
|
|
358
|
+
|
|
359
|
+
const closeTask = runtime.close();
|
|
360
|
+
expect(runtime.close()).toBe(closeTask);
|
|
361
|
+
await expect(closeTask).resolves.toBeUndefined();
|
|
362
|
+
|
|
363
|
+
expect(closeCalls).toBe(1);
|
|
364
|
+
expect(runtime.poll()).toEqual([]);
|
|
365
|
+
const late = runtime.submit({ input: 'after close' });
|
|
366
|
+
expect(late.ok).toBe(false);
|
|
367
|
+
if (!late.ok) expect(late.error.code).toBe('intelligence-closed');
|
|
368
|
+
sink?.text('late text');
|
|
369
|
+
sink?.complete('late completion');
|
|
370
|
+
sink?.fail(new Error('late failure'));
|
|
371
|
+
expect(runtime.poll()).toEqual([]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('accepts a transport-owned identity and provider session', () => {
|
|
375
|
+
const controlled = controlledProvider();
|
|
376
|
+
const runtime = createIntelligenceRuntime(controlled.provider);
|
|
377
|
+
const submission: ActivitySubmission = {
|
|
378
|
+
id: activityId('transport-owned'),
|
|
379
|
+
session: { providerId: 'test.provider', id: 'saved-session' },
|
|
380
|
+
input: 'next turn',
|
|
381
|
+
};
|
|
382
|
+
const result: Result<void, IntelligenceError> = runtime.accept(submission);
|
|
383
|
+
expect(result.ok).toBe(true);
|
|
384
|
+
expect(controlled.sinks.has(submission.id)).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('contains synchronous provider start throws and permits same-runtime retry', () => {
|
|
388
|
+
const siblingId = activityId('sibling');
|
|
389
|
+
const failedId = activityId('failed');
|
|
390
|
+
const spareId = activityId('spare');
|
|
391
|
+
const failedSession = { providerId: 'test.provider', id: 'failed-session' };
|
|
392
|
+
const ids = [siblingId, failedId, spareId, failedId];
|
|
393
|
+
const sinks = new Map<ActivityId, ActivitySink>();
|
|
394
|
+
const starts = new Map<ActivityId, number>();
|
|
395
|
+
let throwOnFailedStart = true;
|
|
396
|
+
let cancelCalls = 0;
|
|
397
|
+
let closeCalls = 0;
|
|
398
|
+
const provider: IntelligenceProvider = {
|
|
399
|
+
id: 'test.provider',
|
|
400
|
+
start(submission, sink) {
|
|
401
|
+
starts.set(submission.id, (starts.get(submission.id) ?? 0) + 1);
|
|
402
|
+
if (submission.id === failedId && throwOnFailedStart) {
|
|
403
|
+
throw new Error('sentinel provider start failure');
|
|
404
|
+
}
|
|
405
|
+
sinks.set(submission.id, sink);
|
|
406
|
+
return ok(undefined);
|
|
407
|
+
},
|
|
408
|
+
cancel() {
|
|
409
|
+
cancelCalls += 1;
|
|
410
|
+
return ok(undefined);
|
|
411
|
+
},
|
|
412
|
+
async close() {
|
|
413
|
+
closeCalls += 1;
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
417
|
+
limits: { maxConcurrentActivities: 2 },
|
|
418
|
+
createActivityId: () => {
|
|
419
|
+
const id = ids.shift();
|
|
420
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
421
|
+
return id;
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const sibling = runtime.submit({ input: 'sibling' });
|
|
426
|
+
expect(sibling.ok).toBe(true);
|
|
427
|
+
if (!sibling.ok) return;
|
|
428
|
+
const siblingSink = sinks.get(sibling.value.id);
|
|
429
|
+
expect(siblingSink).toBeDefined();
|
|
430
|
+
siblingSink?.text('sibling');
|
|
431
|
+
expect(runtime.poll()).toEqual([
|
|
432
|
+
{ type: 'text-delta', activityId: siblingId, sequence: 1, text: 'sibling' },
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
let refused: ReturnType<typeof runtime.submit> | undefined;
|
|
436
|
+
let rawThrow: unknown;
|
|
437
|
+
try {
|
|
438
|
+
refused = runtime.submit({ input: 'throw', session: failedSession });
|
|
439
|
+
} catch (error) {
|
|
440
|
+
rawThrow = error;
|
|
441
|
+
}
|
|
442
|
+
expect(rawThrow).toBeUndefined();
|
|
443
|
+
expect(refused?.ok).toBe(false);
|
|
444
|
+
if (refused === undefined || refused.ok) return;
|
|
445
|
+
expect(refused.error.code).toBe('intelligence-provider-failed');
|
|
446
|
+
if (refused.error.code === 'intelligence-provider-failed') {
|
|
447
|
+
expect(refused.error.detail.providerId).toBe(provider.id);
|
|
448
|
+
expect(refused.error.detail.cause).toBeInstanceOf(Error);
|
|
449
|
+
expect(refused.error.message).not.toContain('sentinel provider start failure');
|
|
450
|
+
expect(refused.error.hint).not.toContain('sentinel provider start failure');
|
|
451
|
+
}
|
|
452
|
+
expect(runtime.poll()).toEqual([]);
|
|
453
|
+
expect(sinks.has(failedId)).toBe(false);
|
|
454
|
+
expect(cancelCalls).toBe(0);
|
|
455
|
+
expect(closeCalls).toBe(0);
|
|
456
|
+
|
|
457
|
+
const spare = runtime.submit({ input: 'spare' });
|
|
458
|
+
expect(spare.ok).toBe(true);
|
|
459
|
+
if (!spare.ok) return;
|
|
460
|
+
sinks.get(spare.value.id)?.complete('spare');
|
|
461
|
+
expect(runtime.poll()).toEqual([
|
|
462
|
+
{
|
|
463
|
+
type: 'completed',
|
|
464
|
+
activityId: spareId,
|
|
465
|
+
sequence: 1,
|
|
466
|
+
session: spare.value.session,
|
|
467
|
+
output: 'spare',
|
|
468
|
+
},
|
|
469
|
+
]);
|
|
470
|
+
|
|
471
|
+
throwOnFailedStart = false;
|
|
472
|
+
const retry = runtime.submit({ input: 'retry', session: failedSession });
|
|
473
|
+
expect(retry.ok).toBe(true);
|
|
474
|
+
if (!retry.ok) return;
|
|
475
|
+
expect(retry.value.id).toBe(failedId);
|
|
476
|
+
expect(retry.value.session).toBe(failedSession);
|
|
477
|
+
siblingSink?.complete('sibling-done');
|
|
478
|
+
sinks.get(retry.value.id)?.complete('retry-done');
|
|
479
|
+
expect(runtime.poll()).toEqual([
|
|
480
|
+
{
|
|
481
|
+
type: 'completed',
|
|
482
|
+
activityId: siblingId,
|
|
483
|
+
sequence: 2,
|
|
484
|
+
session: sibling.value.session,
|
|
485
|
+
output: 'sibling-done',
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
type: 'completed',
|
|
489
|
+
activityId: failedId,
|
|
490
|
+
sequence: 1,
|
|
491
|
+
session: failedSession,
|
|
492
|
+
output: 'retry-done',
|
|
493
|
+
},
|
|
494
|
+
]);
|
|
495
|
+
expect(runtime.poll()).toEqual([]);
|
|
496
|
+
expect(starts.get(siblingId)).toBe(1);
|
|
497
|
+
expect(starts.get(failedId)).toBe(2);
|
|
498
|
+
expect(starts.get(spareId)).toBe(1);
|
|
499
|
+
expect(cancelCalls).toBe(0);
|
|
500
|
+
|
|
501
|
+
const close = runtime.close();
|
|
502
|
+
expect(close).toBe(runtime.close());
|
|
503
|
+
return close.then(() => {
|
|
504
|
+
expect(closeCalls).toBe(1);
|
|
505
|
+
expect(runtime.poll()).toEqual([]);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('contains synchronous provider cancel throws and preserves the same Activity for retry', () => {
|
|
510
|
+
const siblingId = activityId('cancel-sibling');
|
|
511
|
+
const failedId = activityId('cancel-failed');
|
|
512
|
+
const spareId = activityId('cancel-spare');
|
|
513
|
+
const failedSession = { providerId: 'cancel.throw.provider', id: 'failed-session' };
|
|
514
|
+
const ids = [siblingId, failedId, spareId];
|
|
515
|
+
const sinks = new Map<ActivityId, ActivitySink>();
|
|
516
|
+
const starts = new Map<ActivityId, number>();
|
|
517
|
+
const cancelCalls: ActivityId[] = [];
|
|
518
|
+
const sentinel = new Error('sentinel provider cancel failure');
|
|
519
|
+
let throwOnFailedCancel = true;
|
|
520
|
+
const provider: IntelligenceProvider = {
|
|
521
|
+
id: 'cancel.throw.provider',
|
|
522
|
+
start(submission, sink) {
|
|
523
|
+
starts.set(submission.id, (starts.get(submission.id) ?? 0) + 1);
|
|
524
|
+
sinks.set(submission.id, sink);
|
|
525
|
+
return ok(undefined);
|
|
526
|
+
},
|
|
527
|
+
cancel(activity) {
|
|
528
|
+
cancelCalls.push(activity);
|
|
529
|
+
if (activity === failedId && throwOnFailedCancel) throw sentinel;
|
|
530
|
+
const sink = sinks.get(activity);
|
|
531
|
+
if (sink === undefined) {
|
|
532
|
+
return err(
|
|
533
|
+
new IntelligenceError({
|
|
534
|
+
code: 'intelligence-activity-not-found',
|
|
535
|
+
detail: { activityId: activity },
|
|
536
|
+
}),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
sinks.delete(activity);
|
|
540
|
+
sink.cancelled();
|
|
541
|
+
return ok(undefined);
|
|
542
|
+
},
|
|
543
|
+
async close() {},
|
|
544
|
+
};
|
|
545
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
546
|
+
limits: { maxConcurrentActivities: 2 },
|
|
547
|
+
createActivityId: () => {
|
|
548
|
+
const id = ids.shift();
|
|
549
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
550
|
+
return id;
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const sibling = runtime.submit({ input: 'sibling' });
|
|
555
|
+
const failed = runtime.submit({ input: 'cancel', session: failedSession });
|
|
556
|
+
expect(sibling.ok).toBe(true);
|
|
557
|
+
expect(failed.ok).toBe(true);
|
|
558
|
+
if (!sibling.ok || !failed.ok) return;
|
|
559
|
+
expect(runtime.poll()).toEqual([]);
|
|
560
|
+
|
|
561
|
+
let rawThrow: unknown;
|
|
562
|
+
let refused: ReturnType<typeof runtime.cancel> | undefined;
|
|
563
|
+
try {
|
|
564
|
+
refused = runtime.cancel(failed.value.id);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
rawThrow = error;
|
|
567
|
+
}
|
|
568
|
+
expect(rawThrow).toBeUndefined();
|
|
569
|
+
expect(refused?.ok).toBe(false);
|
|
570
|
+
if (refused === undefined || refused.ok) return;
|
|
571
|
+
expect(refused.error.code).toBe('intelligence-provider-failed');
|
|
572
|
+
if (refused.error.code === 'intelligence-provider-failed') {
|
|
573
|
+
expect(refused.error.detail.providerId).toBe(provider.id);
|
|
574
|
+
expect(refused.error.detail.cause).toBe(sentinel);
|
|
575
|
+
expect(refused.error.message).not.toContain(sentinel.message);
|
|
576
|
+
expect(refused.error.hint).not.toContain(sentinel.message);
|
|
577
|
+
}
|
|
578
|
+
expect(cancelCalls).toEqual([failedId]);
|
|
579
|
+
expect(runtime.poll()).toEqual([]);
|
|
580
|
+
|
|
581
|
+
const spare = runtime.submit({ input: 'spare' });
|
|
582
|
+
expect(spare.ok).toBe(false);
|
|
583
|
+
if (!spare.ok) expect(spare.error.code).toBe('intelligence-capacity-exceeded');
|
|
584
|
+
expect(starts.has(spareId)).toBe(false);
|
|
585
|
+
|
|
586
|
+
const siblingSink = sinks.get(sibling.value.id);
|
|
587
|
+
expect(siblingSink).toBeDefined();
|
|
588
|
+
siblingSink?.text('healthy');
|
|
589
|
+
expect(runtime.poll()).toEqual([
|
|
590
|
+
{ type: 'text-delta', activityId: siblingId, sequence: 1, text: 'healthy' },
|
|
591
|
+
]);
|
|
592
|
+
|
|
593
|
+
throwOnFailedCancel = false;
|
|
594
|
+
const retried = runtime.cancel(failed.value.id);
|
|
595
|
+
expect(retried.ok).toBe(true);
|
|
596
|
+
expect(cancelCalls).toEqual([failedId, failedId]);
|
|
597
|
+
expect(runtime.poll()).toEqual([{ type: 'cancelled', activityId: failedId, sequence: 1 }]);
|
|
598
|
+
const afterCancel = runtime.cancel(failed.value.id);
|
|
599
|
+
expect(afterCancel.ok).toBe(false);
|
|
600
|
+
if (!afterCancel.ok) expect(afterCancel.error.code).toBe('intelligence-activity-not-found');
|
|
601
|
+
|
|
602
|
+
siblingSink?.complete('healthy-done');
|
|
603
|
+
expect(runtime.poll()).toEqual([
|
|
604
|
+
{
|
|
605
|
+
type: 'completed',
|
|
606
|
+
activityId: siblingId,
|
|
607
|
+
sequence: 2,
|
|
608
|
+
session: sibling.value.session,
|
|
609
|
+
output: 'healthy-done',
|
|
610
|
+
},
|
|
611
|
+
]);
|
|
612
|
+
expect(starts.get(siblingId)).toBe(1);
|
|
613
|
+
expect(starts.get(failedId)).toBe(1);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('contains a throwing overflow cancellation and preserves provider Result errors', () => {
|
|
617
|
+
const siblingId = activityId('overflow-sibling');
|
|
618
|
+
const returnedId = activityId('returned-cancel-error');
|
|
619
|
+
const overflowId = activityId('overflow-throw');
|
|
620
|
+
const ids = [siblingId, returnedId, overflowId];
|
|
621
|
+
const sinks = new Map<ActivityId, ActivitySink>();
|
|
622
|
+
const cancelCalls: ActivityId[] = [];
|
|
623
|
+
const overflowSentinel = new Error('sentinel overflow cancel failure');
|
|
624
|
+
const returnedError = new IntelligenceError({
|
|
625
|
+
code: 'intelligence-provider-failed',
|
|
626
|
+
detail: { providerId: 'inner.provider', cause: 'provider returned error' },
|
|
627
|
+
});
|
|
628
|
+
const returnedResult = err(returnedError);
|
|
629
|
+
const provider: IntelligenceProvider = {
|
|
630
|
+
id: 'overflow.throw.provider',
|
|
631
|
+
start(submission, sink) {
|
|
632
|
+
sinks.set(submission.id, sink);
|
|
633
|
+
return ok(undefined);
|
|
634
|
+
},
|
|
635
|
+
cancel(activity) {
|
|
636
|
+
cancelCalls.push(activity);
|
|
637
|
+
if (activity === returnedId) return returnedResult;
|
|
638
|
+
if (activity === overflowId) throw overflowSentinel;
|
|
639
|
+
const sink = sinks.get(activity);
|
|
640
|
+
if (sink === undefined) {
|
|
641
|
+
return err(
|
|
642
|
+
new IntelligenceError({
|
|
643
|
+
code: 'intelligence-activity-not-found',
|
|
644
|
+
detail: { activityId: activity },
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
sinks.delete(activity);
|
|
649
|
+
sink.cancelled();
|
|
650
|
+
return ok(undefined);
|
|
651
|
+
},
|
|
652
|
+
async close() {},
|
|
653
|
+
};
|
|
654
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
655
|
+
limits: { maxPendingEventsPerActivity: 2, maxConcurrentActivities: 3 },
|
|
656
|
+
createActivityId: () => {
|
|
657
|
+
const id = ids.shift();
|
|
658
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
659
|
+
return id;
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const sibling = runtime.submit({ input: 'sibling' });
|
|
664
|
+
const returned = runtime.submit({ input: 'returned' });
|
|
665
|
+
const overflow = runtime.submit({ input: 'overflow' });
|
|
666
|
+
expect(sibling.ok).toBe(true);
|
|
667
|
+
expect(returned.ok).toBe(true);
|
|
668
|
+
expect(overflow.ok).toBe(true);
|
|
669
|
+
if (!sibling.ok || !returned.ok || !overflow.ok) return;
|
|
670
|
+
|
|
671
|
+
const returnedCancel = runtime.cancel(returned.value.id);
|
|
672
|
+
expect(returnedCancel).toBe(returnedResult);
|
|
673
|
+
expect(runtime.poll()).toEqual([]);
|
|
674
|
+
sinks.get(returned.value.id)?.complete('returned-retry');
|
|
675
|
+
expect(runtime.poll()).toEqual([
|
|
676
|
+
{
|
|
677
|
+
type: 'completed',
|
|
678
|
+
activityId: returnedId,
|
|
679
|
+
sequence: 1,
|
|
680
|
+
session: returned.value.session,
|
|
681
|
+
output: 'returned-retry',
|
|
682
|
+
},
|
|
683
|
+
]);
|
|
684
|
+
|
|
685
|
+
const overflowSink = sinks.get(overflow.value.id);
|
|
686
|
+
expect(overflowSink).toBeDefined();
|
|
687
|
+
overflowSink?.text('first');
|
|
688
|
+
let rawThrow: unknown;
|
|
689
|
+
try {
|
|
690
|
+
overflowSink?.text('second');
|
|
691
|
+
} catch (error) {
|
|
692
|
+
rawThrow = error;
|
|
693
|
+
}
|
|
694
|
+
expect(rawThrow).toBeUndefined();
|
|
695
|
+
const events = runtime.poll();
|
|
696
|
+
expect(events).toHaveLength(2);
|
|
697
|
+
expect(events.filter((event) => event.type === 'failed')).toHaveLength(1);
|
|
698
|
+
expect(events[0]).toEqual({
|
|
699
|
+
type: 'text-delta',
|
|
700
|
+
activityId: overflowId,
|
|
701
|
+
sequence: 1,
|
|
702
|
+
text: 'first',
|
|
703
|
+
});
|
|
704
|
+
const overflowEvent = events[1];
|
|
705
|
+
expect(overflowEvent?.type).toBe('failed');
|
|
706
|
+
if (overflowEvent?.type === 'failed') {
|
|
707
|
+
expect(overflowEvent.error.code).toBe('intelligence-output-overflow');
|
|
708
|
+
if (overflowEvent.error.code === 'intelligence-output-overflow') {
|
|
709
|
+
expect(overflowEvent.error.detail).toEqual({
|
|
710
|
+
activityId: overflowId,
|
|
711
|
+
bound: 'pending-events',
|
|
712
|
+
limit: 2,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
expect(cancelCalls).toEqual([returnedId, overflowId]);
|
|
717
|
+
|
|
718
|
+
const siblingSink = sinks.get(sibling.value.id);
|
|
719
|
+
siblingSink?.text('sibling-alive');
|
|
720
|
+
expect(runtime.poll()).toEqual([
|
|
721
|
+
{ type: 'text-delta', activityId: siblingId, sequence: 1, text: 'sibling-alive' },
|
|
722
|
+
]);
|
|
723
|
+
siblingSink?.complete('sibling-done');
|
|
724
|
+
expect(runtime.poll()).toEqual([
|
|
725
|
+
{
|
|
726
|
+
type: 'completed',
|
|
727
|
+
activityId: siblingId,
|
|
728
|
+
sequence: 2,
|
|
729
|
+
session: sibling.value.session,
|
|
730
|
+
output: 'sibling-done',
|
|
731
|
+
},
|
|
732
|
+
]);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('releases overflow capacity and quarantines late provider output before a same-session retry', async () => {
|
|
736
|
+
const overflowId = activityId('pending-overflow');
|
|
737
|
+
const siblingId = activityId('healthy-sibling');
|
|
738
|
+
const retryId = activityId('overflow-retry');
|
|
739
|
+
const overflowSession = { providerId: 'overflow.recovery.provider', id: 'session-a' };
|
|
740
|
+
const ids = [overflowId, siblingId, retryId];
|
|
741
|
+
const sinks = new Map<ActivityId, ActivitySink>();
|
|
742
|
+
const starts: Array<{ readonly id: ActivityId; readonly session: string }> = [];
|
|
743
|
+
let overflowCancelCalls = 0;
|
|
744
|
+
let lateDelta: (() => void) | undefined;
|
|
745
|
+
let lateCompletion: (() => void) | undefined;
|
|
746
|
+
let closeCalls = 0;
|
|
747
|
+
const provider: IntelligenceProvider = {
|
|
748
|
+
id: 'overflow.recovery.provider',
|
|
749
|
+
start(submission, sink) {
|
|
750
|
+
starts.push({ id: submission.id, session: submission.session.id });
|
|
751
|
+
sinks.set(submission.id, sink);
|
|
752
|
+
if (submission.input === 'overflow') {
|
|
753
|
+
sink.text('one');
|
|
754
|
+
sink.text('two');
|
|
755
|
+
sink.text('three');
|
|
756
|
+
}
|
|
757
|
+
return ok(undefined);
|
|
758
|
+
},
|
|
759
|
+
cancel(activity) {
|
|
760
|
+
if (activity === overflowId) {
|
|
761
|
+
overflowCancelCalls += 1;
|
|
762
|
+
const sink = sinks.get(activity);
|
|
763
|
+
if (sink !== undefined) {
|
|
764
|
+
sinks.delete(activity);
|
|
765
|
+
lateDelta = () => sink.text('late');
|
|
766
|
+
lateCompletion = () => sink.complete('late');
|
|
767
|
+
}
|
|
768
|
+
throw new Error('sentinel overflow cancel failure');
|
|
769
|
+
}
|
|
770
|
+
const sink = sinks.get(activity);
|
|
771
|
+
if (sink === undefined) {
|
|
772
|
+
return err(
|
|
773
|
+
new IntelligenceError({
|
|
774
|
+
code: 'intelligence-activity-not-found',
|
|
775
|
+
detail: { activityId: activity },
|
|
776
|
+
}),
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
sinks.delete(activity);
|
|
780
|
+
sink.cancelled();
|
|
781
|
+
return ok(undefined);
|
|
782
|
+
},
|
|
783
|
+
async close() {
|
|
784
|
+
closeCalls += 1;
|
|
785
|
+
sinks.clear();
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
const runtime = createIntelligenceRuntime(provider, {
|
|
789
|
+
limits: { maxConcurrentActivities: 2, maxPendingEventsPerActivity: 3 },
|
|
790
|
+
createActivityId: () => {
|
|
791
|
+
const id = ids.shift();
|
|
792
|
+
if (id === undefined) throw new Error('test activity identity exhausted');
|
|
793
|
+
return id;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const overflow = runtime.submit({ input: 'overflow', session: overflowSession });
|
|
798
|
+
const sibling = runtime.submit({ input: 'sibling' });
|
|
799
|
+
expect(overflow.ok).toBe(true);
|
|
800
|
+
expect(sibling.ok).toBe(true);
|
|
801
|
+
if (!overflow.ok || !sibling.ok) return;
|
|
802
|
+
|
|
803
|
+
const overflowEvents = runtime.poll();
|
|
804
|
+
expect(overflowEvents).toHaveLength(3);
|
|
805
|
+
expect(overflowEvents[0]).toEqual({
|
|
806
|
+
type: 'text-delta',
|
|
807
|
+
activityId: overflowId,
|
|
808
|
+
sequence: 1,
|
|
809
|
+
text: 'one',
|
|
810
|
+
});
|
|
811
|
+
expect(overflowEvents[1]).toEqual({
|
|
812
|
+
type: 'text-delta',
|
|
813
|
+
activityId: overflowId,
|
|
814
|
+
sequence: 2,
|
|
815
|
+
text: 'two',
|
|
816
|
+
});
|
|
817
|
+
const overflowEvent = overflowEvents[2];
|
|
818
|
+
expect(overflowEvent?.type).toBe('failed');
|
|
819
|
+
if (overflowEvent?.type === 'failed') {
|
|
820
|
+
expect(overflowEvent.error.code).toBe('intelligence-output-overflow');
|
|
821
|
+
if (overflowEvent.error.code === 'intelligence-output-overflow') {
|
|
822
|
+
expect(overflowEvent.error.detail).toEqual({
|
|
823
|
+
activityId: overflowId,
|
|
824
|
+
bound: 'pending-events',
|
|
825
|
+
limit: 3,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
expect(overflowCancelCalls).toBe(1);
|
|
830
|
+
|
|
831
|
+
const retry = runtime.submit({ input: 'retry', session: overflow.value.session });
|
|
832
|
+
expect(retry.ok).toBe(true);
|
|
833
|
+
if (!retry.ok) return;
|
|
834
|
+
expect(retry.value.session).toEqual(overflow.value.session);
|
|
835
|
+
lateDelta?.();
|
|
836
|
+
lateCompletion?.();
|
|
837
|
+
expect(runtime.poll()).toEqual([]);
|
|
838
|
+
|
|
839
|
+
sinks.get(sibling.value.id)?.complete('healthy');
|
|
840
|
+
sinks.get(retry.value.id)?.complete('retry');
|
|
841
|
+
expect(runtime.poll()).toEqual([
|
|
842
|
+
{
|
|
843
|
+
type: 'completed',
|
|
844
|
+
activityId: siblingId,
|
|
845
|
+
sequence: 1,
|
|
846
|
+
session: sibling.value.session,
|
|
847
|
+
output: 'healthy',
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
type: 'completed',
|
|
851
|
+
activityId: retryId,
|
|
852
|
+
sequence: 1,
|
|
853
|
+
session: retry.value.session,
|
|
854
|
+
output: 'retry',
|
|
855
|
+
},
|
|
856
|
+
]);
|
|
857
|
+
const close = runtime.close();
|
|
858
|
+
expect(close).toBe(runtime.close());
|
|
859
|
+
await close;
|
|
860
|
+
expect(closeCalls).toBe(1);
|
|
861
|
+
expect(starts).toEqual([
|
|
862
|
+
{ id: overflowId, session: overflowSession.id },
|
|
863
|
+
{ id: siblingId, session: sibling.value.session.id },
|
|
864
|
+
{ id: retryId, session: overflowSession.id },
|
|
865
|
+
]);
|
|
866
|
+
});
|
|
867
|
+
});
|