@coze-arch/cli 0.0.18 → 0.0.19-alpha.502ddf

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 (97) hide show
  1. package/lib/__templates__/expo/.coze +1 -0
  2. package/lib/__templates__/expo/.cozeproj/scripts/validate.sh +8 -0
  3. package/lib/__templates__/expo/package.json +2 -1
  4. package/lib/__templates__/nextjs/.coze +1 -0
  5. package/lib/__templates__/nextjs/package.json +3 -1
  6. package/lib/__templates__/nextjs/scripts/validate.sh +10 -0
  7. package/lib/__templates__/nuxt-vue/.coze +1 -0
  8. package/lib/__templates__/nuxt-vue/app/pages/index.vue +6 -0
  9. package/lib/__templates__/nuxt-vue/eslint.config.mjs +25 -0
  10. package/lib/__templates__/nuxt-vue/nuxt.config.ts +2 -2
  11. package/lib/__templates__/nuxt-vue/package.json +9 -2
  12. package/lib/__templates__/nuxt-vue/pnpm-lock.yaml +790 -10
  13. package/lib/__templates__/nuxt-vue/scripts/validate.sh +10 -0
  14. package/lib/__templates__/pi-agent/.coze +10 -0
  15. package/lib/__templates__/pi-agent/AGENTS.md +149 -0
  16. package/lib/__templates__/pi-agent/README.md +218 -0
  17. package/lib/__templates__/pi-agent/_gitignore +3 -0
  18. package/lib/__templates__/pi-agent/_npmrc +23 -0
  19. package/lib/__templates__/pi-agent/bin/pi-bot.ts +8 -0
  20. package/lib/__templates__/pi-agent/docs/project-overview.md +368 -0
  21. package/lib/__templates__/pi-agent/docs/user/getting-started.md +46 -0
  22. package/lib/__templates__/pi-agent/package.json +63 -0
  23. package/lib/__templates__/pi-agent/pi-resources/SYSTEM.md +15 -0
  24. package/lib/__templates__/pi-agent/pi-resources/extensions/preference-memory/index.ts +355 -0
  25. package/lib/__templates__/pi-agent/pi-resources/skills/coze-asr/SKILL.md +30 -0
  26. package/lib/__templates__/pi-agent/pi-resources/skills/coze-image-gen/SKILL.md +29 -0
  27. package/lib/__templates__/pi-agent/pi-resources/skills/coze-tts/SKILL.md +57 -0
  28. package/lib/__templates__/pi-agent/pi-resources/skills/coze-video-gen/SKILL.md +40 -0
  29. package/lib/__templates__/pi-agent/pnpm-lock.yaml +8282 -0
  30. package/lib/__templates__/pi-agent/scripts/dev.sh +14 -0
  31. package/lib/__templates__/pi-agent/scripts/prepare.sh +35 -0
  32. package/lib/__templates__/pi-agent/src/agent.ts +363 -0
  33. package/lib/__templates__/pi-agent/src/channels/feishu/index.ts +760 -0
  34. package/lib/__templates__/pi-agent/src/channels/feishu/streaming-card.ts +297 -0
  35. package/lib/__templates__/pi-agent/src/channels/wechat/index.ts +171 -0
  36. package/lib/__templates__/pi-agent/src/cli.ts +117 -0
  37. package/lib/__templates__/pi-agent/src/config.ts +749 -0
  38. package/lib/__templates__/pi-agent/src/core.ts +219 -0
  39. package/lib/__templates__/pi-agent/src/dashboard/api/channels.ts +104 -0
  40. package/lib/__templates__/pi-agent/src/dashboard/api/models.ts +98 -0
  41. package/lib/__templates__/pi-agent/src/dashboard/api/overview.ts +33 -0
  42. package/lib/__templates__/pi-agent/src/dashboard/config-store.ts +64 -0
  43. package/lib/__templates__/pi-agent/src/dashboard/index.ts +74 -0
  44. package/lib/__templates__/pi-agent/src/dashboard/server.ts +610 -0
  45. package/lib/__templates__/pi-agent/src/dashboard/types.ts +25 -0
  46. package/lib/__templates__/pi-agent/src/dashboard/web/index.html +13 -0
  47. package/lib/__templates__/pi-agent/src/dashboard/web/postcss.config.cjs +7 -0
  48. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/app-layout.tsx +172 -0
  49. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/page-title.tsx +17 -0
  50. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/alert.tsx +22 -0
  51. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/badge.tsx +25 -0
  52. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/button.tsx +40 -0
  53. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/card.tsx +29 -0
  54. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/input.tsx +18 -0
  55. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/label.tsx +8 -0
  56. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/select.tsx +80 -0
  57. package/lib/__templates__/pi-agent/src/dashboard/web/src/components/ui/separator.tsx +23 -0
  58. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-fetch.ts +32 -0
  59. package/lib/__templates__/pi-agent/src/dashboard/web/src/hooks/use-local-storage-state.ts +23 -0
  60. package/lib/__templates__/pi-agent/src/dashboard/web/src/main.tsx +24 -0
  61. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/chat-page.tsx +440 -0
  62. package/lib/__templates__/pi-agent/src/dashboard/web/src/pages/overview-page.tsx +330 -0
  63. package/lib/__templates__/pi-agent/src/dashboard/web/src/services/chat-ws-service.ts +167 -0
  64. package/lib/__templates__/pi-agent/src/dashboard/web/src/styles.css +203 -0
  65. package/lib/__templates__/pi-agent/src/dashboard/web/src/utils/index.ts +11 -0
  66. package/lib/__templates__/pi-agent/src/dashboard/web/tsconfig.json +13 -0
  67. package/lib/__templates__/pi-agent/src/dashboard/web/vite.config.ts +17 -0
  68. package/lib/__templates__/pi-agent/src/index.ts +123 -0
  69. package/lib/__templates__/pi-agent/src/pi-resources.ts +125 -0
  70. package/lib/__templates__/pi-agent/src/session-store.ts +223 -0
  71. package/lib/__templates__/pi-agent/src/tools/common/format-coze-error.ts +12 -0
  72. package/lib/__templates__/pi-agent/src/tools/index.ts +2 -0
  73. package/lib/__templates__/pi-agent/src/tools/web-fetch/index.ts +195 -0
  74. package/lib/__templates__/pi-agent/src/tools/web-search/index.ts +206 -0
  75. package/lib/__templates__/pi-agent/template.config.js +45 -0
  76. package/lib/__templates__/pi-agent/tests/cli.test.ts +136 -0
  77. package/lib/__templates__/pi-agent/tests/config.test.ts +377 -0
  78. package/lib/__templates__/pi-agent/tests/dashboard-models-api.test.ts +171 -0
  79. package/lib/__templates__/pi-agent/tests/feishu-channel.test.ts +149 -0
  80. package/lib/__templates__/pi-agent/tests/feishu-streaming-card.test.ts +15 -0
  81. package/lib/__templates__/pi-agent/tests/pi-resources.test.ts +73 -0
  82. package/lib/__templates__/pi-agent/tests/preference-memory.test.ts +43 -0
  83. package/lib/__templates__/pi-agent/tests/session-store.test.ts +61 -0
  84. package/lib/__templates__/pi-agent/tests/smoke/run-smoke.ts +275 -0
  85. package/lib/__templates__/pi-agent/tests/web-fetch.test.ts +157 -0
  86. package/lib/__templates__/pi-agent/tests/web-search.test.ts +208 -0
  87. package/lib/__templates__/pi-agent/tsconfig.json +21 -0
  88. package/lib/__templates__/pi-agent/types/larksuiteoapi-node-sdk.d.ts +113 -0
  89. package/lib/__templates__/taro/.coze +1 -0
  90. package/lib/__templates__/taro/.cozeproj/scripts/validate.sh +8 -0
  91. package/lib/__templates__/taro/package.json +1 -1
  92. package/lib/__templates__/templates.json +24 -0
  93. package/lib/__templates__/vite/.coze +1 -0
  94. package/lib/__templates__/vite/package.json +3 -1
  95. package/lib/__templates__/vite/scripts/validate.sh +10 -0
  96. package/lib/cli.js +13 -2
  97. package/package.json +1 -1
