ankka 0.9.1 → 0.11.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 (78) hide show
  1. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  3. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +542 -2
  4. package/dist/_proto/ankka/protocol/v1/client_pb.js +107 -6
  5. package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +52 -1
  6. package/dist/_proto/ankka/protocol/v1/consumer_pb.js +11 -1
  7. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +330 -0
  8. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  9. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +184 -0
  10. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +26 -1
  11. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +63 -0
  12. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  13. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  14. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  15. package/dist/agent.d.ts +15 -1
  16. package/dist/agent.js +20 -0
  17. package/dist/approvals.d.ts +47 -0
  18. package/dist/approvals.js +57 -0
  19. package/dist/autonomous.d.ts +23 -1
  20. package/dist/autonomous.js +30 -1
  21. package/dist/client.d.ts +92 -4
  22. package/dist/client.js +262 -15
  23. package/dist/consumer.d.ts +40 -2
  24. package/dist/consumer.js +50 -0
  25. package/dist/context.d.ts +4 -0
  26. package/dist/contract.d.ts +36 -0
  27. package/dist/contract.js +58 -0
  28. package/dist/effects/keyed.d.ts +33 -0
  29. package/dist/effects/keyed.js +41 -0
  30. package/dist/effects/stateless.d.ts +21 -0
  31. package/dist/effects/stateless.js +19 -0
  32. package/dist/endpoint.d.ts +10 -1
  33. package/dist/endpoint.js +20 -0
  34. package/dist/graph.d.ts +171 -0
  35. package/dist/graph.js +446 -0
  36. package/dist/handlers.d.ts +10 -2
  37. package/dist/handlers.js +6 -2
  38. package/dist/index.d.ts +15 -6
  39. package/dist/index.js +13 -4
  40. package/dist/keyedView.d.ts +56 -0
  41. package/dist/keyedView.js +67 -0
  42. package/dist/mcp.d.ts +57 -0
  43. package/dist/mcp.js +64 -0
  44. package/dist/routes.d.ts +23 -3
  45. package/dist/routes.js +16 -3
  46. package/dist/server/agent.js +12 -4
  47. package/dist/server/discovery.d.ts +12 -1
  48. package/dist/server/discovery.js +95 -1
  49. package/dist/server/http.d.ts +2 -1
  50. package/dist/server/http.js +78 -4
  51. package/dist/server/stateless.js +104 -16
  52. package/dist/server/workflow.js +5 -1
  53. package/dist/service.d.ts +45 -3
  54. package/dist/service.js +234 -12
  55. package/dist/services.d.ts +137 -0
  56. package/dist/services.js +259 -0
  57. package/dist/socket.d.ts +15 -0
  58. package/dist/socket.js +25 -0
  59. package/dist/spec.d.ts +1 -1
  60. package/dist/spec.js +87 -14
  61. package/dist/startFrom.d.ts +32 -0
  62. package/dist/startFrom.js +44 -0
  63. package/dist/testkit/index.d.ts +2 -2
  64. package/dist/testkit/index.js +1 -1
  65. package/dist/testkit/integration.js +5 -0
  66. package/dist/testkit/kinds.d.ts +75 -5
  67. package/dist/testkit/kinds.js +264 -34
  68. package/dist/testkit/unit.d.ts +14 -1
  69. package/dist/testkit/unit.js +40 -3
  70. package/dist/timedAction.d.ts +17 -2
  71. package/dist/timedAction.js +33 -1
  72. package/dist/version.d.ts +1 -1
  73. package/dist/version.js +1 -1
  74. package/dist/view.d.ts +32 -0
  75. package/dist/view.js +8 -0
  76. package/dist/workflow.d.ts +18 -1
  77. package/dist/workflow.js +39 -0
  78. package/package.json +3 -2
