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.
- package/dist/cli.js +8 -3
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/server/Dockerfile +2 -0
- package/template/apps/server/src/module-host.test.ts +6 -0
- package/template/apps/server/src/module-host.ts +9 -6
- package/template/apps/web/Dockerfile +2 -0
- package/template/apps/web/src/pages/system/components/SystemResourceTable.vue +6 -5
- package/template/package.json +1 -1
- package/template/packages/agent-client/index.ts +3 -0
- package/template/packages/agent-client/package.json +27 -0
- package/template/packages/agent-client/src/client.test.ts +344 -0
- package/template/packages/agent-client/src/client.ts +186 -0
- package/template/packages/agent-client/src/error.ts +88 -0
- package/template/packages/agent-client/src/event-queue.ts +62 -0
- package/template/packages/agent-client/src/http.ts +19 -0
- package/template/packages/agent-client/src/run-protocol.test.ts +231 -0
- package/template/packages/agent-client/src/run.ts +320 -0
- package/template/packages/agent-client/src/types.ts +35 -0
- package/template/packages/agent-client/tsconfig.json +21 -0
- package/template/packages/agent-contracts/index.ts +7 -0
- package/template/packages/agent-contracts/package.json +24 -0
- package/template/packages/agent-contracts/src/contracts.test.ts +213 -0
- package/template/packages/agent-contracts/src/errors.ts +51 -0
- package/template/packages/agent-contracts/src/events.ts +161 -0
- package/template/packages/agent-contracts/src/operation.ts +28 -0
- package/template/packages/agent-contracts/src/runs.ts +58 -0
- package/template/packages/agent-contracts/src/sse.ts +95 -0
- package/template/packages/agent-contracts/src/usage.ts +62 -0
- package/template/packages/agent-contracts/src/validation.ts +68 -0
- 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
|
-
|
|
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
|
@@ -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 {
|
|
6
|
-
|
|
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
|
|
18
|
+
baseUploadPolicies?: readonly ModuleUploadPolicy[];
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface InitializedModuleHosts {
|
|
19
|
-
readonly uploadPolicies: readonly
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -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,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
|
+
}
|