@jpssff/vanor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-cn.md +166 -0
- package/README.md +120 -0
- package/base/config.js +162 -0
- package/base/core/compaction.js +58 -0
- package/base/core/harness.js +246 -0
- package/base/core/loop.js +72 -0
- package/base/core/prompt.js +126 -0
- package/base/core/session.js +255 -0
- package/base/events.js +54 -0
- package/base/i18n/index.js +80 -0
- package/base/i18n/locales/en.js +254 -0
- package/base/i18n/locales/zh-CN.js +252 -0
- package/base/llm/index.js +119 -0
- package/base/llm/providers/anthropic.js +147 -0
- package/base/llm/providers/openai.js +155 -0
- package/base/llm/sse.js +27 -0
- package/base/llm/trace.js +64 -0
- package/base/logger.js +57 -0
- package/base/memory/index.js +139 -0
- package/base/security/index.js +77 -0
- package/base/skills/loader.js +297 -0
- package/base/test/cli.test.js +91 -0
- package/base/test/config.test.js +63 -0
- package/base/test/core.test.js +154 -0
- package/base/test/i18n.test.js +32 -0
- package/base/test/loop.test.js +97 -0
- package/base/test/memory.test.js +47 -0
- package/base/test/message.test.js +38 -0
- package/base/test/session.test.js +324 -0
- package/base/test/skills.test.js +236 -0
- package/base/test/statusbar.test.js +143 -0
- package/base/test/tools.test.js +127 -0
- package/base/test/trace.test.js +62 -0
- package/base/test/tui.test.js +242 -0
- package/base/test/utils.test.js +35 -0
- package/base/tools/builtin.js +221 -0
- package/base/tools/index.js +157 -0
- package/base/transport/cli.js +417 -0
- package/base/transport/message.js +81 -0
- package/base/transport/statusbar.js +117 -0
- package/base/transport/tui.js +397 -0
- package/base/utils.js +150 -0
- package/docs/TECH_DESIGN.md +544 -0
- package/index.js +175 -0
- package/package.json +33 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readSessionState, saveLatestSessionId, Session, latestSession, listSessions, listUserMessages } from "../core/session.js";
|
|
7
|
+
import { createHarness } from "../core/harness.js";
|
|
8
|
+
import { NullLogger } from "../logger.js";
|
|
9
|
+
|
|
10
|
+
function tempPaths() {
|
|
11
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-session-"));
|
|
12
|
+
return {
|
|
13
|
+
root,
|
|
14
|
+
state: path.join(root, "state.json"),
|
|
15
|
+
sessions: path.join(root, "sessions"),
|
|
16
|
+
memory: path.join(root, "memory"),
|
|
17
|
+
working: path.join(root, "memory", "working"),
|
|
18
|
+
skills: path.join(root, "skills"),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("Session.load 恢复消息、summary、usage 与模型", () => {
|
|
23
|
+
const paths = tempPaths();
|
|
24
|
+
const logger = new NullLogger();
|
|
25
|
+
const s = new Session(paths, logger, { id: "sess_restore", model: "p/m1" }).start();
|
|
26
|
+
s.addMessage({ id: "u1", role: "user", content: "旧问题", meta: {} });
|
|
27
|
+
s.addMessage({ id: "a1", role: "assistant", content: "旧回答", meta: {} });
|
|
28
|
+
s.addCompaction({ summary: "旧摘要", replaced: 1 });
|
|
29
|
+
s.setModel("p/m2");
|
|
30
|
+
s.addUsage({ promptTokens: 10, completionTokens: 5, totalTokens: 15 });
|
|
31
|
+
|
|
32
|
+
const loaded = Session.load(paths, logger, "sess_restore");
|
|
33
|
+
assert.ok(loaded);
|
|
34
|
+
assert.equal(loaded.id, "sess_restore");
|
|
35
|
+
assert.equal(loaded.model, "p/m2");
|
|
36
|
+
assert.equal(loaded.summary, "旧摘要");
|
|
37
|
+
assert.equal(loaded.messages.length, 1);
|
|
38
|
+
assert.equal(loaded.messages[0].content, "旧回答");
|
|
39
|
+
assert.equal(loaded.usage.totalTokens, 15);
|
|
40
|
+
assert.equal(loaded.restored, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("listUserMessages 从 JSONL 读取完整用户消息历史", () => {
|
|
44
|
+
const paths = tempPaths();
|
|
45
|
+
const s = new Session(paths, new NullLogger(), { id: "sess_messages", model: "p/m" }).start();
|
|
46
|
+
s.addMessage({ id: "u1", role: "user", content: "第一个问题", meta: { timestamp: "2026-06-07T08:00:00.000Z" } });
|
|
47
|
+
s.addMessage({ id: "a1", role: "assistant", content: "回答", meta: {} });
|
|
48
|
+
s.addMessage({ id: "u2", role: "user", content: "第二个问题", meta: { timestamp: "2026-06-07T08:01:00.000Z" } });
|
|
49
|
+
s.addCompaction({ summary: "摘要", replaced: 2 });
|
|
50
|
+
s.replaceRecent([]);
|
|
51
|
+
|
|
52
|
+
const messages = listUserMessages(paths, "sess_messages");
|
|
53
|
+
assert.deepEqual(messages.map((m) => m.content), ["第一个问题", "第二个问题"]);
|
|
54
|
+
assert.equal(messages[0].time, "2026-06-07T08:00:00.000Z");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("Session.load 跳过损坏 JSONL 行并裁剪未完成尾部", () => {
|
|
58
|
+
const paths = tempPaths();
|
|
59
|
+
fs.mkdirSync(paths.sessions, { recursive: true });
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
path.join(paths.sessions, "sess_bad.jsonl"),
|
|
62
|
+
[
|
|
63
|
+
JSON.stringify({ type: "meta", key: "start", value: { model: "p/m" } }),
|
|
64
|
+
"{bad json",
|
|
65
|
+
JSON.stringify({ type: "message", message: { id: "u1", role: "user", content: "完成的问题", meta: {} } }),
|
|
66
|
+
JSON.stringify({ type: "message", message: { id: "a1", role: "assistant", content: "完成的回答", meta: {} } }),
|
|
67
|
+
JSON.stringify({ type: "message", message: { id: "u2", role: "user", content: "未完成的问题", meta: {} } }),
|
|
68
|
+
"",
|
|
69
|
+
].join("\n"),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const loaded = Session.load(paths, new NullLogger(), "sess_bad");
|
|
73
|
+
assert.ok(loaded);
|
|
74
|
+
assert.equal(loaded.messages.length, 2);
|
|
75
|
+
assert.equal(loaded.messages.at(-1).role, "assistant");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("latestSession 返回最近修改的会话,listSessions 正确统计消息数", () => {
|
|
79
|
+
const paths = tempPaths();
|
|
80
|
+
const logger = new NullLogger();
|
|
81
|
+
const older = new Session(paths, logger, { id: "sess_old", model: "p/a" }).start();
|
|
82
|
+
older.addMessage({ id: "u1", role: "user", content: "old", meta: {} });
|
|
83
|
+
const newer = new Session(paths, logger, { id: "sess_new", model: "p/b" }).start();
|
|
84
|
+
newer.addMessage({ id: "u2", role: "user", content: "new", meta: {} });
|
|
85
|
+
newer.addMessage({ id: "a2", role: "assistant", content: "ok", meta: {} });
|
|
86
|
+
|
|
87
|
+
fs.utimesSync(older.file, new Date(Date.now() - 10000), new Date(Date.now() - 10000));
|
|
88
|
+
fs.utimesSync(newer.file, new Date(), new Date());
|
|
89
|
+
|
|
90
|
+
const sessions = listSessions(paths);
|
|
91
|
+
assert.equal(sessions[0].id, "sess_new");
|
|
92
|
+
assert.equal(sessions[0].model, "p/b");
|
|
93
|
+
assert.equal(sessions[0].messageCount, 2);
|
|
94
|
+
assert.equal(latestSession(paths).id, "sess_new");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("Session.resume 追加 resume meta", () => {
|
|
98
|
+
const paths = tempPaths();
|
|
99
|
+
paths.workspaceRoot = "/workspace/resume";
|
|
100
|
+
const s = new Session(paths, new NullLogger(), { id: "sess_resume", model: "p/m" }).start();
|
|
101
|
+
s.resume();
|
|
102
|
+
const text = fs.readFileSync(s.file, "utf8");
|
|
103
|
+
assert.match(text, /"key":"resume"/);
|
|
104
|
+
assert.equal(readSessionState(paths).latestSessionId, "sess_resume");
|
|
105
|
+
assert.equal(readSessionState(paths).workspaces["/workspace/resume"].latestSessionId, "sess_resume");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("latestSession 优先使用 state.json 中的 latestSessionId", () => {
|
|
109
|
+
const paths = tempPaths();
|
|
110
|
+
const logger = new NullLogger();
|
|
111
|
+
const oldSession = new Session(paths, logger, { id: "sess_state_old", model: "p/old" }).start();
|
|
112
|
+
oldSession.addMessage({ id: "u1", role: "user", content: "old", meta: {} });
|
|
113
|
+
const newSession = new Session(paths, logger, { id: "sess_state_new", model: "p/new" }).start();
|
|
114
|
+
newSession.addMessage({ id: "u2", role: "user", content: "new", meta: {} });
|
|
115
|
+
fs.utimesSync(oldSession.file, new Date(Date.now() - 10000), new Date(Date.now() - 10000));
|
|
116
|
+
fs.utimesSync(newSession.file, new Date(), new Date());
|
|
117
|
+
|
|
118
|
+
saveLatestSessionId(paths, "sess_state_old");
|
|
119
|
+
|
|
120
|
+
const latest = latestSession(paths);
|
|
121
|
+
assert.equal(latest.id, "sess_state_old");
|
|
122
|
+
assert.equal(latest.model, "p/old");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("latestSession 按 workspaceRoot 恢复对应会话", () => {
|
|
126
|
+
const paths = tempPaths();
|
|
127
|
+
const logger = new NullLogger();
|
|
128
|
+
const wsA = path.join(paths.root, "a");
|
|
129
|
+
const wsB = path.join(paths.root, "b");
|
|
130
|
+
const sessA = new Session({ ...paths, workspaceRoot: wsA }, logger, { id: "sess_ws_a", model: "p/a" }).start();
|
|
131
|
+
sessA.addMessage({ id: "u1", role: "user", content: "a", meta: {} });
|
|
132
|
+
const sessB = new Session({ ...paths, workspaceRoot: wsB }, logger, { id: "sess_ws_b", model: "p/b" }).start();
|
|
133
|
+
sessB.addMessage({ id: "u2", role: "user", content: "b", meta: {} });
|
|
134
|
+
|
|
135
|
+
assert.equal(latestSession(paths, wsA).id, "sess_ws_a");
|
|
136
|
+
assert.equal(latestSession(paths, wsB).id, "sess_ws_b");
|
|
137
|
+
assert.equal(readSessionState(paths).latestSessionId, "sess_ws_b"); // 全局兼容字段仍记录最后一次
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("latestSession 在 state 指向失效时回退扫描 sessions", () => {
|
|
141
|
+
const paths = tempPaths();
|
|
142
|
+
const logger = new NullLogger();
|
|
143
|
+
const s = new Session(paths, logger, { id: "sess_fallback", model: "p/m" }).start();
|
|
144
|
+
s.addMessage({ id: "u1", role: "user", content: "hi", meta: {} });
|
|
145
|
+
saveLatestSessionId(paths, "missing");
|
|
146
|
+
|
|
147
|
+
assert.equal(latestSession(paths).id, "sess_fallback");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("createHarness 支持恢复最近会话", () => {
|
|
151
|
+
const paths = tempPaths();
|
|
152
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
153
|
+
const logger = new NullLogger();
|
|
154
|
+
const s = new Session(paths, logger, { id: "sess_harness", model: "p/m" }).start();
|
|
155
|
+
s.addMessage({ id: "u1", role: "user", content: "继续这个任务", meta: {} });
|
|
156
|
+
s.addMessage({ id: "a1", role: "assistant", content: "好的", meta: {} });
|
|
157
|
+
|
|
158
|
+
const harness = createHarness({
|
|
159
|
+
paths,
|
|
160
|
+
logger,
|
|
161
|
+
restoreLatest: true,
|
|
162
|
+
config: {
|
|
163
|
+
llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "http://x", apiKey: "k" } } },
|
|
164
|
+
logging: {},
|
|
165
|
+
memory: {},
|
|
166
|
+
agent: {},
|
|
167
|
+
security: { approval: "auto", workspaceRoot: paths.root },
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
assert.equal(harness.sessionRestored, true);
|
|
172
|
+
assert.equal(harness.session.id, "sess_harness");
|
|
173
|
+
assert.equal(harness.session.messages.length, 2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("createHarness 按当前工作区恢复对应会话", () => {
|
|
177
|
+
const paths = tempPaths();
|
|
178
|
+
const logger = new NullLogger();
|
|
179
|
+
const wsA = path.join(paths.root, "workspace-a");
|
|
180
|
+
const wsB = path.join(paths.root, "workspace-b");
|
|
181
|
+
new Session({ ...paths, workspaceRoot: wsA }, logger, { id: "sess_harness_a", model: "p/m" }).start();
|
|
182
|
+
new Session({ ...paths, workspaceRoot: wsB }, logger, { id: "sess_harness_b", model: "p/m" }).start();
|
|
183
|
+
|
|
184
|
+
const harnessA = createHarness({
|
|
185
|
+
paths,
|
|
186
|
+
logger,
|
|
187
|
+
restoreLatest: true,
|
|
188
|
+
config: {
|
|
189
|
+
llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "http://x", apiKey: "k" } } },
|
|
190
|
+
logging: {},
|
|
191
|
+
memory: {},
|
|
192
|
+
agent: {},
|
|
193
|
+
security: { approval: "auto", workspaceRoot: wsA },
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
assert.equal(harnessA.sessionRestored, true);
|
|
198
|
+
assert.equal(harnessA.session.id, "sess_harness_a");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("createHarness 支持热重载配置并更新运行时对象", () => {
|
|
202
|
+
const paths = tempPaths();
|
|
203
|
+
paths.config = path.join(paths.root, "config.json");
|
|
204
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
205
|
+
const config1 = {
|
|
206
|
+
llm: { defaultModel: "p1/m1", providers: { p1: { type: "openai", baseURL: "http://one", apiKey: "k1" } } },
|
|
207
|
+
logging: {},
|
|
208
|
+
memory: {},
|
|
209
|
+
agent: { contextWindow: 100 },
|
|
210
|
+
security: { approval: "ask", workspaceRoot: paths.root },
|
|
211
|
+
};
|
|
212
|
+
const config2 = {
|
|
213
|
+
llm: { defaultModel: "p2/m2", providers: { p2: { type: "openai", baseURL: "http://two", apiKey: "k2" } } },
|
|
214
|
+
logging: { llmTrace: true },
|
|
215
|
+
memory: {},
|
|
216
|
+
agent: { contextWindow: 200 },
|
|
217
|
+
security: { approval: "auto", workspaceRoot: paths.root },
|
|
218
|
+
};
|
|
219
|
+
fs.writeFileSync(paths.config, JSON.stringify(config1));
|
|
220
|
+
|
|
221
|
+
const harness = createHarness({ paths, logger: new NullLogger(), config: config1 });
|
|
222
|
+
assert.equal(harness.model, "p1/m1");
|
|
223
|
+
assert.equal(harness.security.approval, "ask");
|
|
224
|
+
assert.equal(harness.config.agent.contextWindow, 100);
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(paths.config, JSON.stringify(config2));
|
|
227
|
+
const r = harness.reloadConfig({ force: true });
|
|
228
|
+
assert.equal(r.reloaded, true);
|
|
229
|
+
assert.equal(harness.model, "p2/m2");
|
|
230
|
+
assert.equal(harness.security.approval, "auto");
|
|
231
|
+
assert.equal(harness.config.agent.contextWindow, 200);
|
|
232
|
+
assert.doesNotThrow(() => harness.llm.resolveModel("p2/m2"));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("createHarness 支持切换语言并写回配置", () => {
|
|
236
|
+
const paths = tempPaths();
|
|
237
|
+
paths.config = path.join(paths.root, "config.json");
|
|
238
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
239
|
+
process.env.VANOR_TEST_KEY = "secret";
|
|
240
|
+
const config = {
|
|
241
|
+
llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "http://one", apiKey: "env:VANOR_TEST_KEY" } } },
|
|
242
|
+
logging: {},
|
|
243
|
+
memory: {},
|
|
244
|
+
agent: {},
|
|
245
|
+
ui: { language: "en" },
|
|
246
|
+
security: { approval: "ask", workspaceRoot: paths.root },
|
|
247
|
+
};
|
|
248
|
+
fs.writeFileSync(paths.config, JSON.stringify(config));
|
|
249
|
+
const harness = createHarness({ paths, logger: new NullLogger(), config });
|
|
250
|
+
|
|
251
|
+
const next = harness.setLanguage("zh-CN");
|
|
252
|
+
const raw = JSON.parse(fs.readFileSync(paths.config, "utf8"));
|
|
253
|
+
assert.equal(next.language, "zh-CN");
|
|
254
|
+
assert.equal(harness.language, "zh-CN");
|
|
255
|
+
assert.equal(raw.ui.language, "zh-CN");
|
|
256
|
+
assert.equal(raw.llm.providers.p.apiKey, "env:VANOR_TEST_KEY");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("热重载遇到无效配置时保留旧配置", () => {
|
|
260
|
+
const paths = tempPaths();
|
|
261
|
+
paths.config = path.join(paths.root, "config.json");
|
|
262
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
263
|
+
const good = {
|
|
264
|
+
llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "http://ok", apiKey: "k" } } },
|
|
265
|
+
logging: {},
|
|
266
|
+
memory: {},
|
|
267
|
+
agent: {},
|
|
268
|
+
security: { approval: "ask", workspaceRoot: paths.root },
|
|
269
|
+
};
|
|
270
|
+
fs.writeFileSync(paths.config, JSON.stringify(good));
|
|
271
|
+
const harness = createHarness({ paths, logger: new NullLogger(), config: good });
|
|
272
|
+
|
|
273
|
+
fs.writeFileSync(paths.config, JSON.stringify({ llm: { defaultModel: "missing/m", providers: {} } }));
|
|
274
|
+
const r = harness.reloadConfig({ force: true });
|
|
275
|
+
assert.ok(r.error);
|
|
276
|
+
assert.equal(harness.model, "p/m");
|
|
277
|
+
assert.equal(harness.config.llm.defaultModel, "p/m");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("runTurn 前自动检测配置文件变更并热重载", async () => {
|
|
281
|
+
const paths = tempPaths();
|
|
282
|
+
paths.config = path.join(paths.root, "config.json");
|
|
283
|
+
fs.mkdirSync(paths.root, { recursive: true });
|
|
284
|
+
const config1 = {
|
|
285
|
+
llm: { defaultModel: "p1/m1", providers: { p1: { type: "openai", baseURL: "http://one", apiKey: "k1" } } },
|
|
286
|
+
logging: {},
|
|
287
|
+
memory: {},
|
|
288
|
+
agent: { maxIterations: 1 },
|
|
289
|
+
security: { approval: "auto", workspaceRoot: paths.root },
|
|
290
|
+
};
|
|
291
|
+
const config2 = {
|
|
292
|
+
llm: { defaultModel: "p2/m2", providers: { p2: { type: "openai", baseURL: "http://two", apiKey: "k2" } } },
|
|
293
|
+
logging: {},
|
|
294
|
+
memory: {},
|
|
295
|
+
agent: { maxIterations: 1 },
|
|
296
|
+
security: { approval: "auto", workspaceRoot: paths.root },
|
|
297
|
+
};
|
|
298
|
+
fs.writeFileSync(paths.config, JSON.stringify(config1));
|
|
299
|
+
const harness = createHarness({ paths, logger: new NullLogger(), config: config1 });
|
|
300
|
+
|
|
301
|
+
fs.writeFileSync(paths.config, JSON.stringify(config2));
|
|
302
|
+
fs.utimesSync(paths.config, new Date(Date.now() + 1000), new Date(Date.now() + 1000));
|
|
303
|
+
|
|
304
|
+
let requestedModel = "";
|
|
305
|
+
const oldFetch = globalThis.fetch;
|
|
306
|
+
globalThis.fetch = async (_url, req) => {
|
|
307
|
+
requestedModel = JSON.parse(req.body).model;
|
|
308
|
+
const encoder = new TextEncoder();
|
|
309
|
+
const stream = new ReadableStream({
|
|
310
|
+
start(controller) {
|
|
311
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'));
|
|
312
|
+
controller.close();
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
return { ok: true, status: 200, body: stream, text: async () => "" };
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
318
|
+
await harness.runTurn("hello", {}, new AbortController().signal);
|
|
319
|
+
} finally {
|
|
320
|
+
globalThis.fetch = oldFetch;
|
|
321
|
+
}
|
|
322
|
+
assert.equal(harness.model, "p2/m2");
|
|
323
|
+
assert.equal(requestedModel, "m2");
|
|
324
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createHarness } from "../core/harness.js";
|
|
7
|
+
import { createSecurity } from "../security/index.js";
|
|
8
|
+
import { createToolContext, runToolCall, ToolRegistry } from "../tools/index.js";
|
|
9
|
+
import { createSkillsTool, defaultSkillDirs, loadSkills, renderSkills, summarizeSkills } from "../skills/loader.js";
|
|
10
|
+
import { createI18n } from "../i18n/index.js";
|
|
11
|
+
import { NullLogger } from "../logger.js";
|
|
12
|
+
|
|
13
|
+
function tempPaths() {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vanor-skills-"));
|
|
15
|
+
return {
|
|
16
|
+
root,
|
|
17
|
+
skills: path.join(root, "skills"),
|
|
18
|
+
sessions: path.join(root, "sessions"),
|
|
19
|
+
memory: path.join(root, "memory"),
|
|
20
|
+
working: path.join(root, "memory", "working"),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeSkill(base, dir, name, description, body = "步骤说明") {
|
|
25
|
+
const skillDir = path.join(base, dir);
|
|
26
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(skillDir, "SKILL.md"),
|
|
29
|
+
["---", `name: ${name}`, `description: ${description}`, "---", "", body, ""].join("\n"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("defaultSkillDirs 覆盖本机常见 skills 目录", () => {
|
|
34
|
+
const paths = tempPaths();
|
|
35
|
+
const workspace = path.join(paths.root, "workspace");
|
|
36
|
+
const dirs = defaultSkillDirs(paths, workspace);
|
|
37
|
+
assert.equal(dirs[0], paths.skills);
|
|
38
|
+
assert.equal(dirs[1], path.join(workspace, ".skills"));
|
|
39
|
+
assert.equal(dirs[2], path.join(workspace, "skills"));
|
|
40
|
+
assert.ok(dirs.includes(path.join(os.homedir(), ".cursor", "skills-cursor")));
|
|
41
|
+
assert.ok(dirs.includes(path.join(os.homedir(), ".claude", "skills")));
|
|
42
|
+
assert.ok(dirs.includes(path.join(os.homedir(), ".agents", "skills")));
|
|
43
|
+
assert.ok(dirs.includes(path.join(os.homedir(), ".codex", "skills")));
|
|
44
|
+
assert.ok(dirs.includes(path.join(os.homedir(), ".codex", "skills", ".archive")));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("工作区 skills 优先于本机全局 skills", () => {
|
|
48
|
+
const paths = tempPaths();
|
|
49
|
+
const workspace = path.join(paths.root, "workspace");
|
|
50
|
+
const global = path.join(paths.root, "global");
|
|
51
|
+
writeSkill(path.join(workspace, ".skills"), "build", "build", "工作区隐藏技能");
|
|
52
|
+
writeSkill(path.join(workspace, "skills"), "test", "test", "工作区技能");
|
|
53
|
+
writeSkill(global, "build", "build", "全局同名技能");
|
|
54
|
+
|
|
55
|
+
const skills = loadSkills([...defaultSkillDirs(paths, workspace), global], new NullLogger());
|
|
56
|
+
assert.equal(skills.find((s) => s.name === "build").description, "工作区隐藏技能");
|
|
57
|
+
assert.equal(skills.find((s) => s.name === "test").description, "工作区技能");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("loadSkills 按目录优先级去重并渲染到 prompt", () => {
|
|
61
|
+
const paths = tempPaths();
|
|
62
|
+
const shared = path.join(paths.root, "shared-skills");
|
|
63
|
+
writeSkill(shared, "deploy", "deploy", "共享部署流程");
|
|
64
|
+
writeSkill(paths.skills, "deploy", "deploy", "Vanor 本地部署流程");
|
|
65
|
+
writeSkill(paths.skills, "review", "review", "代码评审流程");
|
|
66
|
+
|
|
67
|
+
const skills = loadSkills([paths.skills, shared], new NullLogger());
|
|
68
|
+
assert.equal(skills.length, 2);
|
|
69
|
+
assert.equal(skills.find((s) => s.name === "deploy").description, "Vanor 本地部署流程");
|
|
70
|
+
const rendered = renderSkills(skills);
|
|
71
|
+
assert.match(rendered, /deploy/);
|
|
72
|
+
assert.match(rendered, /skills\.write\/edit/);
|
|
73
|
+
assert.match(rendered, /Available skills/);
|
|
74
|
+
const renderedZh = renderSkills(skills, createI18n("zh-CN"));
|
|
75
|
+
assert.match(renderedZh, /可用技能/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("summarizeSkills 统计总数和每个目录数量", () => {
|
|
79
|
+
const paths = tempPaths();
|
|
80
|
+
const shared = path.join(paths.root, "shared-skills");
|
|
81
|
+
writeSkill(paths.skills, "a", "a", "A");
|
|
82
|
+
writeSkill(paths.skills, "b", "b", "B");
|
|
83
|
+
writeSkill(shared, "c", "c", "C");
|
|
84
|
+
|
|
85
|
+
const skills = loadSkills([paths.skills, shared], new NullLogger());
|
|
86
|
+
const summary = summarizeSkills(skills);
|
|
87
|
+
assert.equal(summary.total, 3);
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
summary.byDir.map((x) => [x.dir, x.count]),
|
|
90
|
+
[
|
|
91
|
+
[paths.skills, 2],
|
|
92
|
+
[shared, 1],
|
|
93
|
+
],
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("loadSkills 正确解析 YAML folded description", () => {
|
|
98
|
+
const paths = tempPaths();
|
|
99
|
+
const skillDir = path.join(paths.skills, "babysit");
|
|
100
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
101
|
+
fs.writeFileSync(
|
|
102
|
+
path.join(skillDir, "SKILL.md"),
|
|
103
|
+
[
|
|
104
|
+
"---",
|
|
105
|
+
"name: babysit",
|
|
106
|
+
"description: >-",
|
|
107
|
+
" Keep a PR merge-ready by triaging comments, resolving clear conflicts, and",
|
|
108
|
+
" fixing CI in a loop.",
|
|
109
|
+
"---",
|
|
110
|
+
"# Babysit PR",
|
|
111
|
+
"",
|
|
112
|
+
].join("\n"),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const skills = loadSkills([paths.skills], new NullLogger());
|
|
116
|
+
assert.equal(skills[0].name, "babysit");
|
|
117
|
+
assert.equal(
|
|
118
|
+
skills[0].description,
|
|
119
|
+
"Keep a PR merge-ready by triaging comments, resolving clear conflicts, and fixing CI in a loop.",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("skills 工具支持 list/read/write/edit/remove,并按动作区分审批", async () => {
|
|
124
|
+
const paths = tempPaths();
|
|
125
|
+
let skills = [];
|
|
126
|
+
const reloadSkills = () => {
|
|
127
|
+
skills = loadSkills([paths.skills], new NullLogger());
|
|
128
|
+
return skills;
|
|
129
|
+
};
|
|
130
|
+
const reg = new ToolRegistry();
|
|
131
|
+
reg.register(createSkillsTool(paths, new NullLogger(), { getSkills: () => skills, reloadSkills }));
|
|
132
|
+
const security = createSecurity({ security: { approval: "ask", workspaceRoot: paths.root } });
|
|
133
|
+
let confirmCalls = 0;
|
|
134
|
+
const ctx = createToolContext({
|
|
135
|
+
security,
|
|
136
|
+
logger: new NullLogger(),
|
|
137
|
+
confirm: async () => {
|
|
138
|
+
confirmCalls++;
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const empty = await runToolCall({ id: "1", name: "skills", arguments: { action: "list" } }, reg, ctx);
|
|
144
|
+
assert.equal(empty.content, "(no skills)");
|
|
145
|
+
assert.equal(confirmCalls, 0);
|
|
146
|
+
|
|
147
|
+
const written = await runToolCall(
|
|
148
|
+
{
|
|
149
|
+
id: "2",
|
|
150
|
+
name: "skills",
|
|
151
|
+
arguments: { action: "write", name: "测试技能", description: "用于测试", content: "# 使用方法\n先做 A" },
|
|
152
|
+
},
|
|
153
|
+
reg,
|
|
154
|
+
ctx,
|
|
155
|
+
);
|
|
156
|
+
assert.ok(!written.meta.isError);
|
|
157
|
+
assert.equal(confirmCalls, 1);
|
|
158
|
+
assert.equal(skills.length, 1);
|
|
159
|
+
assert.equal(skills[0].name, "测试技能");
|
|
160
|
+
|
|
161
|
+
const listed = await runToolCall({ id: "2b", name: "skills", arguments: { action: "list" } }, reg, ctx);
|
|
162
|
+
assert.match(listed.content, /Total: 1 skills/);
|
|
163
|
+
assert.match(listed.content, new RegExp(`${paths.skills.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: 1`));
|
|
164
|
+
|
|
165
|
+
const read = await runToolCall({ id: "3", name: "skills", arguments: { action: "read", name: "测试技能" } }, reg, ctx);
|
|
166
|
+
assert.match(read.content, /先做 A/);
|
|
167
|
+
assert.equal(confirmCalls, 1);
|
|
168
|
+
|
|
169
|
+
const edited = await runToolCall(
|
|
170
|
+
{ id: "4", name: "skills", arguments: { action: "edit", name: "测试技能", old_text: "先做 A", new_text: "先做 B" } },
|
|
171
|
+
reg,
|
|
172
|
+
ctx,
|
|
173
|
+
);
|
|
174
|
+
assert.ok(!edited.meta.isError);
|
|
175
|
+
assert.equal(confirmCalls, 2);
|
|
176
|
+
assert.match(
|
|
177
|
+
(await runToolCall({ id: "5", name: "skills", arguments: { action: "read", name: "测试技能" } }, reg, ctx)).content,
|
|
178
|
+
/先做 B/,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const removed = await runToolCall({ id: "6", name: "skills", arguments: { action: "remove", name: "测试技能" } }, reg, ctx);
|
|
182
|
+
assert.ok(!removed.meta.isError);
|
|
183
|
+
assert.equal(confirmCalls, 3);
|
|
184
|
+
assert.equal(skills.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("skills 工具支持中文 i18n 输出", async () => {
|
|
188
|
+
const paths = tempPaths();
|
|
189
|
+
const reg = new ToolRegistry();
|
|
190
|
+
reg.register(createSkillsTool(paths, new NullLogger(), { getSkills: () => [], getI18n: () => createI18n("zh-CN") }));
|
|
191
|
+
const security = createSecurity({ security: { approval: "auto", workspaceRoot: paths.root } });
|
|
192
|
+
const ctx = createToolContext({ security, logger: new NullLogger() });
|
|
193
|
+
|
|
194
|
+
const empty = await runToolCall({ id: "1", name: "skills", arguments: { action: "list" } }, reg, ctx);
|
|
195
|
+
assert.equal(empty.content, "(无技能)");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("createHarness 每次请求前重新加载本地 skills 并注入 system prompt", async () => {
|
|
199
|
+
const paths = tempPaths();
|
|
200
|
+
let capturedSystem = "";
|
|
201
|
+
const oldFetch = globalThis.fetch;
|
|
202
|
+
globalThis.fetch = async (_url, req) => {
|
|
203
|
+
const parsed = JSON.parse(req.body);
|
|
204
|
+
capturedSystem = parsed.messages[0].content;
|
|
205
|
+
const encoder = new TextEncoder();
|
|
206
|
+
const stream = new ReadableStream({
|
|
207
|
+
start(controller) {
|
|
208
|
+
controller.enqueue(
|
|
209
|
+
encoder.encode('data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'),
|
|
210
|
+
);
|
|
211
|
+
controller.close();
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
return { ok: true, status: 200, body: stream, text: async () => "" };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const harness = createHarness({
|
|
219
|
+
paths,
|
|
220
|
+
logger: new NullLogger(),
|
|
221
|
+
config: {
|
|
222
|
+
llm: { defaultModel: "p/m", providers: { p: { type: "openai", baseURL: "http://example.test", apiKey: "k" } } },
|
|
223
|
+
logging: {},
|
|
224
|
+
memory: {},
|
|
225
|
+
agent: { maxIterations: 1 },
|
|
226
|
+
security: { approval: "auto", workspaceRoot: paths.root },
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
writeSkill(paths.skills, "late-skill", "late-skill", "创建 harness 后新增的技能");
|
|
230
|
+
await harness.runTurn("hello", {}, new AbortController().signal);
|
|
231
|
+
assert.match(capturedSystem, /late-skill/);
|
|
232
|
+
assert.match(capturedSystem, /创建 harness 后新增的技能/);
|
|
233
|
+
} finally {
|
|
234
|
+
globalThis.fetch = oldFetch;
|
|
235
|
+
}
|
|
236
|
+
});
|