ankka 0.10.0 → 0.12.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.
Files changed (94) hide show
  1. package/README.md +1 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  3. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  4. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +858 -2
  5. package/dist/_proto/ankka/protocol/v1/client_pb.js +184 -9
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
  7. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +373 -0
  9. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +210 -0
  11. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +35 -5
  12. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
  13. package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
  14. package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
  15. package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
  16. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +70 -1
  17. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  18. package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
  19. package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
  20. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  21. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  22. package/dist/agent.d.ts +15 -1
  23. package/dist/agent.js +20 -0
  24. package/dist/approvals.d.ts +47 -0
  25. package/dist/approvals.js +57 -0
  26. package/dist/autonomous.d.ts +23 -1
  27. package/dist/autonomous.js +30 -1
  28. package/dist/client.d.ts +188 -7
  29. package/dist/client.js +490 -21
  30. package/dist/consumer.d.ts +42 -3
  31. package/dist/consumer.js +35 -1
  32. package/dist/context.d.ts +29 -1
  33. package/dist/context.js +5 -2
  34. package/dist/contract.d.ts +40 -0
  35. package/dist/contract.js +60 -0
  36. package/dist/effects/common.d.ts +7 -0
  37. package/dist/effects/common.js +9 -1
  38. package/dist/effects/eventSourced.d.ts +26 -1
  39. package/dist/effects/eventSourced.js +58 -5
  40. package/dist/effects/keyed.d.ts +33 -0
  41. package/dist/effects/keyed.js +41 -0
  42. package/dist/endpoint.d.ts +10 -1
  43. package/dist/endpoint.js +20 -0
  44. package/dist/eventSourcedEntity.d.ts +8 -3
  45. package/dist/graph.d.ts +24 -2
  46. package/dist/graph.js +27 -1
  47. package/dist/handlers.d.ts +10 -2
  48. package/dist/handlers.js +6 -2
  49. package/dist/index.d.ts +15 -6
  50. package/dist/index.js +12 -3
  51. package/dist/keyedView.d.ts +59 -0
  52. package/dist/keyedView.js +73 -0
  53. package/dist/kinds.js +2 -0
  54. package/dist/mcp.d.ts +57 -0
  55. package/dist/mcp.js +64 -0
  56. package/dist/routes.d.ts +44 -4
  57. package/dist/routes.js +43 -4
  58. package/dist/server/agent.js +12 -4
  59. package/dist/server/discovery.d.ts +17 -1
  60. package/dist/server/discovery.js +182 -1
  61. package/dist/server/eventSourced.d.ts +4 -1
  62. package/dist/server/eventSourced.js +24 -6
  63. package/dist/server/http.d.ts +2 -1
  64. package/dist/server/http.js +80 -4
  65. package/dist/server/stateless.js +42 -3
  66. package/dist/server/workflow.js +5 -1
  67. package/dist/service.d.ts +46 -2
  68. package/dist/service.js +235 -15
  69. package/dist/services.d.ts +137 -0
  70. package/dist/services.js +259 -0
  71. package/dist/socket.d.ts +15 -0
  72. package/dist/socket.js +25 -0
  73. package/dist/spec.d.ts +1 -1
  74. package/dist/spec.js +94 -15
  75. package/dist/standing.d.ts +18 -0
  76. package/dist/standing.js +26 -0
  77. package/dist/startFrom.d.ts +36 -0
  78. package/dist/startFrom.js +51 -0
  79. package/dist/testkit/index.d.ts +2 -2
  80. package/dist/testkit/index.js +1 -1
  81. package/dist/testkit/integration.js +5 -0
  82. package/dist/testkit/kinds.d.ts +50 -5
  83. package/dist/testkit/kinds.js +208 -35
  84. package/dist/testkit/unit.d.ts +41 -3
  85. package/dist/testkit/unit.js +87 -10
  86. package/dist/timedAction.d.ts +17 -2
  87. package/dist/timedAction.js +33 -1
  88. package/dist/version.d.ts +1 -1
  89. package/dist/version.js +1 -1
  90. package/dist/view.d.ts +48 -1
  91. package/dist/view.js +18 -1
  92. package/dist/workflow.d.ts +18 -1
  93. package/dist/workflow.js +39 -0
  94. package/package.json +3 -2
