@ai-setting/roy-agent-cli 1.0.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.
Files changed (158) hide show
  1. package/README.md +126 -0
  2. package/dist/bin/roy.js +127297 -0
  3. package/dist/roy-agent-darwin-arm64/bin/roy.js +127297 -0
  4. package/dist/roy-agent-darwin-x64/bin/roy.js +127297 -0
  5. package/dist/roy-agent-linux-arm64/bin/roy.js +127297 -0
  6. package/dist/roy-agent-linux-x64/bin/roy.js +127297 -0
  7. package/dist/roy-agent-windows-x64/bin/roy.js +127297 -0
  8. package/package.json +91 -0
  9. package/src/bin/roy.ts +12 -0
  10. package/src/cli.ts +101 -0
  11. package/src/commands/act.ts +480 -0
  12. package/src/commands/commands-add.ts +110 -0
  13. package/src/commands/commands-dirs.ts +70 -0
  14. package/src/commands/commands-info.ts +90 -0
  15. package/src/commands/commands-list.ts +161 -0
  16. package/src/commands/commands-remove.ts +147 -0
  17. package/src/commands/commands.ts +55 -0
  18. package/src/commands/config/config-service.test.ts +449 -0
  19. package/src/commands/config/config-service.ts +312 -0
  20. package/src/commands/config/deep-merge.test.ts +168 -0
  21. package/src/commands/config/deep-merge.ts +63 -0
  22. package/src/commands/config/export.ts +97 -0
  23. package/src/commands/config/filter-history-e2e.test.ts +141 -0
  24. package/src/commands/config/import-preserve-refs.test.ts +212 -0
  25. package/src/commands/config/import.ts +119 -0
  26. package/src/commands/config/index.ts +35 -0
  27. package/src/commands/config/list.ts +281 -0
  28. package/src/commands/config/roy-config-e2e.test.ts +297 -0
  29. package/src/commands/config/types.ts +54 -0
  30. package/src/commands/debug/index.ts +38 -0
  31. package/src/commands/debug/log.test.ts +233 -0
  32. package/src/commands/debug/log.ts +123 -0
  33. package/src/commands/debug/span.test.ts +297 -0
  34. package/src/commands/debug/span.ts +211 -0
  35. package/src/commands/debug/trace.test.ts +254 -0
  36. package/src/commands/debug/trace.ts +140 -0
  37. package/src/commands/eventsource/add.ts +133 -0
  38. package/src/commands/eventsource/index.ts +48 -0
  39. package/src/commands/eventsource/list.ts +194 -0
  40. package/src/commands/eventsource/remove.ts +95 -0
  41. package/src/commands/eventsource/start.ts +103 -0
  42. package/src/commands/eventsource/status.ts +185 -0
  43. package/src/commands/eventsource/stop.ts +89 -0
  44. package/src/commands/index.ts +22 -0
  45. package/src/commands/input-handler.test.ts +76 -0
  46. package/src/commands/input-handler.ts +43 -0
  47. package/src/commands/interactive-esc.test.ts +254 -0
  48. package/src/commands/interactive.shutdown.test.ts +122 -0
  49. package/src/commands/interactive.test.ts +221 -0
  50. package/src/commands/interactive.ts +1015 -0
  51. package/src/commands/lsp/check.ts +92 -0
  52. package/src/commands/lsp/index.ts +32 -0
  53. package/src/commands/lsp/install.ts +126 -0
  54. package/src/commands/lsp/list.ts +64 -0
  55. package/src/commands/mcp/index.ts +27 -0
  56. package/src/commands/mcp/list.ts +116 -0
  57. package/src/commands/mcp/reload.ts +70 -0
  58. package/src/commands/mcp/tools.ts +121 -0
  59. package/src/commands/memory/extract-e2e.test.ts +388 -0
  60. package/src/commands/memory/index.ts +11 -0
  61. package/src/commands/memory/memory-simplified.test.ts +58 -0
  62. package/src/commands/memory/memory.ts +25 -0
  63. package/src/commands/memory/organize.ts +300 -0
  64. package/src/commands/memory/recall.test.ts +120 -0
  65. package/src/commands/memory/recall.ts +88 -0
  66. package/src/commands/memory/record-extract-handle-query.test.ts +385 -0
  67. package/src/commands/memory/record-prompt-component.test.ts +343 -0
  68. package/src/commands/memory/record.test.ts +92 -0
  69. package/src/commands/memory/record.ts +332 -0
  70. package/src/commands/plugin.test.ts +292 -0
  71. package/src/commands/plugin.ts +267 -0
  72. package/src/commands/sessions/active.ts +96 -0
  73. package/src/commands/sessions/add-message.ts +96 -0
  74. package/src/commands/sessions/checkpoints.ts +154 -0
  75. package/src/commands/sessions/compact.test.ts +215 -0
  76. package/src/commands/sessions/compact.ts +269 -0
  77. package/src/commands/sessions/delete.ts +236 -0
  78. package/src/commands/sessions/get.ts +165 -0
  79. package/src/commands/sessions/grep.ts +233 -0
  80. package/src/commands/sessions/index.ts +95 -0
  81. package/src/commands/sessions/list.ts +210 -0
  82. package/src/commands/sessions/messages.test.ts +333 -0
  83. package/src/commands/sessions/messages.ts +248 -0
  84. package/src/commands/sessions/mock.ts +194 -0
  85. package/src/commands/sessions/new.ts +82 -0
  86. package/src/commands/sessions/rename.ts +98 -0
  87. package/src/commands/shared/event-handler.ts +213 -0
  88. package/src/commands/shared/event-message-formatter.ts +295 -0
  89. package/src/commands/shared/index.ts +11 -0
  90. package/src/commands/shared/query-executor.test.ts +434 -0
  91. package/src/commands/shared/query-executor.ts +324 -0
  92. package/src/commands/shared/repl-engine.test.ts +354 -0
  93. package/src/commands/shared/session-manager.test.ts +212 -0
  94. package/src/commands/shared/session-manager.ts +114 -0
  95. package/src/commands/skills/get.ts +90 -0
  96. package/src/commands/skills/index.ts +39 -0
  97. package/src/commands/skills/list.ts +129 -0
  98. package/src/commands/skills/reload.ts +59 -0
  99. package/src/commands/skills/search.ts +132 -0
  100. package/src/commands/skills/show-config.ts +93 -0
  101. package/src/commands/tasks/complete.ts +92 -0
  102. package/src/commands/tasks/create.ts +118 -0
  103. package/src/commands/tasks/delete.ts +86 -0
  104. package/src/commands/tasks/get.ts +116 -0
  105. package/src/commands/tasks/index.ts +53 -0
  106. package/src/commands/tasks/list.ts +140 -0
  107. package/src/commands/tasks/operations.ts +120 -0
  108. package/src/commands/tasks/update.ts +122 -0
  109. package/src/commands/tools/exec-tool.ts +128 -0
  110. package/src/commands/tools/get.ts +114 -0
  111. package/src/commands/tools/index.ts +35 -0
  112. package/src/commands/tools/list.ts +107 -0
  113. package/src/commands/tools/shared/index.ts +7 -0
  114. package/src/commands/tools/shared/schema-helper.ts +111 -0
  115. package/src/commands/workflow/commands/add.ts +315 -0
  116. package/src/commands/workflow/commands/get.ts +193 -0
  117. package/src/commands/workflow/commands/list.ts +137 -0
  118. package/src/commands/workflow/commands/nodes.ts +528 -0
  119. package/src/commands/workflow/commands/remove.ts +94 -0
  120. package/src/commands/workflow/commands/run.ts +398 -0
  121. package/src/commands/workflow/commands/status.ts +147 -0
  122. package/src/commands/workflow/commands/stop.ts +91 -0
  123. package/src/commands/workflow/commands/update.ts +130 -0
  124. package/src/commands/workflow/commands/validate.ts +139 -0
  125. package/src/commands/workflow/commands/workflow-cli.test.ts +196 -0
  126. package/src/commands/workflow/index.ts +65 -0
  127. package/src/commands/workflow/renderers.ts +358 -0
  128. package/src/commands/workflow/validators/index.ts +8 -0
  129. package/src/commands/workflow/validators/node-validator-factory.ts +40 -0
  130. package/src/commands/workflow/validators/node-validator.ts +125 -0
  131. package/src/commands/workflow/validators/nodes/agent-node-validator.ts +58 -0
  132. package/src/commands/workflow/validators/nodes/condition-node-validator.ts +34 -0
  133. package/src/commands/workflow/validators/nodes/decorator-node-validator.ts +45 -0
  134. package/src/commands/workflow/validators/nodes/merge-node-validator.ts +46 -0
  135. package/src/commands/workflow/validators/nodes/skill-node-validator.ts +33 -0
  136. package/src/commands/workflow/validators/nodes/tool-node-validator.ts +54 -0
  137. package/src/commands/workflow/validators/nodes/workflow-node-validator.ts +33 -0
  138. package/src/commands/workflow/validators/types.ts +78 -0
  139. package/src/commands/workflow/validators/workflow-validator.test.ts +273 -0
  140. package/src/commands/workflow/validators/workflow-validator.ts +320 -0
  141. package/src/index.ts +19 -0
  142. package/src/plugin/apply.ts +103 -0
  143. package/src/plugin/discover.ts +219 -0
  144. package/src/plugin/index.ts +45 -0
  145. package/src/plugin/registry.ts +272 -0
  146. package/src/plugin/types.ts +165 -0
  147. package/src/services/context-handler.service.test.ts +501 -0
  148. package/src/services/context-handler.service.ts +372 -0
  149. package/src/services/environment.service.commands-prompt.test.ts +167 -0
  150. package/src/services/environment.service.ts +656 -0
  151. package/src/services/output.service.test.ts +92 -0
  152. package/src/services/output.service.ts +122 -0
  153. package/src/services/quiet-mode.service.test.ts +114 -0
  154. package/src/services/quiet-mode.service.ts +81 -0
  155. package/src/services/stream-output.service.test.ts +214 -0
  156. package/src/services/stream-output.service.ts +323 -0
  157. package/src/util/which.test.ts +101 -0
  158. package/src/util/which.ts +55 -0
