ankka 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +350 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.js +90 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +254 -0
- package/dist/_proto/ankka/protocol/v1/client_pb.js +47 -0
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +105 -0
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +28 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +598 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +175 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +224 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +44 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +212 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +47 -0
- package/dist/_proto/ankka/protocol/v1/key_value_pb.d.ts +154 -0
- package/dist/_proto/ankka/protocol/v1/key_value_pb.js +36 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +276 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.js +110 -0
- package/dist/_proto/ankka/protocol/v1/timed_action_pb.d.ts +75 -0
- package/dist/_proto/ankka/protocol/v1/timed_action_pb.js +23 -0
- package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +93 -0
- package/dist/_proto/ankka/protocol/v1/view_pb.js +23 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +298 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.js +67 -0
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +36 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +239 -0
- package/dist/codec.d.ts +44 -0
- package/dist/codec.js +215 -0
- package/dist/consumer.d.ts +31 -0
- package/dist/consumer.js +32 -0
- package/dist/context.d.ts +61 -0
- package/dist/context.js +75 -0
- package/dist/effects/agent.d.ts +37 -0
- package/dist/effects/agent.js +48 -0
- package/dist/effects/common.d.ts +68 -0
- package/dist/effects/common.js +55 -0
- package/dist/effects/eventSourced.d.ts +50 -0
- package/dist/effects/eventSourced.js +66 -0
- package/dist/effects/keyValue.d.ts +36 -0
- package/dist/effects/keyValue.js +47 -0
- package/dist/effects/stateless.d.ts +40 -0
- package/dist/effects/stateless.js +35 -0
- package/dist/effects/workflow.d.ts +99 -0
- package/dist/effects/workflow.js +83 -0
- package/dist/endpoint.d.ts +21 -0
- package/dist/endpoint.js +29 -0
- package/dist/eventSourcedEntity.d.ts +40 -0
- package/dist/eventSourcedEntity.js +66 -0
- package/dist/handlers.d.ts +55 -0
- package/dist/handlers.js +64 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +39 -0
- package/dist/json.d.ts +21 -0
- package/dist/json.js +362 -0
- package/dist/keyValueEntity.d.ts +29 -0
- package/dist/keyValueEntity.js +52 -0
- package/dist/kinds.d.ts +8 -0
- package/dist/kinds.js +41 -0
- package/dist/materialise.d.ts +33 -0
- package/dist/materialise.js +55 -0
- package/dist/routes.d.ts +71 -0
- package/dist/routes.js +79 -0
- package/dist/schema.d.ts +112 -0
- package/dist/schema.js +200 -0
- package/dist/server/agent.d.ts +14 -0
- package/dist/server/agent.js +113 -0
- package/dist/server/discovery.d.ts +5 -0
- package/dist/server/discovery.js +21 -0
- package/dist/server/eventSourced.d.ts +12 -0
- package/dist/server/eventSourced.js +168 -0
- package/dist/server/http.d.ts +10 -0
- package/dist/server/http.js +153 -0
- package/dist/server/keyValue.d.ts +5 -0
- package/dist/server/keyValue.js +122 -0
- package/dist/server/payloads.d.ts +6 -0
- package/dist/server/payloads.js +11 -0
- package/dist/server/queue.d.ts +10 -0
- package/dist/server/queue.js +67 -0
- package/dist/server/server.d.ts +34 -0
- package/dist/server/server.js +101 -0
- package/dist/server/stateless.d.ts +9 -0
- package/dist/server/stateless.js +108 -0
- package/dist/server/workflow.d.ts +5 -0
- package/dist/server/workflow.js +244 -0
- package/dist/service.d.ts +147 -0
- package/dist/service.js +384 -0
- package/dist/spec.d.ts +7 -0
- package/dist/spec.js +106 -0
- package/dist/testkit/index.d.ts +3 -0
- package/dist/testkit/index.js +6 -0
- package/dist/testkit/integration.d.ts +60 -0
- package/dist/testkit/integration.js +284 -0
- package/dist/testkit/kinds.d.ts +133 -0
- package/dist/testkit/kinds.js +398 -0
- package/dist/testkit/unit.d.ts +54 -0
- package/dist/testkit/unit.js +224 -0
- package/dist/time.d.ts +73 -0
- package/dist/time.js +275 -0
- package/dist/timedAction.d.ts +20 -0
- package/dist/timedAction.js +28 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/view.d.ts +37 -0
- package/dist/view.js +52 -0
- package/dist/workflow.d.ts +33 -0
- package/dist/workflow.js +61 -0
- package/package.json +75 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Unit testkits for the other kinds: key value entities, workflows, views, consumers, timed actions and
|
|
2
|
+
// agents. Same rules as the event sourced kit: no sidecar, effects as values, every value round-tripped
|
|
3
|
+
// through the component's codecs, the server's reduction.
|
|
4
|
+
import { ComponentClient, noClient } from "../client.js";
|
|
5
|
+
import { binaryCodecs, codecFor } from "../codec.js";
|
|
6
|
+
import { commandContext } from "../context.js";
|
|
7
|
+
import { CommandError, ErrorCode } from "../effects/common.js";
|
|
8
|
+
import { materialiseKeyValue, materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
|
|
9
|
+
import { Ankka } from "../service.js";
|
|
10
|
+
import { checkPlan, runTool } from "../server/agent.js";
|
|
11
|
+
function roundTrip(codec, value) {
|
|
12
|
+
return codec.decode(codec.encode(value));
|
|
13
|
+
}
|
|
14
|
+
function findHandler(handlers, handler, who) {
|
|
15
|
+
const name = typeof handler === "string" ? handler : handler.name;
|
|
16
|
+
const found = handlers.get(name);
|
|
17
|
+
if (!found)
|
|
18
|
+
throw new Error(`${who} has no handler ${JSON.stringify(name)}; declared: ${[...handlers.keys()].sort().join(", ")}`);
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
function registryFor(cls, client) {
|
|
22
|
+
return Ankka.service({ client, log: () => { } })
|
|
23
|
+
.register(cls)
|
|
24
|
+
.validate();
|
|
25
|
+
}
|
|
26
|
+
/** Drives one key value entity instance through its handlers. */
|
|
27
|
+
export class KeyValueTestKit {
|
|
28
|
+
#registered;
|
|
29
|
+
#entity;
|
|
30
|
+
#client;
|
|
31
|
+
entityId;
|
|
32
|
+
state;
|
|
33
|
+
constructor(cls, entityId, client) {
|
|
34
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
35
|
+
this.#entity = new cls();
|
|
36
|
+
this.#entity._bindInstance(entityId);
|
|
37
|
+
this.#client = client;
|
|
38
|
+
this.entityId = entityId;
|
|
39
|
+
this.state = this.#entity.emptyState();
|
|
40
|
+
}
|
|
41
|
+
static of(cls, entityId = "test", client = noClient()) {
|
|
42
|
+
return new KeyValueTestKit(cls, entityId, client);
|
|
43
|
+
}
|
|
44
|
+
async call(handler, input, metadata = {}) {
|
|
45
|
+
const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
|
|
46
|
+
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
47
|
+
this.#entity._bindCommand(this.state, commandContext(this.#registered.id, this.entityId, 0n, metadata), this.#client.withMetadata(metadata));
|
|
48
|
+
let effect;
|
|
49
|
+
try {
|
|
50
|
+
effect = (await ref.run(this.#entity, wireInput));
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
this.#entity._unbindCommand();
|
|
54
|
+
}
|
|
55
|
+
if (ref.readOnly && effect.kind !== "read-only")
|
|
56
|
+
throw new Error(`${this.#registered.id}/${ref.name} is a query and returned an updating effect`);
|
|
57
|
+
const m = materialiseKeyValue(effect, this.state);
|
|
58
|
+
if (m.error)
|
|
59
|
+
return m;
|
|
60
|
+
const newState = roundTrip(this.#registered.stateCodec, m.newState);
|
|
61
|
+
const replyCodec = (ref.reply ? codecFor(ref.reply) : binaryCodecs.done);
|
|
62
|
+
const reply = m.noReply ? undefined : roundTrip(replyCodec, m.reply);
|
|
63
|
+
this.state = newState;
|
|
64
|
+
return Object.freeze({ ...m, newState, reply });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Drives one workflow instance: commands, steps, and the transitions between them, stopping at a pause. */
|
|
68
|
+
export class WorkflowTestKit {
|
|
69
|
+
#registered;
|
|
70
|
+
#cls;
|
|
71
|
+
#client;
|
|
72
|
+
entityId;
|
|
73
|
+
state;
|
|
74
|
+
#progress = { pending: null, paused: null, ended: false, failed: null };
|
|
75
|
+
constructor(cls, entityId, client) {
|
|
76
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
77
|
+
this.#cls = cls;
|
|
78
|
+
this.#client = client;
|
|
79
|
+
this.entityId = entityId;
|
|
80
|
+
const instance = new cls();
|
|
81
|
+
instance._bindInstance(entityId);
|
|
82
|
+
this.state = instance.emptyState();
|
|
83
|
+
}
|
|
84
|
+
static of(cls, entityId = "test", client = noClient()) {
|
|
85
|
+
return new WorkflowTestKit(cls, entityId, client);
|
|
86
|
+
}
|
|
87
|
+
get progress() {
|
|
88
|
+
return this.#progress;
|
|
89
|
+
}
|
|
90
|
+
#instance() {
|
|
91
|
+
const w = new this.#cls();
|
|
92
|
+
w._bindInstance(this.entityId);
|
|
93
|
+
return w;
|
|
94
|
+
}
|
|
95
|
+
async call(handler, input, metadata = {}) {
|
|
96
|
+
const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
|
|
97
|
+
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
98
|
+
const w = this.#instance();
|
|
99
|
+
w._bindCommand(this.state, commandContext(this.#registered.id, this.entityId, 0n, metadata), this.#client.withMetadata(metadata));
|
|
100
|
+
let effect;
|
|
101
|
+
try {
|
|
102
|
+
effect = (await ref.run(w, wireInput));
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
w._unbindCommand();
|
|
106
|
+
}
|
|
107
|
+
if (ref.readOnly && effect.kind !== "read-only")
|
|
108
|
+
throw new Error(`${this.#registered.id}/${ref.name} is a query and returned a changing effect`);
|
|
109
|
+
const m = materialiseWorkflowCommand(effect, this.state);
|
|
110
|
+
if (m.error)
|
|
111
|
+
return m;
|
|
112
|
+
const newState = roundTrip(this.#registered.stateCodec, m.newState);
|
|
113
|
+
const replyCodec = (ref.reply ? codecFor(ref.reply) : binaryCodecs.done);
|
|
114
|
+
const reply = m.noReply ? undefined : roundTrip(replyCodec, m.reply);
|
|
115
|
+
this.state = newState;
|
|
116
|
+
if (m.transition) {
|
|
117
|
+
this.#checkStep(m.transition);
|
|
118
|
+
this.#progress = { pending: m.transition, paused: null, ended: false, failed: null };
|
|
119
|
+
}
|
|
120
|
+
return Object.freeze({ ...m, newState, reply });
|
|
121
|
+
}
|
|
122
|
+
#checkStep(ref) {
|
|
123
|
+
if (!this.#registered.steps.has(ref.step))
|
|
124
|
+
throw new Error(`${this.#registered.id} transitions to ${JSON.stringify(ref.step)}, which is not a declared step`);
|
|
125
|
+
}
|
|
126
|
+
/** Runs one step, by name or declaration, with an input (or the pending transition's). */
|
|
127
|
+
async runStep(step, input) {
|
|
128
|
+
const ref = findHandler(this.#registered.steps, step, this.#registered.cls.name);
|
|
129
|
+
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
130
|
+
const w = this.#instance();
|
|
131
|
+
w._bindCommand(this.state, commandContext(this.#registered.id, this.entityId, 0n, {}), this.#client);
|
|
132
|
+
let effect;
|
|
133
|
+
try {
|
|
134
|
+
effect = (await ref.run(w, wireInput));
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
w._unbindCommand();
|
|
138
|
+
}
|
|
139
|
+
const m = materialiseStep(effect, this.state);
|
|
140
|
+
const newState = roundTrip(this.#registered.stateCodec, m.newState);
|
|
141
|
+
this.state = newState;
|
|
142
|
+
switch (m.next.kind) {
|
|
143
|
+
case "transition":
|
|
144
|
+
this.#checkStep(m.next.ref);
|
|
145
|
+
this.#progress = { pending: m.next.ref, paused: null, ended: false, failed: null };
|
|
146
|
+
break;
|
|
147
|
+
case "pause":
|
|
148
|
+
if (m.next.onTimeout)
|
|
149
|
+
this.#checkStep(m.next.onTimeout);
|
|
150
|
+
this.#progress = { pending: null, paused: { after: m.next.after?.toMillis() ?? null, onTimeout: m.next.onTimeout }, ended: false, failed: null };
|
|
151
|
+
break;
|
|
152
|
+
case "end":
|
|
153
|
+
this.#progress = { pending: null, paused: null, ended: true, failed: null };
|
|
154
|
+
break;
|
|
155
|
+
case "fail":
|
|
156
|
+
this.#progress = { pending: null, paused: null, ended: true, failed: m.next.error };
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
return Object.freeze({ ...m, newState });
|
|
160
|
+
}
|
|
161
|
+
/** Follows transitions until the workflow ends or pauses. Returns the progress reached. */
|
|
162
|
+
async runUntilEnd(limit = 100) {
|
|
163
|
+
for (let i = 0; i < limit && this.#progress.pending; i++) {
|
|
164
|
+
const ref = this.#progress.pending;
|
|
165
|
+
await this.runStep(ref.step, ref.input);
|
|
166
|
+
}
|
|
167
|
+
if (this.#progress.pending)
|
|
168
|
+
throw new Error(`the workflow did not end or pause within ${limit} steps`);
|
|
169
|
+
return this.#progress;
|
|
170
|
+
}
|
|
171
|
+
/** As the engine would when a pause's timeout passes: runs `onTimeout`, then follows transitions. */
|
|
172
|
+
async resume() {
|
|
173
|
+
if (!this.#progress.paused)
|
|
174
|
+
throw new Error("the workflow is not paused");
|
|
175
|
+
const ref = this.#progress.paused.onTimeout;
|
|
176
|
+
if (!ref)
|
|
177
|
+
throw new Error("the pause has no onTimeout step; a command must move the workflow on");
|
|
178
|
+
await this.runStep(ref.step, ref.input);
|
|
179
|
+
return this.runUntilEnd();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Feeds a view events by source key and keeps the rows as the sidecar would. */
|
|
183
|
+
export class ViewTestKit {
|
|
184
|
+
#registered;
|
|
185
|
+
#cls;
|
|
186
|
+
#client;
|
|
187
|
+
rows = new Map();
|
|
188
|
+
constructor(cls, client) {
|
|
189
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
190
|
+
this.#cls = cls;
|
|
191
|
+
this.#client = client;
|
|
192
|
+
}
|
|
193
|
+
static of(cls, client = noClient()) {
|
|
194
|
+
return new ViewTestKit(cls, client);
|
|
195
|
+
}
|
|
196
|
+
async #apply(key, fn, metadata) {
|
|
197
|
+
const view = new this.#cls();
|
|
198
|
+
const rowCodec = this.#registered.rowCodec;
|
|
199
|
+
const current = this.rows.get(key);
|
|
200
|
+
view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata));
|
|
201
|
+
const effect = await fn(view);
|
|
202
|
+
switch (effect.kind) {
|
|
203
|
+
case "update-row":
|
|
204
|
+
this.rows.set(key, roundTrip(rowCodec, effect.row));
|
|
205
|
+
break;
|
|
206
|
+
case "delete-row":
|
|
207
|
+
this.rows.delete(key);
|
|
208
|
+
break;
|
|
209
|
+
case "ignore":
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
return effect;
|
|
213
|
+
}
|
|
214
|
+
/** A change from source instance `key`: the event, round-tripped through the source's event codec. */
|
|
215
|
+
onChange(key, event, metadata = {}) {
|
|
216
|
+
const wire = roundTrip(this.#registered.eventCodec, event);
|
|
217
|
+
return this.#apply(key, (v) => v.onChange(wire), metadata);
|
|
218
|
+
}
|
|
219
|
+
/** The source instance `key` was deleted. */
|
|
220
|
+
onDelete(key, metadata = {}) {
|
|
221
|
+
return this.#apply(key, (v) => v.onDelete(), metadata);
|
|
222
|
+
}
|
|
223
|
+
get(key) {
|
|
224
|
+
return this.rows.get(key) ?? null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Feeds a consumer messages and collects what it produced. */
|
|
228
|
+
export class ConsumerTestKit {
|
|
229
|
+
#registered;
|
|
230
|
+
#cls;
|
|
231
|
+
#client;
|
|
232
|
+
/** Everything `produce` sent, round-tripped through the out codec. */
|
|
233
|
+
produced = [];
|
|
234
|
+
constructor(cls, client) {
|
|
235
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
236
|
+
this.#cls = cls;
|
|
237
|
+
this.#client = client;
|
|
238
|
+
}
|
|
239
|
+
static of(cls, client = noClient()) {
|
|
240
|
+
return new ConsumerTestKit(cls, client);
|
|
241
|
+
}
|
|
242
|
+
async #apply(fn, metadata) {
|
|
243
|
+
const consumer = new this.#cls();
|
|
244
|
+
consumer._bind(metadata, this.#client.withMetadata(metadata));
|
|
245
|
+
const effect = await fn(consumer);
|
|
246
|
+
if (effect.kind === "produce") {
|
|
247
|
+
const outCodec = this.#registered.outCodec;
|
|
248
|
+
if (!outCodec)
|
|
249
|
+
throw new Error(`${this.#registered.id} produced a message but declares no out shape`);
|
|
250
|
+
this.produced.push({ payload: roundTrip(outCodec, effect.payload), metadata: effect.metadata });
|
|
251
|
+
}
|
|
252
|
+
return effect;
|
|
253
|
+
}
|
|
254
|
+
/** A message from source instance `subject`. */
|
|
255
|
+
onMessage(message, subject = "test", metadata = {}) {
|
|
256
|
+
const wire = roundTrip(this.#registered.messageCodec, message);
|
|
257
|
+
return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, ...metadata });
|
|
258
|
+
}
|
|
259
|
+
onDelete(subject = "test", metadata = {}) {
|
|
260
|
+
return this.#apply((c) => c.onDelete(), { "ce-subject": subject, ...metadata });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Invokes a timed action's handlers as the sweeper would. */
|
|
264
|
+
export class TimedActionTestKit {
|
|
265
|
+
#registered;
|
|
266
|
+
#cls;
|
|
267
|
+
#client;
|
|
268
|
+
constructor(cls, client) {
|
|
269
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
270
|
+
this.#cls = cls;
|
|
271
|
+
this.#client = client;
|
|
272
|
+
}
|
|
273
|
+
static of(cls, client = noClient()) {
|
|
274
|
+
return new TimedActionTestKit(cls, client);
|
|
275
|
+
}
|
|
276
|
+
async invoke(action, input, metadata = {}) {
|
|
277
|
+
const ref = findHandler(this.#registered.actions, action, this.#registered.cls.name);
|
|
278
|
+
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
279
|
+
const instance = new this.#cls();
|
|
280
|
+
instance._bind({ "ankka.timer": "test", "ankka.attempts": "1", ...metadata }, this.#client.withMetadata(metadata));
|
|
281
|
+
return (await ref.run(instance, wireInput));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/** A model that answers from a script and fails loudly when the script runs out. */
|
|
285
|
+
export class ScriptedModel {
|
|
286
|
+
#script = [];
|
|
287
|
+
calls = [];
|
|
288
|
+
expectText(text) {
|
|
289
|
+
this.#script.push({ kind: "text", text });
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
expectToolCall(tool, args = {}) {
|
|
293
|
+
this.#script.push({ kind: "tool-call", tool, argumentsJson: typeof args === "string" ? args : JSON.stringify(args) });
|
|
294
|
+
return this;
|
|
295
|
+
}
|
|
296
|
+
expectRefusal(message) {
|
|
297
|
+
this.#script.push({ kind: "refusal", message });
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
get remaining() {
|
|
301
|
+
return this.#script.length;
|
|
302
|
+
}
|
|
303
|
+
/** @internal */
|
|
304
|
+
_next(call) {
|
|
305
|
+
this.calls.push(call);
|
|
306
|
+
const next = this.#script.shift();
|
|
307
|
+
if (!next)
|
|
308
|
+
throw new Error(`the scripted model ran out of responses after ${this.calls.length} call(s); script more with expectText/expectToolCall/expectRefusal`);
|
|
309
|
+
return next;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
|
|
314
|
+
* (scripted), tool calls back into the agent, output guardrails, and the session's history.
|
|
315
|
+
*/
|
|
316
|
+
export class AgentTestKit {
|
|
317
|
+
#registered;
|
|
318
|
+
#cls;
|
|
319
|
+
#client;
|
|
320
|
+
#model;
|
|
321
|
+
sessionId;
|
|
322
|
+
/** The session's memory: what the user said and the agent answered, turn after turn. */
|
|
323
|
+
history = [];
|
|
324
|
+
constructor(cls, sessionId, model, client) {
|
|
325
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
326
|
+
this.#cls = cls;
|
|
327
|
+
this.#client = client;
|
|
328
|
+
this.#model = model;
|
|
329
|
+
this.sessionId = sessionId;
|
|
330
|
+
}
|
|
331
|
+
static of(cls, sessionId, model, client = noClient()) {
|
|
332
|
+
return new AgentTestKit(cls, sessionId, model, client);
|
|
333
|
+
}
|
|
334
|
+
#agent() {
|
|
335
|
+
const a = new this.#cls();
|
|
336
|
+
a._bind(this.sessionId, {}, this.#client);
|
|
337
|
+
return a;
|
|
338
|
+
}
|
|
339
|
+
/** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
|
|
340
|
+
async ask(handler, input) {
|
|
341
|
+
const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
|
|
342
|
+
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
343
|
+
const agent = this.#agent();
|
|
344
|
+
const plan = (await ref.run(agent, wireInput));
|
|
345
|
+
if (plan?.kind !== "agent")
|
|
346
|
+
throw new Error(`${this.#registered.id}/${ref.name} returned something that is not an agent effect`);
|
|
347
|
+
const problem = checkPlan(this.#registered, plan);
|
|
348
|
+
if (problem)
|
|
349
|
+
throw new Error(problem);
|
|
350
|
+
if (plan.failure)
|
|
351
|
+
throw new CommandError(plan.failure);
|
|
352
|
+
const guard = async (stage, text) => {
|
|
353
|
+
for (const name of plan.guardrailNames) {
|
|
354
|
+
const reason = await this.#registered.guardrails.get(name).check(stage, text);
|
|
355
|
+
if (reason)
|
|
356
|
+
throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const user = [...plan.context, plan.user ?? ""].filter((t) => t !== "").join("\n\n");
|
|
360
|
+
await guard("input", user);
|
|
361
|
+
const messages = plan.sessionMemory ? this.history.map((h) => ({ ...h })) : [];
|
|
362
|
+
messages.push({ role: "user", text: user });
|
|
363
|
+
for (let step = 0; step <= this.#registered.maxToolCallSteps; step++) {
|
|
364
|
+
const answer = this.#model._next({ system: plan.system, messages: [...messages], tools: [...plan.toolNames] });
|
|
365
|
+
switch (answer.kind) {
|
|
366
|
+
case "refusal":
|
|
367
|
+
throw new CommandError({ message: answer.message, code: ErrorCode.BadRequest });
|
|
368
|
+
case "tool-call": {
|
|
369
|
+
if (!plan.toolNames.includes(answer.tool))
|
|
370
|
+
throw new Error(`the model called ${JSON.stringify(answer.tool)}, which the plan did not offer`);
|
|
371
|
+
let result;
|
|
372
|
+
try {
|
|
373
|
+
result = await runTool(this.#registered, this.#agent(), answer.tool, answer.argumentsJson);
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
result = `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
377
|
+
}
|
|
378
|
+
messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` }, { role: "tool", text: result });
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "text": {
|
|
382
|
+
await guard("output", answer.text);
|
|
383
|
+
if (plan.sessionMemory)
|
|
384
|
+
this.history.push({ role: "user", text: plan.user ?? "" }, { role: "assistant", text: answer.text });
|
|
385
|
+
return answer.text;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw new CommandError({ message: `the agent made more than ${this.#registered.maxToolCallSteps} tool calls`, code: ErrorCode.Internal });
|
|
390
|
+
}
|
|
391
|
+
/** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
|
|
392
|
+
async *stream(handler, input) {
|
|
393
|
+
const text = await this.ask(handler, input);
|
|
394
|
+
const parts = text.split(/(?<= )/);
|
|
395
|
+
for (const part of parts)
|
|
396
|
+
yield part;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ComponentClient } from "../client.ts";
|
|
2
|
+
import { type Principal } from "../context.ts";
|
|
3
|
+
import type { ErrorDetail, Metadata, Retention } from "../effects/common.ts";
|
|
4
|
+
import type { Endpoint, EndpointClass } from "../endpoint.ts";
|
|
5
|
+
import { EventSourcedEntity, type EventSourcedEntityClass } from "../eventSourcedEntity.ts";
|
|
6
|
+
import type { HandlerRef } from "../handlers.ts";
|
|
7
|
+
import { type Materialised } from "../materialise.ts";
|
|
8
|
+
export type { Materialised };
|
|
9
|
+
/** Drives one event sourced entity instance through its handlers, folding events as it goes. */
|
|
10
|
+
export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E>> {
|
|
11
|
+
#private;
|
|
12
|
+
readonly entityId: string;
|
|
13
|
+
/** The state now, after every call so far. */
|
|
14
|
+
state: S;
|
|
15
|
+
/** The sequence number of the last event, `0n` before any. */
|
|
16
|
+
sequence: bigint;
|
|
17
|
+
/** Every event persisted so far, in order. */
|
|
18
|
+
readonly allEvents: E[];
|
|
19
|
+
private constructor();
|
|
20
|
+
/** A kit for `cls` as instance `entityId`. Calls to other components reach `client`, which by default refuses them. */
|
|
21
|
+
static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient): EventSourcedTestKit<S, E, C>;
|
|
22
|
+
/** Sends a command or query and returns what its effect amounts to. */
|
|
23
|
+
call<I, R>(handler: HandlerRef<C, I, R, any> | string, input?: I, metadata?: Metadata): Promise<Materialised<S, E, R>>;
|
|
24
|
+
}
|
|
25
|
+
/** A reply from the endpoint testkit. */
|
|
26
|
+
export declare class Response {
|
|
27
|
+
readonly status: number;
|
|
28
|
+
readonly contentType: string;
|
|
29
|
+
readonly body: Uint8Array;
|
|
30
|
+
constructor(status: number, contentType: string, body: Uint8Array);
|
|
31
|
+
text(): string;
|
|
32
|
+
json(): unknown;
|
|
33
|
+
}
|
|
34
|
+
export interface RequestOptions {
|
|
35
|
+
readonly query?: Readonly<Record<string, string | readonly string[]>>;
|
|
36
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
37
|
+
readonly principal?: Principal;
|
|
38
|
+
readonly metadata?: Metadata;
|
|
39
|
+
}
|
|
40
|
+
/** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
|
|
41
|
+
export declare class EndpointTestKit<C extends Endpoint> {
|
|
42
|
+
#private;
|
|
43
|
+
private constructor();
|
|
44
|
+
static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
|
|
45
|
+
request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
46
|
+
/** The frames of an SSE route, each string one frame. */
|
|
47
|
+
sse(path: string, options?: RequestOptions): Promise<string[]>;
|
|
48
|
+
get(path: string, options?: RequestOptions): Promise<Response>;
|
|
49
|
+
post(path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
50
|
+
put(path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
51
|
+
patch(path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
52
|
+
delete(path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
53
|
+
}
|
|
54
|
+
export type { ErrorDetail, Retention };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The unit testkits: one component, no sidecar, no database, no network. Effects come back as values.
|
|
2
|
+
// Every input, event, state and reply is encoded and decoded through the component's own codecs on the
|
|
3
|
+
// way in and out, so a shape the codec cannot express fails here rather than on first deployment. The
|
|
4
|
+
// reduction is the server's (`materialise.ts`), so a test cannot pass on something the sidecar would
|
|
5
|
+
// not do.
|
|
6
|
+
import { create } from "@bufbuild/protobuf";
|
|
7
|
+
import { HttpRequestSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
|
|
8
|
+
import { ComponentClient, noClient } from "../client.js";
|
|
9
|
+
import { binaryCodecs, codecFor } from "../codec.js";
|
|
10
|
+
import { commandContext } from "../context.js";
|
|
11
|
+
import { EventSourcedEntity } from "../eventSourcedEntity.js";
|
|
12
|
+
import { materialiseEventSourced } from "../materialise.js";
|
|
13
|
+
import { Ankka } from "../service.js";
|
|
14
|
+
import { createHttpDispatcher } from "../server/http.js";
|
|
15
|
+
function roundTrip(codec, value) {
|
|
16
|
+
return codec.decode(codec.encode(value));
|
|
17
|
+
}
|
|
18
|
+
function findHandler(handlers, handler, who) {
|
|
19
|
+
const name = typeof handler === "string" ? handler : handler.name;
|
|
20
|
+
const found = handlers.get(name);
|
|
21
|
+
if (!found)
|
|
22
|
+
throw new Error(`${who} has no handler ${JSON.stringify(name)}; declared: ${[...handlers.keys()].sort().join(", ")}`);
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
/** Drives one event sourced entity instance through its handlers, folding events as it goes. */
|
|
26
|
+
export class EventSourcedTestKit {
|
|
27
|
+
#registered;
|
|
28
|
+
#entity;
|
|
29
|
+
#client;
|
|
30
|
+
entityId;
|
|
31
|
+
/** The state now, after every call so far. */
|
|
32
|
+
state;
|
|
33
|
+
/** The sequence number of the last event, `0n` before any. */
|
|
34
|
+
sequence = 0n;
|
|
35
|
+
/** Every event persisted so far, in order. */
|
|
36
|
+
allEvents = [];
|
|
37
|
+
constructor(cls, entityId, client) {
|
|
38
|
+
const registry = Ankka.service({ client, log: () => { } })
|
|
39
|
+
.register(cls)
|
|
40
|
+
.validate();
|
|
41
|
+
this.#registered = registry.component(cls.componentId);
|
|
42
|
+
this.#entity = new cls();
|
|
43
|
+
this.#entity._bindInstance(entityId);
|
|
44
|
+
this.#client = client;
|
|
45
|
+
this.entityId = entityId;
|
|
46
|
+
this.state = this.#entity.emptyState();
|
|
47
|
+
}
|
|
48
|
+
/** A kit for `cls` as instance `entityId`. Calls to other components reach `client`, which by default refuses them. */
|
|
49
|
+
static of(cls, entityId = "test", client = noClient()) {
|
|
50
|
+
return new EventSourcedTestKit(cls, entityId, client);
|
|
51
|
+
}
|
|
52
|
+
/** Sends a command or query and returns what its effect amounts to. */
|
|
53
|
+
async call(handler, input, metadata = {}) {
|
|
54
|
+
const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
|
|
55
|
+
const inputCodec = ref.input ? codecFor(ref.input) : undefined;
|
|
56
|
+
const wireInput = inputCodec ? roundTrip(inputCodec, input) : undefined;
|
|
57
|
+
const context = commandContext(this.#registered.id, this.entityId, this.sequence, metadata);
|
|
58
|
+
this.#entity._bindCommand(this.state, context, this.#client.withMetadata(metadata));
|
|
59
|
+
let effect;
|
|
60
|
+
try {
|
|
61
|
+
effect = (await ref.run(this.#entity, wireInput));
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
this.#entity._unbindCommand();
|
|
65
|
+
}
|
|
66
|
+
if (ref.readOnly && effect.kind !== "read-only")
|
|
67
|
+
throw new Error(`${this.#registered.id}/${ref.name} is a query and returned a persisting effect`);
|
|
68
|
+
const m = materialiseEventSourced(effect, this.state, (s, e) => this.#entity.applyEvent(s, e));
|
|
69
|
+
const eventCodec = this.#registered.eventCodec;
|
|
70
|
+
const stateCodec = this.#registered.stateCodec;
|
|
71
|
+
const events = m.events.map((e) => roundTrip(eventCodec, e));
|
|
72
|
+
if (m.error)
|
|
73
|
+
return Object.freeze({ events: [], newState: this.state, retention: m.retention, reply: undefined, error: m.error, noReply: false });
|
|
74
|
+
const newState = roundTrip(stateCodec, m.newState);
|
|
75
|
+
const replyCodec = (ref.reply ? codecFor(ref.reply) : binaryCodecs.done);
|
|
76
|
+
const reply = m.noReply ? undefined : roundTrip(replyCodec, m.reply);
|
|
77
|
+
this.state = newState;
|
|
78
|
+
this.sequence += BigInt(events.length);
|
|
79
|
+
this.allEvents.push(...events);
|
|
80
|
+
return Object.freeze({ events, newState, retention: m.retention, reply, error: undefined, noReply: m.noReply });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** A reply from the endpoint testkit. */
|
|
84
|
+
export class Response {
|
|
85
|
+
status;
|
|
86
|
+
contentType;
|
|
87
|
+
body;
|
|
88
|
+
constructor(status, contentType, body) {
|
|
89
|
+
this.status = status;
|
|
90
|
+
this.contentType = contentType;
|
|
91
|
+
this.body = body;
|
|
92
|
+
Object.freeze(this);
|
|
93
|
+
}
|
|
94
|
+
text() {
|
|
95
|
+
return new TextDecoder().decode(this.body);
|
|
96
|
+
}
|
|
97
|
+
json() {
|
|
98
|
+
return JSON.parse(this.text());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const PLACEHOLDER = /\{[A-Za-z_][A-Za-z0-9_]*\}/g;
|
|
102
|
+
/** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
|
|
103
|
+
export class EndpointTestKit {
|
|
104
|
+
#cls;
|
|
105
|
+
#dispatcher;
|
|
106
|
+
constructor(cls, client) {
|
|
107
|
+
this.#cls = cls;
|
|
108
|
+
const registry = Ankka.service({ client, log: () => { } })
|
|
109
|
+
.register(cls)
|
|
110
|
+
.validate();
|
|
111
|
+
this.#dispatcher = createHttpDispatcher({ registry, client, log: () => { } });
|
|
112
|
+
}
|
|
113
|
+
static of(cls, client = noClient()) {
|
|
114
|
+
return new EndpointTestKit(cls, client);
|
|
115
|
+
}
|
|
116
|
+
#match(method, path) {
|
|
117
|
+
const prefix = this.#cls.prefix;
|
|
118
|
+
const withoutQuery = path.split("?")[0];
|
|
119
|
+
const rest = withoutQuery.startsWith(prefix) ? withoutQuery.slice(prefix.length) || "/" : withoutQuery;
|
|
120
|
+
const candidates = [];
|
|
121
|
+
for (const [id, route] of Object.entries(this.#cls.routes)) {
|
|
122
|
+
if (route.method !== method.toUpperCase())
|
|
123
|
+
continue;
|
|
124
|
+
const pattern = new RegExp("^" + route.template.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(PLACEHOLDER, "([^/]+)") + "$");
|
|
125
|
+
const m = pattern.exec(rest);
|
|
126
|
+
if (!m)
|
|
127
|
+
continue;
|
|
128
|
+
const literals = route.template.split("/").filter((seg) => seg && !seg.startsWith("{")).length;
|
|
129
|
+
candidates.push({ literals, id, route, args: m.slice(1).map(decodeURIComponent) });
|
|
130
|
+
}
|
|
131
|
+
candidates.sort((a, b) => b.literals - a.literals);
|
|
132
|
+
return candidates[0];
|
|
133
|
+
}
|
|
134
|
+
#request(method, path, body, options) {
|
|
135
|
+
const matched = this.#match(method, path);
|
|
136
|
+
if (!matched)
|
|
137
|
+
return undefined;
|
|
138
|
+
let bytes = new Uint8Array();
|
|
139
|
+
if (body !== undefined && body !== null) {
|
|
140
|
+
if (body instanceof Uint8Array)
|
|
141
|
+
bytes = new Uint8Array(body);
|
|
142
|
+
else if (typeof body === "string")
|
|
143
|
+
bytes = new TextEncoder().encode(body);
|
|
144
|
+
else if (matched.route.body)
|
|
145
|
+
bytes = codecFor(matched.route.body).encode(body);
|
|
146
|
+
else
|
|
147
|
+
bytes = new TextEncoder().encode(JSON.stringify(body));
|
|
148
|
+
}
|
|
149
|
+
const query = [];
|
|
150
|
+
const fromPath = path.includes("?") ? new URLSearchParams(path.slice(path.indexOf("?") + 1)) : undefined;
|
|
151
|
+
for (const [name, value] of fromPath ?? [])
|
|
152
|
+
query.push({ name, value });
|
|
153
|
+
for (const [name, value] of Object.entries(options.query ?? {})) {
|
|
154
|
+
for (const v of typeof value === "string" ? [value] : value)
|
|
155
|
+
query.push({ name, value: v });
|
|
156
|
+
}
|
|
157
|
+
const headers = Object.entries(options.headers ?? {}).map(([name, value]) => ({ name, value }));
|
|
158
|
+
const principal = options.principal
|
|
159
|
+
? { subject: options.principal.subject, name: options.principal.name ?? undefined, email: options.principal.email ?? undefined, emailVerified: options.principal.emailVerified, roles: [...options.principal.roles] }
|
|
160
|
+
: undefined;
|
|
161
|
+
const req = create(HttpRequestSchema, {
|
|
162
|
+
endpointId: this.#cls.name,
|
|
163
|
+
routeId: matched.id,
|
|
164
|
+
pathArgs: matched.args,
|
|
165
|
+
query,
|
|
166
|
+
headers,
|
|
167
|
+
contentType: matched.route.body ? codecFor(matched.route.body).contentType : "",
|
|
168
|
+
body: bytes,
|
|
169
|
+
principal,
|
|
170
|
+
metadata: { entries: Object.entries(options.metadata ?? {}).map(([key, value]) => ({ key, value })) },
|
|
171
|
+
});
|
|
172
|
+
return { req, route: matched.route };
|
|
173
|
+
}
|
|
174
|
+
async request(method, path, body, options = {}) {
|
|
175
|
+
const prepared = this.#request(method, path, body, options);
|
|
176
|
+
if (!prepared)
|
|
177
|
+
return new Response(404, "text/plain", new TextEncoder().encode(`no route ${method.toUpperCase()} ${path}`));
|
|
178
|
+
if (prepared.route.streaming) {
|
|
179
|
+
const frames = [];
|
|
180
|
+
for await (const f of this.#dispatcher.handleStream(prepared.req)) {
|
|
181
|
+
if (f.frame.case === "text")
|
|
182
|
+
frames.push(f.frame.value);
|
|
183
|
+
if (f.frame.case === "failed")
|
|
184
|
+
return new Response(500, "text/plain", new TextEncoder().encode(f.frame.value.message));
|
|
185
|
+
}
|
|
186
|
+
return new Response(200, "text/event-stream", new TextEncoder().encode(frames.join("\n")));
|
|
187
|
+
}
|
|
188
|
+
const reply = await this.#dispatcher.handle(prepared.req);
|
|
189
|
+
if (reply.message.case === "response") {
|
|
190
|
+
const r = reply.message.value;
|
|
191
|
+
return new Response(r.status, r.contentType, r.body);
|
|
192
|
+
}
|
|
193
|
+
return new Response(500, "text/plain", new TextEncoder().encode(reply.message.case === "failure" ? reply.message.value.error?.message ?? "failure" : "no reply"));
|
|
194
|
+
}
|
|
195
|
+
/** The frames of an SSE route, each string one frame. */
|
|
196
|
+
async sse(path, options = {}) {
|
|
197
|
+
const prepared = this.#request("GET", path, undefined, options);
|
|
198
|
+
if (!prepared)
|
|
199
|
+
throw new Error(`no route GET ${path}`);
|
|
200
|
+
const frames = [];
|
|
201
|
+
for await (const f of this.#dispatcher.handleStream(prepared.req)) {
|
|
202
|
+
if (f.frame.case === "text")
|
|
203
|
+
frames.push(f.frame.value);
|
|
204
|
+
if (f.frame.case === "failed")
|
|
205
|
+
throw new Error(f.frame.value.message);
|
|
206
|
+
}
|
|
207
|
+
return frames;
|
|
208
|
+
}
|
|
209
|
+
get(path, options) {
|
|
210
|
+
return this.request("GET", path, undefined, options);
|
|
211
|
+
}
|
|
212
|
+
post(path, body, options) {
|
|
213
|
+
return this.request("POST", path, body, options);
|
|
214
|
+
}
|
|
215
|
+
put(path, body, options) {
|
|
216
|
+
return this.request("PUT", path, body, options);
|
|
217
|
+
}
|
|
218
|
+
patch(path, body, options) {
|
|
219
|
+
return this.request("PATCH", path, body, options);
|
|
220
|
+
}
|
|
221
|
+
delete(path, body, options) {
|
|
222
|
+
return this.request("DELETE", path, body, options);
|
|
223
|
+
}
|
|
224
|
+
}
|