@@ -12,6 +12,7 @@
12
12
  // pulls it on first use — and an unreleased one (version `0.0.0`, a checkout of the ankka repository) uses
13
13
  // `ankka-sidecar:latest`, the image `sbt sidecar/Docker/publishLocal` builds.
14
14
  import { execFileSync } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
16
17
  import { tmpdir } from "node:os";
17
18
  import { join } from "node:path";
@@ -220,6 +221,9 @@ export class AnkkaTestKit {
220
221
  ANKKA_DB_NAME: "ankka",
221
222
  ANKKA_DB_USER: "ankka",
222
223
  ANKKA_DB_PASSWORD: "ankka",
224
+ // A fresh secret key per kit, so the secret store works with no setup; the caller's `env`
225
+ // replaces it, and `ANKKA_SECRET_KEY: ""` starts a service with none.
226
+ ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
223
227
  ...this.#env,
224
228
  })
225
229
  .withExposedPorts(HTTP_PORT, CALLBACK_PORT)
@@ -254,6 +258,7 @@ export class AnkkaTestKit {
254
258
  ANKKA_DB_NAME: "ankka",
255
259
  ANKKA_DB_USER: "ankka",
256
260
  ANKKA_DB_PASSWORD: "ankka",
261
+ ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
257
262
  ...env,
258
263
  })
259
264
  .withExposedPorts(httpPort)
@@ -5,12 +5,17 @@ import type { ConsumerEffect, TimedActionEffect, ViewEffect } from "../effects/s
5
5
  import type { KeyValueEntity, KeyValueEntityClass } from "../keyValueEntity.ts";
6
6
  import type { Workflow, WorkflowClass } from "../workflow.ts";
7
7
  import type { View, ViewClass } from "../view.ts";
8
+ import type { KeyedView, KeyedViewClass } from "../keyedView.ts";
9
+ import { type KeyedViewEffect } from "../effects/keyed.ts";
10
+ import type { ComponentRef } from "../client.ts";
8
11
  import { type Consumer, type ConsumerClass } from "../consumer.ts";
9
12
  import { type Delta, type GraphConsumerClass } from "../graph.ts";
10
13
  import type { TimedAction, TimedActionClass } from "../timedAction.ts";
11
14
  import type { Agent, AgentClass } from "../agent.ts";
12
15
  import type { HandlerRef } from "../handlers.ts";
13
16
  import { type MaterialisedKeyValue, type MaterialisedStep, type MaterialisedWorkflowCommand } from "../materialise.ts";
17
+ import { type AgentOutcome, type DecisionInput } from "../approvals.ts";
18
+ import type { Standing } from "../standing.ts";
14
19
  /** Drives one key value entity instance through its handlers. */
15
20
  export declare class KeyValueTestKit<S, C extends KeyValueEntity<S>> {
16
21
  #private;
@@ -54,12 +59,37 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
54
59
  readonly rows: Map<string, Row>;
55
60
  private constructor();
56
61
  static of<E, Row, C extends View<E, Row>>(cls: ViewClass<E, Row, C>, client?: ComponentClient): ViewTestKit<E, Row, C>;
57
- /** A change from source instance `key`: the event, round-tripped through the source's event codec. */
58
- onChange(key: string, event: E, metadata?: Metadata): Promise<ViewEffect<Row>>;
62
+ /**
63
+ * A change from source instance `key`: the event, round-tripped through the source's event codec.
64
+ * `standing` is a workflow's, for a view whose source is one.
65
+ */
66
+ onChange(key: string, event: E, metadata?: Metadata, standing?: Standing): Promise<ViewEffect<Row>>;
59
67
  /** The source instance `key` was deleted. */
60
68
  onDelete(key: string, metadata?: Metadata): Promise<ViewEffect<Row>>;
61
69
  get(key: string): Row | null;
62
70
  }