@@ -0,0 +1,44 @@
1
+ // Where a topic source starts, and what a view or consumer that reads a topic may declare about it.
2
+ export const StartFrom = Object.freeze({
3
+ /** The oldest message the broker still holds. */
4
+ earliest: Object.freeze({ kind: "earliest" }),
5
+ /** After the newest: only what is published from then on. */
6
+ latest: Object.freeze({ kind: "latest" }),
7
+ /** The first message published at or after `when`. */
8
+ at(when) {
9
+ const atMillis = when instanceof Date ? when.getTime() : Number.NaN;
10
+ if (!Number.isFinite(atMillis))
11
+ throw new TypeError(`StartFrom.at needs a valid Date, not ${String(when)}`);
12
+ return Object.freeze({ kind: "at", atMillis });
13
+ },
14
+ });
15
+ /** The first protocol in which a process can declare a start position or a version. */
16
+ export const START_POSITION_PROTOCOL = [1, 7];
17
+ export function isStartFrom(value) {
18
+ if (typeof value !== "object" || value === null)
19
+ return false;
20
+ const kind = value.kind;
21
+ if (kind === "earliest" || kind === "latest")
22
+ return true;
23
+ return kind === "at" && Number.isFinite(value.atMillis);
24
+ }
25
+ /** The first protocol in which a process can declare a view's queries. */
26
+ export const DECLARED_QUERY_PROTOCOL = [1, 13];
27
+ function olderThan(protocolVersion, [wantMajor, wantMinor]) {
28
+ const [major, minor] = protocolVersion.split(".").map((part) => Number.parseInt(part, 10));
29
+ if (!Number.isFinite(major) || !Number.isFinite(minor))
30
+ return false;
31
+ return major < wantMajor || (major === wantMajor && minor < wantMinor);
32
+ }
33
+ /** Whether a sidecar speaking `protocolVersion` would ignore a start position and a version. */
34
+ export function olderThanStartPositions(protocolVersion) {
35
+ return olderThan(protocolVersion, START_POSITION_PROTOCOL);
36
+ }
37
+ /** Whether a sidecar speaking `protocolVersion` would ignore a view's declared queries. */
38
+ export function olderThanDeclaredQueries(protocolVersion) {
39
+ return olderThan(protocolVersion, DECLARED_QUERY_PROTOCOL);
40
+ }
41
+ /** Whether a sidecar speaking `protocolVersion` would ignore a topic's contract, broker and parallel reading. */
42
+ export function olderThanContracts(protocolVersion) {
43
+ return olderThan(protocolVersion, [1, 14]);
44
+ }
@@ -1,3 +1,3 @@
1
- export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised } from "./unit.ts";
2
- export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, } from "./kinds.ts";
1
+ export { EventSourcedTestKit, EndpointTestKit, Response, type RequestOptions, type Materialised, type SocketRun } from "./unit.ts";
2
+ export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, KeyedViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, type WorkflowProgress, type ModelResponse, type ModelCall, type GraphChange, } from "./kinds.ts";
3
3
  export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage, type AnkkaTestKitOptions, type HttpResponse, type Http, type Beside } from "./integration.ts";
@@ -2,5 +2,5 @@
2
2
  // (Postgres and the real sidecar image in Docker). The integration testkit's Docker dependencies,
3
3
  // `testcontainers` and `@testcontainers/postgresql`, are loaded only when `AnkkaTestKit.start` runs.
4
4
  export { EventSourcedTestKit, EndpointTestKit, Response } from "./unit.js";
5
- export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, ConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, } from "./kinds.js";
5
+ export { KeyValueTestKit, WorkflowTestKit, ViewTestKit, KeyedViewTestKit, ConsumerTestKit, GraphConsumerTestKit, TimedActionTestKit, AgentTestKit, ScriptedModel, } from "./kinds.js";
6
6
  export { AnkkaTestKit, PUBLISHED_SIDECAR, sidecarImage } from "./integration.js";
