@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,143 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { StatusBar } from "../transport/statusbar.js";
|
|
4
|
+
|
|
5
|
+
// 在受控的“伪 TTY”下捕获 StatusBar 的输出
|
|
6
|
+
function captureTTY(fn) {
|
|
7
|
+
const out = [];
|
|
8
|
+
const orig = {
|
|
9
|
+
write: process.stdout.write,
|
|
10
|
+
isTTY: process.stdout.isTTY,
|
|
11
|
+
columns: process.stdout.columns,
|
|
12
|
+
rows: process.stdout.rows,
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
process.stdout.isTTY = true;
|
|
16
|
+
process.stdout.columns = 80;
|
|
17
|
+
process.stdout.rows = 24;
|
|
18
|
+
process.stdout.write = (s) => {
|
|
19
|
+
out.push(s);
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
fn();
|
|
23
|
+
} finally {
|
|
24
|
+
process.stdout.write = orig.write;
|
|
25
|
+
process.stdout.isTTY = orig.isTTY;
|
|
26
|
+
process.stdout.columns = orig.columns;
|
|
27
|
+
process.stdout.rows = orig.rows;
|
|
28
|
+
}
|
|
29
|
+
return out.join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("TTY 下渲染上下文/模型,并设置滚动区", () => {
|
|
33
|
+
const joined = captureTTY(() => {
|
|
34
|
+
const sb = new StatusBar(() => ({ used: 10, total: 100, model: "p/m", cwd: "/tmp/x" }));
|
|
35
|
+
sb.enable();
|
|
36
|
+
sb.render();
|
|
37
|
+
sb.disable();
|
|
38
|
+
});
|
|
39
|
+
assert.match(joined, /ctx 10\/100/);
|
|
40
|
+
assert.match(joined, /p\/m/);
|
|
41
|
+
assert.match(joined, /tmp\/x/);
|
|
42
|
+
assert.match(joined, /\x1b\[1;23r/); // 滚动区 1..rows-1
|
|
43
|
+
assert.match(joined, /\x1b\[r/); // disable 时重置滚动区
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("auto 审批模式显示 ⚡auto 标记", () => {
|
|
47
|
+
const joined = captureTTY(() => {
|
|
48
|
+
const sb = new StatusBar(() => ({ used: 1, total: 100, model: "m", cwd: "/", approval: "auto" }));
|
|
49
|
+
sb.enable();
|
|
50
|
+
sb.render();
|
|
51
|
+
sb.disable();
|
|
52
|
+
});
|
|
53
|
+
assert.match(joined, /auto/);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("可为固定输入行预留滚动区底部空间", () => {
|
|
57
|
+
const joined = captureTTY(() => {
|
|
58
|
+
const sb = new StatusBar(() => ({ used: 1, total: 100, model: "m", cwd: "/" }), { reservedInputLines: 1 });
|
|
59
|
+
sb.enable();
|
|
60
|
+
sb.disable();
|
|
61
|
+
});
|
|
62
|
+
assert.match(joined, /\x1b\[1;22r/); // 24 行终端:1..22 输出滚动,23 输入,24 状态
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("disable 清理底部保留区域,避免退出后残留输入行/状态栏", () => {
|
|
66
|
+
const joined = captureTTY(() => {
|
|
67
|
+
const sb = new StatusBar(() => ({ used: 1, total: 100, model: "m", cwd: "/" }), { reservedInputLines: 1 });
|
|
68
|
+
sb.enable();
|
|
69
|
+
sb.disable();
|
|
70
|
+
});
|
|
71
|
+
assert.match(joined, /\x1b\[r/); // 重置滚动区
|
|
72
|
+
assert.match(joined, /\x1b\[23;1H\x1b\[J/); // 从输入行清到屏幕底部
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("非 TTY 下不启用、不输出", () => {
|
|
76
|
+
const sb = new StatusBar(() => ({ used: 1, total: 2, model: "m", cwd: "/" }));
|
|
77
|
+
// 默认测试环境非 TTY
|
|
78
|
+
sb.enable();
|
|
79
|
+
assert.equal(sb.enabled, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("脏检查:内容不变时重复渲染不再写入(防闪)", () => {
|
|
83
|
+
let writes = 0;
|
|
84
|
+
const orig = {
|
|
85
|
+
write: process.stdout.write,
|
|
86
|
+
isTTY: process.stdout.isTTY,
|
|
87
|
+
columns: process.stdout.columns,
|
|
88
|
+
rows: process.stdout.rows,
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
process.stdout.isTTY = true;
|
|
92
|
+
process.stdout.columns = 80;
|
|
93
|
+
process.stdout.rows = 24;
|
|
94
|
+
process.stdout.write = () => {
|
|
95
|
+
writes++;
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
const sb = new StatusBar(() => ({ used: 5, total: 100, model: "m", cwd: "/" }));
|
|
99
|
+
sb.enable();
|
|
100
|
+
const baseline = writes; // enable 已完成首次绘制
|
|
101
|
+
sb.render();
|
|
102
|
+
sb.render();
|
|
103
|
+
assert.equal(writes, baseline); // 内容相同:不产生任何新的写入
|
|
104
|
+
sb.disable();
|
|
105
|
+
} finally {
|
|
106
|
+
process.stdout.write = orig.write;
|
|
107
|
+
process.stdout.isTTY = orig.isTTY;
|
|
108
|
+
process.stdout.columns = orig.columns;
|
|
109
|
+
process.stdout.rows = orig.rows;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("内容变化时重新渲染(脏检查不误伤)", () => {
|
|
114
|
+
let used = 5;
|
|
115
|
+
let writes = 0;
|
|
116
|
+
const orig = {
|
|
117
|
+
write: process.stdout.write,
|
|
118
|
+
isTTY: process.stdout.isTTY,
|
|
119
|
+
columns: process.stdout.columns,
|
|
120
|
+
rows: process.stdout.rows,
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
process.stdout.isTTY = true;
|
|
124
|
+
process.stdout.columns = 80;
|
|
125
|
+
process.stdout.rows = 24;
|
|
126
|
+
process.stdout.write = () => {
|
|
127
|
+
writes++;
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
const sb = new StatusBar(() => ({ used, total: 100, model: "m", cwd: "/" }));
|
|
131
|
+
sb.enable();
|
|
132
|
+
const baseline = writes;
|
|
133
|
+
used = 42; // 上下文用量变化
|
|
134
|
+
sb.render();
|
|
135
|
+
assert.ok(writes > baseline); // 内容变化:应重新写入
|
|
136
|
+
sb.disable();
|
|
137
|
+
} finally {
|
|
138
|
+
process.stdout.write = orig.write;
|
|
139
|
+
process.stdout.isTTY = orig.isTTY;
|
|
140
|
+
process.stdout.columns = orig.columns;
|
|
141
|
+
process.stdout.rows = orig.rows;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
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 { createSecurity } from "../security/index.js";
|
|
7
|
+
import { defaultRegistry, createToolContext, runToolCall } from "../tools/index.js";
|
|
8
|
+
import { NullLogger } from "../logger.js";
|
|
9
|
+
|
|
10
|
+
function tempWorkspace() {
|
|
11
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "vanor-test-"));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ctxFor(ws, overrides = {}) {
|
|
15
|
+
const security = createSecurity({
|
|
16
|
+
security: { approval: "ask", allowlist: [], denylist: ["rm -rf *"], workspaceRoot: ws, ...overrides },
|
|
17
|
+
});
|
|
18
|
+
return createToolContext({ security, logger: new NullLogger(), confirm: async () => true });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("write_file 与 read_file 往返", async () => {
|
|
22
|
+
const ws = tempWorkspace();
|
|
23
|
+
const reg = defaultRegistry();
|
|
24
|
+
const ctx = ctxFor(ws);
|
|
25
|
+
|
|
26
|
+
const w = await runToolCall(
|
|
27
|
+
{ id: "1", name: "write_file", arguments: { path: "a.txt", content: "HELLO" } },
|
|
28
|
+
reg,
|
|
29
|
+
ctx,
|
|
30
|
+
);
|
|
31
|
+
assert.ok(!w.meta.isError);
|
|
32
|
+
|
|
33
|
+
const r = await runToolCall({ id: "2", name: "read_file", arguments: { path: "a.txt" } }, reg, ctx);
|
|
34
|
+
assert.equal(r.content, "HELLO");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("路径越界被拒绝", async () => {
|
|
38
|
+
const ws = tempWorkspace();
|
|
39
|
+
const reg = defaultRegistry();
|
|
40
|
+
const ctx = ctxFor(ws);
|
|
41
|
+
const r = await runToolCall(
|
|
42
|
+
{ id: "1", name: "read_file", arguments: { path: "/etc/hosts" } },
|
|
43
|
+
reg,
|
|
44
|
+
ctx,
|
|
45
|
+
);
|
|
46
|
+
assert.ok(r.meta.isError);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("参数缺失返回错误", async () => {
|
|
50
|
+
const ws = tempWorkspace();
|
|
51
|
+
const reg = defaultRegistry();
|
|
52
|
+
const ctx = ctxFor(ws);
|
|
53
|
+
const r = await runToolCall({ id: "1", name: "read_file", arguments: {} }, reg, ctx);
|
|
54
|
+
assert.ok(r.meta.isError);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("未知工具返回错误", async () => {
|
|
58
|
+
const ws = tempWorkspace();
|
|
59
|
+
const reg = defaultRegistry();
|
|
60
|
+
const ctx = ctxFor(ws);
|
|
61
|
+
const r = await runToolCall({ id: "1", name: "nope", arguments: {} }, reg, ctx);
|
|
62
|
+
assert.ok(r.meta.isError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("confirm 拒绝则不执行写入", async () => {
|
|
66
|
+
const ws = tempWorkspace();
|
|
67
|
+
const reg = defaultRegistry();
|
|
68
|
+
const security = createSecurity({ security: { approval: "ask", workspaceRoot: ws } });
|
|
69
|
+
const ctx = createToolContext({ security, logger: new NullLogger(), confirm: async () => false });
|
|
70
|
+
const r = await runToolCall(
|
|
71
|
+
{ id: "1", name: "write_file", arguments: { path: "b.txt", content: "x" } },
|
|
72
|
+
reg,
|
|
73
|
+
ctx,
|
|
74
|
+
);
|
|
75
|
+
assert.ok(r.meta.isError);
|
|
76
|
+
assert.ok(!fs.existsSync(path.join(ws, "b.txt")));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("setApproval(auto) 后写文件无需确认", async () => {
|
|
80
|
+
const ws = tempWorkspace();
|
|
81
|
+
const reg = defaultRegistry();
|
|
82
|
+
const security = createSecurity({ security: { approval: "ask", workspaceRoot: ws } });
|
|
83
|
+
let confirmCalls = 0;
|
|
84
|
+
const ctx = createToolContext({
|
|
85
|
+
security,
|
|
86
|
+
logger: new NullLogger(),
|
|
87
|
+
confirm: async () => {
|
|
88
|
+
confirmCalls++;
|
|
89
|
+
return false;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
// ask 模式:confirm 返回 false → 拒绝
|
|
93
|
+
const r1 = await runToolCall({ id: "1", name: "write_file", arguments: { path: "a.txt", content: "x" } }, reg, ctx);
|
|
94
|
+
assert.ok(r1.meta.isError);
|
|
95
|
+
// 运行时切到 auto → 不再询问,直接放行
|
|
96
|
+
security.setApproval("auto");
|
|
97
|
+
const r2 = await runToolCall({ id: "2", name: "write_file", arguments: { path: "b.txt", content: "y" } }, reg, ctx);
|
|
98
|
+
assert.ok(!r2.meta.isError);
|
|
99
|
+
assert.equal(confirmCalls, 1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("setApproval(auto) 不绕过 denylist", async () => {
|
|
103
|
+
const ws = tempWorkspace();
|
|
104
|
+
const reg = defaultRegistry();
|
|
105
|
+
const security = createSecurity({
|
|
106
|
+
security: { approval: "ask", denylist: ["rm -rf *"], workspaceRoot: ws },
|
|
107
|
+
});
|
|
108
|
+
security.setApproval("auto");
|
|
109
|
+
const ctx = createToolContext({ security, logger: new NullLogger(), confirm: async () => true });
|
|
110
|
+
const r = await runToolCall({ id: "1", name: "exec", arguments: { command: "rm -rf /tmp/x" } }, reg, ctx);
|
|
111
|
+
assert.ok(r.meta.isError);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("denylist 命中 exec 被拒", async () => {
|
|
115
|
+
const ws = tempWorkspace();
|
|
116
|
+
const security = createSecurity({
|
|
117
|
+
security: { approval: "auto", denylist: ["rm -rf *"], workspaceRoot: ws },
|
|
118
|
+
});
|
|
119
|
+
const ctx = createToolContext({ security, logger: new NullLogger(), confirm: async () => true });
|
|
120
|
+
const reg = defaultRegistry();
|
|
121
|
+
const r = await runToolCall(
|
|
122
|
+
{ id: "1", name: "exec", arguments: { command: "rm -rf /tmp/x" } },
|
|
123
|
+
reg,
|
|
124
|
+
ctx,
|
|
125
|
+
);
|
|
126
|
+
assert.ok(r.meta.isError);
|
|
127
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createTracer, maskValue, redactHeaders } from "../llm/trace.js";
|
|
4
|
+
import { EVENTS } from "../events.js";
|
|
5
|
+
|
|
6
|
+
function fakeLogger() {
|
|
7
|
+
return {
|
|
8
|
+
calls: [],
|
|
9
|
+
info(event, detail) {
|
|
10
|
+
this.calls.push({ event, detail });
|
|
11
|
+
},
|
|
12
|
+
debug() {},
|
|
13
|
+
warn() {},
|
|
14
|
+
error() {},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test("maskValue 掩码长串保留首尾", () => {
|
|
19
|
+
const m = maskValue("Bearer sk-tr-v1-0123456789abcdef");
|
|
20
|
+
assert.match(m, /…/);
|
|
21
|
+
assert.notEqual(m, "Bearer sk-tr-v1-0123456789abcdef");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("默认关闭时不记录", () => {
|
|
25
|
+
const log = fakeLogger();
|
|
26
|
+
const t = createTracer({ logging: { llmTrace: false } }, log);
|
|
27
|
+
assert.equal(t.enabled, false);
|
|
28
|
+
t.request({ provider: "p", model: "m", url: "u", headers: {}, body: {} });
|
|
29
|
+
t.response({ provider: "p", model: "m", status: 200 });
|
|
30
|
+
assert.equal(log.calls.length, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("开启时记录请求并默认脱敏 Authorization", () => {
|
|
34
|
+
const log = fakeLogger();
|
|
35
|
+
const t = createTracer({ logging: { llmTrace: true } }, log);
|
|
36
|
+
t.request({
|
|
37
|
+
provider: "p",
|
|
38
|
+
model: "m",
|
|
39
|
+
url: "https://x/api",
|
|
40
|
+
headers: { Authorization: "Bearer sk-tr-v1-0123456789abcdef", "Content-Type": "application/json" },
|
|
41
|
+
body: { model: "m" },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(log.calls.length, 1);
|
|
44
|
+
assert.equal(log.calls[0].event, EVENTS.llm.requestDetail);
|
|
45
|
+
const h = log.calls[0].detail.headers;
|
|
46
|
+
assert.match(h.Authorization, /…/);
|
|
47
|
+
assert.equal(h["Content-Type"], "application/json");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("关闭脱敏时保留完整 Authorization", () => {
|
|
51
|
+
const headers = { Authorization: "Bearer sk-tr-v1-0123456789abcdef" };
|
|
52
|
+
const redacted = redactHeaders(headers, false);
|
|
53
|
+
assert.equal(redacted.Authorization, headers.Authorization);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("记录响应详情含返回内容", () => {
|
|
57
|
+
const log = fakeLogger();
|
|
58
|
+
const t = createTracer({ logging: { llmTrace: true } }, log);
|
|
59
|
+
t.response({ provider: "p", model: "m", status: 200, ms: 12, text: "hi", toolCalls: [], usage: null });
|
|
60
|
+
assert.equal(log.calls[0].event, EVENTS.llm.responseDetail);
|
|
61
|
+
assert.equal(log.calls[0].detail.text, "hi");
|
|
62
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { advanceCursor, charWidth, displayWidth, stripAnsi, TerminalUI } from "../transport/tui.js";
|
|
5
|
+
|
|
6
|
+
class FakeInput extends EventEmitter {
|
|
7
|
+
constructor() {
|
|
8
|
+
super();
|
|
9
|
+
this.isTTY = true;
|
|
10
|
+
this.raw = false;
|
|
11
|
+
}
|
|
12
|
+
setRawMode(v) {
|
|
13
|
+
this.raw = v;
|
|
14
|
+
}
|
|
15
|
+
setEncoding() {}
|
|
16
|
+
resume() {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class FakeOutput {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.isTTY = true;
|
|
22
|
+
this.rows = 24;
|
|
23
|
+
this.columns = 80;
|
|
24
|
+
this.out = [];
|
|
25
|
+
}
|
|
26
|
+
write(s) {
|
|
27
|
+
this.out.push(String(s));
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
text() {
|
|
31
|
+
return this.out.join("");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fakeStatusBar() {
|
|
36
|
+
return {
|
|
37
|
+
inputRow: () => 23,
|
|
38
|
+
scrollBottom: () => 22,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("stripAnsi 去除颜色序列", () => {
|
|
43
|
+
assert.equal(stripAnsi("\x1b[36m› \x1b[0mhello"), "› hello");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("中文宽字符按 2 列计算", () => {
|
|
47
|
+
assert.equal(charWidth("你"), 2);
|
|
48
|
+
assert.equal(displayWidth("你好 Vanor"), 10);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("advanceCursor 在滚动区底部换行时保持在底部", () => {
|
|
52
|
+
assert.deepEqual(advanceCursor({ row: 22, col: 1, cols: 80, bottom: 22 }, "abc\n"), { row: 22, col: 1 });
|
|
53
|
+
assert.deepEqual(advanceCursor({ row: 21, col: 79, cols: 80, bottom: 22 }, "ab"), { row: 22, col: 1 });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("advanceCursor 按终端显示列宽推进中文输出", () => {
|
|
57
|
+
assert.deepEqual(advanceCursor({ row: 22, col: 1, cols: 80, bottom: 22 }, "你"), { row: 22, col: 3 });
|
|
58
|
+
assert.deepEqual(advanceCursor({ row: 22, col: 1, cols: 80, bottom: 22 }, "你好"), { row: 22, col: 5 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("TerminalUI 渲染固定输入行并在输出后重画草稿", () => {
|
|
62
|
+
const input = new FakeInput();
|
|
63
|
+
const output = new FakeOutput();
|
|
64
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
65
|
+
assert.equal(tui.enable(), true);
|
|
66
|
+
|
|
67
|
+
input.emit("data", "下一轮问题");
|
|
68
|
+
const before = output.text();
|
|
69
|
+
assert.match(before, /\x1b\[23;1H\x1b\[2K› 下一轮问题/);
|
|
70
|
+
|
|
71
|
+
tui.write("agent output\n");
|
|
72
|
+
const after = output.text();
|
|
73
|
+
assert.match(after, /\x1b\[22;1Hagent output\n\x1b8/); // 输出写入滚动区底部
|
|
74
|
+
assert.match(after, /\x1b\[23;1H\x1b\[2K› 下一轮问题/); // 草稿仍在固定输入行
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("TerminalUI 中文流式分片不会互相覆盖", () => {
|
|
78
|
+
const input = new FakeInput();
|
|
79
|
+
const output = new FakeOutput();
|
|
80
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
81
|
+
tui.enable();
|
|
82
|
+
|
|
83
|
+
tui.write("你");
|
|
84
|
+
tui.write("好");
|
|
85
|
+
|
|
86
|
+
const out = output.text();
|
|
87
|
+
assert.match(out, /\x1b\[22;1H你\x1b8/);
|
|
88
|
+
assert.match(out, /\x1b\[22;3H好\x1b8/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("TerminalUI 中文输入光标按显示列定位", () => {
|
|
92
|
+
const input = new FakeInput();
|
|
93
|
+
const output = new FakeOutput();
|
|
94
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
95
|
+
tui.enable();
|
|
96
|
+
|
|
97
|
+
input.emit("data", "你");
|
|
98
|
+
|
|
99
|
+
assert.match(output.text(), /\x1b\[23;1H\x1b\[2K› 你\x1b\[23;5H/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("TerminalUI 运行中提交的输入会排队", async () => {
|
|
103
|
+
const input = new FakeInput();
|
|
104
|
+
const output = new FakeOutput();
|
|
105
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
106
|
+
tui.enable();
|
|
107
|
+
|
|
108
|
+
input.emit("data", "queued");
|
|
109
|
+
input.emit("data", "\r");
|
|
110
|
+
assert.equal(await tui.readLine(), "queued");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("TerminalUI 上下键切换提交历史", async () => {
|
|
114
|
+
const input = new FakeInput();
|
|
115
|
+
const output = new FakeOutput();
|
|
116
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
117
|
+
tui.enable();
|
|
118
|
+
|
|
119
|
+
input.emit("data", "first");
|
|
120
|
+
input.emit("data", "\r");
|
|
121
|
+
input.emit("data", "second");
|
|
122
|
+
input.emit("data", "\r");
|
|
123
|
+
assert.equal(await tui.readLine(), "first");
|
|
124
|
+
assert.equal(await tui.readLine(), "second");
|
|
125
|
+
|
|
126
|
+
input.emit("data", "draft");
|
|
127
|
+
input.emit("data", "\x1b[A");
|
|
128
|
+
assert.equal(tui.buffer, "second");
|
|
129
|
+
input.emit("data", "\x1b[A");
|
|
130
|
+
assert.equal(tui.buffer, "first");
|
|
131
|
+
input.emit("data", "\x1b[B");
|
|
132
|
+
assert.equal(tui.buffer, "second");
|
|
133
|
+
input.emit("data", "\x1b[B");
|
|
134
|
+
assert.equal(tui.buffer, "draft");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("TerminalUI 支持被拆分的上下键转义序列", async () => {
|
|
138
|
+
const input = new FakeInput();
|
|
139
|
+
const output = new FakeOutput();
|
|
140
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
141
|
+
tui.enable();
|
|
142
|
+
|
|
143
|
+
input.emit("data", "history");
|
|
144
|
+
input.emit("data", "\r");
|
|
145
|
+
assert.equal(await tui.readLine(), "history");
|
|
146
|
+
|
|
147
|
+
input.emit("data", "\x1b");
|
|
148
|
+
input.emit("data", "[");
|
|
149
|
+
input.emit("data", "A");
|
|
150
|
+
assert.equal(tui.buffer, "history");
|
|
151
|
+
assert.doesNotMatch(tui.buffer, /\[A/);
|
|
152
|
+
|
|
153
|
+
input.emit("data", "\x1b");
|
|
154
|
+
input.emit("data", "[");
|
|
155
|
+
input.emit("data", "B");
|
|
156
|
+
assert.equal(tui.buffer, "");
|
|
157
|
+
assert.doesNotMatch(tui.buffer, /\[B/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("TerminalUI 支持粘贴整段文本并提交", async () => {
|
|
161
|
+
const input = new FakeInput();
|
|
162
|
+
const output = new FakeOutput();
|
|
163
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
164
|
+
tui.enable();
|
|
165
|
+
|
|
166
|
+
input.emit("data", "粘贴文本\r");
|
|
167
|
+
assert.equal(await tui.readLine(), "粘贴文本");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("TerminalUI 开启和关闭 bracketed paste", () => {
|
|
171
|
+
const input = new FakeInput();
|
|
172
|
+
const output = new FakeOutput();
|
|
173
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
174
|
+
tui.enable();
|
|
175
|
+
tui.disable();
|
|
176
|
+
|
|
177
|
+
const out = output.text();
|
|
178
|
+
assert.match(out, /\x1b\[\?2004h/);
|
|
179
|
+
assert.match(out, /\x1b\[\?2004l/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("TerminalUI bracketed paste 多行文本作为同一条输入提交", async () => {
|
|
183
|
+
const input = new FakeInput();
|
|
184
|
+
const output = new FakeOutput();
|
|
185
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
186
|
+
tui.enable();
|
|
187
|
+
|
|
188
|
+
input.emit("data", "\x1b[200~第一行\n第二行\n第三行\x1b[201~");
|
|
189
|
+
assert.equal(tui.queue.length, 0); // 粘贴中的换行不触发提交
|
|
190
|
+
assert.equal(tui.buffer, "第一行\n第二行\n第三行");
|
|
191
|
+
assert.match(output.text(), /第一行↵ 第二行↵ 第三行/);
|
|
192
|
+
|
|
193
|
+
input.emit("data", "\r");
|
|
194
|
+
assert.equal(await tui.readLine(), "第一行\n第二行\n第三行");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("TerminalUI bracketed paste 支持跨 data chunk", async () => {
|
|
198
|
+
const input = new FakeInput();
|
|
199
|
+
const output = new FakeOutput();
|
|
200
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
201
|
+
tui.enable();
|
|
202
|
+
|
|
203
|
+
input.emit("data", "\x1b[200~a\n");
|
|
204
|
+
input.emit("data", "b\nc");
|
|
205
|
+
input.emit("data", "\x1b[201~\r");
|
|
206
|
+
|
|
207
|
+
assert.equal(await tui.readLine(), "a\nb\nc");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("TerminalUI confirm 临时占用输入行后恢复草稿", async () => {
|
|
211
|
+
const input = new FakeInput();
|
|
212
|
+
const output = new FakeOutput();
|
|
213
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
214
|
+
tui.enable();
|
|
215
|
+
input.emit("data", "draft");
|
|
216
|
+
|
|
217
|
+
const answer = tui.ask("确认? ");
|
|
218
|
+
input.emit("data", "y");
|
|
219
|
+
input.emit("data", "\r");
|
|
220
|
+
assert.equal(await answer, "y");
|
|
221
|
+
assert.equal(tui.buffer, "draft");
|
|
222
|
+
assert.match(output.text(), /› draft/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("TerminalUI confirm 中 Ctrl-C 取消当前确认而不触发全局中断", async () => {
|
|
226
|
+
const input = new FakeInput();
|
|
227
|
+
const output = new FakeOutput();
|
|
228
|
+
const tui = new TerminalUI({ input, output, statusBar: fakeStatusBar(), prompt: "› " });
|
|
229
|
+
let interrupted = false;
|
|
230
|
+
tui.onSigint = () => {
|
|
231
|
+
interrupted = true;
|
|
232
|
+
};
|
|
233
|
+
tui.enable();
|
|
234
|
+
input.emit("data", "draft");
|
|
235
|
+
|
|
236
|
+
const answer = tui.ask("确认? ");
|
|
237
|
+
input.emit("data", "\x03");
|
|
238
|
+
|
|
239
|
+
assert.equal(await answer, "");
|
|
240
|
+
assert.equal(interrupted, false);
|
|
241
|
+
assert.equal(tui.buffer, "draft");
|
|
242
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateSchema, globToRegExp, deepMerge, estimateTokens } from "../utils.js";
|
|
4
|
+
|
|
5
|
+
test("validateSchema 校验类型与必填", () => {
|
|
6
|
+
const schema = {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: { name: { type: "string" }, age: { type: "integer" } },
|
|
9
|
+
required: ["name"],
|
|
10
|
+
};
|
|
11
|
+
assert.deepEqual(validateSchema(schema, { name: "a", age: 3 }), []);
|
|
12
|
+
assert.ok(validateSchema(schema, { age: 3 }).length === 1); // 缺 name
|
|
13
|
+
assert.ok(validateSchema(schema, { name: "a", age: 1.5 }).length === 1); // age 非整数
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("validateSchema 支持 enum", () => {
|
|
17
|
+
const schema = { type: "string", enum: ["a", "b"] };
|
|
18
|
+
assert.deepEqual(validateSchema(schema, "a"), []);
|
|
19
|
+
assert.equal(validateSchema(schema, "c").length, 1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("globToRegExp 通配匹配", () => {
|
|
23
|
+
assert.ok(globToRegExp("git *").test("git status"));
|
|
24
|
+
assert.ok(!globToRegExp("git *").test("npm install"));
|
|
25
|
+
assert.ok(globToRegExp("rm -rf *").test("rm -rf /tmp/x"));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("deepMerge 深合并", () => {
|
|
29
|
+
assert.deepEqual(deepMerge({ a: { x: 1, y: 2 } }, { a: { y: 3 } }), { a: { x: 1, y: 3 } });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("estimateTokens 近似", () => {
|
|
33
|
+
assert.equal(estimateTokens("abcd"), 1);
|
|
34
|
+
assert.equal(estimateTokens(""), 0);
|
|
35
|
+
});
|