create-windy 0.2.9 → 0.2.11

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 (31) hide show
  1. package/dist/cli.js +8 -3
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/Dockerfile +2 -0
  5. package/template/apps/server/src/module-host.test.ts +6 -0
  6. package/template/apps/server/src/module-host.ts +9 -6
  7. package/template/apps/web/Dockerfile +2 -0
  8. package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +6 -5
  9. package/template/package.json +1 -1
  10. package/template/packages/agent-client/index.ts +3 -0
  11. package/template/packages/agent-client/package.json +27 -0
  12. package/template/packages/agent-client/src/client.test.ts +344 -0
  13. package/template/packages/agent-client/src/client.ts +186 -0
  14. package/template/packages/agent-client/src/error.ts +88 -0
  15. package/template/packages/agent-client/src/event-queue.ts +62 -0
  16. package/template/packages/agent-client/src/http.ts +19 -0
  17. package/template/packages/agent-client/src/run-protocol.test.ts +231 -0
  18. package/template/packages/agent-client/src/run.ts +320 -0
  19. package/template/packages/agent-client/src/types.ts +35 -0
  20. package/template/packages/agent-client/tsconfig.json +21 -0
  21. package/template/packages/agent-contracts/index.ts +7 -0
  22. package/template/packages/agent-contracts/package.json +24 -0
  23. package/template/packages/agent-contracts/src/contracts.test.ts +213 -0
  24. package/template/packages/agent-contracts/src/errors.ts +51 -0
  25. package/template/packages/agent-contracts/src/events.ts +161 -0
  26. package/template/packages/agent-contracts/src/operation.ts +28 -0
  27. package/template/packages/agent-contracts/src/runs.ts +58 -0
  28. package/template/packages/agent-contracts/src/sse.ts +95 -0
  29. package/template/packages/agent-contracts/src/usage.ts +62 -0
  30. package/template/packages/agent-contracts/src/validation.ts +68 -0
  31. package/template/packages/agent-contracts/tsconfig.json +16 -0
package/dist/cli.js CHANGED
@@ -213,7 +213,6 @@ var optionalModuleManifests = [
213
213
  "system",
214
214
  "system.settings"
215
215
  ]),
216
- packages: ["packages/storage"],
217
216
  fileRoots: ["apps/server/src/storage"],
218
217
  documentation: ["docs/architecture/object-storage.md"],
219
218
  schemaFiles: ["packages/database/src/schema/file-storage.ts"],