71
+ /**
72
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
73
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
74
+ * them. Rows round-trip through the view's row codec and events through the source's.
75
+ *
76
+ * A declared query is SQL and there is no database to run it, so the test says what each query
77
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
78
+ * no rows would pass a handler that updated nothing.
79
+ */
80
+ export declare class KeyedViewTestKit<Row, C extends KeyedView<Row>> {
81
+ #private;
82
+ readonly rows: Map<string, Row>;
83
+ private constructor();
84
+ static of<Row, C extends KeyedView<Row>>(cls: KeyedViewClass<Row, C>, client?: ComponentClient): KeyedViewTestKit<Row, C>;
85
+ /** What the declared query `name` answers, given the values it is asked with. */
86
+ answering(name: string, answer: (values: Readonly<Record<string, string>>) => Row[]): this;
87
+ /** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
88
+ change<E>(entity: ComponentRef, key: string, event: E, standing?: Standing): Promise<KeyedViewEffect<Row>>;
89
+ /** The entity `key` of `entity` was deleted. */
90
+ deleted(entity: ComponentRef, key: string): Promise<KeyedViewEffect<Row>>;
91
+ get(key: string): Row | null;
92
+ }
63
93
  /** Feeds a consumer messages and collects what it produced. */
64
94
  export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
65
95
  #private;
@@ -78,7 +108,7 @@ export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
78
108
  * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
79
109
  * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
80
110
  */
81
- onMessage(message: M, subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
111
+ onMessage(message: M, subject?: string, metadata?: Metadata, standing?: Standing): Promise<ConsumerEffect<Out>>;
82
112
  onDelete(subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
83
113
  }
84
114
  /**
@@ -141,9 +171,15 @@ export declare class ScriptedModel {
141
171
  /** @internal */
142
172
  _next(call: ModelCall): ModelResponse;
143
173
  }
174
+ /** Scripted MCP servers: `{ server: { tool: (args) => text } }`, called by the model as `mcp__<server>__<tool>`. */
175
+ export type ScriptedMcp = Readonly<Record<string, Readonly<Record<string, (args: Record<string, unknown>) => string | Promise<string>>>>>;
144
176
  /**
145
177
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
146
178
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
179
+ *
180
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
181
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
182
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
147
183
  */
148
184
  export declare class AgentTestKit<C extends Agent> {
149
185
  #private;
@@ -154,9 +190,18 @@ export declare class AgentTestKit<C extends Agent> {
154
190
  text: string;
155
191
  }[];
156
192
  private constructor();
157
- static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient): AgentTestKit<C>;
158
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
193
+ static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient, options?: {
194
+ readonly mcp?: ScriptedMcp;
195
+ }): AgentTestKit<C>;
196
+ /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
159
197
  ask<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<string>;
198
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
199
+ outcome<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<AgentOutcome<string>>;
200
+ /**
201
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
202
+ * model is told its result; refused, the model is told who refused it and why.
203
+ */
204
+ decide(approvalId: string, decision: DecisionInput): Promise<AgentOutcome<string>>;
160
205
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
161
206
  stream<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): AsyncIterable<string>;
162
207
  }
@@ -5,12 +5,15 @@ import { ComponentClient, noClient } from "../client.js";
5
5
  import { binaryCodecs, codecFor } from "../codec.js";
6
6
  import { commandContext } from "../context.js";
7
7
  import { CommandError, ErrorCode } from "../effects/common.js";
8
+ import { reduceRowChanges } from "../effects/keyed.js";
8
9
  import { PROTOCOL_KEY, requireSeveralMessages } from "../consumer.js";
9
10
  import { deltaRecords, readDelta } from "../graph.js";
10
11
  import { PROTOCOL_VERSION } from "../spec.js";
