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.
- package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +542 -2
- package/dist/_proto/ankka/protocol/v1/client_pb.js +107 -6
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +52 -1
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +11 -1
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +330 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +184 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +26 -1
- package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +63 -0
- package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
- package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
- package/dist/agent.d.ts +15 -1
- package/dist/agent.js +20 -0
- package/dist/approvals.d.ts +47 -0
- package/dist/approvals.js +57 -0
- package/dist/autonomous.d.ts +23 -1
- package/dist/autonomous.js +30 -1
- package/dist/client.d.ts +92 -4
- package/dist/client.js +262 -15
- package/dist/consumer.d.ts +40 -2
- package/dist/consumer.js +50 -0
- package/dist/context.d.ts +4 -0
- package/dist/contract.d.ts +36 -0
- package/dist/contract.js +58 -0
- package/dist/effects/keyed.d.ts +33 -0
- package/dist/effects/keyed.js +41 -0
- package/dist/effects/stateless.d.ts +21 -0
- package/dist/effects/stateless.js +19 -0
- package/dist/endpoint.d.ts +10 -1
- package/dist/endpoint.js +20 -0
- package/dist/graph.d.ts +171 -0
- package/dist/graph.js +446 -0
- package/dist/handlers.d.ts +10 -2
- package/dist/handlers.js +6 -2
- package/dist/index.d.ts +15 -6
- package/dist/index.js +13 -4
- package/dist/keyedView.d.ts +56 -0
- package/dist/keyedView.js +67 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +64 -0
- package/dist/routes.d.ts +23 -3
- package/dist/routes.js +16 -3
- package/dist/server/agent.js +12 -4
- package/dist/server/discovery.d.ts +12 -1
- package/dist/server/discovery.js +95 -1
- package/dist/server/http.d.ts +2 -1
- package/dist/server/http.js +78 -4
- package/dist/server/stateless.js +104 -16
- package/dist/server/workflow.js +5 -1
- package/dist/service.d.ts +45 -3
- package/dist/service.js +234 -12
- package/dist/services.d.ts +137 -0
- package/dist/services.js +259 -0
- package/dist/socket.d.ts +15 -0
- package/dist/socket.js +25 -0
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +87 -14
- package/dist/startFrom.d.ts +32 -0
- package/dist/startFrom.js +44 -0
- package/dist/testkit/index.d.ts +2 -2
- package/dist/testkit/index.js +1 -1
- package/dist/testkit/integration.js +5 -0
- package/dist/testkit/kinds.d.ts +75 -5
- package/dist/testkit/kinds.js +264 -34
- package/dist/testkit/unit.d.ts +14 -1
- package/dist/testkit/unit.js +40 -3
- package/dist/timedAction.d.ts +17 -2
- package/dist/timedAction.js +33 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/view.d.ts +32 -0
- package/dist/view.js +8 -0
- package/dist/workflow.d.ts +18 -1
- package/dist/workflow.js +39 -0
- 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
|
+
}
|
package/dist/testkit/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/testkit/index.js
CHANGED
|
@@ -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)
|
package/dist/testkit/kinds.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
129
|
-
|
|
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
|
}
|
package/dist/testkit/kinds.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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: "
|
|
606
|
+
turn.messages.push({ role: "tool", text: await this.#run(answer.tool, answer.argumentsJson) });
|
|
379
607
|
break;
|
|
380
608
|
}
|
|
381
609
|
case "text": {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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. */
|
package/dist/testkit/unit.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|