@dynamicagents/core 0.9.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 (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +522 -0
  3. package/dist/a2a/agent-stub.d.ts +60 -0
  4. package/dist/a2a/agent-stub.js +1 -0
  5. package/dist/a2a/caller-token.d.ts +44 -0
  6. package/dist/a2a/caller-token.js +61 -0
  7. package/dist/a2a/caller.d.ts +23 -0
  8. package/dist/a2a/caller.js +33 -0
  9. package/dist/a2a/card.d.ts +158 -0
  10. package/dist/a2a/card.js +163 -0
  11. package/dist/a2a/context.d.ts +19 -0
  12. package/dist/a2a/context.js +61 -0
  13. package/dist/a2a/deliver.d.ts +159 -0
  14. package/dist/a2a/deliver.js +185 -0
  15. package/dist/a2a/executor.d.ts +84 -0
  16. package/dist/a2a/executor.js +106 -0
  17. package/dist/a2a/index.d.ts +35 -0
  18. package/dist/a2a/index.js +34 -0
  19. package/dist/a2a/notify.d.ts +106 -0
  20. package/dist/a2a/notify.js +180 -0
  21. package/dist/a2a/parts.d.ts +45 -0
  22. package/dist/a2a/parts.js +94 -0
  23. package/dist/a2a/push.d.ts +70 -0
  24. package/dist/a2a/push.js +53 -0
  25. package/dist/a2a/self-origin.d.ts +91 -0
  26. package/dist/a2a/self-origin.js +114 -0
  27. package/dist/a2a/task-store.d.ts +44 -0
  28. package/dist/a2a/task-store.js +99 -0
  29. package/dist/a2a/task.d.ts +85 -0
  30. package/dist/a2a/task.js +15 -0
  31. package/dist/a2a/verify.d.ts +80 -0
  32. package/dist/a2a/verify.js +143 -0
  33. package/dist/agent/budget.d.ts +46 -0
  34. package/dist/agent/budget.js +42 -0
  35. package/dist/agent/control.d.ts +109 -0
  36. package/dist/agent/control.js +115 -0
  37. package/dist/agent/errors.d.ts +85 -0
  38. package/dist/agent/errors.js +64 -0
  39. package/dist/agent/final-reply.d.ts +49 -0
  40. package/dist/agent/final-reply.js +68 -0
  41. package/dist/agent/history.d.ts +97 -0
  42. package/dist/agent/history.js +133 -0
  43. package/dist/agent/index.d.ts +29 -0
  44. package/dist/agent/index.js +29 -0
  45. package/dist/agent/inference.d.ts +110 -0
  46. package/dist/agent/inference.js +120 -0
  47. package/dist/agent/model.d.ts +90 -0
  48. package/dist/agent/model.js +1 -0
  49. package/dist/agent/session.d.ts +100 -0
  50. package/dist/agent/session.js +82 -0
  51. package/dist/agent/workers-ai/index.d.ts +23 -0
  52. package/dist/agent/workers-ai/index.js +23 -0
  53. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  54. package/dist/agent/workers-ai/runtime.js +63 -0
  55. package/dist/alarm/index.d.ts +77 -0
  56. package/dist/alarm/index.js +116 -0
  57. package/dist/config.d.ts +202 -0
  58. package/dist/config.js +135 -0
  59. package/dist/contract/index.d.ts +9 -0
  60. package/dist/contract/index.js +8 -0
  61. package/dist/contract/plugin.d.ts +324 -0
  62. package/dist/contract/plugin.js +114 -0
  63. package/dist/contract/recipe.d.ts +180 -0
  64. package/dist/contract/recipe.js +1 -0
  65. package/dist/contract/validation.d.ts +91 -0
  66. package/dist/contract/validation.js +84 -0
  67. package/dist/db/db.d.ts +147 -0
  68. package/dist/db/db.js +90 -0
  69. package/dist/db/index.d.ts +8 -0
  70. package/dist/db/index.js +8 -0
  71. package/dist/db/migrations/index.d.ts +20 -0
  72. package/dist/db/migrations/index.js +60 -0
  73. package/dist/db/models/subtasks.d.ts +100 -0
  74. package/dist/db/models/subtasks.js +241 -0
  75. package/dist/db/models/tasks.d.ts +118 -0
  76. package/dist/db/models/tasks.js +274 -0
  77. package/dist/db/schema.d.ts +468 -0
  78. package/dist/db/schema.js +88 -0
  79. package/dist/env.d.ts +53 -0
  80. package/dist/env.js +47 -0
  81. package/dist/host/agent.d.ts +305 -0
  82. package/dist/host/agent.js +400 -0
  83. package/dist/host/index.d.ts +20 -0
  84. package/dist/host/index.js +19 -0
  85. package/dist/host/plugin-host.d.ts +42 -0
  86. package/dist/host/plugin-host.js +1 -0
  87. package/dist/index.d.ts +19 -0
  88. package/dist/index.js +17 -0
  89. package/dist/job/index.d.ts +21 -0
  90. package/dist/job/index.js +21 -0
  91. package/dist/job/lifecycle.d.ts +176 -0
  92. package/dist/job/lifecycle.js +230 -0
  93. package/dist/job/state.d.ts +92 -0
  94. package/dist/job/state.js +40 -0
  95. package/dist/platform.d.ts +138 -0
  96. package/dist/platform.js +140 -0
  97. package/dist/round/agent.d.ts +271 -0
  98. package/dist/round/agent.js +678 -0
  99. package/dist/round/index.d.ts +25 -0
  100. package/dist/round/index.js +23 -0
  101. package/dist/round/policy.d.ts +98 -0
  102. package/dist/round/policy.js +1 -0
  103. package/dist/round/subagent.d.ts +87 -0
  104. package/dist/round/subagent.js +108 -0
  105. package/dist/round/turn.d.ts +249 -0
  106. package/dist/round/turn.js +564 -0
  107. package/dist/round/workflow.d.ts +147 -0
  108. package/dist/round/workflow.js +387 -0
  109. package/dist/runtime/index.d.ts +127 -0
  110. package/dist/runtime/index.js +186 -0
  111. package/dist/runtime/tool-families.d.ts +32 -0
  112. package/dist/runtime/tool-families.js +61 -0
  113. package/dist/subagent/fingerprint.d.ts +37 -0
  114. package/dist/subagent/fingerprint.js +92 -0
  115. package/dist/subagent/index.d.ts +169 -0
  116. package/dist/subagent/index.js +330 -0
  117. package/dist/subagent/prompt.d.ts +32 -0
  118. package/dist/subagent/prompt.js +37 -0
  119. package/dist/subagent/run.d.ts +157 -0
  120. package/dist/subagent/run.js +540 -0
  121. package/dist/subagent/workspace.d.ts +85 -0
  122. package/dist/subagent/workspace.js +127 -0
  123. package/dist/subtasks/catalog.d.ts +40 -0
  124. package/dist/subtasks/catalog.js +36 -0
  125. package/dist/subtasks/decomposition.d.ts +85 -0
  126. package/dist/subtasks/decomposition.js +156 -0
  127. package/dist/subtasks/delegate.d.ts +120 -0
  128. package/dist/subtasks/delegate.js +131 -0
  129. package/dist/subtasks/index.d.ts +9 -0
  130. package/dist/subtasks/index.js +9 -0
  131. package/dist/subtasks/subtask-types.d.ts +91 -0
  132. package/dist/subtasks/subtask-types.js +103 -0
  133. package/dist/subtasks/types.d.ts +295 -0
  134. package/dist/subtasks/types.js +15 -0
  135. package/dist/testing/auth.d.ts +34 -0
  136. package/dist/testing/auth.js +35 -0
  137. package/dist/testing/do.d.ts +29 -0
  138. package/dist/testing/do.js +25 -0
  139. package/dist/testing/fake-session.d.ts +26 -0
  140. package/dist/testing/fake-session.js +37 -0
  141. package/dist/testing/fixtures.d.ts +64 -0
  142. package/dist/testing/fixtures.js +104 -0
  143. package/dist/testing/harness.d.ts +97 -0
  144. package/dist/testing/harness.js +138 -0
  145. package/dist/testing/index.d.ts +31 -0
  146. package/dist/testing/index.js +35 -0
  147. package/dist/testing/mock-model.d.ts +77 -0
  148. package/dist/testing/mock-model.js +136 -0
  149. package/dist/testing/node.d.ts +56 -0
  150. package/dist/testing/node.js +56 -0
  151. package/dist/testing/vcr-global-setup.d.ts +12 -0
  152. package/dist/testing/vcr-global-setup.js +15 -0
  153. package/dist/testing/vcr-shared.d.ts +38 -0
  154. package/dist/testing/vcr-shared.js +33 -0
  155. package/dist/testing/vcr-spec.d.ts +25 -0
  156. package/dist/testing/vcr-spec.js +124 -0
  157. package/dist/testing/vcr-store.d.ts +86 -0
  158. package/dist/testing/vcr-store.js +191 -0
  159. package/dist/testing/vcr.d.ts +117 -0
  160. package/dist/testing/vcr.js +275 -0
  161. package/dist/worker/define-agent.d.ts +123 -0
  162. package/dist/worker/define-agent.js +20 -0
  163. package/dist/worker/index.d.ts +218 -0
  164. package/dist/worker/index.js +369 -0
  165. package/eslint-rules/index.js +31 -0
  166. package/eslint-rules/no-deprecated-object-properties.js +81 -0
  167. package/package.json +178 -0
  168. package/scripts/generate-keys.mjs +48 -0
@@ -0,0 +1,34 @@
1
+ export interface GatekeeperTokenOptions {
2
+ /**
3
+ * Who the token is for. Defaults to an agent at the default RPC path
4
+ * (`${AGENT_ORIGIN}${A2A_RPC_PATH}`), which is what a gatekeeper mints: the
5
+ * agent's **endpoint**, not its origin.
6
+ *
7
+ * Pass this explicitly when a spec mounts an agent somewhere else — an agent
8
+ * at `/proactive/a2a` expects that string and refuses this default, which is
9
+ * the whole point of scoping the audience to the endpoint.
10
+ */
11
+ audience?: string;
12
+ issuer?: string;
13
+ /** Relative string ("5m"), absolute epoch seconds, or Date. Past values expire the token. */
14
+ expiresIn?: string | number | Date;
15
+ identity?: Record<string, unknown>;
16
+ /** Claim the identity is carried in. Defaults to core's `IDENTITY_CLAIM`. */
17
+ identityClaim?: string;
18
+ /**
19
+ * Which agent on the target origin this token authorizes, defaulting to
20
+ * `"main"`.
21
+ *
22
+ * The tenants of one deployment share an endpoint and therefore an audience,
23
+ * so this claim is the only thing distinguishing them. Set it to a *different*
24
+ * tenant than the request addresses to exercise the replay case; set it to
25
+ * `""` to mint a token carrying no tenant at all, which is rejected.
26
+ */
27
+ tenant?: string;
28
+ /** Claim the tenant is carried in. Defaults to core's `TENANT_CLAIM`. */
29
+ tenantClaim?: string;
30
+ }
31
+ /** The tenant `makeGatekeeperToken` authorizes unless a spec says otherwise. */
32
+ export declare const TEST_TENANT = "main";
33
+ /** Sign a short-lived EdDSA gatekeeper JWT using the test gatekeeper key. */
34
+ export declare function makeGatekeeperToken(options?: GatekeeperTokenOptions): Promise<string>;
@@ -0,0 +1,35 @@
1
+ import { importJWK, SignJWT } from "jose";
2
+ import { A2A_JWS_ALG, A2A_RPC_PATH, IDENTITY_CLAIM, TENANT_CLAIM, endpointUrl, jwksUrl } from "@dynamicagents/g2a-protocol";
3
+ import { TEST_GATEKEEPER_PRIVATE_JWK, GATEKEEPER_ORIGIN, AGENT_ORIGIN } from "./fixtures.js";
4
+ /** The tenant `makeGatekeeperToken` authorizes unless a spec says otherwise. */
5
+ export const TEST_TENANT = "main";
6
+ /** Sign a short-lived EdDSA gatekeeper JWT using the test gatekeeper key. */
7
+ export async function makeGatekeeperToken(options = {}) {
8
+ const privateKey = await importJWK(TEST_GATEKEEPER_PRIVATE_JWK, "EdDSA");
9
+ const tenant = options.tenant ?? TEST_TENANT;
10
+ return (new SignJWT({
11
+ [options.identityClaim ?? IDENTITY_CLAIM]: options.identity ?? {
12
+ key: "custom:1:test-agent",
13
+ name: "Test Agent",
14
+ kind: "custom",
15
+ workspaceId: 1
16
+ },
17
+ // An empty tenant omits the claim entirely rather than signing `""` — that
18
+ // is the "gatekeeper too old to scope its tokens" case, and it must be
19
+ // rejected rather than treated as a wildcard.
20
+ ...(tenant ? { [options.tenantClaim ?? TENANT_CLAIM]: tenant } : {})
21
+ })
22
+ // Composed from `@dynamicagents/g2a-protocol`, the same package the real
23
+ // gatekeeper mints with. A fixture that agrees with the verifier but not with
24
+ // the issuer is worse than no fixture: the suite goes green on tokens
25
+ // nothing in production would ever send.
26
+ .setProtectedHeader({
27
+ alg: A2A_JWS_ALG,
28
+ kid: TEST_GATEKEEPER_PRIVATE_JWK.kid,
29
+ jku: jwksUrl(GATEKEEPER_ORIGIN)
30
+ })
31
+ .setIssuer(options.issuer ?? GATEKEEPER_ORIGIN)
32
+ .setAudience(options.audience ?? endpointUrl(AGENT_ORIGIN, A2A_RPC_PATH))
33
+ .setExpirationTime(options.expiresIn ?? "5m")
34
+ .sign(privateKey));
35
+ }
@@ -0,0 +1,29 @@
1
+ import { AgentDB, type AgentDBOptions } from "../db/db.js";
2
+ /**
3
+ * Durable Object test helpers.
4
+ *
5
+ * The predecessor hardcoded its own DO binding and exposed one `with*` wrapper
6
+ * per named `AgentDB` domain — including one for a domain that belonged to a
7
+ * plugin. Both are parameterized here: a consumer binds their namespace once,
8
+ * and {@link DoTestHelpers.withDb} hands over the whole `AgentDB` rather than
9
+ * pre-selecting a table.
10
+ */
11
+ export interface DoTestHelpers<T extends Rpc.DurableObjectBranded | undefined> {
12
+ /** Fresh, unique DO stub per test — state never leaks between tests. */
13
+ freshStub(label: string): DurableObjectStub<T>;
14
+ /** Run `fn` inside a fresh DO instance with a migrated {@link AgentDB}. */
15
+ withDb<R>(label: string, fn: (db: AgentDB) => R): Promise<R>;
16
+ }
17
+ /**
18
+ * `ctx` is protected in the DO type system but public at runtime. Cast once so
19
+ * callers don't repeat the assertion.
20
+ */
21
+ export declare function doStorage(instance: unknown): DurableObjectStorage;
22
+ /**
23
+ * Bind the helpers to a consumer's DO namespace.
24
+ *
25
+ * ```ts
26
+ * const { freshStub, withDb } = makeDoHelpers(env.MyAgent);
27
+ * ```
28
+ */
29
+ export declare function makeDoHelpers<T extends Rpc.DurableObjectBranded | undefined = undefined>(ns: DurableObjectNamespace<T>, defaults?: AgentDBOptions): DoTestHelpers<T>;
@@ -0,0 +1,25 @@
1
+ import { runInDurableObject } from "cloudflare:test";
2
+ import { AgentDB } from "../db/db.js";
3
+ /**
4
+ * `ctx` is protected in the DO type system but public at runtime. Cast once so
5
+ * callers don't repeat the assertion.
6
+ */
7
+ export function doStorage(instance) {
8
+ return instance.ctx.storage;
9
+ }
10
+ /**
11
+ * Bind the helpers to a consumer's DO namespace.
12
+ *
13
+ * ```ts
14
+ * const { freshStub, withDb } = makeDoHelpers(env.MyAgent);
15
+ * ```
16
+ */
17
+ export function makeDoHelpers(ns, defaults = { maxSubtasks: 8 }) {
18
+ const freshStub = (label) => ns.get(ns.idFromName(`test:${label}:${crypto.randomUUID()}`));
19
+ return {
20
+ freshStub,
21
+ withDb(label, fn) {
22
+ return runInDurableObject(freshStub(label), (instance) => fn(new AgentDB(doStorage(instance), defaults)));
23
+ }
24
+ };
25
+ }
@@ -0,0 +1,26 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { SessionMessage } from "agents/experimental/memory/session";
3
+ import type { SessionLike } from "../agent/session.js";
4
+ /**
5
+ * An in-memory {@link SessionLike} standing in for the DO's real continuous
6
+ * Session. Shared by the turn, decomposition, and composition specs so a change
7
+ * to the Session contract breaks in one place.
8
+ *
9
+ * Mirrors the two real behaviors the phased pipeline depends on:
10
+ *
11
+ * - `appendMessage` **dedupes by id** — appending an existing id is a no-op, the
12
+ * property that makes deterministic-id appends exactly-once on a step re-run.
13
+ * - `getMessage` reads the raw stored message back.
14
+ */
15
+ export declare class FakeSession implements SessionLike {
16
+ messages: SessionMessage[];
17
+ system: string;
18
+ /** Compaction overlays to report — non-empty ⇒ history has been displaced. */
19
+ compactions: unknown[];
20
+ appendMessage(m: SessionMessage): void;
21
+ getHistory(): Promise<SessionMessage[]>;
22
+ getMessage(id: string): Promise<SessionMessage | null>;
23
+ refreshSystemPrompt(): Promise<string>;
24
+ tools(): Promise<ToolSet>;
25
+ getCompactions(): Promise<unknown[]>;
26
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * An in-memory {@link SessionLike} standing in for the DO's real continuous
3
+ * Session. Shared by the turn, decomposition, and composition specs so a change
4
+ * to the Session contract breaks in one place.
5
+ *
6
+ * Mirrors the two real behaviors the phased pipeline depends on:
7
+ *
8
+ * - `appendMessage` **dedupes by id** — appending an existing id is a no-op, the
9
+ * property that makes deterministic-id appends exactly-once on a step re-run.
10
+ * - `getMessage` reads the raw stored message back.
11
+ */
12
+ export class FakeSession {
13
+ messages = [];
14
+ system = "SOUL BLOCK\n\n## memory\n(empty)";
15
+ /** Compaction overlays to report — non-empty ⇒ history has been displaced. */
16
+ compactions = [];
17
+ appendMessage(m) {
18
+ if (this.messages.some((existing) => existing.id === m.id))
19
+ return;
20
+ this.messages.push(m);
21
+ }
22
+ async getHistory() {
23
+ return this.messages;
24
+ }
25
+ async getMessage(id) {
26
+ return this.messages.find((m) => m.id === id) ?? null;
27
+ }
28
+ async refreshSystemPrompt() {
29
+ return this.system;
30
+ }
31
+ async tools() {
32
+ return {};
33
+ }
34
+ async getCompactions() {
35
+ return this.compactions;
36
+ }
37
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `@dynamicagents/core/testing/fixtures` — the **realm-neutral** slice of the
3
+ * harness: fixed keys, origins, and task/message builders.
4
+ *
5
+ * Its own subpath because it is the one part a consumer's `vitest.config.ts`
6
+ * needs, and that file runs in **Node**. The `/testing` barrel pulls in
7
+ * `cloudflare:test` (DO helpers) and `vitest` (the VCR spec hooks), so importing
8
+ * it from a config fails at load with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before a
9
+ * single test runs. Nothing here imports either, so it is safe from a config, a
10
+ * spec, or a plain Node script.
11
+ *
12
+ * Seeding the required secrets from these values is the intended use:
13
+ *
14
+ * ```ts
15
+ * // vitest.config.ts
16
+ * import { GATEKEEPER_ORIGIN, TEST_AGENT_PRIVATE_JWK } from "@dynamicagents/core/testing/fixtures";
17
+ *
18
+ * process.env.A2A_SIGNING_KEY ??= JSON.stringify(TEST_AGENT_PRIVATE_JWK);
19
+ * process.env.GATEKEEPER_ORIGINS ??= JSON.stringify([GATEKEEPER_ORIGIN]);
20
+ * ```
21
+ */
22
+ import { type JWK } from "jose";
23
+ import { type Message, type Task, type TaskState } from "@a2a-js/sdk";
24
+ /**
25
+ * A model pair for specs, and the only place one should be spelled.
26
+ *
27
+ * Core ships no model default — {@link ModelConfig.chatModelId} explains why —
28
+ * so every `resolveConfig` call needs one, including in tests. Without a shared
29
+ * fixture each spec invents its own id, and a suite full of invented ids is a
30
+ * suite that cannot tell "the config requires a model" from "this spec forgot
31
+ * one".
32
+ *
33
+ * The ids are deliberately obvious placeholders rather than real Workers AI
34
+ * models: nothing here reaches a binding, and a real id in a fixture is an
35
+ * invitation to copy it into an agent without deciding anything.
36
+ */
37
+ export declare const TEST_MODELS: {
38
+ readonly chatModelId: "test:primary";
39
+ readonly fallbackChatModelId: "test:fallback";
40
+ };
41
+ /** The gatekeeper origin used in all tests. Must match vitest.config.ts and the MockAgent setup. */
42
+ export declare const GATEKEEPER_ORIGIN = "https://gatekeeper.test";
43
+ /** Agent origin matching `url.origin` for requests to `http://localhost`. */
44
+ export declare const AGENT_ORIGIN = "http://localhost";
45
+ /** Fixed Ed25519 private JWK used as A2A_SIGNING_KEY in tests. */
46
+ export declare const TEST_AGENT_PRIVATE_JWK: JWK & {
47
+ kid: string;
48
+ };
49
+ /** Fixed Ed25519 private JWK for signing gatekeeper JWTs in tests. */
50
+ export declare const TEST_GATEKEEPER_PRIVATE_JWK: JWK & {
51
+ kid: string;
52
+ };
53
+ /** Public JWKS the gatekeeper would serve at its `jku` (the private key minus `d`). */
54
+ export declare function gatekeeperPublicJwks(): string;
55
+ /**
56
+ * Build an A2A v1.0 `TaskStatus` for tests. The generated protobuf types require
57
+ * every field to be present (`message` included, holding `undefined` when there
58
+ * is none), so this keeps the specs from spelling that out each time.
59
+ */
60
+ export declare function testStatus(state: TaskState, message?: Message): NonNullable<Task["status"]>;
61
+ /** An agent-role `Message` carrying a single v1.0 text part. */
62
+ export declare function testAgentMessage(messageId: string, text: string, contextId?: string, taskId?: string): Message;
63
+ /** A complete v1.0 `Task` in the given state. */
64
+ export declare function testTask(id: string, contextId: string, state: TaskState, message?: Message): Task;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `@dynamicagents/core/testing/fixtures` — the **realm-neutral** slice of the
3
+ * harness: fixed keys, origins, and task/message builders.
4
+ *
5
+ * Its own subpath because it is the one part a consumer's `vitest.config.ts`
6
+ * needs, and that file runs in **Node**. The `/testing` barrel pulls in
7
+ * `cloudflare:test` (DO helpers) and `vitest` (the VCR spec hooks), so importing
8
+ * it from a config fails at load with `ERR_UNSUPPORTED_ESM_URL_SCHEME` before a
9
+ * single test runs. Nothing here imports either, so it is safe from a config, a
10
+ * spec, or a plain Node script.
11
+ *
12
+ * Seeding the required secrets from these values is the intended use:
13
+ *
14
+ * ```ts
15
+ * // vitest.config.ts
16
+ * import { GATEKEEPER_ORIGIN, TEST_AGENT_PRIVATE_JWK } from "@dynamicagents/core/testing/fixtures";
17
+ *
18
+ * process.env.A2A_SIGNING_KEY ??= JSON.stringify(TEST_AGENT_PRIVATE_JWK);
19
+ * process.env.GATEKEEPER_ORIGINS ??= JSON.stringify([GATEKEEPER_ORIGIN]);
20
+ * ```
21
+ */
22
+ import {} from "jose";
23
+ import { Role } from "@a2a-js/sdk";
24
+ /**
25
+ * A model pair for specs, and the only place one should be spelled.
26
+ *
27
+ * Core ships no model default — {@link ModelConfig.chatModelId} explains why —
28
+ * so every `resolveConfig` call needs one, including in tests. Without a shared
29
+ * fixture each spec invents its own id, and a suite full of invented ids is a
30
+ * suite that cannot tell "the config requires a model" from "this spec forgot
31
+ * one".
32
+ *
33
+ * The ids are deliberately obvious placeholders rather than real Workers AI
34
+ * models: nothing here reaches a binding, and a real id in a fixture is an
35
+ * invitation to copy it into an agent without deciding anything.
36
+ */
37
+ export const TEST_MODELS = {
38
+ chatModelId: "test:primary",
39
+ fallbackChatModelId: "test:fallback"
40
+ };
41
+ /** The gatekeeper origin used in all tests. Must match vitest.config.ts and the MockAgent setup. */
42
+ export const GATEKEEPER_ORIGIN = "https://gatekeeper.test";
43
+ /** Agent origin matching `url.origin` for requests to `http://localhost`. */
44
+ export const AGENT_ORIGIN = "http://localhost";
45
+ /** Fixed Ed25519 private JWK used as A2A_SIGNING_KEY in tests. */
46
+ export const TEST_AGENT_PRIVATE_JWK = {
47
+ crv: "Ed25519",
48
+ d: "sbR9EgZV1zUY-K6ENkvSLY8c8Q9kJ9NnxsXc4GVx_1g",
49
+ x: "1dXrUHeE89GBnZbd7MjzJK-3Xvu7khZCK9ZrQauZQ6s",
50
+ kty: "OKP",
51
+ kid: "test-agent-key-1"
52
+ };
53
+ /** Fixed Ed25519 private JWK for signing gatekeeper JWTs in tests. */
54
+ export const TEST_GATEKEEPER_PRIVATE_JWK = {
55
+ crv: "Ed25519",
56
+ d: "OVKcn3LDH-qybNIdUbr7T9wbmlxNk2maU4_nILbaLKY",
57
+ x: "jYiAbquXL6db7RihLvp2nsp1ShAolDI0tGOjuwsZVnI",
58
+ kty: "OKP",
59
+ kid: "test-gk-key-1"
60
+ };
61
+ /** Public JWKS the gatekeeper would serve at its `jku` (the private key minus `d`). */
62
+ export function gatekeeperPublicJwks() {
63
+ const { d: _d, ...pub } = TEST_GATEKEEPER_PRIVATE_JWK;
64
+ return JSON.stringify({ keys: [{ ...pub, use: "sig", alg: "EdDSA" }] });
65
+ }
66
+ /**
67
+ * Build an A2A v1.0 `TaskStatus` for tests. The generated protobuf types require
68
+ * every field to be present (`message` included, holding `undefined` when there
69
+ * is none), so this keeps the specs from spelling that out each time.
70
+ */
71
+ export function testStatus(state, message) {
72
+ return { state, message, timestamp: new Date().toISOString() };
73
+ }
74
+ /** An agent-role `Message` carrying a single v1.0 text part. */
75
+ export function testAgentMessage(messageId, text, contextId = "", taskId = "") {
76
+ return {
77
+ messageId,
78
+ contextId,
79
+ taskId,
80
+ role: Role.ROLE_AGENT,
81
+ parts: [
82
+ {
83
+ content: { $case: "text", value: text },
84
+ metadata: undefined,
85
+ filename: "",
86
+ mediaType: "text/plain"
87
+ }
88
+ ],
89
+ metadata: undefined,
90
+ extensions: [],
91
+ referenceTaskIds: []
92
+ };
93
+ }
94
+ /** A complete v1.0 `Task` in the given state. */
95
+ export function testTask(id, contextId, state, message) {
96
+ return {
97
+ id,
98
+ contextId,
99
+ status: testStatus(state, message),
100
+ artifacts: [],
101
+ history: [],
102
+ metadata: undefined
103
+ };
104
+ }
@@ -0,0 +1,97 @@
1
+ import type { PlainTask } from "../a2a/task.js";
2
+ /**
3
+ * Drive one A2A turn against a Worker the way a gatekeeper does.
4
+ *
5
+ * Core already shipped every *piece* of this — a token signer, Ed25519 fixtures,
6
+ * a fake session, a scripted model — and no assembly, so every consumer wrote the
7
+ * assembly themselves and got the same four things wrong first: the token's
8
+ * audience is the **endpoint** and not the origin, the tenant claim has to match
9
+ * the tenant in the body, `SendMessage` is refused without a push config, and the
10
+ * gatekeeper's own JWKS has to be reachable or verification fails before anything
11
+ * interesting happens.
12
+ *
13
+ * None of that is a property of any one agent, so none of it should be written
14
+ * more than once.
15
+ *
16
+ * ```ts
17
+ * const harness = createAgentHarness({ worker, env, tenant: "reactive" });
18
+ * using _ = harness.interceptGatekeeper();
19
+ *
20
+ * const accepted = await harness.send("what's the weather?");
21
+ * expect(accepted.status.state).toBe(TaskState.TASK_STATE_SUBMITTED);
22
+ * ```
23
+ *
24
+ * What it covers is the **synchronous accept**: everything from the gatekeeper's
25
+ * bearer token to the `submitted` Task the Worker returns. The turn itself runs
26
+ * in a Workflow the test runtime does not start, so assert on the callbacks with
27
+ * {@link AgentHarness.callbacks} while driving the workflow body directly with a
28
+ * fake `step`.
29
+ */
30
+ /** The minimum of an `ExportedHandler` this harness calls. */
31
+ export interface HarnessWorker<TEnv> {
32
+ fetch(request: Request, env: TEnv): Promise<Response> | Response;
33
+ }
34
+ export interface AgentHarnessOptions<TEnv> {
35
+ /** The Worker module under test — usually `import worker from "@/index"`. */
36
+ worker: HarnessWorker<TEnv>;
37
+ /** The `env` the pool built from `wrangler.jsonc`. */
38
+ env: TEnv;
39
+ /** Which agent to address. Must be a tenant the Worker mounts. */
40
+ tenant?: string;
41
+ /** Where the Worker serves JSON-RPC. Defaults to core's `/a2a`. */
42
+ rpcPath?: string;
43
+ /** The origin the Worker is addressed on. Defaults to the test fixture's. */
44
+ origin?: string;
45
+ }
46
+ /** One push notification the agent POSTed back, already decoded. */
47
+ export interface CapturedCallback {
48
+ /** The `taskId` the callback was about. */
49
+ taskId: string;
50
+ /** The task state, e.g. `TASK_STATE_WORKING`. */
51
+ state: string;
52
+ /** The message text, joined across parts. `""` for a no-reply completion. */
53
+ text: string;
54
+ /** The per-task validation token echoed in the callback header. */
55
+ token: string | null;
56
+ /** The raw decoded body, for anything the projection above drops. */
57
+ body: unknown;
58
+ }
59
+ export interface AgentHarness {
60
+ /** The endpoint a token is minted for — the audience, not the origin. */
61
+ readonly endpoint: string;
62
+ /** The gatekeeper webhook the agent is told to call back on. */
63
+ readonly pushUrl: string;
64
+ /**
65
+ * Send one turn and return the accepted Task.
66
+ *
67
+ * Throws on a JSON-RPC error rather than returning it, so a spec asserting the
68
+ * happy path fails with the agent's own message instead of on a later
69
+ * `undefined`. Use {@link rpc} for the refusal cases.
70
+ */
71
+ send(text: string, options?: {
72
+ taskId?: string;
73
+ }): Promise<PlainTask>;
74
+ /**
75
+ * One raw JSON-RPC call with a valid gatekeeper token, returning the `Response`.
76
+ * For specs about what the edge *refuses*.
77
+ */
78
+ rpc(body: unknown, init?: {
79
+ token?: string;
80
+ }): Promise<Response>;
81
+ /** A valid gatekeeper token for this harness's tenant and endpoint. */
82
+ token(overrides?: {
83
+ tenant?: string;
84
+ }): Promise<string>;
85
+ /**
86
+ * Stub `fetch` so the gatekeeper's JWKS resolves and every push callback is
87
+ * captured instead of leaving the isolate.
88
+ *
89
+ * Returns a disposable — `using _ = harness.interceptGatekeeper()` — that restores
90
+ * the global on scope exit. Anything neither the JWKS nor the webhook 404s, so
91
+ * an unexpected outbound call fails loudly rather than hanging.
92
+ */
93
+ interceptGatekeeper(): Disposable;
94
+ /** Callbacks captured since {@link interceptGatekeeper}, in arrival order. */
95
+ readonly callbacks: CapturedCallback[];
96
+ }
97
+ export declare function createAgentHarness<TEnv>(options: AgentHarnessOptions<TEnv>): AgentHarness;
@@ -0,0 +1,138 @@
1
+ import { A2A_PROTOCOL_VERSION } from "@a2a-js/sdk";
2
+ import { A2A_RPC_PATH, NOTIFICATION_TOKEN_HEADER, endpointUrl, jwksUrl } from "@dynamicagents/g2a-protocol";
3
+ import { makeGatekeeperToken, TEST_TENANT } from "./auth.js";
4
+ import { AGENT_ORIGIN, GATEKEEPER_ORIGIN, gatekeeperPublicJwks } from "./fixtures.js";
5
+ /**
6
+ * Project the push envelope onto the three fields a spec usually asserts.
7
+ *
8
+ * Reads `status.message.parts` — the key the wire form actually uses. It read
9
+ * `content` until `harness.spec.ts` drove a real callback through it, which
10
+ * made `text` the empty string on every capture: silently, since a harness
11
+ * reporting `""` for what an agent said looks exactly like an agent that said
12
+ * nothing, and the no-reply completion is a legitimate outcome.
13
+ */
14
+ function projectCallback(body, token) {
15
+ const task = body.task ?? body;
16
+ const t = task;
17
+ const parts = t.status?.message?.parts ?? [];
18
+ return {
19
+ taskId: t.id ?? "",
20
+ state: t.status?.state ?? "",
21
+ text: parts
22
+ .map((p) => p.text ?? "")
23
+ .join("")
24
+ .trim(),
25
+ token,
26
+ body
27
+ };
28
+ }
29
+ export function createAgentHarness(options) {
30
+ const origin = options.origin ?? AGENT_ORIGIN;
31
+ const rpcPath = options.rpcPath ?? A2A_RPC_PATH;
32
+ // The audience a gatekeeper actually mints: the endpoint this deployment serves,
33
+ // which is also exactly what its card advertises as its interface. Composed
34
+ // with the protocol package's own helper, so a harness cannot agree with the
35
+ // verifier while disagreeing with what a real gatekeeper would send.
36
+ const endpoint = endpointUrl(origin, rpcPath);
37
+ const tenant = options.tenant ?? TEST_TENANT;
38
+ const pushUrl = `${GATEKEEPER_ORIGIN}/a2a/push`;
39
+ const pushToken = "harness-push-token";
40
+ const callbacks = [];
41
+ const token = (overrides = {}) => makeGatekeeperToken({
42
+ audience: endpoint,
43
+ tenant: overrides.tenant ?? tenant
44
+ });
45
+ const rpc = async (body, init = {}) => options.worker.fetch(new Request(endpoint, {
46
+ method: "POST",
47
+ headers: {
48
+ "content-type": "application/json",
49
+ "A2A-Version": A2A_PROTOCOL_VERSION,
50
+ authorization: `Bearer ${init.token ?? (await token())}`
51
+ },
52
+ body: JSON.stringify(body)
53
+ }), options.env);
54
+ return {
55
+ endpoint,
56
+ pushUrl,
57
+ callbacks,
58
+ token,
59
+ rpc,
60
+ async send(text, sendOptions = {}) {
61
+ const messageId = crypto.randomUUID();
62
+ const res = await rpc({
63
+ jsonrpc: "2.0",
64
+ id: 1,
65
+ method: "SendMessage",
66
+ // `SendMessageRequest` is **flat**: `tenant`, `message`, `configuration`,
67
+ // `metadata`. There is no `request` wrapper, and wrapping is silent when
68
+ // you do it — `fromJSON` drops unknown keys rather than rejecting them,
69
+ // so the whole turn decodes to an empty message with no push config and
70
+ // the agent refuses it for "missing" fields the caller did send.
71
+ //
72
+ // This harness shipped with exactly that envelope. Nothing caught it,
73
+ // because nothing in this package drove the harness against a real
74
+ // Worker until `harness.spec.ts` — which is the argument for that spec
75
+ // existing, made by the code it tests.
76
+ params: {
77
+ tenant,
78
+ message: {
79
+ messageId,
80
+ role: "ROLE_USER",
81
+ // `parts`, not `content`. The decoded `Message` exposes `parts`, and
82
+ // `fromJSON` silently yields an empty list for anything else — so a
83
+ // turn sent under the wrong key arrives as a message with no text
84
+ // and the agent answers a blank prompt.
85
+ parts: [{ text }],
86
+ ...(sendOptions.taskId ? { taskId: sendOptions.taskId } : {})
87
+ },
88
+ configuration: {
89
+ // Required by the accept-and-notify contract: an agent that replies
90
+ // out of band and is given nowhere to call back has accepted a turn
91
+ // it can never answer.
92
+ taskPushNotificationConfig: { url: pushUrl, token: pushToken }
93
+ }
94
+ }
95
+ });
96
+ const envelope = await res.json();
97
+ if (envelope.error) {
98
+ throw new Error(`SendMessage was refused (${envelope.error.code}): ${envelope.error.message}`);
99
+ }
100
+ const result = envelope.result;
101
+ const task = (result?.task ?? result);
102
+ if (!task) {
103
+ throw new Error(`SendMessage returned no task: ${JSON.stringify(envelope)}`);
104
+ }
105
+ return task;
106
+ },
107
+ interceptGatekeeper() {
108
+ const original = globalThis.fetch;
109
+ globalThis.fetch = (async (input, init) => {
110
+ const url = typeof input === "string" ? input : String(input);
111
+ // The gatekeeper's public JWKS — without it every token fails to verify and
112
+ // every spec below it reports a 401 that has nothing to do with its subject.
113
+ if (url === jwksUrl(GATEKEEPER_ORIGIN)) {
114
+ return new Response(gatekeeperPublicJwks(), {
115
+ headers: { "content-type": "application/json" }
116
+ });
117
+ }
118
+ if (url === pushUrl) {
119
+ const request = new Request(input, init);
120
+ const body = await request.json().catch(() => null);
121
+ callbacks.push(projectCallback(body, request.headers.get(NOTIFICATION_TOKEN_HEADER)));
122
+ return new Response(null, { status: 202 });
123
+ }
124
+ // Anything else is a call this harness did not expect. 404 rather than
125
+ // pass through: a spec that silently reaches the real network is a spec
126
+ // that passes for the wrong reason.
127
+ return new Response(`unexpected outbound fetch: ${url}`, {
128
+ status: 404
129
+ });
130
+ });
131
+ return {
132
+ [Symbol.dispose]() {
133
+ globalThis.fetch = original;
134
+ }
135
+ };
136
+ }
137
+ };
138
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@dynamicagents/core/testing` — the harness both predecessor agents grew, shipped so
3
+ * a consumer does not grow it a third time.
4
+ *
5
+ * Never import this from runtime code. It is a separate subpath precisely so it
6
+ * cannot reach a production bundle, and it pulls in `cloudflare:test`, which
7
+ * does not exist in a deployed Worker.
8
+ *
9
+ * **This barrel is the workerd half** — safe to import from a spec. The VCR
10
+ * recorder needs `node:fs`, so it lives behind `@dynamicagents/core/testing/node`
11
+ * and must not be re-exported here: pulling it into this graph would drag Node
12
+ * builtins into every spec that wanted a fixture. `vcr-shared.ts` is the seam
13
+ * both realms may load.
14
+ *
15
+ * Three things live here:
16
+ *
17
+ * - **VCR (spec side)** — `setupRecording()`, which names a cassette per test and
18
+ * talks to the Node-side recorder over the in-band control channel.
19
+ * - **Fakes** — a `SessionLike` reference implementation and a scripted
20
+ * `LanguageModel`, so a loop can be driven with no model call at all.
21
+ * - **Fixtures** — Ed25519 keypairs and a gatekeeper-JWT signer, so the zero-trust
22
+ * path can be exercised end to end without a real gatekeeper.
23
+ */
24
+ export { setupRecording, cassetteNameFor, type SetupRecordingOptions } from "./vcr-spec.js";
25
+ export { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE, type VcrReleaseResult } from "./vcr-shared.js";
26
+ export { FakeSession } from "./fake-session.js";
27
+ export { mockModel, finalReply, throwingModel, countingModel, rateLimitedModel, type MockStep } from "./mock-model.js";
28
+ export { makeGatekeeperToken, type GatekeeperTokenOptions } from "./auth.js";
29
+ export { AGENT_ORIGIN, GATEKEEPER_ORIGIN, TEST_AGENT_PRIVATE_JWK, TEST_GATEKEEPER_PRIVATE_JWK, TEST_MODELS, gatekeeperPublicJwks, testAgentMessage, testStatus, testTask } from "./fixtures.js";
30
+ export { doStorage, makeDoHelpers, type DoTestHelpers } from "./do.js";
31
+ export { createAgentHarness, type AgentHarness, type AgentHarnessOptions, type CapturedCallback, type HarnessWorker } from "./harness.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `@dynamicagents/core/testing` — the harness both predecessor agents grew, shipped so
3
+ * a consumer does not grow it a third time.
4
+ *
5
+ * Never import this from runtime code. It is a separate subpath precisely so it
6
+ * cannot reach a production bundle, and it pulls in `cloudflare:test`, which
7
+ * does not exist in a deployed Worker.
8
+ *
9
+ * **This barrel is the workerd half** — safe to import from a spec. The VCR
10
+ * recorder needs `node:fs`, so it lives behind `@dynamicagents/core/testing/node`
11
+ * and must not be re-exported here: pulling it into this graph would drag Node
12
+ * builtins into every spec that wanted a fixture. `vcr-shared.ts` is the seam
13
+ * both realms may load.
14
+ *
15
+ * Three things live here:
16
+ *
17
+ * - **VCR (spec side)** — `setupRecording()`, which names a cassette per test and
18
+ * talks to the Node-side recorder over the in-band control channel.
19
+ * - **Fakes** — a `SessionLike` reference implementation and a scripted
20
+ * `LanguageModel`, so a loop can be driven with no model call at all.
21
+ * - **Fixtures** — Ed25519 keypairs and a gatekeeper-JWT signer, so the zero-trust
22
+ * path can be exercised end to end without a real gatekeeper.
23
+ */
24
+ export { setupRecording, cassetteNameFor } from "./vcr-spec.js";
25
+ export { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE } from "./vcr-shared.js";
26
+ export { FakeSession } from "./fake-session.js";
27
+ // `throwingModel`, `countingModel` and `rateLimitedModel` were reachable only
28
+ // through a deep `dist/` path until now, which meant a consumer could not assert
29
+ // the one thing they exist for: that a rate limit is waited out on the *same*
30
+ // model rather than falling through to the fallback slot.
31
+ export { mockModel, finalReply, throwingModel, countingModel, rateLimitedModel } from "./mock-model.js";
32
+ export { makeGatekeeperToken } from "./auth.js";
33
+ export { AGENT_ORIGIN, GATEKEEPER_ORIGIN, TEST_AGENT_PRIVATE_JWK, TEST_GATEKEEPER_PRIVATE_JWK, TEST_MODELS, gatekeeperPublicJwks, testAgentMessage, testStatus, testTask } from "./fixtures.js";
34
+ export { doStorage, makeDoHelpers } from "./do.js";
35
+ export { createAgentHarness } from "./harness.js";