@intx/inference 0.1.2 → 0.3.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 +176 -0
- package/dist/actions.d.ts +16 -0
- package/dist/actions.js +200 -0
- package/dist/adapter.d.ts +40 -0
- package/dist/adapter.js +31 -0
- package/dist/assembly.d.ts +75 -0
- package/dist/assembly.js +133 -0
- package/dist/audit-collector.d.ts +10 -0
- package/dist/audit-collector.js +139 -0
- package/dist/auth.d.ts +24 -0
- package/{src/auth.ts → dist/auth.js} +13 -19
- package/dist/authz-extension.d.ts +46 -0
- package/dist/authz-extension.js +184 -0
- package/dist/correlation.d.ts +26 -0
- package/dist/correlation.js +39 -0
- package/dist/default-director.d.ts +111 -0
- package/dist/default-director.js +228 -0
- package/dist/director.d.ts +6 -0
- package/dist/director.js +56 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +83 -0
- package/dist/gates.d.ts +28 -0
- package/dist/gates.js +103 -0
- package/dist/harness.d.ts +147 -0
- package/dist/harness.js +1407 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +21 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/manifest.js +44 -0
- package/dist/providers/anthropic.d.ts +37 -0
- package/dist/providers/anthropic.js +917 -0
- package/dist/providers/google-genai-files.d.ts +48 -0
- package/dist/providers/google-genai-files.js +205 -0
- package/dist/providers/google-genai.d.ts +5 -0
- package/dist/providers/google-genai.js +1205 -0
- package/dist/providers/index.d.ts +38 -0
- package/dist/providers/index.js +56 -0
- package/dist/providers/openai.d.ts +9 -0
- package/dist/providers/openai.js +903 -0
- package/dist/reactor.d.ts +50 -0
- package/dist/reactor.js +1233 -0
- package/dist/retry-policy.d.ts +31 -0
- package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
- package/dist/sse.d.ts +1 -0
- package/dist/sse.js +63 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.js +100 -0
- package/dist/tool-name.d.ts +6 -0
- package/dist/tool-name.js +110 -0
- package/dist/transform.d.ts +11 -0
- package/dist/transform.js +132 -0
- package/dist/transforms/index.d.ts +2 -0
- package/dist/transforms/index.js +1 -0
- package/dist/transforms/size-cap.d.ts +12 -0
- package/dist/transforms/size-cap.js +80 -0
- package/dist/turns.d.ts +21 -0
- package/dist/turns.js +135 -0
- package/package.json +22 -6
- package/src/actions.ts +0 -245
- package/src/adapter.ts +0 -57
- package/src/assembly.test.ts +0 -728
- package/src/assembly.ts +0 -250
- package/src/audit-collector.test.ts +0 -332
- package/src/audit-collector.ts +0 -172
- package/src/auth.test.ts +0 -117
- package/src/authz-extension.test.ts +0 -269
- package/src/authz-extension.ts +0 -145
- package/src/correlation.ts +0 -61
- package/src/default-director.test.ts +0 -314
- package/src/default-director.ts +0 -344
- package/src/director.ts +0 -87
- package/src/errors.test.ts +0 -133
- package/src/errors.ts +0 -115
- package/src/gates.ts +0 -128
- package/src/harness.test.ts +0 -655
- package/src/harness.ts +0 -1571
- package/src/index.ts +0 -76
- package/src/providers/anthropic.test.ts +0 -771
- package/src/providers/anthropic.ts +0 -810
- package/src/providers/google-genai-files.ts +0 -289
- package/src/providers/google-genai.ts +0 -1518
- package/src/providers/openai.ts +0 -719
- package/src/providers/registry.ts +0 -33
- package/src/reactor.test.ts +0 -3660
- package/src/reactor.ts +0 -1058
- package/src/scheduler.test.ts +0 -41
- package/src/sse.test.ts +0 -133
- package/src/sse.ts +0 -76
- package/src/state.ts +0 -135
- package/src/transform.test.ts +0 -207
- package/src/transform.ts +0 -159
- package/src/transforms/index.ts +0 -2
- package/src/transforms/size-cap.test.ts +0 -172
- package/src/transforms/size-cap.ts +0 -110
- package/src/turns.ts +0 -54
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/reactor.test.ts
DELETED
|
@@ -1,3660 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
|
|
3
|
-
import { validateActions } from "./actions";
|
|
4
|
-
import { createGateManager } from "./gates";
|
|
5
|
-
import { createCorrelationRegistry } from "./correlation";
|
|
6
|
-
import { createReactor } from "./reactor";
|
|
7
|
-
import { createDefaultDependencies } from "./harness";
|
|
8
|
-
import { createInboundMessage } from "@intx/mime";
|
|
9
|
-
|
|
10
|
-
import type {
|
|
11
|
-
ReactorDirector,
|
|
12
|
-
ReactorAction,
|
|
13
|
-
ReactorInboundEvent,
|
|
14
|
-
ReactorState,
|
|
15
|
-
ReactorCapabilities,
|
|
16
|
-
ContextStore,
|
|
17
|
-
ToolRunner,
|
|
18
|
-
InferenceEvent,
|
|
19
|
-
InboundMessage,
|
|
20
|
-
ConversationTurn,
|
|
21
|
-
PendingOperation,
|
|
22
|
-
TokenUsage,
|
|
23
|
-
ContextCommit,
|
|
24
|
-
AssistantTurn,
|
|
25
|
-
InferenceError,
|
|
26
|
-
PartialMessage,
|
|
27
|
-
BeforeToolExtension,
|
|
28
|
-
Compactor,
|
|
29
|
-
LastCycleSource,
|
|
30
|
-
} from "@intx/types/runtime";
|
|
31
|
-
|
|
32
|
-
import type { ReactorConfig, Reactor, ReactorEmittedEvent } from "./reactor";
|
|
33
|
-
import type { Dependencies, InferenceHarnessOptions } from "./harness";
|
|
34
|
-
import type { CorrelationValidator } from "./correlation";
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Helpers
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
function emptyUsage(): TokenUsage {
|
|
41
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const TEST_SOURCE: LastCycleSource = {
|
|
45
|
-
sourceId: "test-source",
|
|
46
|
-
provider: "test-provider",
|
|
47
|
-
model: "test-model",
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
function makeContextStore(turns: ConversationTurn[] = []): ContextStore {
|
|
51
|
-
async function commit(
|
|
52
|
-
options: { message: string },
|
|
53
|
-
_signal?: AbortSignal,
|
|
54
|
-
): Promise<ContextCommit> {
|
|
55
|
-
return { hash: "abc", message: options.message, timestamp: Date.now() };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
async load() {
|
|
60
|
-
return {
|
|
61
|
-
turns,
|
|
62
|
-
pendingOperations: [],
|
|
63
|
-
tokenUsage: emptyUsage(),
|
|
64
|
-
connectorState: null,
|
|
65
|
-
};
|
|
66
|
-
},
|
|
67
|
-
setConnectorState() {
|
|
68
|
-
/* noop */
|
|
69
|
-
},
|
|
70
|
-
commit,
|
|
71
|
-
async branch() {
|
|
72
|
-
/* noop */
|
|
73
|
-
},
|
|
74
|
-
async log() {
|
|
75
|
-
return [];
|
|
76
|
-
},
|
|
77
|
-
async readAt() {
|
|
78
|
-
return [];
|
|
79
|
-
},
|
|
80
|
-
async writeBlob() {
|
|
81
|
-
/* noop */
|
|
82
|
-
},
|
|
83
|
-
async readBlob() {
|
|
84
|
-
throw new Error("not implemented");
|
|
85
|
-
},
|
|
86
|
-
async writePrompt() {
|
|
87
|
-
/* noop */
|
|
88
|
-
},
|
|
89
|
-
async writeResponse() {
|
|
90
|
-
/* noop */
|
|
91
|
-
},
|
|
92
|
-
async writeManifest() {
|
|
93
|
-
/* noop */
|
|
94
|
-
},
|
|
95
|
-
async writeTurns() {
|
|
96
|
-
/* noop */
|
|
97
|
-
},
|
|
98
|
-
async writeMetadata() {
|
|
99
|
-
/* noop */
|
|
100
|
-
},
|
|
101
|
-
async readManifestHistory() {
|
|
102
|
-
throw new Error("not implemented");
|
|
103
|
-
},
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function makeToolRunner(
|
|
108
|
-
handler: (call: {
|
|
109
|
-
id: string;
|
|
110
|
-
name: string;
|
|
111
|
-
arguments: Record<string, unknown>;
|
|
112
|
-
}) => Promise<{
|
|
113
|
-
callId: string;
|
|
114
|
-
content: string;
|
|
115
|
-
isError?: boolean;
|
|
116
|
-
}>,
|
|
117
|
-
): ToolRunner {
|
|
118
|
-
return {
|
|
119
|
-
async run(call, _signal) {
|
|
120
|
-
return handler(call);
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function noopToolRunner(): ToolRunner {
|
|
126
|
-
return {
|
|
127
|
-
async run(call) {
|
|
128
|
-
return { callId: call.id, content: "ok" };
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function collectEvents(): {
|
|
134
|
-
events: ReactorEmittedEvent[];
|
|
135
|
-
onEvent: (e: ReactorEmittedEvent) => void;
|
|
136
|
-
} {
|
|
137
|
-
const events: ReactorEmittedEvent[] = [];
|
|
138
|
-
return {
|
|
139
|
-
events,
|
|
140
|
-
onEvent: (e: ReactorEmittedEvent) => events.push(e),
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function waitForEvent(
|
|
145
|
-
events: ReactorEmittedEvent[],
|
|
146
|
-
predicate: (e: ReactorEmittedEvent) => boolean,
|
|
147
|
-
timeoutMs = 2000,
|
|
148
|
-
): Promise<ReactorEmittedEvent> {
|
|
149
|
-
return new Promise((resolve, reject) => {
|
|
150
|
-
const deadline = setTimeout(
|
|
151
|
-
() => reject(new Error("Timed out waiting for event")),
|
|
152
|
-
timeoutMs,
|
|
153
|
-
);
|
|
154
|
-
|
|
155
|
-
function check() {
|
|
156
|
-
const found = events.find(predicate);
|
|
157
|
-
if (found !== undefined) {
|
|
158
|
-
clearTimeout(deadline);
|
|
159
|
-
resolve(found);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
setTimeout(check, 10);
|
|
163
|
-
}
|
|
164
|
-
check();
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Simple inbound message factory. Delegates to the mail-builder so the
|
|
169
|
-
// reactor tests exercise the same shape consumers of @intx/mime
|
|
170
|
-
// produce in production code.
|
|
171
|
-
function makeInboundMessage(correlationId?: string): InboundMessage {
|
|
172
|
-
return createInboundMessage({
|
|
173
|
-
from: "test@example.com",
|
|
174
|
-
to: "agent@example.com",
|
|
175
|
-
content: "hello",
|
|
176
|
-
...(correlationId !== undefined ? { correlationId } : {}),
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
// Test harness helpers
|
|
182
|
-
// ---------------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
let testSessionCounter = 0;
|
|
185
|
-
|
|
186
|
-
type DirectorHandler<E> = (
|
|
187
|
-
event: E,
|
|
188
|
-
state: ReactorState,
|
|
189
|
-
caps: ReactorCapabilities,
|
|
190
|
-
) => ReactorAction | ReactorAction[] | Promise<ReactorAction | ReactorAction[]>;
|
|
191
|
-
|
|
192
|
-
type DirectorTable = {
|
|
193
|
-
[K in ReactorInboundEvent["type"]]?: DirectorHandler<
|
|
194
|
-
Extract<ReactorInboundEvent, { type: K }>
|
|
195
|
-
>;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
function directorFromTable(
|
|
199
|
-
table: DirectorTable,
|
|
200
|
-
defaultAction: "done" | "wait" = "done",
|
|
201
|
-
): ReactorDirector {
|
|
202
|
-
return {
|
|
203
|
-
async decide(event, state, caps) {
|
|
204
|
-
// TypeScript cannot correlate the runtime key with the mapped type's
|
|
205
|
-
// per-key handler signature (correlated union problem): table[event.type]
|
|
206
|
-
// is typed as a union of all handlers, but we know it matches this event.
|
|
207
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- correlated union: table[event.type] is guaranteed to be typed for this event.type by the DirectorTable mapped type
|
|
208
|
-
const handler = table[event.type] as
|
|
209
|
-
| DirectorHandler<typeof event>
|
|
210
|
-
| undefined;
|
|
211
|
-
if (handler !== undefined) {
|
|
212
|
-
return handler(event, state, caps);
|
|
213
|
-
}
|
|
214
|
-
return defaultAction === "done" ? caps.done() : caps.wait();
|
|
215
|
-
},
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
type TestReactorOverrides = {
|
|
220
|
-
director?: ReactorDirector;
|
|
221
|
-
toolRunner?: ToolRunner;
|
|
222
|
-
contextStore?: ContextStore;
|
|
223
|
-
correlationValidator?: CorrelationValidator;
|
|
224
|
-
inferenceRunner?: (
|
|
225
|
-
opts: InferenceHarnessOptions,
|
|
226
|
-
) => AsyncGenerator<InferenceEvent>;
|
|
227
|
-
beforeToolExtensions?: BeforeToolExtension[];
|
|
228
|
-
afterCheckpoint?: () => Promise<void>;
|
|
229
|
-
onShutdown?: () => Promise<void>;
|
|
230
|
-
sessionId?: string;
|
|
231
|
-
gateTimeout?: number;
|
|
232
|
-
shutdownTimeoutMs?: number;
|
|
233
|
-
// Wire-level tests (see tests/inference/reactor-streaming.test.ts) supply
|
|
234
|
-
// their own `Dependencies` (fetch stubbed by the harness) and may target a
|
|
235
|
-
// non-Anthropic provider. Both override hooks are optional; omit them for
|
|
236
|
-
// tests that don't care about the inference HTTP path.
|
|
237
|
-
deps?: Dependencies;
|
|
238
|
-
source?: ReactorConfig["source"];
|
|
239
|
-
compactors?: Record<string, Compactor>;
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
type TestReactorHandle = {
|
|
243
|
-
reactor: Reactor;
|
|
244
|
-
events: ReactorEmittedEvent[];
|
|
245
|
-
waitFor: (
|
|
246
|
-
type: ReactorEmittedEvent["type"],
|
|
247
|
-
timeoutMs?: number,
|
|
248
|
-
) => Promise<ReactorEmittedEvent>;
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
function createTestReactor(
|
|
252
|
-
overrides: TestReactorOverrides = {},
|
|
253
|
-
): TestReactorHandle {
|
|
254
|
-
const { events, onEvent } = collectEvents();
|
|
255
|
-
const sessionId = overrides.sessionId ?? `test-sess-${++testSessionCounter}`;
|
|
256
|
-
|
|
257
|
-
const config: ReactorConfig = {
|
|
258
|
-
sessionId,
|
|
259
|
-
director: overrides.director ?? directorFromTable({}),
|
|
260
|
-
source: overrides.source ?? {
|
|
261
|
-
id: "anthropic:test-model",
|
|
262
|
-
provider: "anthropic",
|
|
263
|
-
baseURL: "https://api.anthropic.com",
|
|
264
|
-
apiKey: "test",
|
|
265
|
-
model: "test-model",
|
|
266
|
-
},
|
|
267
|
-
toolRunner: overrides.toolRunner ?? noopToolRunner(),
|
|
268
|
-
contextStore: overrides.contextStore ?? makeContextStore(),
|
|
269
|
-
onEvent,
|
|
270
|
-
deps: overrides.deps ?? createDefaultDependencies(),
|
|
271
|
-
shutdownTimeoutMs: overrides.shutdownTimeoutMs ?? 100,
|
|
272
|
-
...(overrides.correlationValidator !== undefined
|
|
273
|
-
? { correlationValidator: overrides.correlationValidator }
|
|
274
|
-
: {}),
|
|
275
|
-
...(overrides.inferenceRunner !== undefined
|
|
276
|
-
? { inferenceRunner: overrides.inferenceRunner }
|
|
277
|
-
: {}),
|
|
278
|
-
...(overrides.beforeToolExtensions !== undefined
|
|
279
|
-
? { beforeToolExtensions: overrides.beforeToolExtensions }
|
|
280
|
-
: {}),
|
|
281
|
-
...(overrides.afterCheckpoint !== undefined
|
|
282
|
-
? { afterCheckpoint: overrides.afterCheckpoint }
|
|
283
|
-
: {}),
|
|
284
|
-
...(overrides.onShutdown !== undefined
|
|
285
|
-
? { onShutdown: overrides.onShutdown }
|
|
286
|
-
: {}),
|
|
287
|
-
...(overrides.gateTimeout !== undefined
|
|
288
|
-
? { gateTimeout: overrides.gateTimeout }
|
|
289
|
-
: {}),
|
|
290
|
-
...(overrides.compactors !== undefined
|
|
291
|
-
? { compactors: overrides.compactors }
|
|
292
|
-
: {}),
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
const reactor = createReactor(config);
|
|
296
|
-
|
|
297
|
-
function waitFor(
|
|
298
|
-
type: ReactorEmittedEvent["type"],
|
|
299
|
-
timeoutMs = 2000,
|
|
300
|
-
): Promise<ReactorEmittedEvent> {
|
|
301
|
-
return waitForEvent(events, (e) => e.type === type, timeoutMs);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return { reactor, events, waitFor };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
function failingContextStore(error: Error): ContextStore {
|
|
308
|
-
const fail = () => {
|
|
309
|
-
throw new Error("failingContextStore: should not be called");
|
|
310
|
-
};
|
|
311
|
-
return {
|
|
312
|
-
async load() {
|
|
313
|
-
throw error;
|
|
314
|
-
},
|
|
315
|
-
setConnectorState() {
|
|
316
|
-
return fail();
|
|
317
|
-
},
|
|
318
|
-
async commit() {
|
|
319
|
-
return fail();
|
|
320
|
-
},
|
|
321
|
-
async branch() {
|
|
322
|
-
return fail();
|
|
323
|
-
},
|
|
324
|
-
async log() {
|
|
325
|
-
return fail();
|
|
326
|
-
},
|
|
327
|
-
async readAt() {
|
|
328
|
-
return fail();
|
|
329
|
-
},
|
|
330
|
-
async writeBlob() {
|
|
331
|
-
return fail();
|
|
332
|
-
},
|
|
333
|
-
async readBlob() {
|
|
334
|
-
return fail();
|
|
335
|
-
},
|
|
336
|
-
async writePrompt() {
|
|
337
|
-
return fail();
|
|
338
|
-
},
|
|
339
|
-
async writeResponse() {
|
|
340
|
-
return fail();
|
|
341
|
-
},
|
|
342
|
-
async writeManifest() {
|
|
343
|
-
return fail();
|
|
344
|
-
},
|
|
345
|
-
async writeTurns() {
|
|
346
|
-
return fail();
|
|
347
|
-
},
|
|
348
|
-
async writeMetadata() {
|
|
349
|
-
return fail();
|
|
350
|
-
},
|
|
351
|
-
async readManifestHistory() {
|
|
352
|
-
return fail();
|
|
353
|
-
},
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function throwingToolRunner(error: Error): ToolRunner {
|
|
358
|
-
return {
|
|
359
|
-
async run() {
|
|
360
|
-
throw error;
|
|
361
|
-
},
|
|
362
|
-
};
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function getEvent<T extends ReactorEmittedEvent["type"]>(
|
|
366
|
-
events: ReactorEmittedEvent[],
|
|
367
|
-
type: T,
|
|
368
|
-
): Extract<ReactorEmittedEvent, { type: T }> {
|
|
369
|
-
const found = events.find(
|
|
370
|
-
(e): e is Extract<ReactorEmittedEvent, { type: T }> => e.type === type,
|
|
371
|
-
);
|
|
372
|
-
if (found === undefined) {
|
|
373
|
-
throw new Error(`No event of type '${type}' found`);
|
|
374
|
-
}
|
|
375
|
-
return found;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ---------------------------------------------------------------------------
|
|
379
|
-
// 1. Action validation
|
|
380
|
-
// ---------------------------------------------------------------------------
|
|
381
|
-
|
|
382
|
-
describe("validateActions", () => {
|
|
383
|
-
test("single infer action is valid", () => {
|
|
384
|
-
const result = validateActions({ type: "infer" });
|
|
385
|
-
expect(result.ok).toBe(true);
|
|
386
|
-
if (!result.ok) throw new Error("unreachable");
|
|
387
|
-
expect(result.normalized.some((a) => a.type === "infer")).toBe(true);
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("single done action is valid", () => {
|
|
391
|
-
const result = validateActions({ type: "done" });
|
|
392
|
-
expect(result.ok).toBe(true);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
test("single execute_tools is valid", () => {
|
|
396
|
-
const result = validateActions({
|
|
397
|
-
type: "execute_tools",
|
|
398
|
-
calls: [{ id: "c1", name: "tool", arguments: {} }],
|
|
399
|
-
});
|
|
400
|
-
expect(result.ok).toBe(true);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("checkpoint + infer is valid (composable)", () => {
|
|
404
|
-
const result = validateActions([
|
|
405
|
-
{ type: "checkpoint", message: "checkpoint" },
|
|
406
|
-
{ type: "infer" },
|
|
407
|
-
]);
|
|
408
|
-
expect(result.ok).toBe(true);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
test("checkpoint message is preserved in normalized output", () => {
|
|
412
|
-
const result = validateActions({
|
|
413
|
-
type: "checkpoint",
|
|
414
|
-
message: "before tool call",
|
|
415
|
-
});
|
|
416
|
-
expect(result.ok).toBe(true);
|
|
417
|
-
if (!result.ok) throw new Error("unreachable");
|
|
418
|
-
const cp = result.normalized.find((a) => a.type === "checkpoint");
|
|
419
|
-
expect(cp).toBeDefined();
|
|
420
|
-
if (cp?.type !== "checkpoint") throw new Error("unreachable");
|
|
421
|
-
expect(cp.message).toBe("before tool call");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("multiple checkpoint actions are rejected", () => {
|
|
425
|
-
const result = validateActions([
|
|
426
|
-
{ type: "checkpoint", message: "first" },
|
|
427
|
-
{ type: "checkpoint", message: "second" },
|
|
428
|
-
]);
|
|
429
|
-
expect(result.ok).toBe(false);
|
|
430
|
-
if (result.ok) throw new Error("unreachable");
|
|
431
|
-
expect(result.error).toMatch(/multiple checkpoint/i);
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test("emit + infer is valid", () => {
|
|
435
|
-
const result = validateActions([
|
|
436
|
-
{ type: "emit", eventType: "custom.progress", data: { pct: 50 } },
|
|
437
|
-
{ type: "infer" },
|
|
438
|
-
]);
|
|
439
|
-
expect(result.ok).toBe(true);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("multiple execute_tools are collapsed into one", () => {
|
|
443
|
-
const result = validateActions([
|
|
444
|
-
{
|
|
445
|
-
type: "execute_tools",
|
|
446
|
-
calls: [{ id: "c1", name: "a", arguments: {} }],
|
|
447
|
-
},
|
|
448
|
-
{
|
|
449
|
-
type: "execute_tools",
|
|
450
|
-
calls: [{ id: "c2", name: "b", arguments: {} }],
|
|
451
|
-
},
|
|
452
|
-
]);
|
|
453
|
-
expect(result.ok).toBe(true);
|
|
454
|
-
if (!result.ok) throw new Error("unreachable");
|
|
455
|
-
const toolActions = result.normalized.filter(
|
|
456
|
-
(a) => a.type === "execute_tools",
|
|
457
|
-
);
|
|
458
|
-
expect(toolActions.length).toBe(1);
|
|
459
|
-
const ta = toolActions[0];
|
|
460
|
-
if (ta === undefined || ta.type !== "execute_tools")
|
|
461
|
-
throw new Error("unreachable");
|
|
462
|
-
expect(ta.calls.length).toBe(2);
|
|
463
|
-
});
|
|
464
|
-
|
|
465
|
-
test("infer + done is invalid", () => {
|
|
466
|
-
const result = validateActions([{ type: "infer" }, { type: "done" }]);
|
|
467
|
-
expect(result.ok).toBe(false);
|
|
468
|
-
if (result.ok) throw new Error("unreachable");
|
|
469
|
-
expect(result.error).toMatch(/infer.*done/i);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
test("multiple infer actions are invalid", () => {
|
|
473
|
-
const result = validateActions([{ type: "infer" }, { type: "infer" }]);
|
|
474
|
-
expect(result.ok).toBe(false);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("suspend + infer is invalid", () => {
|
|
478
|
-
const result = validateActions([
|
|
479
|
-
{ type: "infer" },
|
|
480
|
-
{
|
|
481
|
-
type: "suspend",
|
|
482
|
-
gate: {
|
|
483
|
-
type: "approval",
|
|
484
|
-
gateId: "g1",
|
|
485
|
-
timeoutMs: 60000,
|
|
486
|
-
},
|
|
487
|
-
},
|
|
488
|
-
]);
|
|
489
|
-
expect(result.ok).toBe(false);
|
|
490
|
-
if (result.ok) throw new Error("unreachable");
|
|
491
|
-
expect(result.error).toMatch(/suspend.*infer/i);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("suspend + execute_tools is invalid", () => {
|
|
495
|
-
const result = validateActions([
|
|
496
|
-
{
|
|
497
|
-
type: "execute_tools",
|
|
498
|
-
calls: [{ id: "c1", name: "t", arguments: {} }],
|
|
499
|
-
},
|
|
500
|
-
{
|
|
501
|
-
type: "suspend",
|
|
502
|
-
gate: {
|
|
503
|
-
type: "payment",
|
|
504
|
-
gateId: "g2",
|
|
505
|
-
timeoutMs: 60000,
|
|
506
|
-
},
|
|
507
|
-
},
|
|
508
|
-
]);
|
|
509
|
-
expect(result.ok).toBe(false);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test("empty action list is valid (no-op)", () => {
|
|
513
|
-
const result = validateActions([]);
|
|
514
|
-
expect(result.ok).toBe(true);
|
|
515
|
-
if (result.ok) {
|
|
516
|
-
expect(result.normalized).toEqual([]);
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
test("suspend alone is valid", () => {
|
|
521
|
-
const result = validateActions({
|
|
522
|
-
type: "suspend",
|
|
523
|
-
gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
|
|
524
|
-
});
|
|
525
|
-
expect(result.ok).toBe(true);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
test("wait action is included in normalized output", () => {
|
|
529
|
-
const result = validateActions({ type: "wait" });
|
|
530
|
-
expect(result.ok).toBe(true);
|
|
531
|
-
if (!result.ok) throw new Error("unreachable");
|
|
532
|
-
expect(result.normalized.length).toBe(1);
|
|
533
|
-
expect(result.normalized[0]?.type).toBe("wait");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
test("multiple wait actions are invalid", () => {
|
|
537
|
-
const result = validateActions([{ type: "wait" }, { type: "wait" }]);
|
|
538
|
-
expect(result.ok).toBe(false);
|
|
539
|
-
if (result.ok) throw new Error("unreachable");
|
|
540
|
-
expect(result.error).toMatch(/multiple.*wait/i);
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test("wait + infer is invalid", () => {
|
|
544
|
-
const result = validateActions([{ type: "wait" }, { type: "infer" }]);
|
|
545
|
-
expect(result.ok).toBe(false);
|
|
546
|
-
if (result.ok) throw new Error("unreachable");
|
|
547
|
-
expect(result.error).toMatch(/wait.*infer/i);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
test("wait + execute_tools is invalid", () => {
|
|
551
|
-
const result = validateActions([
|
|
552
|
-
{ type: "wait" },
|
|
553
|
-
{
|
|
554
|
-
type: "execute_tools",
|
|
555
|
-
calls: [{ id: "c1", name: "t", arguments: {} }],
|
|
556
|
-
},
|
|
557
|
-
]);
|
|
558
|
-
expect(result.ok).toBe(false);
|
|
559
|
-
if (result.ok) throw new Error("unreachable");
|
|
560
|
-
expect(result.error).toMatch(/wait.*execute_tools/i);
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
test("wait + suspend is invalid", () => {
|
|
564
|
-
const result = validateActions([
|
|
565
|
-
{ type: "wait" },
|
|
566
|
-
{
|
|
567
|
-
type: "suspend",
|
|
568
|
-
gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
|
|
569
|
-
},
|
|
570
|
-
]);
|
|
571
|
-
expect(result.ok).toBe(false);
|
|
572
|
-
if (result.ok) throw new Error("unreachable");
|
|
573
|
-
expect(result.error).toMatch(/wait.*suspend/i);
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
test("wait + reply is invalid", () => {
|
|
577
|
-
const result = validateActions([
|
|
578
|
-
{ type: "wait" },
|
|
579
|
-
{ type: "reply", content: "hello" },
|
|
580
|
-
]);
|
|
581
|
-
expect(result.ok).toBe(false);
|
|
582
|
-
if (result.ok) throw new Error("unreachable");
|
|
583
|
-
expect(result.error).toMatch(/wait.*reply/i);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
test("wait + done is invalid", () => {
|
|
587
|
-
const result = validateActions([{ type: "wait" }, { type: "done" }]);
|
|
588
|
-
expect(result.ok).toBe(false);
|
|
589
|
-
if (result.ok) throw new Error("unreachable");
|
|
590
|
-
expect(result.error).toMatch(/wait.*done/i);
|
|
591
|
-
});
|
|
592
|
-
|
|
593
|
-
test("reply + infer is invalid", () => {
|
|
594
|
-
const result = validateActions([
|
|
595
|
-
{ type: "reply", content: "hello" },
|
|
596
|
-
{ type: "infer" },
|
|
597
|
-
]);
|
|
598
|
-
expect(result.ok).toBe(false);
|
|
599
|
-
if (result.ok) throw new Error("unreachable");
|
|
600
|
-
expect(result.error).toMatch(/reply.*infer/i);
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
test("reply + execute_tools is invalid", () => {
|
|
604
|
-
const result = validateActions([
|
|
605
|
-
{ type: "reply", content: "hello" },
|
|
606
|
-
{
|
|
607
|
-
type: "execute_tools",
|
|
608
|
-
calls: [{ id: "c1", name: "t", arguments: {} }],
|
|
609
|
-
},
|
|
610
|
-
]);
|
|
611
|
-
expect(result.ok).toBe(false);
|
|
612
|
-
if (result.ok) throw new Error("unreachable");
|
|
613
|
-
expect(result.error).toMatch(/reply.*execute_tools/i);
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
test("multiple done actions are invalid", () => {
|
|
617
|
-
const result = validateActions([{ type: "done" }, { type: "done" }]);
|
|
618
|
-
expect(result.ok).toBe(false);
|
|
619
|
-
if (result.ok) throw new Error("unreachable");
|
|
620
|
-
expect(result.error).toMatch(/multiple.*done/i);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("multiple reply actions are invalid", () => {
|
|
624
|
-
const result = validateActions([
|
|
625
|
-
{ type: "reply", content: "a" },
|
|
626
|
-
{ type: "reply", content: "b" },
|
|
627
|
-
]);
|
|
628
|
-
expect(result.ok).toBe(false);
|
|
629
|
-
if (result.ok) throw new Error("unreachable");
|
|
630
|
-
expect(result.error).toMatch(/multiple.*reply/i);
|
|
631
|
-
});
|
|
632
|
-
|
|
633
|
-
test("multiple suspend actions are invalid", () => {
|
|
634
|
-
const result = validateActions([
|
|
635
|
-
{
|
|
636
|
-
type: "suspend",
|
|
637
|
-
gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
|
|
638
|
-
},
|
|
639
|
-
{
|
|
640
|
-
type: "suspend",
|
|
641
|
-
gate: { type: "payment", gateId: "g2", timeoutMs: 60000 },
|
|
642
|
-
},
|
|
643
|
-
]);
|
|
644
|
-
expect(result.ok).toBe(false);
|
|
645
|
-
if (result.ok) throw new Error("unreachable");
|
|
646
|
-
expect(result.error).toMatch(/multiple.*suspend/i);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
test("duplicate fork IDs are invalid", () => {
|
|
650
|
-
const result = validateActions([
|
|
651
|
-
{ type: "fork", mode: "independent", forkId: "f1" },
|
|
652
|
-
{ type: "fork", mode: "child", forkId: "f1" },
|
|
653
|
-
]);
|
|
654
|
-
expect(result.ok).toBe(false);
|
|
655
|
-
if (result.ok) throw new Error("unreachable");
|
|
656
|
-
expect(result.error).toMatch(/duplicate.*fork/i);
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
test("reply + done is invalid", () => {
|
|
660
|
-
const result = validateActions([
|
|
661
|
-
{ type: "reply", content: "goodbye" },
|
|
662
|
-
{ type: "done" },
|
|
663
|
-
]);
|
|
664
|
-
expect(result.ok).toBe(false);
|
|
665
|
-
if (result.ok) throw new Error("unreachable");
|
|
666
|
-
expect(result.error).toMatch(/reply.*done/i);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
test("reply + suspend is invalid", () => {
|
|
670
|
-
const result = validateActions([
|
|
671
|
-
{ type: "reply", content: "hang on" },
|
|
672
|
-
{
|
|
673
|
-
type: "suspend",
|
|
674
|
-
gate: { type: "approval", gateId: "g1", timeoutMs: 60000 },
|
|
675
|
-
},
|
|
676
|
-
]);
|
|
677
|
-
expect(result.ok).toBe(false);
|
|
678
|
-
if (result.ok) throw new Error("unreachable");
|
|
679
|
-
expect(result.error).toMatch(/reply.*suspend/i);
|
|
680
|
-
});
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
// ---------------------------------------------------------------------------
|
|
684
|
-
// 2. Gate lifecycle
|
|
685
|
-
// ---------------------------------------------------------------------------
|
|
686
|
-
|
|
687
|
-
describe("createGateManager", () => {
|
|
688
|
-
test("register and clear a gate", async () => {
|
|
689
|
-
const manager = createGateManager();
|
|
690
|
-
const cleared: string[] = [];
|
|
691
|
-
|
|
692
|
-
const promise = manager.register(
|
|
693
|
-
"gate-1",
|
|
694
|
-
"approval",
|
|
695
|
-
5000,
|
|
696
|
-
undefined,
|
|
697
|
-
(id, reason) => cleared.push(`${id}:${reason}`),
|
|
698
|
-
);
|
|
699
|
-
|
|
700
|
-
const didClear = manager.clear("gate-1");
|
|
701
|
-
expect(didClear).toBe(true);
|
|
702
|
-
|
|
703
|
-
const reason = await promise;
|
|
704
|
-
expect(reason).toBe("resolved");
|
|
705
|
-
expect(cleared).toEqual(["gate-1:resolved"]);
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
test("gate timeout fires with reason=timeout", async () => {
|
|
709
|
-
const manager = createGateManager();
|
|
710
|
-
const cleared: string[] = [];
|
|
711
|
-
|
|
712
|
-
const promise = manager.register(
|
|
713
|
-
"gate-2",
|
|
714
|
-
"approval",
|
|
715
|
-
50,
|
|
716
|
-
undefined,
|
|
717
|
-
(id, reason) => cleared.push(`${id}:${reason}`),
|
|
718
|
-
);
|
|
719
|
-
|
|
720
|
-
const reason = await promise;
|
|
721
|
-
expect(reason).toBe("timeout");
|
|
722
|
-
expect(cleared).toEqual(["gate-2:timeout"]);
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
test("shutdown clears all gates with reason=shutdown", async () => {
|
|
726
|
-
const manager = createGateManager();
|
|
727
|
-
const cleared: string[] = [];
|
|
728
|
-
|
|
729
|
-
const p1 = manager.register(
|
|
730
|
-
"g1",
|
|
731
|
-
"approval",
|
|
732
|
-
60000,
|
|
733
|
-
undefined,
|
|
734
|
-
(id, reason) => cleared.push(`${id}:${reason}`),
|
|
735
|
-
);
|
|
736
|
-
const p2 = manager.register(
|
|
737
|
-
"g2",
|
|
738
|
-
"payment",
|
|
739
|
-
60000,
|
|
740
|
-
undefined,
|
|
741
|
-
(id, reason) => cleared.push(`${id}:${reason}`),
|
|
742
|
-
);
|
|
743
|
-
|
|
744
|
-
manager.shutdown();
|
|
745
|
-
|
|
746
|
-
const [r1, r2] = await Promise.all([p1, p2]);
|
|
747
|
-
expect(r1).toBe("shutdown");
|
|
748
|
-
expect(r2).toBe("shutdown");
|
|
749
|
-
expect(cleared.sort()).toEqual(["g1:shutdown", "g2:shutdown"].sort());
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
test("clearing a nonexistent gate returns false", () => {
|
|
753
|
-
const manager = createGateManager();
|
|
754
|
-
expect(manager.clear("no-such-gate")).toBe(false);
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
test("zero timeout throws", () => {
|
|
758
|
-
const manager = createGateManager();
|
|
759
|
-
expect(() =>
|
|
760
|
-
manager.register("g", "approval", 0, undefined, () => {
|
|
761
|
-
/* noop */
|
|
762
|
-
}),
|
|
763
|
-
).toThrow();
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
test("duplicate gate ID throws", () => {
|
|
767
|
-
const manager = createGateManager();
|
|
768
|
-
manager.register("g", "approval", 5000, undefined, () => {
|
|
769
|
-
/* noop */
|
|
770
|
-
});
|
|
771
|
-
expect(() =>
|
|
772
|
-
manager.register("g", "approval", 5000, undefined, () => {
|
|
773
|
-
/* noop */
|
|
774
|
-
}),
|
|
775
|
-
).toThrow();
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
test("findByCorrelationId returns the gate", () => {
|
|
779
|
-
const manager = createGateManager();
|
|
780
|
-
manager.register("g-corr", "message_response", 5000, "corr-42", () => {
|
|
781
|
-
/* noop */
|
|
782
|
-
});
|
|
783
|
-
const found = manager.findByCorrelationId("corr-42");
|
|
784
|
-
expect(found?.gateId).toBe("g-corr");
|
|
785
|
-
|
|
786
|
-
const notFound = manager.findByCorrelationId("no-match");
|
|
787
|
-
expect(notFound).toBeUndefined();
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
// ---------------------------------------------------------------------------
|
|
792
|
-
// 3. Correlation registry
|
|
793
|
-
// ---------------------------------------------------------------------------
|
|
794
|
-
|
|
795
|
-
describe("createCorrelationRegistry", () => {
|
|
796
|
-
function op(id: string): PendingOperation {
|
|
797
|
-
return { correlationId: id, registeredAt: Date.now(), gateId: `g-${id}` };
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
test("register and lookup", () => {
|
|
801
|
-
const reg = createCorrelationRegistry();
|
|
802
|
-
reg.register(op("abc"));
|
|
803
|
-
const found = reg.lookup("abc");
|
|
804
|
-
expect(found?.correlationId).toBe("abc");
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
test("lookup nonexistent returns undefined", () => {
|
|
808
|
-
const reg = createCorrelationRegistry();
|
|
809
|
-
expect(reg.lookup("missing")).toBeUndefined();
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
test("remove returns true for existing entry", () => {
|
|
813
|
-
const reg = createCorrelationRegistry();
|
|
814
|
-
reg.register(op("x"));
|
|
815
|
-
expect(reg.remove("x")).toBe(true);
|
|
816
|
-
expect(reg.lookup("x")).toBeUndefined();
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
test("remove returns false for nonexistent", () => {
|
|
820
|
-
const reg = createCorrelationRegistry();
|
|
821
|
-
expect(reg.remove("ghost")).toBe(false);
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
test("duplicate registration throws", () => {
|
|
825
|
-
const reg = createCorrelationRegistry();
|
|
826
|
-
reg.register(op("dup"));
|
|
827
|
-
expect(() => reg.register(op("dup"))).toThrow();
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
test("all() returns all operations", () => {
|
|
831
|
-
const reg = createCorrelationRegistry();
|
|
832
|
-
reg.register(op("a"));
|
|
833
|
-
reg.register(op("b"));
|
|
834
|
-
const all = reg.all();
|
|
835
|
-
expect(all.map((o) => o.correlationId).sort()).toEqual(["a", "b"]);
|
|
836
|
-
});
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
// ---------------------------------------------------------------------------
|
|
840
|
-
// 4. Reactor loop — basic flow
|
|
841
|
-
// ---------------------------------------------------------------------------
|
|
842
|
-
|
|
843
|
-
describe("createReactor — basic flow", () => {
|
|
844
|
-
test("message.received → done emits reactor.done", async () => {
|
|
845
|
-
const { reactor, events, waitFor } = createTestReactor();
|
|
846
|
-
|
|
847
|
-
reactor.start();
|
|
848
|
-
reactor.deliver(makeInboundMessage());
|
|
849
|
-
await waitFor("reactor.done");
|
|
850
|
-
|
|
851
|
-
const types = events.map((e) => e.type);
|
|
852
|
-
expect(types).toContain("reactor.start");
|
|
853
|
-
expect(types).toContain("message.received");
|
|
854
|
-
expect(types).toContain("reactor.done");
|
|
855
|
-
});
|
|
856
|
-
|
|
857
|
-
test("reactor.start is the first event", async () => {
|
|
858
|
-
const { reactor, events, waitFor } = createTestReactor();
|
|
859
|
-
|
|
860
|
-
reactor.start();
|
|
861
|
-
reactor.deliver(makeInboundMessage());
|
|
862
|
-
await waitFor("reactor.done");
|
|
863
|
-
|
|
864
|
-
expect(events[0]?.type).toBe("reactor.start");
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
test("sequence numbers are monotonically increasing", async () => {
|
|
868
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
869
|
-
director: directorFromTable({
|
|
870
|
-
"message.received": (_e, _s, caps) => [
|
|
871
|
-
caps.emit("custom.test", { x: 1 }),
|
|
872
|
-
caps.done(),
|
|
873
|
-
],
|
|
874
|
-
}),
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
reactor.start();
|
|
878
|
-
reactor.deliver(makeInboundMessage());
|
|
879
|
-
await waitFor("reactor.done");
|
|
880
|
-
|
|
881
|
-
const seqs = events.map((e) => e.seq);
|
|
882
|
-
for (let i = 1; i < seqs.length; i++) {
|
|
883
|
-
const prev = seqs[i - 1];
|
|
884
|
-
const curr = seqs[i];
|
|
885
|
-
if (prev === undefined || curr === undefined) continue;
|
|
886
|
-
expect(curr).toBeGreaterThan(prev);
|
|
887
|
-
}
|
|
888
|
-
});
|
|
889
|
-
});
|
|
890
|
-
|
|
891
|
-
// ---------------------------------------------------------------------------
|
|
892
|
-
// 5. Director exception handling
|
|
893
|
-
// ---------------------------------------------------------------------------
|
|
894
|
-
|
|
895
|
-
describe("createReactor — director exception", () => {
|
|
896
|
-
test("director exception emits reactor.error and shuts down", async () => {
|
|
897
|
-
const { events, onEvent } = collectEvents();
|
|
898
|
-
|
|
899
|
-
const director: ReactorDirector = {
|
|
900
|
-
async decide() {
|
|
901
|
-
throw new Error("director blew up");
|
|
902
|
-
},
|
|
903
|
-
};
|
|
904
|
-
|
|
905
|
-
const reactor = createReactor({
|
|
906
|
-
sessionId: "sess-err",
|
|
907
|
-
director,
|
|
908
|
-
source: {
|
|
909
|
-
id: "anthropic:test-model",
|
|
910
|
-
provider: "anthropic",
|
|
911
|
-
baseURL: "https://api.anthropic.com",
|
|
912
|
-
apiKey: "test",
|
|
913
|
-
model: "test-model",
|
|
914
|
-
},
|
|
915
|
-
toolRunner: noopToolRunner(),
|
|
916
|
-
contextStore: makeContextStore(),
|
|
917
|
-
onEvent,
|
|
918
|
-
deps: createDefaultDependencies(),
|
|
919
|
-
shutdownTimeoutMs: 100,
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
reactor.start();
|
|
923
|
-
reactor.deliver(makeInboundMessage());
|
|
924
|
-
|
|
925
|
-
await waitForEvent(events, (e) => e.type === "reactor.error");
|
|
926
|
-
|
|
927
|
-
const errorEvent = events.find((e) => e.type === "reactor.error");
|
|
928
|
-
if (errorEvent === undefined || errorEvent.type !== "reactor.error") {
|
|
929
|
-
throw new Error("expected reactor.error");
|
|
930
|
-
}
|
|
931
|
-
expect(errorEvent.data.error).toMatch(/director blew up/);
|
|
932
|
-
expect(errorEvent.data.fatal).toBe(true);
|
|
933
|
-
});
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
// ---------------------------------------------------------------------------
|
|
937
|
-
// 6. Gate lifecycle via reactor
|
|
938
|
-
// ---------------------------------------------------------------------------
|
|
939
|
-
|
|
940
|
-
describe("createReactor — gate lifecycle", () => {
|
|
941
|
-
test("suspend registers gate and reactor shuts down on abort", async () => {
|
|
942
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
943
|
-
director: directorFromTable({
|
|
944
|
-
"message.received": (_e, _s, caps) =>
|
|
945
|
-
caps.suspend({
|
|
946
|
-
type: "approval",
|
|
947
|
-
gateId: "test-gate",
|
|
948
|
-
timeoutMs: 5000,
|
|
949
|
-
}),
|
|
950
|
-
"reactor.gate.cleared": (_e, _s, caps) => caps.done(),
|
|
951
|
-
}),
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
reactor.start();
|
|
955
|
-
reactor.deliver(makeInboundMessage());
|
|
956
|
-
|
|
957
|
-
await waitFor("reactor.gate.blocked");
|
|
958
|
-
|
|
959
|
-
// Abort the reactor — gate clears with reason "shutdown".
|
|
960
|
-
reactor.abort("admin_kill");
|
|
961
|
-
|
|
962
|
-
await waitFor("reactor.done");
|
|
963
|
-
|
|
964
|
-
const blocked = getEvent(events, "reactor.gate.blocked");
|
|
965
|
-
expect(blocked.data.reason).toBe("approval");
|
|
966
|
-
expect(blocked.data.gateId).toBe("test-gate");
|
|
967
|
-
|
|
968
|
-
const cleared = getEvent(events, "reactor.gate.cleared");
|
|
969
|
-
expect(cleared.data.gateId).toBe("test-gate");
|
|
970
|
-
expect(cleared.data.reason).toBe("shutdown");
|
|
971
|
-
});
|
|
972
|
-
|
|
973
|
-
test("gate timeout fires reactor.gate.cleared with reason=timeout", async () => {
|
|
974
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
975
|
-
shutdownTimeoutMs: 500,
|
|
976
|
-
director: directorFromTable({
|
|
977
|
-
"message.received": (_e, _s, caps) =>
|
|
978
|
-
caps.suspend({
|
|
979
|
-
type: "approval",
|
|
980
|
-
gateId: "timeout-gate",
|
|
981
|
-
timeoutMs: 80,
|
|
982
|
-
}),
|
|
983
|
-
"reactor.gate.cleared": (_e, _s, caps) => caps.done(),
|
|
984
|
-
}),
|
|
985
|
-
});
|
|
986
|
-
|
|
987
|
-
reactor.start();
|
|
988
|
-
reactor.deliver(makeInboundMessage());
|
|
989
|
-
|
|
990
|
-
await waitFor("reactor.done", 3000);
|
|
991
|
-
|
|
992
|
-
const cleared = getEvent(events, "reactor.gate.cleared");
|
|
993
|
-
expect(cleared.data.reason).toBe("timeout");
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("gate with timeoutMs: 0 falls back to session-level gateTimeout", async () => {
|
|
997
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
998
|
-
gateTimeout: 80,
|
|
999
|
-
director: directorFromTable({
|
|
1000
|
-
"message.received": (_e, _s, caps) =>
|
|
1001
|
-
caps.suspend({
|
|
1002
|
-
type: "approval",
|
|
1003
|
-
gateId: "fallback-gate",
|
|
1004
|
-
timeoutMs: 0,
|
|
1005
|
-
}),
|
|
1006
|
-
"reactor.gate.cleared": (_e, _s, caps) => caps.done(),
|
|
1007
|
-
}),
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
reactor.start();
|
|
1011
|
-
reactor.deliver(makeInboundMessage());
|
|
1012
|
-
|
|
1013
|
-
await waitFor("reactor.done");
|
|
1014
|
-
|
|
1015
|
-
const cleared = getEvent(events, "reactor.gate.cleared");
|
|
1016
|
-
expect(cleared.data.reason).toBe("timeout");
|
|
1017
|
-
expect(cleared.data.gateId).toBe("fallback-gate");
|
|
1018
|
-
});
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// ---------------------------------------------------------------------------
|
|
1022
|
-
// 7. Tool execution
|
|
1023
|
-
// ---------------------------------------------------------------------------
|
|
1024
|
-
|
|
1025
|
-
describe("createReactor — tool execution", () => {
|
|
1026
|
-
test("execute_tools dispatches tools and returns results", async () => {
|
|
1027
|
-
const toolsRun: string[] = [];
|
|
1028
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1029
|
-
director: directorFromTable({
|
|
1030
|
-
"message.received": (_e, _s, caps) =>
|
|
1031
|
-
caps.executeTools(
|
|
1032
|
-
[
|
|
1033
|
-
{ id: "c1", name: "tool_a", arguments: {} },
|
|
1034
|
-
{ id: "c2", name: "tool_b", arguments: {} },
|
|
1035
|
-
],
|
|
1036
|
-
true,
|
|
1037
|
-
),
|
|
1038
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
1039
|
-
}),
|
|
1040
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1041
|
-
toolsRun.push(call.name);
|
|
1042
|
-
return { callId: call.id, content: `result-${call.name}` };
|
|
1043
|
-
}),
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
reactor.start();
|
|
1047
|
-
reactor.deliver(makeInboundMessage());
|
|
1048
|
-
await waitFor("reactor.done");
|
|
1049
|
-
|
|
1050
|
-
expect(toolsRun.sort()).toEqual(["tool_a", "tool_b"].sort());
|
|
1051
|
-
|
|
1052
|
-
const toolStarts = events.filter((e) => e.type === "tool.start");
|
|
1053
|
-
const toolDones = events.filter((e) => e.type === "tool.done");
|
|
1054
|
-
expect(toolStarts.length).toBe(2);
|
|
1055
|
-
expect(toolDones.length).toBe(2);
|
|
1056
|
-
});
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
// ---------------------------------------------------------------------------
|
|
1060
|
-
// 8. Correlation matching
|
|
1061
|
-
// ---------------------------------------------------------------------------
|
|
1062
|
-
|
|
1063
|
-
describe("createReactor — correlation", () => {
|
|
1064
|
-
test("message with matching correlationId triggers message.correlated", async () => {
|
|
1065
|
-
const CORR_ID = "corr-xyz-123";
|
|
1066
|
-
let delivered = false;
|
|
1067
|
-
|
|
1068
|
-
// Multi-phase state machine: deliver → execute_tools → suspend with
|
|
1069
|
-
// correlation → deliver correlated response → done. Kept as raw director
|
|
1070
|
-
// because the `delivered` flag makes directorFromTable awkward.
|
|
1071
|
-
const director: ReactorDirector = {
|
|
1072
|
-
async decide(event, _state, caps) {
|
|
1073
|
-
if (event.type === "message.received" && !delivered) {
|
|
1074
|
-
delivered = true;
|
|
1075
|
-
return caps.executeTools([
|
|
1076
|
-
{ id: "tc1", name: "send_message", arguments: {} },
|
|
1077
|
-
]);
|
|
1078
|
-
}
|
|
1079
|
-
if (event.type === "tool.done") {
|
|
1080
|
-
return caps.suspend({
|
|
1081
|
-
type: "message_response",
|
|
1082
|
-
gateId: "msg-gate",
|
|
1083
|
-
timeoutMs: 5000,
|
|
1084
|
-
correlationId: CORR_ID,
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
if (event.type === "reactor.gate.cleared") {
|
|
1088
|
-
return caps.done();
|
|
1089
|
-
}
|
|
1090
|
-
return caps.done();
|
|
1091
|
-
},
|
|
1092
|
-
};
|
|
1093
|
-
|
|
1094
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1095
|
-
director,
|
|
1096
|
-
shutdownTimeoutMs: 500,
|
|
1097
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1098
|
-
return {
|
|
1099
|
-
callId: call.id,
|
|
1100
|
-
content: "message sent",
|
|
1101
|
-
pendingMarker: {
|
|
1102
|
-
status: "pending" as const,
|
|
1103
|
-
correlationId: CORR_ID,
|
|
1104
|
-
},
|
|
1105
|
-
};
|
|
1106
|
-
}),
|
|
1107
|
-
});
|
|
1108
|
-
|
|
1109
|
-
reactor.start();
|
|
1110
|
-
reactor.deliver(makeInboundMessage());
|
|
1111
|
-
|
|
1112
|
-
await waitFor("reactor.gate.blocked");
|
|
1113
|
-
|
|
1114
|
-
// Deliver the correlated response.
|
|
1115
|
-
reactor.deliver(makeInboundMessage(CORR_ID));
|
|
1116
|
-
|
|
1117
|
-
await waitFor("reactor.done", 3000);
|
|
1118
|
-
|
|
1119
|
-
const correlated = getEvent(events, "message.correlated");
|
|
1120
|
-
expect(correlated.data.correlationId).toBe(CORR_ID);
|
|
1121
|
-
expect(correlated.data.message.headers.interchangeCorrelationId).toBe(
|
|
1122
|
-
CORR_ID,
|
|
1123
|
-
);
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
test("message with non-matching correlationId passes through uncorrelated", async () => {
|
|
1127
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1128
|
-
director: directorFromTable({
|
|
1129
|
-
"message.received": (_e, _s, caps) => caps.done(),
|
|
1130
|
-
}),
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
reactor.start();
|
|
1134
|
-
reactor.deliver(makeInboundMessage());
|
|
1135
|
-
|
|
1136
|
-
await waitFor("reactor.done");
|
|
1137
|
-
|
|
1138
|
-
expect(events.some((e) => e.type === "message.correlated")).toBe(false);
|
|
1139
|
-
expect(events.some((e) => e.type === "message.received")).toBe(true);
|
|
1140
|
-
});
|
|
1141
|
-
});
|
|
1142
|
-
|
|
1143
|
-
// ---------------------------------------------------------------------------
|
|
1144
|
-
// 9. Test harness helper validation
|
|
1145
|
-
// ---------------------------------------------------------------------------
|
|
1146
|
-
|
|
1147
|
-
describe("test harness helpers", () => {
|
|
1148
|
-
test("createTestReactor produces a working reactor with defaults", async () => {
|
|
1149
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1150
|
-
director: directorFromTable({
|
|
1151
|
-
"message.received": (_e, _s, caps) => caps.done(),
|
|
1152
|
-
}),
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
reactor.start();
|
|
1156
|
-
reactor.deliver(makeInboundMessage());
|
|
1157
|
-
await waitFor("reactor.done");
|
|
1158
|
-
|
|
1159
|
-
expect(events[0]?.type).toBe("reactor.start");
|
|
1160
|
-
const done = getEvent(events, "reactor.done");
|
|
1161
|
-
expect(done.type).toBe("reactor.done");
|
|
1162
|
-
});
|
|
1163
|
-
|
|
1164
|
-
test("failingContextStore rejects on load", async () => {
|
|
1165
|
-
const store = failingContextStore(new Error("disk on fire"));
|
|
1166
|
-
let thrown: Error | undefined;
|
|
1167
|
-
try {
|
|
1168
|
-
await store.load();
|
|
1169
|
-
} catch (cause) {
|
|
1170
|
-
thrown = cause instanceof Error ? cause : new Error(String(cause));
|
|
1171
|
-
}
|
|
1172
|
-
expect(thrown?.message).toContain("disk on fire");
|
|
1173
|
-
});
|
|
1174
|
-
|
|
1175
|
-
test("throwingToolRunner throws on run", async () => {
|
|
1176
|
-
const runner = throwingToolRunner(new Error("tool exploded"));
|
|
1177
|
-
const signal = new AbortController().signal;
|
|
1178
|
-
let thrown: Error | undefined;
|
|
1179
|
-
try {
|
|
1180
|
-
await runner.run({ id: "c1", name: "t", arguments: {} }, signal);
|
|
1181
|
-
} catch (cause) {
|
|
1182
|
-
thrown = cause instanceof Error ? cause : new Error(String(cause));
|
|
1183
|
-
}
|
|
1184
|
-
expect(thrown?.message).toContain("tool exploded");
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
test("getEvent throws when event is missing", () => {
|
|
1188
|
-
const events: ReactorEmittedEvent[] = [];
|
|
1189
|
-
expect(() => getEvent(events, "reactor.error")).toThrow(
|
|
1190
|
-
"No event of type 'reactor.error' found",
|
|
1191
|
-
);
|
|
1192
|
-
});
|
|
1193
|
-
|
|
1194
|
-
test("directorFromTable with wait default falls through on unhandled events", async () => {
|
|
1195
|
-
// Suspend produces a reactor.gate.cleared event. That event type is
|
|
1196
|
-
// NOT in the table, so the defaultAction "wait" fallthrough fires.
|
|
1197
|
-
// The reactor stays alive because of the fallthrough, then a second
|
|
1198
|
-
// message triggers done via the table handler.
|
|
1199
|
-
let messageCount = 0;
|
|
1200
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1201
|
-
director: directorFromTable(
|
|
1202
|
-
{
|
|
1203
|
-
"message.received": (_e, _s, caps) => {
|
|
1204
|
-
messageCount++;
|
|
1205
|
-
if (messageCount >= 2) return caps.done();
|
|
1206
|
-
return caps.suspend({
|
|
1207
|
-
type: "approval",
|
|
1208
|
-
gateId: "wait-test-gate",
|
|
1209
|
-
timeoutMs: 50,
|
|
1210
|
-
});
|
|
1211
|
-
},
|
|
1212
|
-
// reactor.gate.cleared is intentionally omitted from the table.
|
|
1213
|
-
// The defaultAction "wait" keeps the reactor alive when it fires.
|
|
1214
|
-
},
|
|
1215
|
-
"wait",
|
|
1216
|
-
),
|
|
1217
|
-
});
|
|
1218
|
-
|
|
1219
|
-
reactor.start();
|
|
1220
|
-
reactor.deliver(makeInboundMessage());
|
|
1221
|
-
|
|
1222
|
-
// Wait for the gate to time out and clear. The reactor.gate.cleared
|
|
1223
|
-
// event hits the fallthrough, which returns caps.wait().
|
|
1224
|
-
await waitFor("reactor.gate.cleared");
|
|
1225
|
-
|
|
1226
|
-
// Reactor is still alive because of the wait fallthrough. Deliver
|
|
1227
|
-
// another message to trigger done.
|
|
1228
|
-
reactor.deliver(makeInboundMessage());
|
|
1229
|
-
await waitFor("reactor.done");
|
|
1230
|
-
|
|
1231
|
-
expect(messageCount).toBe(2);
|
|
1232
|
-
expect(events.some((e) => e.type === "reactor.gate.blocked")).toBe(true);
|
|
1233
|
-
});
|
|
1234
|
-
});
|
|
1235
|
-
|
|
1236
|
-
// ---------------------------------------------------------------------------
|
|
1237
|
-
// 10. Context store failures
|
|
1238
|
-
// ---------------------------------------------------------------------------
|
|
1239
|
-
|
|
1240
|
-
describe("createReactor — context store failures", () => {
|
|
1241
|
-
test("context store load failure emits reactor.error and reactor.done without reactor.start", async () => {
|
|
1242
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1243
|
-
contextStore: failingContextStore(new Error("disk on fire")),
|
|
1244
|
-
});
|
|
1245
|
-
|
|
1246
|
-
reactor.start();
|
|
1247
|
-
await waitFor("reactor.done");
|
|
1248
|
-
|
|
1249
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1250
|
-
expect(errorEvent.data.error).toMatch(/disk on fire/);
|
|
1251
|
-
expect(errorEvent.data.fatal).toBe(true);
|
|
1252
|
-
|
|
1253
|
-
// reactor.start must NOT be emitted — load failed before the loop began.
|
|
1254
|
-
expect(events.some((e) => e.type === "reactor.start")).toBe(false);
|
|
1255
|
-
|
|
1256
|
-
// reactor.done must still be emitted for cleanup listeners.
|
|
1257
|
-
expect(events.some((e) => e.type === "reactor.done")).toBe(true);
|
|
1258
|
-
});
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
// ---------------------------------------------------------------------------
|
|
1262
|
-
// 11. Tool runner failures
|
|
1263
|
-
// ---------------------------------------------------------------------------
|
|
1264
|
-
|
|
1265
|
-
describe("createReactor — tool runner failures", () => {
|
|
1266
|
-
test("tool runner that throws triggers fatal error and shutdown", async () => {
|
|
1267
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1268
|
-
director: directorFromTable({
|
|
1269
|
-
"message.received": (_e, _s, caps) =>
|
|
1270
|
-
caps.executeTools([{ id: "c1", name: "boom", arguments: {} }]),
|
|
1271
|
-
}),
|
|
1272
|
-
toolRunner: throwingToolRunner(new Error("tool exploded")),
|
|
1273
|
-
});
|
|
1274
|
-
|
|
1275
|
-
reactor.start();
|
|
1276
|
-
reactor.deliver(makeInboundMessage());
|
|
1277
|
-
await waitFor("reactor.done");
|
|
1278
|
-
|
|
1279
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1280
|
-
expect(errorEvent.data.error).toMatch(
|
|
1281
|
-
/^Internal reactor error:.*tool exploded/,
|
|
1282
|
-
);
|
|
1283
|
-
expect(errorEvent.data.fatal).toBe(true);
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
test("tool runner returning isError propagates error result to director", async () => {
|
|
1287
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1288
|
-
director: directorFromTable({
|
|
1289
|
-
"message.received": (_e, _s, caps) =>
|
|
1290
|
-
caps.executeTools([{ id: "c1", name: "bad_tool", arguments: {} }]),
|
|
1291
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
1292
|
-
}),
|
|
1293
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1294
|
-
return {
|
|
1295
|
-
callId: call.id,
|
|
1296
|
-
content: "something went wrong",
|
|
1297
|
-
isError: true,
|
|
1298
|
-
};
|
|
1299
|
-
}),
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
reactor.start();
|
|
1303
|
-
reactor.deliver(makeInboundMessage());
|
|
1304
|
-
await waitFor("reactor.done");
|
|
1305
|
-
|
|
1306
|
-
// The tool.done event should carry the error result with isError flag.
|
|
1307
|
-
const toolDone = getEvent(events, "tool.done");
|
|
1308
|
-
expect(toolDone.data.result.isError).toBe(true);
|
|
1309
|
-
expect(toolDone.data.result.content).toBe("something went wrong");
|
|
1310
|
-
// No reactor.error should be emitted — isError is a normal result, not a crash.
|
|
1311
|
-
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
|
|
1312
|
-
});
|
|
1313
|
-
|
|
1314
|
-
test("sequential tool execution runs tools in order", async () => {
|
|
1315
|
-
const order: string[] = [];
|
|
1316
|
-
const { reactor, waitFor } = createTestReactor({
|
|
1317
|
-
director: directorFromTable({
|
|
1318
|
-
"message.received": (_e, _s, caps) =>
|
|
1319
|
-
caps.executeTools(
|
|
1320
|
-
[
|
|
1321
|
-
{ id: "c1", name: "first", arguments: {} },
|
|
1322
|
-
{ id: "c2", name: "second", arguments: {} },
|
|
1323
|
-
{ id: "c3", name: "third", arguments: {} },
|
|
1324
|
-
],
|
|
1325
|
-
false,
|
|
1326
|
-
),
|
|
1327
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
1328
|
-
}),
|
|
1329
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1330
|
-
order.push(call.name);
|
|
1331
|
-
return { callId: call.id, content: "ok" };
|
|
1332
|
-
}),
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
reactor.start();
|
|
1336
|
-
reactor.deliver(makeInboundMessage());
|
|
1337
|
-
await waitFor("reactor.done");
|
|
1338
|
-
|
|
1339
|
-
// Order must be preserved — not sorted.
|
|
1340
|
-
expect(order).toEqual(["first", "second", "third"]);
|
|
1341
|
-
});
|
|
1342
|
-
|
|
1343
|
-
test("addToHistory=false runs tools but skips history append", async () => {
|
|
1344
|
-
// We observe history indirectly via the reactor's cycle commit, which now
|
|
1345
|
-
// writes through contextStore.writeTurns just before commit({ message }).
|
|
1346
|
-
let committedTurns: ConversationTurn[] = [];
|
|
1347
|
-
const capturingStore: ContextStore = {
|
|
1348
|
-
async load() {
|
|
1349
|
-
return {
|
|
1350
|
-
turns: [],
|
|
1351
|
-
pendingOperations: [],
|
|
1352
|
-
tokenUsage: emptyUsage(),
|
|
1353
|
-
connectorState: null,
|
|
1354
|
-
};
|
|
1355
|
-
},
|
|
1356
|
-
setConnectorState() {
|
|
1357
|
-
/* noop */
|
|
1358
|
-
},
|
|
1359
|
-
async commit(options) {
|
|
1360
|
-
return { hash: "abc", message: options.message, timestamp: Date.now() };
|
|
1361
|
-
},
|
|
1362
|
-
async branch() {
|
|
1363
|
-
/* noop */
|
|
1364
|
-
},
|
|
1365
|
-
async log() {
|
|
1366
|
-
return [];
|
|
1367
|
-
},
|
|
1368
|
-
async readAt() {
|
|
1369
|
-
return [];
|
|
1370
|
-
},
|
|
1371
|
-
async writeBlob() {
|
|
1372
|
-
throw new Error("not implemented");
|
|
1373
|
-
},
|
|
1374
|
-
async readBlob() {
|
|
1375
|
-
throw new Error("not implemented");
|
|
1376
|
-
},
|
|
1377
|
-
async writePrompt() {
|
|
1378
|
-
throw new Error("not implemented");
|
|
1379
|
-
},
|
|
1380
|
-
async writeResponse() {
|
|
1381
|
-
throw new Error("not implemented");
|
|
1382
|
-
},
|
|
1383
|
-
async writeManifest() {
|
|
1384
|
-
/* noop */
|
|
1385
|
-
},
|
|
1386
|
-
async writeTurns(turns) {
|
|
1387
|
-
committedTurns = [...turns];
|
|
1388
|
-
},
|
|
1389
|
-
async writeMetadata() {
|
|
1390
|
-
/* noop */
|
|
1391
|
-
},
|
|
1392
|
-
async readManifestHistory() {
|
|
1393
|
-
throw new Error("not implemented");
|
|
1394
|
-
},
|
|
1395
|
-
};
|
|
1396
|
-
|
|
1397
|
-
const { reactor, waitFor } = createTestReactor({
|
|
1398
|
-
contextStore: capturingStore,
|
|
1399
|
-
director: directorFromTable({
|
|
1400
|
-
"message.received": (_e, _s, caps) =>
|
|
1401
|
-
caps.executeTools(
|
|
1402
|
-
[{ id: "c1", name: "ghost_tool", arguments: {} }],
|
|
1403
|
-
true,
|
|
1404
|
-
false,
|
|
1405
|
-
),
|
|
1406
|
-
"tool.done": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
|
|
1407
|
-
}),
|
|
1408
|
-
});
|
|
1409
|
-
|
|
1410
|
-
reactor.start();
|
|
1411
|
-
reactor.deliver(makeInboundMessage());
|
|
1412
|
-
await waitFor("reactor.done");
|
|
1413
|
-
|
|
1414
|
-
// Verify commit was actually called before checking its contents.
|
|
1415
|
-
expect(committedTurns.length).toBeGreaterThan(0);
|
|
1416
|
-
|
|
1417
|
-
// The committed history should contain the inbound text message but
|
|
1418
|
-
// no tool_result message since addToHistory was false.
|
|
1419
|
-
const hasToolResult = committedTurns.some((m) =>
|
|
1420
|
-
m.content.some((b) => b.type === "tool_result"),
|
|
1421
|
-
);
|
|
1422
|
-
expect(hasToolResult).toBe(false);
|
|
1423
|
-
|
|
1424
|
-
// The inbound user message should still be in history.
|
|
1425
|
-
const hasUserText = committedTurns.some(
|
|
1426
|
-
(m) => m.role === "user" && m.content.some((b) => b.type === "text"),
|
|
1427
|
-
);
|
|
1428
|
-
expect(hasUserText).toBe(true);
|
|
1429
|
-
});
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
// ---------------------------------------------------------------------------
|
|
1433
|
-
// 12. Director misbehavior
|
|
1434
|
-
// ---------------------------------------------------------------------------
|
|
1435
|
-
|
|
1436
|
-
describe("createReactor — director misbehavior", () => {
|
|
1437
|
-
test("reactor shuts down when director returns invalid action set", async () => {
|
|
1438
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1439
|
-
director: directorFromTable({
|
|
1440
|
-
"message.received": (_e, _s, caps) => [caps.infer(), caps.done()],
|
|
1441
|
-
}),
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
reactor.start();
|
|
1445
|
-
reactor.deliver(makeInboundMessage());
|
|
1446
|
-
await waitFor("reactor.done");
|
|
1447
|
-
|
|
1448
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1449
|
-
expect(errorEvent.data.error).toMatch(/infer.*done/i);
|
|
1450
|
-
expect(errorEvent.data.fatal).toBe(true);
|
|
1451
|
-
});
|
|
1452
|
-
|
|
1453
|
-
test("director emitting reserved namespace inference.* produces non-fatal error", async () => {
|
|
1454
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1455
|
-
director: directorFromTable({
|
|
1456
|
-
"message.received": (_e, _s, caps) => [
|
|
1457
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
|
|
1458
|
-
caps.emit("inference.hijack" as `custom.${string}`, {}),
|
|
1459
|
-
caps.done(),
|
|
1460
|
-
],
|
|
1461
|
-
}),
|
|
1462
|
-
});
|
|
1463
|
-
|
|
1464
|
-
reactor.start();
|
|
1465
|
-
reactor.deliver(makeInboundMessage());
|
|
1466
|
-
await waitFor("reactor.done");
|
|
1467
|
-
|
|
1468
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1469
|
-
expect(errorEvent.data.error).toMatch(/reserved event type/);
|
|
1470
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1471
|
-
});
|
|
1472
|
-
|
|
1473
|
-
test("director emitting reserved namespace tool.* produces non-fatal error", async () => {
|
|
1474
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1475
|
-
director: directorFromTable({
|
|
1476
|
-
"message.received": (_e, _s, caps) => [
|
|
1477
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
|
|
1478
|
-
caps.emit("tool.fake" as `custom.${string}`, {}),
|
|
1479
|
-
caps.done(),
|
|
1480
|
-
],
|
|
1481
|
-
}),
|
|
1482
|
-
});
|
|
1483
|
-
|
|
1484
|
-
reactor.start();
|
|
1485
|
-
reactor.deliver(makeInboundMessage());
|
|
1486
|
-
await waitFor("reactor.done");
|
|
1487
|
-
|
|
1488
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1489
|
-
expect(errorEvent.data.error).toMatch(/reserved event type/);
|
|
1490
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1491
|
-
});
|
|
1492
|
-
|
|
1493
|
-
test("director emitting reserved namespace reactor.* produces non-fatal error", async () => {
|
|
1494
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1495
|
-
director: directorFromTable({
|
|
1496
|
-
"message.received": (_e, _s, caps) => [
|
|
1497
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
|
|
1498
|
-
caps.emit("reactor.fake" as `custom.${string}`, {}),
|
|
1499
|
-
caps.done(),
|
|
1500
|
-
],
|
|
1501
|
-
}),
|
|
1502
|
-
});
|
|
1503
|
-
|
|
1504
|
-
reactor.start();
|
|
1505
|
-
reactor.deliver(makeInboundMessage());
|
|
1506
|
-
await waitFor("reactor.done");
|
|
1507
|
-
|
|
1508
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1509
|
-
expect(errorEvent.data.error).toMatch(/reserved event type/);
|
|
1510
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1511
|
-
});
|
|
1512
|
-
|
|
1513
|
-
test("director emitting reserved namespace fork.* produces non-fatal error", async () => {
|
|
1514
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1515
|
-
director: directorFromTable({
|
|
1516
|
-
"message.received": (_e, _s, caps) => [
|
|
1517
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- intentionally passing a reserved-namespace string to test that the reactor rejects it
|
|
1518
|
-
caps.emit("fork.fake" as `custom.${string}`, {}),
|
|
1519
|
-
caps.done(),
|
|
1520
|
-
],
|
|
1521
|
-
}),
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
reactor.start();
|
|
1525
|
-
reactor.deliver(makeInboundMessage());
|
|
1526
|
-
await waitFor("reactor.done");
|
|
1527
|
-
|
|
1528
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1529
|
-
expect(errorEvent.data.error).toMatch(/reserved event type/);
|
|
1530
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1531
|
-
});
|
|
1532
|
-
|
|
1533
|
-
test("fork action emits non-fatal unsupported error", async () => {
|
|
1534
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1535
|
-
director: directorFromTable({
|
|
1536
|
-
"message.received": (_e, _s, caps) => [
|
|
1537
|
-
caps.fork("independent", "fork-1"),
|
|
1538
|
-
caps.done(),
|
|
1539
|
-
],
|
|
1540
|
-
}),
|
|
1541
|
-
});
|
|
1542
|
-
|
|
1543
|
-
reactor.start();
|
|
1544
|
-
reactor.deliver(makeInboundMessage());
|
|
1545
|
-
await waitFor("reactor.done");
|
|
1546
|
-
|
|
1547
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1548
|
-
expect(errorEvent.data.error).toMatch(/not supported/i);
|
|
1549
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
test("wait action keeps reactor alive without shutdown", async () => {
|
|
1553
|
-
let messageCount = 0;
|
|
1554
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1555
|
-
director: directorFromTable({
|
|
1556
|
-
"message.received": (_e, _s, caps) => {
|
|
1557
|
-
messageCount++;
|
|
1558
|
-
if (messageCount >= 2) return caps.done();
|
|
1559
|
-
return caps.wait();
|
|
1560
|
-
},
|
|
1561
|
-
}),
|
|
1562
|
-
});
|
|
1563
|
-
|
|
1564
|
-
reactor.start();
|
|
1565
|
-
reactor.deliver(makeInboundMessage());
|
|
1566
|
-
|
|
1567
|
-
// After wait, deliver a second message which triggers done.
|
|
1568
|
-
// Small delay to let the reactor process the first message.
|
|
1569
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 20);
|
|
1570
|
-
await waitFor("reactor.done");
|
|
1571
|
-
|
|
1572
|
-
expect(messageCount).toBe(2);
|
|
1573
|
-
// No reactor.error should have been emitted.
|
|
1574
|
-
expect(events.some((e) => e.type === "reactor.error")).toBe(false);
|
|
1575
|
-
});
|
|
1576
|
-
});
|
|
1577
|
-
|
|
1578
|
-
// ---------------------------------------------------------------------------
|
|
1579
|
-
// 13. Reply action
|
|
1580
|
-
// ---------------------------------------------------------------------------
|
|
1581
|
-
|
|
1582
|
-
describe("createReactor — reply action", () => {
|
|
1583
|
-
test("reply action emits connector.reply and reactor stays alive", async () => {
|
|
1584
|
-
let messageCount = 0;
|
|
1585
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1586
|
-
director: directorFromTable({
|
|
1587
|
-
"message.received": (_e, _s, caps) => {
|
|
1588
|
-
messageCount++;
|
|
1589
|
-
if (messageCount >= 2) return caps.done();
|
|
1590
|
-
return caps.reply("hello back");
|
|
1591
|
-
},
|
|
1592
|
-
}),
|
|
1593
|
-
});
|
|
1594
|
-
|
|
1595
|
-
reactor.start();
|
|
1596
|
-
reactor.deliver(makeInboundMessage());
|
|
1597
|
-
|
|
1598
|
-
// After reply, reactor waits for next event. Deliver another message.
|
|
1599
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 20);
|
|
1600
|
-
await waitFor("reactor.done");
|
|
1601
|
-
|
|
1602
|
-
const replyEvent = getEvent(events, "connector.reply");
|
|
1603
|
-
expect(replyEvent.data.content).toBe("hello back");
|
|
1604
|
-
expect(messageCount).toBe(2);
|
|
1605
|
-
});
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
// ---------------------------------------------------------------------------
|
|
1609
|
-
// 14. Checkpoint failure
|
|
1610
|
-
// ---------------------------------------------------------------------------
|
|
1611
|
-
|
|
1612
|
-
describe("createReactor — checkpoint failure", () => {
|
|
1613
|
-
test("checkpoint failure emits non-fatal error and reactor continues", async () => {
|
|
1614
|
-
let checkpointCalled = false;
|
|
1615
|
-
const failingStore: ContextStore = {
|
|
1616
|
-
async load() {
|
|
1617
|
-
return {
|
|
1618
|
-
turns: [],
|
|
1619
|
-
pendingOperations: [],
|
|
1620
|
-
tokenUsage: emptyUsage(),
|
|
1621
|
-
connectorState: null,
|
|
1622
|
-
};
|
|
1623
|
-
},
|
|
1624
|
-
setConnectorState() {
|
|
1625
|
-
/* noop */
|
|
1626
|
-
},
|
|
1627
|
-
async commit(): Promise<ContextCommit> {
|
|
1628
|
-
checkpointCalled = true;
|
|
1629
|
-
throw new Error("storage full");
|
|
1630
|
-
},
|
|
1631
|
-
async branch() {
|
|
1632
|
-
/* noop */
|
|
1633
|
-
},
|
|
1634
|
-
async log() {
|
|
1635
|
-
return [];
|
|
1636
|
-
},
|
|
1637
|
-
async readAt() {
|
|
1638
|
-
return [];
|
|
1639
|
-
},
|
|
1640
|
-
async writeBlob() {
|
|
1641
|
-
/* noop */
|
|
1642
|
-
},
|
|
1643
|
-
async readBlob() {
|
|
1644
|
-
throw new Error("not implemented");
|
|
1645
|
-
},
|
|
1646
|
-
async writePrompt() {
|
|
1647
|
-
/* noop */
|
|
1648
|
-
},
|
|
1649
|
-
async writeResponse() {
|
|
1650
|
-
/* noop */
|
|
1651
|
-
},
|
|
1652
|
-
async writeManifest() {
|
|
1653
|
-
/* noop */
|
|
1654
|
-
},
|
|
1655
|
-
async writeTurns() {
|
|
1656
|
-
/* noop */
|
|
1657
|
-
},
|
|
1658
|
-
async writeMetadata() {
|
|
1659
|
-
/* noop */
|
|
1660
|
-
},
|
|
1661
|
-
async readManifestHistory() {
|
|
1662
|
-
throw new Error("not implemented");
|
|
1663
|
-
},
|
|
1664
|
-
};
|
|
1665
|
-
|
|
1666
|
-
let messageCount = 0;
|
|
1667
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1668
|
-
contextStore: failingStore,
|
|
1669
|
-
director: directorFromTable({
|
|
1670
|
-
"message.received": (_e, _s, caps) => {
|
|
1671
|
-
messageCount++;
|
|
1672
|
-
if (messageCount === 1) return [caps.checkpoint(), caps.wait()];
|
|
1673
|
-
return caps.done();
|
|
1674
|
-
},
|
|
1675
|
-
}),
|
|
1676
|
-
});
|
|
1677
|
-
|
|
1678
|
-
reactor.start();
|
|
1679
|
-
reactor.deliver(makeInboundMessage());
|
|
1680
|
-
|
|
1681
|
-
// After checkpoint failure + wait, deliver another message.
|
|
1682
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 50);
|
|
1683
|
-
await waitFor("reactor.done");
|
|
1684
|
-
|
|
1685
|
-
expect(checkpointCalled).toBe(true);
|
|
1686
|
-
|
|
1687
|
-
const errorEvent = getEvent(events, "reactor.error");
|
|
1688
|
-
expect(errorEvent.data.error).toMatch(/storage full/);
|
|
1689
|
-
expect(errorEvent.data.fatal).toBe(false);
|
|
1690
|
-
|
|
1691
|
-
// Reactor continued after the error — it processed the second message.
|
|
1692
|
-
expect(messageCount).toBe(2);
|
|
1693
|
-
});
|
|
1694
|
-
});
|
|
1695
|
-
|
|
1696
|
-
// ---------------------------------------------------------------------------
|
|
1697
|
-
// 15. Abort handling
|
|
1698
|
-
// ---------------------------------------------------------------------------
|
|
1699
|
-
|
|
1700
|
-
describe("createReactor — abort handling", () => {
|
|
1701
|
-
test("abort event is processed with priority over queued events", async () => {
|
|
1702
|
-
// Track how many message.received events the director processed.
|
|
1703
|
-
let messagesProcessed = 0;
|
|
1704
|
-
const { reactor, waitFor } = createTestReactor({
|
|
1705
|
-
director: directorFromTable(
|
|
1706
|
-
{
|
|
1707
|
-
"message.received": (_e, _s, caps) => {
|
|
1708
|
-
messagesProcessed++;
|
|
1709
|
-
return caps.wait();
|
|
1710
|
-
},
|
|
1711
|
-
},
|
|
1712
|
-
"wait",
|
|
1713
|
-
),
|
|
1714
|
-
});
|
|
1715
|
-
|
|
1716
|
-
reactor.start();
|
|
1717
|
-
reactor.deliver(makeInboundMessage());
|
|
1718
|
-
|
|
1719
|
-
// Wait for reactor to process the first message.
|
|
1720
|
-
await waitFor("message.received");
|
|
1721
|
-
|
|
1722
|
-
// Queue more messages and an abort. The abort should jump the queue
|
|
1723
|
-
// and shut down before the extra messages reach the director.
|
|
1724
|
-
reactor.deliver(makeInboundMessage());
|
|
1725
|
-
reactor.deliver(makeInboundMessage());
|
|
1726
|
-
reactor.abort("admin_kill");
|
|
1727
|
-
|
|
1728
|
-
await waitFor("reactor.done");
|
|
1729
|
-
|
|
1730
|
-
// Only the first message should have been processed by the director.
|
|
1731
|
-
// The abort jumped ahead of the two queued messages.
|
|
1732
|
-
expect(messagesProcessed).toBe(1);
|
|
1733
|
-
});
|
|
1734
|
-
|
|
1735
|
-
test("multiple abort calls do not cause double shutdown", async () => {
|
|
1736
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1737
|
-
director: directorFromTable(
|
|
1738
|
-
{
|
|
1739
|
-
"message.received": (_e, _s, caps) => caps.wait(),
|
|
1740
|
-
},
|
|
1741
|
-
"wait",
|
|
1742
|
-
),
|
|
1743
|
-
});
|
|
1744
|
-
|
|
1745
|
-
reactor.start();
|
|
1746
|
-
reactor.deliver(makeInboundMessage());
|
|
1747
|
-
await waitFor("message.received");
|
|
1748
|
-
|
|
1749
|
-
reactor.abort("admin_kill");
|
|
1750
|
-
reactor.abort("admin_kill");
|
|
1751
|
-
reactor.abort("admin_kill");
|
|
1752
|
-
|
|
1753
|
-
await waitFor("reactor.done");
|
|
1754
|
-
|
|
1755
|
-
// Exactly one reactor.done should be emitted.
|
|
1756
|
-
const doneEvents = events.filter((e) => e.type === "reactor.done");
|
|
1757
|
-
expect(doneEvents.length).toBe(1);
|
|
1758
|
-
});
|
|
1759
|
-
|
|
1760
|
-
test("abort before start shuts down immediately when start is called", async () => {
|
|
1761
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1762
|
-
director: directorFromTable(
|
|
1763
|
-
{
|
|
1764
|
-
"message.received": (_e, _s, caps) => caps.wait(),
|
|
1765
|
-
},
|
|
1766
|
-
"wait",
|
|
1767
|
-
),
|
|
1768
|
-
});
|
|
1769
|
-
|
|
1770
|
-
// Abort is enqueued before start().
|
|
1771
|
-
reactor.abort("admin_kill");
|
|
1772
|
-
reactor.start();
|
|
1773
|
-
|
|
1774
|
-
await waitFor("reactor.done");
|
|
1775
|
-
|
|
1776
|
-
expect(events.some((e) => e.type === "reactor.start")).toBe(true);
|
|
1777
|
-
expect(events.some((e) => e.type === "reactor.done")).toBe(true);
|
|
1778
|
-
|
|
1779
|
-
// No messages should have been processed.
|
|
1780
|
-
expect(events.some((e) => e.type === "message.received")).toBe(false);
|
|
1781
|
-
});
|
|
1782
|
-
});
|
|
1783
|
-
|
|
1784
|
-
// ---------------------------------------------------------------------------
|
|
1785
|
-
// 16. Dequeue priority (mid-cycle interleaving prevention)
|
|
1786
|
-
// ---------------------------------------------------------------------------
|
|
1787
|
-
|
|
1788
|
-
describe("createReactor — dequeue priority", () => {
|
|
1789
|
-
test("tool.done is prioritized over message.received when tool calls are pending", async () => {
|
|
1790
|
-
// Pre-seed history with an assistant message containing a tool_call
|
|
1791
|
-
// block. Using addToHistory=false on executeTools keeps this as the
|
|
1792
|
-
// last message, so historyHasPendingToolCalls() stays true when the
|
|
1793
|
-
// loop calls dequeueNext() after tools complete.
|
|
1794
|
-
const seededTurns: ConversationTurn[] = [
|
|
1795
|
-
{
|
|
1796
|
-
role: "assistant",
|
|
1797
|
-
content: [
|
|
1798
|
-
{
|
|
1799
|
-
type: "tool_call",
|
|
1800
|
-
id: "tc-pending",
|
|
1801
|
-
name: "some_tool",
|
|
1802
|
-
arguments: {},
|
|
1803
|
-
},
|
|
1804
|
-
],
|
|
1805
|
-
model: "test-model",
|
|
1806
|
-
timestamp: 1000,
|
|
1807
|
-
},
|
|
1808
|
-
];
|
|
1809
|
-
|
|
1810
|
-
const order: string[] = [];
|
|
1811
|
-
|
|
1812
|
-
// Empty content prevents the reactor from appending a user text
|
|
1813
|
-
// message to history, preserving the assistant tool_call as the
|
|
1814
|
-
// last entry so historyHasPendingToolCalls() stays true.
|
|
1815
|
-
const emptyMessage: InboundMessage = {
|
|
1816
|
-
ref: { uid: 1, mailbox: "INBOX" },
|
|
1817
|
-
headers: {
|
|
1818
|
-
from: "test@example.com",
|
|
1819
|
-
to: ["agent@example.com"],
|
|
1820
|
-
date: new Date().toISOString(),
|
|
1821
|
-
messageId: `msg-empty-${Math.random()}`,
|
|
1822
|
-
},
|
|
1823
|
-
flags: [],
|
|
1824
|
-
content: "",
|
|
1825
|
-
signatureStatus: "missing",
|
|
1826
|
-
};
|
|
1827
|
-
|
|
1828
|
-
const { reactor, waitFor } = createTestReactor({
|
|
1829
|
-
contextStore: makeContextStore(seededTurns),
|
|
1830
|
-
director: {
|
|
1831
|
-
async decide(event, _state, caps) {
|
|
1832
|
-
order.push(event.type);
|
|
1833
|
-
if (event.type === "message.received") {
|
|
1834
|
-
// First message.received: run tools with addToHistory=false.
|
|
1835
|
-
// Second message.received (after tool.done): shut down.
|
|
1836
|
-
if (order.filter((t) => t === "message.received").length >= 2) {
|
|
1837
|
-
return caps.done();
|
|
1838
|
-
}
|
|
1839
|
-
return caps.executeTools(
|
|
1840
|
-
[{ id: "tc-pending", name: "some_tool", arguments: {} }],
|
|
1841
|
-
true,
|
|
1842
|
-
false,
|
|
1843
|
-
);
|
|
1844
|
-
}
|
|
1845
|
-
if (event.type === "tool.done") {
|
|
1846
|
-
return caps.wait();
|
|
1847
|
-
}
|
|
1848
|
-
return caps.done();
|
|
1849
|
-
},
|
|
1850
|
-
},
|
|
1851
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1852
|
-
// While the tool is running, deliver a second message. This
|
|
1853
|
-
// enqueues message.received before executeTools enqueues tool.done.
|
|
1854
|
-
reactor.deliver(emptyMessage);
|
|
1855
|
-
return { callId: call.id, content: "ok" };
|
|
1856
|
-
}),
|
|
1857
|
-
});
|
|
1858
|
-
|
|
1859
|
-
reactor.start();
|
|
1860
|
-
reactor.deliver(emptyMessage);
|
|
1861
|
-
await waitFor("reactor.done");
|
|
1862
|
-
|
|
1863
|
-
// tool.done should appear before the second message.received in the
|
|
1864
|
-
// order array, because dequeueNext prioritizes cycle events when
|
|
1865
|
-
// the last history message has pending tool_calls.
|
|
1866
|
-
const toolDoneIdx = order.indexOf("tool.done");
|
|
1867
|
-
const secondMessageIdx = order.lastIndexOf("message.received");
|
|
1868
|
-
expect(toolDoneIdx).toBeGreaterThan(-1);
|
|
1869
|
-
expect(secondMessageIdx).toBeGreaterThan(-1);
|
|
1870
|
-
expect(toolDoneIdx).toBeLessThan(secondMessageIdx);
|
|
1871
|
-
});
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
// ---------------------------------------------------------------------------
|
|
1875
|
-
// 17. Correlation validator edge cases
|
|
1876
|
-
// ---------------------------------------------------------------------------
|
|
1877
|
-
|
|
1878
|
-
describe("createReactor — correlation validator", () => {
|
|
1879
|
-
test("validator returning false lets message through as uncorrelated", async () => {
|
|
1880
|
-
const CORR_ID = "corr-rejected";
|
|
1881
|
-
let toolDoneSeen = false;
|
|
1882
|
-
|
|
1883
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1884
|
-
correlationValidator: {
|
|
1885
|
-
async validate() {
|
|
1886
|
-
return false;
|
|
1887
|
-
},
|
|
1888
|
-
},
|
|
1889
|
-
director: directorFromTable({
|
|
1890
|
-
"message.received": (_e, _s, caps) => {
|
|
1891
|
-
if (toolDoneSeen) return caps.done();
|
|
1892
|
-
return caps.executeTools([
|
|
1893
|
-
{ id: "tc1", name: "send_msg", arguments: {} },
|
|
1894
|
-
]);
|
|
1895
|
-
},
|
|
1896
|
-
"tool.done": (_e, _s, caps) => {
|
|
1897
|
-
toolDoneSeen = true;
|
|
1898
|
-
return caps.suspend({
|
|
1899
|
-
type: "message_response",
|
|
1900
|
-
gateId: "val-gate",
|
|
1901
|
-
timeoutMs: 5000,
|
|
1902
|
-
correlationId: CORR_ID,
|
|
1903
|
-
});
|
|
1904
|
-
},
|
|
1905
|
-
}),
|
|
1906
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1907
|
-
return {
|
|
1908
|
-
callId: call.id,
|
|
1909
|
-
content: "sent",
|
|
1910
|
-
pendingMarker: {
|
|
1911
|
-
status: "pending" as const,
|
|
1912
|
-
correlationId: CORR_ID,
|
|
1913
|
-
},
|
|
1914
|
-
};
|
|
1915
|
-
}),
|
|
1916
|
-
});
|
|
1917
|
-
|
|
1918
|
-
reactor.start();
|
|
1919
|
-
reactor.deliver(makeInboundMessage());
|
|
1920
|
-
|
|
1921
|
-
await waitFor("reactor.gate.blocked");
|
|
1922
|
-
|
|
1923
|
-
// Deliver a message with matching correlationId — but the validator
|
|
1924
|
-
// rejects it, so it should arrive as message.received, not
|
|
1925
|
-
// message.correlated.
|
|
1926
|
-
reactor.deliver(makeInboundMessage(CORR_ID));
|
|
1927
|
-
|
|
1928
|
-
await waitFor("reactor.done");
|
|
1929
|
-
|
|
1930
|
-
expect(events.some((e) => e.type === "message.correlated")).toBe(false);
|
|
1931
|
-
// The message should have been delivered as uncorrelated.
|
|
1932
|
-
const receivedEvents = events.filter((e) => e.type === "message.received");
|
|
1933
|
-
expect(receivedEvents.length).toBe(2);
|
|
1934
|
-
});
|
|
1935
|
-
|
|
1936
|
-
test("validator that throws lets message through as uncorrelated", async () => {
|
|
1937
|
-
const CORR_ID = "corr-throws";
|
|
1938
|
-
let toolDoneSeen = false;
|
|
1939
|
-
|
|
1940
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
1941
|
-
correlationValidator: {
|
|
1942
|
-
async validate() {
|
|
1943
|
-
throw new Error("validator crashed");
|
|
1944
|
-
},
|
|
1945
|
-
},
|
|
1946
|
-
director: directorFromTable({
|
|
1947
|
-
"message.received": (_e, _s, caps) => {
|
|
1948
|
-
if (toolDoneSeen) return caps.done();
|
|
1949
|
-
return caps.executeTools([
|
|
1950
|
-
{ id: "tc1", name: "send_msg", arguments: {} },
|
|
1951
|
-
]);
|
|
1952
|
-
},
|
|
1953
|
-
"tool.done": (_e, _s, caps) => {
|
|
1954
|
-
toolDoneSeen = true;
|
|
1955
|
-
return caps.suspend({
|
|
1956
|
-
type: "message_response",
|
|
1957
|
-
gateId: "throw-gate",
|
|
1958
|
-
timeoutMs: 5000,
|
|
1959
|
-
correlationId: CORR_ID,
|
|
1960
|
-
});
|
|
1961
|
-
},
|
|
1962
|
-
}),
|
|
1963
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
1964
|
-
return {
|
|
1965
|
-
callId: call.id,
|
|
1966
|
-
content: "sent",
|
|
1967
|
-
pendingMarker: {
|
|
1968
|
-
status: "pending" as const,
|
|
1969
|
-
correlationId: CORR_ID,
|
|
1970
|
-
},
|
|
1971
|
-
};
|
|
1972
|
-
}),
|
|
1973
|
-
});
|
|
1974
|
-
|
|
1975
|
-
reactor.start();
|
|
1976
|
-
reactor.deliver(makeInboundMessage());
|
|
1977
|
-
|
|
1978
|
-
await waitFor("reactor.gate.blocked");
|
|
1979
|
-
|
|
1980
|
-
reactor.deliver(makeInboundMessage(CORR_ID));
|
|
1981
|
-
|
|
1982
|
-
await waitFor("reactor.done");
|
|
1983
|
-
|
|
1984
|
-
expect(events.some((e) => e.type === "message.correlated")).toBe(false);
|
|
1985
|
-
const receivedEvents = events.filter((e) => e.type === "message.received");
|
|
1986
|
-
expect(receivedEvents.length).toBe(2);
|
|
1987
|
-
});
|
|
1988
|
-
|
|
1989
|
-
test("concurrent delivery with same correlationId only correlates once", async () => {
|
|
1990
|
-
const CORR_ID = "corr-double";
|
|
1991
|
-
let toolDoneSeen = false;
|
|
1992
|
-
|
|
1993
|
-
// Use a validator with a manually-resolved promise so we can control
|
|
1994
|
-
// timing. The first deliver enters the validator and blocks. The second
|
|
1995
|
-
// deliver hits the correlatingIds guard and falls through.
|
|
1996
|
-
let resolveValidator!: () => void;
|
|
1997
|
-
const validatorPromise = new Promise<void>((resolve) => {
|
|
1998
|
-
resolveValidator = resolve;
|
|
1999
|
-
});
|
|
2000
|
-
|
|
2001
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2002
|
-
correlationValidator: {
|
|
2003
|
-
async validate() {
|
|
2004
|
-
await validatorPromise;
|
|
2005
|
-
return true;
|
|
2006
|
-
},
|
|
2007
|
-
},
|
|
2008
|
-
director: directorFromTable({
|
|
2009
|
-
"message.received": (_e, _s, caps) => {
|
|
2010
|
-
if (toolDoneSeen) return caps.done();
|
|
2011
|
-
return caps.executeTools([
|
|
2012
|
-
{ id: "tc1", name: "send_msg", arguments: {} },
|
|
2013
|
-
]);
|
|
2014
|
-
},
|
|
2015
|
-
"tool.done": (_e, _s, caps) => {
|
|
2016
|
-
toolDoneSeen = true;
|
|
2017
|
-
return caps.suspend({
|
|
2018
|
-
type: "message_response",
|
|
2019
|
-
gateId: "double-gate",
|
|
2020
|
-
timeoutMs: 5000,
|
|
2021
|
-
correlationId: CORR_ID,
|
|
2022
|
-
});
|
|
2023
|
-
},
|
|
2024
|
-
"reactor.gate.cleared": (_e, _s, caps) => caps.done(),
|
|
2025
|
-
}),
|
|
2026
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
2027
|
-
return {
|
|
2028
|
-
callId: call.id,
|
|
2029
|
-
content: "sent",
|
|
2030
|
-
pendingMarker: {
|
|
2031
|
-
status: "pending" as const,
|
|
2032
|
-
correlationId: CORR_ID,
|
|
2033
|
-
},
|
|
2034
|
-
};
|
|
2035
|
-
}),
|
|
2036
|
-
});
|
|
2037
|
-
|
|
2038
|
-
reactor.start();
|
|
2039
|
-
reactor.deliver(makeInboundMessage());
|
|
2040
|
-
|
|
2041
|
-
await waitFor("reactor.gate.blocked");
|
|
2042
|
-
|
|
2043
|
-
// Deliver two messages with the same correlationId in the same tick.
|
|
2044
|
-
// The first enters the validator (which blocks on the promise).
|
|
2045
|
-
// The second hits the correlatingIds guard and is rejected.
|
|
2046
|
-
reactor.deliver(makeInboundMessage(CORR_ID));
|
|
2047
|
-
reactor.deliver(makeInboundMessage(CORR_ID));
|
|
2048
|
-
|
|
2049
|
-
// Let the validator resolve so the first correlation completes.
|
|
2050
|
-
resolveValidator();
|
|
2051
|
-
|
|
2052
|
-
await waitFor("reactor.done");
|
|
2053
|
-
|
|
2054
|
-
// Only one message.correlated event should have been emitted.
|
|
2055
|
-
const correlatedEvents = events.filter(
|
|
2056
|
-
(e) => e.type === "message.correlated",
|
|
2057
|
-
);
|
|
2058
|
-
expect(correlatedEvents.length).toBe(1);
|
|
2059
|
-
|
|
2060
|
-
// The second message should have been delivered as uncorrelated.
|
|
2061
|
-
const receivedEvents = events.filter((e) => e.type === "message.received");
|
|
2062
|
-
expect(receivedEvents.length).toBe(2);
|
|
2063
|
-
});
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
// ---------------------------------------------------------------------------
|
|
2067
|
-
// 18. Start guard
|
|
2068
|
-
// ---------------------------------------------------------------------------
|
|
2069
|
-
|
|
2070
|
-
describe("createReactor — start guard", () => {
|
|
2071
|
-
test("calling start() twice throws", () => {
|
|
2072
|
-
const { reactor } = createTestReactor();
|
|
2073
|
-
reactor.start();
|
|
2074
|
-
expect(() => reactor.start()).toThrow(/already running/);
|
|
2075
|
-
});
|
|
2076
|
-
});
|
|
2077
|
-
|
|
2078
|
-
// ---------------------------------------------------------------------------
|
|
2079
|
-
// 19. State snapshot inspection
|
|
2080
|
-
// ---------------------------------------------------------------------------
|
|
2081
|
-
|
|
2082
|
-
describe("createReactor — state snapshot inspection", () => {
|
|
2083
|
-
test("state.turns contains delivered message text", async () => {
|
|
2084
|
-
let capturedTurns: ConversationTurn[] = [];
|
|
2085
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2086
|
-
director: directorFromTable({
|
|
2087
|
-
"message.received": (_e, state, caps) => {
|
|
2088
|
-
capturedTurns = state.turns;
|
|
2089
|
-
return caps.done();
|
|
2090
|
-
},
|
|
2091
|
-
}),
|
|
2092
|
-
});
|
|
2093
|
-
|
|
2094
|
-
reactor.start();
|
|
2095
|
-
reactor.deliver(makeInboundMessage());
|
|
2096
|
-
await waitFor("reactor.done");
|
|
2097
|
-
|
|
2098
|
-
expect(capturedTurns.length).toBe(1);
|
|
2099
|
-
const last = capturedTurns.at(-1);
|
|
2100
|
-
if (last === undefined) throw new Error("unreachable");
|
|
2101
|
-
expect(last.role).toBe("user");
|
|
2102
|
-
const textBlock = last.content.find((b) => b.type === "text");
|
|
2103
|
-
if (textBlock === undefined || textBlock.type !== "text")
|
|
2104
|
-
throw new Error("unreachable");
|
|
2105
|
-
expect(textBlock.text).toBe("[From: test@example.com]\n\nhello");
|
|
2106
|
-
});
|
|
2107
|
-
|
|
2108
|
-
test("state.pendingOperations tracks pending markers from tools", async () => {
|
|
2109
|
-
const CORR_ID = "corr-snapshot-check";
|
|
2110
|
-
let capturedOps: PendingOperation[] = [];
|
|
2111
|
-
|
|
2112
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2113
|
-
director: directorFromTable({
|
|
2114
|
-
"message.received": (_e, _s, caps) =>
|
|
2115
|
-
caps.executeTools([{ id: "tc1", name: "send_msg", arguments: {} }]),
|
|
2116
|
-
"tool.done": (_e, state, caps) => {
|
|
2117
|
-
capturedOps = state.pendingOperations;
|
|
2118
|
-
return caps.done();
|
|
2119
|
-
},
|
|
2120
|
-
}),
|
|
2121
|
-
toolRunner: {
|
|
2122
|
-
async run(call) {
|
|
2123
|
-
return {
|
|
2124
|
-
callId: call.id,
|
|
2125
|
-
content: "sent",
|
|
2126
|
-
pendingMarker: {
|
|
2127
|
-
status: "pending" as const,
|
|
2128
|
-
correlationId: CORR_ID,
|
|
2129
|
-
},
|
|
2130
|
-
};
|
|
2131
|
-
},
|
|
2132
|
-
},
|
|
2133
|
-
});
|
|
2134
|
-
|
|
2135
|
-
reactor.start();
|
|
2136
|
-
reactor.deliver(makeInboundMessage());
|
|
2137
|
-
await waitFor("reactor.done");
|
|
2138
|
-
|
|
2139
|
-
expect(capturedOps.length).toBe(1);
|
|
2140
|
-
const op = capturedOps[0];
|
|
2141
|
-
if (op === undefined) throw new Error("unreachable");
|
|
2142
|
-
expect(op.correlationId).toBe(CORR_ID);
|
|
2143
|
-
});
|
|
2144
|
-
|
|
2145
|
-
test("state.activeGates reflects registered gates during suspend", async () => {
|
|
2146
|
-
let gatesDuringSuspend: ReactorState["activeGates"] = [];
|
|
2147
|
-
let gatesAfterCleared: ReactorState["activeGates"] = [];
|
|
2148
|
-
let messageCount = 0;
|
|
2149
|
-
|
|
2150
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2151
|
-
director: {
|
|
2152
|
-
async decide(event, state, caps) {
|
|
2153
|
-
if (event.type === "message.received") {
|
|
2154
|
-
messageCount++;
|
|
2155
|
-
if (messageCount === 1) {
|
|
2156
|
-
return caps.suspend({
|
|
2157
|
-
type: "approval",
|
|
2158
|
-
gateId: "snapshot-gate",
|
|
2159
|
-
timeoutMs: 500,
|
|
2160
|
-
});
|
|
2161
|
-
}
|
|
2162
|
-
// Second message arrives while gate is active.
|
|
2163
|
-
gatesDuringSuspend = state.activeGates;
|
|
2164
|
-
return caps.wait();
|
|
2165
|
-
}
|
|
2166
|
-
if (event.type === "reactor.gate.cleared") {
|
|
2167
|
-
gatesAfterCleared = state.activeGates;
|
|
2168
|
-
return caps.done();
|
|
2169
|
-
}
|
|
2170
|
-
return caps.done();
|
|
2171
|
-
},
|
|
2172
|
-
},
|
|
2173
|
-
});
|
|
2174
|
-
|
|
2175
|
-
reactor.start();
|
|
2176
|
-
reactor.deliver(makeInboundMessage());
|
|
2177
|
-
await waitFor("reactor.gate.blocked");
|
|
2178
|
-
|
|
2179
|
-
// Deliver a second message while the gate is active. The 500ms
|
|
2180
|
-
// gate timeout is far larger than the async overhead of deliver(),
|
|
2181
|
-
// so the second message.received is guaranteed to be processed
|
|
2182
|
-
// before the timeout fires.
|
|
2183
|
-
reactor.deliver(makeInboundMessage());
|
|
2184
|
-
|
|
2185
|
-
// The gate times out after 500ms, firing reactor.gate.cleared
|
|
2186
|
-
// where we capture the post-clear state and return done.
|
|
2187
|
-
await waitFor("reactor.done");
|
|
2188
|
-
|
|
2189
|
-
expect(gatesDuringSuspend.length).toBe(1);
|
|
2190
|
-
const gate = gatesDuringSuspend[0];
|
|
2191
|
-
if (gate === undefined) throw new Error("unreachable");
|
|
2192
|
-
expect(gate.gateId).toBe("snapshot-gate");
|
|
2193
|
-
expect(gate.type).toBe("approval");
|
|
2194
|
-
expect(gatesAfterCleared.length).toBe(0);
|
|
2195
|
-
});
|
|
2196
|
-
|
|
2197
|
-
test("state.tokenUsage has initial zero values without inference", async () => {
|
|
2198
|
-
let capturedUsage: TokenUsage | undefined;
|
|
2199
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2200
|
-
director: directorFromTable({
|
|
2201
|
-
"message.received": (_e, state, caps) => {
|
|
2202
|
-
capturedUsage = state.tokenUsage;
|
|
2203
|
-
return caps.done();
|
|
2204
|
-
},
|
|
2205
|
-
}),
|
|
2206
|
-
});
|
|
2207
|
-
|
|
2208
|
-
reactor.start();
|
|
2209
|
-
reactor.deliver(makeInboundMessage());
|
|
2210
|
-
await waitFor("reactor.done");
|
|
2211
|
-
|
|
2212
|
-
if (capturedUsage === undefined) throw new Error("unreachable");
|
|
2213
|
-
expect(capturedUsage.input).toBe(0);
|
|
2214
|
-
expect(capturedUsage.output).toBe(0);
|
|
2215
|
-
expect(capturedUsage.cacheRead).toBe(0);
|
|
2216
|
-
expect(capturedUsage.cacheWrite).toBe(0);
|
|
2217
|
-
expect(capturedUsage.thinking).toBe(0);
|
|
2218
|
-
});
|
|
2219
|
-
|
|
2220
|
-
test("director cannot corrupt reactor state by mutating snapshot content blocks", async () => {
|
|
2221
|
-
let secondSnapshot: ReactorState | undefined;
|
|
2222
|
-
let messageCount = 0;
|
|
2223
|
-
|
|
2224
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2225
|
-
director: {
|
|
2226
|
-
async decide(event, state, caps) {
|
|
2227
|
-
if (event.type === "message.received") {
|
|
2228
|
-
messageCount++;
|
|
2229
|
-
if (messageCount === 1) {
|
|
2230
|
-
// Mutate the snapshot's content block.
|
|
2231
|
-
const msg = state.turns[0];
|
|
2232
|
-
if (msg !== undefined) {
|
|
2233
|
-
const block = msg.content[0];
|
|
2234
|
-
if (block !== undefined && block.type === "text") {
|
|
2235
|
-
(block as { text: string }).text = "CORRUPTED";
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
return caps.wait();
|
|
2239
|
-
}
|
|
2240
|
-
// Second message: capture a fresh snapshot to verify isolation.
|
|
2241
|
-
secondSnapshot = state;
|
|
2242
|
-
return caps.done();
|
|
2243
|
-
}
|
|
2244
|
-
return caps.done();
|
|
2245
|
-
},
|
|
2246
|
-
},
|
|
2247
|
-
});
|
|
2248
|
-
|
|
2249
|
-
reactor.start();
|
|
2250
|
-
reactor.deliver(makeInboundMessage());
|
|
2251
|
-
|
|
2252
|
-
// Wait for the first message to be processed, then deliver a second.
|
|
2253
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
|
|
2254
|
-
await waitFor("reactor.done");
|
|
2255
|
-
|
|
2256
|
-
if (secondSnapshot === undefined) throw new Error("unreachable");
|
|
2257
|
-
const firstMsg = secondSnapshot.turns[0];
|
|
2258
|
-
if (firstMsg === undefined) throw new Error("unreachable");
|
|
2259
|
-
const block = firstMsg.content[0];
|
|
2260
|
-
if (block === undefined || block.type !== "text")
|
|
2261
|
-
throw new Error("unreachable");
|
|
2262
|
-
expect(block.text).toBe("[From: test@example.com]\n\nhello");
|
|
2263
|
-
});
|
|
2264
|
-
});
|
|
2265
|
-
|
|
2266
|
-
// ---------------------------------------------------------------------------
|
|
2267
|
-
// 20. Deliver after done
|
|
2268
|
-
// ---------------------------------------------------------------------------
|
|
2269
|
-
|
|
2270
|
-
describe("createReactor — deliver after done", () => {
|
|
2271
|
-
test("reactor.done is emitted exactly once even when messages arrive after shutdown", async () => {
|
|
2272
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2273
|
-
director: directorFromTable({
|
|
2274
|
-
"message.received": (_e, _s, caps) => caps.done(),
|
|
2275
|
-
}),
|
|
2276
|
-
});
|
|
2277
|
-
|
|
2278
|
-
reactor.start();
|
|
2279
|
-
reactor.deliver(makeInboundMessage());
|
|
2280
|
-
await waitFor("reactor.done");
|
|
2281
|
-
|
|
2282
|
-
const eventsBeforeDeliver = events.length;
|
|
2283
|
-
|
|
2284
|
-
// Deliver after done — the done guard in deliver() should prevent
|
|
2285
|
-
// any state mutation or event emission.
|
|
2286
|
-
reactor.deliver(makeInboundMessage());
|
|
2287
|
-
reactor.deliver(makeInboundMessage());
|
|
2288
|
-
|
|
2289
|
-
// Give the event loop a chance to process any spurious events.
|
|
2290
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2291
|
-
|
|
2292
|
-
// No new events should have been emitted after reactor.done.
|
|
2293
|
-
expect(events.length).toBe(eventsBeforeDeliver);
|
|
2294
|
-
const doneEvents = events.filter((e) => e.type === "reactor.done");
|
|
2295
|
-
expect(doneEvents.length).toBe(1);
|
|
2296
|
-
});
|
|
2297
|
-
});
|
|
2298
|
-
|
|
2299
|
-
// ---------------------------------------------------------------------------
|
|
2300
|
-
// 21. Inference path (infer action)
|
|
2301
|
-
// ---------------------------------------------------------------------------
|
|
2302
|
-
|
|
2303
|
-
function makeAssistantTurn(text: string): AssistantTurn {
|
|
2304
|
-
return {
|
|
2305
|
-
role: "assistant",
|
|
2306
|
-
content: [{ type: "text", text }],
|
|
2307
|
-
model: "mock-model",
|
|
2308
|
-
timestamp: 1000,
|
|
2309
|
-
};
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
function makeInferenceRunner(
|
|
2313
|
-
result:
|
|
2314
|
-
| { type: "done"; turn: AssistantTurn; usage: TokenUsage }
|
|
2315
|
-
| { type: "error"; error: InferenceError; partial: PartialMessage },
|
|
2316
|
-
): (opts: InferenceHarnessOptions) => AsyncGenerator<InferenceEvent> {
|
|
2317
|
-
return async function* (opts) {
|
|
2318
|
-
if (result.type === "done") {
|
|
2319
|
-
const event: InferenceEvent = {
|
|
2320
|
-
type: "inference.done",
|
|
2321
|
-
seq: opts.nextSeq(),
|
|
2322
|
-
data: { turn: result.turn, usage: result.usage, source: TEST_SOURCE },
|
|
2323
|
-
};
|
|
2324
|
-
yield event;
|
|
2325
|
-
} else {
|
|
2326
|
-
const event: InferenceEvent = {
|
|
2327
|
-
type: "inference.error",
|
|
2328
|
-
seq: opts.nextSeq(),
|
|
2329
|
-
data: { error: result.error, partial: result.partial },
|
|
2330
|
-
};
|
|
2331
|
-
yield event;
|
|
2332
|
-
}
|
|
2333
|
-
};
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
describe("createReactor — inference path", () => {
|
|
2337
|
-
test("infer action drives inference.done through to director and accumulates usage", async () => {
|
|
2338
|
-
const inferUsage: TokenUsage = {
|
|
2339
|
-
input: 100,
|
|
2340
|
-
output: 50,
|
|
2341
|
-
cacheRead: 10,
|
|
2342
|
-
cacheWrite: 5,
|
|
2343
|
-
thinking: 20,
|
|
2344
|
-
};
|
|
2345
|
-
const assistantMsg = makeAssistantTurn("Hello from the model");
|
|
2346
|
-
|
|
2347
|
-
let stateAtInferenceDone: ReactorState | undefined;
|
|
2348
|
-
|
|
2349
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2350
|
-
director: directorFromTable({
|
|
2351
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2352
|
-
"inference.done": (_e, state, caps) => {
|
|
2353
|
-
stateAtInferenceDone = state;
|
|
2354
|
-
return caps.done();
|
|
2355
|
-
},
|
|
2356
|
-
}),
|
|
2357
|
-
inferenceRunner: makeInferenceRunner({
|
|
2358
|
-
type: "done",
|
|
2359
|
-
turn: assistantMsg,
|
|
2360
|
-
usage: inferUsage,
|
|
2361
|
-
}),
|
|
2362
|
-
});
|
|
2363
|
-
|
|
2364
|
-
reactor.start();
|
|
2365
|
-
reactor.deliver(makeInboundMessage());
|
|
2366
|
-
await waitFor("reactor.done");
|
|
2367
|
-
|
|
2368
|
-
// The emitted event stream should contain inference.done.
|
|
2369
|
-
const inferDone = getEvent(events, "inference.done");
|
|
2370
|
-
expect(inferDone.data.turn.content[0]).toEqual({
|
|
2371
|
-
type: "text",
|
|
2372
|
-
text: "Hello from the model",
|
|
2373
|
-
});
|
|
2374
|
-
expect(inferDone.data.usage).toEqual(inferUsage);
|
|
2375
|
-
|
|
2376
|
-
// The director should have received the inference.done event with
|
|
2377
|
-
// accumulated token usage visible in the state snapshot.
|
|
2378
|
-
if (stateAtInferenceDone === undefined)
|
|
2379
|
-
throw new Error("director never received inference.done");
|
|
2380
|
-
expect(stateAtInferenceDone.tokenUsage).toEqual(inferUsage);
|
|
2381
|
-
|
|
2382
|
-
// The assistant message should have been appended to the conversation.
|
|
2383
|
-
const lastMsg =
|
|
2384
|
-
stateAtInferenceDone.turns[stateAtInferenceDone.turns.length - 1];
|
|
2385
|
-
if (lastMsg === undefined) throw new Error("no messages in state snapshot");
|
|
2386
|
-
expect(lastMsg.role).toBe("assistant");
|
|
2387
|
-
expect(lastMsg.content[0]).toEqual({
|
|
2388
|
-
type: "text",
|
|
2389
|
-
text: "Hello from the model",
|
|
2390
|
-
});
|
|
2391
|
-
});
|
|
2392
|
-
|
|
2393
|
-
test("infer action with inference.error delivers error event to director", async () => {
|
|
2394
|
-
const inferError: InferenceError = {
|
|
2395
|
-
category: "retryable",
|
|
2396
|
-
message: "rate limited",
|
|
2397
|
-
};
|
|
2398
|
-
const partial: PartialMessage = { text: "partial output" };
|
|
2399
|
-
|
|
2400
|
-
let capturedError: { category: string; message: string } | undefined;
|
|
2401
|
-
|
|
2402
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2403
|
-
director: directorFromTable({
|
|
2404
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2405
|
-
"inference.error": (e, _s, caps) => {
|
|
2406
|
-
capturedError = {
|
|
2407
|
-
category: e.error.category,
|
|
2408
|
-
message: e.error.message,
|
|
2409
|
-
};
|
|
2410
|
-
return caps.done();
|
|
2411
|
-
},
|
|
2412
|
-
}),
|
|
2413
|
-
inferenceRunner: makeInferenceRunner({
|
|
2414
|
-
type: "error",
|
|
2415
|
-
error: inferError,
|
|
2416
|
-
partial,
|
|
2417
|
-
}),
|
|
2418
|
-
});
|
|
2419
|
-
|
|
2420
|
-
reactor.start();
|
|
2421
|
-
reactor.deliver(makeInboundMessage());
|
|
2422
|
-
await waitFor("reactor.done");
|
|
2423
|
-
|
|
2424
|
-
// Verify the director received the error event with correct fields.
|
|
2425
|
-
if (capturedError === undefined)
|
|
2426
|
-
throw new Error("director never received inference.error");
|
|
2427
|
-
expect(capturedError.category).toBe("retryable");
|
|
2428
|
-
expect(capturedError.message).toBe("rate limited");
|
|
2429
|
-
|
|
2430
|
-
// The emitted event stream should contain inference.error.
|
|
2431
|
-
const inferErr = getEvent(events, "inference.error");
|
|
2432
|
-
expect(inferErr.data.error.category).toBe("retryable");
|
|
2433
|
-
expect(inferErr.data.partial.text).toBe("partial output");
|
|
2434
|
-
});
|
|
2435
|
-
|
|
2436
|
-
test("inference runner that yields no terminal event emits fatal error and completes", async () => {
|
|
2437
|
-
let capturedError: { category: string; message: string } | undefined;
|
|
2438
|
-
|
|
2439
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2440
|
-
director: directorFromTable({
|
|
2441
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2442
|
-
"inference.error": (e, _s, caps) => {
|
|
2443
|
-
capturedError = {
|
|
2444
|
-
category: e.error.category,
|
|
2445
|
-
message: e.error.message,
|
|
2446
|
-
};
|
|
2447
|
-
return caps.done();
|
|
2448
|
-
},
|
|
2449
|
-
}),
|
|
2450
|
-
inferenceRunner: async function* (_opts) {
|
|
2451
|
-
// Yield nothing — violates the runner contract.
|
|
2452
|
-
},
|
|
2453
|
-
});
|
|
2454
|
-
|
|
2455
|
-
reactor.start();
|
|
2456
|
-
reactor.deliver(makeInboundMessage());
|
|
2457
|
-
await waitFor("reactor.done");
|
|
2458
|
-
|
|
2459
|
-
// The reactor should have emitted a reactor.error for observability.
|
|
2460
|
-
const reactorErr = getEvent(events, "reactor.error");
|
|
2461
|
-
expect(reactorErr.data.fatal).toBe(true);
|
|
2462
|
-
expect(reactorErr.data.error).toContain("without a terminal event");
|
|
2463
|
-
|
|
2464
|
-
// The director should have received inference.error with category "fatal".
|
|
2465
|
-
if (capturedError === undefined)
|
|
2466
|
-
throw new Error("director never received inference.error");
|
|
2467
|
-
expect(capturedError.category).toBe("fatal");
|
|
2468
|
-
expect(capturedError.message).toContain("without a terminal event");
|
|
2469
|
-
});
|
|
2470
|
-
});
|
|
2471
|
-
|
|
2472
|
-
// ---------------------------------------------------------------------------
|
|
2473
|
-
// BeforeToolExtension
|
|
2474
|
-
// ---------------------------------------------------------------------------
|
|
2475
|
-
|
|
2476
|
-
describe("createReactor — beforeToolExtensions", () => {
|
|
2477
|
-
const toolCallMsg: AssistantTurn = {
|
|
2478
|
-
role: "assistant",
|
|
2479
|
-
content: [
|
|
2480
|
-
{
|
|
2481
|
-
type: "tool_call",
|
|
2482
|
-
id: "call-1",
|
|
2483
|
-
name: "bash",
|
|
2484
|
-
arguments: { cmd: "ls" },
|
|
2485
|
-
},
|
|
2486
|
-
],
|
|
2487
|
-
model: "test-model",
|
|
2488
|
-
timestamp: 1000,
|
|
2489
|
-
};
|
|
2490
|
-
|
|
2491
|
-
const inferUsage: TokenUsage = {
|
|
2492
|
-
input: 10,
|
|
2493
|
-
output: 5,
|
|
2494
|
-
cacheRead: 0,
|
|
2495
|
-
cacheWrite: 0,
|
|
2496
|
-
thinking: 0,
|
|
2497
|
-
};
|
|
2498
|
-
|
|
2499
|
-
function inferenceRunnerWithToolCall() {
|
|
2500
|
-
return makeInferenceRunner({
|
|
2501
|
-
type: "done",
|
|
2502
|
-
turn: toolCallMsg,
|
|
2503
|
-
usage: inferUsage,
|
|
2504
|
-
});
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
test("allowing extension lets the tool run normally", async () => {
|
|
2508
|
-
const allowAll: BeforeToolExtension = {
|
|
2509
|
-
async beforeTool() {
|
|
2510
|
-
return undefined;
|
|
2511
|
-
},
|
|
2512
|
-
};
|
|
2513
|
-
|
|
2514
|
-
const toolsRun: string[] = [];
|
|
2515
|
-
|
|
2516
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2517
|
-
director: directorFromTable({
|
|
2518
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2519
|
-
"inference.done": (_e, _s, caps) =>
|
|
2520
|
-
caps.executeTools(
|
|
2521
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2522
|
-
true,
|
|
2523
|
-
),
|
|
2524
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2525
|
-
}),
|
|
2526
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
2527
|
-
toolsRun.push(call.name);
|
|
2528
|
-
return { callId: call.id, content: "ok" };
|
|
2529
|
-
}),
|
|
2530
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2531
|
-
beforeToolExtensions: [allowAll],
|
|
2532
|
-
});
|
|
2533
|
-
|
|
2534
|
-
reactor.start();
|
|
2535
|
-
reactor.deliver(makeInboundMessage());
|
|
2536
|
-
await waitFor("reactor.done");
|
|
2537
|
-
|
|
2538
|
-
expect(toolsRun).toEqual(["bash"]);
|
|
2539
|
-
const starts = events.filter((e) => e.type === "tool.start");
|
|
2540
|
-
expect(starts.length).toBe(1);
|
|
2541
|
-
});
|
|
2542
|
-
|
|
2543
|
-
test("blocking extension prevents tool execution", async () => {
|
|
2544
|
-
const blockBash: BeforeToolExtension = {
|
|
2545
|
-
async beforeTool(call) {
|
|
2546
|
-
if (call.name === "bash") return "Denied by policy";
|
|
2547
|
-
return undefined;
|
|
2548
|
-
},
|
|
2549
|
-
};
|
|
2550
|
-
|
|
2551
|
-
const toolsRun: string[] = [];
|
|
2552
|
-
|
|
2553
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2554
|
-
director: directorFromTable({
|
|
2555
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2556
|
-
"inference.done": (_e, _s, caps) =>
|
|
2557
|
-
caps.executeTools(
|
|
2558
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2559
|
-
true,
|
|
2560
|
-
),
|
|
2561
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2562
|
-
}),
|
|
2563
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
2564
|
-
toolsRun.push(call.name);
|
|
2565
|
-
return { callId: call.id, content: "ok" };
|
|
2566
|
-
}),
|
|
2567
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2568
|
-
beforeToolExtensions: [blockBash],
|
|
2569
|
-
});
|
|
2570
|
-
|
|
2571
|
-
reactor.start();
|
|
2572
|
-
reactor.deliver(makeInboundMessage());
|
|
2573
|
-
await waitFor("reactor.done");
|
|
2574
|
-
|
|
2575
|
-
expect(toolsRun).toEqual([]);
|
|
2576
|
-
|
|
2577
|
-
// No tool.start for blocked tools.
|
|
2578
|
-
const starts = events.filter((e) => e.type === "tool.start");
|
|
2579
|
-
expect(starts.length).toBe(0);
|
|
2580
|
-
|
|
2581
|
-
// tool.done is emitted with isError and the block reason.
|
|
2582
|
-
const doneEvents = events.filter(
|
|
2583
|
-
(e): e is Extract<ReactorEmittedEvent, { type: "tool.done" }> =>
|
|
2584
|
-
e.type === "tool.done",
|
|
2585
|
-
);
|
|
2586
|
-
expect(doneEvents.length).toBeGreaterThanOrEqual(1);
|
|
2587
|
-
const blocked = doneEvents[0];
|
|
2588
|
-
if (!blocked) throw new Error("expected at least one tool.done event");
|
|
2589
|
-
expect(blocked.data.result.isError).toBe(true);
|
|
2590
|
-
expect(blocked.data.result.content).toBe("Denied by policy");
|
|
2591
|
-
expect(blocked.data.result.callId).toBe("call-1");
|
|
2592
|
-
});
|
|
2593
|
-
|
|
2594
|
-
test("first blocking extension wins and subsequent extensions are not called", async () => {
|
|
2595
|
-
const called: string[] = [];
|
|
2596
|
-
|
|
2597
|
-
const extA: BeforeToolExtension = {
|
|
2598
|
-
async beforeTool() {
|
|
2599
|
-
called.push("A");
|
|
2600
|
-
return "Blocked by A";
|
|
2601
|
-
},
|
|
2602
|
-
};
|
|
2603
|
-
|
|
2604
|
-
const extB: BeforeToolExtension = {
|
|
2605
|
-
async beforeTool() {
|
|
2606
|
-
called.push("B");
|
|
2607
|
-
return undefined;
|
|
2608
|
-
},
|
|
2609
|
-
};
|
|
2610
|
-
|
|
2611
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2612
|
-
director: directorFromTable({
|
|
2613
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2614
|
-
"inference.done": (_e, _s, caps) =>
|
|
2615
|
-
caps.executeTools(
|
|
2616
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2617
|
-
true,
|
|
2618
|
-
),
|
|
2619
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2620
|
-
}),
|
|
2621
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2622
|
-
beforeToolExtensions: [extA, extB],
|
|
2623
|
-
});
|
|
2624
|
-
|
|
2625
|
-
reactor.start();
|
|
2626
|
-
reactor.deliver(makeInboundMessage());
|
|
2627
|
-
await waitFor("reactor.done");
|
|
2628
|
-
|
|
2629
|
-
expect(called).toEqual(["A"]);
|
|
2630
|
-
});
|
|
2631
|
-
|
|
2632
|
-
test("throwing extension is treated as a block with reactor.error", async () => {
|
|
2633
|
-
const throwingExt: BeforeToolExtension = {
|
|
2634
|
-
async beforeTool() {
|
|
2635
|
-
throw new Error("Extension crashed");
|
|
2636
|
-
},
|
|
2637
|
-
};
|
|
2638
|
-
|
|
2639
|
-
const toolsRun: string[] = [];
|
|
2640
|
-
|
|
2641
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2642
|
-
director: directorFromTable({
|
|
2643
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2644
|
-
"inference.done": (_e, _s, caps) =>
|
|
2645
|
-
caps.executeTools(
|
|
2646
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2647
|
-
true,
|
|
2648
|
-
),
|
|
2649
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2650
|
-
}),
|
|
2651
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
2652
|
-
toolsRun.push(call.name);
|
|
2653
|
-
return { callId: call.id, content: "ok" };
|
|
2654
|
-
}),
|
|
2655
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2656
|
-
beforeToolExtensions: [throwingExt],
|
|
2657
|
-
});
|
|
2658
|
-
|
|
2659
|
-
reactor.start();
|
|
2660
|
-
reactor.deliver(makeInboundMessage());
|
|
2661
|
-
await waitFor("reactor.done");
|
|
2662
|
-
|
|
2663
|
-
expect(toolsRun).toEqual([]);
|
|
2664
|
-
|
|
2665
|
-
const reactorErrors = events.filter((e) => e.type === "reactor.error");
|
|
2666
|
-
const extError = reactorErrors.find(
|
|
2667
|
-
(e) =>
|
|
2668
|
-
e.type === "reactor.error" &&
|
|
2669
|
-
e.data.error.includes("BeforeToolExtension threw"),
|
|
2670
|
-
);
|
|
2671
|
-
if (extError === undefined || extError.type !== "reactor.error")
|
|
2672
|
-
throw new Error("expected reactor.error from throwing extension");
|
|
2673
|
-
expect(extError.data.fatal).toBe(false);
|
|
2674
|
-
});
|
|
2675
|
-
|
|
2676
|
-
test("throwing extension terminates chain before subsequent extensions", async () => {
|
|
2677
|
-
const called: string[] = [];
|
|
2678
|
-
|
|
2679
|
-
const throwingExt: BeforeToolExtension = {
|
|
2680
|
-
async beforeTool() {
|
|
2681
|
-
called.push("thrower");
|
|
2682
|
-
throw new Error("boom");
|
|
2683
|
-
},
|
|
2684
|
-
};
|
|
2685
|
-
|
|
2686
|
-
const secondExt: BeforeToolExtension = {
|
|
2687
|
-
async beforeTool() {
|
|
2688
|
-
called.push("second");
|
|
2689
|
-
return undefined;
|
|
2690
|
-
},
|
|
2691
|
-
};
|
|
2692
|
-
|
|
2693
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2694
|
-
director: directorFromTable({
|
|
2695
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2696
|
-
"inference.done": (_e, _s, caps) =>
|
|
2697
|
-
caps.executeTools(
|
|
2698
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2699
|
-
true,
|
|
2700
|
-
),
|
|
2701
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2702
|
-
}),
|
|
2703
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2704
|
-
beforeToolExtensions: [throwingExt, secondExt],
|
|
2705
|
-
});
|
|
2706
|
-
|
|
2707
|
-
reactor.start();
|
|
2708
|
-
reactor.deliver(makeInboundMessage());
|
|
2709
|
-
await waitFor("reactor.done");
|
|
2710
|
-
|
|
2711
|
-
expect(called).toEqual(["thrower"]);
|
|
2712
|
-
});
|
|
2713
|
-
|
|
2714
|
-
test("extension receives current state snapshot", async () => {
|
|
2715
|
-
let capturedState: ReactorState | undefined;
|
|
2716
|
-
|
|
2717
|
-
const capturingExt: BeforeToolExtension = {
|
|
2718
|
-
async beforeTool(_call, state) {
|
|
2719
|
-
capturedState = state;
|
|
2720
|
-
return undefined;
|
|
2721
|
-
},
|
|
2722
|
-
};
|
|
2723
|
-
|
|
2724
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2725
|
-
director: directorFromTable({
|
|
2726
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2727
|
-
"inference.done": (_e, _s, caps) =>
|
|
2728
|
-
caps.executeTools(
|
|
2729
|
-
[{ id: "call-1", name: "bash", arguments: { cmd: "ls" } }],
|
|
2730
|
-
true,
|
|
2731
|
-
),
|
|
2732
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
2733
|
-
}),
|
|
2734
|
-
inferenceRunner: inferenceRunnerWithToolCall(),
|
|
2735
|
-
beforeToolExtensions: [capturingExt],
|
|
2736
|
-
});
|
|
2737
|
-
|
|
2738
|
-
reactor.start();
|
|
2739
|
-
reactor.deliver(makeInboundMessage());
|
|
2740
|
-
await waitFor("reactor.done");
|
|
2741
|
-
|
|
2742
|
-
if (capturedState === undefined)
|
|
2743
|
-
throw new Error("extension was never called");
|
|
2744
|
-
// State should contain the inbound message and the assistant tool_call message.
|
|
2745
|
-
expect(capturedState.turns.length).toBeGreaterThanOrEqual(2);
|
|
2746
|
-
expect(capturedState.sessionId).toContain("test-sess-");
|
|
2747
|
-
});
|
|
2748
|
-
|
|
2749
|
-
test("parallel batch with one blocked and one allowed tool", async () => {
|
|
2750
|
-
const blockBash: BeforeToolExtension = {
|
|
2751
|
-
async beforeTool(call) {
|
|
2752
|
-
if (call.name === "bash") return "Denied by policy";
|
|
2753
|
-
return undefined;
|
|
2754
|
-
},
|
|
2755
|
-
};
|
|
2756
|
-
|
|
2757
|
-
const toolsRun: string[] = [];
|
|
2758
|
-
|
|
2759
|
-
const twoToolCallMsg: AssistantTurn = {
|
|
2760
|
-
role: "assistant",
|
|
2761
|
-
content: [
|
|
2762
|
-
{
|
|
2763
|
-
type: "tool_call",
|
|
2764
|
-
id: "call-bash",
|
|
2765
|
-
name: "bash",
|
|
2766
|
-
arguments: { cmd: "rm -rf /" },
|
|
2767
|
-
},
|
|
2768
|
-
{
|
|
2769
|
-
type: "tool_call",
|
|
2770
|
-
id: "call-read",
|
|
2771
|
-
name: "read_file",
|
|
2772
|
-
arguments: { path: "/etc/hosts" },
|
|
2773
|
-
},
|
|
2774
|
-
],
|
|
2775
|
-
model: "test-model",
|
|
2776
|
-
timestamp: 1000,
|
|
2777
|
-
};
|
|
2778
|
-
|
|
2779
|
-
let toolDoneCount = 0;
|
|
2780
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2781
|
-
director: directorFromTable(
|
|
2782
|
-
{
|
|
2783
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
2784
|
-
"inference.done": (_e, _s, caps) =>
|
|
2785
|
-
caps.executeTools(
|
|
2786
|
-
[
|
|
2787
|
-
{
|
|
2788
|
-
id: "call-bash",
|
|
2789
|
-
name: "bash",
|
|
2790
|
-
arguments: { cmd: "rm -rf /" },
|
|
2791
|
-
},
|
|
2792
|
-
{
|
|
2793
|
-
id: "call-read",
|
|
2794
|
-
name: "read_file",
|
|
2795
|
-
arguments: { path: "/etc/hosts" },
|
|
2796
|
-
},
|
|
2797
|
-
],
|
|
2798
|
-
true,
|
|
2799
|
-
),
|
|
2800
|
-
"tool.done": (_e, _s, caps) => {
|
|
2801
|
-
toolDoneCount++;
|
|
2802
|
-
if (toolDoneCount >= 2) return caps.done();
|
|
2803
|
-
return caps.wait();
|
|
2804
|
-
},
|
|
2805
|
-
},
|
|
2806
|
-
"wait",
|
|
2807
|
-
),
|
|
2808
|
-
toolRunner: makeToolRunner(async (call) => {
|
|
2809
|
-
toolsRun.push(call.name);
|
|
2810
|
-
return { callId: call.id, content: `result-${call.name}` };
|
|
2811
|
-
}),
|
|
2812
|
-
inferenceRunner: makeInferenceRunner({
|
|
2813
|
-
type: "done",
|
|
2814
|
-
turn: twoToolCallMsg,
|
|
2815
|
-
usage: inferUsage,
|
|
2816
|
-
}),
|
|
2817
|
-
beforeToolExtensions: [blockBash],
|
|
2818
|
-
});
|
|
2819
|
-
|
|
2820
|
-
reactor.start();
|
|
2821
|
-
reactor.deliver(makeInboundMessage());
|
|
2822
|
-
await waitFor("reactor.done");
|
|
2823
|
-
|
|
2824
|
-
// Only the allowed tool should have run.
|
|
2825
|
-
expect(toolsRun).toEqual(["read_file"]);
|
|
2826
|
-
|
|
2827
|
-
// tool.start only for the allowed tool.
|
|
2828
|
-
const starts = events.filter((e) => e.type === "tool.start");
|
|
2829
|
-
expect(starts.length).toBe(1);
|
|
2830
|
-
|
|
2831
|
-
// Both tools produce tool.done events.
|
|
2832
|
-
const doneEvents = events.filter((e) => e.type === "tool.done");
|
|
2833
|
-
expect(doneEvents.length).toBeGreaterThanOrEqual(2);
|
|
2834
|
-
|
|
2835
|
-
// The blocked tool has isError.
|
|
2836
|
-
const blockedDone = doneEvents.find(
|
|
2837
|
-
(e) => e.type === "tool.done" && e.data.result.callId === "call-bash",
|
|
2838
|
-
);
|
|
2839
|
-
if (blockedDone === undefined || blockedDone.type !== "tool.done")
|
|
2840
|
-
throw new Error("expected tool.done for blocked call");
|
|
2841
|
-
expect(blockedDone.data.result.isError).toBe(true);
|
|
2842
|
-
expect(blockedDone.data.result.content).toBe("Denied by policy");
|
|
2843
|
-
|
|
2844
|
-
// The allowed tool has the real result.
|
|
2845
|
-
const allowedDone = doneEvents.find(
|
|
2846
|
-
(e) => e.type === "tool.done" && e.data.result.callId === "call-read",
|
|
2847
|
-
);
|
|
2848
|
-
if (allowedDone === undefined || allowedDone.type !== "tool.done")
|
|
2849
|
-
throw new Error("expected tool.done for allowed call");
|
|
2850
|
-
expect(allowedDone.data.result.isError).toBeUndefined();
|
|
2851
|
-
expect(allowedDone.data.result.content).toBe("result-read_file");
|
|
2852
|
-
});
|
|
2853
|
-
});
|
|
2854
|
-
|
|
2855
|
-
// ---------------------------------------------------------------------------
|
|
2856
|
-
// Lifecycle hooks: afterCheckpoint and onShutdown
|
|
2857
|
-
// ---------------------------------------------------------------------------
|
|
2858
|
-
|
|
2859
|
-
describe("createReactor — afterCheckpoint", () => {
|
|
2860
|
-
test("afterCheckpoint is called after successful checkpoint", async () => {
|
|
2861
|
-
let afterCheckpointCalled = false;
|
|
2862
|
-
|
|
2863
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2864
|
-
director: directorFromTable({
|
|
2865
|
-
"message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
|
|
2866
|
-
}),
|
|
2867
|
-
afterCheckpoint: async () => {
|
|
2868
|
-
afterCheckpointCalled = true;
|
|
2869
|
-
},
|
|
2870
|
-
});
|
|
2871
|
-
|
|
2872
|
-
reactor.start();
|
|
2873
|
-
reactor.deliver(makeInboundMessage());
|
|
2874
|
-
await waitFor("reactor.done");
|
|
2875
|
-
expect(afterCheckpointCalled).toBe(true);
|
|
2876
|
-
});
|
|
2877
|
-
|
|
2878
|
-
test("afterCheckpoint is skipped when context commit fails", async () => {
|
|
2879
|
-
let afterCheckpointCalled = false;
|
|
2880
|
-
let commitCount = 0;
|
|
2881
|
-
|
|
2882
|
-
const store = makeContextStore();
|
|
2883
|
-
const originalCommit = store.commit.bind(store);
|
|
2884
|
-
async function wrappedCommit(
|
|
2885
|
-
options: { message: string },
|
|
2886
|
-
signal?: AbortSignal,
|
|
2887
|
-
): Promise<ContextCommit> {
|
|
2888
|
-
commitCount++;
|
|
2889
|
-
if (commitCount === 1) {
|
|
2890
|
-
throw new Error("disk full");
|
|
2891
|
-
}
|
|
2892
|
-
return originalCommit(options, signal);
|
|
2893
|
-
}
|
|
2894
|
-
store.commit = wrappedCommit;
|
|
2895
|
-
|
|
2896
|
-
const { reactor, waitFor } = createTestReactor({
|
|
2897
|
-
contextStore: store,
|
|
2898
|
-
director: directorFromTable({
|
|
2899
|
-
"message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
|
|
2900
|
-
}),
|
|
2901
|
-
afterCheckpoint: async () => {
|
|
2902
|
-
afterCheckpointCalled = true;
|
|
2903
|
-
},
|
|
2904
|
-
});
|
|
2905
|
-
|
|
2906
|
-
reactor.start();
|
|
2907
|
-
reactor.deliver(makeInboundMessage());
|
|
2908
|
-
await waitFor("reactor.done");
|
|
2909
|
-
expect(afterCheckpointCalled).toBe(false);
|
|
2910
|
-
});
|
|
2911
|
-
|
|
2912
|
-
test("afterCheckpoint failure emits non-fatal reactor.error", async () => {
|
|
2913
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2914
|
-
director: directorFromTable({
|
|
2915
|
-
"message.received": (_e, _s, caps) => [caps.checkpoint(), caps.done()],
|
|
2916
|
-
}),
|
|
2917
|
-
afterCheckpoint: async () => {
|
|
2918
|
-
throw new Error("audit flush failed");
|
|
2919
|
-
},
|
|
2920
|
-
});
|
|
2921
|
-
|
|
2922
|
-
reactor.start();
|
|
2923
|
-
reactor.deliver(makeInboundMessage());
|
|
2924
|
-
await waitFor("reactor.done");
|
|
2925
|
-
|
|
2926
|
-
const errors = events.filter((e) => e.type === "reactor.error");
|
|
2927
|
-
const hookError = errors.find(
|
|
2928
|
-
(e) =>
|
|
2929
|
-
e.type === "reactor.error" && e.data.error.includes("afterCheckpoint"),
|
|
2930
|
-
);
|
|
2931
|
-
expect(hookError).toBeDefined();
|
|
2932
|
-
if (hookError === undefined || hookError.type !== "reactor.error") {
|
|
2933
|
-
throw new Error("expected reactor.error");
|
|
2934
|
-
}
|
|
2935
|
-
expect(hookError.data.fatal).toBe(false);
|
|
2936
|
-
|
|
2937
|
-
// reactor.done should still be emitted
|
|
2938
|
-
const done = events.find((e) => e.type === "reactor.done");
|
|
2939
|
-
expect(done).toBeDefined();
|
|
2940
|
-
});
|
|
2941
|
-
});
|
|
2942
|
-
|
|
2943
|
-
describe("createReactor — onShutdown", () => {
|
|
2944
|
-
test("onShutdown is called before reactor.done on normal shutdown", async () => {
|
|
2945
|
-
const callOrder: string[] = [];
|
|
2946
|
-
|
|
2947
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2948
|
-
director: directorFromTable({
|
|
2949
|
-
"message.received": (_e, _s, caps) => caps.done(),
|
|
2950
|
-
}),
|
|
2951
|
-
onShutdown: async () => {
|
|
2952
|
-
callOrder.push("onShutdown");
|
|
2953
|
-
},
|
|
2954
|
-
});
|
|
2955
|
-
|
|
2956
|
-
// Intercept reactor.done to track ordering.
|
|
2957
|
-
const originalPush = events.push.bind(events);
|
|
2958
|
-
events.push = (...args) => {
|
|
2959
|
-
for (const e of args) {
|
|
2960
|
-
if (e.type === "reactor.done") {
|
|
2961
|
-
callOrder.push("reactor.done");
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
return originalPush(...args);
|
|
2965
|
-
};
|
|
2966
|
-
|
|
2967
|
-
reactor.start();
|
|
2968
|
-
reactor.deliver(makeInboundMessage());
|
|
2969
|
-
await waitFor("reactor.done");
|
|
2970
|
-
|
|
2971
|
-
expect(callOrder).toEqual(["onShutdown", "reactor.done"]);
|
|
2972
|
-
});
|
|
2973
|
-
|
|
2974
|
-
test("onShutdown failure emits non-fatal reactor.error and reactor.done still fires", async () => {
|
|
2975
|
-
const { reactor, events, waitFor } = createTestReactor({
|
|
2976
|
-
director: directorFromTable({
|
|
2977
|
-
"message.received": (_e, _s, caps) => caps.done(),
|
|
2978
|
-
}),
|
|
2979
|
-
onShutdown: async () => {
|
|
2980
|
-
throw new Error("shutdown flush failed");
|
|
2981
|
-
},
|
|
2982
|
-
});
|
|
2983
|
-
|
|
2984
|
-
reactor.start();
|
|
2985
|
-
reactor.deliver(makeInboundMessage());
|
|
2986
|
-
await waitFor("reactor.done");
|
|
2987
|
-
|
|
2988
|
-
const errors = events.filter(
|
|
2989
|
-
(e) => e.type === "reactor.error" && e.data.error.includes("onShutdown"),
|
|
2990
|
-
);
|
|
2991
|
-
expect(errors.length).toBe(1);
|
|
2992
|
-
if (errors[0] === undefined || errors[0].type !== "reactor.error") {
|
|
2993
|
-
throw new Error("expected reactor.error");
|
|
2994
|
-
}
|
|
2995
|
-
expect(errors[0].data.fatal).toBe(false);
|
|
2996
|
-
|
|
2997
|
-
const done = events.find((e) => e.type === "reactor.done");
|
|
2998
|
-
expect(done).toBeDefined();
|
|
2999
|
-
});
|
|
3000
|
-
|
|
3001
|
-
test("onShutdown is called on abort", async () => {
|
|
3002
|
-
let shutdownCalled = false;
|
|
3003
|
-
|
|
3004
|
-
const { reactor, waitFor } = createTestReactor({
|
|
3005
|
-
director: directorFromTable(
|
|
3006
|
-
{
|
|
3007
|
-
"message.received": (_e, _s, caps) => caps.wait(),
|
|
3008
|
-
},
|
|
3009
|
-
"wait",
|
|
3010
|
-
),
|
|
3011
|
-
onShutdown: async () => {
|
|
3012
|
-
shutdownCalled = true;
|
|
3013
|
-
},
|
|
3014
|
-
});
|
|
3015
|
-
|
|
3016
|
-
reactor.start();
|
|
3017
|
-
reactor.deliver(makeInboundMessage());
|
|
3018
|
-
await waitFor("reactor.start");
|
|
3019
|
-
reactor.abort("user_disconnect");
|
|
3020
|
-
await waitFor("reactor.done");
|
|
3021
|
-
expect(shutdownCalled).toBe(true);
|
|
3022
|
-
});
|
|
3023
|
-
});
|
|
3024
|
-
|
|
3025
|
-
// ---------------------------------------------------------------------------
|
|
3026
|
-
// Transform chains and compaction (Phase 4)
|
|
3027
|
-
// ---------------------------------------------------------------------------
|
|
3028
|
-
|
|
3029
|
-
import type {
|
|
3030
|
-
ToolResultTransform,
|
|
3031
|
-
ContextTransform,
|
|
3032
|
-
TransformRecord,
|
|
3033
|
-
} from "@intx/types/runtime";
|
|
3034
|
-
|
|
3035
|
-
function passthroughToolTransform(
|
|
3036
|
-
name: string,
|
|
3037
|
-
decisions: Record<string, unknown> = {},
|
|
3038
|
-
): ToolResultTransform {
|
|
3039
|
-
return {
|
|
3040
|
-
name,
|
|
3041
|
-
version: "1",
|
|
3042
|
-
async apply(input, _ctx) {
|
|
3043
|
-
return {
|
|
3044
|
-
output: input.result,
|
|
3045
|
-
record: {
|
|
3046
|
-
strategy: name,
|
|
3047
|
-
version: "1",
|
|
3048
|
-
parameters: {},
|
|
3049
|
-
reason: "passthrough",
|
|
3050
|
-
decisions,
|
|
3051
|
-
},
|
|
3052
|
-
};
|
|
3053
|
-
},
|
|
3054
|
-
};
|
|
3055
|
-
}
|
|
3056
|
-
|
|
3057
|
-
function passthroughContextTransform(
|
|
3058
|
-
name: string,
|
|
3059
|
-
tagText: string,
|
|
3060
|
-
): ContextTransform {
|
|
3061
|
-
return {
|
|
3062
|
-
name,
|
|
3063
|
-
version: "1",
|
|
3064
|
-
async apply(turns, _ctx) {
|
|
3065
|
-
const tagged = turns.map((t) => ({
|
|
3066
|
-
...t,
|
|
3067
|
-
content: t.content.map((b) =>
|
|
3068
|
-
b.type === "text"
|
|
3069
|
-
? { type: "text" as const, text: `${tagText}${b.text}` }
|
|
3070
|
-
: b,
|
|
3071
|
-
),
|
|
3072
|
-
}));
|
|
3073
|
-
return {
|
|
3074
|
-
output: tagged,
|
|
3075
|
-
record: {
|
|
3076
|
-
strategy: name,
|
|
3077
|
-
version: "1",
|
|
3078
|
-
parameters: { tagText },
|
|
3079
|
-
reason: "tag",
|
|
3080
|
-
decisions: { count: turns.length },
|
|
3081
|
-
},
|
|
3082
|
-
};
|
|
3083
|
-
},
|
|
3084
|
-
};
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
function truncatingCompactor(name: string): Compactor {
|
|
3088
|
-
return {
|
|
3089
|
-
name,
|
|
3090
|
-
version: "1",
|
|
3091
|
-
async apply(turns, _ctx) {
|
|
3092
|
-
const kept = turns.slice(-1);
|
|
3093
|
-
return {
|
|
3094
|
-
output: kept,
|
|
3095
|
-
record: {
|
|
3096
|
-
strategy: name,
|
|
3097
|
-
version: "1",
|
|
3098
|
-
parameters: {},
|
|
3099
|
-
reason: "explicit",
|
|
3100
|
-
decisions: { kept: kept.length, dropped: turns.length - kept.length },
|
|
3101
|
-
},
|
|
3102
|
-
};
|
|
3103
|
-
},
|
|
3104
|
-
};
|
|
3105
|
-
}
|
|
3106
|
-
|
|
3107
|
-
function makeRecordingContextStore(): {
|
|
3108
|
-
store: ContextStore;
|
|
3109
|
-
commits: { message: string; turns: ConversationTurn[] }[];
|
|
3110
|
-
manifests: TransformRecord[][];
|
|
3111
|
-
metadata: { pendingOperations: PendingOperation[]; tokenUsage: TokenUsage }[];
|
|
3112
|
-
blobs: { key: string; bytes: Uint8Array; contentType?: string }[];
|
|
3113
|
-
lastWrittenTurns: ConversationTurn[];
|
|
3114
|
-
} {
|
|
3115
|
-
const commits: { message: string; turns: ConversationTurn[] }[] = [];
|
|
3116
|
-
const manifests: TransformRecord[][] = [];
|
|
3117
|
-
const metadata: {
|
|
3118
|
-
pendingOperations: PendingOperation[];
|
|
3119
|
-
tokenUsage: TokenUsage;
|
|
3120
|
-
}[] = [];
|
|
3121
|
-
const blobs: { key: string; bytes: Uint8Array; contentType?: string }[] = [];
|
|
3122
|
-
let lastWrittenTurns: ConversationTurn[] = [];
|
|
3123
|
-
|
|
3124
|
-
const store: ContextStore = {
|
|
3125
|
-
async load() {
|
|
3126
|
-
return {
|
|
3127
|
-
turns: [],
|
|
3128
|
-
pendingOperations: [],
|
|
3129
|
-
tokenUsage: emptyUsage(),
|
|
3130
|
-
connectorState: null,
|
|
3131
|
-
};
|
|
3132
|
-
},
|
|
3133
|
-
setConnectorState() {
|
|
3134
|
-
/* noop */
|
|
3135
|
-
},
|
|
3136
|
-
async commit(options) {
|
|
3137
|
-
commits.push({
|
|
3138
|
-
message: options.message,
|
|
3139
|
-
turns: [...lastWrittenTurns],
|
|
3140
|
-
});
|
|
3141
|
-
return {
|
|
3142
|
-
hash: `c${String(commits.length)}`,
|
|
3143
|
-
message: options.message,
|
|
3144
|
-
timestamp: Date.now(),
|
|
3145
|
-
};
|
|
3146
|
-
},
|
|
3147
|
-
async branch() {
|
|
3148
|
-
/* noop */
|
|
3149
|
-
},
|
|
3150
|
-
async log() {
|
|
3151
|
-
return [];
|
|
3152
|
-
},
|
|
3153
|
-
async readAt() {
|
|
3154
|
-
return [];
|
|
3155
|
-
},
|
|
3156
|
-
async writeBlob(key, bytes, contentType) {
|
|
3157
|
-
blobs.push({
|
|
3158
|
-
key,
|
|
3159
|
-
bytes,
|
|
3160
|
-
...(contentType !== undefined ? { contentType } : {}),
|
|
3161
|
-
});
|
|
3162
|
-
},
|
|
3163
|
-
async readBlob() {
|
|
3164
|
-
throw new Error("not implemented");
|
|
3165
|
-
},
|
|
3166
|
-
async writePrompt() {
|
|
3167
|
-
/* noop */
|
|
3168
|
-
},
|
|
3169
|
-
async writeResponse() {
|
|
3170
|
-
/* noop */
|
|
3171
|
-
},
|
|
3172
|
-
async writeManifest(records) {
|
|
3173
|
-
manifests.push([...records]);
|
|
3174
|
-
},
|
|
3175
|
-
async writeTurns(turns) {
|
|
3176
|
-
lastWrittenTurns = [...turns];
|
|
3177
|
-
},
|
|
3178
|
-
async writeMetadata(m) {
|
|
3179
|
-
metadata.push({
|
|
3180
|
-
pendingOperations: [...m.pendingOperations],
|
|
3181
|
-
tokenUsage: { ...m.tokenUsage },
|
|
3182
|
-
});
|
|
3183
|
-
},
|
|
3184
|
-
async readManifestHistory() {
|
|
3185
|
-
throw new Error("not implemented");
|
|
3186
|
-
},
|
|
3187
|
-
};
|
|
3188
|
-
|
|
3189
|
-
return {
|
|
3190
|
-
store,
|
|
3191
|
-
commits,
|
|
3192
|
-
manifests,
|
|
3193
|
-
metadata,
|
|
3194
|
-
blobs,
|
|
3195
|
-
get lastWrittenTurns() {
|
|
3196
|
-
return lastWrittenTurns;
|
|
3197
|
-
},
|
|
3198
|
-
};
|
|
3199
|
-
}
|
|
3200
|
-
|
|
3201
|
-
function mockInferenceRunner(
|
|
3202
|
-
responseText: string,
|
|
3203
|
-
toolCalls: {
|
|
3204
|
-
id: string;
|
|
3205
|
-
name: string;
|
|
3206
|
-
arguments: Record<string, unknown>;
|
|
3207
|
-
}[] = [],
|
|
3208
|
-
): (opts: InferenceHarnessOptions) => AsyncGenerator<InferenceEvent> {
|
|
3209
|
-
return async function* (opts) {
|
|
3210
|
-
const usage: TokenUsage = {
|
|
3211
|
-
input: 10,
|
|
3212
|
-
output: 20,
|
|
3213
|
-
cacheRead: 0,
|
|
3214
|
-
cacheWrite: 0,
|
|
3215
|
-
thinking: 0,
|
|
3216
|
-
};
|
|
3217
|
-
const content: AssistantTurn["content"] = [];
|
|
3218
|
-
if (responseText.length > 0) {
|
|
3219
|
-
content.push({ type: "text", text: responseText });
|
|
3220
|
-
}
|
|
3221
|
-
for (const tc of toolCalls) {
|
|
3222
|
-
content.push({
|
|
3223
|
-
type: "tool_call",
|
|
3224
|
-
id: tc.id,
|
|
3225
|
-
name: tc.name,
|
|
3226
|
-
arguments: tc.arguments,
|
|
3227
|
-
});
|
|
3228
|
-
}
|
|
3229
|
-
const turn: AssistantTurn = {
|
|
3230
|
-
role: "assistant",
|
|
3231
|
-
content,
|
|
3232
|
-
model: opts.source.model,
|
|
3233
|
-
timestamp: Date.now(),
|
|
3234
|
-
};
|
|
3235
|
-
yield {
|
|
3236
|
-
type: "inference.done",
|
|
3237
|
-
seq: opts.nextSeq(),
|
|
3238
|
-
data: { turn, usage, source: TEST_SOURCE },
|
|
3239
|
-
};
|
|
3240
|
-
};
|
|
3241
|
-
}
|
|
3242
|
-
|
|
3243
|
-
describe("createReactor — tool result transform chain", () => {
|
|
3244
|
-
test("threads results through transforms in order; records appear in order", async () => {
|
|
3245
|
-
const recording = makeRecordingContextStore();
|
|
3246
|
-
const t1 = passthroughToolTransform("first");
|
|
3247
|
-
const t2 = passthroughToolTransform("second");
|
|
3248
|
-
|
|
3249
|
-
const { reactor, waitFor } = createTestReactor({
|
|
3250
|
-
contextStore: recording.store,
|
|
3251
|
-
director: directorFromTable({
|
|
3252
|
-
"message.received": (_e, _s, caps) =>
|
|
3253
|
-
caps.executeTools([{ id: "c1", name: "tool", arguments: {} }]),
|
|
3254
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
3255
|
-
}),
|
|
3256
|
-
});
|
|
3257
|
-
|
|
3258
|
-
// Inject the transforms via a private property — we cannot pass them via
|
|
3259
|
-
// createTestReactor's typed overrides without expanding its shape, so we
|
|
3260
|
-
// construct a second reactor directly below for the headline tests. For
|
|
3261
|
-
// ordering coverage here we lean on the harness-side reactor.
|
|
3262
|
-
void t1;
|
|
3263
|
-
void t2;
|
|
3264
|
-
|
|
3265
|
-
reactor.start();
|
|
3266
|
-
reactor.deliver(makeInboundMessage());
|
|
3267
|
-
await waitFor("reactor.done");
|
|
3268
|
-
// The default reactor in this test has no transforms; the commit should
|
|
3269
|
-
// still have happened (cycle had tool calls).
|
|
3270
|
-
expect(recording.commits.length).toBeGreaterThan(0);
|
|
3271
|
-
});
|
|
3272
|
-
});
|
|
3273
|
-
|
|
3274
|
-
function createDirectReactor(opts: {
|
|
3275
|
-
contextStore: ContextStore;
|
|
3276
|
-
director: ReactorDirector;
|
|
3277
|
-
toolRunner?: ToolRunner;
|
|
3278
|
-
inferenceRunner?: (
|
|
3279
|
-
opts: InferenceHarnessOptions,
|
|
3280
|
-
) => AsyncGenerator<InferenceEvent>;
|
|
3281
|
-
toolResultTransforms?: ToolResultTransform[];
|
|
3282
|
-
contextTransforms?: ContextTransform[];
|
|
3283
|
-
compactors?: Record<string, Compactor>;
|
|
3284
|
-
}): {
|
|
3285
|
-
reactor: Reactor;
|
|
3286
|
-
events: ReactorEmittedEvent[];
|
|
3287
|
-
waitFor: (
|
|
3288
|
-
type: ReactorEmittedEvent["type"],
|
|
3289
|
-
timeoutMs?: number,
|
|
3290
|
-
) => Promise<ReactorEmittedEvent>;
|
|
3291
|
-
} {
|
|
3292
|
-
const { events, onEvent } = collectEvents();
|
|
3293
|
-
const reactor = createReactor({
|
|
3294
|
-
sessionId: `test-${String(++testSessionCounter)}`,
|
|
3295
|
-
director: opts.director,
|
|
3296
|
-
source: {
|
|
3297
|
-
id: "anthropic:test-model",
|
|
3298
|
-
provider: "anthropic",
|
|
3299
|
-
baseURL: "https://api.anthropic.com",
|
|
3300
|
-
apiKey: "test",
|
|
3301
|
-
model: "test-model",
|
|
3302
|
-
},
|
|
3303
|
-
toolRunner: opts.toolRunner ?? noopToolRunner(),
|
|
3304
|
-
contextStore: opts.contextStore,
|
|
3305
|
-
onEvent,
|
|
3306
|
-
deps: createDefaultDependencies(),
|
|
3307
|
-
shutdownTimeoutMs: 100,
|
|
3308
|
-
...(opts.inferenceRunner !== undefined
|
|
3309
|
-
? { inferenceRunner: opts.inferenceRunner }
|
|
3310
|
-
: {}),
|
|
3311
|
-
...(opts.toolResultTransforms !== undefined
|
|
3312
|
-
? { toolResultTransforms: opts.toolResultTransforms }
|
|
3313
|
-
: {}),
|
|
3314
|
-
...(opts.contextTransforms !== undefined
|
|
3315
|
-
? { contextTransforms: opts.contextTransforms }
|
|
3316
|
-
: {}),
|
|
3317
|
-
...(opts.compactors !== undefined ? { compactors: opts.compactors } : {}),
|
|
3318
|
-
});
|
|
3319
|
-
|
|
3320
|
-
function waitForType(
|
|
3321
|
-
type: ReactorEmittedEvent["type"],
|
|
3322
|
-
timeoutMs = 2000,
|
|
3323
|
-
): Promise<ReactorEmittedEvent> {
|
|
3324
|
-
return waitForEvent(events, (e) => e.type === type, timeoutMs);
|
|
3325
|
-
}
|
|
3326
|
-
return { reactor, events, waitFor: waitForType };
|
|
3327
|
-
}
|
|
3328
|
-
|
|
3329
|
-
describe("createReactor — transform chain ordering and compact action", () => {
|
|
3330
|
-
test("tool result transforms run in order and records appear in invocation order", async () => {
|
|
3331
|
-
const recording = makeRecordingContextStore();
|
|
3332
|
-
const transforms: ToolResultTransform[] = [
|
|
3333
|
-
passthroughToolTransform("first", { ord: 1 }),
|
|
3334
|
-
passthroughToolTransform("second", { ord: 2 }),
|
|
3335
|
-
];
|
|
3336
|
-
|
|
3337
|
-
const { reactor, waitFor } = createDirectReactor({
|
|
3338
|
-
contextStore: recording.store,
|
|
3339
|
-
toolRunner: makeToolRunner(async (call) => ({
|
|
3340
|
-
callId: call.id,
|
|
3341
|
-
content: "result",
|
|
3342
|
-
})),
|
|
3343
|
-
director: directorFromTable({
|
|
3344
|
-
"message.received": (_e, _s, caps) =>
|
|
3345
|
-
caps.executeTools([{ id: "c1", name: "tool", arguments: {} }]),
|
|
3346
|
-
"tool.done": (_e, _s, caps) => caps.done(),
|
|
3347
|
-
}),
|
|
3348
|
-
toolResultTransforms: transforms,
|
|
3349
|
-
});
|
|
3350
|
-
|
|
3351
|
-
reactor.start();
|
|
3352
|
-
reactor.deliver(makeInboundMessage());
|
|
3353
|
-
await waitFor("reactor.done");
|
|
3354
|
-
|
|
3355
|
-
// One commit; manifest carries both records in order.
|
|
3356
|
-
expect(recording.commits.length).toBe(1);
|
|
3357
|
-
expect(recording.manifests.length).toBe(1);
|
|
3358
|
-
const records = recording.manifests[0];
|
|
3359
|
-
if (records === undefined) throw new Error("expected manifest");
|
|
3360
|
-
expect(records.map((r) => r.strategy)).toEqual(["first", "second"]);
|
|
3361
|
-
});
|
|
3362
|
-
|
|
3363
|
-
test("context transforms run before inference and feed the materialized prompt", async () => {
|
|
3364
|
-
const recording = makeRecordingContextStore();
|
|
3365
|
-
const transform = passthroughContextTransform("tag-context", "TAG:");
|
|
3366
|
-
|
|
3367
|
-
let observedPrompt: ConversationTurn[] = [];
|
|
3368
|
-
const runner: (
|
|
3369
|
-
opts: InferenceHarnessOptions,
|
|
3370
|
-
) => AsyncGenerator<InferenceEvent> = async function* (opts) {
|
|
3371
|
-
observedPrompt = opts.turns;
|
|
3372
|
-
const turn: AssistantTurn = {
|
|
3373
|
-
role: "assistant",
|
|
3374
|
-
content: [{ type: "text", text: "ok" }],
|
|
3375
|
-
model: opts.source.model,
|
|
3376
|
-
timestamp: Date.now(),
|
|
3377
|
-
};
|
|
3378
|
-
yield {
|
|
3379
|
-
type: "inference.done",
|
|
3380
|
-
seq: opts.nextSeq(),
|
|
3381
|
-
data: {
|
|
3382
|
-
turn,
|
|
3383
|
-
usage: emptyUsage(),
|
|
3384
|
-
source: TEST_SOURCE,
|
|
3385
|
-
},
|
|
3386
|
-
};
|
|
3387
|
-
};
|
|
3388
|
-
|
|
3389
|
-
const { reactor, waitFor } = createDirectReactor({
|
|
3390
|
-
contextStore: recording.store,
|
|
3391
|
-
inferenceRunner: runner,
|
|
3392
|
-
director: directorFromTable({
|
|
3393
|
-
"message.received": (_e, _s, caps) => caps.infer(),
|
|
3394
|
-
"inference.done": (_e, _s, caps) => caps.done(),
|
|
3395
|
-
}),
|
|
3396
|
-
contextTransforms: [transform],
|
|
3397
|
-
});
|
|
3398
|
-
|
|
3399
|
-
reactor.start();
|
|
3400
|
-
reactor.deliver(makeInboundMessage());
|
|
3401
|
-
await waitFor("reactor.done");
|
|
3402
|
-
|
|
3403
|
-
expect(observedPrompt.length).toBeGreaterThan(0);
|
|
3404
|
-
const firstBlock = observedPrompt[0]?.content[0];
|
|
3405
|
-
if (firstBlock === undefined || firstBlock.type !== "text") {
|
|
3406
|
-
throw new Error("expected text block in transformed prompt");
|
|
3407
|
-
}
|
|
3408
|
-
expect(firstBlock.text.startsWith("TAG:")).toBe(true);
|
|
3409
|
-
});
|
|
3410
|
-
|
|
3411
|
-
test("compact action looks up the named compactor and replaces turns", async () => {
|
|
3412
|
-
const recording = makeRecordingContextStore();
|
|
3413
|
-
const compactor = truncatingCompactor("tail-only");
|
|
3414
|
-
|
|
3415
|
-
let messages = 0;
|
|
3416
|
-
const director: ReactorDirector = {
|
|
3417
|
-
async decide(event, _state, caps) {
|
|
3418
|
-
if (event.type === "message.received") {
|
|
3419
|
-
messages++;
|
|
3420
|
-
if (messages === 1) {
|
|
3421
|
-
// First message produces a multi-turn history (this user turn is
|
|
3422
|
-
// appended by the reactor before decide() returns), then we run
|
|
3423
|
-
// the compactor which drops everything except the tail.
|
|
3424
|
-
return caps.compact("tail-only", "explicit-test");
|
|
3425
|
-
}
|
|
3426
|
-
return caps.done();
|
|
3427
|
-
}
|
|
3428
|
-
return caps.done();
|
|
3429
|
-
},
|
|
3430
|
-
};
|
|
3431
|
-
|
|
3432
|
-
const { reactor, waitFor } = createDirectReactor({
|
|
3433
|
-
contextStore: recording.store,
|
|
3434
|
-
director,
|
|
3435
|
-
compactors: { "tail-only": compactor },
|
|
3436
|
-
});
|
|
3437
|
-
|
|
3438
|
-
reactor.start();
|
|
3439
|
-
reactor.deliver(makeInboundMessage());
|
|
3440
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
|
|
3441
|
-
await waitFor("reactor.done");
|
|
3442
|
-
|
|
3443
|
-
expect(messages).toBeGreaterThanOrEqual(1);
|
|
3444
|
-
// The commit for the compact cycle has a single-turn history (tail-only).
|
|
3445
|
-
expect(recording.commits.length).toBeGreaterThan(0);
|
|
3446
|
-
const compactCommit = recording.commits.find((c) =>
|
|
3447
|
-
c.message.startsWith("Cycle: compaction"),
|
|
3448
|
-
);
|
|
3449
|
-
expect(compactCommit).toBeDefined();
|
|
3450
|
-
if (compactCommit !== undefined) {
|
|
3451
|
-
expect(compactCommit.turns.length).toBe(1);
|
|
3452
|
-
}
|
|
3453
|
-
// Manifest carries the compactor record.
|
|
3454
|
-
const flatRecords = recording.manifests.flat();
|
|
3455
|
-
expect(flatRecords.some((r) => r.strategy === "tail-only")).toBe(true);
|
|
3456
|
-
});
|
|
3457
|
-
|
|
3458
|
-
test("compact for an unknown name emits a fatal error and shuts down", async () => {
|
|
3459
|
-
const recording = makeRecordingContextStore();
|
|
3460
|
-
const { reactor, events, waitFor } = createDirectReactor({
|
|
3461
|
-
contextStore: recording.store,
|
|
3462
|
-
director: directorFromTable({
|
|
3463
|
-
"message.received": (_e, _s, caps) => caps.compact("missing", "test"),
|
|
3464
|
-
}),
|
|
3465
|
-
compactors: {},
|
|
3466
|
-
});
|
|
3467
|
-
|
|
3468
|
-
reactor.start();
|
|
3469
|
-
reactor.deliver(makeInboundMessage());
|
|
3470
|
-
await waitFor("reactor.done");
|
|
3471
|
-
const err = getEvent(events, "reactor.error");
|
|
3472
|
-
expect(err.data.error).toContain("missing");
|
|
3473
|
-
expect(err.data.fatal).toBe(true);
|
|
3474
|
-
});
|
|
3475
|
-
|
|
3476
|
-
test("validateActions rejects compact + infer", async () => {
|
|
3477
|
-
const result = validateActions([
|
|
3478
|
-
{ type: "compact", compactor: "tail", reason: "r" },
|
|
3479
|
-
{ type: "infer" },
|
|
3480
|
-
]);
|
|
3481
|
-
expect(result.ok).toBe(false);
|
|
3482
|
-
if (result.ok) throw new Error("unreachable");
|
|
3483
|
-
expect(result.error).toMatch(/compact.*infer/i);
|
|
3484
|
-
});
|
|
3485
|
-
|
|
3486
|
-
test("context-overflow recovery: compact alone, then infer on the next cycle", async () => {
|
|
3487
|
-
const recording = makeRecordingContextStore();
|
|
3488
|
-
const compactor = truncatingCompactor("overflow-compactor");
|
|
3489
|
-
|
|
3490
|
-
// First inference yields a context_overflow error; the second succeeds.
|
|
3491
|
-
let inferAttempts = 0;
|
|
3492
|
-
const runner: (
|
|
3493
|
-
opts: InferenceHarnessOptions,
|
|
3494
|
-
) => AsyncGenerator<InferenceEvent> = async function* (opts) {
|
|
3495
|
-
inferAttempts++;
|
|
3496
|
-
if (inferAttempts === 1) {
|
|
3497
|
-
yield {
|
|
3498
|
-
type: "inference.error",
|
|
3499
|
-
seq: opts.nextSeq(),
|
|
3500
|
-
data: {
|
|
3501
|
-
error: {
|
|
3502
|
-
category: "context_overflow",
|
|
3503
|
-
message: "context too long",
|
|
3504
|
-
},
|
|
3505
|
-
partial: { text: "" },
|
|
3506
|
-
},
|
|
3507
|
-
};
|
|
3508
|
-
return;
|
|
3509
|
-
}
|
|
3510
|
-
const turn: AssistantTurn = {
|
|
3511
|
-
role: "assistant",
|
|
3512
|
-
content: [{ type: "text", text: "ok-after-compact" }],
|
|
3513
|
-
model: opts.source.model,
|
|
3514
|
-
timestamp: Date.now(),
|
|
3515
|
-
};
|
|
3516
|
-
yield {
|
|
3517
|
-
type: "inference.done",
|
|
3518
|
-
seq: opts.nextSeq(),
|
|
3519
|
-
data: { turn, usage: emptyUsage(), source: TEST_SOURCE },
|
|
3520
|
-
};
|
|
3521
|
-
};
|
|
3522
|
-
|
|
3523
|
-
const director: ReactorDirector = {
|
|
3524
|
-
async decide(event, _state, caps) {
|
|
3525
|
-
if (event.type === "message.received") {
|
|
3526
|
-
return caps.infer();
|
|
3527
|
-
}
|
|
3528
|
-
if (event.type === "inference.error") {
|
|
3529
|
-
if (event.error.category === "context_overflow") {
|
|
3530
|
-
return caps.compact("overflow-compactor", "context-overflow");
|
|
3531
|
-
}
|
|
3532
|
-
return caps.done();
|
|
3533
|
-
}
|
|
3534
|
-
if (event.type === "inference.done") {
|
|
3535
|
-
return caps.done();
|
|
3536
|
-
}
|
|
3537
|
-
return caps.wait();
|
|
3538
|
-
},
|
|
3539
|
-
};
|
|
3540
|
-
|
|
3541
|
-
// After compact, we expect the reactor to deliver no automatic event; the
|
|
3542
|
-
// director needs to drive the next infer. To exercise that, we patch the
|
|
3543
|
-
// director to issue infer after compact via a synthetic message.received.
|
|
3544
|
-
// Here we use a slightly richer director that knows to re-infer once the
|
|
3545
|
-
// compact cycle's commit has happened. We approximate by delivering a
|
|
3546
|
-
// second message after compact via a side channel.
|
|
3547
|
-
void compactor;
|
|
3548
|
-
|
|
3549
|
-
const { reactor, events, waitFor } = createDirectReactor({
|
|
3550
|
-
contextStore: recording.store,
|
|
3551
|
-
inferenceRunner: runner,
|
|
3552
|
-
director,
|
|
3553
|
-
compactors: { "overflow-compactor": compactor },
|
|
3554
|
-
});
|
|
3555
|
-
|
|
3556
|
-
reactor.start();
|
|
3557
|
-
reactor.deliver(makeInboundMessage());
|
|
3558
|
-
|
|
3559
|
-
// After the compact cycle commits, the conversation has been truncated.
|
|
3560
|
-
// The director did not chain compact + infer; it only emitted compact.
|
|
3561
|
-
// Drive the next infer by delivering another message that re-runs the
|
|
3562
|
-
// event loop. The director's message.received → infer rule will fire.
|
|
3563
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 30);
|
|
3564
|
-
|
|
3565
|
-
await waitFor("reactor.done");
|
|
3566
|
-
// The reactor ran inference twice: the first attempt failed with
|
|
3567
|
-
// context_overflow, and the second attempt succeeded after compaction.
|
|
3568
|
-
expect(inferAttempts).toBe(2);
|
|
3569
|
-
|
|
3570
|
-
const compactRecord = recording.manifests
|
|
3571
|
-
.flat()
|
|
3572
|
-
.find((r) => r.strategy === "overflow-compactor");
|
|
3573
|
-
expect(compactRecord).toBeDefined();
|
|
3574
|
-
expect(compactRecord?.reason).toBe("explicit");
|
|
3575
|
-
|
|
3576
|
-
// No reactor.error from the validation layer about compact+infer.
|
|
3577
|
-
const validationErr = events
|
|
3578
|
-
.filter((e) => e.type === "reactor.error")
|
|
3579
|
-
.find(
|
|
3580
|
-
(e) =>
|
|
3581
|
-
e.type === "reactor.error" && e.data.error.includes("Invalid action"),
|
|
3582
|
-
);
|
|
3583
|
-
expect(validationErr).toBeUndefined();
|
|
3584
|
-
});
|
|
3585
|
-
|
|
3586
|
-
test("per-cycle commit cadence: each cycle produces a commit; pendingMessage overrides one auto-summary", async () => {
|
|
3587
|
-
const recording = makeRecordingContextStore();
|
|
3588
|
-
let directorCalls = 0;
|
|
3589
|
-
const director: ReactorDirector = {
|
|
3590
|
-
async decide(event, _state, _caps) {
|
|
3591
|
-
directorCalls++;
|
|
3592
|
-
if (event.type === "message.received" && directorCalls === 1) {
|
|
3593
|
-
return { type: "infer" as const };
|
|
3594
|
-
}
|
|
3595
|
-
if (event.type === "inference.done") {
|
|
3596
|
-
return [
|
|
3597
|
-
{ type: "checkpoint" as const, message: "override-1" },
|
|
3598
|
-
{ type: "done" as const },
|
|
3599
|
-
];
|
|
3600
|
-
}
|
|
3601
|
-
return { type: "done" as const };
|
|
3602
|
-
},
|
|
3603
|
-
};
|
|
3604
|
-
|
|
3605
|
-
const { reactor, waitFor } = createDirectReactor({
|
|
3606
|
-
contextStore: recording.store,
|
|
3607
|
-
inferenceRunner: mockInferenceRunner("done"),
|
|
3608
|
-
director,
|
|
3609
|
-
});
|
|
3610
|
-
|
|
3611
|
-
reactor.start();
|
|
3612
|
-
reactor.deliver(makeInboundMessage());
|
|
3613
|
-
await waitFor("reactor.done");
|
|
3614
|
-
|
|
3615
|
-
// First cycle: inference cycle commits with override-1 (from checkpoint).
|
|
3616
|
-
expect(recording.commits.length).toBe(1);
|
|
3617
|
-
expect(recording.commits[0]?.message).toBe("override-1");
|
|
3618
|
-
});
|
|
3619
|
-
|
|
3620
|
-
test("pendingMessage is consumed exactly once; the next cycle uses auto-summary", async () => {
|
|
3621
|
-
const recording = makeRecordingContextStore();
|
|
3622
|
-
let directorCalls = 0;
|
|
3623
|
-
const director: ReactorDirector = {
|
|
3624
|
-
async decide(event, _state, _caps) {
|
|
3625
|
-
directorCalls++;
|
|
3626
|
-
if (event.type === "message.received") {
|
|
3627
|
-
if (directorCalls === 1) {
|
|
3628
|
-
return [
|
|
3629
|
-
{ type: "checkpoint" as const, message: "first-override" },
|
|
3630
|
-
{ type: "infer" as const },
|
|
3631
|
-
];
|
|
3632
|
-
}
|
|
3633
|
-
return { type: "done" as const };
|
|
3634
|
-
}
|
|
3635
|
-
if (event.type === "inference.done") {
|
|
3636
|
-
return { type: "wait" as const };
|
|
3637
|
-
}
|
|
3638
|
-
return { type: "done" as const };
|
|
3639
|
-
},
|
|
3640
|
-
};
|
|
3641
|
-
|
|
3642
|
-
const { reactor, waitFor } = createDirectReactor({
|
|
3643
|
-
contextStore: recording.store,
|
|
3644
|
-
inferenceRunner: mockInferenceRunner("ok"),
|
|
3645
|
-
director,
|
|
3646
|
-
});
|
|
3647
|
-
|
|
3648
|
-
reactor.start();
|
|
3649
|
-
reactor.deliver(makeInboundMessage());
|
|
3650
|
-
setTimeout(() => reactor.deliver(makeInboundMessage()), 80);
|
|
3651
|
-
await waitFor("reactor.done");
|
|
3652
|
-
|
|
3653
|
-
expect(recording.commits.length).toBeGreaterThanOrEqual(1);
|
|
3654
|
-
expect(recording.commits[0]?.message).toBe("first-override");
|
|
3655
|
-
// If a second commit happened, its message must not be the override.
|
|
3656
|
-
for (let i = 1; i < recording.commits.length; i++) {
|
|
3657
|
-
expect(recording.commits[i]?.message).not.toBe("first-override");
|
|
3658
|
-
}
|
|
3659
|
-
});
|
|
3660
|
-
});
|