skyloom 1.14.6 → 1.15.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/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +74 -0
- package/CONVERSION_PLAN.md +191 -191
- package/README.md +523 -220
- package/config/default.yaml +46 -43
- package/config/models.yaml +928 -155
- package/config/providers.yaml +109 -6
- package/dist/agents/snow.d.ts +2 -0
- package/dist/agents/snow.d.ts.map +1 -1
- package/dist/agents/snow.js +36 -5
- package/dist/agents/snow.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +207 -1
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +190 -40
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +6 -31
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent.d.ts +6 -4
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +61 -20
- package/dist/core/agent.js.map +1 -1
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +30 -9
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/commands.d.ts +110 -0
- package/dist/core/commands.d.ts.map +1 -0
- package/dist/core/commands.js +633 -0
- package/dist/core/commands.js.map +1 -0
- package/dist/core/concurrency.d.ts +38 -0
- package/dist/core/concurrency.d.ts.map +1 -0
- package/dist/core/concurrency.js +65 -0
- package/dist/core/concurrency.js.map +1 -0
- package/dist/core/factory.js +16 -16
- package/dist/core/file_checkpoint.d.ts +9 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -1
- package/dist/core/file_checkpoint.js +33 -1
- package/dist/core/file_checkpoint.js.map +1 -1
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +66 -13
- package/dist/core/llm.js.map +1 -1
- package/dist/core/memory.js +51 -51
- package/dist/core/schemas.d.ts +16 -0
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +32 -0
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +27 -0
- package/dist/core/security.js.map +1 -1
- package/dist/core/skymd.js +14 -14
- package/dist/core/trace.d.ts +105 -0
- package/dist/core/trace.d.ts.map +1 -0
- package/dist/core/trace.js +213 -0
- package/dist/core/trace.js.map +1 -0
- package/dist/tools/builtin.d.ts +2 -6
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +180 -125
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/extra.d.ts +13 -0
- package/dist/tools/extra.d.ts.map +1 -0
- package/dist/tools/extra.js +827 -0
- package/dist/tools/extra.js.map +1 -0
- package/dist/tools/guards.d.ts +12 -0
- package/dist/tools/guards.d.ts.map +1 -0
- package/dist/tools/guards.js +143 -0
- package/dist/tools/guards.js.map +1 -0
- package/dist/tools/model_tool.d.ts.map +1 -1
- package/dist/tools/model_tool.js +24 -4
- package/dist/tools/model_tool.js.map +1 -1
- package/dist/web/markdown.d.ts +32 -0
- package/dist/web/markdown.d.ts.map +1 -0
- package/dist/web/markdown.js +202 -0
- package/dist/web/markdown.js.map +1 -0
- package/dist/web/server.d.ts +4 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -582
- package/dist/web/server.js.map +1 -1
- package/dist/web/ui.d.ts +31 -0
- package/dist/web/ui.d.ts.map +1 -0
- package/dist/web/ui.js +1009 -0
- package/dist/web/ui.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -152
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +68 -68
- package/src/agents/snow.ts +38 -5
- package/src/cli/commands_md.ts +112 -112
- package/src/cli/input_macros.ts +83 -83
- package/src/cli/loom.ts +1041 -1041
- package/src/cli/loom_chat.ts +772 -603
- package/src/cli/main.ts +853 -723
- package/src/cli/tui.ts +264 -289
- package/src/core/agent/guard.ts +133 -133
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +1630 -1590
- package/src/core/agent_helpers.ts +500 -500
- package/src/core/bus.ts +221 -221
- package/src/core/cache.ts +153 -153
- package/src/core/catalog.ts +199 -178
- package/src/core/circuit_breaker.ts +119 -119
- package/src/core/commands.ts +704 -0
- package/src/core/concurrency.ts +73 -0
- package/src/core/config.ts +365 -365
- package/src/core/constants.ts +95 -95
- package/src/core/factory.ts +656 -656
- package/src/core/file_checkpoint.ts +163 -136
- package/src/core/hooks.ts +126 -126
- package/src/core/llm.ts +972 -915
- package/src/core/logger.ts +143 -143
- package/src/core/mcp.ts +1001 -1001
- package/src/core/memory.ts +1201 -1201
- package/src/core/middleware.ts +350 -350
- package/src/core/model_config.ts +159 -159
- package/src/core/pipelines.ts +424 -424
- package/src/core/schemas.ts +319 -282
- package/src/core/security.ts +27 -0
- package/src/core/semantic.ts +211 -211
- package/src/core/skill.ts +384 -384
- package/src/core/skymd.ts +143 -143
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +457 -457
- package/src/core/trace.ts +236 -0
- package/src/core/verify.ts +71 -71
- package/src/plugins/loader.ts +91 -91
- package/src/skills/loader.ts +75 -75
- package/src/tools/builtin.ts +571 -493
- package/src/tools/computer.ts +279 -279
- package/src/tools/extra.ts +662 -0
- package/src/tools/guards.ts +82 -0
- package/src/tools/model_tool.ts +93 -74
- package/src/tools/todo.ts +76 -76
- package/src/web/markdown.ts +193 -0
- package/src/web/server.ts +117 -693
- package/src/web/ui.ts +949 -0
- package/tests/agent.test.ts +211 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -124
- package/tests/claude_compat.test.ts +110 -110
- package/tests/commands.test.ts +103 -0
- package/tests/concurrency.test.ts +102 -0
- package/tests/config.test.ts +41 -41
- package/tests/extra_tools.test.ts +212 -0
- package/tests/fence_plugin.test.ts +52 -52
- package/tests/guard.test.ts +75 -75
- package/tests/loom.test.ts +337 -337
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -109
- package/tests/skymd.test.ts +146 -146
- package/tests/ssrf.test.ts +38 -38
- package/tests/structured_retry.test.ts +87 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -94
- package/tests/trace.test.ts +128 -0
- package/tests/tui.test.ts +67 -67
- package/tests/web.test.ts +169 -0
- package/tsconfig.json +38 -38
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseTodoItems, renderTodoList, createTodoTool, TODO_WORKING_KEY } from "../src/tools/todo";
|
|
3
|
-
import { clampToolResult } from "../src/core/agent";
|
|
4
|
-
import { ToolRegistry } from "../src/core/tool";
|
|
5
|
-
|
|
6
|
-
describe("todo_write 任务清单", () => {
|
|
7
|
-
it("parses a JSON array with status validation and defaults", () => {
|
|
8
|
-
const { items, error } = parseTodoItems('[{"text":"调研","status":"done"},{"text":"实现","status":"active"},{"text":"测试"}]');
|
|
9
|
-
expect(error).toBe("");
|
|
10
|
-
expect(items).toEqual([
|
|
11
|
-
{ text: "调研", status: "done" },
|
|
12
|
-
{ text: "实现", status: "active" },
|
|
13
|
-
{ text: "测试", status: "pending" },
|
|
14
|
-
]);
|
|
15
|
-
// plain string items are accepted as pending
|
|
16
|
-
expect(parseTodoItems('["a","b"]').items).toEqual([
|
|
17
|
-
{ text: "a", status: "pending" },
|
|
18
|
-
{ text: "b", status: "pending" },
|
|
19
|
-
]);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("rejects malformed input", () => {
|
|
23
|
-
expect(parseTodoItems("not json").items).toBeNull();
|
|
24
|
-
expect(parseTodoItems('{"text":"x"}').items).toBeNull();
|
|
25
|
-
expect(parseTodoItems('[{"text":""}]').items).toBeNull();
|
|
26
|
-
expect(parseTodoItems(JSON.stringify(Array(25).fill({ text: "x" }))).items).toBeNull();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("renderTodoList shows progress and per-item marks", () => {
|
|
30
|
-
const out = renderTodoList([
|
|
31
|
-
{ text: "调研", status: "done" },
|
|
32
|
-
{ text: "实现", status: "active" },
|
|
33
|
-
{ text: "测试", status: "pending" },
|
|
34
|
-
]);
|
|
35
|
-
expect(out).toContain("任务清单 1/3");
|
|
36
|
-
expect(out).toContain("✓ 调研");
|
|
37
|
-
expect(out).toContain("◐ 实现");
|
|
38
|
-
expect(out).toContain("· 测试");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("the tool stores the list in working memory (survives compaction)", async () => {
|
|
42
|
-
const working: Record<string, any> = {};
|
|
43
|
-
const fakeAgent = { memory: { setWorking: (k: string, v: any) => { working[k] = v; } } };
|
|
44
|
-
const tool = createTodoTool(fakeAgent);
|
|
45
|
-
const out = String(await tool.handler!({ items: '[{"text":"步骤一","status":"active"}]' }));
|
|
46
|
-
expect(out).toContain("✓ 任务清单 0/1");
|
|
47
|
-
expect(working[TODO_WORKING_KEY]).toEqual([{ text: "步骤一", status: "active" }]);
|
|
48
|
-
expect(String(await tool.handler!({ items: "bad" }))).toContain("✗");
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("工具结果上下文保护", () => {
|
|
53
|
-
it("passes small results through untouched", () => {
|
|
54
|
-
expect(clampToolResult("short", 100)).toBe("short");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("clamps oversized results keeping head + tail with a hint", () => {
|
|
58
|
-
const big = "H".repeat(9000) + "M".repeat(9000) + "T".repeat(9000);
|
|
59
|
-
const out = clampToolResult(big, 12000);
|
|
60
|
-
expect(out.length).toBeLessThan(13000);
|
|
61
|
-
expect(out.startsWith("HHHH")).toBe(true);
|
|
62
|
-
expect(out.endsWith("TTTT")).toBe(true);
|
|
63
|
-
expect(out).toContain("中间省略");
|
|
64
|
-
expect(out).toContain("offset");
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("ToolRegistry 运行时统计", () => {
|
|
69
|
-
it("tracks calls / failures / cache hits / avg duration", async () => {
|
|
70
|
-
const reg = new ToolRegistry();
|
|
71
|
-
reg.register({
|
|
72
|
-
name: "ok_tool", description: "test tool", parameters: [], cacheable: true,
|
|
73
|
-
handler: async () => "fine",
|
|
74
|
-
});
|
|
75
|
-
reg.register({
|
|
76
|
-
name: "bad_tool", description: "failing test tool", parameters: [], maxRetries: 0,
|
|
77
|
-
handler: async () => { throw new Error("boom"); },
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
await reg.execute("ok_tool", { q: 1 });
|
|
81
|
-
await reg.execute("ok_tool", { q: 1 }); // cache hit
|
|
82
|
-
await reg.execute("bad_tool", {});
|
|
83
|
-
|
|
84
|
-
const stats = reg.getStats();
|
|
85
|
-
const ok = stats.find(s => s.name === "ok_tool")!;
|
|
86
|
-
expect(ok.calls).toBe(1);
|
|
87
|
-
expect(ok.cacheHits).toBe(1);
|
|
88
|
-
expect(ok.failures).toBe(0);
|
|
89
|
-
const bad = stats.find(s => s.name === "bad_tool")!;
|
|
90
|
-
expect(bad.calls).toBe(1);
|
|
91
|
-
expect(bad.failures).toBe(1);
|
|
92
|
-
expect(bad.breaker).toBeDefined();
|
|
93
|
-
});
|
|
94
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseTodoItems, renderTodoList, createTodoTool, TODO_WORKING_KEY } from "../src/tools/todo";
|
|
3
|
+
import { clampToolResult } from "../src/core/agent";
|
|
4
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
5
|
+
|
|
6
|
+
describe("todo_write 任务清单", () => {
|
|
7
|
+
it("parses a JSON array with status validation and defaults", () => {
|
|
8
|
+
const { items, error } = parseTodoItems('[{"text":"调研","status":"done"},{"text":"实现","status":"active"},{"text":"测试"}]');
|
|
9
|
+
expect(error).toBe("");
|
|
10
|
+
expect(items).toEqual([
|
|
11
|
+
{ text: "调研", status: "done" },
|
|
12
|
+
{ text: "实现", status: "active" },
|
|
13
|
+
{ text: "测试", status: "pending" },
|
|
14
|
+
]);
|
|
15
|
+
// plain string items are accepted as pending
|
|
16
|
+
expect(parseTodoItems('["a","b"]').items).toEqual([
|
|
17
|
+
{ text: "a", status: "pending" },
|
|
18
|
+
{ text: "b", status: "pending" },
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects malformed input", () => {
|
|
23
|
+
expect(parseTodoItems("not json").items).toBeNull();
|
|
24
|
+
expect(parseTodoItems('{"text":"x"}').items).toBeNull();
|
|
25
|
+
expect(parseTodoItems('[{"text":""}]').items).toBeNull();
|
|
26
|
+
expect(parseTodoItems(JSON.stringify(Array(25).fill({ text: "x" }))).items).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renderTodoList shows progress and per-item marks", () => {
|
|
30
|
+
const out = renderTodoList([
|
|
31
|
+
{ text: "调研", status: "done" },
|
|
32
|
+
{ text: "实现", status: "active" },
|
|
33
|
+
{ text: "测试", status: "pending" },
|
|
34
|
+
]);
|
|
35
|
+
expect(out).toContain("任务清单 1/3");
|
|
36
|
+
expect(out).toContain("✓ 调研");
|
|
37
|
+
expect(out).toContain("◐ 实现");
|
|
38
|
+
expect(out).toContain("· 测试");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("the tool stores the list in working memory (survives compaction)", async () => {
|
|
42
|
+
const working: Record<string, any> = {};
|
|
43
|
+
const fakeAgent = { memory: { setWorking: (k: string, v: any) => { working[k] = v; } } };
|
|
44
|
+
const tool = createTodoTool(fakeAgent);
|
|
45
|
+
const out = String(await tool.handler!({ items: '[{"text":"步骤一","status":"active"}]' }));
|
|
46
|
+
expect(out).toContain("✓ 任务清单 0/1");
|
|
47
|
+
expect(working[TODO_WORKING_KEY]).toEqual([{ text: "步骤一", status: "active" }]);
|
|
48
|
+
expect(String(await tool.handler!({ items: "bad" }))).toContain("✗");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("工具结果上下文保护", () => {
|
|
53
|
+
it("passes small results through untouched", () => {
|
|
54
|
+
expect(clampToolResult("short", 100)).toBe("short");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("clamps oversized results keeping head + tail with a hint", () => {
|
|
58
|
+
const big = "H".repeat(9000) + "M".repeat(9000) + "T".repeat(9000);
|
|
59
|
+
const out = clampToolResult(big, 12000);
|
|
60
|
+
expect(out.length).toBeLessThan(13000);
|
|
61
|
+
expect(out.startsWith("HHHH")).toBe(true);
|
|
62
|
+
expect(out.endsWith("TTTT")).toBe(true);
|
|
63
|
+
expect(out).toContain("中间省略");
|
|
64
|
+
expect(out).toContain("offset");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("ToolRegistry 运行时统计", () => {
|
|
69
|
+
it("tracks calls / failures / cache hits / avg duration", async () => {
|
|
70
|
+
const reg = new ToolRegistry();
|
|
71
|
+
reg.register({
|
|
72
|
+
name: "ok_tool", description: "test tool", parameters: [], cacheable: true,
|
|
73
|
+
handler: async () => "fine",
|
|
74
|
+
});
|
|
75
|
+
reg.register({
|
|
76
|
+
name: "bad_tool", description: "failing test tool", parameters: [], maxRetries: 0,
|
|
77
|
+
handler: async () => { throw new Error("boom"); },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await reg.execute("ok_tool", { q: 1 });
|
|
81
|
+
await reg.execute("ok_tool", { q: 1 }); // cache hit
|
|
82
|
+
await reg.execute("bad_tool", {});
|
|
83
|
+
|
|
84
|
+
const stats = reg.getStats();
|
|
85
|
+
const ok = stats.find(s => s.name === "ok_tool")!;
|
|
86
|
+
expect(ok.calls).toBe(1);
|
|
87
|
+
expect(ok.cacheHits).toBe(1);
|
|
88
|
+
expect(ok.failures).toBe(0);
|
|
89
|
+
const bad = stats.find(s => s.name === "bad_tool")!;
|
|
90
|
+
expect(bad.calls).toBe(1);
|
|
91
|
+
expect(bad.failures).toBe(1);
|
|
92
|
+
expect(bad.breaker).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Tracer, traceTotals, renderTrace } from "../src/core/trace";
|
|
3
|
+
|
|
4
|
+
/** A controllable clock so span timings are deterministic. */
|
|
5
|
+
function clock() {
|
|
6
|
+
let t = 0;
|
|
7
|
+
return { now: () => t, advance: (ms: number) => { t += ms; } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("Tracer", () => {
|
|
11
|
+
it("builds a nested span tree with correct parentage and timing", () => {
|
|
12
|
+
const c = clock();
|
|
13
|
+
const tr = new Tracer({ now: c.now });
|
|
14
|
+
const root = tr.startTrace("fix the bug", "fog");
|
|
15
|
+
c.advance(10);
|
|
16
|
+
const llm = tr.startSpan("chat", "llm", { model: "gpt-4o" });
|
|
17
|
+
c.advance(100);
|
|
18
|
+
llm.set({ promptTokens: 1200, completionTokens: 300, cost: 0.02 });
|
|
19
|
+
llm.end("ok");
|
|
20
|
+
const tool = tr.startSpan("read_file", "tool");
|
|
21
|
+
c.advance(25);
|
|
22
|
+
tool.end("ok");
|
|
23
|
+
root.end("ok");
|
|
24
|
+
const trace = tr.endTrace()!;
|
|
25
|
+
|
|
26
|
+
expect(trace.spans).toHaveLength(3);
|
|
27
|
+
const rootSpan = trace.spans.find((s) => s.kind === "turn")!;
|
|
28
|
+
const llmSpan = trace.spans.find((s) => s.kind === "llm")!;
|
|
29
|
+
const toolSpan = trace.spans.find((s) => s.kind === "tool")!;
|
|
30
|
+
expect(llmSpan.parentId).toBe(rootSpan.id);
|
|
31
|
+
expect(toolSpan.parentId).toBe(rootSpan.id); // sibling of llm, both under root
|
|
32
|
+
expect(llmSpan.endMs! - llmSpan.startMs).toBe(100);
|
|
33
|
+
expect(toolSpan.endMs! - toolSpan.startMs).toBe(25);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("deeply nests spans by stack order", () => {
|
|
37
|
+
const c = clock();
|
|
38
|
+
const tr = new Tracer({ now: c.now });
|
|
39
|
+
const root = tr.startTrace("t");
|
|
40
|
+
const a = tr.startSpan("orchestrate", "orchestration");
|
|
41
|
+
const b = tr.startSpan("subtask", "task");
|
|
42
|
+
b.end();
|
|
43
|
+
a.end();
|
|
44
|
+
root.end();
|
|
45
|
+
const trace = tr.endTrace()!;
|
|
46
|
+
const rootSpan = trace.spans[0];
|
|
47
|
+
const orch = trace.spans.find((s) => s.kind === "orchestration")!;
|
|
48
|
+
const task = trace.spans.find((s) => s.kind === "task")!;
|
|
49
|
+
expect(orch.parentId).toBe(rootSpan.id);
|
|
50
|
+
expect(task.parentId).toBe(orch.id);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("end() is idempotent and endTrace closes dangling spans", () => {
|
|
54
|
+
const c = clock();
|
|
55
|
+
const tr = new Tracer({ now: c.now });
|
|
56
|
+
tr.startTrace("t");
|
|
57
|
+
const s = tr.startSpan("hang", "tool");
|
|
58
|
+
c.advance(5);
|
|
59
|
+
s.end("ok");
|
|
60
|
+
const firstEnd = s.span!.endMs;
|
|
61
|
+
c.advance(50);
|
|
62
|
+
s.end("error"); // second call ignored
|
|
63
|
+
expect(s.span!.endMs).toBe(firstEnd);
|
|
64
|
+
expect(s.span!.status).toBe("ok");
|
|
65
|
+
|
|
66
|
+
const open = tr.startSpan("never-ended", "tool"); // left open on purpose
|
|
67
|
+
void open;
|
|
68
|
+
const trace = tr.endTrace()!;
|
|
69
|
+
const dangling = trace.spans.find((x) => x.name === "never-ended")!;
|
|
70
|
+
expect(dangling.endMs).not.toBeNull();
|
|
71
|
+
expect(dangling.status).toBe("ok");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("aggregates tokens, cost and status counts", () => {
|
|
75
|
+
const c = clock();
|
|
76
|
+
const tr = new Tracer({ now: c.now });
|
|
77
|
+
tr.startTrace("t");
|
|
78
|
+
tr.startSpan("llm1", "llm", { promptTokens: 100, completionTokens: 50, cost: 0.01 }).end("ok");
|
|
79
|
+
tr.startSpan("llm2", "llm", { totalTokens: 200, cost: 0.02 }).end("ok");
|
|
80
|
+
tr.startSpan("tool1", "tool").end("error", { error: "boom" });
|
|
81
|
+
c.advance(0);
|
|
82
|
+
const trace = tr.endTrace()!;
|
|
83
|
+
const tot = traceTotals(trace);
|
|
84
|
+
expect(tot.llmCalls).toBe(2);
|
|
85
|
+
expect(tot.toolCalls).toBe(1);
|
|
86
|
+
expect(tot.errors).toBe(1);
|
|
87
|
+
expect(tot.totalTokens).toBe(150 + 200);
|
|
88
|
+
expect(tot.cost).toBeCloseTo(0.03, 6);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps a ring buffer of recent traces", () => {
|
|
92
|
+
const tr = new Tracer({ maxFinished: 3 });
|
|
93
|
+
for (let i = 0; i < 5; i++) { tr.startTrace(`t${i}`); tr.endTrace(); }
|
|
94
|
+
const recent = tr.recent(10);
|
|
95
|
+
expect(recent).toHaveLength(3);
|
|
96
|
+
expect(recent.map((t) => t.label)).toEqual(["t2", "t3", "t4"]);
|
|
97
|
+
expect(tr.last()!.label).toBe("t4");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("a disabled tracer is a no-op and never throws", () => {
|
|
101
|
+
const tr = new Tracer({ enabled: false });
|
|
102
|
+
const root = tr.startTrace("t");
|
|
103
|
+
root.set({ x: 1 }).end();
|
|
104
|
+
tr.startSpan("s", "tool").end();
|
|
105
|
+
expect(tr.endTrace()).toBeNull();
|
|
106
|
+
expect(tr.last()).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders a readable tree with totals", () => {
|
|
110
|
+
const c = clock();
|
|
111
|
+
const tr = new Tracer({ now: c.now });
|
|
112
|
+
tr.startTrace("ship feature", "rain");
|
|
113
|
+
c.advance(5);
|
|
114
|
+
const llm = tr.startSpan("chat", "llm", { model: "gpt-4o" });
|
|
115
|
+
c.advance(120);
|
|
116
|
+
llm.end("ok", { promptTokens: 1000, completionTokens: 200, cost: 0.015 });
|
|
117
|
+
tr.startSpan("write_file", "tool").end("error", { error: "disk full" });
|
|
118
|
+
const trace = tr.endTrace()!;
|
|
119
|
+
const out = renderTrace(trace);
|
|
120
|
+
expect(out).toContain("ship feature");
|
|
121
|
+
expect(out).toContain("llm:chat");
|
|
122
|
+
expect(out).toContain("gpt-4o");
|
|
123
|
+
expect(out).toContain("tool:write_file");
|
|
124
|
+
expect(out).toContain("✗");
|
|
125
|
+
expect(out).toContain("disk full");
|
|
126
|
+
expect(out).toMatch(/1 llm · 1 tool · 1200 tokens/);
|
|
127
|
+
});
|
|
128
|
+
});
|
package/tests/tui.test.ts
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { charWidth, visualWidth, padVisual, StreamRenderer } from "../src/cli/tui";
|
|
3
|
-
|
|
4
|
-
describe("CJK-aware width", () => {
|
|
5
|
-
it("counts ascii as 1, CJK as 2", () => {
|
|
6
|
-
expect(charWidth("a".codePointAt(0)!)).toBe(1);
|
|
7
|
-
expect(charWidth("雾".codePointAt(0)!)).toBe(2);
|
|
8
|
-
expect(charWidth(",".codePointAt(0)!)).toBe(2); // fullwidth comma
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("treats control chars as width 0", () => {
|
|
12
|
-
expect(charWidth("\r".codePointAt(0)!)).toBe(0);
|
|
13
|
-
expect(charWidth("\n".codePointAt(0)!)).toBe(0);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("visualWidth sums correctly and ignores ANSI", () => {
|
|
17
|
-
expect(visualWidth("abc")).toBe(3);
|
|
18
|
-
expect(visualWidth("雾雨")).toBe(4);
|
|
19
|
-
expect(visualWidth("a雾b")).toBe(4);
|
|
20
|
-
expect(visualWidth("\x1b[36m雾\x1b[39m")).toBe(2); // color codes don't count
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("padVisual pads to a visual column count", () => {
|
|
24
|
-
expect(visualWidth(padVisual("雾", 6))).toBe(6);
|
|
25
|
-
expect(padVisual("abc", 2)).toBe("abc"); // never truncates
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
/** Capture writes from a StreamRenderer into a string. */
|
|
30
|
-
function render(text: string, columns = 40, chunk = 3): string {
|
|
31
|
-
let buf = "";
|
|
32
|
-
const fakeOut = { columns, write: (s: string) => { buf += s; return true; } } as any;
|
|
33
|
-
const r = new StreamRenderer(fakeOut, { gutter: " " });
|
|
34
|
-
for (let i = 0; i < text.length; i += chunk) r.write(text.slice(i, i + chunk));
|
|
35
|
-
r.flush();
|
|
36
|
-
return buf;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
describe("StreamRenderer", () => {
|
|
40
|
-
it("prefixes every line with the gutter", () => {
|
|
41
|
-
const out = render("hello world", 80);
|
|
42
|
-
expect(out.startsWith(" ")).toBe(true);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("never exceeds the content width per visual line", () => {
|
|
46
|
-
const out = render("天空织机是一个本地优先的多智能体终端框架用于验证换行宽度限制是否生效啊", 40);
|
|
47
|
-
const maxContent = Math.min(40 - 2 - 1, 96);
|
|
48
|
-
for (const line of out.split("\n")) {
|
|
49
|
-
expect(visualWidth(line)).toBeLessThanOrEqual(2 + maxContent); // gutter + content
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("strips stray carriage returns (CRLF from providers)", () => {
|
|
54
|
-
const out = render("line one\r\nline two", 80);
|
|
55
|
-
expect(out.includes("\r")).toBe(false);
|
|
56
|
-
expect(out).toContain("line one");
|
|
57
|
-
expect(out).toContain("line two");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("wraps English on word boundaries without splitting short words", () => {
|
|
61
|
-
// maxCols floors at 32, so use text long enough to exceed it.
|
|
62
|
-
const out = render("alpha beta gamma delta epsilon zeta eta theta iota kappa", 40);
|
|
63
|
-
expect(out.split("\n").length).toBeGreaterThan(1);
|
|
64
|
-
// no whole word should be broken across a wrap (each appears intact)
|
|
65
|
-
for (const w of ["alpha", "epsilon", "kappa"]) expect(out).toContain(w);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { charWidth, visualWidth, padVisual, StreamRenderer } from "../src/cli/tui";
|
|
3
|
+
|
|
4
|
+
describe("CJK-aware width", () => {
|
|
5
|
+
it("counts ascii as 1, CJK as 2", () => {
|
|
6
|
+
expect(charWidth("a".codePointAt(0)!)).toBe(1);
|
|
7
|
+
expect(charWidth("雾".codePointAt(0)!)).toBe(2);
|
|
8
|
+
expect(charWidth(",".codePointAt(0)!)).toBe(2); // fullwidth comma
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("treats control chars as width 0", () => {
|
|
12
|
+
expect(charWidth("\r".codePointAt(0)!)).toBe(0);
|
|
13
|
+
expect(charWidth("\n".codePointAt(0)!)).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("visualWidth sums correctly and ignores ANSI", () => {
|
|
17
|
+
expect(visualWidth("abc")).toBe(3);
|
|
18
|
+
expect(visualWidth("雾雨")).toBe(4);
|
|
19
|
+
expect(visualWidth("a雾b")).toBe(4);
|
|
20
|
+
expect(visualWidth("\x1b[36m雾\x1b[39m")).toBe(2); // color codes don't count
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("padVisual pads to a visual column count", () => {
|
|
24
|
+
expect(visualWidth(padVisual("雾", 6))).toBe(6);
|
|
25
|
+
expect(padVisual("abc", 2)).toBe("abc"); // never truncates
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Capture writes from a StreamRenderer into a string. */
|
|
30
|
+
function render(text: string, columns = 40, chunk = 3): string {
|
|
31
|
+
let buf = "";
|
|
32
|
+
const fakeOut = { columns, write: (s: string) => { buf += s; return true; } } as any;
|
|
33
|
+
const r = new StreamRenderer(fakeOut, { gutter: " " });
|
|
34
|
+
for (let i = 0; i < text.length; i += chunk) r.write(text.slice(i, i + chunk));
|
|
35
|
+
r.flush();
|
|
36
|
+
return buf;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("StreamRenderer", () => {
|
|
40
|
+
it("prefixes every line with the gutter", () => {
|
|
41
|
+
const out = render("hello world", 80);
|
|
42
|
+
expect(out.startsWith(" ")).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never exceeds the content width per visual line", () => {
|
|
46
|
+
const out = render("天空织机是一个本地优先的多智能体终端框架用于验证换行宽度限制是否生效啊", 40);
|
|
47
|
+
const maxContent = Math.min(40 - 2 - 1, 96);
|
|
48
|
+
for (const line of out.split("\n")) {
|
|
49
|
+
expect(visualWidth(line)).toBeLessThanOrEqual(2 + maxContent); // gutter + content
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("strips stray carriage returns (CRLF from providers)", () => {
|
|
54
|
+
const out = render("line one\r\nline two", 80);
|
|
55
|
+
expect(out.includes("\r")).toBe(false);
|
|
56
|
+
expect(out).toContain("line one");
|
|
57
|
+
expect(out).toContain("line two");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("wraps English on word boundaries without splitting short words", () => {
|
|
61
|
+
// maxCols floors at 32, so use text long enough to exceed it.
|
|
62
|
+
const out = render("alpha beta gamma delta epsilon zeta eta theta iota kappa", 40);
|
|
63
|
+
expect(out.split("\n").length).toBeGreaterThan(1);
|
|
64
|
+
// no whole word should be broken across a wrap (each appears intact)
|
|
65
|
+
for (const w of ["alpha", "epsilon", "kappa"]) expect(out).toContain(w);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from "vitest";
|
|
2
|
+
import { escapeHtml, highlightCode, mdInline, mdToHtml } from "../src/web/markdown";
|
|
3
|
+
import { renderInkWashUI, AGENTS_META } from "../src/web/ui";
|
|
4
|
+
|
|
5
|
+
/* ════════ markdown renderer (isomorphic, injected into the page) ════════ */
|
|
6
|
+
|
|
7
|
+
describe("web · markdown renderer", () => {
|
|
8
|
+
it("escapes HTML in all text paths (no XSS through content)", () => {
|
|
9
|
+
expect(escapeHtml('<img src=x onerror=alert(1)>')).not.toContain("<img");
|
|
10
|
+
expect(mdToHtml('hello <script>alert(1)</script>')).not.toContain("<script");
|
|
11
|
+
expect(mdToHtml('`<b>code</b>`')).toContain("<b>");
|
|
12
|
+
expect(mdToHtml('# <svg/onload=x>')).not.toContain("<svg");
|
|
13
|
+
// link text + URL are escaped; javascript: URLs are never linkified
|
|
14
|
+
expect(mdToHtml('[x](javascript:alert(1))')).not.toContain("<a");
|
|
15
|
+
expect(mdToHtml('["><img>](https://e.com)')).not.toContain("<img");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("renders inline markdown: bold, italic, code, strikethrough, links", () => {
|
|
19
|
+
const h = mdInline("**粗** *斜* `code` ~~删~~ [链接](https://example.com)");
|
|
20
|
+
expect(h).toContain("<strong>粗</strong>");
|
|
21
|
+
expect(h).toContain("<em>斜</em>");
|
|
22
|
+
expect(h).toContain("<code>code</code>");
|
|
23
|
+
expect(h).toContain("<del>删</del>");
|
|
24
|
+
expect(h).toContain('href="https://example.com"');
|
|
25
|
+
expect(h).toContain('rel="noopener noreferrer"');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders block markdown: headings, lists, quote, hr, table", () => {
|
|
29
|
+
const h = mdToHtml("# 标题\n\n- 甲\n- 乙\n\n1. 一\n2. 二\n\n> 引用\n\n---\n\n| A | B |\n|---|---|\n| 1 | 2 |");
|
|
30
|
+
expect(h).toContain('class="md-h md-h1"');
|
|
31
|
+
expect(h).toContain("<ul><li>甲</li><li>乙</li></ul>");
|
|
32
|
+
expect(h).toContain("<ol><li>一</li><li>二</li></ol>");
|
|
33
|
+
expect(h).toContain("<blockquote>引用</blockquote>");
|
|
34
|
+
expect(h).toContain("<hr>");
|
|
35
|
+
expect(h).toContain("<th>A</th>");
|
|
36
|
+
expect(h).toContain("<td>2</td>");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders fenced code blocks with language tag, copy button, and highlighting", () => {
|
|
40
|
+
const h = mdToHtml("```ts\nconst x = 'hi' // note\n```");
|
|
41
|
+
expect(h).toContain('class="cb-lang">ts<');
|
|
42
|
+
expect(h).toContain('class="cb-copy"');
|
|
43
|
+
expect(h).toContain('<span class="tk-k">const</span>');
|
|
44
|
+
expect(h).toContain(`<span class="tk-s">'hi'</span>`);
|
|
45
|
+
expect(h).toContain('tk-c'); // comment token
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("tolerates an unclosed fence (mid-stream rendering)", () => {
|
|
49
|
+
const h = mdToHtml("说明\n```py\nprint(1)");
|
|
50
|
+
expect(h).toContain("<p>说明</p>");
|
|
51
|
+
expect(h).toContain("print");
|
|
52
|
+
expect(h).toContain("codeblock");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses #-comments for python/shell and //-comments for C-likes", () => {
|
|
56
|
+
expect(highlightCode("# note", "py")).toContain("tk-c");
|
|
57
|
+
expect(highlightCode("// note", "ts")).toContain("tk-c");
|
|
58
|
+
expect(highlightCode("# not a comment", "ts")).not.toContain("tk-c");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("escapes content inside code blocks", () => {
|
|
62
|
+
const h = mdToHtml("```html\n<div onclick=x>\n```");
|
|
63
|
+
expect(h).not.toContain("<div onclick");
|
|
64
|
+
expect(h).toContain("<div");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/* ════════ page integrity ════════ */
|
|
69
|
+
|
|
70
|
+
describe("web · page", () => {
|
|
71
|
+
const html = renderInkWashUI();
|
|
72
|
+
const script = (html.match(/<script>([\s\S]*?)<\/script>/) || [])[1] || "";
|
|
73
|
+
|
|
74
|
+
it("injected client script is valid standalone JS with no module artifacts", () => {
|
|
75
|
+
expect(script.length).toBeGreaterThan(1000);
|
|
76
|
+
expect(() => new Function(script)).not.toThrow();
|
|
77
|
+
for (const bad of ["exports.", "require(", "Object.defineProperty(exports"]) {
|
|
78
|
+
expect(script, `leaked module artifact: ${bad}`).not.toContain(bad);
|
|
79
|
+
}
|
|
80
|
+
for (const fn of ["function escapeHtml", "function highlightCode", "function mdInline", "function mdToHtml", "function clientMain"]) {
|
|
81
|
+
expect(script).toContain(fn);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ships the enterprise interaction surface", () => {
|
|
86
|
+
// stop-generation, theme toggle, export/clear, shortcuts, scroll pill, toasts
|
|
87
|
+
for (const marker of ["send-btn", "theme-btn", "export-btn", "clear-btn", "keys-modal", "scroll-pill", "toasts", "AbortController", "localStorage"]) {
|
|
88
|
+
expect(html, `missing: ${marker}`).toContain(marker);
|
|
89
|
+
}
|
|
90
|
+
// tool timeline + reasoning + markdown body classes exist in CSS
|
|
91
|
+
for (const cls of [".tool-row", ".think", ".codeblock", ".md-table", ".caret", ".welcome"]) {
|
|
92
|
+
expect(html, `missing css: ${cls}`).toContain(cls);
|
|
93
|
+
}
|
|
94
|
+
// dark mode tokens
|
|
95
|
+
expect(html).toContain("[data-theme=dark]");
|
|
96
|
+
expect(html).toContain("prefers-reduced-motion");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("shortcut labels are OS-aware, not hardcoded to macOS", () => {
|
|
100
|
+
// no ⌘ baked into the static HTML/hint — labels are filled at boot
|
|
101
|
+
const staticHtml = html.replace(/<script>[\s\S]*?<\/script>/, "");
|
|
102
|
+
expect(staticHtml).not.toContain("⌘");
|
|
103
|
+
// the client detects Apple platforms and picks per-platform modifiers:
|
|
104
|
+
// ⌘ on Apple; Alt+digit (Ctrl+digit is browser-reserved) and Ctrl+K elsewhere
|
|
105
|
+
expect(script).toContain("isApple");
|
|
106
|
+
expect(script).toMatch(/isApple \? .⌘. : .Alt\+./);
|
|
107
|
+
expect(script).toMatch(/isApple \? .⌘. : .Ctrl\+./);
|
|
108
|
+
expect(script).toContain("localizeShortcuts");
|
|
109
|
+
// physical-key matching so macOS Option+digit (¡™£…) still works
|
|
110
|
+
expect(script).toContain("Digit[1-6]");
|
|
111
|
+
expect(script).toContain("e.altKey");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("includes all six agents with light+dark pigments and suggestions", () => {
|
|
115
|
+
expect(AGENTS_META).toHaveLength(6);
|
|
116
|
+
for (const a of AGENTS_META) {
|
|
117
|
+
expect(a.light).toMatch(/^#[0-9a-f]{6}$/i);
|
|
118
|
+
expect(a.dark).toMatch(/^#[0-9a-f]{6}$/i);
|
|
119
|
+
expect(a.tips.length).toBeGreaterThanOrEqual(3);
|
|
120
|
+
expect(html).toContain(a.kanji);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/* ════════ live server integration ════════ */
|
|
126
|
+
|
|
127
|
+
describe("web · server", () => {
|
|
128
|
+
let close: (() => void) | null = null;
|
|
129
|
+
afterAll(() => { if (close) close(); });
|
|
130
|
+
|
|
131
|
+
it("serves the UI and the JSON API; rejects bad requests", async () => {
|
|
132
|
+
const { startWebServer } = await import("../src/web/server");
|
|
133
|
+
const port = 3789 + Math.floor(Math.random() * 1000);
|
|
134
|
+
await startWebServer(port);
|
|
135
|
+
|
|
136
|
+
const page = await fetch(`http://127.0.0.1:${port}/`);
|
|
137
|
+
expect(page.status).toBe(200);
|
|
138
|
+
const body = await page.text();
|
|
139
|
+
expect(body).toContain("水墨气象台");
|
|
140
|
+
expect(body).toContain("clientMain()");
|
|
141
|
+
|
|
142
|
+
const agents = await fetch(`http://127.0.0.1:${port}/api/agents`);
|
|
143
|
+
expect(agents.status).toBe(200);
|
|
144
|
+
const aj: any = await agents.json();
|
|
145
|
+
// agent construction is environment-dependent (needs provider config);
|
|
146
|
+
// the contract here is the response shape, not the roster
|
|
147
|
+
expect(Array.isArray(aj.agents)).toBe(true);
|
|
148
|
+
|
|
149
|
+
const status = await fetch(`http://127.0.0.1:${port}/api/status`);
|
|
150
|
+
expect(status.status).toBe(200);
|
|
151
|
+
|
|
152
|
+
const bad = await fetch(`http://127.0.0.1:${port}/api/chat`, {
|
|
153
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: "{}",
|
|
154
|
+
});
|
|
155
|
+
expect(bad.status).toBe(400);
|
|
156
|
+
|
|
157
|
+
// Host-header guard: a rebound/evil Host is refused on loopback binding.
|
|
158
|
+
// (fetch/undici silently drops a Host override, so use raw http.)
|
|
159
|
+
const http = await import("http");
|
|
160
|
+
const evilStatus = await new Promise<number>((resolve, reject) => {
|
|
161
|
+
const r = http.request(
|
|
162
|
+
{ host: "127.0.0.1", port, path: "/api/agents", headers: { Host: "evil.example.com" } },
|
|
163
|
+
(resp) => { resp.resume(); resolve(resp.statusCode || 0); });
|
|
164
|
+
r.on("error", reject);
|
|
165
|
+
r.end();
|
|
166
|
+
});
|
|
167
|
+
expect(evilStatus).toBe(403);
|
|
168
|
+
}, 15000);
|
|
169
|
+
});
|