@@ -0,0 +1,372 @@
1
+ /**
2
+ * @fileoverview Context Handler Service
3
+ *
4
+ * 处理 env.handle_query 的上下文阈值检测和压缩逻辑
5
+ *
6
+ * 功能:
7
+ * 1. 包装 handle_query,捕获 ContextError
8
+ * 2. 两阶段自动压缩:
9
+ * - 阶段1: 调用 SessionComponent.generateCompactHint() 生成场景化提示
10
+ * - 阶段2: 调用 SessionComponent.compact() 并传入 scenarioHint
11
+ * 3. 用 checkpoint 构造新 query 并重试
12
+ * 4. 生成 trace id 用于调用链追踪
13
+ */
14
+
15
+ import type { Environment, AgentContext } from "@ai-setting/roy-agent-core";
16
+ import type { SessionComponent, SessionCheckpoint } from "@ai-setting/roy-agent-core";
17
+ import {
18
+ ContextError,
19
+ ErrorCodes,
20
+ getTracerProvider,
21
+ type OTelTracer,
22
+ TracedAs,
23
+ } from "@ai-setting/roy-agent-core";
24
+ import { createLogger } from "@ai-setting/roy-agent-core";
25
+
26
+ // 创建 logger
27
+ const logger = createLogger("context-handler");
28
+
29
+ export interface CompactResult {
30
+ success: boolean;
31
+ checkpoint?: SessionCheckpoint;
32
+ newQuery?: string;
33
+ error?: string;
34
+ }
35
+
36
+ export interface HandleQueryOptions {
37
+ /** 最大重试次数(compact 后) */
38
+ maxRetries?: number;
39
+ /** 是否自动压缩 */
40
+ autoCompact?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Context Handler Service
45
+ *
46
+ * 处理 env.handle_query 的上下文阈值检测和压缩逻辑
47
+ *
48
+ * 采用两阶段自动压缩流程:
49
+ * - 阶段1: 根据历史消息生成场景化的压缩提示 (generateCompactHint)
50
+ * - 阶段2: 使用生成的提示进行实际的压缩 (compact with scenarioHint)
51
+ */
52
+ export class ContextHandlerService {
53
+ private readonly maxRetries: number;
54
+ private readonly autoCompact: boolean;
55
+ private tracer?: OTelTracer;
56
+
57
+ constructor(
58
+ private env: Environment,
59
+ private sessionComponent: SessionComponent,
60
+ options: HandleQueryOptions = {}
61
+ ) {
62
+ this.maxRetries = options.maxRetries ?? 1;
63
+ this.autoCompact = options.autoCompact ?? true;
64
+
65
+ // 初始化 tracer
66
+ this.initializeTracer();
67
+ }
68
+
69
+ /**
70
+ * 初始化 tracer
71
+ */
72
+ private initializeTracer(): void {
73
+ try {
74
+ this.tracer = getTracerProvider().getTracer("roy-tracer");
75
+ } catch {
76
+ // Tracer 可能尚未初始化,延迟初始化
77
+ this.tracer = undefined;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 获取 tracer 实例
83
+ */
84
+ private getTracerInstance(): OTelTracer | undefined {
85
+ if (!this.tracer) {
86
+ try {
87
+ this.tracer = getTracerProvider().getTracer("roy-tracer");
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+ return this.tracer;
93
+ }
94
+
95
+ /**
96
+ * 包装 handle_query,添加上下文阈值处理和 trace 追踪
97
+ */
98
+ async handleQueryWithContext(
99
+ query: string,
100
+ context?: AgentContext
101
+ ): Promise<string> {
102
+ // 从 metadata 获取 trace id(如果上层已设置)
103
+ const traceId = (context?.metadata as Record<string, unknown>)?.traceId as string;
104
+ const sessionId = context?.sessionId;
105
+
106
+ // 获取 tracer 实例并启动根 span
107
+ const tracer = this.getTracerInstance();
108
+ const span = tracer?.startSpan("handleQueryWithContext", {
109
+ attributes: {
110
+ query: query.substring(0, 200),
111
+ sessionId,
112
+ traceId,
113
+ },
114
+ });
115
+
116
+ let lastError: Error | undefined;
117
+
118
+ try {
119
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
120
+ try {
121
+ // 正常执行 handle_query
122
+ const result = await this.env.handle_query(query, context);
123
+
124
+ // 结束根 span(成功)
125
+ if (span) {
126
+ span?.end(result);
127
+ }
128
+
129
+ return result;
130
+
131
+ } catch (error) {
132
+ // 检查是否是上下文阈值异常
133
+ if (error instanceof ContextError && error.code === ErrorCodes.CONTEXT_THRESHOLD_EXCEEDED) {
134
+ lastError = error;
135
+
136
+ if (attempt >= this.maxRetries) {
137
+ logger.error("Context threshold exceeded after max retries", {
138
+ attempt,
139
+ maxRetries: this.maxRetries
140
+ });
141
+
142
+ // 结束根 span(失败)
143
+ if (span) {
144
+ span?.end(undefined, error as Error);
145
+ }
146
+ throw error;
147
+ }
148
+
149
+ if (!this.autoCompact) {
150
+ logger.warn("Auto compact disabled, propagating error");
151
+
152
+ // 结束根 span(失败)
153
+ if (span) {
154
+ span?.end(undefined, error as Error);
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ logger.info(`Context threshold exceeded, compacting (attempt ${attempt + 1}/${this.maxRetries + 1})`);
160
+
161
+ // 发布阈值即将压缩事件
162
+ this.env.pushEnvEvent({
163
+ type: "context.threshold_exceeded",
164
+ metadata: {
165
+ sessionId,
166
+ traceId,
167
+ },
168
+ payload: {
169
+ totalTokens: error.usage?.totalTokens,
170
+ contextWindow: error.contextWindow,
171
+ attempt: attempt + 1,
172
+ },
173
+ });
174
+
175
+ // 执行两阶段压缩
176
+ const compactResult = await this.compactSessionWithTwoPhases(
177
+ context?.sessionId,
178
+ query
179
+ );
180
+
181
+ if (!compactResult.success) {
182
+ logger.error("Compact failed", { error: compactResult.error });
183
+
184
+ // 结束根 span(失败)
185
+ if (span) {
186
+ span?.end(undefined, error as Error);
187
+ }
188
+ throw error;
189
+ }
190
+
191
+ // 用新的 query 重试
192
+ if (compactResult.newQuery) {
193
+ query = compactResult.newQuery;
194
+ }
195
+
196
+ continue;
197
+ }
198
+
199
+ // 其他错误直接抛出
200
+ // 结束根 span(失败)
201
+ if (span) {
202
+ span?.end(undefined, error as Error);
203
+ }
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ throw lastError ?? new Error("Unexpected exit from handleQueryWithContext");
209
+ } finally {
210
+ // 清理 span 上下文
211
+ if (span) {
212
+ tracer?.endSpan(span);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 执行两阶段 session compact
219
+ *
220
+ * 阶段1: 生成场景化压缩提示 (generateCompactHint)
221
+ * 阶段2: 使用提示进行压缩 (compact with scenarioHint)
222
+ */
223
+ @TracedAs("context-handler.compactSessionWithTwoPhases", { recordParams: true, recordResult: true })
224
+ async compactSessionWithTwoPhases(
225
+ sessionId?: string,
226
+ originalQuery?: string
227
+ ): Promise<CompactResult> {
228
+ if (!sessionId) {
229
+ return { success: false, error: "No session ID provided" };
230
+ }
231
+
232
+ try {
233
+ // 发布压缩开始事件
234
+ this.env.pushEnvEvent({
235
+ type: "context.compacting",
236
+ metadata: { sessionId },
237
+ payload: { phase: "two-phase" },
238
+ });
239
+
240
+ // ========================================
241
+ // 阶段1: 生成场景化压缩提示
242
+ // ========================================
243
+ let scenarioHint = "";
244
+ try {
245
+ scenarioHint = await this.sessionComponent.generateCompactHint(sessionId);
246
+ logger.info("[Phase 1] Compact hint generated", {
247
+ sessionId,
248
+ hintLength: scenarioHint.length,
249
+ hintPreview: scenarioHint.substring(0, 100),
250
+ });
251
+ } catch (hintError) {
252
+ // 如果生成提示失败,继续使用空提示(不影响主流程)
253
+ logger.warn("[Phase 1] Hint generation failed, continuing with empty hint", {
254
+ error: hintError instanceof Error ? hintError.message : String(hintError),
255
+ });
256
+ scenarioHint = "";
257
+ }
258
+
259
+ // 发布提示生成完成事件
260
+ this.env.pushEnvEvent({
261
+ type: "context.hint_generated",
262
+ metadata: { sessionId },
263
+ payload: {
264
+ hintLength: scenarioHint.length,
265
+ hintPreview: scenarioHint.substring(0, 200),
266
+ },
267
+ });
268
+
269
+ // ========================================
270
+ // 阶段2: 执行实际压缩
271
+ // ========================================
272
+ const result = await this.sessionComponent.compact(sessionId, {
273
+ summary: originalQuery
274
+ ? `Auto-compacted. Original query: ${originalQuery.substring(0, 200)}${originalQuery.length > 200 ? "..." : ""}`
275
+ : "Auto-compacted due to context threshold",
276
+ scenarioHint, // 传入场景化提示
277
+ });
278
+
279
+ // 发布压缩完成事件
280
+ this.env.pushEnvEvent({
281
+ type: "context.compacted",
282
+ metadata: { sessionId },
283
+ payload: {
284
+ checkpointId: result.checkpoint.id,
285
+ messagesCompacted: result.deletedMessageCount,
286
+ scenarioHintUsed: !!scenarioHint,
287
+ },
288
+ });
289
+
290
+ // 构造新 query
291
+ const newQuery = this.constructQueryWithCheckpoint(
292
+ result.checkpoint,
293
+ originalQuery
294
+ );
295
+
296
+ logger.info("[Phase 2] Compact completed", {
297
+ checkpointId: result.checkpoint.id,
298
+ deletedMessageCount: result.deletedMessageCount,
299
+ scenarioHintUsed: !!scenarioHint,
300
+ });
301
+
302
+ return {
303
+ success: true,
304
+ checkpoint: result.checkpoint,
305
+ newQuery,
306
+ };
307
+
308
+ } catch (error) {
309
+ const errorMessage = error instanceof Error ? error.message : String(error);
310
+ logger.error("Two-phase compact failed", { error: errorMessage });
311
+
312
+ return {
313
+ success: false,
314
+ error: errorMessage,
315
+ };
316
+ }
317
+ }
318
+
319
+ /**
320
+ * 执行 session compact(兼容旧接口,内部调用两阶段版本)
321
+ */
322
+ async compactSession(
323
+ sessionId?: string,
324
+ originalQuery?: string
325
+ ): Promise<CompactResult> {
326
+ return this.compactSessionWithTwoPhases(sessionId, originalQuery);
327
+ }
328
+
329
+ /**
330
+ * 用 checkpoint 构造新 query
331
+ */
332
+ @TracedAs("context-handler.constructQuery")
333
+ private constructQueryWithCheckpoint(
334
+ checkpoint: SessionCheckpoint,
335
+ originalQuery?: string
336
+ ): string {
337
+ const parts: string[] = [];
338
+
339
+ parts.push("【会话历史摘要】");
340
+ parts.push(checkpoint.summary);
341
+
342
+ if (checkpoint.processKeyPoints.length > 0) {
343
+ parts.push("");
344
+ parts.push("【已完成的工作】");
345
+ checkpoint.processKeyPoints.forEach((point, i) => {
346
+ parts.push(`${i + 1}. ${point}`);
347
+ });
348
+ }
349
+
350
+ if (checkpoint.currentState) {
351
+ parts.push("");
352
+ parts.push("【当前状态】");
353
+ parts.push(checkpoint.currentState);
354
+ }
355
+
356
+ if (checkpoint.nextSteps.length > 0) {
357
+ parts.push("");
358
+ parts.push("【待完成的任务】");
359
+ checkpoint.nextSteps.forEach((step, i) => {
360
+ parts.push(`${i + 1}. ${step}`);
361
+ });
362
+ }
363
+
364
+ if (originalQuery) {
365
+ parts.push("");
366
+ parts.push("【用户新输入】");
367
+ parts.push(originalQuery);
368
+ }
369
+
370
+ return parts.join("\n");
371
+ }
372
+ }
@@ -0,0 +1,167 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs/promises";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import { EnvironmentService } from "./environment.service";
6
+ import { OutputService } from "./output.service";
7
+
8
+ // 测试命令目录常量(避免污染真实的 ~/.roy-agent/commands)
9
+ const TEST_COMMANDS_DIR = path.join(os.homedir(), ".roy-agent", "commands");
10
+
11
+ /**
12
+ * Commands Prompt Integration Test
13
+ *
14
+ * 验证 EnvironmentService 在创建环境时,
15
+ * 是否正确注册 commands-prompt Hook,
16
+ * 使得 default prompt 能够动态注入用户命令信息。
17
+ *
18
+ * 注意:这些测试会创建真实的命令在 TEST_COMMANDS_DIR,
19
+ * 会在 afterEach 中清理测试命令。
20
+ */
21
+ describe("EnvironmentService Commands Prompt Integration", () => {
22
+ let tempDir: string;
23
+ let configPath: string;
24
+ let outputService: OutputService;
25
+ let testCmdName: string;
26
+
27
+ beforeEach(async () => {
28
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "roy-env-commands-test-"));
29
+ configPath = path.join(tempDir, "config.jsonc");
30
+
31
+ // 创建空配置文件,使用测试命令目录
32
+ await fs.writeFile(configPath, JSON.stringify({
33
+ "command.userCommandsDir": TEST_COMMANDS_DIR,
34
+ "command.projectCommandsDir": path.join(tempDir, "project-commands"),
35
+ }));
36
+
37
+ // 确保测试命令目录存在
38
+ await fs.mkdir(TEST_COMMANDS_DIR, { recursive: true });
39
+
40
+ // 使用真实的 OutputService(支持 quiet 模式)
41
+ outputService = new OutputService({ quiet: true });
42
+
43
+ // 使用唯一命令名避免冲突
44
+ testCmdName = `test-cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
45
+ });
46
+
47
+ afterEach(async () => {
48
+ // 清理测试创建的命令(只清理测试命令,不清理真实命令如 lark-cli)
49
+ try {
50
+ const entries = await fs.readdir(TEST_COMMANDS_DIR);
51
+ for (const entry of entries) {
52
+ // 只清理以 test-cmd 开头的测试命令
53
+ if (entry.startsWith("test-cmd")) {
54
+ await fs.rm(path.join(TEST_COMMANDS_DIR, entry), { recursive: true, force: true });
55
+ }
56
+ }
57
+ } catch {
58
+ // 忽略清理错误
59
+ }
60
+
61
+ // 清理临时目录
62
+ await fs.rm(tempDir, { recursive: true, force: true });
63
+ });
64
+
65
+ describe("registerCommandsPromptHook", () => {
66
+ it("should inject commands into default prompt via hook", async () => {
67
+ // 1. 创建环境
68
+ const envService = new EnvironmentService(outputService);
69
+
70
+ await envService.create({
71
+ configPath,
72
+ sessionStorage: { type: "memory" }, // 使用内存存储,避免污染正式数据库
73
+ });
74
+
75
+ try {
76
+ // 2. 获取 CommandsComponent 并添加命令
77
+ const commandsComponent = envService.getCommands();
78
+ expect(commandsComponent).toBeDefined();
79
+
80
+ await commandsComponent!.add({
81
+ name: testCmdName,
82
+ target: "/usr/bin/echo",
83
+ description: "Test command",
84
+ tips: "Use with args",
85
+ });
86
+
87
+ // 3. 验证命令元信息存在
88
+ const metaList = await commandsComponent!.getCommandsMeta();
89
+ const testCmdMeta = metaList.find(m => m.name === testCmdName);
90
+ expect(testCmdMeta).toBeDefined();
91
+ expect(testCmdMeta?.description).toBe("Test command");
92
+
93
+ // 4. 验证 renderCommandsSection 方法存在
94
+ const envServiceAny = envService as any;
95
+ expect(typeof envServiceAny.renderCommandsSection).toBe("function");
96
+
97
+ // 5. 验证 commands section 渲染正确
98
+ const section = envServiceAny.renderCommandsSection(metaList);
99
+ expect(section).toContain("Your Commands Arsenal");
100
+ expect(section).toContain(`| \`${testCmdName}\` | Test command |`);
101
+ expect(section).toContain("Run `<command> --help`");
102
+
103
+ } finally {
104
+ await envService.dispose();
105
+ }
106
+ });
107
+
108
+ it("should render commands section correctly", async () => {
109
+ // 验证 renderCommandsSection 逻辑
110
+ const metaList = [
111
+ { name: "git", description: "Git version control", tips: "Use --help" },
112
+ { name: "npm", description: "Node package manager", tips: "Use --help" },
113
+ ];
114
+
115
+ const envService = new EnvironmentService(outputService);
116
+
117
+ // 通过反射获取私有方法(仅用于测试)
118
+ const renderCommandsSection = (envService as any).renderCommandsSection?.bind(envService);
119
+
120
+ if (renderCommandsSection) {
121
+ const section = renderCommandsSection(metaList);
122
+
123
+ expect(section).toContain("Your Commands Arsenal");
124
+ expect(section).toContain("| `git` | Git version control |");
125
+ expect(section).toContain("| `npm` | Node package manager |");
126
+ expect(section).toContain("Run `<command> --help`");
127
+
128
+ // 验证按字母顺序排列
129
+ const gitIndex = section.indexOf("| `git`");
130
+ const npmIndex = section.indexOf("| `npm`");
131
+ expect(gitIndex).toBeLessThan(npmIndex);
132
+ }
133
+ });
134
+
135
+ it("should handle empty command list", async () => {
136
+ const envService = new EnvironmentService(outputService);
137
+
138
+ // 通过反射获取私有方法
139
+ const renderCommandsSection = (envService as any).renderCommandsSection?.bind(envService);
140
+
141
+ if (renderCommandsSection) {
142
+ const section = renderCommandsSection([]);
143
+
144
+ // 空命令列表应该只返回标题(无表格行)
145
+ expect(section).toContain("Your Commands Arsenal");
146
+ expect(section).not.toContain("| `");
147
+ }
148
+ });
149
+
150
+ it("should register commands-prompt hook on environment creation", async () => {
151
+ const envService = new EnvironmentService(outputService);
152
+
153
+ await envService.create({
154
+ configPath,
155
+ sessionStorage: { type: "memory" }, // 使用内存存储,避免污染正式数据库
156
+ });
157
+
158
+ try {
159
+ // 验证 Hook 已注册(通过日志输出确认)
160
+ const promptComponent = envService.getEnvironment()?.getComponent("prompt");
161
+ expect(promptComponent).toBeDefined();
162
+ } finally {
163
+ await envService.dispose();
164
+ }
165
+ });
166
+ });
167
+ });