@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.
- package/README.md +126 -0
- package/dist/bin/roy.js +127297 -0
- package/dist/roy-agent-darwin-arm64/bin/roy.js +127297 -0
- package/dist/roy-agent-darwin-x64/bin/roy.js +127297 -0
- package/dist/roy-agent-linux-arm64/bin/roy.js +127297 -0
- package/dist/roy-agent-linux-x64/bin/roy.js +127297 -0
- package/dist/roy-agent-windows-x64/bin/roy.js +127297 -0
- package/package.json +91 -0
- package/src/bin/roy.ts +12 -0
- package/src/cli.ts +101 -0
- package/src/commands/act.ts +480 -0
- package/src/commands/commands-add.ts +110 -0
- package/src/commands/commands-dirs.ts +70 -0
- package/src/commands/commands-info.ts +90 -0
- package/src/commands/commands-list.ts +161 -0
- package/src/commands/commands-remove.ts +147 -0
- package/src/commands/commands.ts +55 -0
- package/src/commands/config/config-service.test.ts +449 -0
- package/src/commands/config/config-service.ts +312 -0
- package/src/commands/config/deep-merge.test.ts +168 -0
- package/src/commands/config/deep-merge.ts +63 -0
- package/src/commands/config/export.ts +97 -0
- package/src/commands/config/filter-history-e2e.test.ts +141 -0
- package/src/commands/config/import-preserve-refs.test.ts +212 -0
- package/src/commands/config/import.ts +119 -0
- package/src/commands/config/index.ts +35 -0
- package/src/commands/config/list.ts +281 -0
- package/src/commands/config/roy-config-e2e.test.ts +297 -0
- package/src/commands/config/types.ts +54 -0
- package/src/commands/debug/index.ts +38 -0
- package/src/commands/debug/log.test.ts +233 -0
- package/src/commands/debug/log.ts +123 -0
- package/src/commands/debug/span.test.ts +297 -0
- package/src/commands/debug/span.ts +211 -0
- package/src/commands/debug/trace.test.ts +254 -0
- package/src/commands/debug/trace.ts +140 -0
- package/src/commands/eventsource/add.ts +133 -0
- package/src/commands/eventsource/index.ts +48 -0
- package/src/commands/eventsource/list.ts +194 -0
- package/src/commands/eventsource/remove.ts +95 -0
- package/src/commands/eventsource/start.ts +103 -0
- package/src/commands/eventsource/status.ts +185 -0
- package/src/commands/eventsource/stop.ts +89 -0
- package/src/commands/index.ts +22 -0
- package/src/commands/input-handler.test.ts +76 -0
- package/src/commands/input-handler.ts +43 -0
- package/src/commands/interactive-esc.test.ts +254 -0
- package/src/commands/interactive.shutdown.test.ts +122 -0
- package/src/commands/interactive.test.ts +221 -0
- package/src/commands/interactive.ts +1015 -0
- package/src/commands/lsp/check.ts +92 -0
- package/src/commands/lsp/index.ts +32 -0
- package/src/commands/lsp/install.ts +126 -0
- package/src/commands/lsp/list.ts +64 -0
- package/src/commands/mcp/index.ts +27 -0
- package/src/commands/mcp/list.ts +116 -0
- package/src/commands/mcp/reload.ts +70 -0
- package/src/commands/mcp/tools.ts +121 -0
- package/src/commands/memory/extract-e2e.test.ts +388 -0
- package/src/commands/memory/index.ts +11 -0
- package/src/commands/memory/memory-simplified.test.ts +58 -0
- package/src/commands/memory/memory.ts +25 -0
- package/src/commands/memory/organize.ts +300 -0
- package/src/commands/memory/recall.test.ts +120 -0
- package/src/commands/memory/recall.ts +88 -0
- package/src/commands/memory/record-extract-handle-query.test.ts +385 -0
- package/src/commands/memory/record-prompt-component.test.ts +343 -0
- package/src/commands/memory/record.test.ts +92 -0
- package/src/commands/memory/record.ts +332 -0
- package/src/commands/plugin.test.ts +292 -0
- package/src/commands/plugin.ts +267 -0
- package/src/commands/sessions/active.ts +96 -0
- package/src/commands/sessions/add-message.ts +96 -0
- package/src/commands/sessions/checkpoints.ts +154 -0
- package/src/commands/sessions/compact.test.ts +215 -0
- package/src/commands/sessions/compact.ts +269 -0
- package/src/commands/sessions/delete.ts +236 -0
- package/src/commands/sessions/get.ts +165 -0
- package/src/commands/sessions/grep.ts +233 -0
- package/src/commands/sessions/index.ts +95 -0
- package/src/commands/sessions/list.ts +210 -0
- package/src/commands/sessions/messages.test.ts +333 -0
- package/src/commands/sessions/messages.ts +248 -0
- package/src/commands/sessions/mock.ts +194 -0
- package/src/commands/sessions/new.ts +82 -0
- package/src/commands/sessions/rename.ts +98 -0
- package/src/commands/shared/event-handler.ts +213 -0
- package/src/commands/shared/event-message-formatter.ts +295 -0
- package/src/commands/shared/index.ts +11 -0
- package/src/commands/shared/query-executor.test.ts +434 -0
- package/src/commands/shared/query-executor.ts +324 -0
- package/src/commands/shared/repl-engine.test.ts +354 -0
- package/src/commands/shared/session-manager.test.ts +212 -0
- package/src/commands/shared/session-manager.ts +114 -0
- package/src/commands/skills/get.ts +90 -0
- package/src/commands/skills/index.ts +39 -0
- package/src/commands/skills/list.ts +129 -0
- package/src/commands/skills/reload.ts +59 -0
- package/src/commands/skills/search.ts +132 -0
- package/src/commands/skills/show-config.ts +93 -0
- package/src/commands/tasks/complete.ts +92 -0
- package/src/commands/tasks/create.ts +118 -0
- package/src/commands/tasks/delete.ts +86 -0
- package/src/commands/tasks/get.ts +116 -0
- package/src/commands/tasks/index.ts +53 -0
- package/src/commands/tasks/list.ts +140 -0
- package/src/commands/tasks/operations.ts +120 -0
- package/src/commands/tasks/update.ts +122 -0
- package/src/commands/tools/exec-tool.ts +128 -0
- package/src/commands/tools/get.ts +114 -0
- package/src/commands/tools/index.ts +35 -0
- package/src/commands/tools/list.ts +107 -0
- package/src/commands/tools/shared/index.ts +7 -0
- package/src/commands/tools/shared/schema-helper.ts +111 -0
- package/src/commands/workflow/commands/add.ts +315 -0
- package/src/commands/workflow/commands/get.ts +193 -0
- package/src/commands/workflow/commands/list.ts +137 -0
- package/src/commands/workflow/commands/nodes.ts +528 -0
- package/src/commands/workflow/commands/remove.ts +94 -0
- package/src/commands/workflow/commands/run.ts +398 -0
- package/src/commands/workflow/commands/status.ts +147 -0
- package/src/commands/workflow/commands/stop.ts +91 -0
- package/src/commands/workflow/commands/update.ts +130 -0
- package/src/commands/workflow/commands/validate.ts +139 -0
- package/src/commands/workflow/commands/workflow-cli.test.ts +196 -0
- package/src/commands/workflow/index.ts +65 -0
- package/src/commands/workflow/renderers.ts +358 -0
- package/src/commands/workflow/validators/index.ts +8 -0
- package/src/commands/workflow/validators/node-validator-factory.ts +40 -0
- package/src/commands/workflow/validators/node-validator.ts +125 -0
- package/src/commands/workflow/validators/nodes/agent-node-validator.ts +58 -0
- package/src/commands/workflow/validators/nodes/condition-node-validator.ts +34 -0
- package/src/commands/workflow/validators/nodes/decorator-node-validator.ts +45 -0
- package/src/commands/workflow/validators/nodes/merge-node-validator.ts +46 -0
- package/src/commands/workflow/validators/nodes/skill-node-validator.ts +33 -0
- package/src/commands/workflow/validators/nodes/tool-node-validator.ts +54 -0
- package/src/commands/workflow/validators/nodes/workflow-node-validator.ts +33 -0
- package/src/commands/workflow/validators/types.ts +78 -0
- package/src/commands/workflow/validators/workflow-validator.test.ts +273 -0
- package/src/commands/workflow/validators/workflow-validator.ts +320 -0
- package/src/index.ts +19 -0
- package/src/plugin/apply.ts +103 -0
- package/src/plugin/discover.ts +219 -0
- package/src/plugin/index.ts +45 -0
- package/src/plugin/registry.ts +272 -0
- package/src/plugin/types.ts +165 -0
- package/src/services/context-handler.service.test.ts +501 -0
- package/src/services/context-handler.service.ts +372 -0
- package/src/services/environment.service.commands-prompt.test.ts +167 -0
- package/src/services/environment.service.ts +656 -0
- package/src/services/output.service.test.ts +92 -0
- package/src/services/output.service.ts +122 -0
- package/src/services/quiet-mode.service.test.ts +114 -0
- package/src/services/quiet-mode.service.ts +81 -0
- package/src/services/stream-output.service.test.ts +214 -0
- package/src/services/stream-output.service.ts +323 -0
- package/src/util/which.test.ts +101 -0
- 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
|
+
});
|