11
12
  import { materialiseKeyValue, materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
12
13
  import { Ankka } from "../service.js";
13
14
  import { checkPlan, runTool } from "../server/agent.js";
15
+ import { ApprovalAwaited } from "../approvals.js";
16
+ import { TOOL_PREFIX, toolName } from "../mcp.js";
14
17
  function roundTrip(codec, value) {
15
18
  return codec.decode(codec.encode(value));
16
19
  }
@@ -196,11 +199,11 @@ export class ViewTestKit {
196
199
  static of(cls, client = noClient()) {
197
200
  return new ViewTestKit(cls, client);
198
201
  }
199
- async #apply(key, fn, metadata) {
202
+ async #apply(key, fn, metadata, standing) {
200
203
  const view = new this.#cls();
201
204
  const rowCodec = this.#registered.rowCodec;
202
205
  const current = this.rows.get(key);
203
- view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata));
206
+ view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata), standing);
204
207
  const effect = await fn(view);
205
208
  switch (effect.kind) {
206
209
  case "update-row":
@@ -214,10 +217,13 @@ export class ViewTestKit {
214
217
  }
215
218
  return effect;
216
219
  }
217
- /** A change from source instance `key`: the event, round-tripped through the source's event codec. */
218
- onChange(key, event, metadata = {}) {
220
+ /**
221
+ * A change from source instance `key`: the event, round-tripped through the source's event codec.
222
+ * `standing` is a workflow's, for a view whose source is one.
223
+ */
224
+ onChange(key, event, metadata = {}, standing) {
219
225
  const wire = roundTrip(this.#registered.eventCodec, event);
220
- return this.#apply(key, (v) => v.onChange(wire), metadata);
226
+ return this.#apply(key, (v) => v.onChange(wire), metadata, standing);
221
227
  }
222
228
  /** The source instance `key` was deleted. */
223
229
  onDelete(key, metadata = {}) {
@@ -227,6 +233,86 @@ export class ViewTestKit {
227
233
  return this.rows.get(key) ?? null;
228
234
  }
229
235
  }
236
+ /**
237
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
238
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
239
+ * them. Rows round-trip through the view's row codec and events through the source's.
240
+ *
241
+ * A declared query is SQL and there is no database to run it, so the test says what each query
242
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
243
+ * no rows would pass a handler that updated nothing.
244
+ */
245
+ export class KeyedViewTestKit {
246
+ #registered;
247
+ #cls;
248
+ #client;
249
+ #answers = new Map();
250
+ #sequence = 0;
251
+ rows = new Map();
252
+ constructor(cls, client) {
253
+ this.#registered = registryFor(cls, client).component(cls.componentId);
254
+ this.#cls = cls;
255
+ this.#client = client;
256
+ }
257
+ static of(cls, client = noClient()) {
258
+ return new KeyedViewTestKit(cls, client);
259
+ }
260
+ /** What the declared query `name` answers, given the values it is asked with. */
261
+ answering(name, answer) {
262
+ this.#answers.set(name, answer);
263
+ return this;
264
+ }
265
+ #source(entity) {
266
+ const source = this.#registered.sources.get(entity.componentId);
267
+ if (!source)
268
+ throw new Error(`${this.#registered.id} reads no ${JSON.stringify(entity.componentId)}`);
269
+ return source;
270
+ }
271
+ async #apply(key, run, standing) {
272
+ const view = new this.#cls();
273
+ const rowCodec = this.#registered.rowCodec;
274
+ const id = this.#registered.id;
275
+ const rows = {
276
+ get: async (k) => {
277
+ const row = this.rows.get(k);
278
+ return row === undefined ? null : roundTrip(rowCodec, row);
279
+ },
280
+ ask: async (name, values = {}) => {
281
+ const answer = this.#answers.get(name);
282
+ if (!answer)
283
+ throw new Error(`${id} asked the query ${JSON.stringify(name)}, and the test has not said what it answers; say so with answering(${JSON.stringify(name)}, ...)`);
284
+ return answer(values).map((r) => roundTrip(rowCodec, r));
285
+ },
286
+ };
287
+ this.#sequence += 1;
288
+ const metadata = { "ce-subject": key, "ankka.sequence": String(this.#sequence) };
289
+ view._bind(metadata, this.#client.withMetadata(metadata), rows, standing);
290
+ const effect = ((await run(view)) ?? { kind: "rows", changes: [] });
291
+ if (effect.kind !== "rows")
292
+ throw new TypeError(`${id}'s handler returned something that is not a keyed view effect`);
293
+ for (const [k, row] of reduceRowChanges(effect.changes)) {
294
+ if (row === undefined)
295
+ this.rows.delete(k);
296
+ else
297
+ this.rows.set(k, roundTrip(rowCodec, row));
298
+ }
299
+ return effect;
300
+ }
301
+ /** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
302
+ change(entity, key, event, standing) {
303
+ const source = this.#source(entity);
304
+ const wire = roundTrip(source.eventCodec, event);
305
+ return this.#apply(key, (v) => source.onChange(v, wire), standing);
306
+ }
307
+ /** The entity `key` of `entity` was deleted. */
308
+ deleted(entity, key) {
309
+ const source = this.#source(entity);
310
+ return this.#apply(key, (v) => (source.deleted ? source.deleted(v) : undefined));
311
+ }
312
+ get(key) {
313
+ return this.rows.get(key) ?? null;
314
+ }
315
+ }
230
316
  /** Feeds a consumer messages and collects what it produced. */
231
317
  export class ConsumerTestKit {
232
318
  #registered;
@@ -245,9 +331,9 @@ export class ConsumerTestKit {
245
331
  static of(cls, client = noClient()) {
246
332
  return new ConsumerTestKit(cls, client);
247
333
  }
248
- async #apply(fn, metadata) {
334
+ async #apply(fn, metadata, standing) {
249
335
  const consumer = new this.#cls();
250
- consumer._bind(metadata, this.#client.withMetadata(metadata));
336
+ consumer._bind(metadata, this.#client.withMetadata(metadata), standing);
251
337
  const effect = await fn(consumer);
252
338
  const outCodec = () => {
253
339
  const codec = this.#registered.outCodec;
@@ -271,9 +357,9 @@ export class ConsumerTestKit {
271
357
  * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
272
358
  * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
273
359
  */
274
- onMessage(message, subject = "test", metadata = {}) {
360
+ onMessage(message, subject = "test", metadata = {}, standing) {
275
361
  const wire = roundTrip(this.#registered.messageCodec, message);
276
- return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
362
+ return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata }, standing);
277
363
  }
278
364
  onDelete(subject = "test", metadata = {}) {
279
365
  return this.#apply((c) => c.onDelete(), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
@@ -372,32 +458,50 @@ export class ScriptedModel {
372
458
  /**
373
459
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
374
460
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
461
+ *
462
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
463
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
464
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
375
465
  */
376
466
  export class AgentTestKit {
377
467
  #registered;
378
468
  #cls;
379
469
  #client;
380
470
  #model;
471
+ #mcp;
381
472
  sessionId;
382
473
  /** The session's memory: what the user said and the agent answered, turn after turn. */
383
474
  history = [];
384
- constructor(cls, sessionId, model, client) {
475
+ #turn;
476
+ #decided = new Set();
477
+ #nextId = 0;
478
+ constructor(cls, sessionId, model, client, mcp) {
385
479
  this.#registered = registryFor(cls, client).component(cls.componentId);
386
480
  this.#cls = cls;
387
481
  this.#client = client;
388
482
  this.#model = model;
483
+ this.#mcp = mcp;
389
484
  this.sessionId = sessionId;
390
485
  }
391
- static of(cls, sessionId, model, client = noClient()) {
392
- return new AgentTestKit(cls, sessionId, model, client);
486
+ static of(cls, sessionId, model, client = noClient(), options = {}) {
487
+ return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {});
393
488
  }
394
489
  #agent() {
395
490
  const a = new this.#cls();
396
491
  a._bind(this.sessionId, {}, this.#client);
397
492
  return a;
398
493
  }
399
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
494
+ /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
400
495
  async ask(handler, input) {
496
+ const outcome = await this.outcome(handler, input);
497
+ if (outcome.kind === "awaiting-approval")
498
+ throw new ApprovalAwaited(outcome.requests);
499
+ return outcome.value;
500
+ }
501
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
502
+ async outcome(handler, input) {
503
+ if (this.#turn?.pending)
504
+ throw new CommandError({ message: `session '${this.sessionId}' has a turn awaiting a decision`, code: ErrorCode.Conflict });
401
505
  const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
402
506
  const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
403
507
  const agent = this.#agent();
@@ -409,43 +513,112 @@ export class AgentTestKit {
409
513
  throw new Error(problem);
410
514
  if (plan.failure)
411
515
  throw new CommandError(plan.failure);
412
- const guard = async (stage, text) => {
413
- for (const name of plan.guardrailNames) {
414
- const reason = await this.#registered.guardrails.get(name).check(stage, text);
415
- if (reason)
416
- throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
417
- }
418
- };
419
516
  const user = [...plan.context, plan.user ?? ""].filter((t) => t !== "").join("\n\n");
420
- await guard("input", user);
517
+ await this.#guard(plan, "input", user);
421
518
  const messages = plan.sessionMemory ? this.history.map((h) => ({ ...h })) : [];
422
519
  messages.push({ role: "user", text: user });
423
- for (let step = 0; step <= this.#registered.maxToolCallSteps; step++) {
424
- const answer = this.#model._next({ system: plan.system, messages: [...messages], tools: [...plan.toolNames] });
520
+ this.#turn = { plan, messages, steps: 0 };
521
+ return this.#goOn(this.#turn);
522
+ }
523
+ /**
524
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
525
+ * model is told its result; refused, the model is told who refused it and why.
526
+ */
527
+ async decide(approvalId, decision) {
528
+ if (decision.by.trim() === "")
529
+ throw new CommandError({ message: "a decision must name who made it", code: ErrorCode.BadRequest });
530
+ if (this.#decided.has(approvalId))
531
+ throw new CommandError({ message: `approval request '${approvalId}' is decided`, code: ErrorCode.Conflict });
532
+ const turn = this.#turn;
533
+ if (!turn?.pending || turn.pending.request.id !== approvalId) {
534
+ throw new CommandError({ message: `no approval request '${approvalId}' in session '${this.sessionId}'`, code: ErrorCode.NotFound });
535
+ }
536
+ const { tool, argumentsJson } = turn.pending;
537
+ turn.pending = undefined;
538
+ this.#decided.add(approvalId);
539
+ const result = decision.approved
540
+ ? await this.#run(tool, argumentsJson)
541
+ : `A person (${decision.by}) refused this tool call; the tool did not run.${decision.note ? ` Their note: ${decision.note}` : ""}`;
542
+ turn.messages.push({ role: "tool", text: result });
543
+ return this.#goOn(turn);
544
+ }
545
+ async #guard(plan, stage, text) {
546
+ for (const name of plan.guardrailNames) {
547
+ const reason = await this.#registered.guardrails.get(name).check(stage, text);
548
+ if (reason)
549
+ throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
550
+ }
551
+ }
552
+ #mcpTool(name) {
553
+ if (!name.startsWith(TOOL_PREFIX))
554
+ return undefined;
555
+ const rest = name.slice(TOOL_PREFIX.length);
556
+ const at = rest.indexOf("__");
557
+ return at < 0 ? undefined : { server: rest.slice(0, at), tool: rest.slice(at + 2) };
558
+ }
559
+ #offered(plan) {
560
+ const mcp = [...this.#registered.mcpServers.keys()].flatMap((server) => Object.keys(this.#mcp[server] ?? {}).map((tool) => toolName(server, tool)));
561
+ return [...plan.toolNames, ...mcp];
562
+ }
563
+ #requiresApproval(name) {
564
+ const mcp = this.#mcpTool(name);
565
+ if (mcp)
566
+ return this.#registered.mcpServers.get(mcp.server)?.approval !== undefined;
567
+ return this.#registered.tools.get(name)?.approval !== undefined;
568
+ }
569
+ async #run(name, argumentsJson) {
570
+ const mcp = this.#mcpTool(name);
571
+ try {
572
+ if (!mcp)
573
+ return await runTool(this.#registered, this.#agent(), name, argumentsJson);
574
+ const text = await this.#mcp[mcp.server][mcp.tool](JSON.parse(argumentsJson || "{}"));
575
+ for (const g of [...this.#registered.resultGuardrails.values()].sort((a, b) => a.name.localeCompare(b.name))) {
576
+ const reason = await g.check(name, text);
577
+ if (reason)
578
+ return `A result guardrail refused what this tool answered, so it is not shown: ${g.name}: ${reason}`;
579
+ }
580
+ return text;
581
+ }
582
+ catch (e) {
583
+ return `error: ${e instanceof Error ? e.message : String(e)}`;
584
+ }
585
+ }
586
+ async #goOn(turn) {
587
+ const offered = this.#offered(turn.plan);
588
+ while (turn.steps <= this.#registered.maxToolCallSteps) {
589
+ const answer = this.#model._next({ system: turn.plan.system, messages: [...turn.messages], tools: offered });
425
590
  switch (answer.kind) {
426
591
  case "refusal":
592
+ this.#turn = undefined;
427
593
  throw new CommandError({ message: answer.message, code: ErrorCode.BadRequest });
428
594
  case "tool-call": {
429
- if (!plan.toolNames.includes(answer.tool))
595
+ turn.steps++;
596
+ if (!offered.includes(answer.tool))
430
597
  throw new Error(`the model called ${JSON.stringify(answer.tool)}, which the plan did not offer`);
431
- let result;
432
- try {
433
- result = await runTool(this.#registered, this.#agent(), answer.tool, answer.argumentsJson);
434
- }
435
- catch (e) {
436
- result = `error: ${e instanceof Error ? e.message : String(e)}`;
598
+ turn.messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` });
599
+ if (this.#requiresApproval(answer.tool)) {
600
+ const request = Object.freeze({
601
+ id: `approval-${++this.#nextId}`,
602
+ tool: answer.tool,
603
+ arguments: JSON.parse(answer.argumentsJson || "{}"),
604
+ requestedAt: Date.now(),
605
+ });
606
+ turn.pending = { request, tool: answer.tool, argumentsJson: answer.argumentsJson };
607
+ return { kind: "awaiting-approval", requests: [request] };
437
608
  }
438
- messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` }, { role: "tool", text: result });
609
+ turn.messages.push({ role: "tool", text: await this.#run(answer.tool, answer.argumentsJson) });
439
610
  break;
440
611
  }
441
612
  case "text": {
442
- await guard("output", answer.text);
443
- if (plan.sessionMemory)
444
- this.history.push({ role: "user", text: plan.user ?? "" }, { role: "assistant", text: answer.text });
445
- return answer.text;
613
+ this.#turn = undefined;
614
+ await this.#guard(turn.plan, "output", answer.text);
615
+ if (turn.plan.sessionMemory)
616
+ this.history.push({ role: "user", text: turn.plan.user ?? "" }, { role: "assistant", text: answer.text });
617
+ return { kind: "answered", value: answer.text };
446
618
  }
447
619
  }
448
620
  }
621
+ this.#turn = undefined;
449
622
  throw new CommandError({ message: `the agent made more than ${this.#registered.maxToolCallSteps} tool calls`, code: ErrorCode.Internal });
450
623
  }
451
624
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
@@ -1,15 +1,29 @@
1
1
  import { ComponentClient } from "../client.ts";
2
2
  import { type Principal } from "../context.ts";
3
- import type { ErrorDetail, Metadata, Retention } from "../effects/common.ts";
3
+ import { type ErrorDetail, type Metadata, type Retention } from "../effects/common.ts";
4
+ import type { ReplicationChange } from "../effects/eventSourced.ts";
4
5
  import type { Endpoint, EndpointClass } from "../endpoint.ts";
5
6
  import { EventSourcedEntity, type EventSourcedEntityClass } from "../eventSourcedEntity.ts";
6
7
  import type { HandlerRef } from "../handlers.ts";
7
8
  import { type Materialised } from "../materialise.ts";
8
9
  export type { Materialised };
9
10
  /** Drives one event sourced entity instance through its handlers, folding events as it goes. */
11
+ /** How a replicated entity's copy stands in the test kit (1.19); `installation` is the copy's own. */
12
+ export type ReplicationMode = {
13
+ readonly kind: "unreplicated";
14
+ } | {
15
+ readonly kind: "home";
16
+ readonly installation?: string;
17
+ } | {
18
+ readonly kind: "follower";
19
+ readonly home: string;
20
+ readonly installation?: string;
21
+ };
10
22
  export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E>> {
11
23
  #private;
12
24
  readonly entityId: string;
25
+ /** The handover or change to the replication set the last call asked for, if any. */
26
+ lastReplication: ReplicationChange | undefined;
13
27
  /** The state now, after every call so far. */
14
28
  state: S;
15
29
  /** The sequence number of the last event, `0n` before any. */
@@ -18,7 +32,18 @@ export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E
18
32
  readonly allEvents: E[];
19
33
  private constructor();
20
34
  /** 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>;
35
+ static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient,
36
+ /**
37
+ * How a replicated entity's copy stands (1.19): `{ kind: "home" }`, where every command runs, or
38
+ * `{ kind: "follower", home }`, where a command that may persist is refused `NOT_HOME` and a query is
39
+ * answered from what `replay` gave it. Not replicated by default.
40
+ */
41
+ mode?: ReplicationMode): EventSourcedTestKit<S, E, C>;
42
+ /**
43
+ * Applies `events` as history the copy already holds, written at `writtenAt` (this installation by
44
+ * default): for a follower, what it has mirrored from its home.
45
+ */
46
+ replay(events: readonly E[], writtenAt?: string | null): void;
22
47
  /** Sends a command or query and returns what its effect amounts to. */
23
48
  call<I, R>(handler: HandlerRef<C, I, R, any> | string, input?: I, metadata?: Metadata): Promise<Materialised<S, E, R>>;
24
49
  }
@@ -34,15 +59,28 @@ export declare class Response {
34
59
  export interface RequestOptions {
35
60
  readonly query?: Readonly<Record<string, string | readonly string[]>>;
36
61
  readonly headers?: Readonly<Record<string, string>>;
37
- readonly principal?: Principal;
62
+ /** The verified caller, as the runtime would hand it over; `claims` and `issuer` may be left out. */
63
+ readonly principal?: Omit<Principal, "claims" | "issuer"> & Partial<Pick<Principal, "claims" | "issuer">>;
38
64
  readonly metadata?: Metadata;
39
65
  }
66
+ /** What a socket route's handler did with a scripted socket. */
67
+ export interface SocketRun {
68
+ readonly sent: readonly string[];
69
+ readonly ended: "finished" | "failed";
70
+ readonly error?: string;
71
+ }
40
72
  /** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
41
73
  export declare class EndpointTestKit<C extends Endpoint> {
42
74
  #private;
43
75
  private constructor();
44
76
  static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
45
77
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
78
+ /**
79
+ * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
80
+ * socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
81
+ * order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
82
+ */
83
+ socket(path: string, frames?: readonly string[], options?: RequestOptions): Promise<SocketRun>;
46
84
  /** The frames of an SSE route, each string one frame. */
47
85
  sse(path: string, options?: RequestOptions): Promise<string[]>;
48
86
  get(path: string, options?: RequestOptions): Promise<Response>;