create-windy 0.2.8 → 0.2.10

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 (36) 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/index.ts +1 -0
  6. package/template/apps/server/src/module-host.test.ts +6 -0
  7. package/template/apps/server/src/module-host.ts +9 -6
  8. package/template/apps/web/Dockerfile +2 -0
  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
  32. package/template/packages/modules/src/ai-operation-composition.test.ts +195 -0
  33. package/template/packages/modules/src/ai-operation-composition.ts +145 -0
  34. package/template/packages/modules/src/composition.ts +19 -37
  35. package/template/packages/modules/src/implementation-bindings.ts +70 -0
  36. package/template/packages/modules/src/manifest.ts +37 -1
@@ -0,0 +1,320 @@
1
+ import {
2
+ decodeAgentSseFrame,
3
+ isTerminalAgentEvent,
4
+ splitAgentSseFrames,
5
+ type AgentEvent,
6
+ type TerminalAgentEvent,
7
+ } from "@southwind-ai/agent-contracts";
8
+ import { AgentClientError, requestError, safeHttpError } from "./error.js";
9
+ import { EventQueue } from "./event-queue.js";
10
+ import { safeJson } from "./http.js";
11
+ import type { AgentFetch, AgentRun } from "./types.js";
12
+
13
+ interface HttpAgentRunOptions<Output> {
14
+ id: string;
15
+ eventsUrl: URL;
16
+ resultUrl: URL;
17
+ cancelUrl: URL;
18
+ fetch: AgentFetch;
19
+ signal?: AbortSignal;
20
+ maxReconnects: number;
21
+ reconnectDelayMs: number;
22
+ wait(milliseconds: number, signal?: AbortSignal): Promise<void>;
23
+ parseOutput(value: unknown): Output;
24
+ }
25
+
26
+ type TerminalOutcome =
27
+ | { terminal: TerminalAgentEvent }
28
+ | { failure: AgentClientError };
29
+
30
+ export class HttpAgentRun<Output> implements AgentRun<Output> {
31
+ readonly id: string;
32
+ readonly events: AsyncIterable<AgentEvent>;
33
+
34
+ private readonly queue: EventQueue<AgentEvent>;
35
+ private readonly terminalOutcome: Promise<TerminalOutcome>;
36
+ private resolveTerminal!: (outcome: TerminalOutcome) => void;
37
+ private pump?: Promise<void>;
38
+ private resultRequest?: Promise<Output>;
39
+ private cancelRequest?: Promise<void>;
40
+ private lastSequence = 0;
41
+ private terminal?: TerminalAgentEvent;
42
+
43
+ constructor(private readonly options: HttpAgentRunOptions<Output>) {
44
+ this.id = options.id;
45
+ this.queue = new EventQueue(() => this.ensurePump());
46
+ this.events = this.queue;
47
+ this.terminalOutcome = new Promise((resolve) => {
48
+ this.resolveTerminal = resolve;
49
+ });
50
+
51
+ if (options.signal?.aborted) {
52
+ void this.cancel("调用方已取消").catch(() => undefined);
53
+ } else {
54
+ options.signal?.addEventListener(
55
+ "abort",
56
+ () => void this.cancel("调用方已取消").catch(() => undefined),
57
+ { once: true },
58
+ );
59
+ }
60
+ }
61
+
62
+ result(): Promise<Output> {
63
+ this.ensurePump();
64
+ this.resultRequest ||= this.resolveResult();
65
+ return this.resultRequest;
66
+ }
67
+
68
+ cancel(reason?: string): Promise<void> {
69
+ if (this.terminal) return Promise.resolve();
70
+ this.cancelRequest ||= this.sendCancel(reason);
71
+ return this.cancelRequest;
72
+ }
73
+
74
+ private ensurePump(): void {
75
+ if (this.pump) return;
76
+ this.pump = this.consumeEvents().catch((error: unknown) => {
77
+ const failure =
78
+ error instanceof AgentClientError
79
+ ? error
80
+ : requestError(error, "读取 Agent Run 事件", this.id);
81
+ this.queue.fail(failure);
82
+ this.resolveTerminal({ failure });
83
+ });
84
+ }
85
+
86
+ private async consumeEvents(): Promise<void> {
87
+ let reconnects = 0;
88
+ while (!this.terminal) {
89
+ let interruption: AgentClientError | undefined;
90
+ try {
91
+ const ended = await this.consumeConnection();
92
+ if (ended) return;
93
+ } catch (error) {
94
+ interruption = this.streamError(error);
95
+ if (!interruption.retryable) throw interruption;
96
+ }
97
+ if (reconnects >= this.options.maxReconnects) {
98
+ throw (
99
+ interruption ||
100
+ new AgentClientError(
101
+ "STREAM_INTERRUPTED",
102
+ "Agent Run 事件流在终态前中断",
103
+ true,
104
+ this.id,
105
+ )
106
+ );
107
+ }
108
+ reconnects += 1;
109
+ try {
110
+ await this.options.wait(
111
+ this.options.reconnectDelayMs,
112
+ this.options.signal,
113
+ );
114
+ } catch (error) {
115
+ throw this.streamError(error);
116
+ }
117
+ }
118
+ }
119
+
120
+ private async consumeConnection(): Promise<boolean> {
121
+ const url = new URL(this.options.eventsUrl);
122
+ const headers = new Headers({ accept: "text/event-stream" });
123
+ if (this.lastSequence > 0) {
124
+ const cursor = String(this.lastSequence);
125
+ headers.set("last-event-id", cursor);
126
+ url.searchParams.set("after", cursor);
127
+ }
128
+
129
+ let response: Response;
130
+ try {
131
+ response = await this.options.fetch(url, {
132
+ method: "GET",
133
+ headers,
134
+ signal: this.options.signal,
135
+ });
136
+ } catch (error) {
137
+ throw requestError(error, "连接 Agent Run 事件流", this.id);
138
+ }
139
+ if (!response.ok) {
140
+ throw await safeHttpError(response, "连接 Agent Run 事件流", this.id);
141
+ }
142
+ if (
143
+ !response.headers
144
+ .get("content-type")
145
+ ?.toLowerCase()
146
+ .includes("text/event-stream")
147
+ ) {
148
+ throw this.protocolViolation("Agent Run 事件响应类型无效");
149
+ }
150
+ if (!response.body) {
151
+ throw this.protocolViolation("Agent Run 事件响应缺少 body");
152
+ }
153
+
154
+ const reader = response.body.getReader();
155
+ const decoder = new TextDecoder();
156
+ let remainder = "";
157
+ try {
158
+ while (true) {
159
+ const chunk = await reader.read();
160
+ if (chunk.done) {
161
+ remainder += decoder.decode();
162
+ return this.processBuffer(remainder, true);
163
+ }
164
+ remainder += decoder.decode(chunk.value, { stream: true });
165
+ const split = splitAgentSseFrames(remainder);
166
+ remainder = split.remainder;
167
+ for (const frame of split.frames) {
168
+ if (this.processFrame(frame)) {
169
+ await reader.cancel();
170
+ return true;
171
+ }
172
+ }
173
+ }
174
+ } catch (error) {
175
+ if (error instanceof AgentClientError) throw error;
176
+ throw requestError(error, "读取 Agent Run 事件流", this.id);
177
+ } finally {
178
+ reader.releaseLock();
179
+ }
180
+ }
181
+
182
+ private processBuffer(buffer: string, final: boolean): boolean {
183
+ const split = splitAgentSseFrames(buffer);
184
+ for (const frame of split.frames) {
185
+ if (this.processFrame(frame)) return true;
186
+ }
187
+ if (final && split.remainder.trim()) {
188
+ // 连接可在 frame 中途断开;恢复时服务端会按 cursor 重放完整事件。
189
+ return false;
190
+ }
191
+ return Boolean(this.terminal);
192
+ }
193
+
194
+ private processFrame(frame: string): boolean {
195
+ let decoded;
196
+ try {
197
+ decoded = decodeAgentSseFrame(frame);
198
+ } catch (error) {
199
+ throw this.protocolViolation("Agent Run 事件格式无效", error);
200
+ }
201
+ if (decoded.kind === "comment") return false;
202
+
203
+ const { id, event } = decoded;
204
+ if (event.runId !== this.id) {
205
+ throw this.protocolViolation("Agent Run 事件引用了错误的 Run");
206
+ }
207
+ // at-least-once 重放可能包含整个 cursor 前缀;只向调用方发出新 sequence。
208
+ if (id <= this.lastSequence) return false;
209
+ if (this.terminal) {
210
+ throw this.protocolViolation("Agent Run 终态后出现了额外事件");
211
+ }
212
+
213
+ this.lastSequence = id;
214
+ this.queue.push(event);
215
+ if (!isTerminalAgentEvent(event)) return false;
216
+
217
+ this.terminal = event;
218
+ this.queue.close();
219
+ this.resolveTerminal({ terminal: event });
220
+ return true;
221
+ }
222
+
223
+ private async resolveResult(): Promise<Output> {
224
+ const outcome = await this.terminalOutcome;
225
+ if ("failure" in outcome) throw outcome.failure;
226
+ if (outcome.terminal.type === "run.failed") {
227
+ throw new AgentClientError(
228
+ "RUN_FAILED",
229
+ outcome.terminal.error.message,
230
+ outcome.terminal.error.retryable,
231
+ this.id,
232
+ { agentError: outcome.terminal.error },
233
+ );
234
+ }
235
+ if (outcome.terminal.type === "run.cancelled") {
236
+ throw new AgentClientError(
237
+ "RUN_CANCELLED",
238
+ "Agent Run 已取消",
239
+ false,
240
+ this.id,
241
+ );
242
+ }
243
+ return this.fetchResult();
244
+ }
245
+
246
+ private async fetchResult(): Promise<Output> {
247
+ let response: Response;
248
+ try {
249
+ response = await this.options.fetch(this.options.resultUrl, {
250
+ method: "GET",
251
+ headers: { accept: "application/json" },
252
+ signal: this.options.signal,
253
+ });
254
+ } catch (error) {
255
+ throw requestError(error, "读取 Agent Run 结果", this.id);
256
+ }
257
+ if (!response.ok) {
258
+ throw await safeHttpError(response, "读取 Agent Run 结果", this.id);
259
+ }
260
+ const value = await safeJson(response, "读取 Agent Run 结果", this.id);
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
262
+ throw this.protocolViolation("Agent Run 结果格式无效");
263
+ }
264
+ const record = value as Record<string, unknown>;
265
+ if (
266
+ record.runId !== this.id ||
267
+ record.status !== "completed" ||
268
+ !Object.hasOwn(record, "output")
269
+ ) {
270
+ throw this.protocolViolation("Agent Run 结果字段无效");
271
+ }
272
+ try {
273
+ return this.options.parseOutput(record.output);
274
+ } catch (error) {
275
+ throw this.protocolViolation(
276
+ "Agent Run 输出未通过 Operation 校验",
277
+ error,
278
+ );
279
+ }
280
+ }
281
+
282
+ private async sendCancel(reason?: string): Promise<void> {
283
+ let response: Response;
284
+ try {
285
+ response = await this.options.fetch(this.options.cancelUrl, {
286
+ method: "POST",
287
+ headers: { "content-type": "application/json" },
288
+ body: JSON.stringify(reason?.trim() ? { reason } : {}),
289
+ });
290
+ } catch (error) {
291
+ throw requestError(error, "取消 Agent Run", this.id);
292
+ }
293
+ if (!response.ok) {
294
+ throw await safeHttpError(response, "取消 Agent Run", this.id);
295
+ }
296
+ }
297
+
298
+ private streamError(error: unknown): AgentClientError {
299
+ if (this.options.signal?.aborted) {
300
+ return new AgentClientError(
301
+ "CANCELLED",
302
+ "Agent Run 事件读取已取消",
303
+ false,
304
+ this.id,
305
+ { cause: error },
306
+ );
307
+ }
308
+ if (error instanceof AgentClientError) return error;
309
+ return requestError(error, "读取 Agent Run 事件", this.id);
310
+ }
311
+
312
+ private protocolViolation(
313
+ message: string,
314
+ cause?: unknown,
315
+ ): AgentClientError {
316
+ return new AgentClientError("PROTOCOL_VIOLATION", message, false, this.id, {
317
+ cause,
318
+ });
319
+ }
320
+ }
@@ -0,0 +1,35 @@
1
+ import type { AgentEvent, AgentOperation } from "@southwind-ai/agent-contracts";
2
+
3
+ export interface StartAgentRunOptions {
4
+ idempotencyKey: string;
5
+ signal?: AbortSignal;
6
+ }
7
+
8
+ export interface AgentRun<Output> {
9
+ readonly id: string;
10
+ readonly events: AsyncIterable<AgentEvent>;
11
+ result(): Promise<Output>;
12
+ cancel(reason?: string): Promise<void>;
13
+ }
14
+
15
+ export interface AgentCaller {
16
+ start<Input, Output>(
17
+ operation: AgentOperation<Input, Output>,
18
+ input: Input,
19
+ options: StartAgentRunOptions,
20
+ ): Promise<AgentRun<Output>>;
21
+ }
22
+
23
+ export type AgentFetch = (
24
+ input: string | URL | Request,
25
+ init?: RequestInit,
26
+ ) => Promise<Response>;
27
+
28
+ export interface AgentClientOptions {
29
+ baseUrl: string | URL;
30
+ fetch?: AgentFetch;
31
+ runsPath?: string;
32
+ maxReconnects?: number;
33
+ reconnectDelayMs?: number;
34
+ wait?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
35
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "lib": ["esnext", "dom"],
6
+ "moduleResolution": "bundler",
7
+ "baseUrl": ".",
8
+ "paths": {
9
+ "@southwind-ai/agent-contracts": ["../agent-contracts/index.ts"],
10
+ "@southwind-ai/storage": ["../storage/index.ts"]
11
+ },
12
+ "strict": true,
13
+ "noImplicitAny": true,
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "outDir": "dist",
17
+ "types": ["bun"]
18
+ },
19
+ "include": ["src/**/*", "index.ts"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./src/errors.js";
2
+ export * from "./src/events.js";
3
+ export * from "./src/operation.js";
4
+ export * from "./src/runs.js";
5
+ export * from "./src/sse.js";
6
+ export * from "./src/usage.js";
7
+ export { AgentContractParseError } from "./src/validation.js";
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@southwind-ai/agent-contracts",
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
+ "exports": {
19
+ ".": "./index.ts"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }
@@ -0,0 +1,213 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ AgentContractParseError,
4
+ agentErrorCodes,
5
+ decodeAgentSseFrame,
6
+ defineAgentOperation,
7
+ isAgentEvent,
8
+ isTerminalAgentEvent,
9
+ isTerminalAgentRunStatus,
10
+ parseAgentError,
11
+ parseAgentEvent,
12
+ parseStartAgentRunResponse,
13
+ parseAgentUsage,
14
+ splitAgentSseFrames,
15
+ type AgentEvent,
16
+ } from "../index.js";
17
+
18
+ const error = {
19
+ code: "RUNTIME_UNAVAILABLE",
20
+ message: "运行时不可用",
21
+ requestId: "request-1",
22
+ retryable: true,
23
+ } as const;
24
+
25
+ const events: AgentEvent[] = [
26
+ { type: "run.started", runId: "run-1", sequence: 1 },
27
+ { type: "output.delta", runId: "run-1", sequence: 2, text: "你好" },
28
+ {
29
+ type: "tool.requested",
30
+ runId: "run-1",
31
+ sequence: 3,
32
+ toolCallId: "call-1",
33
+ toolKey: "work-order.ticket.read",
34
+ },
35
+ {
36
+ type: "approval.required",
37
+ runId: "run-1",
38
+ sequence: 4,
39
+ workItemId: "work-1",
40
+ },
41
+ {
42
+ type: "tool.completed",
43
+ runId: "run-1",
44
+ sequence: 5,
45
+ toolCallId: "call-1",
46
+ },
47
+ {
48
+ type: "usage.reported",
49
+ runId: "run-1",
50
+ sequence: 6,
51
+ usage: { source: "provider-reported", inputTokens: 10 },
52
+ },
53
+ { type: "run.completed", runId: "run-1", sequence: 7 },
54
+ { type: "run.failed", runId: "run-2", sequence: 1, error },
55
+ { type: "run.cancelled", runId: "run-3", sequence: 1 },
56
+ ];
57
+
58
+ describe("Agent contract", () => {
59
+ test("Operation 运行时只保留稳定 key", () => {
60
+ const operation = defineAgentOperation<{ id: string }, { text: string }>(
61
+ "work-order.ticket.summarize",
62
+ (value) => {
63
+ if (
64
+ !value ||
65
+ typeof value !== "object" ||
66
+ !("text" in value) ||
67
+ typeof value.text !== "string"
68
+ ) {
69
+ throw new TypeError("输出格式无效");
70
+ }
71
+ return { text: value.text };
72
+ },
73
+ );
74
+ expect(operation.key).toBe("work-order.ticket.summarize");
75
+ expect(operation.parseOutput({ text: "摘要" })).toEqual({ text: "摘要" });
76
+ expect(() => operation.parseOutput({ content: "未校验输出" })).toThrow(
77
+ "输出格式无效",
78
+ );
79
+ expect(() => defineAgentOperation(" ", operation.parseOutput)).toThrow();
80
+ });
81
+
82
+ test("解析创建 Run 响应并拒绝未知状态或缺失字段", () => {
83
+ expect(
84
+ parseStartAgentRunResponse({
85
+ runId: "run-1",
86
+ status: "accepted",
87
+ eventsUrl: "/api/agent/runs/run-1/events",
88
+ }),
89
+ ).toEqual({
90
+ runId: "run-1",
91
+ status: "accepted",
92
+ eventsUrl: "/api/agent/runs/run-1/events",
93
+ });
94
+ expect(() =>
95
+ parseStartAgentRunResponse({
96
+ runId: "run-1",
97
+ status: "queued",
98
+ eventsUrl: "/api/agent/runs/run-1/events",
99
+ }),
100
+ ).toThrow(AgentContractParseError);
101
+ expect(() =>
102
+ parseStartAgentRunResponse({
103
+ runId: "run-1",
104
+ status: "accepted",
105
+ }),
106
+ ).toThrow(AgentContractParseError);
107
+ });
108
+
109
+ test("解析全部 canonical event", () => {
110
+ for (const event of events) {
111
+ expect(parseAgentEvent(event)).toEqual(event);
112
+ expect(isAgentEvent(event)).toBe(true);
113
+ }
114
+ });
115
+
116
+ test("拒绝未知、缺字段和非法 sequence", () => {
117
+ expect(() =>
118
+ parseAgentEvent({ type: "provider.delta", runId: "run-1", sequence: 1 }),
119
+ ).toThrow(AgentContractParseError);
120
+ expect(() =>
121
+ parseAgentEvent({ type: "output.delta", runId: "run-1", sequence: 1 }),
122
+ ).toThrow(AgentContractParseError);
123
+ expect(() =>
124
+ parseAgentEvent({
125
+ type: "run.started",
126
+ runId: "run-1",
127
+ sequence: 0,
128
+ }),
129
+ ).toThrow(AgentContractParseError);
130
+ expect(isAgentEvent(null)).toBe(false);
131
+ });
132
+
133
+ test("unknown 用量不会伪装为零", () => {
134
+ expect(parseAgentUsage({ source: "unknown" })).toEqual({
135
+ source: "unknown",
136
+ });
137
+ expect(() =>
138
+ parseAgentUsage({ source: "unknown", inputTokens: 0 }),
139
+ ).toThrow(AgentContractParseError);
140
+ expect(() => parseAgentUsage({ source: "estimated" })).toThrow(
141
+ AgentContractParseError,
142
+ );
143
+ expect(
144
+ parseAgentUsage({ source: "estimated", firstTokenLatencyMs: 0 }),
145
+ ).toEqual({ source: "estimated", firstTokenLatencyMs: 0 });
146
+ });
147
+
148
+ test("稳定错误码全部可解析并拒绝未知错误", () => {
149
+ for (const code of agentErrorCodes) {
150
+ expect(parseAgentError({ ...error, code }).code).toBe(code);
151
+ }
152
+ expect(() => parseAgentError({ ...error, code: "PROVIDER_BODY" })).toThrow(
153
+ AgentContractParseError,
154
+ );
155
+ });
156
+
157
+ test("terminal status 与 event 判断完整", () => {
158
+ expect(isTerminalAgentRunStatus("accepted")).toBe(false);
159
+ expect(isTerminalAgentRunStatus("running")).toBe(false);
160
+ expect(isTerminalAgentRunStatus("awaiting-approval")).toBe(false);
161
+ expect(isTerminalAgentRunStatus("completed")).toBe(true);
162
+ expect(isTerminalAgentRunStatus("failed")).toBe(true);
163
+ expect(isTerminalAgentRunStatus("cancelled")).toBe(true);
164
+ expect(events.filter(isTerminalAgentEvent).map(({ type }) => type)).toEqual(
165
+ ["run.completed", "run.failed", "run.cancelled"],
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("Agent SSE", () => {
171
+ test("拆分跨 chunk 缓冲并保留尾部", () => {
172
+ const result = splitAgentSseFrames(
173
+ ": heartbeat\r\n\r\nid: 1\nevent: run.started\ndata: {",
174
+ );
175
+ expect(result).toEqual({
176
+ frames: [": heartbeat"],
177
+ remainder: "id: 1\nevent: run.started\ndata: {",
178
+ });
179
+ });
180
+
181
+ test("解码 heartbeat 与 canonical event", () => {
182
+ expect(decodeAgentSseFrame(": heartbeat")).toEqual({
183
+ kind: "comment",
184
+ comment: "heartbeat",
185
+ });
186
+ const event = events[1]!;
187
+ const frame = `id: 2\nevent: output.delta\ndata: ${JSON.stringify(event)}`;
188
+ expect(decodeAgentSseFrame(frame)).toEqual({
189
+ kind: "event",
190
+ id: 2,
191
+ event,
192
+ });
193
+ });
194
+
195
+ test("拒绝损坏或不一致的 SSE frame", () => {
196
+ expect(() => decodeAgentSseFrame("event: run.started")).toThrow(
197
+ AgentContractParseError,
198
+ );
199
+ expect(() =>
200
+ decodeAgentSseFrame(
201
+ 'id: 2\nevent: run.started\ndata: {"type":"run.started","runId":"run-1","sequence":1}',
202
+ ),
203
+ ).toThrow(AgentContractParseError);
204
+ expect(() =>
205
+ decodeAgentSseFrame(
206
+ 'id: 1\nevent: output.delta\ndata: {"type":"run.started","runId":"run-1","sequence":1}',
207
+ ),
208
+ ).toThrow(AgentContractParseError);
209
+ expect(() =>
210
+ decodeAgentSseFrame("id: 1\nevent: run.started\ndata: not-json"),
211
+ ).toThrow(AgentContractParseError);
212
+ });
213
+ });
@@ -0,0 +1,51 @@
1
+ import {
2
+ AgentContractParseError,
3
+ booleanAt,
4
+ recordAt,
5
+ stringAt,
6
+ } from "./validation.js";
7
+
8
+ export const agentErrorCodes = [
9
+ "OPERATION_NOT_FOUND",
10
+ "OPERATION_DISABLED",
11
+ "INVALID_INPUT",
12
+ "FORBIDDEN",
13
+ "LICENSE_NOT_ALLOWED",
14
+ "RUNTIME_UNAVAILABLE",
15
+ "MODEL_ROUTE_UNAVAILABLE",
16
+ "PROVIDER_UNAVAILABLE",
17
+ "RATE_LIMITED",
18
+ "BUDGET_EXCEEDED",
19
+ "TOOL_DENIED",
20
+ "APPROVAL_REQUIRED",
21
+ "OUTPUT_VALIDATION_FAILED",
22
+ "RUN_CONFLICT",
23
+ "RUN_TIMEOUT",
24
+ "RUN_CANCELLED",
25
+ "STREAM_INTERRUPTED",
26
+ ] as const;
27
+
28
+ export type AgentErrorCode = (typeof agentErrorCodes)[number];
29
+
30
+ export interface AgentError {
31
+ code: AgentErrorCode;
32
+ message: string;
33
+ requestId: string;
34
+ retryable: boolean;
35
+ }
36
+
37
+ const errorCodeSet: ReadonlySet<string> = new Set(agentErrorCodes);
38
+
39
+ export function parseAgentError(input: unknown): AgentError {
40
+ const record = recordAt(input, "error");
41
+ const code = stringAt(record, "code", "error");
42
+ if (!errorCodeSet.has(code)) {
43
+ throw new AgentContractParseError("error.code", "错误码无效");
44
+ }
45
+ return {
46
+ code: code as AgentErrorCode,
47
+ message: stringAt(record, "message", "error"),
48
+ requestId: stringAt(record, "requestId", "error"),
49
+ retryable: booleanAt(record, "retryable", "error"),
50
+ };
51
+ }