@@ -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,11 +5,16 @@ 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 { Consumer, ConsumerClass } from "../consumer.ts";
8
+ import type { KeyedView, KeyedViewClass } from "../keyedView.ts";
9
+ import { type KeyedViewEffect } from "../effects/keyed.ts";
10
+ import type { ComponentRef } from "../client.ts";
11
+ import { type Consumer, type ConsumerClass } from "../consumer.ts";
12
+ import { type Delta, type GraphConsumerClass } from "../graph.ts";
9
13
  import type { TimedAction, TimedActionClass } from "../timedAction.ts";
10
14
  import type { Agent, AgentClass } from "../agent.ts";
11
15
  import type { HandlerRef } from "../handlers.ts";
12
16
  import { type MaterialisedKeyValue, type MaterialisedStep, type MaterialisedWorkflowCommand } from "../materialise.ts";
17
+ import { type AgentOutcome, type DecisionInput } from "../approvals.ts";
13
18
  /** Drives one key value entity instance through its handlers. */
14
19
  export declare class KeyValueTestKit<S, C extends KeyValueEntity<S>> {
15
20
  #private;
@@ -59,20 +64,70 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
59
64
  onDelete(key: string, metadata?: Metadata): Promise<ViewEffect<Row>>;
60
65
  get(key: string): Row | null;
61
66
  }
67
+ /**
68
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
69
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
70
+ * them. Rows round-trip through the view's row codec and events through the source's.
71
+ *
72
+ * A declared query is SQL and there is no database to run it, so the test says what each query
73
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
74
+ * no rows would pass a handler that updated nothing.
75
+ */
76
+ export declare class KeyedViewTestKit<Row, C extends KeyedView<Row>> {
77
+ #private;
78
+ readonly rows: Map<string, Row>;
79
+ private constructor();
80
+ static of<Row, C extends KeyedView<Row>>(cls: KeyedViewClass<Row, C>, client?: ComponentClient): KeyedViewTestKit<Row, C>;
81
+ /** What the declared query `name` answers, given the values it is asked with. */
82
+ answering(name: string, answer: (values: Readonly<Record<string, string>>) => Row[]): this;
83
+ /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
84
+ change<E>(entity: ComponentRef, key: string, event: E): Promise<KeyedViewEffect<Row>>;
85
+ /** The entity `key` of `entity` was deleted. */
86
+ deleted(entity: ComponentRef, key: string): Promise<KeyedViewEffect<Row>>;
87
+ get(key: string): Row | null;
88
+ }
62
89
  /** Feeds a consumer messages and collects what it produced. */
63
90
  export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
64
91
  #private;
65
- /** Everything `produce` sent, round-tripped through the out codec. */
92
+ /**
93
+ * Everything the consumer produced, one entry per message, round-tripped through the out codec.
94
+ * `key` is the record key a message of `produceAll` named; absent, the message is keyed by its subject.
95
+ */
66
96
  readonly produced: {
67
97
  readonly payload: Out;
68
98
  readonly metadata: Metadata;
99
+ readonly key?: string;
69
100
  }[];
70
101
  private constructor();
71
102
  static of<M, Out, C extends Consumer<M, Out>>(cls: ConsumerClass<M, Out, C>, client?: ComponentClient): ConsumerTestKit<M, Out, C>;
