@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,324 @@
1
+ /**
2
+ * @fileoverview Query Executor - 查询执行共享逻辑
3
+ *
4
+ * 从 act.ts 抽取的查询执行逻辑,供 interactive 复用
5
+ *
6
+ * 功能:
7
+ * - 初始化 SummaryAgent
8
+ * - 订阅 LLM/上下文事件
9
+ * - 流式输出
10
+ * - ContextHandler 执行
11
+ * - 支持 abort 流式输出
12
+ */
13
+
14
+ import { OutputService } from "../../services/output.service";
15
+ import { StreamOutputService, abortStream, resetStreamAbort } from "../../services/stream-output.service";
16
+ import { ContextHandlerService } from "../../services/context-handler.service";
17
+ import type {
18
+ BaseEnvironment,
19
+ SessionComponent,
20
+ LLMComponent,
21
+ PromptComponent
22
+ } from "@ai-setting/roy-agent-core";
23
+ import { ContextError, ErrorCodes, getTracerProvider } from "@ai-setting/roy-agent-core";
24
+
25
+ export interface QueryExecutorOptions {
26
+ env: BaseEnvironment;
27
+ sessionComponent: SessionComponent;
28
+ output: OutputService;
29
+ quiet: boolean;
30
+ }
31
+
32
+ export interface StreamOptions {
33
+ showReasoning: boolean;
34
+ showToolCalls: boolean;
35
+ showToolResults: boolean;
36
+ }
37
+
38
+ /**
39
+ * QueryExecutor - 查询执行器
40
+ *
41
+ * 封装查询执行的完整流程
42
+ */
43
+ export class QueryExecutor {
44
+ private env: BaseEnvironment;
45
+ private sessionComponent: SessionComponent;
46
+ private output: OutputService;
47
+ private quiet: boolean;
48
+ private streamService: StreamOutputService | null = null;
49
+ private unsubscribe: (() => void) | null = null;
50
+ private streamOptions: StreamOptions = {
51
+ showReasoning: false,
52
+ showToolCalls: false,
53
+ showToolResults: false,
54
+ };
55
+ private _pendingContextConfig: { contextWindow: number; thresholdRatio: number } | null = null;
56
+
57
+ constructor(options: QueryExecutorOptions) {
58
+ this.env = options.env;
59
+ this.sessionComponent = options.sessionComponent;
60
+ this.output = options.output;
61
+ this.quiet = options.quiet;
62
+ }
63
+
64
+ /**
65
+ * 初始化 SummaryAgent(用于 compact)
66
+ */
67
+ initSummaryAgent(llmComponent: LLMComponent, promptComponent: PromptComponent): void {
68
+ this.sessionComponent.setSummaryComponents(promptComponent, llmComponent);
69
+ }
70
+
71
+ /**
72
+ * 初始化流式输出配置(设置 context window 信息)
73
+ */
74
+ initStreamOutput(llmComponent: LLMComponent | null, providerId: string = "minimax"): void {
75
+ if (llmComponent) {
76
+ const contextConfig = llmComponent.getContextThresholdConfig(providerId);
77
+ // 延迟设置:在 subscribeToEvents 时设置
78
+ this._pendingContextConfig = contextConfig;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 订阅事件(用于流式输出)
84
+ */
85
+ subscribeToEvents(options: StreamOptions): void {
86
+ // 只订阅一次
87
+ if (this.unsubscribe) {
88
+ return;
89
+ }
90
+
91
+ this.streamOptions = options;
92
+ this.streamService = new StreamOutputService(options);
93
+
94
+ // 设置 context window 信息
95
+ if (this._pendingContextConfig) {
96
+ this.streamService.setContextInfo(
97
+ this._pendingContextConfig.contextWindow,
98
+ this._pendingContextConfig.contextWindow * this._pendingContextConfig.thresholdRatio
99
+ );
100
+ this._pendingContextConfig = null;
101
+ }
102
+
103
+ this.unsubscribe = this.env.subscribeTo(
104
+ [
105
+ "llm.start",
106
+ "llm.text",
107
+ "llm.reasoning",
108
+ "llm.tool_call",
109
+ "llm.completed",
110
+ "llm.error",
111
+ "tool.result",
112
+ "tool.error",
113
+ "context.threshold_exceeded",
114
+ "context.compacting",
115
+ "context.compacted",
116
+ ],
117
+ (event) => this.handleEvent(event)
118
+ );
119
+ }
120
+
121
+ /**
122
+ * 处理事件
123
+ */
124
+ protected handleEvent(event: any): void {
125
+ // 工具调用
126
+ if (event.type === "llm.tool_call" && this.streamOptions.showToolCalls) {
127
+ const payload = event.payload as any;
128
+ this.output.log(`🔧 ${payload.toolCall.name}`);
129
+ return;
130
+ }
131
+ if (event.type === "tool.result" && this.streamOptions.showToolResults) {
132
+ const payload = event.payload as any;
133
+ const result = payload.result?.output ?? payload.result?.error ?? "无输出";
134
+ this.output.log(`📤 ${payload.name}: ${String(result).substring(0, 200)}`);
135
+ return;
136
+ }
137
+ if (event.type === "tool.error") {
138
+ this.output.error(`❌ ${event.payload.toolName}: ${event.payload.error}`);
139
+ return;
140
+ }
141
+
142
+ // 上下文事件
143
+ if (event.type === "context.threshold_exceeded" && !this.quiet) {
144
+ const payload = event.payload as any;
145
+ this.output.warn(`⚙ 上下文阈值 (${payload.totalTokens}/${payload.contextWindow})`);
146
+ }
147
+ if (event.type === "context.compacting" && !this.quiet) {
148
+ this.output.info("⚙ 压缩中...");
149
+ }
150
+ if (event.type === "context.compacted" && !this.quiet) {
151
+ this.output.success(`✓ 已压缩 (${event.payload.checkpointId})`);
152
+ }
153
+
154
+ // 其他事件交给 streamService
155
+ this.streamService?.handleEvent(event);
156
+ }
157
+
158
+ /**
159
+ * 执行查询
160
+ */
161
+ async execute(
162
+ message: string,
163
+ sessionId: string,
164
+ streamOptions?: Partial<StreamOptions>,
165
+ traceId?: string
166
+ ): Promise<string> {
167
+ // 合并流式选项
168
+ const options: StreamOptions = {
169
+ showReasoning: streamOptions?.showReasoning ?? this.streamOptions.showReasoning,
170
+ showToolCalls: streamOptions?.showToolCalls ?? this.streamOptions.showToolCalls,
171
+ showToolResults: streamOptions?.showToolResults ?? this.streamOptions.showToolResults,
172
+ };
173
+
174
+ // 订阅事件(如果尚未订阅)
175
+ if (!this.unsubscribe) {
176
+ this.subscribeToEvents(options);
177
+ }
178
+
179
+ if (!this.quiet) {
180
+ this.output.info(`执行: ${message}`);
181
+ }
182
+
183
+ // 创建 Context Handler
184
+ const contextHandler = new ContextHandlerService(
185
+ this.env,
186
+ this.sessionComponent,
187
+ { maxRetries: 1, autoCompact: true }
188
+ );
189
+
190
+ // 构建 context(携带 trace id)
191
+ const context = {
192
+ sessionId,
193
+ metadata: {
194
+ originalQuery: message,
195
+ traceId,
196
+ },
197
+ };
198
+
199
+ // ========================================================================
200
+ // 创建子 span
201
+ //
202
+ // 使用与 interactive.ts 相同的 tracer 实例(roy-tracer)
203
+ // 这样可以继承 currentContext 中的 traceId
204
+ // ========================================================================
205
+ let span: any;
206
+ const tracer = getTracerProvider().getTracer("roy-tracer");
207
+ // 获取当前 tracer 的上下文(应该已经包含根 span 的上下文)
208
+ const currentContext = tracer.getCurrentContext();
209
+ span = tracer.startSpan("query-executor.execute", {
210
+ parent: currentContext,
211
+ attributes: {
212
+ query: message.substring(0, 200),
213
+ sessionId,
214
+ },
215
+ });
216
+
217
+ // 执行查询
218
+ let result: string;
219
+ try {
220
+ result = await contextHandler.handleQueryWithContext(message, context);
221
+
222
+ // 结束 span(成功)
223
+ if (span) {
224
+ span.end(result);
225
+ }
226
+ } catch (error) {
227
+ // 结束 span(失败)
228
+ if (span) {
229
+ span.setAttribute("error", String(error));
230
+ span.end(undefined, error instanceof Error ? error : new Error(String(error)));
231
+ }
232
+
233
+ if (error instanceof ContextError && error.code === ErrorCodes.CONTEXT_THRESHOLD_EXCEEDED) {
234
+ this.output.error(
235
+ `上下文阈值超出限制: ${error.usage?.totalTokens}/${error.contextWindow}`
236
+ );
237
+ this.output.info(
238
+ "请手动压缩会话: roy sessions compact " + sessionId
239
+ );
240
+ }
241
+ throw error;
242
+ } finally {
243
+ // span 结束由 tracer 自动处理
244
+ }
245
+
246
+ if (!this.quiet) {
247
+ this.output.success("执行完成");
248
+ }
249
+
250
+ return result;
251
+ }
252
+
253
+ /**
254
+ * 清理资源
255
+ */
256
+ dispose(): void {
257
+ if (this.unsubscribe) {
258
+ this.unsubscribe();
259
+ this.unsubscribe = null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 获取流式输出服务的完整文本
265
+ */
266
+ getFullText(): string {
267
+ return this.streamService?.getFullText() ?? "";
268
+ }
269
+
270
+ /**
271
+ * 手动压缩会话上下文
272
+ *
273
+ * 复用 ContextHandlerService.compactSession 逻辑
274
+ */
275
+ async compact(sessionId: string): Promise<{
276
+ success: boolean;
277
+ checkpoint?: any;
278
+ deletedMessageCount?: number;
279
+ error?: string;
280
+ }> {
281
+ try {
282
+ // 发布压缩开始事件
283
+ const envCore = this.env.getComponent("core") as any;
284
+ if (envCore?.pushEnvEvent) {
285
+ envCore.pushEnvEvent({
286
+ type: "context.compacting",
287
+ metadata: { sessionId },
288
+ payload: {},
289
+ });
290
+ }
291
+
292
+ // 执行 compact
293
+ const result = await this.sessionComponent.compact(sessionId, {
294
+ summary: "Manual compact from interactive mode",
295
+ });
296
+
297
+ // 发布压缩完成事件
298
+ if (envCore?.pushEnvEvent) {
299
+ envCore.pushEnvEvent({
300
+ type: "context.compacted",
301
+ metadata: { sessionId },
302
+ payload: {
303
+ checkpointId: result.checkpoint.id,
304
+ messagesCompacted: result.deletedMessageCount,
305
+ },
306
+ });
307
+ }
308
+
309
+ return {
310
+ success: true,
311
+ checkpoint: result.checkpoint,
312
+ deletedMessageCount: result.deletedMessageCount,
313
+ };
314
+
315
+ } catch (error) {
316
+ const errorMessage = error instanceof Error ? error.message : String(error);
317
+ this.output.error(`压缩失败: ${errorMessage}`);
318
+ return {
319
+ success: false,
320
+ error: errorMessage,
321
+ };
322
+ }
323
+ }
324
+ }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * @fileoverview REPL Engine Test
3
+ *
4
+ * TDD: 测试 REPL 引擎的核心功能
5
+ */
6
+
7
+ import { describe, test, expect, vi, beforeEach, afterEach } from "bun:test";
8
+ import * as readline from "readline";
9
+
10
+ // We'll test the REPL class by testing its interface and logic
11
+ // Since REPL relies on readline which is hard to mock in unit tests,
12
+ // we'll focus on testing the command registry and event handling
13
+
14
+ interface REPLCommand {
15
+ name: string;
16
+ aliases?: string[];
17
+ description: string;
18
+ execute: (args: string[]) => Promise<void>;
19
+ }
20
+
21
+ interface REPLOptions {
22
+ onExecute: (message: string) => Promise<string>;
23
+ onStatus: () => { sessionId: string; sessionTitle: string; messageCount: number; tokenCount: number };
24
+ onSwitchSession: (sessionId: string) => Promise<{ sessionId: string; sessionTitle: string }>;
25
+ onCompact: () => Promise<void>;
26
+ }
27
+
28
+ /**
29
+ * REPLCommandRegistry - 测试 REPL 命令注册逻辑
30
+ */
31
+ class REPLCommandRegistry {
32
+ private commands: Map<string, REPLCommand> = new Map();
33
+ private registeredNames: Set<string> = new Set(); // Track original names for getAllCommands
34
+
35
+ registerCommand(command: REPLCommand): void {
36
+ // Store by lowercase name for case-insensitive lookup
37
+ const lowerName = command.name.toLowerCase();
38
+ this.commands.set(lowerName, command);
39
+ this.registeredNames.add(lowerName);
40
+ command.aliases?.forEach((alias) => {
41
+ this.commands.set(alias.toLowerCase(), command);
42
+ });
43
+ }
44
+
45
+ getCommand(name: string): REPLCommand | undefined {
46
+ return this.commands.get(name.toLowerCase());
47
+ }
48
+
49
+ getAllCommands(): REPLCommand[] {
50
+ // Return unique commands by registered names only (not aliases)
51
+ const uniqueCommands: REPLCommand[] = [];
52
+ const seen = new Set<REPLCommand>();
53
+ for (const name of this.registeredNames) {
54
+ const command = this.commands.get(name);
55
+ if (command && !seen.has(command)) {
56
+ uniqueCommands.push(command);
57
+ seen.add(command);
58
+ }
59
+ }
60
+ return uniqueCommands;
61
+ }
62
+
63
+ hasCommand(name: string): boolean {
64
+ return this.commands.has(name.toLowerCase());
65
+ }
66
+ }
67
+
68
+ describe("REPLCommandRegistry", () => {
69
+ let registry: REPLCommandRegistry;
70
+
71
+ beforeEach(() => {
72
+ registry = new REPLCommandRegistry();
73
+ });
74
+
75
+ describe("registerCommand()", () => {
76
+ test("should register command by name", async () => {
77
+ // Given
78
+ const executeFn = vi.fn();
79
+ const command: REPLCommand = {
80
+ name: "test",
81
+ description: "Test command",
82
+ execute: executeFn,
83
+ };
84
+
85
+ // When
86
+ registry.registerCommand(command);
87
+
88
+ // Then
89
+ expect(registry.hasCommand("test")).toBe(true);
90
+ expect(registry.getCommand("test")).toBe(command);
91
+ });
92
+
93
+ test("should register command aliases", async () => {
94
+ // Given
95
+ const executeFn = vi.fn();
96
+ const command: REPLCommand = {
97
+ name: "test",
98
+ aliases: ["t", "x"],
99
+ description: "Test command",
100
+ execute: executeFn,
101
+ };
102
+
103
+ // When
104
+ registry.registerCommand(command);
105
+
106
+ // Then
107
+ expect(registry.hasCommand("test")).toBe(true);
108
+ expect(registry.hasCommand("t")).toBe(true);
109
+ expect(registry.hasCommand("x")).toBe(true);
110
+ expect(registry.getCommand("t")).toBe(command);
111
+ expect(registry.getCommand("x")).toBe(command);
112
+ });
113
+
114
+ test("should overwrite existing command with same name", async () => {
115
+ // Given
116
+ const executeFn1 = vi.fn();
117
+ const executeFn2 = vi.fn();
118
+ registry.registerCommand({
119
+ name: "test",
120
+ description: "First",
121
+ execute: executeFn1,
122
+ });
123
+
124
+ // When
125
+ registry.registerCommand({
126
+ name: "test",
127
+ description: "Second",
128
+ execute: executeFn2,
129
+ });
130
+
131
+ // Then
132
+ expect(registry.getCommand("test")?.description).toBe("Second");
133
+ });
134
+ });
135
+
136
+ describe("getCommand()", () => {
137
+ test("should find command by exact name", async () => {
138
+ // Given
139
+ const command: REPLCommand = {
140
+ name: "help",
141
+ description: "Show help",
142
+ execute: vi.fn(),
143
+ };
144
+ registry.registerCommand(command);
145
+
146
+ // When & Then
147
+ expect(registry.getCommand("help")).toBe(command);
148
+ });
149
+
150
+ test("should find command by alias", async () => {
151
+ // Given
152
+ const command: REPLCommand = {
153
+ name: "help",
154
+ aliases: ["h", "?"],
155
+ description: "Show help",
156
+ execute: vi.fn(),
157
+ };
158
+ registry.registerCommand(command);
159
+
160
+ // When & Then
161
+ expect(registry.getCommand("h")).toBe(command);
162
+ expect(registry.getCommand("?")).toBe(command);
163
+ });
164
+
165
+ test("should return undefined for unknown command", async () => {
166
+ // When & Then
167
+ expect(registry.getCommand("unknown")).toBeUndefined();
168
+ });
169
+
170
+ test("should be case insensitive", async () => {
171
+ // Given
172
+ const command: REPLCommand = {
173
+ name: "Help",
174
+ description: "Show help",
175
+ execute: vi.fn(),
176
+ };
177
+ registry.registerCommand(command);
178
+
179
+ // When & Then
180
+ expect(registry.getCommand("help")).toBe(command);
181
+ expect(registry.getCommand("HELP")).toBe(command);
182
+ });
183
+ });
184
+
185
+ describe("getAllCommands()", () => {
186
+ test("should return all unique commands", async () => {
187
+ // Given
188
+ registry.registerCommand({
189
+ name: "cmd1",
190
+ aliases: ["c1"],
191
+ description: "Command 1",
192
+ execute: vi.fn(),
193
+ });
194
+ registry.registerCommand({
195
+ name: "cmd2",
196
+ aliases: ["c2"],
197
+ description: "Command 2",
198
+ execute: vi.fn(),
199
+ });
200
+
201
+ // When
202
+ const commands = registry.getAllCommands();
203
+
204
+ // Then
205
+ expect(commands.length).toBe(2);
206
+ });
207
+ });
208
+ });
209
+
210
+ /**
211
+ * REPLOptions Interface Test
212
+ */
213
+ describe("REPLOptions", () => {
214
+ test("should require onExecute callback", () => {
215
+ // Given
216
+ const mockExecute = vi.fn().mockResolvedValue("response");
217
+
218
+ // When
219
+ const options: REPLOptions = {
220
+ onExecute: mockExecute,
221
+ onStatus: () => ({
222
+ sessionId: "sess_123",
223
+ sessionTitle: "Test Session",
224
+ messageCount: 5,
225
+ tokenCount: 1024,
226
+ }),
227
+ onSwitchSession: vi.fn().mockResolvedValue({
228
+ sessionId: "sess_456",
229
+ sessionTitle: "New Session",
230
+ }),
231
+ onCompact: vi.fn().mockResolvedValue(undefined),
232
+ };
233
+
234
+ // Then
235
+ expect(options.onExecute).toBeDefined();
236
+ expect(typeof options.onExecute("test")).toBe("object"); // Promise
237
+ });
238
+
239
+ test("should provide status information", () => {
240
+ // Given
241
+ const status = {
242
+ sessionId: "sess_123",
243
+ sessionTitle: "Test Session",
244
+ messageCount: 10,
245
+ tokenCount: 2048,
246
+ };
247
+
248
+ // When
249
+ const options: REPLOptions = {
250
+ onExecute: vi.fn().mockResolvedValue("response"),
251
+ onStatus: () => status,
252
+ onSwitchSession: vi.fn(),
253
+ onCompact: vi.fn(),
254
+ };
255
+
256
+ // Then
257
+ expect(options.onStatus()).toEqual(status);
258
+ });
259
+ });
260
+
261
+ /**
262
+ * Input Parsing Test
263
+ */
264
+ describe("Input Parsing", () => {
265
+ test("should parse command with arguments", () => {
266
+ // Given
267
+ const input = "compact --force";
268
+
269
+ // When
270
+ const [command, ...args] = input.split(/\s+/);
271
+
272
+ // Then
273
+ expect(command).toBe("compact");
274
+ expect(args).toEqual(["--force"]);
275
+ });
276
+
277
+ test("should parse command without arguments", () => {
278
+ // Given
279
+ const input = "help";
280
+
281
+ // When
282
+ const [command, ...args] = input.split(/\s+/);
283
+
284
+ // Then
285
+ expect(command).toBe("help");
286
+ expect(args).toEqual([]);
287
+ });
288
+
289
+ test("should identify slash commands", () => {
290
+ // Given
291
+ const slashInput = "/help";
292
+ const normalInput = "show me something";
293
+
294
+ // When & Then
295
+ expect(slashInput.startsWith("/")).toBe(true);
296
+ expect(normalInput.startsWith("/")).toBe(false);
297
+ });
298
+
299
+ test("should strip slash prefix", () => {
300
+ // Given
301
+ const input = "/exit";
302
+
303
+ // When
304
+ const command = input.slice(1);
305
+
306
+ // Then
307
+ expect(command).toBe("exit");
308
+ });
309
+ });
310
+
311
+ /**
312
+ * Command Execution Flow Test
313
+ */
314
+ describe("Command Execution Flow", () => {
315
+ test("should execute registered command", async () => {
316
+ // Given
317
+ const executeFn = vi.fn().mockResolvedValue(undefined);
318
+ const registry = new REPLCommandRegistry();
319
+ registry.registerCommand({
320
+ name: "test",
321
+ description: "Test command",
322
+ execute: executeFn,
323
+ });
324
+
325
+ // When
326
+ const command = registry.getCommand("test");
327
+ if (command) {
328
+ await command.execute([]);
329
+ }
330
+
331
+ // Then
332
+ expect(executeFn).toHaveBeenCalledWith([]);
333
+ });
334
+
335
+ test("should pass arguments to command", async () => {
336
+ // Given
337
+ const executeFn = vi.fn().mockResolvedValue(undefined);
338
+ const registry = new REPLCommandRegistry();
339
+ registry.registerCommand({
340
+ name: "test",
341
+ description: "Test command",
342
+ execute: executeFn,
343
+ });
344
+
345
+ // When
346
+ const command = registry.getCommand("test");
347
+ if (command) {
348
+ await command.execute(["arg1", "arg2"]);
349
+ }
350
+
351
+ // Then
352
+ expect(executeFn).toHaveBeenCalledWith(["arg1", "arg2"]);
353
+ });
354
+ });