@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,77 @@
1
+ import { MockLanguageModelV3 } from "ai/test";
2
+ export interface MockStep {
3
+ /**
4
+ * Assistant text for this step. On its own → finishReason "stop", which for the
5
+ * main-agent round is a *failed* attempt: prose is not an ending there, so use
6
+ * {@link finalReply} to script an answer. Alongside `toolCall` → the
7
+ * intermediate content emitted before a tool call.
8
+ */
9
+ text?: string;
10
+ /** Emit a tool call (finishReason "tool-calls"); may accompany `text`. */
11
+ toolCall?: {
12
+ toolName: string;
13
+ input?: unknown;
14
+ };
15
+ /**
16
+ * Emit several tool calls in **one** step, which a real model does whenever it
17
+ * decides two things at once. That is the only way to script the cases the round
18
+ * has to arbitrate: two different endings in one step (precedence decides), or
19
+ * the same ending twice (the tool's own `parse` decides).
20
+ */
21
+ toolCalls?: {
22
+ toolName: string;
23
+ input?: unknown;
24
+ }[];
25
+ }
26
+ /**
27
+ * A step where the main agent answers the user — the `final_reply` control call.
28
+ *
29
+ * Scripting a bare `{ text }` instead reproduces the bug this tool exists to catch
30
+ * (a model narrating rather than acting) and fails the attempt, which is what the
31
+ * error-path specs use it for.
32
+ */
33
+ export declare function finalReply(text: string, opts?: {
34
+ text?: string;
35
+ }): MockStep;
36
+ /**
37
+ * A mock model that returns each step in sequence — one per `generateText` call.
38
+ * Uses the function form (with our own counter) rather than the array form, whose
39
+ * call-count indexing is off by one in this SDK version. Extra calls repeat the
40
+ * last step.
41
+ */
42
+ export declare function mockModel(...steps: MockStep[]): MockLanguageModelV3;
43
+ /**
44
+ * A model whose every call throws, and a count of how many times it was asked.
45
+ *
46
+ * The count is the point. Several of the attempt ladder's rules are about a call
47
+ * that must *not* happen — a fallback slot left unspent, a repair not attempted
48
+ * — and those are invisible to an assertion on the returned outcome alone, which
49
+ * can be right for the wrong reason.
50
+ */
51
+ export declare function throwingModel(error: unknown): {
52
+ model: MockLanguageModelV3;
53
+ calls: () => number;
54
+ };
55
+ /** {@link mockModel}, plus the same call count {@link throwingModel} reports. */
56
+ export declare function countingModel(...steps: MockStep[]): {
57
+ model: MockLanguageModelV3;
58
+ calls: () => number;
59
+ };
60
+ /**
61
+ * A model that fails the first `failures` calls with a retryable `APICallError`,
62
+ * then behaves like {@link mockModel}.
63
+ *
64
+ * Exists for the one behaviour a scripted-outcome assertion cannot see: whether
65
+ * a rate limit was *waited out on the same model* or fell straight through to
66
+ * the fallback. Only the call count distinguishes them — both produce a
67
+ * successful round.
68
+ *
69
+ * `retry-after: 0` is deliberate. The AI SDK honours the header and its own
70
+ * backoff opens at two seconds, which would spend real seconds asserting
71
+ * something that has nothing to do with duration. Zero exercises the identical
72
+ * path — header parsed, preferred over the exponential delay, waited — for free.
73
+ */
74
+ export declare function rateLimitedModel(failures: number, ...steps: MockStep[]): {
75
+ model: MockLanguageModelV3;
76
+ calls: () => number;
77
+ };
@@ -0,0 +1,136 @@
1
+ import { MockLanguageModelV3 } from "ai/test";
2
+ // From `ai`, not `@ai-sdk/provider`. `ai` re-exports the class and is a declared
3
+ // peer; reaching for the provider package directly makes this module — which is
4
+ // on the published `/testing` subpath every consumer loads — depend on a package
5
+ // core does not declare and only resolves today by hoisting.
6
+ import { APICallError } from "ai";
7
+ /**
8
+ * Test doubles for the LLM. Lets the tool-loop / executor specs run the real
9
+ * `generateText` machinery (tool execution, multi-step, fallback) against a
10
+ * scripted model with no network or `AI` binding.
11
+ */
12
+ /** Zeroed usage block satisfying the LanguageModelV3 result shape. */
13
+ const USAGE = {
14
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
15
+ outputTokens: { total: 0, text: 0, reasoning: 0 }
16
+ };
17
+ /**
18
+ * A step where the main agent answers the user — the `final_reply` control call.
19
+ *
20
+ * Scripting a bare `{ text }` instead reproduces the bug this tool exists to catch
21
+ * (a model narrating rather than acting) and fails the attempt, which is what the
22
+ * error-path specs use it for.
23
+ */
24
+ export function finalReply(text, opts = {}) {
25
+ return {
26
+ ...(opts.text !== undefined ? { text: opts.text } : {}),
27
+ toolCall: { toolName: "final_reply", input: { text } }
28
+ };
29
+ }
30
+ function stepResult(step) {
31
+ const content = [];
32
+ // Keep the empty-string text part so a `{ text: "" }` step still yields "".
33
+ if (step.text !== undefined)
34
+ content.push({ type: "text", text: step.text });
35
+ const calls = [
36
+ ...(step.toolCall ? [step.toolCall] : []),
37
+ ...(step.toolCalls ?? [])
38
+ ];
39
+ for (const call of calls) {
40
+ content.push({
41
+ type: "tool-call",
42
+ toolCallId: crypto.randomUUID(),
43
+ toolName: call.toolName,
44
+ input: JSON.stringify(call.input ?? {})
45
+ });
46
+ }
47
+ const unified = calls.length > 0 ? "tool-calls" : "stop";
48
+ return {
49
+ content,
50
+ finishReason: { unified, raw: undefined },
51
+ usage: USAGE,
52
+ warnings: []
53
+ };
54
+ }
55
+ /**
56
+ * A mock model that returns each step in sequence — one per `generateText` call.
57
+ * Uses the function form (with our own counter) rather than the array form, whose
58
+ * call-count indexing is off by one in this SDK version. Extra calls repeat the
59
+ * last step.
60
+ */
61
+ export function mockModel(...steps) {
62
+ let i = 0;
63
+ return new MockLanguageModelV3({
64
+ doGenerate: async () => stepResult(steps[Math.min(i++, steps.length - 1)])
65
+ });
66
+ }
67
+ /**
68
+ * A model whose every call throws, and a count of how many times it was asked.
69
+ *
70
+ * The count is the point. Several of the attempt ladder's rules are about a call
71
+ * that must *not* happen — a fallback slot left unspent, a repair not attempted
72
+ * — and those are invisible to an assertion on the returned outcome alone, which
73
+ * can be right for the wrong reason.
74
+ */
75
+ export function throwingModel(error) {
76
+ let calls = 0;
77
+ return {
78
+ model: new MockLanguageModelV3({
79
+ doGenerate: async () => {
80
+ calls += 1;
81
+ throw error;
82
+ }
83
+ }),
84
+ calls: () => calls
85
+ };
86
+ }
87
+ /** {@link mockModel}, plus the same call count {@link throwingModel} reports. */
88
+ export function countingModel(...steps) {
89
+ let calls = 0;
90
+ let i = 0;
91
+ return {
92
+ model: new MockLanguageModelV3({
93
+ doGenerate: async () => {
94
+ calls += 1;
95
+ return stepResult(steps[Math.min(i++, steps.length - 1)]);
96
+ }
97
+ }),
98
+ calls: () => calls
99
+ };
100
+ }
101
+ /**
102
+ * A model that fails the first `failures` calls with a retryable `APICallError`,
103
+ * then behaves like {@link mockModel}.
104
+ *
105
+ * Exists for the one behaviour a scripted-outcome assertion cannot see: whether
106
+ * a rate limit was *waited out on the same model* or fell straight through to
107
+ * the fallback. Only the call count distinguishes them — both produce a
108
+ * successful round.
109
+ *
110
+ * `retry-after: 0` is deliberate. The AI SDK honours the header and its own
111
+ * backoff opens at two seconds, which would spend real seconds asserting
112
+ * something that has nothing to do with duration. Zero exercises the identical
113
+ * path — header parsed, preferred over the exponential delay, waited — for free.
114
+ */
115
+ export function rateLimitedModel(failures, ...steps) {
116
+ let calls = 0;
117
+ let i = 0;
118
+ return {
119
+ model: new MockLanguageModelV3({
120
+ doGenerate: async () => {
121
+ calls += 1;
122
+ if (calls <= failures) {
123
+ throw new APICallError({
124
+ message: "429 Wholesale Rate limited",
125
+ url: "mock:chat:test",
126
+ requestBodyValues: {},
127
+ statusCode: 429,
128
+ responseHeaders: { "retry-after": "0" }
129
+ });
130
+ }
131
+ return stepResult(steps[Math.min(i++, steps.length - 1)]);
132
+ }
133
+ }),
134
+ calls: () => calls
135
+ };
136
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `@dynamicagents/core/testing/node` — the **Node-realm** half of the VCR harness.
3
+ *
4
+ * Separate from `@dynamicagents/core/testing` because the two halves cannot share a
5
+ * module graph. This side reaches `node:fs`; the other side runs inside workerd,
6
+ * which has no filesystem. `vcr-shared.ts` is the only thing both may touch, and
7
+ * it is deliberately dependency-free so it can load in either realm.
8
+ *
9
+ * Import this from a Vitest **config** (or anything else running in Node), never
10
+ * from a spec:
11
+ *
12
+ * ```ts
13
+ * // vitest.config.ts
14
+ * import { defineConfig } from "vitest/config";
15
+ * import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
16
+ * import path from "node:path";
17
+ * import { createVcr, recordFromEnv } from "@dynamicagents/core/testing/node";
18
+ *
19
+ * const vcr = createVcr({
20
+ * snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
21
+ * record: recordFromEnv(),
22
+ * // Why a committed cassette is safe, and why playback needs no credentials.
23
+ * excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
24
+ * });
25
+ *
26
+ * export default defineConfig({
27
+ * plugins: [
28
+ * cloudflareTest({
29
+ * wrangler: { configPath: "./wrangler.jsonc" },
30
+ * miniflare: { outboundService: vcr.outboundService }
31
+ * })
32
+ * ],
33
+ * test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
34
+ * });
35
+ * ```
36
+ *
37
+ * `RECORD=1` captures live traffic into every cassette a run activates; anything
38
+ * else replays. A host with no active cassette is **blocked**, so a test that
39
+ * was never wired for recording cannot reach the network by accident.
40
+ *
41
+ * ## Version constraints: there are none
42
+ *
43
+ * This used to carry two, both of which failed unreadably, and both of which
44
+ * were consequences of installing the recorder as Miniflare's `fetchMock`:
45
+ * the pool had to be `^0.18` (Miniflare 5 dropped the option, and pool 0.20
46
+ * removed it from its overrides), and your `undici` had to be byte-identical to
47
+ * Miniflare's (`fetchMock` was validated with `instanceof MockAgent`).
48
+ *
49
+ * The recorder is now a plain `outboundService` function — the hook `fetchMock`
50
+ * was sugar over in the first place. It is present and identical in Miniflare 4
51
+ * and 5, it type-checks against both, and it involves no `undici` at all. Core
52
+ * declares no `undici` peer and the pool peer is open.
53
+ */
54
+ export { createVcr, recordFromEnv, closeVcr, type Vcr, type VcrOptions, type VcrRequest, type VcrRequestHeaders, type VcrOutboundService } from "./vcr.js";
55
+ export { Cassette, requestKey, replayable, type CassetteEntry, type CassetteOptions, type RecordedRequest, type RecordedResponse, type ReplayableResponse } from "./vcr-store.js";
56
+ export { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE, type VcrReleaseResult } from "./vcr-shared.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `@dynamicagents/core/testing/node` — the **Node-realm** half of the VCR harness.
3
+ *
4
+ * Separate from `@dynamicagents/core/testing` because the two halves cannot share a
5
+ * module graph. This side reaches `node:fs`; the other side runs inside workerd,
6
+ * which has no filesystem. `vcr-shared.ts` is the only thing both may touch, and
7
+ * it is deliberately dependency-free so it can load in either realm.
8
+ *
9
+ * Import this from a Vitest **config** (or anything else running in Node), never
10
+ * from a spec:
11
+ *
12
+ * ```ts
13
+ * // vitest.config.ts
14
+ * import { defineConfig } from "vitest/config";
15
+ * import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
16
+ * import path from "node:path";
17
+ * import { createVcr, recordFromEnv } from "@dynamicagents/core/testing/node";
18
+ *
19
+ * const vcr = createVcr({
20
+ * snapshotsDir: path.resolve(import.meta.dirname, "test/snapshots"),
21
+ * record: recordFromEnv(),
22
+ * // Why a committed cassette is safe, and why playback needs no credentials.
23
+ * excludeHeaders: ["x-api-key", "authorization", "cookie", "set-cookie"]
24
+ * });
25
+ *
26
+ * export default defineConfig({
27
+ * plugins: [
28
+ * cloudflareTest({
29
+ * wrangler: { configPath: "./wrangler.jsonc" },
30
+ * miniflare: { outboundService: vcr.outboundService }
31
+ * })
32
+ * ],
33
+ * test: { globalSetup: ["@dynamicagents/core/testing/vcr-global-setup"] }
34
+ * });
35
+ * ```
36
+ *
37
+ * `RECORD=1` captures live traffic into every cassette a run activates; anything
38
+ * else replays. A host with no active cassette is **blocked**, so a test that
39
+ * was never wired for recording cannot reach the network by accident.
40
+ *
41
+ * ## Version constraints: there are none
42
+ *
43
+ * This used to carry two, both of which failed unreadably, and both of which
44
+ * were consequences of installing the recorder as Miniflare's `fetchMock`:
45
+ * the pool had to be `^0.18` (Miniflare 5 dropped the option, and pool 0.20
46
+ * removed it from its overrides), and your `undici` had to be byte-identical to
47
+ * Miniflare's (`fetchMock` was validated with `instanceof MockAgent`).
48
+ *
49
+ * The recorder is now a plain `outboundService` function — the hook `fetchMock`
50
+ * was sugar over in the first place. It is present and identical in Miniflare 4
51
+ * and 5, it type-checks against both, and it involves no `undici` at all. Core
52
+ * declares no `undici` peer and the pool peer is open.
53
+ */
54
+ export { createVcr, recordFromEnv, closeVcr } from "./vcr.js";
55
+ export { Cassette, requestKey, replayable } from "./vcr-store.js";
56
+ export { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER, CASSETTE_NAME_RE } from "./vcr-shared.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Vitest `globalSetup`: the exported teardown runs after all tests but before
3
+ * Vite tears down its server, so it is the last point at which a cassette can
4
+ * still be written.
5
+ *
6
+ * Belt and braces rather than load-bearing — each cassette is already flushed
7
+ * when its test releases it, so a run that ends normally has nothing left to do
8
+ * here, and a run that dies mid-test keeps everything recorded up to that point.
9
+ * The recorder holds no sockets and no timers, so nothing here has to close.
10
+ */
11
+ export declare function setup(): void;
12
+ export declare function teardown(): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { closeVcr } from "./vcr.js";
2
+ /**
3
+ * Vitest `globalSetup`: the exported teardown runs after all tests but before
4
+ * Vite tears down its server, so it is the last point at which a cassette can
5
+ * still be written.
6
+ *
7
+ * Belt and braces rather than load-bearing — each cassette is already flushed
8
+ * when its test releases it, so a run that ends normally has nothing left to do
9
+ * here, and a run that dies mid-test keeps everything recorded up to that point.
10
+ * The recorder holds no sockets and no timers, so nothing here has to close.
11
+ */
12
+ export function setup() { }
13
+ export async function teardown() {
14
+ await closeVcr();
15
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Constants shared between the two sides of the VCR harness that must NOT import
3
+ * each other: the Node-side recorder (`vcr.ts` / `vcr-store.ts`, which reach
4
+ * `node:fs`) and the worker-side spec helper (`vcr-spec.ts`, which runs in
5
+ * workerd). Keep this file dependency-free so both realms can load it.
6
+ */
7
+ /**
8
+ * Reserved origin for the in-band control channel. A recorded spec `fetch`es
9
+ * here — through the same Miniflare `outboundService` every worker fetch already
10
+ * flows through — to tell the Node-side recorder which cassette is active for
11
+ * the current test. See `setupRecording` (vcr-spec.ts) and `createVcr` (vcr.ts).
12
+ * Never a real network host.
13
+ */
14
+ export declare const VCR_CONTROL_ORIGIN = "https://vcr.internal";
15
+ /**
16
+ * Stamped on every response the recorder produces.
17
+ *
18
+ * The point is to prove the recorder is *installed*. A Miniflare option that no
19
+ * longer exists is silently ignored rather than rejected — which is exactly how
20
+ * a harness still wired to `fetchMock` on pool 0.20 failed: nothing intercepted
21
+ * anything, every request escaped to the real network, and each one died as
22
+ * `internal error; reference = …` naming nothing. Checking for this header on
23
+ * the control response turns that whole class of failure into one named error
24
+ * before any test runs.
25
+ */
26
+ export declare const VCR_MARKER_HEADER = "x-vcr";
27
+ /** Body of a successful `POST /release`: what the recorder could not serve. */
28
+ export interface VcrReleaseResult {
29
+ /** `"<METHOD> <url>"` for each blocked or unmatched request in the test. */
30
+ misses: string[];
31
+ }
32
+ /**
33
+ * Cassette filenames are derived from kebab-cased file + describe + test names
34
+ * (see `cassetteNameFor`), so they are always lowercase `a-z0-9-` plus the
35
+ * `.snapshot.json` suffix. Enforced on the control channel as a path-traversal
36
+ * guard (no `/`, no `..`) before the name is joined to the snapshots dir.
37
+ */
38
+ export declare const CASSETTE_NAME_RE: RegExp;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Constants shared between the two sides of the VCR harness that must NOT import
3
+ * each other: the Node-side recorder (`vcr.ts` / `vcr-store.ts`, which reach
4
+ * `node:fs`) and the worker-side spec helper (`vcr-spec.ts`, which runs in
5
+ * workerd). Keep this file dependency-free so both realms can load it.
6
+ */
7
+ /**
8
+ * Reserved origin for the in-band control channel. A recorded spec `fetch`es
9
+ * here — through the same Miniflare `outboundService` every worker fetch already
10
+ * flows through — to tell the Node-side recorder which cassette is active for
11
+ * the current test. See `setupRecording` (vcr-spec.ts) and `createVcr` (vcr.ts).
12
+ * Never a real network host.
13
+ */
14
+ export const VCR_CONTROL_ORIGIN = "https://vcr.internal";
15
+ /**
16
+ * Stamped on every response the recorder produces.
17
+ *
18
+ * The point is to prove the recorder is *installed*. A Miniflare option that no
19
+ * longer exists is silently ignored rather than rejected — which is exactly how
20
+ * a harness still wired to `fetchMock` on pool 0.20 failed: nothing intercepted
21
+ * anything, every request escaped to the real network, and each one died as
22
+ * `internal error; reference = …` naming nothing. Checking for this header on
23
+ * the control response turns that whole class of failure into one named error
24
+ * before any test runs.
25
+ */
26
+ export const VCR_MARKER_HEADER = "x-vcr";
27
+ /**
28
+ * Cassette filenames are derived from kebab-cased file + describe + test names
29
+ * (see `cassetteNameFor`), so they are always lowercase `a-z0-9-` plus the
30
+ * `.snapshot.json` suffix. Enforced on the control channel as a path-traversal
31
+ * guard (no `/`, no `..`) before the name is joined to the snapshots dir.
32
+ */
33
+ export const CASSETTE_NAME_RE = /^[a-z0-9-]+\.snapshot\.json$/;
@@ -0,0 +1,25 @@
1
+ import type { RunnerTestCase } from "vitest";
2
+ /**
3
+ * Cassette filename for a test: `kebab(<project-relative path, minus
4
+ * .spec.ts>)` then each describe level then the test name, all kebab-cased and
5
+ * joined by `--`, plus `.snapshot.json`. Example:
6
+ * `test-arc-agi-recorded--arc-recorded-real-api--plays-a-real-game.snapshot.json`.
7
+ * Exported for debugging / the cassette-rename step.
8
+ */
9
+ export declare function cassetteNameFor(task: RunnerTestCase): string;
10
+ export interface SetupRecordingOptions {
11
+ /**
12
+ * Let a test finish even though the recorder could not serve one of its
13
+ * requests. Only for a spec that deliberately exercises a failing `fetch`;
14
+ * the default is to fail, because a silent miss reads as a broken assertion
15
+ * several frames from its cause.
16
+ */
17
+ allowMisses?: boolean;
18
+ }
19
+ /**
20
+ * Call once at the top of a recorded spec file (outside any `describe`). Adds
21
+ * per-test hooks that activate the test's cassette before it runs and release
22
+ * it after. A missing cassette **fails** the test with instructions to record
23
+ * (never skips — CI must go red so the gap is visible).
24
+ */
25
+ export declare function setupRecording(options?: SetupRecordingOptions): void;
@@ -0,0 +1,124 @@
1
+ import { beforeEach, afterEach } from "vitest";
2
+ import { VCR_CONTROL_ORIGIN, VCR_MARKER_HEADER } from "./vcr-shared.js";
3
+ /**
4
+ * Worker-side half of the VCR harness. `setupRecording()` is the *only* thing a
5
+ * recorded spec wires up: one call at the top of the file gives every `it` its
6
+ * own cassette, auto-named from the file + describe + test names, recorded under
7
+ * `RECORD=1` and replayed otherwise. No per-recipe config, no registry.
8
+ *
9
+ * How it reaches the Node-side recorder: specs run in workerd (no filesystem),
10
+ * so the active cassette is announced over an in-band control channel — a
11
+ * `fetch` to {@link VCR_CONTROL_ORIGIN} that `createVcr` (vcr.ts) answers from
12
+ * the same Miniflare `outboundService` every worker fetch already flows through.
13
+ */
14
+ const kebab = (s) => s
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "");
18
+ /** The File task (root of the tree) carries `filepath`; describe suites don't. */
19
+ function isFileTask(suite) {
20
+ return typeof suite.filepath === "string";
21
+ }
22
+ /**
23
+ * The spec's path relative to the project root.
24
+ *
25
+ * Vitest already computes this as `file.name`, which is the whole answer: it is
26
+ * stable across machines and checkouts, and it is *unique per spec file*, which
27
+ * is what a cassette name has to be.
28
+ *
29
+ * Deriving it from `filepath` instead — by stripping a leading `test/` or
30
+ * `src/` — got this wrong twice. Splitting on a segment that never matched
31
+ * returned the absolute path unchanged, so a cassette was named after the
32
+ * developer's home directory; and stripping *both* roots collapsed
33
+ * `test/api.spec.ts` and `src/api.spec.ts` onto one name, which for a store
34
+ * keyed solely by filename means one recording silently overwrites the other
35
+ * and playback serves the wrong responses.
36
+ *
37
+ * The fallback is only for a runner that does not populate `name`; a bare
38
+ * filename can still collide, but it is strictly better than an absolute path
39
+ * and nothing here reaches it.
40
+ */
41
+ function relativeSpecPath(file) {
42
+ if (typeof file.name === "string" && file.name !== "")
43
+ return file.name;
44
+ return file.filepath.replace(/\\/g, "/").split("/").pop();
45
+ }
46
+ /**
47
+ * Cassette filename for a test: `kebab(<project-relative path, minus
48
+ * .spec.ts>)` then each describe level then the test name, all kebab-cased and
49
+ * joined by `--`, plus `.snapshot.json`. Example:
50
+ * `test-arc-agi-recorded--arc-recorded-real-api--plays-a-real-game.snapshot.json`.
51
+ * Exported for debugging / the cassette-rename step.
52
+ */
53
+ export function cassetteNameFor(task) {
54
+ const rel = relativeSpecPath(task.file).replace(/\.spec\.ts$/, "");
55
+ const suites = [];
56
+ let suite = task.suite;
57
+ while (suite && !isFileTask(suite)) {
58
+ suites.unshift(suite.name);
59
+ suite = suite.suite;
60
+ }
61
+ return [rel, ...suites, task.name].map(kebab).join("--") + ".snapshot.json";
62
+ }
63
+ /**
64
+ * The recorder answers every control request with {@link VCR_MARKER_HEADER}, so
65
+ * its absence — or a `fetch` that rejects outright, which is what a request to
66
+ * a host that does not resolve does — means nothing is intercepting outbound
67
+ * traffic at all.
68
+ */
69
+ const NOT_INSTALLED = `The VCR recorder is not installed, so ${VCR_CONTROL_ORIGIN} went to the ` +
70
+ `real network. Wire it into your vitest config:\n\n` +
71
+ ` const vcr = createVcr({ snapshotsDir, record: recordFromEnv() });\n` +
72
+ ` cloudflareTest({ miniflare: { outboundService: vcr.outboundService } })\n\n` +
73
+ `(\`fetchMock\` is not it: pool 0.20 removed the option, and an unknown key ` +
74
+ `in \`miniflare\` is ignored rather than rejected.)`;
75
+ /**
76
+ * Call once at the top of a recorded spec file (outside any `describe`). Adds
77
+ * per-test hooks that activate the test's cassette before it runs and release
78
+ * it after. A missing cassette **fails** the test with instructions to record
79
+ * (never skips — CI must go red so the gap is visible).
80
+ */
81
+ export function setupRecording(options = {}) {
82
+ beforeEach(async (ctx) => {
83
+ const cassette = cassetteNameFor(ctx.task);
84
+ const res = await fetch(`${VCR_CONTROL_ORIGIN}/use?cassette=${cassette}`, {
85
+ method: "POST"
86
+ }).catch(() => {
87
+ throw new Error(NOT_INSTALLED);
88
+ });
89
+ if (res.headers.get(VCR_MARKER_HEADER) === null) {
90
+ throw new Error(NOT_INSTALLED);
91
+ }
92
+ if (res.status === 404) {
93
+ throw new Error(`No VCR cassette "${cassette}". Record it with \`RECORD=1\` ` +
94
+ `(add \`-t "${ctx.task.name}"\` to record only this test), which needs ` +
95
+ `whatever real credentials the recorded API calls require.`);
96
+ }
97
+ if (res.status === 409) {
98
+ throw new Error(`VCR cassette "${cassette}" could not activate: another recorded test is ` +
99
+ `already using the shared recorder. Recorded specs must not run in ` +
100
+ `parallel — keep them sequential.`);
101
+ }
102
+ if (!res.ok) {
103
+ throw new Error(`VCR control channel error (HTTP ${res.status}) activating "${cassette}".`);
104
+ }
105
+ });
106
+ afterEach(async () => {
107
+ const res = await fetch(`${VCR_CONTROL_ORIGIN}/release`, {
108
+ method: "POST"
109
+ });
110
+ if (options.allowMisses === true || !res.ok)
111
+ return;
112
+ const { misses } = (await res.json());
113
+ if (misses.length === 0)
114
+ return;
115
+ // A miss cannot reject the Worker's own `fetch()` — Miniflare turns anything
116
+ // an outbound service throws into a 500 — so the request under test sees a
117
+ // 500 body and fails somewhere downstream, if at all. Reporting it here is
118
+ // what makes the *cause* the failure.
119
+ throw new Error(`VCR could not serve ${misses.length} request(s) in this test:\n` +
120
+ misses.map((m) => ` • ${m}`).join("\n") +
121
+ `\nRe-record with \`RECORD=1\`, or pass ` +
122
+ `\`setupRecording({ allowMisses: true })\` if the failure is the point.`);
123
+ });
124
+ }
@@ -0,0 +1,86 @@
1
+ /** A stored request. `body` is utf-8 text; binary request bodies are not supported. */
2
+ export interface RecordedRequest {
3
+ method: string;
4
+ url: string;
5
+ headers: Record<string, string | string[]>;
6
+ body: string;
7
+ }
8
+ /** A stored response. `body` is base64, so a binary payload survives the file. */
9
+ export interface RecordedResponse {
10
+ statusCode: number;
11
+ headers: Record<string, string | string[]>;
12
+ body: string;
13
+ trailers?: Record<string, string>;
14
+ }
15
+ /**
16
+ * One cassette entry: a request and every response recorded for it, in order.
17
+ *
18
+ * The array is what makes a replayed sequence work — two identical requests
19
+ * that returned different things (an ARC frame after a move, say) are one entry
20
+ * with two responses, walked in order. See {@link Cassette.match}.
21
+ */
22
+ export interface CassetteEntry {
23
+ request: RecordedRequest;
24
+ responses: RecordedResponse[];
25
+ }
26
+ /** What {@link Cassette.match} hands back: everything needed to build a Response. */
27
+ export interface ReplayableResponse {
28
+ status: number;
29
+ headers: [string, string][];
30
+ body: Uint8Array;
31
+ }
32
+ export interface CassetteOptions {
33
+ /** Request *and* response headers never written to the file (API keys, auth). */
34
+ excludeHeaders?: string[];
35
+ }
36
+ /** `${METHOD}\n${url}\n${sha256(body)}` — the whole matching rule. */
37
+ export declare function requestKey(method: string, url: string, body: string): string;
38
+ /**
39
+ * Turn a stored response into the pieces a `Response` is built from: body
40
+ * decoded from base64, headers flattened (a repeated header such as
41
+ * `set-cookie` becomes one entry per value) and {@link HOP_BY_HOP} dropped.
42
+ *
43
+ * Used on both paths, so a response served straight after recording is framed
44
+ * exactly as the same response will be on replay.
45
+ */
46
+ export declare function replayable(response: RecordedResponse): ReplayableResponse;
47
+ /**
48
+ * One cassette file, loaded lazily and written only when it has changed.
49
+ *
50
+ * Playback is **read-only**: nothing here mutates on a replay, which is what
51
+ * stops a plain `npm test` from dirtying committed cassettes. The sequence
52
+ * counter ({@link #calls}) is in memory and dies with the process, deliberately
53
+ * — `SnapshotAgent` persisted its equivalent and re-saved on close, so every
54
+ * run left every cassette modified in git.
55
+ */
56
+ export declare class Cassette {
57
+ #private;
58
+ constructor(file: string, options?: CassetteOptions);
59
+ get file(): string;
60
+ /** Every request in the file, as `METHOD url`, for error messages. */
61
+ get recorded(): string[];
62
+ /**
63
+ * Read the file into memory, keying each entry by its own request rather than
64
+ * trusting anything stored alongside it.
65
+ *
66
+ * Two entries that key the same are a mistake rather than a merge: a request
67
+ * that was issued twice belongs in one entry with two `responses`, which is
68
+ * what {@link match} walks. Saying so here turns a hand-edit slip into a named
69
+ * error instead of a replay that silently serves the wrong response second.
70
+ */
71
+ load(): void;
72
+ /**
73
+ * The next recorded response for this request, or `null` if it was never
74
+ * recorded.
75
+ *
76
+ * Repeated identical requests walk `responses` in order and then **hold on
77
+ * the last one**, so a recording that captured a single response replays it
78
+ * for every call — which is what a poll loop needs when it runs a different
79
+ * number of times on replay than it did while recording.
80
+ */
81
+ match(method: string, url: string, body: string): ReplayableResponse | null;
82
+ /** Append a live response. Repeats of a request extend its `responses`. */
83
+ record(request: RecordedRequest, response: RecordedResponse): void;
84
+ /** Write if anything was recorded since the last flush. A no-op in playback. */
85
+ flush(): void;
86
+ }