72
- /** A message from source instance `subject`. */
103
+ /**
104
+ * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
105
+ * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
106
+ */
73
107
  onMessage(message: M, subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
74
108
  onDelete(subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
75
109
  }
110
+ /**
111
+ * Feeds a graph consumer changes and answers with the elements it published: each read back from the
112
+ * bytes that would be published, under the key it would be published under, so a test asserts on what
113
+ * a reader of the topic sees.
114
+ */
115
+ export declare class GraphConsumerTestKit<M> {
116
+ #private;
117
+ private constructor();
118
+ static of<M>(cls: GraphConsumerClass<M>, client?: ComponentClient): GraphConsumerTestKit<M>;
119
+ /** A change from source instance `subject` (default `"test"`) at `sequence` (default 1). */
120
+ onMessage(message: M, options?: GraphChange): Promise<Delta[]>;
121
+ /** The source instance was deleted, at `sequence`. */
122
+ onDelete(options?: GraphChange): Promise<Delta[]>;
123
+ }
124
+ /** The change a graph consumer is handed: whose it is and where in that instance's history. */
125
+ export interface GraphChange {
126
+ readonly subject?: string;
127
+ /** The change's sequence number; `0` is a source with none, as a topic is. */
128
+ readonly sequence?: number | bigint;
129
+ readonly metadata?: Metadata;
130
+ }
76
131
  /** Invokes a timed action's handlers as the sweeper would. */
77
132
  export declare class TimedActionTestKit<C extends TimedAction> {
78
133
  #private;
@@ -112,9 +167,15 @@ export declare class ScriptedModel {
112
167
  /** @internal */
113
168
  _next(call: ModelCall): ModelResponse;
114
169
  }
170
+ /** Scripted MCP servers: `{ server: { tool: (args) => text } }`, called by the model as `mcp__<server>__<tool>`. */
171
+ export type ScriptedMcp = Readonly<Record<string, Readonly<Record<string, (args: Record<string, unknown>) => string | Promise<string>>>>>;
115
172
  /**
116
173
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
117
174
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
175
+ *
176
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
177
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
178
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
118
179
  */
119
180
  export declare class AgentTestKit<C extends Agent> {
120
181
  #private;
@@ -125,9 +186,18 @@ export declare class AgentTestKit<C extends Agent> {
125
186
  text: string;
126
187
  }[];
127
188
  private constructor();
128
- static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient): AgentTestKit<C>;
129
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
189
+ static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient, options?: {
190
+ readonly mcp?: ScriptedMcp;
191
+ }): AgentTestKit<C>;
192
+ /** 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`. */
130
193
  ask<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<string>;
194
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
195
+ outcome<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<AgentOutcome<string>>;
196
+ /**
197
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
198
+ * model is told its result; refused, the model is told who refused it and why.
199
+ */
200
+ decide(approvalId: string, decision: DecisionInput): Promise<AgentOutcome<string>>;
131
201
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
132
202
  stream<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): AsyncIterable<string>;
133
203
  }
@@ -5,9 +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";
9
+ import { PROTOCOL_KEY, requireSeveralMessages } from "../consumer.js";
10
+ import { deltaRecords, readDelta } from "../graph.js";
11
+ import { PROTOCOL_VERSION } from "../spec.js";
8
12
  import { materialiseKeyValue, materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
9
13
  import { Ankka } from "../service.js";
10
14
  import { checkPlan, runTool } from "../server/agent.js";
15
+ import { ApprovalAwaited } from "../approvals.js";
16
+ import { TOOL_PREFIX, toolName } from "../mcp.js";
11
17
  function roundTrip(codec, value) {
12
18
  return codec.decode(codec.encode(value));
13
19
  }
@@ -224,12 +230,95 @@ export class ViewTestKit {
224
230
  return this.rows.get(key) ?? null;
225
231
  }
226
232
  }