@@ -1585,9 +1584,11 @@ function createStarterPackage(source, projectName, includeOxc = true, projectLic
1585
1584
  typecheck: [
1586
1585
  "bun run --cwd apps/web typecheck",
1587
1586
  "bun run --cwd apps/server typecheck",
1587
+ "bun run --cwd packages/agent-contracts typecheck",
1588
+ "bun run --cwd packages/agent-client typecheck",
1588
1589
  "bun run --cwd packages/jobs typecheck",
1589
1590
  "bun run --cwd packages/server-sdk typecheck",
1590
- ...selectedModules.includes("system.storage") ? ["bun run --cwd packages/storage typecheck"] : [],
1591
+ "bun run --cwd packages/storage typecheck",
1591
1592
  "bun run --cwd packages/database typecheck",
1592
1593
  "bun run --cwd packages/crud-generator typecheck"
1593
1594
  ].join(" && "),
@@ -1623,6 +1624,8 @@ var templateDirectories = [
1623
1624
  "apps/server",
1624
1625
  "apps/web",
1625
1626
  "examples",
1627
+ "packages/agent-client",
1628
+ "packages/agent-contracts",
1626
1629
  "packages/config",
1627
1630
  "packages/crud-generator",
1628
1631
  "packages/database",
@@ -4002,7 +4005,9 @@ var recipes = [
4002
4005
  { id: "starter-0.2.5-to-0.2.6", from: "0.2.5", to: "0.2.6" },
4003
4006
  { id: "starter-0.2.6-to-0.2.7", from: "0.2.6", to: "0.2.7" },
4004
4007
  { id: "starter-0.2.7-to-0.2.8", from: "0.2.7", to: "0.2.8" },
4005
- { id: "starter-0.2.8-to-0.2.9", from: "0.2.8", to: "0.2.9" }
4008
+ { id: "starter-0.2.8-to-0.2.9", from: "0.2.8", to: "0.2.9" },
4009
+ { id: "starter-0.2.9-to-0.2.10", from: "0.2.9", to: "0.2.10" },
4010
+ { id: "starter-0.2.10-to-0.2.11", from: "0.2.10", to: "0.2.11" }
4006
4011
  ];
4007
4012
  function resolveRecipeChain(sourceVersion, targetVersion) {
4008
4013
  if (sourceVersion === targetVersion)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "52071fd54061fd9b5578a11d3b64f9d687122378",
4
- "generatorVersion": "0.2.9",
3
+ "templateCommit": "3be52627976c276e8dafafa6bfd2c23ab10fd908",
4
+ "generatorVersion": "0.2.11",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -8,6 +8,8 @@ COPY apps/server/package.json apps/server/package.json
8
8
  COPY apps/web/package.json apps/web/package.json
9
9
  COPY apps/license/package.json apps/license/package.json
10
10
  COPY apps/signing-service/package.json apps/signing-service/package.json
11
+ COPY packages/agent-client/package.json packages/agent-client/package.json
12
+ COPY packages/agent-contracts/package.json packages/agent-contracts/package.json
11
13
  COPY packages/config/package.json packages/config/package.json
12
14
  COPY packages/create-windy/package.json packages/create-windy/package.json
13
15
  COPY packages/crud-generator/package.json packages/crud-generator/package.json
@@ -1,12 +1,16 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { DurableHandlerRegistry } from "@southwind-ai/jobs";
3
3
  import type { ModuleInitializer } from "@southwind-ai/server-sdk";
4
+ // @windy-module work-order begin
4
5
  import { workOrderModuleInitializer } from "./example-modules.js";
6
+ // @windy-module work-order end
5
7
  import { initializeModuleHosts } from "./module-host.js";
8
+ // @windy-module work-order begin
6
9
  import {
7
10
  createStorageRuntime,
8
11
  platformUploadPolicies,
9
12
  } from "./storage/runtime.js";
13
+ // @windy-module work-order end
10
14
 
11
15
  describe("模块 Host 初始化", () => {
12
16
  test("按安装顺序完成初始化后再创建运行时注册表", async () => {
@@ -93,6 +97,7 @@ describe("模块 Host 初始化", () => {
93
97
  ).rejects.toThrow("任务 handler 重复注册:known.refresh");
94
98
  });
95
99
 
100
+ // @windy-module work-order begin
96
101
  test("work-order 附件策略只随模块初始化并被上传运行时采用", async () => {
97
102
  const withoutWorkOrder = await initializeModuleHosts({
98
103
  modules: [],
@@ -124,6 +129,7 @@ describe("模块 Host 初始化", () => {
124
129
  });
125
130
  expect(session.purpose).toBe("work-order.ticket.attachment");
126
131
  });
132
+ // @windy-module work-order end
127
133
  });
128
134
 
129
135
  function initializer(
@@ -2,8 +2,11 @@ import {
2
2
  DurableHandlerRegistry,
3
3
  type DurableHandlerDefinition,
4
4
  } from "@southwind-ai/jobs";
5
- import type { ModuleHost, ModuleInitializer } from "@southwind-ai/server-sdk";
6
- import type { UploadPolicy } from "@southwind-ai/storage";
5
+ import type {
6
+ ModuleHost,
7
+ ModuleInitializer,
8
+ ModuleUploadPolicy,
9
+ } from "@southwind-ai/server-sdk";
7
10
 
8
11
  interface InstalledModule {
9
12
  name: string;
@@ -12,11 +15,11 @@ interface InstalledModule {
12
15
  export interface InitializeModuleHostsInput {
13
16
  modules: readonly InstalledModule[];
14
17
  initializers: readonly ModuleInitializer[];
15
- baseUploadPolicies?: readonly UploadPolicy[];
18
+ baseUploadPolicies?: readonly ModuleUploadPolicy[];
16
19
  }
17
20
 
18
21
  export interface InitializedModuleHosts {
19
- readonly uploadPolicies: readonly UploadPolicy[];
22
+ readonly uploadPolicies: readonly ModuleUploadPolicy[];
20
23
  installDurableHandlers(registry: DurableHandlerRegistry): void;
21
24
  }
22
25
 
@@ -27,7 +30,7 @@ export async function initializeModuleHosts(
27
30
  ): Promise<InitializedModuleHosts> {
28
31
  const installedNames = new Set(input.modules.map(({ name }) => name));
29
32
  const initializedNames = new Set<string>();
30
- const uploadPolicies: UploadPolicy[] = [];
33
+ const uploadPolicies: ModuleUploadPolicy[] = [];
31
34
  const uploadPurposes = new Set<string>();
32
35
  for (const policy of input.baseUploadPolicies ?? []) {
33
36
  if (uploadPurposes.has(policy.purpose)) {
@@ -69,7 +72,7 @@ export async function initializeModuleHosts(
69
72
 
70
73
  interface ScopedHostState {
71
74
  moduleName: string;
72
- uploadPolicies: UploadPolicy[];
75
+ uploadPolicies: ModuleUploadPolicy[];
73
76
  uploadPurposes: Set<string>;
74
77
  handlerKeys: Set<string>;
75
78
  handlerInstallers: HandlerInstaller[];
@@ -8,6 +8,8 @@ COPY apps/license/package.json apps/license/package.json
8
8
  COPY apps/server/package.json apps/server/package.json
9
9
  COPY apps/signing-service/package.json apps/signing-service/package.json
10
10
  COPY apps/web/package.json apps/web/package.json
11
+ COPY packages/agent-client/package.json packages/agent-client/package.json
12
+ COPY packages/agent-contracts/package.json packages/agent-contracts/package.json
11
13
  COPY packages/config/package.json packages/config/package.json
12
14
  COPY packages/create-windy/package.json packages/create-windy/package.json
13
15
  COPY packages/crud-generator/package.json packages/crud-generator/package.json
@@ -15,7 +15,10 @@ import {
15
15
  import type { ResourceColumn, ResourceInlineToggle } from "../resource-config";
16
16
  import type { SystemRecord } from "@/services/system-api";
17
17
  import { formatDateTime } from "@/lib/date-time";
18
- import { AUDIT_ACTION_DEFINITIONS } from "@southwind-ai/shared";
18
+ import {
19
+ AUDIT_ACTION_DEFINITIONS,
20
+ LICENSE_CATALOG,
21
+ } from "@southwind-ai/shared";
19
22
  import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
20
23
  import {
21
24
  Tooltip,
@@ -108,10 +111,8 @@ function formatLicensePolicy(item: SystemRecord): string {
108
111
  }
109
112
 
110
113
  function licenseVersionLabel(version: string): string {
111
- return (
112
- access.snapshot.value?.licenseCatalog?.find(({ key }) => key === version)
113
- ?.label || version
114
- );
114
+ const catalog = access.snapshot.value?.licenseCatalog || LICENSE_CATALOG;
115
+ return catalog.find(({ key }) => key === version)?.label || version;
115
116
  }
116
117
  </script>
117
118
 
@@ -38,7 +38,7 @@
38
38
  "ops:release": "bun run scripts/production-release.ts",
39
39
  "test:release:evidence": "bun run scripts/release-evidence.ts",
40
40
  "test:packages:pack": "bun run scripts/platform-package-release.ts",
41
- "typecheck": "bun run --cwd apps/web typecheck && bun run --cwd apps/server typecheck && bun run --cwd apps/license typecheck && bun run --cwd apps/signing-service typecheck && bun run --cwd packages/license-sdk typecheck && bun run --cwd packages/jobs typecheck && bun run --cwd packages/server-sdk typecheck && bun run --cwd packages/storage typecheck && bun run --cwd packages/database typecheck && bun run --cwd packages/crud-generator typecheck && bun run --cwd packages/create-windy typecheck",
41
+ "typecheck": "bun run --cwd apps/web typecheck && bun run --cwd apps/server typecheck && bun run --cwd apps/license typecheck && bun run --cwd apps/signing-service typecheck && bun run --cwd packages/agent-contracts typecheck && bun run --cwd packages/agent-client typecheck && bun run --cwd packages/license-sdk typecheck && bun run --cwd packages/jobs typecheck && bun run --cwd packages/server-sdk typecheck && bun run --cwd packages/storage typecheck && bun run --cwd packages/database typecheck && bun run --cwd packages/crud-generator typecheck && bun run --cwd packages/create-windy typecheck",
42
42
  "lint": "oxlint .",
43
43
  "format": "oxfmt",
44
44
  "format:staged": "lint-staged",
@@ -0,0 +1,3 @@
1
+ export * from "./src/client.js";
2
+ export * from "./src/error.js";
3
+ export type * from "./src/types.js";
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@southwind-ai/agent-client",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "engines": {
7
+ "bun": ">=1.3.0"
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "src/**/*.ts",
12
+ "!src/**/*.test.ts"
13
+ ],
14
+ "scripts": {
15
+ "test": "bun test",
16
+ "typecheck": "tsc --noEmit --project tsconfig.json"
17
+ },
18
+ "dependencies": {
19
+ "@southwind-ai/agent-contracts": "workspace:*"
20
+ },
21
+ "exports": {
22
+ ".": "./index.ts"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
@@ -0,0 +1,344 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defineAgentOperation,
4
+ type AgentEvent,
5
+ } from "@southwind-ai/agent-contracts";
6
+ import { createAgentCaller } from "../index.js";
7
+ import { AgentClientError } from "./error.js";
8
+ import type { AgentFetch } from "./types.js";
9
+
10
+ interface FetchCall {
11
+ url: URL;
12
+ init?: RequestInit;
13
+ }
14
+
15
+ type FetchPlan = (call: FetchCall) => Response | Promise<Response>;
16
+
17
+ const operation = defineAgentOperation<{ text: string }, { answer: string }>(
18
+ "support.answer",
19
+ (value) => {
20
+ if (
21
+ !value ||
22
+ typeof value !== "object" ||
23
+ Array.isArray(value) ||
24
+ typeof (value as Record<string, unknown>).answer !== "string"
25
+ ) {
26
+ throw new TypeError("answer 必须是字符串");
27
+ }
28
+ return { answer: (value as Record<string, unknown>).answer as string };
29
+ },
30
+ );
31
+
32
+ describe("@southwind-ai/agent-client", () => {
33
+ test("创建 Run、消费可恢复 SSE,并在 completed 后读取类型化结果", async () => {
34
+ const fake = plannedFetch([
35
+ () =>
36
+ json({
37
+ runId: "run-1",
38
+ status: "accepted",
39
+ eventsUrl: "/api/agent/runs/run-1/events",
40
+ }),
41
+ () =>
42
+ sse([
43
+ frame({ type: "run.started", runId: "run-1", sequence: 1 }),
44
+ ": heartbeat\n\n",
45
+ frame({
46
+ type: "output.delta",
47
+ runId: "run-1",
48
+ sequence: 2,
49
+ text: "你",
50
+ }),
51
+ ]),
52
+ () =>
53
+ sse([
54
+ frame({
55
+ type: "output.delta",
56
+ runId: "run-1",
57
+ sequence: 2,
58
+ text: "你",
59
+ }),
60
+ frame({ type: "run.completed", runId: "run-1", sequence: 3 }),
61
+ ]),
62
+ () =>
63
+ json({
64
+ runId: "run-1",
65
+ status: "completed",
66
+ output: { answer: "你好" },
67
+ }),
68
+ ]);
69
+ const caller = createAgentCaller({
70
+ baseUrl: "https://agent.internal",
71
+ fetch: fake.fetch,
72
+ reconnectDelayMs: 0,
73
+ wait: async () => undefined,
74
+ });
75
+
76
+ const run = await caller.start(
77
+ operation,
78
+ { text: "你好" },
79
+ {
80
+ idempotencyKey: "request-1",
81
+ },
82
+ );
83
+ const collected = collect(run.events);
84
+ await expect(run.result()).resolves.toEqual({ answer: "你好" });
85
+ expect((await collected).map(({ sequence }) => sequence)).toEqual([
86
+ 1, 2, 3,
87
+ ]);
88
+
89
+ expect(fake.calls).toHaveLength(4);
90
+ expect(fake.calls[0]?.url.pathname).toBe("/api/agent/runs");
91
+ expect(fake.calls[0]?.init?.headers).toEqual({
92
+ "content-type": "application/json",
93
+ "idempotency-key": "request-1",
94
+ });
95
+ expect(JSON.parse(String(fake.calls[0]?.init?.body))).toEqual({
96
+ operationKey: "support.answer",
97
+ input: { text: "你好" },
98
+ });
99
+ const resumedHeaders = new Headers(fake.calls[2]?.init?.headers);
100
+ expect(resumedHeaders.get("last-event-id")).toBe("2");
101
+ expect(fake.calls[2]?.url.searchParams.get("after")).toBe("2");
102
+ expect(fake.calls[3]?.url.pathname).toBe("/api/agent/runs/run-1/result");
103
+ });
104
+
105
+ test("EOF 会消耗恢复预算,超过预算返回稳定中断错误", async () => {
106
+ const fake = plannedFetch([
107
+ () =>
108
+ json({
109
+ runId: "run-eof",
110
+ status: "accepted",
111
+ eventsUrl: "/api/agent/runs/run-eof/events",
112
+ }),
113
+ () =>
114
+ sse([
115
+ frame({
116
+ type: "run.started",
117
+ runId: "run-eof",
118
+ sequence: 1,
119
+ }),
120
+ ]),
121
+ () => sse([": heartbeat\n\n"]),
122
+ ]);
123
+ const waits: number[] = [];
124
+ const caller = createAgentCaller({
125
+ baseUrl: "https://agent.internal",
126
+ fetch: fake.fetch,
127
+ maxReconnects: 1,
128
+ reconnectDelayMs: 7,
129
+ wait: async (milliseconds) => {
130
+ waits.push(milliseconds);
131
+ },
132
+ });
133
+ const run = await caller.start(
134
+ operation,
135
+ { text: "x" },
136
+ {
137
+ idempotencyKey: "request-eof",
138
+ },
139
+ );
140
+
141
+ await expect(run.result()).rejects.toMatchObject({
142
+ code: "STREAM_INTERRUPTED",
143
+ retryable: true,
144
+ runId: "run-eof",
145
+ });
146
+ expect(waits).toEqual([7]);
147
+ expect(fake.calls).toHaveLength(3);
148
+ expect(new Headers(fake.calls[2]?.init?.headers).get("last-event-id")).toBe(
149
+ "1",
150
+ );
151
+ });
152
+
153
+ test("result 使用 Operation parser,拒绝未校验的远端输出", async () => {
154
+ const fake = plannedFetch([
155
+ () =>
156
+ json({
157
+ runId: "run-invalid",
158
+ status: "accepted",
159
+ eventsUrl: "/events",
160
+ }),
161
+ () =>
162
+ sse([
163
+ frame({
164
+ type: "run.completed",
165
+ runId: "run-invalid",
166
+ sequence: 1,
167
+ }),
168
+ ]),
169
+ () =>
170
+ json({
171
+ runId: "run-invalid",
172
+ status: "completed",
173
+ output: { answer: 42 },
174
+ }),
175
+ ]);
176
+ const run = await createAgentCaller({
177
+ baseUrl: "https://agent.internal",
178
+ fetch: fake.fetch,
179
+ }).start(operation, { text: "x" }, { idempotencyKey: "invalid-output" });
180
+
181
+ await expect(run.result()).rejects.toMatchObject({
182
+ code: "PROTOCOL_VIOLATION",
183
+ retryable: false,
184
+ });
185
+ });
186
+
187
+ test("cancel 幂等发送独立 command,AbortSignal 也触发取消", async () => {
188
+ const fake = plannedFetch([
189
+ () =>
190
+ json({
191
+ runId: "run-cancel",
192
+ status: "accepted",
193
+ eventsUrl: "/events",
194
+ }),
195
+ () => json({ accepted: true }),
196
+ ]);
197
+ const abort = new AbortController();
198
+ const run = await createAgentCaller({
199
+ baseUrl: "https://agent.internal",
200
+ fetch: fake.fetch,
201
+ }).start(
202
+ operation,
203
+ { text: "x" },
204
+ { idempotencyKey: "cancel-1", signal: abort.signal },
205
+ );
206
+
207
+ abort.abort();
208
+ await Promise.all([run.cancel("重复调用"), run.cancel("再次重复")]);
209
+ expect(fake.calls).toHaveLength(2);
210
+ expect(fake.calls[1]?.url.pathname).toBe(
211
+ "/api/agent/runs/run-cancel/cancel",
212
+ );
213
+ });
214
+
215
+ test("创建阶段 AbortSignal 使用稳定取消错误", async () => {
216
+ const abort = new AbortController();
217
+ abort.abort(new Error("caller stopped"));
218
+ const fetch: AgentFetch = async (_input, init) => {
219
+ expect(init?.signal?.aborted).toBe(true);
220
+ throw init?.signal?.reason;
221
+ };
222
+
223
+ await expect(
224
+ createAgentCaller({
225
+ baseUrl: "https://agent.internal",
226
+ fetch,
227
+ }).start(
228
+ operation,
229
+ { text: "x" },
230
+ { idempotencyKey: "aborted", signal: abort.signal },
231
+ ),
232
+ ).rejects.toMatchObject({
233
+ code: "CANCELLED",
234
+ retryable: false,
235
+ });
236
+ });
237
+
238
+ test("只保留已知 canonical HTTP 错误,未知正文不会泄漏", async () => {
239
+ const known = plannedFetch([
240
+ () =>
241
+ json(
242
+ {
243
+ code: "RATE_LIMITED",
244
+ message: "请求过于频繁",
245
+ requestId: "request-known",
246
+ retryable: true,
247
+ },
248
+ 429,
249
+ ),
250
+ ]);
251
+ const caller = createAgentCaller({
252
+ baseUrl: "https://agent.internal",
253
+ fetch: known.fetch,
254
+ });
255
+ try {
256
+ await caller.start(
257
+ operation,
258
+ { text: "x" },
259
+ {
260
+ idempotencyKey: "known",
261
+ },
262
+ );
263
+ throw new Error("expected rejection");
264
+ } catch (error) {
265
+ expect(error).toBeInstanceOf(AgentClientError);
266
+ expect(error).toMatchObject({
267
+ code: "RUN_REJECTED",
268
+ message: "请求过于频繁",
269
+ retryable: true,
270
+ agentError: { code: "RATE_LIMITED", requestId: "request-known" },
271
+ });
272
+ }
273
+
274
+ const secret = "provider-body-secret";
275
+ const unknown = plannedFetch([
276
+ () =>
277
+ new Response(`upstream failure ${secret}`, {
278
+ status: 502,
279
+ headers: { "content-type": "text/plain" },
280
+ }),
281
+ ]);
282
+ try {
283
+ await createAgentCaller({
284
+ baseUrl: "https://agent.internal",
285
+ fetch: unknown.fetch,
286
+ }).start(operation, { text: "x" }, { idempotencyKey: "unknown" });
287
+ throw new Error("expected rejection");
288
+ } catch (error) {
289
+ expect(error).toBeInstanceOf(AgentClientError);
290
+ expect(String((error as Error).message)).not.toContain(secret);
291
+ expect(error).toMatchObject({
292
+ code: "REQUEST_FAILED",
293
+ message: "创建 Agent Run失败(HTTP 502)",
294
+ });
295
+ }
296
+ });
297
+ });
298
+
299
+ function plannedFetch(plans: FetchPlan[]): {
300
+ fetch: AgentFetch;
301
+ calls: FetchCall[];
302
+ } {
303
+ const calls: FetchCall[] = [];
304
+ const fetch: AgentFetch = async (input, init) => {
305
+ const call = {
306
+ url: new URL(input instanceof Request ? input.url : input.toString()),
307
+ init,
308
+ };
309
+ calls.push(call);
310
+ const plan = plans[calls.length - 1];
311
+ if (!plan) throw new Error(`unexpected fetch ${call.url}`);
312
+ return plan(call);
313
+ };
314
+ return { fetch, calls };
315
+ }
316
+
317
+ function json(value: unknown, status = 200): Response {
318
+ return Response.json(value, { status });
319
+ }
320
+
321
+ function sse(parts: string[]): Response {
322
+ const encoder = new TextEncoder();
323
+ return new Response(
324
+ new ReadableStream<Uint8Array>({
325
+ start(controller) {
326
+ for (const part of parts) controller.enqueue(encoder.encode(part));
327
+ controller.close();
328
+ },
329
+ }),
330
+ { headers: { "content-type": "text/event-stream; charset=utf-8" } },
331
+ );
332
+ }
333
+
334
+ function frame(event: AgentEvent): string {
335
+ return `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
336
+ }
337
+
338
+ async function collect(
339
+ events: AsyncIterable<AgentEvent>,
340
+ ): Promise<AgentEvent[]> {
341
+ const collected: AgentEvent[] = [];
342
+ for await (const event of events) collected.push(event);
343
+ return collected;
344
+ }