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.
Files changed (157) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/publish.yml +74 -0
  3. package/CONVERSION_PLAN.md +191 -191
  4. package/README.md +523 -220
  5. package/config/default.yaml +46 -43
  6. package/config/models.yaml +928 -155
  7. package/config/providers.yaml +109 -6
  8. package/dist/agents/snow.d.ts +2 -0
  9. package/dist/agents/snow.d.ts.map +1 -1
  10. package/dist/agents/snow.js +36 -5
  11. package/dist/agents/snow.js.map +1 -1
  12. package/dist/cli/loom_chat.d.ts.map +1 -1
  13. package/dist/cli/loom_chat.js +207 -1
  14. package/dist/cli/loom_chat.js.map +1 -1
  15. package/dist/cli/main.js +190 -40
  16. package/dist/cli/main.js.map +1 -1
  17. package/dist/cli/tui.d.ts.map +1 -1
  18. package/dist/cli/tui.js +6 -31
  19. package/dist/cli/tui.js.map +1 -1
  20. package/dist/core/agent.d.ts +6 -4
  21. package/dist/core/agent.d.ts.map +1 -1
  22. package/dist/core/agent.js +61 -20
  23. package/dist/core/agent.js.map +1 -1
  24. package/dist/core/catalog.d.ts.map +1 -1
  25. package/dist/core/catalog.js +30 -9
  26. package/dist/core/catalog.js.map +1 -1
  27. package/dist/core/commands.d.ts +110 -0
  28. package/dist/core/commands.d.ts.map +1 -0
  29. package/dist/core/commands.js +633 -0
  30. package/dist/core/commands.js.map +1 -0
  31. package/dist/core/concurrency.d.ts +38 -0
  32. package/dist/core/concurrency.d.ts.map +1 -0
  33. package/dist/core/concurrency.js +65 -0
  34. package/dist/core/concurrency.js.map +1 -0
  35. package/dist/core/factory.js +16 -16
  36. package/dist/core/file_checkpoint.d.ts +9 -0
  37. package/dist/core/file_checkpoint.d.ts.map +1 -1
  38. package/dist/core/file_checkpoint.js +33 -1
  39. package/dist/core/file_checkpoint.js.map +1 -1
  40. package/dist/core/llm.d.ts.map +1 -1
  41. package/dist/core/llm.js +66 -13
  42. package/dist/core/llm.js.map +1 -1
  43. package/dist/core/memory.js +51 -51
  44. package/dist/core/schemas.d.ts +16 -0
  45. package/dist/core/schemas.d.ts.map +1 -1
  46. package/dist/core/schemas.js +32 -0
  47. package/dist/core/schemas.js.map +1 -1
  48. package/dist/core/security.d.ts.map +1 -1
  49. package/dist/core/security.js +27 -0
  50. package/dist/core/security.js.map +1 -1
  51. package/dist/core/skymd.js +14 -14
  52. package/dist/core/trace.d.ts +105 -0
  53. package/dist/core/trace.d.ts.map +1 -0
  54. package/dist/core/trace.js +213 -0
  55. package/dist/core/trace.js.map +1 -0
  56. package/dist/tools/builtin.d.ts +2 -6
  57. package/dist/tools/builtin.d.ts.map +1 -1
  58. package/dist/tools/builtin.js +180 -125
  59. package/dist/tools/builtin.js.map +1 -1
  60. package/dist/tools/extra.d.ts +13 -0
  61. package/dist/tools/extra.d.ts.map +1 -0
  62. package/dist/tools/extra.js +827 -0
  63. package/dist/tools/extra.js.map +1 -0
  64. package/dist/tools/guards.d.ts +12 -0
  65. package/dist/tools/guards.d.ts.map +1 -0
  66. package/dist/tools/guards.js +143 -0
  67. package/dist/tools/guards.js.map +1 -0
  68. package/dist/tools/model_tool.d.ts.map +1 -1
  69. package/dist/tools/model_tool.js +24 -4
  70. package/dist/tools/model_tool.js.map +1 -1
  71. package/dist/web/markdown.d.ts +32 -0
  72. package/dist/web/markdown.d.ts.map +1 -0
  73. package/dist/web/markdown.js +202 -0
  74. package/dist/web/markdown.js.map +1 -0
  75. package/dist/web/server.d.ts +4 -0
  76. package/dist/web/server.d.ts.map +1 -1
  77. package/dist/web/server.js +14 -582
  78. package/dist/web/server.js.map +1 -1
  79. package/dist/web/ui.d.ts +31 -0
  80. package/dist/web/ui.d.ts.map +1 -0
  81. package/dist/web/ui.js +1009 -0
  82. package/dist/web/ui.js.map +1 -0
  83. package/docs/AESTHETIC_DESIGN.md +152 -152
  84. package/docs/OPTIMIZATION_PLAN.md +178 -178
  85. package/package.json +68 -68
  86. package/src/agents/snow.ts +38 -5
  87. package/src/cli/commands_md.ts +112 -112
  88. package/src/cli/input_macros.ts +83 -83
  89. package/src/cli/loom.ts +1041 -1041
  90. package/src/cli/loom_chat.ts +772 -603
  91. package/src/cli/main.ts +853 -723
  92. package/src/cli/tui.ts +264 -289
  93. package/src/core/agent/guard.ts +133 -133
  94. package/src/core/agent/task.ts +100 -100
  95. package/src/core/agent.ts +1630 -1590
  96. package/src/core/agent_helpers.ts +500 -500
  97. package/src/core/bus.ts +221 -221
  98. package/src/core/cache.ts +153 -153
  99. package/src/core/catalog.ts +199 -178
  100. package/src/core/circuit_breaker.ts +119 -119
  101. package/src/core/commands.ts +704 -0
  102. package/src/core/concurrency.ts +73 -0
  103. package/src/core/config.ts +365 -365
  104. package/src/core/constants.ts +95 -95
  105. package/src/core/factory.ts +656 -656
  106. package/src/core/file_checkpoint.ts +163 -136
  107. package/src/core/hooks.ts +126 -126
  108. package/src/core/llm.ts +972 -915
  109. package/src/core/logger.ts +143 -143
  110. package/src/core/mcp.ts +1001 -1001
  111. package/src/core/memory.ts +1201 -1201
  112. package/src/core/middleware.ts +350 -350
  113. package/src/core/model_config.ts +159 -159
  114. package/src/core/pipelines.ts +424 -424
  115. package/src/core/schemas.ts +319 -282
  116. package/src/core/security.ts +27 -0
  117. package/src/core/semantic.ts +211 -211
  118. package/src/core/skill.ts +384 -384
  119. package/src/core/skymd.ts +143 -143
  120. package/src/core/theme.ts +65 -65
  121. package/src/core/tool.ts +457 -457
  122. package/src/core/trace.ts +236 -0
  123. package/src/core/verify.ts +71 -71
  124. package/src/plugins/loader.ts +91 -91
  125. package/src/skills/loader.ts +75 -75
  126. package/src/tools/builtin.ts +571 -493
  127. package/src/tools/computer.ts +279 -279
  128. package/src/tools/extra.ts +662 -0
  129. package/src/tools/guards.ts +82 -0
  130. package/src/tools/model_tool.ts +93 -74
  131. package/src/tools/todo.ts +76 -76
  132. package/src/web/markdown.ts +193 -0
  133. package/src/web/server.ts +117 -693
  134. package/src/web/ui.ts +949 -0
  135. package/tests/agent.test.ts +211 -159
  136. package/tests/agent_helpers.test.ts +48 -48
  137. package/tests/catalog.test.ts +86 -86
  138. package/tests/checkpoint_commands.test.ts +124 -124
  139. package/tests/claude_compat.test.ts +110 -110
  140. package/tests/commands.test.ts +103 -0
  141. package/tests/concurrency.test.ts +102 -0
  142. package/tests/config.test.ts +41 -41
  143. package/tests/extra_tools.test.ts +212 -0
  144. package/tests/fence_plugin.test.ts +52 -52
  145. package/tests/guard.test.ts +75 -75
  146. package/tests/loom.test.ts +337 -337
  147. package/tests/memory.test.ts +170 -170
  148. package/tests/model_config.test.ts +109 -109
  149. package/tests/skymd.test.ts +146 -146
  150. package/tests/ssrf.test.ts +38 -38
  151. package/tests/structured_retry.test.ts +87 -0
  152. package/tests/task.test.ts +60 -60
  153. package/tests/todo_toolstats.test.ts +94 -94
  154. package/tests/trace.test.ts +128 -0
  155. package/tests/tui.test.ts +67 -67
  156. package/tests/web.test.ts +169 -0
  157. 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("&lt;b&gt;");
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("&lt;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
+ });