233
+ /**
234
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
235
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
236
+ * them. Rows round-trip through the view's row codec and events through the source's.
237
+ *
238
+ * A declared query is SQL and there is no database to run it, so the test says what each query
239
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
240
+ * no rows would pass a handler that updated nothing.
241
+ */
242
+ export class KeyedViewTestKit {
243
+ #registered;
244
+ #cls;
245
+ #client;
246
+ #answers = new Map();
247
+ #sequence = 0;
248
+ rows = new Map();
249
+ constructor(cls, client) {
250
+ this.#registered = registryFor(cls, client).component(cls.componentId);
251
+ this.#cls = cls;
252
+ this.#client = client;
253
+ }
254
+ static of(cls, client = noClient()) {
255
+ return new KeyedViewTestKit(cls, client);
256
+ }
257
+ /** What the declared query `name` answers, given the values it is asked with. */
258
+ answering(name, answer) {
259
+ this.#answers.set(name, answer);
260
+ return this;
261
+ }
262
+ #source(entity) {
263
+ const source = this.#registered.sources.get(entity.componentId);
264
+ if (!source)
265
+ throw new Error(`${this.#registered.id} reads no ${JSON.stringify(entity.componentId)}`);
266
+ return source;
267
+ }
268
+ async #apply(key, run) {
269
+ const view = new this.#cls();
270
+ const rowCodec = this.#registered.rowCodec;
271
+ const id = this.#registered.id;
272
+ const rows = {
273
+ get: async (k) => {
274
+ const row = this.rows.get(k);
275
+ return row === undefined ? null : roundTrip(rowCodec, row);
276
+ },
277
+ ask: async (name, values = {}) => {
278
+ const answer = this.#answers.get(name);
279
+ if (!answer)
280
+ 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)}, ...)`);
281
+ return answer(values).map((r) => roundTrip(rowCodec, r));
282
+ },
283
+ };
284
+ this.#sequence += 1;
285
+ const metadata = { "ce-subject": key, "ankka.sequence": String(this.#sequence) };
286
+ view._bind(metadata, this.#client.withMetadata(metadata), rows);
287
+ const effect = ((await run(view)) ?? { kind: "rows", changes: [] });
288
+ if (effect.kind !== "rows")
289
+ throw new TypeError(`${id}'s handler returned something that is not a keyed view effect`);
290
+ for (const [k, row] of reduceRowChanges(effect.changes)) {
291
+ if (row === undefined)
292
+ this.rows.delete(k);
293
+ else
294
+ this.rows.set(k, roundTrip(rowCodec, row));
295
+ }
296
+ return effect;
297
+ }
298
+ /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
299
+ change(entity, key, event) {
300
+ const source = this.#source(entity);
301
+ const wire = roundTrip(source.eventCodec, event);
302
+ return this.#apply(key, (v) => source.onChange(v, wire));
303
+ }
304
+ /** The entity `key` of `entity` was deleted. */
305
+ deleted(entity, key) {
306
+ const source = this.#source(entity);
307
+ return this.#apply(key, (v) => (source.deleted ? source.deleted(v) : undefined));
308
+ }
309
+ get(key) {
310
+ return this.rows.get(key) ?? null;
311
+ }
312
+ }
227
313
  /** Feeds a consumer messages and collects what it produced. */
