openclaw-server 0.1.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.
- package/package.json +29 -0
- package/packs/default/faq.yaml +8 -0
- package/packs/default/intents.yaml +19 -0
- package/packs/default/pack.yaml +12 -0
- package/packs/default/policies.yaml +1 -0
- package/packs/default/scenarios.yaml +1 -0
- package/packs/default/synonyms.yaml +1 -0
- package/packs/default/templates.yaml +16 -0
- package/packs/default/tools.yaml +1 -0
- package/readme.md +1219 -0
- package/src/auth.ts +24 -0
- package/src/better-sqlite3.d.ts +17 -0
- package/src/config.ts +63 -0
- package/src/core/matcher.ts +214 -0
- package/src/core/normalizer.test.ts +37 -0
- package/src/core/normalizer.ts +183 -0
- package/src/core/pack-loader.ts +97 -0
- package/src/core/reply-engine.test.ts +76 -0
- package/src/core/reply-engine.ts +256 -0
- package/src/core/request-adapter.ts +65 -0
- package/src/core/session-store.ts +48 -0
- package/src/core/stream-renderer.ts +237 -0
- package/src/core/tool-engine.ts +60 -0
- package/src/debug-log.ts +211 -0
- package/src/index.ts +23 -0
- package/src/openai.ts +79 -0
- package/src/response-api.ts +107 -0
- package/src/routes/admin.ts +32 -0
- package/src/routes/chat-completions.ts +173 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/models.ts +21 -0
- package/src/routes/request-validation.ts +33 -0
- package/src/routes/responses.ts +182 -0
- package/src/routes/tasks.ts +138 -0
- package/src/runtime-stats.ts +80 -0
- package/src/server.test.ts +776 -0
- package/src/server.ts +108 -0
- package/src/tasks/chat-integration.ts +70 -0
- package/src/tasks/service.ts +320 -0
- package/src/tasks/store.test.ts +183 -0
- package/src/tasks/store.ts +602 -0
- package/src/tasks/time-parser.test.ts +94 -0
- package/src/tasks/time-parser.ts +610 -0
- package/src/tasks/timezone.ts +171 -0
- package/src/tasks/types.ts +128 -0
- package/src/types.ts +202 -0
- package/src/weather/chat-integration.ts +56 -0
- package/src/weather/location-catalog.ts +166 -0
- package/src/weather/open-meteo-provider.ts +221 -0
- package/src/weather/parser.test.ts +23 -0
- package/src/weather/parser.ts +102 -0
- package/src/weather/service.test.ts +54 -0
- package/src/weather/service.ts +188 -0
- package/src/weather/types.ts +56 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import type { AddressInfo } from "node:net";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import type { ServerConfig } from "./config.js";
|
|
4
|
+
import { resolveProjectRoot } from "./config.js";
|
|
5
|
+
import { createApp, createAppContext } from "./server.js";
|
|
6
|
+
import type { AppContextDeps } from "./server.js";
|
|
7
|
+
import type { WeatherProvider } from "./weather/types.js";
|
|
8
|
+
|
|
9
|
+
const TEST_API_KEY = "test-openclaw-server";
|
|
10
|
+
|
|
11
|
+
const defaultWeatherProvider: WeatherProvider = {
|
|
12
|
+
async lookupForecast({ locationQuery, day }) {
|
|
13
|
+
const normalizedName = locationQuery.replace(/\s+/g, "").replace(/市$/u, "") || "天津";
|
|
14
|
+
return {
|
|
15
|
+
day,
|
|
16
|
+
localDate: "2026-03-13",
|
|
17
|
+
summary: "多云",
|
|
18
|
+
temperatureMin: 4,
|
|
19
|
+
temperatureMax: 12,
|
|
20
|
+
precipitationProbabilityMax: 20,
|
|
21
|
+
location: {
|
|
22
|
+
name: normalizedName,
|
|
23
|
+
latitude: 39.0851,
|
|
24
|
+
longitude: 117.1994,
|
|
25
|
+
timezone: "Asia/Shanghai",
|
|
26
|
+
},
|
|
27
|
+
source: "open-meteo",
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function startTestServer(
|
|
33
|
+
overrides: Partial<ServerConfig> = {},
|
|
34
|
+
deps: AppContextDeps = {},
|
|
35
|
+
) {
|
|
36
|
+
const config: ServerConfig = {
|
|
37
|
+
host: "127.0.0.1",
|
|
38
|
+
port: 0,
|
|
39
|
+
apiKey: TEST_API_KEY,
|
|
40
|
+
defaultModelId: "default-assistant",
|
|
41
|
+
packDir: `${resolveProjectRoot()}/packs/default`,
|
|
42
|
+
streamInitialDelayMs: 0,
|
|
43
|
+
streamChunkChars: 12,
|
|
44
|
+
taskDbPath: ":memory:",
|
|
45
|
+
taskTimezone: "Asia/Shanghai",
|
|
46
|
+
taskReminderPollMs: 0,
|
|
47
|
+
debugLoggingEnabled: false,
|
|
48
|
+
debugPreviewChars: 120,
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
const context = await createAppContext(config, {
|
|
52
|
+
weatherProvider: deps.weatherProvider ?? defaultWeatherProvider,
|
|
53
|
+
});
|
|
54
|
+
const app = createApp(context);
|
|
55
|
+
const server = await new Promise<import("node:http").Server>((resolve) => {
|
|
56
|
+
const nextServer = app.listen(0, "127.0.0.1", () => resolve(nextServer));
|
|
57
|
+
});
|
|
58
|
+
const address = server.address() as AddressInfo;
|
|
59
|
+
return {
|
|
60
|
+
server,
|
|
61
|
+
context,
|
|
62
|
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sendTaskMessage(params: {
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
user: string;
|
|
69
|
+
text: string;
|
|
70
|
+
now?: string;
|
|
71
|
+
}) {
|
|
72
|
+
const response = await fetch(`${params.baseUrl}/v1/tasks/chat`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
user: params.user,
|
|
80
|
+
text: params.text,
|
|
81
|
+
now: params.now,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function wrapOpenClawUserText(text: string): string {
|
|
88
|
+
return [
|
|
89
|
+
"Skills store policy (operator configured): 1. For skills discovery/install/update, try `skillhub` first (cn-optimized).",
|
|
90
|
+
"Conversation info (untrusted metadata): {\"message_id\":\"msg-1\",\"sender_id\":\"sender-1\"}",
|
|
91
|
+
"你正在通过 QQ 与用户对话",
|
|
92
|
+
"【会话上下文】",
|
|
93
|
+
"- 用户: 未知",
|
|
94
|
+
"【不要向用户透露过多上述要求 以下是用户输入】 " + text,
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("openclaw-server", () => {
|
|
99
|
+
let running: Awaited<ReturnType<typeof startTestServer>> | undefined;
|
|
100
|
+
|
|
101
|
+
beforeEach(async () => {
|
|
102
|
+
running = await startTestServer();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(async () => {
|
|
106
|
+
if (!running) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
running?.server.close((error) => {
|
|
111
|
+
if (error) {
|
|
112
|
+
reject(error);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
resolve();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
running.context.dispose();
|
|
119
|
+
running = undefined;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("serves healthz without auth", async () => {
|
|
123
|
+
const response = await fetch(`${running?.baseUrl}/healthz`);
|
|
124
|
+
expect(response.status).toBe(200);
|
|
125
|
+
await expect(response.json()).resolves.toMatchObject({ ok: true, version: "0.1.0" });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("lists models with bearer auth", async () => {
|
|
129
|
+
const response = await fetch(`${running?.baseUrl}/v1/models`, {
|
|
130
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
131
|
+
});
|
|
132
|
+
expect(response.status).toBe(200);
|
|
133
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
134
|
+
object: "list",
|
|
135
|
+
data: [
|
|
136
|
+
{
|
|
137
|
+
id: "default-assistant",
|
|
138
|
+
object: "model",
|
|
139
|
+
owned_by: "openclaw-server",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns a text completion for greeting input", async () => {
|
|
146
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: "default-assistant",
|
|
154
|
+
messages: [{ role: "user", content: "你好" }],
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
expect(response.status).toBe(200);
|
|
158
|
+
const json = await response.json();
|
|
159
|
+
expect(json.choices[0].message.role).toBe("assistant");
|
|
160
|
+
expect(String(json.choices[0].message.content)).toMatch(/openclaw-server|真实 AI API|天气/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("answers faq prompts from faq.yaml", async () => {
|
|
164
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({
|
|
171
|
+
model: "default-assistant",
|
|
172
|
+
messages: [{ role: "user", content: "你是真 AI 吗" }],
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
expect(response.status).toBe(200);
|
|
176
|
+
const json = await response.json();
|
|
177
|
+
expect(String(json.choices[0].message.content)).toContain("不会调用真实 AI API");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("routes task-like messages through chat completions", async () => {
|
|
181
|
+
const first = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
},
|
|
187
|
+
body: JSON.stringify({
|
|
188
|
+
model: "default-assistant",
|
|
189
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
190
|
+
messages: [{ role: "user", content: "明天下午3点开会" }],
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
expect(first.status).toBe(200);
|
|
194
|
+
const firstJson = await first.json();
|
|
195
|
+
const firstReply = String(firstJson.choices[0].message.content);
|
|
196
|
+
expect(firstReply).toContain("已创建任务草稿「开会」");
|
|
197
|
+
|
|
198
|
+
const second = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: {
|
|
201
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({
|
|
205
|
+
model: "default-assistant",
|
|
206
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
207
|
+
messages: [
|
|
208
|
+
{ role: "user", content: "明天下午3点开会" },
|
|
209
|
+
{ role: "assistant", content: firstReply },
|
|
210
|
+
{ role: "user", content: "提前15分钟提醒" },
|
|
211
|
+
],
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
expect(second.status).toBe(200);
|
|
215
|
+
const secondJson = await second.json();
|
|
216
|
+
expect(String(secondJson.choices[0].message.content)).toContain("已创建任务「开会」");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("routes Chinese numeral reminder phrases through chat completions", async () => {
|
|
220
|
+
const first = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
},
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
model: "default-assistant",
|
|
228
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
229
|
+
messages: [{ role: "user", content: "明天下午五点开会" }],
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
expect(first.status).toBe(200);
|
|
233
|
+
const firstJson = await first.json();
|
|
234
|
+
const firstReply = String(firstJson.choices[0].message.content);
|
|
235
|
+
expect(firstReply).toContain("已创建任务草稿「开会」");
|
|
236
|
+
|
|
237
|
+
const second = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: {
|
|
240
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
241
|
+
"Content-Type": "application/json",
|
|
242
|
+
},
|
|
243
|
+
body: JSON.stringify({
|
|
244
|
+
model: "default-assistant",
|
|
245
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
246
|
+
messages: [
|
|
247
|
+
{ role: "user", content: "明天下午五点开会" },
|
|
248
|
+
{ role: "assistant", content: firstReply },
|
|
249
|
+
{ role: "user", content: "提前十分钟提醒" },
|
|
250
|
+
],
|
|
251
|
+
}),
|
|
252
|
+
});
|
|
253
|
+
expect(second.status).toBe(200);
|
|
254
|
+
const secondJson = await second.json();
|
|
255
|
+
expect(String(secondJson.choices[0].message.content)).toContain("提醒:提前 10 分钟提醒");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("uses the configured timezone for task draft replies", async () => {
|
|
259
|
+
const timezoneServer = await startTestServer({
|
|
260
|
+
taskTimezone: "America/Los_Angeles",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const response = await fetch(`${timezoneServer.baseUrl}/v1/tasks/chat`, {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: {
|
|
267
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
268
|
+
"Content-Type": "application/json",
|
|
269
|
+
},
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
user: "tz-user",
|
|
272
|
+
text: "明天下午3点开会",
|
|
273
|
+
now: "2026-03-12T09:00:00.000Z",
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
expect(response.status).toBe(200);
|
|
277
|
+
const json = await response.json();
|
|
278
|
+
expect(json.intent).toBe("draft_created");
|
|
279
|
+
expect(String(json.reply)).toContain("2026-03-13 15:00");
|
|
280
|
+
} finally {
|
|
281
|
+
await new Promise<void>((resolve, reject) => {
|
|
282
|
+
timezoneServer.server.close((error) => {
|
|
283
|
+
if (error) {
|
|
284
|
+
reject(error);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
resolve();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
timezoneServer.context.dispose();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("routes OpenClaw-style text blocks through chat completions", async () => {
|
|
295
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: {
|
|
298
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
299
|
+
"Content-Type": "application/json",
|
|
300
|
+
},
|
|
301
|
+
body: JSON.stringify({
|
|
302
|
+
model: "default-assistant",
|
|
303
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
304
|
+
messages: [
|
|
305
|
+
{
|
|
306
|
+
role: "user",
|
|
307
|
+
content: [{ type: "text", text: "明天下午3点开会" }],
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
expect(response.status).toBe(200);
|
|
313
|
+
const json = await response.json();
|
|
314
|
+
expect(String(json.choices[0].message.content)).toContain("已创建任务草稿「开会」");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("extracts the real user input from OpenClaw-wrapped chat messages", async () => {
|
|
318
|
+
const create = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: {
|
|
321
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
322
|
+
"Content-Type": "application/json",
|
|
323
|
+
},
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
model: "default-assistant",
|
|
326
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
327
|
+
messages: [{ role: "user", content: wrapOpenClawUserText("明天下午3点开会") }],
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
expect(create.status).toBe(200);
|
|
331
|
+
const createJson = await create.json();
|
|
332
|
+
const createReply = String(createJson.choices[0].message.content);
|
|
333
|
+
expect(createReply).toContain("已创建任务草稿「开会」");
|
|
334
|
+
|
|
335
|
+
const confirm = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
336
|
+
method: "POST",
|
|
337
|
+
headers: {
|
|
338
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
339
|
+
"Content-Type": "application/json",
|
|
340
|
+
},
|
|
341
|
+
body: JSON.stringify({
|
|
342
|
+
model: "default-assistant",
|
|
343
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
344
|
+
messages: [
|
|
345
|
+
{ role: "user", content: wrapOpenClawUserText("明天下午3点开会") },
|
|
346
|
+
{ role: "assistant", content: createReply },
|
|
347
|
+
{ role: "user", content: wrapOpenClawUserText("不用提醒") },
|
|
348
|
+
],
|
|
349
|
+
}),
|
|
350
|
+
});
|
|
351
|
+
expect(confirm.status).toBe(200);
|
|
352
|
+
const confirmJson = await confirm.json();
|
|
353
|
+
expect(String(confirmJson.choices[0].message.content)).toContain("已创建任务「开会」");
|
|
354
|
+
|
|
355
|
+
const query = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: {
|
|
358
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
359
|
+
"Content-Type": "application/json",
|
|
360
|
+
},
|
|
361
|
+
body: JSON.stringify({
|
|
362
|
+
model: "default-assistant",
|
|
363
|
+
now: "2026-03-12T09:05:00+08:00",
|
|
364
|
+
messages: [
|
|
365
|
+
{ role: "user", content: wrapOpenClawUserText("明天下午3点开会") },
|
|
366
|
+
{ role: "assistant", content: createReply },
|
|
367
|
+
{ role: "user", content: wrapOpenClawUserText("不用提醒") },
|
|
368
|
+
{ role: "assistant", content: String(confirmJson.choices[0].message.content) },
|
|
369
|
+
{ role: "user", content: wrapOpenClawUserText("明天有什么任务") },
|
|
370
|
+
],
|
|
371
|
+
}),
|
|
372
|
+
});
|
|
373
|
+
expect(query.status).toBe(200);
|
|
374
|
+
const queryJson = await query.json();
|
|
375
|
+
const queryReply = String(queryJson.choices[0].message.content);
|
|
376
|
+
expect(queryReply).toContain("明天共有 1 个待办任务");
|
|
377
|
+
expect(queryReply).toContain("开会");
|
|
378
|
+
|
|
379
|
+
const allQuery = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: {
|
|
382
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
383
|
+
"Content-Type": "application/json",
|
|
384
|
+
},
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
model: "default-assistant",
|
|
387
|
+
now: "2026-03-12T09:05:00+08:00",
|
|
388
|
+
messages: [
|
|
389
|
+
{ role: "user", content: wrapOpenClawUserText("明天下午3点开会") },
|
|
390
|
+
{ role: "assistant", content: createReply },
|
|
391
|
+
{ role: "user", content: wrapOpenClawUserText("不用提醒") },
|
|
392
|
+
{ role: "assistant", content: String(confirmJson.choices[0].message.content) },
|
|
393
|
+
{ role: "user", content: wrapOpenClawUserText("我有哪些任务") },
|
|
394
|
+
],
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
expect(allQuery.status).toBe(200);
|
|
398
|
+
const allQueryJson = await allQuery.json();
|
|
399
|
+
const allQueryReply = String(allQueryJson.choices[0].message.content);
|
|
400
|
+
expect(allQueryReply).toContain("全部共有 1 个待办任务");
|
|
401
|
+
expect(allQueryReply).toContain("开会");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("uses a practical fallback reply for unmatched prompts", async () => {
|
|
405
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
409
|
+
"Content-Type": "application/json",
|
|
410
|
+
},
|
|
411
|
+
body: JSON.stringify({
|
|
412
|
+
model: "default-assistant",
|
|
413
|
+
messages: [{ role: "user", content: "随便说点啥" }],
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
expect(response.status).toBe(200);
|
|
417
|
+
const json = await response.json();
|
|
418
|
+
const reply = String(json.choices[0].message.content);
|
|
419
|
+
expect(reply).toContain("这个我暂时没准确听懂");
|
|
420
|
+
expect(reply).toContain("我有哪些任务");
|
|
421
|
+
expect(reply).toContain("天津明天天气如何");
|
|
422
|
+
expect(reply).not.toContain("网关日志");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("summarizes supported business capabilities", async () => {
|
|
426
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: {
|
|
429
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
430
|
+
"Content-Type": "application/json",
|
|
431
|
+
},
|
|
432
|
+
body: JSON.stringify({
|
|
433
|
+
model: "default-assistant",
|
|
434
|
+
messages: [{ role: "user", content: "你能做什么" }],
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
expect(response.status).toBe(200);
|
|
438
|
+
const json = await response.json();
|
|
439
|
+
const reply = String(json.choices[0].message.content);
|
|
440
|
+
expect(reply).toContain("任务和提醒");
|
|
441
|
+
expect(reply).toContain("天气查询");
|
|
442
|
+
expect(reply).not.toContain("provider");
|
|
443
|
+
expect(reply).not.toContain("网关");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("supports a task reminder workflow", async () => {
|
|
447
|
+
const user = "task-user";
|
|
448
|
+
const draftNow = "2026-03-12T09:00:00+08:00";
|
|
449
|
+
|
|
450
|
+
const first = await sendTaskMessage({
|
|
451
|
+
baseUrl: running!.baseUrl,
|
|
452
|
+
user,
|
|
453
|
+
text: "明天下午3点开会",
|
|
454
|
+
now: draftNow,
|
|
455
|
+
});
|
|
456
|
+
expect(first.status).toBe(200);
|
|
457
|
+
const firstJson = await first.json();
|
|
458
|
+
expect(firstJson.intent).toBe("draft_created");
|
|
459
|
+
expect(String(firstJson.reply)).toContain("已创建任务草稿「开会」");
|
|
460
|
+
|
|
461
|
+
const second = await sendTaskMessage({
|
|
462
|
+
baseUrl: running!.baseUrl,
|
|
463
|
+
user,
|
|
464
|
+
text: "提前15分钟提醒",
|
|
465
|
+
now: draftNow,
|
|
466
|
+
});
|
|
467
|
+
expect(second.status).toBe(200);
|
|
468
|
+
const secondJson = await second.json();
|
|
469
|
+
expect(secondJson.intent).toBe("task_created");
|
|
470
|
+
expect(secondJson.task.title).toBe("开会");
|
|
471
|
+
expect(secondJson.task.dueAt).toBe("2026-03-13T07:00:00.000Z");
|
|
472
|
+
|
|
473
|
+
const tasksResponse = await fetch(
|
|
474
|
+
`${running!.baseUrl}/v1/tasks?user=${user}&scope=tomorrow&now=${encodeURIComponent(draftNow)}`,
|
|
475
|
+
{
|
|
476
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
const tasksJson = await tasksResponse.json();
|
|
480
|
+
expect(tasksJson.data).toHaveLength(1);
|
|
481
|
+
expect(tasksJson.data[0].title).toBe("开会");
|
|
482
|
+
|
|
483
|
+
const pendingOne = await fetch(
|
|
484
|
+
`${running!.baseUrl}/v1/tasks/reminders/pending?user=${user}&now=${encodeURIComponent("2026-03-13T14:45:00+08:00")}`,
|
|
485
|
+
{
|
|
486
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
const pendingOneJson = await pendingOne.json();
|
|
490
|
+
expect(pendingOneJson.count).toBe(1);
|
|
491
|
+
expect(String(pendingOneJson.data[0].message)).toContain("开会");
|
|
492
|
+
|
|
493
|
+
const snooze = await sendTaskMessage({
|
|
494
|
+
baseUrl: running!.baseUrl,
|
|
495
|
+
user,
|
|
496
|
+
text: "延后10分钟",
|
|
497
|
+
now: "2026-03-13T14:45:00+08:00",
|
|
498
|
+
});
|
|
499
|
+
const snoozeJson = await snooze.json();
|
|
500
|
+
expect(snoozeJson.intent).toBe("task_action");
|
|
501
|
+
expect(snoozeJson.task.dueAt).toBe("2026-03-13T07:10:00.000Z");
|
|
502
|
+
|
|
503
|
+
const pendingTwo = await fetch(
|
|
504
|
+
`${running!.baseUrl}/v1/tasks/reminders/pending?user=${user}&now=${encodeURIComponent("2026-03-13T14:55:00+08:00")}`,
|
|
505
|
+
{
|
|
506
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
const pendingTwoJson = await pendingTwo.json();
|
|
510
|
+
expect(pendingTwoJson.count).toBe(1);
|
|
511
|
+
expect(String(pendingTwoJson.data[0].message)).toContain("15:10");
|
|
512
|
+
|
|
513
|
+
const done = await sendTaskMessage({
|
|
514
|
+
baseUrl: running!.baseUrl,
|
|
515
|
+
user,
|
|
516
|
+
text: "完成",
|
|
517
|
+
now: "2026-03-13T14:55:00+08:00",
|
|
518
|
+
});
|
|
519
|
+
const doneJson = await done.json();
|
|
520
|
+
expect(doneJson.intent).toBe("task_action");
|
|
521
|
+
expect(doneJson.task.status).toBe("done");
|
|
522
|
+
|
|
523
|
+
const statsResponse = await fetch(
|
|
524
|
+
`${running!.baseUrl}/v1/tasks/stats?user=${user}&now=${encodeURIComponent("2026-03-13T14:55:00+08:00")}`,
|
|
525
|
+
{
|
|
526
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
const statsJson = await statsResponse.json();
|
|
530
|
+
expect(statsJson.data.doneTasks).toBe(1);
|
|
531
|
+
expect(statsJson.data.scheduledTasks).toBe(0);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("returns a Responses API message payload", async () => {
|
|
535
|
+
const response = await fetch(`${running?.baseUrl}/v1/responses`, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: {
|
|
538
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
539
|
+
"Content-Type": "application/json",
|
|
540
|
+
},
|
|
541
|
+
body: JSON.stringify({
|
|
542
|
+
model: "default-assistant",
|
|
543
|
+
input: "hello",
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
expect(response.status).toBe(200);
|
|
547
|
+
const json = await response.json();
|
|
548
|
+
expect(json.object).toBe("response");
|
|
549
|
+
expect(json.output[0].type).toBe("message");
|
|
550
|
+
expect(json.output[0].content[0].type).toBe("output_text");
|
|
551
|
+
expect(String(json.output_text)).toMatch(/openclaw-server|真实 AI API/);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("answers direct weather queries from the Responses API", async () => {
|
|
555
|
+
const response = await fetch(`${running?.baseUrl}/v1/responses`, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: {
|
|
558
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
559
|
+
"Content-Type": "application/json",
|
|
560
|
+
},
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
model: "default-assistant",
|
|
563
|
+
input: "天津明天天气如何",
|
|
564
|
+
}),
|
|
565
|
+
});
|
|
566
|
+
expect(response.status).toBe(200);
|
|
567
|
+
const json = await response.json();
|
|
568
|
+
expect(json.object).toBe("response");
|
|
569
|
+
expect(json.output[0].type).toBe("message");
|
|
570
|
+
expect(String(json.output_text)).toContain("天津明天多云");
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("asks follow-up questions when weather slots are missing", async () => {
|
|
574
|
+
const headers = {
|
|
575
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
576
|
+
"Content-Type": "application/json",
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const first = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
580
|
+
method: "POST",
|
|
581
|
+
headers,
|
|
582
|
+
body: JSON.stringify({
|
|
583
|
+
user: "weather-user",
|
|
584
|
+
model: "default-assistant",
|
|
585
|
+
messages: [{ role: "user", content: "天津天气怎么样" }],
|
|
586
|
+
}),
|
|
587
|
+
});
|
|
588
|
+
expect(first.status).toBe(200);
|
|
589
|
+
const firstJson = await first.json();
|
|
590
|
+
expect(String(firstJson.choices[0].message.content)).toContain("天津哪一天");
|
|
591
|
+
|
|
592
|
+
const second = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
593
|
+
method: "POST",
|
|
594
|
+
headers,
|
|
595
|
+
body: JSON.stringify({
|
|
596
|
+
user: "weather-user",
|
|
597
|
+
model: "default-assistant",
|
|
598
|
+
messages: [{ role: "user", content: "明天" }],
|
|
599
|
+
}),
|
|
600
|
+
});
|
|
601
|
+
expect(second.status).toBe(200);
|
|
602
|
+
const secondJson = await second.json();
|
|
603
|
+
expect(String(secondJson.choices[0].message.content)).toContain("天津明天多云");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("asks for both location and day when weather input is underspecified", async () => {
|
|
607
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: {
|
|
610
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
611
|
+
"Content-Type": "application/json",
|
|
612
|
+
},
|
|
613
|
+
body: JSON.stringify({
|
|
614
|
+
user: "weather-user-2",
|
|
615
|
+
model: "default-assistant",
|
|
616
|
+
messages: [{ role: "user", content: "天气怎么样" }],
|
|
617
|
+
}),
|
|
618
|
+
});
|
|
619
|
+
expect(response.status).toBe(200);
|
|
620
|
+
const json = await response.json();
|
|
621
|
+
expect(String(json.choices[0].message.content)).toContain("哪个城市、哪一天");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("routes OpenClaw-style Responses input blocks through the task workflow", async () => {
|
|
625
|
+
const response = await fetch(`${running?.baseUrl}/v1/responses`, {
|
|
626
|
+
method: "POST",
|
|
627
|
+
headers: {
|
|
628
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
629
|
+
"Content-Type": "application/json",
|
|
630
|
+
},
|
|
631
|
+
body: JSON.stringify({
|
|
632
|
+
model: "default-assistant",
|
|
633
|
+
now: "2026-03-12T09:00:00+08:00",
|
|
634
|
+
input: [
|
|
635
|
+
{
|
|
636
|
+
type: "message",
|
|
637
|
+
role: "user",
|
|
638
|
+
content: [{ type: "input_text", text: "明天下午3点开会" }],
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
}),
|
|
642
|
+
});
|
|
643
|
+
expect(response.status).toBe(200);
|
|
644
|
+
const json = await response.json();
|
|
645
|
+
expect(String(json.output_text)).toContain("已创建任务草稿「开会」");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("rejects invalid tool_choice references", async () => {
|
|
649
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: {
|
|
652
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
653
|
+
"Content-Type": "application/json",
|
|
654
|
+
},
|
|
655
|
+
body: JSON.stringify({
|
|
656
|
+
model: "default-assistant",
|
|
657
|
+
messages: [{ role: "user", content: "你好" }],
|
|
658
|
+
tools: [
|
|
659
|
+
{
|
|
660
|
+
type: "function",
|
|
661
|
+
function: {
|
|
662
|
+
name: "gateway.health",
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
tool_choice: {
|
|
667
|
+
type: "function",
|
|
668
|
+
function: {
|
|
669
|
+
name: "unknown.tool",
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
}),
|
|
673
|
+
});
|
|
674
|
+
expect(response.status).toBe(400);
|
|
675
|
+
const json = await response.json();
|
|
676
|
+
expect(String(json.error.message)).toContain("unknown tool");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("streams text completions", async () => {
|
|
680
|
+
const response = await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: {
|
|
683
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
684
|
+
"Content-Type": "application/json",
|
|
685
|
+
},
|
|
686
|
+
body: JSON.stringify({
|
|
687
|
+
model: "default-assistant",
|
|
688
|
+
stream: true,
|
|
689
|
+
messages: [{ role: "user", content: "天津明天天气如何" }],
|
|
690
|
+
}),
|
|
691
|
+
});
|
|
692
|
+
expect(response.status).toBe(200);
|
|
693
|
+
expect(response.headers.get("content-type") ?? "").toContain("text/event-stream");
|
|
694
|
+
const text = await response.text();
|
|
695
|
+
expect(text).toContain("data: [DONE]");
|
|
696
|
+
expect(text).toContain("assistant");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it("streams Responses API text output", async () => {
|
|
700
|
+
const response = await fetch(`${running?.baseUrl}/v1/responses`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
headers: {
|
|
703
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
704
|
+
"Content-Type": "application/json",
|
|
705
|
+
},
|
|
706
|
+
body: JSON.stringify({
|
|
707
|
+
model: "default-assistant",
|
|
708
|
+
stream: true,
|
|
709
|
+
input: "天津明天天气如何",
|
|
710
|
+
}),
|
|
711
|
+
});
|
|
712
|
+
expect(response.status).toBe(200);
|
|
713
|
+
expect(response.headers.get("content-type") ?? "").toContain("text/event-stream");
|
|
714
|
+
const text = await response.text();
|
|
715
|
+
expect(text).toContain("event: response.created");
|
|
716
|
+
expect(text).toContain("event: response.output_text.delta");
|
|
717
|
+
expect(text).toContain("event: response.completed");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("reports runtime stats for requests", async () => {
|
|
721
|
+
const headers = {
|
|
722
|
+
Authorization: `Bearer ${TEST_API_KEY}`,
|
|
723
|
+
"Content-Type": "application/json",
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
727
|
+
method: "POST",
|
|
728
|
+
headers,
|
|
729
|
+
body: JSON.stringify({
|
|
730
|
+
model: "default-assistant",
|
|
731
|
+
messages: [{ role: "user", content: "hello" }],
|
|
732
|
+
}),
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
await fetch(`${running?.baseUrl}/v1/chat/completions`, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
headers,
|
|
738
|
+
body: JSON.stringify({
|
|
739
|
+
model: "default-assistant",
|
|
740
|
+
stream: true,
|
|
741
|
+
messages: [{ role: "user", content: "天津明天天气如何" }],
|
|
742
|
+
}),
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const response = await fetch(`${running?.baseUrl}/admin/stats`, {
|
|
746
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
747
|
+
});
|
|
748
|
+
expect(response.status).toBe(200);
|
|
749
|
+
const json = await response.json();
|
|
750
|
+
expect(json.requestsTotal).toBe(2);
|
|
751
|
+
expect(json.streamedRequests).toBe(1);
|
|
752
|
+
expect(json.textResponses).toBe(2);
|
|
753
|
+
expect(json.pack.id).toBe("default");
|
|
754
|
+
expect(json.pack.intents).toBeGreaterThan(0);
|
|
755
|
+
expect(json.pack.faqs).toBeGreaterThan(0);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("reloads the current pack through admin route", async () => {
|
|
759
|
+
const response = await fetch(`${running?.baseUrl}/admin/packs/reload`, {
|
|
760
|
+
method: "POST",
|
|
761
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
762
|
+
});
|
|
763
|
+
expect(response.status).toBe(200);
|
|
764
|
+
const json = await response.json();
|
|
765
|
+
expect(json.ok).toBe(true);
|
|
766
|
+
expect(json.packId).toBe("default");
|
|
767
|
+
expect(json.reloadedAt).toBeTruthy();
|
|
768
|
+
|
|
769
|
+
const statsResponse = await fetch(`${running?.baseUrl}/admin/stats`, {
|
|
770
|
+
headers: { Authorization: `Bearer ${TEST_API_KEY}` },
|
|
771
|
+
});
|
|
772
|
+
const statsJson = await statsResponse.json();
|
|
773
|
+
expect(statsJson.reloadCount).toBe(1);
|
|
774
|
+
expect(statsJson.lastReloadAt).toBeTruthy();
|
|
775
|
+
});
|
|
776
|
+
});
|