@@ -0,0 +1,377 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import test from "node:test";
6
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
7
+ import {
8
+ getDefaultConfigPath,
9
+ loadConfig,
10
+ loadBotAppConfig,
11
+ resolveRuntimeModel
12
+ } from "../src/config.js";
13
+
14
+ function createConfigFile(config: unknown): string {
15
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-workspace-config-"));
16
+ const workspaceDir = join(tempDir, "workspace");
17
+ const configPath = join(workspaceDir, "config.json");
18
+
19
+ mkdirSync(workspaceDir, { recursive: true });
20
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
21
+
22
+ return configPath;
23
+ }
24
+
25
+ test("loadConfig resolves workspace directory and default model", () => {
26
+ const configPath = createConfigFile({
27
+ agents: {
28
+ defaults: {
29
+ workspace: ".",
30
+ thinkingLevel: "medium",
31
+ model: {
32
+ primary: "coze/glm-4-7-251222"
33
+ }
34
+ }
35
+ },
36
+ models: {
37
+ providers: {
38
+ coze: {
39
+ api: "openai-completions",
40
+ baseUrl: "${COZE_BASE_URL}",
41
+ apiKey: "${COZE_API_KEY}",
42
+ models: [
43
+ {
44
+ id: "glm-4-7-251222",
45
+ name: "GLM 4.7",
46
+ input: ["text"],
47
+ reasoning: false,
48
+ contextWindow: 200000,
49
+ maxTokens: 8192,
50
+ cost: {
51
+ input: 0,
52
+ output: 0,
53
+ cacheRead: 0,
54
+ cacheWrite: 0
55
+ }
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ }
61
+ });
62
+
63
+ const loaded = loadConfig(configPath);
64
+ const tempRoot = dirname(dirname(configPath));
65
+
66
+ rmSync(tempRoot, { recursive: true, force: true });
67
+
68
+ assert.equal(loaded.workspaceDir, dirname(configPath));
69
+ assert.equal(loaded.thinkingLevel, "medium");
70
+ assert.deepEqual(loaded.defaultModel, {
71
+ provider: "coze",
72
+ modelId: "glm-4-7-251222"
73
+ });
74
+ });
75
+
76
+ test("loadBotAppConfig defaults agent mode to pi when env override is absent", () => {
77
+ const configPath = createConfigFile({
78
+ agents: {
79
+ defaults: {
80
+ workspace: ".",
81
+ model: {
82
+ primary: "openai/gpt-5-mini"
83
+ }
84
+ }
85
+ }
86
+ });
87
+
88
+ const loaded = loadBotAppConfig({
89
+ configPath,
90
+ env: {}
91
+ });
92
+ const tempRoot = dirname(dirname(configPath));
93
+
94
+ rmSync(tempRoot, { recursive: true, force: true });
95
+
96
+ assert.equal(loaded.agent.mode, "pi");
97
+ });
98
+
99
+ test("resolveRuntimeModel builds a custom provider model from workspace/config.json", async () => {
100
+ const configPath = createConfigFile({
101
+ agents: {
102
+ defaults: {
103
+ workspace: ".",
104
+ model: {
105
+ primary: "coze/glm-4-7-251222"
106
+ }
107
+ }
108
+ },
109
+ models: {
110
+ providers: {
111
+ missing: {
112
+ api: "openai-completions",
113
+ baseUrl: "${MISSING_BASE_URL}",
114
+ apiKey: "${MISSING_API_KEY}",
115
+ models: [
116
+ {
117
+ id: "unused-model",
118
+ name: "Unused",
119
+ input: ["text"],
120
+ reasoning: false
121
+ }
122
+ ]
123
+ },
124
+ coze: {
125
+ api: "openai-completions",
126
+ baseUrl: "${COZE_BASE_URL}",
127
+ apiKey: "${COZE_API_KEY}",
128
+ headers: {
129
+ "X-Workspace": "${WORKSPACE_ID}"
130
+ },
131
+ models: [
132
+ {
133
+ id: "glm-4-7-251222",
134
+ name: "GLM 4.7",
135
+ input: ["text"],
136
+ reasoning: false,
137
+ contextWindow: 200000,
138
+ maxTokens: 8192,
139
+ headers: {
140
+ "X-Trace": "${TRACE_ID}"
141
+ },
142
+ cost: {
143
+ input: 0,
144
+ output: 0,
145
+ cacheRead: 0,
146
+ cacheWrite: 0
147
+ }
148
+ }
149
+ ]
150
+ }
151
+ }
152
+ }
153
+ });
154
+
155
+ const tempRoot = dirname(dirname(configPath));
156
+ const env = {
157
+ COZE_BASE_URL: "https://coze.example.com/v1",
158
+ COZE_API_KEY: "coze-secret",
159
+ WORKSPACE_ID: "workspace-1",
160
+ TRACE_ID: "trace-1"
161
+ };
162
+
163
+ const resolved = resolveRuntimeModel({
164
+ configPath,
165
+ env
166
+ });
167
+
168
+ assert.ok(resolved);
169
+ assert.equal(resolved.model.provider, "coze");
170
+ assert.equal(resolved.model.api, "openai-completions");
171
+ assert.equal(resolved.model.baseUrl, env.COZE_BASE_URL);
172
+ assert.equal(resolved.apiKey, env.COZE_API_KEY);
173
+ assert.deepEqual(resolved.model.headers, {
174
+ "X-Workspace": env.WORKSPACE_ID,
175
+ "X-Trace": env.TRACE_ID
176
+ });
177
+
178
+ const authStorage = AuthStorage.inMemory();
179
+ const modelRegistry = new ModelRegistry(authStorage, join(tempRoot, "absent-models.json"));
180
+
181
+ authStorage.setRuntimeApiKey(resolved.model.provider, resolved.apiKey!);
182
+
183
+ const auth = await modelRegistry.getApiKeyAndHeaders(resolved.model);
184
+ rmSync(tempRoot, { recursive: true, force: true });
185
+
186
+ assert.deepEqual(auth, {
187
+ ok: true,
188
+ apiKey: env.COZE_API_KEY,
189
+ headers: {
190
+ "X-Workspace": env.WORKSPACE_ID,
191
+ "X-Trace": env.TRACE_ID
192
+ }
193
+ });
194
+ });
195
+
196
+ test("resolveRuntimeModel applies workspace overrides to built-in providers", () => {
197
+ const configPath = createConfigFile({
198
+ agents: {
199
+ defaults: {
200
+ workspace: ".",
201
+ model: {
202
+ primary: "openai/gpt-5-mini"
203
+ }
204
+ }
205
+ },
206
+ models: {
207
+ providers: {
208
+ openai: {
209
+ baseUrl: "https://proxy.example.com/v1",
210
+ headers: {
211
+ "X-Proxy": "${PROXY_ID}"
212
+ },
213
+ modelOverrides: {
214
+ "gpt-5-mini": {
215
+ maxTokens: 4096
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ });
222
+
223
+ const tempRoot = dirname(dirname(configPath));
224
+ const resolved = resolveRuntimeModel({
225
+ provider: "openai",
226
+ model: "gpt-5-mini",
227
+ baseUrl: "https://forced.example.com/v1",
228
+ configPath,
229
+ env: {
230
+ PROXY_ID: "proxy-1"
231
+ }
232
+ });
233
+
234
+ rmSync(tempRoot, { recursive: true, force: true });
235
+
236
+ assert.ok(resolved);
237
+ assert.equal(resolved.model.provider, "openai");
238
+ assert.equal(resolved.model.id, "gpt-5-mini");
239
+ assert.equal(resolved.model.baseUrl, "https://forced.example.com/v1");
240
+ assert.equal(resolved.model.maxTokens, 4096);
241
+ assert.equal(resolved.model.headers?.["X-Proxy"], "proxy-1");
242
+ });
243
+
244
+ test("loadBotAppConfig builds the app config from config.json plus env-backed channels", () => {
245
+ const configPath = createConfigFile({
246
+ agents: {
247
+ defaults: {
248
+ workspace: ".",
249
+ thinkingLevel: "high",
250
+ model: {
251
+ primary: "coze/auto"
252
+ }
253
+ }
254
+ },
255
+ models: {
256
+ providers: {
257
+ coze: {
258
+ api: "openai-completions",
259
+ apiKey: "${COZE_API_KEY}",
260
+ baseUrl: "${COZE_BASE_URL}",
261
+ models: [
262
+ {
263
+ id: "auto",
264
+ name: "Auto",
265
+ input: ["text"],
266
+ reasoning: false
267
+ }
268
+ ]
269
+ }
270
+ }
271
+ },
272
+ channels: {
273
+ feishu: {
274
+ enabled: true,
275
+ appId: "${FEISHU_APP_ID}",
276
+ appSecret: "${FEISHU_APP_SECRET}",
277
+ domain: "${FEISHU_DOMAIN}",
278
+ encryptKey: "${FEISHU_ENCRYPT_KEY}",
279
+ verificationToken: "${FEISHU_VERIFICATION_TOKEN}",
280
+ requireMention: false,
281
+ thinkingReaction: {
282
+ enabled: false,
283
+ emojiType: "OneSecond"
284
+ }
285
+ }
286
+ }
287
+ });
288
+ const tempRoot = dirname(dirname(configPath));
289
+ const loaded = loadBotAppConfig({
290
+ configPath,
291
+ env: {
292
+ PI_BOT_AGENT_MODE: "pi",
293
+ FEISHU_APP_ID: "app-id",
294
+ FEISHU_APP_SECRET: "app-secret",
295
+ FEISHU_DOMAIN: "feishu.example.com",
296
+ FEISHU_ENCRYPT_KEY: "encrypt-key",
297
+ FEISHU_VERIFICATION_TOKEN: "verify-token"
298
+ }
299
+ });
300
+
301
+ rmSync(tempRoot, { recursive: true, force: true });
302
+
303
+ assert.equal(getDefaultConfigPath(), resolve(process.cwd(), "<%= workspaceDir %>/config.json"));
304
+ assert.equal(loaded.agent.mode, "pi");
305
+ assert.equal(loaded.agent.provider, "coze");
306
+ assert.equal(loaded.agent.model, "auto");
307
+ assert.equal(loaded.agent.configPath, configPath);
308
+ assert.equal(loaded.agent.thinkingLevel, "high");
309
+ assert.equal(loaded.agent.cwd, dirname(configPath));
310
+ assert.equal(
311
+ (((loaded.configRoot?.agents as { defaults?: { model?: { primary?: string } } })?.defaults?.model?.primary) ?? ""),
312
+ "coze/auto"
313
+ );
314
+ assert.equal(loaded.channels.feishu?.appId, "app-id");
315
+ assert.equal(loaded.channels.feishu?.verificationToken, "verify-token");
316
+ assert.equal(loaded.routing.feishuGroupRequireMention, false);
317
+ assert.equal(loaded.channels.feishu?.thinkingReaction?.enabled, false);
318
+ assert.equal(loaded.channels.feishu?.thinkingReaction?.emojiType, "OneSecond");
319
+ });
320
+
321
+ test("loadConfig falls back to a minimal mock config when config.json is missing", () => {
322
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-"));
323
+ const configPath = join(tempDir, "workspace", "config.json");
324
+
325
+ const loaded = loadConfig(configPath);
326
+
327
+ rmSync(tempDir, { recursive: true, force: true });
328
+
329
+ assert.equal(loaded.source, "fallback-mock");
330
+ assert.equal(loaded.path, configPath);
331
+ assert.equal(loaded.workspaceDir, dirname(configPath));
332
+ assert.deepEqual(loaded.defaultModel, {
333
+ provider: "openai",
334
+ modelId: "gpt-5-mini"
335
+ });
336
+ assert.equal(loaded.config.channels?.feishu?.enabled, false);
337
+ assert.equal(loaded.config.channels?.wechat?.enabled, false);
338
+ });
339
+
340
+ test("loadBotAppConfig falls back to mock mode and skips file config path when config.json is missing", () => {
341
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-"));
342
+ const configPath = join(tempDir, "workspace", "config.json");
343
+
344
+ const loaded = loadBotAppConfig({
345
+ configPath,
346
+ env: {}
347
+ });
348
+
349
+ rmSync(tempDir, { recursive: true, force: true });
350
+
351
+ assert.equal(loaded.agent.mode, "mock");
352
+ assert.equal(loaded.agent.provider, "openai");
353
+ assert.equal(loaded.agent.model, "gpt-5-mini");
354
+ assert.equal(loaded.agent.configPath, undefined);
355
+ assert.equal(loaded.agent.cwd, dirname(configPath));
356
+ assert.equal(
357
+ (((loaded.configRoot?.agents as { defaults?: { model?: { primary?: string } } })?.defaults?.model?.primary) ?? ""),
358
+ "openai/gpt-5-mini"
359
+ );
360
+ });
361
+
362
+ test("loadBotAppConfig still allows env to override mode when config.json is missing", () => {
363
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-missing-config-"));
364
+ const configPath = join(tempDir, "workspace", "config.json");
365
+
366
+ const loaded = loadBotAppConfig({
367
+ configPath,
368
+ env: {
369
+ PI_BOT_AGENT_MODE: "pi"
370
+ }
371
+ });
372
+
373
+ rmSync(tempDir, { recursive: true, force: true });
374
+
375
+ assert.equal(loaded.agent.mode, "pi");
376
+ assert.equal(loaded.agent.configPath, undefined);
377
+ });
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { ValidationError, readModelsResponse, saveModelsRequest } from "../src/dashboard/api/models.js";
7
+
8
+ function createConfigFile(config: unknown): { configPath: string; cleanup: () => void } {
9
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-bot-dashboard-models-"));
10
+ const workspaceDir = join(tempDir, "workspace");
11
+ const configPath = join(workspaceDir, "config.json");
12
+
13
+ mkdirSync(workspaceDir, { recursive: true });
14
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
15
+
16
+ return {
17
+ configPath,
18
+ cleanup: () => rmSync(tempDir, { recursive: true, force: true }),
19
+ };
20
+ }
21
+
22
+ test("readModelsResponse exposes default model and selectable options", () => {
23
+ const { configPath, cleanup } = createConfigFile({
24
+ agents: {
25
+ defaults: {
26
+ model: {
27
+ primary: "coze/auto",
28
+ },
29
+ },
30
+ },
31
+ models: {
32
+ providers: {
33
+ coze: {
34
+ api: "openai-completions",
35
+ apiKey: "${COZE_API_KEY}",
36
+ baseUrl: "https://coze.example.com/v1",
37
+ authHeader: true,
38
+ models: [
39
+ {
40
+ id: "auto",
41
+ name: "Auto",
42
+ input: ["text", "image"],
43
+ reasoning: true,
44
+ contextWindow: 200000,
45
+ maxTokens: 8192,
46
+ },
47
+ ],
48
+ },
49
+ },
50
+ },
51
+ });
52
+
53
+ const result = readModelsResponse({ configPath });
54
+ cleanup();
55
+
56
+ assert.equal(result.defaultModel, "coze/auto");
57
+ assert.deepEqual(result.options, [{ value: "coze/auto", label: "coze / Auto" }]);
58
+ });
59
+
60
+ test("saveModelsRequest updates default model while preserving unrelated config", () => {
61
+ const { configPath, cleanup } = createConfigFile({
62
+ agents: {
63
+ defaults: {
64
+ model: {
65
+ primary: "coze/auto",
66
+ },
67
+ },
68
+ },
69
+ models: {
70
+ providers: {
71
+ coze: {
72
+ api: "openai-completions",
73
+ apiKey: "${COZE_API_KEY}",
74
+ baseUrl: "https://coze.example.com/v1",
75
+ headers: {
76
+ "X-Provider": "keep-me",
77
+ },
78
+ models: [
79
+ {
80
+ id: "auto",
81
+ name: "Auto",
82
+ input: ["text"],
83
+ reasoning: false,
84
+ contextWindow: 200000,
85
+ maxTokens: 8192,
86
+ cost: {
87
+ input: 0,
88
+ output: 0,
89
+ cacheRead: 0,
90
+ cacheWrite: 0,
91
+ },
92
+ },
93
+ {
94
+ id: "glm-4.7",
95
+ name: "GLM-4.7",
96
+ input: ["text"],
97
+ reasoning: false,
98
+ contextWindow: 256000,
99
+ maxTokens: 8192,
100
+ cost: {
101
+ input: 0,
102
+ output: 0,
103
+ cacheRead: 0,
104
+ cacheWrite: 0,
105
+ },
106
+ },
107
+ ],
108
+ },
109
+ },
110
+ },
111
+ channels: {
112
+ feishu: {
113
+ enabled: true,
114
+ },
115
+ },
116
+ });
117
+
118
+ saveModelsRequest({
119
+ configPath,
120
+ body: {
121
+ models: {
122
+ defaultModel: "coze/glm-4.7",
123
+ },
124
+ },
125
+ });
126
+
127
+ const saved = JSON.parse(readFileSync(configPath, "utf-8")) as {
128
+ agents: { defaults: { model: { primary: string } } };
129
+ models: {
130
+ providers: {
131
+ coze: {
132
+ apiKey: string;
133
+ baseUrl: string;
134
+ authHeader: boolean;
135
+ headers: Record<string, string>;
136
+ models: Array<Record<string, unknown>>;
137
+ };
138
+ };
139
+ };
140
+ channels: { feishu: { enabled: boolean } };
141
+ };
142
+
143
+ cleanup();
144
+
145
+ assert.equal(saved.agents.defaults.model.primary, "coze/glm-4.7");
146
+ assert.deepEqual(saved.models.providers.coze.headers, {
147
+ "X-Provider": "keep-me",
148
+ });
149
+ assert.equal(saved.channels.feishu.enabled, true);
150
+ });
151
+
152
+ test("saveModelsRequest rejects invalid defaultModel", () => {
153
+ const { configPath, cleanup } = createConfigFile({
154
+ agents: { defaults: { model: { primary: "coze/auto" } } },
155
+ models: { providers: { coze: { models: [{ id: "auto", name: "Auto" }] } } },
156
+ });
157
+ try {
158
+ assert.throws(() => {
159
+ saveModelsRequest({
160
+ configPath,
161
+ body: {
162
+ models: {
163
+ defaultModel: "coze/does-not-exist",
164
+ },
165
+ },
166
+ });
167
+ }, ValidationError);
168
+ } finally {
169
+ cleanup();
170
+ }
171
+ });
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createFeishuChannel } from "../src/channels/feishu/index.js";
4
+
5
+ test("feishu channel adds and removes thinking reaction around replies", async () => {
6
+ const operations: string[] = [];
7
+ let nextReactionId = 0;
8
+
9
+ const channel = createFeishuChannel(
10
+ {
11
+ thinkingReaction: {
12
+ enabled: true,
13
+ emojiType: "OneSecond"
14
+ },
15
+ transport: {
16
+ addReaction({ messageId, emojiType }) {
17
+ nextReactionId += 1;
18
+ operations.push(`add:${messageId}:${emojiType}`);
19
+ return {
20
+ reactionId: `reaction-${nextReactionId}`,
21
+ emojiType
22
+ };
23
+ },
24
+ removeReaction({ messageId, reactionId, emojiType }) {
25
+ operations.push(`remove:${messageId}:${reactionId}:${emojiType}`);
26
+ },
27
+ send(message) {
28
+ operations.push(`send:${message.replyToMessageId}:${message.text}`);
29
+ }
30
+ }
31
+ },
32
+ {
33
+ async onMessage() {
34
+ operations.push("handler:start");
35
+ await Promise.resolve();
36
+ operations.push("handler:end");
37
+ return {
38
+ text: "reply text"
39
+ };
40
+ }
41
+ }
42
+ );
43
+
44
+ const result = await channel.simulateIncomingText({
45
+ text: "hello",
46
+ senderId: "user-1",
47
+ conversationId: "dm-1",
48
+ isDirectMessage: true
49
+ });
50
+
51
+ assert.equal(result.handled, true);
52
+ assert.equal(result.reply?.text, "reply text");
53
+ assert.deepEqual(operations, [
54
+ `add:${result.message?.messageId}:OneSecond`,
55
+ "handler:start",
56
+ "handler:end",
57
+ `remove:${result.message?.messageId}:reaction-1:OneSecond`,
58
+ `send:${result.message?.messageId}:reply text`
59
+ ]);
60
+ });
61
+
62
+ test("feishu channel defaults thinking reaction emoji to OneSecond", async () => {
63
+ const operations: string[] = [];
64
+
65
+ const channel = createFeishuChannel(
66
+ {
67
+ transport: {
68
+ addReaction({ messageId, emojiType }) {
69
+ operations.push(`add:${messageId}:${emojiType}`);
70
+ return {
71
+ reactionId: "reaction-1",
72
+ emojiType
73
+ };
74
+ },
75
+ removeReaction({ messageId, reactionId, emojiType }) {
76
+ operations.push(`remove:${messageId}:${reactionId}:${emojiType}`);
77
+ },
78
+ send(message) {
79
+ operations.push(`send:${message.replyToMessageId}:${message.text}`);
80
+ }
81
+ }
82
+ },
83
+ {
84
+ onMessage() {
85
+ return {
86
+ text: "reply text"
87
+ };
88
+ }
89
+ }
90
+ );
91
+
92
+ const result = await channel.simulateIncomingText({
93
+ text: "hello",
94
+ senderId: "user-default",
95
+ conversationId: "dm-default",
96
+ isDirectMessage: true
97
+ });
98
+
99
+ assert.equal(result.handled, true);
100
+ assert.deepEqual(operations, [
101
+ `add:${result.message?.messageId}:OneSecond`,
102
+ `remove:${result.message?.messageId}:reaction-1:OneSecond`,
103
+ `send:${result.message?.messageId}:reply text`
104
+ ]);
105
+ });
106
+
107
+ test("feishu channel still replies when thinking reaction fails", async () => {
108
+ const operations: string[] = [];
109
+
110
+ const channel = createFeishuChannel(
111
+ {
112
+ thinkingReaction: {
113
+ enabled: true,
114
+ emojiType: "OneSecond"
115
+ },
116
+ transport: {
117
+ addReaction() {
118
+ operations.push("add-failed");
119
+ throw new Error("reaction unavailable");
120
+ },
121
+ send(message) {
122
+ operations.push(`send:${message.replyToMessageId}:${message.text}`);
123
+ }
124
+ }
125
+ },
126
+ {
127
+ onMessage() {
128
+ operations.push("handler");
129
+ return {
130
+ text: "reply after failure"
131
+ };
132
+ }
133
+ }
134
+ );
135
+
136
+ const result = await channel.simulateIncomingText({
137
+ text: "hello",
138
+ senderId: "user-2",
139
+ conversationId: "dm-2",
140
+ isDirectMessage: true
141
+ });
142
+
143
+ assert.equal(result.handled, true);
144
+ assert.deepEqual(operations, [
145
+ "add-failed",
146
+ "handler",
147
+ `send:${result.message?.messageId}:reply after failure`
148
+ ]);
149
+ });
@@ -0,0 +1,15 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mergeStreamingText } from "../src/channels/feishu/streaming-card.js";
4
+
5
+ test("mergeStreamingText prefers progressive full-text updates", () => {
6
+ assert.equal(mergeStreamingText("", "hel"), "hel");
7
+ assert.equal(mergeStreamingText("hel", "hello"), "hello");
8
+ assert.equal(mergeStreamingText("hello", "hello world"), "hello world");
9
+ });
10
+
11
+ test("mergeStreamingText merges overlapping chunks without duplicating suffixes", () => {
12
+ assert.equal(mergeStreamingText("hello", "lo world"), "hello world");
13
+ assert.equal(mergeStreamingText("abc", "bcdef"), "abcdef");
14
+ });
15
+