228
314
  export class ConsumerTestKit {
229
315
  #registered;
230
316
  #cls;
231
317
  #client;
232
- /** Everything `produce` sent, round-tripped through the out codec. */
318
+ /**
319
+ * Everything the consumer produced, one entry per message, round-tripped through the out codec.
320
+ * `key` is the record key a message of `produceAll` named; absent, the message is keyed by its subject.
321
+ */
233
322
  produced = [];
234
323
  constructor(cls, client) {
235
324
  this.#registered = registryFor(cls, client).component(cls.componentId);
@@ -243,21 +332,75 @@ export class ConsumerTestKit {
243
332
  const consumer = new this.#cls();
244
333
  consumer._bind(metadata, this.#client.withMetadata(metadata));
245
334
  const effect = await fn(consumer);
246
- if (effect.kind === "produce") {
247
- const outCodec = this.#registered.outCodec;
248
- if (!outCodec)
335
+ const outCodec = () => {
336
+ const codec = this.#registered.outCodec;
337
+ if (!codec)
249
338
  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 });
339
+ return codec;
340
+ };
341
+ if (effect.kind === "produce") {
342
+ this.produced.push({ payload: roundTrip(outCodec(), effect.payload), metadata: effect.metadata });
343
+ }
344
+ else if (effect.kind === "produceAll" && effect.messages.length > 0) {
345
+ // The server's rule: several messages go only to a runtime that said it accepts them.
346
+ requireSeveralMessages(metadata);
347
+ for (const m of effect.messages) {
348
+ this.produced.push({ payload: roundTrip(outCodec(), m.payload), metadata: m.metadata ?? {}, ...(m.key !== undefined ? { key: m.key } : {}) });
349
+ }
251
350
  }
252
351
  return effect;
253
352
  }
254
- /** A message from source instance `subject`. */
353
+ /**
354
+ * A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
355
+ * version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
356
+ */
255
357
  onMessage(message, subject = "test", metadata = {}) {
256
358
  const wire = roundTrip(this.#registered.messageCodec, message);
257
- return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, ...metadata });
359
+ return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
258
360
  }
259
361
  onDelete(subject = "test", metadata = {}) {
260
- return this.#apply((c) => c.onDelete(), { "ce-subject": subject, ...metadata });
362
+ return this.#apply((c) => c.onDelete(), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
363
+ }
364
+ }
365
+ /**
366
+ * Feeds a graph consumer changes and answers with the elements it published: each read back from the
367
+ * bytes that would be published, under the key it would be published under, so a test asserts on what
368
+ * a reader of the topic sees.
369
+ */
370
+ export class GraphConsumerTestKit {
371
+ #registered;
372
+ #cls;
373
+ #client;
374
+ constructor(cls, client) {
375
+ this.#registered = registryFor(cls, client).component(cls.componentId);
376
+ this.#cls = cls;
377
+ this.#client = client;
378
+ }
379
+ static of(cls, client = noClient()) {
380
+ return new GraphConsumerTestKit(cls, client);
381
+ }
382
+ async #apply(fn, options) {
383
+ const metadata = {
384
+ "ce-subject": options.subject ?? "test",
385
+ "ankka.sequence": String(options.sequence ?? 1),
386
+ [PROTOCOL_KEY]: PROTOCOL_VERSION,
387
+ ...options.metadata,
388
+ };
389
+ const consumer = new this.#cls();
390
+ consumer._bind(metadata, this.#client.withMetadata(metadata));
391
+ const records = deltaRecords(await fn(consumer), metadata);
392
+ if (records.length > 0)
393
+ requireSeveralMessages(metadata);
394
+ return records.map((r) => readDelta(r.value, r.key));
395
+ }
396
+ /** A change from source instance `subject` (default `"test"`) at `sequence` (default 1). */
397
+ onMessage(message, options = {}) {
398
+ const wire = roundTrip(this.#registered.messageCodec, message);
399
+ return this.#apply((c) => c.onMessage(wire), options);
400
+ }
401
+ /** The source instance was deleted, at `sequence`. */
402
+ onDelete(options = {}) {
403
+ return this.#apply((c) => c.onDelete(), options);
261
404
  }
262
405
  }
263
406
  /** Invokes a timed action's handlers as the sweeper would. */
@@ -312,32 +455,50 @@ export class ScriptedModel {
312
455
  /**
313
456
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
314
457
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
458
+ *
459
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
460
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
461
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
315
462
  */
316
463
  export class AgentTestKit {
317
464
  #registered;
318
465
  #cls;
319
466
  #client;
320
467
  #model;
468
+ #mcp;
321
469
  sessionId;
322
470
  /** The session's memory: what the user said and the agent answered, turn after turn. */
323
471
  history = [];
324
- constructor(cls, sessionId, model, client) {
472
+ #turn;
473
+ #decided = new Set();
474
+ #nextId = 0;
475
+ constructor(cls, sessionId, model, client, mcp) {
325
476
  this.#registered = registryFor(cls, client).component(cls.componentId);
326
477
  this.#cls = cls;
327
478
  this.#client = client;
328
479
  this.#model = model;
480
+ this.#mcp = mcp;
329
481
  this.sessionId = sessionId;
330
482
  }
331
- static of(cls, sessionId, model, client = noClient()) {
332
- return new AgentTestKit(cls, sessionId, model, client);
483
+ static of(cls, sessionId, model, client = noClient(), options = {}) {
484
+ return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {});
333
485
  }
334
486
  #agent() {
335
487
  const a = new this.#cls();
336
488
  a._bind(this.sessionId, {}, this.#client);
337
489
  return a;
338
490
  }
339
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
491
+ /** 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`. */
340
492
  async ask(handler, input) {
493
+ const outcome = await this.outcome(handler, input);
494
+ if (outcome.kind === "awaiting-approval")
495
+ throw new ApprovalAwaited(outcome.requests);
496
+ return outcome.value;
497
+ }
498
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
499
+ async outcome(handler, input) {
500
+ if (this.#turn?.pending)
501
+ throw new CommandError({ message: `session '${this.sessionId}' has a turn awaiting a decision`, code: ErrorCode.Conflict });
341
502
  const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
342
503
  const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
343
504
  const agent = this.#agent();
@@ -349,43 +510,112 @@ export class AgentTestKit {
349
510
  throw new Error(problem);
350
511
  if (plan.failure)
351
512
  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
513
  const user = [...plan.context, plan.user ?? ""].filter((t) => t !== "").join("\n\n");
360
- await guard("input", user);
514
+ await this.#guard(plan, "input", user);
361
515
  const messages = plan.sessionMemory ? this.history.map((h) => ({ ...h })) : [];
362
516
  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] });
517
+ this.#turn = { plan, messages, steps: 0 };
518
+ return this.#goOn(this.#turn);
519
+ }
520
+ /**
521
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
522
+ * model is told its result; refused, the model is told who refused it and why.
523
+ */
524
+ async decide(approvalId, decision) {
525
+ if (decision.by.trim() === "")
526
+ throw new CommandError({ message: "a decision must name who made it", code: ErrorCode.BadRequest });
527
+ if (this.#decided.has(approvalId))
528
+ throw new CommandError({ message: `approval request '${approvalId}' is decided`, code: ErrorCode.Conflict });
529
+ const turn = this.#turn;
530
+ if (!turn?.pending || turn.pending.request.id !== approvalId) {
531
+ throw new CommandError({ message: `no approval request '${approvalId}' in session '${this.sessionId}'`, code: ErrorCode.NotFound });
532
+ }
533
+ const { tool, argumentsJson } = turn.pending;
534
+ turn.pending = undefined;
535
+ this.#decided.add(approvalId);
536
+ const result = decision.approved
537
+ ? await this.#run(tool, argumentsJson)
538
+ : `A person (${decision.by}) refused this tool call; the tool did not run.${decision.note ? ` Their note: ${decision.note}` : ""}`;
539
+ turn.messages.push({ role: "tool", text: result });
540
+ return this.#goOn(turn);
541
+ }
542
+ async #guard(plan, stage, text) {
543
+ for (const name of plan.guardrailNames) {
544
+ const reason = await this.#registered.guardrails.get(name).check(stage, text);
545
+ if (reason)
546
+ throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
547
+ }
548
+ }
549
+ #mcpTool(name) {
550
+ if (!name.startsWith(TOOL_PREFIX))
551
+ return undefined;
552
+ const rest = name.slice(TOOL_PREFIX.length);
553
+ const at = rest.indexOf("__");
554
+ return at < 0 ? undefined : { server: rest.slice(0, at), tool: rest.slice(at + 2) };
555
+ }
556
+ #offered(plan) {
557
+ const mcp = [...this.#registered.mcpServers.keys()].flatMap((server) => Object.keys(this.#mcp[server] ?? {}).map((tool) => toolName(server, tool)));
558
+ return [...plan.toolNames, ...mcp];
559
+ }
560
+ #requiresApproval(name) {
561
+ const mcp = this.#mcpTool(name);
562
+ if (mcp)
563
+ return this.#registered.mcpServers.get(mcp.server)?.approval !== undefined;
564
+ return this.#registered.tools.get(name)?.approval !== undefined;
565
+ }
566
+ async #run(name, argumentsJson) {
567
+ const mcp = this.#mcpTool(name);
568
+ try {
569
+ if (!mcp)
570
+ return await runTool(this.#registered, this.#agent(), name, argumentsJson);
571
+ const text = await this.#mcp[mcp.server][mcp.tool](JSON.parse(argumentsJson || "{}"));
572
+ for (const g of [...this.#registered.resultGuardrails.values()].sort((a, b) => a.name.localeCompare(b.name))) {
573
+ const reason = await g.check(name, text);
574
+ if (reason)
575
+ return `A result guardrail refused what this tool answered, so it is not shown: ${g.name}: ${reason}`;
576
+ }
577
+ return text;
578
+ }
579
+ catch (e) {
580
+ return `error: ${e instanceof Error ? e.message : String(e)}`;
581
+ }
582
+ }
583
+ async #goOn(turn) {
584
+ const offered = this.#offered(turn.plan);
585
+ while (turn.steps <= this.#registered.maxToolCallSteps) {
586
+ const answer = this.#model._next({ system: turn.plan.system, messages: [...turn.messages], tools: offered });
365
587
  switch (answer.kind) {
366
588
  case "refusal":
589
+ this.#turn = undefined;
367
590
  throw new CommandError({ message: answer.message, code: ErrorCode.BadRequest });
368
591
  case "tool-call": {
369
- if (!plan.toolNames.includes(answer.tool))
592
+ turn.steps++;
593
+ if (!offered.includes(answer.tool))
370
594
  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)}`;
595
+ turn.messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` });
596
+ if (this.#requiresApproval(answer.tool)) {
597
+ const request = Object.freeze({
598
+ id: `approval-${++this.#nextId}`,
599
+ tool: answer.tool,
600
+ arguments: JSON.parse(answer.argumentsJson || "{}"),
601
+ requestedAt: Date.now(),
602
+ });
603
+ turn.pending = { request, tool: answer.tool, argumentsJson: answer.argumentsJson };
604
+ return { kind: "awaiting-approval", requests: [request] };
377
605
  }
378
- messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` }, { role: "tool", text: result });
606
+ turn.messages.push({ role: "tool", text: await this.#run(answer.tool, answer.argumentsJson) });
379
607
  break;
380
608
  }
381
609
  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;
610
+ this.#turn = undefined;
611
+ await this.#guard(turn.plan, "output", answer.text);
612
+ if (turn.plan.sessionMemory)
613
+ this.history.push({ role: "user", text: turn.plan.user ?? "" }, { role: "assistant", text: answer.text });
614
+ return { kind: "answered", value: answer.text };
386
615
  }
387
616
  }
388
617
  }
618
+ this.#turn = undefined;
389
619
  throw new CommandError({ message: `the agent made more than ${this.#registered.maxToolCallSteps} tool calls`, code: ErrorCode.Internal });
390
620
  }
391
621
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
@@ -34,15 +34,28 @@ export declare class Response {
34
34
  export interface RequestOptions {
35
35
  readonly query?: Readonly<Record<string, string | readonly string[]>>;
36
36
  readonly headers?: Readonly<Record<string, string>>;
37
- readonly principal?: Principal;
37
+ /** The verified caller, as the runtime would hand it over; `claims` and `issuer` may be left out. */
38
+ readonly principal?: Omit<Principal, "claims" | "issuer"> & Partial<Pick<Principal, "claims" | "issuer">>;
38
39
  readonly metadata?: Metadata;
39
40
  }
41
+ /** What a socket route's handler did with a scripted socket. */
42
+ export interface SocketRun {
43
+ readonly sent: readonly string[];
44
+ readonly ended: "finished" | "failed";
45
+ readonly error?: string;
46
+ }
40
47
  /** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
41
48
  export declare class EndpointTestKit<C extends Endpoint> {
42
49
  #private;
43
50
  private constructor();
44
51
  static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
45
52
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
53
+ /**
54
+ * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
55
+ * socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
56
+ * order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
57
+ */
58
+ socket(path: string, frames?: readonly string[], options?: RequestOptions): Promise<SocketRun>;
46
59
  /** The frames of an SSE route, each string one frame. */
47
60
  sse(path: string, options?: RequestOptions): Promise<string[]>;
48
61
  get(path: string, options?: RequestOptions): Promise<Response>;