skyloom 1.13.5 → 1.13.7
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 +36 -36
- package/README.md +220 -159
- package/config/providers.yaml +39 -39
- package/config/skills/api_integrator/SKILL.md +15 -15
- package/config/skills/arch_designer/SKILL.md +13 -13
- package/config/skills/ci_cd_manager/SKILL.md +14 -14
- package/config/skills/code_analysis/SKILL.md +13 -13
- package/config/skills/code_generator/SKILL.md +12 -12
- package/config/skills/code_reviewer/SKILL.md +13 -13
- package/config/skills/content_writer/SKILL.md +14 -14
- package/config/skills/data_transformer/SKILL.md +15 -15
- package/config/skills/document_analysis/SKILL.md +13 -13
- package/config/skills/emotional_companion/SKILL.md +15 -15
- package/config/skills/performance_checker/SKILL.md +14 -14
- package/config/skills/security_auditor/SKILL.md +14 -14
- package/config/skills/self_evolve/SKILL.md +13 -13
- package/config/skills/sys_operator/SKILL.md +15 -15
- package/config/skills/task_planner/SKILL.md +14 -14
- package/config/skills/web_research/SKILL.md +14 -14
- package/config/skills/workflow_designer/SKILL.md +13 -13
- package/dist/agents/dew.js +52 -52
- package/dist/agents/fair.js +84 -84
- package/dist/agents/fog.js +30 -30
- package/dist/agents/frost.js +32 -32
- package/dist/agents/rain.js +32 -32
- package/dist/agents/snow.js +68 -68
- package/dist/cli/commands_md.d.ts +41 -0
- package/dist/cli/commands_md.d.ts.map +1 -0
- package/dist/cli/commands_md.js +140 -0
- package/dist/cli/commands_md.js.map +1 -0
- package/dist/cli/input_macros.d.ts +28 -0
- package/dist/cli/input_macros.d.ts.map +1 -0
- package/dist/cli/input_macros.js +120 -0
- package/dist/cli/input_macros.js.map +1 -0
- package/dist/cli/loom.d.ts +220 -0
- package/dist/cli/loom.d.ts.map +1 -0
- package/dist/cli/loom.js +1094 -0
- package/dist/cli/loom.js.map +1 -0
- package/dist/cli/loom_chat.d.ts +20 -0
- package/dist/cli/loom_chat.d.ts.map +1 -0
- package/dist/cli/loom_chat.js +685 -0
- package/dist/cli/loom_chat.js.map +1 -0
- package/dist/cli/main.js +310 -14
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +7 -1
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/agent/guard.d.ts +45 -0
- package/dist/core/agent/guard.d.ts.map +1 -0
- package/dist/core/agent/guard.js +113 -0
- package/dist/core/agent/guard.js.map +1 -0
- package/dist/core/agent.d.ts +17 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +182 -93
- package/dist/core/agent.js.map +1 -1
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +34 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/file_checkpoint.d.ts +57 -0
- package/dist/core/file_checkpoint.d.ts.map +1 -0
- package/dist/core/file_checkpoint.js +162 -0
- package/dist/core/file_checkpoint.js.map +1 -0
- package/dist/core/hooks.d.ts +43 -0
- package/dist/core/hooks.d.ts.map +1 -0
- package/dist/core/hooks.js +110 -0
- package/dist/core/hooks.js.map +1 -0
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +15 -9
- package/dist/core/llm.js.map +1 -1
- package/dist/core/longdoc.js +5 -5
- package/dist/core/mcp.d.ts +16 -0
- package/dist/core/mcp.d.ts.map +1 -1
- package/dist/core/mcp.js +55 -0
- package/dist/core/mcp.js.map +1 -1
- package/dist/core/model_config.d.ts +40 -0
- package/dist/core/model_config.d.ts.map +1 -0
- package/dist/core/model_config.js +191 -0
- package/dist/core/model_config.js.map +1 -0
- package/dist/core/skill.d.ts +7 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +47 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/skymd.d.ts +39 -0
- package/dist/core/skymd.d.ts.map +1 -0
- package/dist/core/skymd.js +177 -0
- package/dist/core/skymd.js.map +1 -0
- package/dist/core/tool.d.ts +12 -0
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +30 -0
- package/dist/core/tool.js.map +1 -1
- package/dist/core/verify.d.ts +27 -0
- package/dist/core/verify.d.ts.map +1 -0
- package/dist/core/verify.js +62 -0
- package/dist/core/verify.js.map +1 -0
- package/dist/skills/loader.d.ts +22 -2
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +45 -15
- package/dist/skills/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +13 -3
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/model_tool.d.ts +11 -0
- package/dist/tools/model_tool.d.ts.map +1 -0
- package/dist/tools/model_tool.js +71 -0
- package/dist/tools/model_tool.js.map +1 -0
- package/dist/tools/todo.d.ts +30 -0
- package/dist/tools/todo.d.ts.map +1 -0
- package/dist/tools/todo.js +78 -0
- package/dist/tools/todo.js.map +1 -0
- package/docs/AESTHETIC_DESIGN.md +152 -144
- package/docs/OPTIMIZATION_PLAN.md +178 -178
- package/package.json +1 -1
- package/scripts/install.js +48 -48
- package/scripts/link.js +10 -10
- package/setup.bat +79 -79
- package/skill-test-ty2fOA/test.md +10 -10
- package/src/agents/dew.ts +70 -70
- package/src/agents/fair.ts +102 -102
- package/src/agents/fog.ts +48 -48
- package/src/agents/frost.ts +50 -50
- package/src/agents/rain.ts +50 -50
- package/src/agents/snow.ts +239 -239
- package/src/cli/commands_md.ts +112 -0
- package/src/cli/input_macros.ts +83 -0
- package/src/cli/loom.ts +982 -0
- package/src/cli/loom_chat.ts +598 -0
- package/src/cli/main.ts +255 -9
- package/src/cli/mode.ts +58 -58
- package/src/cli/tui.ts +228 -222
- package/src/core/agent/guard.ts +134 -0
- package/src/core/agent/task.ts +100 -100
- package/src/core/agent.ts +177 -95
- package/src/core/arbitrate.ts +162 -162
- package/src/core/catalog.ts +178 -178
- package/src/core/checkpoint.ts +94 -94
- package/src/core/estimate.ts +104 -104
- package/src/core/evolve.ts +191 -191
- package/src/core/factory.ts +31 -2
- package/src/core/file_checkpoint.ts +136 -0
- package/src/core/filter.ts +103 -103
- package/src/core/graph.ts +156 -156
- package/src/core/hooks.ts +126 -0
- package/src/core/icons.ts +53 -53
- package/src/core/index.ts +37 -37
- package/src/core/learn.ts +146 -146
- package/src/core/llm.ts +15 -9
- package/src/core/longdoc.ts +155 -155
- package/src/core/mcp.ts +48 -0
- package/src/core/mcp_server.ts +176 -176
- package/src/core/model_config.ts +157 -0
- package/src/core/profile.ts +255 -255
- package/src/core/router.ts +124 -124
- package/src/core/sandbox.ts +142 -142
- package/src/core/security.ts +243 -243
- package/src/core/skill.ts +42 -0
- package/src/core/skymd.ts +143 -0
- package/src/core/theme.ts +65 -65
- package/src/core/tool.ts +30 -0
- package/src/core/tool_router.ts +193 -193
- package/src/core/vector.ts +152 -152
- package/src/core/verify.ts +71 -0
- package/src/core/workspace.ts +150 -150
- package/src/plugins/loader.ts +66 -66
- package/src/skills/loader.ts +45 -16
- package/src/sql.js.d.ts +29 -29
- package/src/tools/builtin.ts +13 -3
- package/src/tools/computer.ts +269 -269
- package/src/tools/delegate.ts +49 -49
- package/src/tools/model_tool.ts +74 -0
- package/src/tools/todo.ts +76 -0
- package/src/web/tts.ts +93 -93
- package/tests/agent.test.ts +159 -159
- package/tests/agent_helpers.test.ts +48 -48
- package/tests/bus.test.ts +121 -121
- package/tests/catalog.test.ts +86 -86
- package/tests/checkpoint_commands.test.ts +124 -0
- package/tests/claude_compat.test.ts +110 -0
- package/tests/config.test.ts +41 -41
- package/tests/guard.test.ts +75 -0
- package/tests/icons.test.ts +45 -45
- package/tests/loom.test.ts +248 -0
- package/tests/memory.test.ts +170 -170
- package/tests/model_config.test.ts +109 -0
- package/tests/router.test.ts +86 -86
- package/tests/schemas.test.ts +51 -51
- package/tests/semantic.test.ts +83 -83
- package/tests/setup.ts +10 -10
- package/tests/skill.test.ts +172 -172
- package/tests/skymd.test.ts +146 -0
- package/tests/task.test.ts +60 -60
- package/tests/todo_toolstats.test.ts +94 -0
- package/tests/tool.test.ts +108 -108
- package/tests/tool_router.test.ts +71 -71
- package/tests/tui.test.ts +67 -67
- package/vitest.config.ts +17 -17
package/tests/config.test.ts
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { mergeConfigs } from "../src/core/config";
|
|
3
|
-
|
|
4
|
-
describe("mergeConfigs", () => {
|
|
5
|
-
it("preserves top-level default_model / default_provider (regression)", () => {
|
|
6
|
-
// Previously these were dropped, so the wizard's chosen model never applied.
|
|
7
|
-
const def: any = { agents: { fog: { temperature: 0.7 } }, llm: { default_model: "gpt-4o" } };
|
|
8
|
-
const user: any = { default_model: "deepseek-v4-flash", default_provider: "deepseek" };
|
|
9
|
-
const merged: any = mergeConfigs(def, user);
|
|
10
|
-
expect(merged.default_model).toBe("deepseek-v4-flash");
|
|
11
|
-
expect(merged.default_provider).toBe("deepseek");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("deep-merges the llm block with the user winning", () => {
|
|
15
|
-
const def: any = { agents: {}, llm: { default_model: "gpt-4o", temperature: 0.7 } };
|
|
16
|
-
const user: any = { llm: { default_model: "deepseek-chat" } };
|
|
17
|
-
const merged: any = mergeConfigs(def, user);
|
|
18
|
-
expect(merged.llm.default_model).toBe("deepseek-chat");
|
|
19
|
-
expect(merged.llm.temperature).toBe(0.7); // preserved from default
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("merges agents (user overrides, defaults retained)", () => {
|
|
23
|
-
const def: any = { agents: { fog: { temperature: 0.7 }, rain: { temperature: 0.5 } } };
|
|
24
|
-
const user: any = { agents: { fog: { model: "deepseek-v4-pro", temperature: 0.2 } } };
|
|
25
|
-
const merged: any = mergeConfigs(def, user);
|
|
26
|
-
expect(merged.agents.fog.model).toBe("deepseek-v4-pro");
|
|
27
|
-
expect(merged.agents.rain.temperature).toBe(0.5);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("returns the default config unchanged when no user config", () => {
|
|
31
|
-
const def: any = { agents: { fog: {} }, default_model: "gpt-4o" };
|
|
32
|
-
expect(mergeConfigs(def, null)).toBe(def);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("preserves passthrough top-level blocks (memory, workspace)", () => {
|
|
36
|
-
const def: any = { agents: {}, memory: { short_term_limit: 100 }, workspace: { path: "auto" } };
|
|
37
|
-
const merged: any = mergeConfigs(def, { agents: {} } as any);
|
|
38
|
-
expect(merged.memory.short_term_limit).toBe(100);
|
|
39
|
-
expect(merged.workspace.path).toBe("auto");
|
|
40
|
-
});
|
|
41
|
-
});
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeConfigs } from "../src/core/config";
|
|
3
|
+
|
|
4
|
+
describe("mergeConfigs", () => {
|
|
5
|
+
it("preserves top-level default_model / default_provider (regression)", () => {
|
|
6
|
+
// Previously these were dropped, so the wizard's chosen model never applied.
|
|
7
|
+
const def: any = { agents: { fog: { temperature: 0.7 } }, llm: { default_model: "gpt-4o" } };
|
|
8
|
+
const user: any = { default_model: "deepseek-v4-flash", default_provider: "deepseek" };
|
|
9
|
+
const merged: any = mergeConfigs(def, user);
|
|
10
|
+
expect(merged.default_model).toBe("deepseek-v4-flash");
|
|
11
|
+
expect(merged.default_provider).toBe("deepseek");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("deep-merges the llm block with the user winning", () => {
|
|
15
|
+
const def: any = { agents: {}, llm: { default_model: "gpt-4o", temperature: 0.7 } };
|
|
16
|
+
const user: any = { llm: { default_model: "deepseek-chat" } };
|
|
17
|
+
const merged: any = mergeConfigs(def, user);
|
|
18
|
+
expect(merged.llm.default_model).toBe("deepseek-chat");
|
|
19
|
+
expect(merged.llm.temperature).toBe(0.7); // preserved from default
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("merges agents (user overrides, defaults retained)", () => {
|
|
23
|
+
const def: any = { agents: { fog: { temperature: 0.7 }, rain: { temperature: 0.5 } } };
|
|
24
|
+
const user: any = { agents: { fog: { model: "deepseek-v4-pro", temperature: 0.2 } } };
|
|
25
|
+
const merged: any = mergeConfigs(def, user);
|
|
26
|
+
expect(merged.agents.fog.model).toBe("deepseek-v4-pro");
|
|
27
|
+
expect(merged.agents.rain.temperature).toBe(0.5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns the default config unchanged when no user config", () => {
|
|
31
|
+
const def: any = { agents: { fog: {} }, default_model: "gpt-4o" };
|
|
32
|
+
expect(mergeConfigs(def, null)).toBe(def);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("preserves passthrough top-level blocks (memory, workspace)", () => {
|
|
36
|
+
const def: any = { agents: {}, memory: { short_term_limit: 100 }, workspace: { path: "auto" } };
|
|
37
|
+
const merged: any = mergeConfigs(def, { agents: {} } as any);
|
|
38
|
+
expect(merged.memory.short_term_limit).toBe(100);
|
|
39
|
+
expect(merged.workspace.path).toBe("auto");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { LoopGuard } from "../src/core/agent/guard";
|
|
3
|
+
|
|
4
|
+
function toolCall(name: string, args: any = {}) {
|
|
5
|
+
return { id: "c" + Math.random(), type: "function", function: { name, arguments: JSON.stringify(args) } } as any;
|
|
6
|
+
}
|
|
7
|
+
const ok = (name: string) => ({ toolName: name, success: true, result: "ok" });
|
|
8
|
+
const fail = (name: string) => ({ toolName: name, success: false, result: "Error: boom" });
|
|
9
|
+
|
|
10
|
+
describe("LoopGuard", () => {
|
|
11
|
+
it("no hints / no stop for a normal round", () => {
|
|
12
|
+
const g = new LoopGuard();
|
|
13
|
+
const d = g.observe("hello", [toolCall("read", { path: "a" })], [ok("read")]);
|
|
14
|
+
expect(d.hints).toEqual([]);
|
|
15
|
+
expect(d.stop).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("hard-stops when the same tool signature repeats past the threshold", () => {
|
|
19
|
+
const g = new LoopGuard();
|
|
20
|
+
let last: any;
|
|
21
|
+
for (let i = 0; i < 12; i++) last = g.observe("", [toolCall("spin", { n: 1 })], [ok("spin")]);
|
|
22
|
+
expect(last.stop).toBeDefined();
|
|
23
|
+
expect(last.stop.contentLine).toContain("repeated");
|
|
24
|
+
expect(last.stop.note).toContain("Stopping");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("injects a tool-loop hint once before hard-stopping", () => {
|
|
28
|
+
const g = new LoopGuard();
|
|
29
|
+
const hintRounds: string[][] = [];
|
|
30
|
+
for (let i = 0; i < 12; i++) hintRounds.push(g.observe("", [toolCall("spin", { n: 1 })], [ok("spin")]).hints);
|
|
31
|
+
const allHints = hintRounds.flat();
|
|
32
|
+
expect(allHints.filter((h) => h.includes("[Tool loop]")).length).toBe(1); // once only
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("flags a narration loop when responses are near-identical", () => {
|
|
36
|
+
const g = new LoopGuard();
|
|
37
|
+
const text = "我正在分析这个问题并准备给出答案,请稍候。";
|
|
38
|
+
g.observe(text, [], []);
|
|
39
|
+
const d = g.observe(text, [], []); // same content again
|
|
40
|
+
expect(d.hints.some((h) => h.includes("Stop narrating"))).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("injects a recovery hint when most recent tool calls failed", () => {
|
|
44
|
+
const g = new LoopGuard();
|
|
45
|
+
const hints: string[] = [];
|
|
46
|
+
for (let i = 0; i < 5; i++) hints.push(...g.observe("", [toolCall("t" + i)], [fail("t" + i)]).hints);
|
|
47
|
+
expect(hints.some((h) => h.includes("[Recovery hint]"))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("hard-stops when the last 8 tool calls all failed", () => {
|
|
51
|
+
const g = new LoopGuard();
|
|
52
|
+
let last: any;
|
|
53
|
+
// distinct tool names so the signature-loop guard doesn't fire first
|
|
54
|
+
for (let i = 0; i < 8; i++) last = g.observe("", [toolCall("t" + i, { i })], [fail("t" + i)]);
|
|
55
|
+
expect(last.stop).toBeDefined();
|
|
56
|
+
expect(last.stop.contentLine).toContain("every recent tool call failed");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("hard-stops on a search storm (>=12 distinct search calls)", () => {
|
|
60
|
+
const g = new LoopGuard();
|
|
61
|
+
let last: any;
|
|
62
|
+
// cycle distinct search tools so the signature-loop guard doesn't fire first
|
|
63
|
+
const tools = ["web_search", "fetch_page", "http_get"];
|
|
64
|
+
for (let i = 0; i < 12; i++) last = g.observe("", [toolCall(tools[i % 3], { q: "x" + i })], [ok(tools[i % 3])]);
|
|
65
|
+
expect(last.stop).toBeDefined();
|
|
66
|
+
expect(last.stop.contentLine).toContain("excessive web searching");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ignores task_done in signature counting (never hard-stops on it)", () => {
|
|
70
|
+
const g = new LoopGuard();
|
|
71
|
+
let last: any;
|
|
72
|
+
for (let i = 0; i < 15; i++) last = g.observe("", [toolCall("task_done", {})], [ok("task_done")]);
|
|
73
|
+
expect(last.stop).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
});
|
package/tests/icons.test.ts
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for agent icon system.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect } from 'vitest';
|
|
5
|
-
import { AGENT_COLORS, AGENT_EMOJI, iconText, svgPath } from '../src/core/icons';
|
|
6
|
-
|
|
7
|
-
describe('AGENT_COLORS', () => {
|
|
8
|
-
it('has all 6 agents', () => {
|
|
9
|
-
expect(Object.keys(AGENT_COLORS).sort()).toEqual(['dew', 'fair', 'fog', 'frost', 'rain', 'snow']);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('each agent has a non-empty color', () => {
|
|
13
|
-
for (const color of Object.values(AGENT_COLORS)) {
|
|
14
|
-
expect(color).toBeTruthy();
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe('AGENT_EMOJI', () => {
|
|
20
|
-
it('has all 6 agents', () => {
|
|
21
|
-
expect(Object.keys(AGENT_EMOJI).sort()).toEqual(['dew', 'fair', 'fog', 'frost', 'rain', 'snow']);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('unique emoji per agent', () => {
|
|
25
|
-
const values = Object.values(AGENT_EMOJI);
|
|
26
|
-
expect(new Set(values).size).toBe(values.length);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('iconText', () => {
|
|
31
|
-
it('returns glyph for known agent', () => {
|
|
32
|
-
expect(iconText('fog')).toBe('≋');
|
|
33
|
-
expect(iconText('fair')).toBe('☼');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('returns name as fallback for unknown agent', () => {
|
|
37
|
-
expect(iconText('unknown')).toBe('unknown');
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe('svgPath', () => {
|
|
42
|
-
it('returns a path ending with .svg', () => {
|
|
43
|
-
expect(svgPath('fog')).toContain('icons');
|
|
44
|
-
});
|
|
45
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for agent icon system.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { AGENT_COLORS, AGENT_EMOJI, iconText, svgPath } from '../src/core/icons';
|
|
6
|
+
|
|
7
|
+
describe('AGENT_COLORS', () => {
|
|
8
|
+
it('has all 6 agents', () => {
|
|
9
|
+
expect(Object.keys(AGENT_COLORS).sort()).toEqual(['dew', 'fair', 'fog', 'frost', 'rain', 'snow']);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('each agent has a non-empty color', () => {
|
|
13
|
+
for (const color of Object.values(AGENT_COLORS)) {
|
|
14
|
+
expect(color).toBeTruthy();
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('AGENT_EMOJI', () => {
|
|
20
|
+
it('has all 6 agents', () => {
|
|
21
|
+
expect(Object.keys(AGENT_EMOJI).sort()).toEqual(['dew', 'fair', 'fog', 'frost', 'rain', 'snow']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('unique emoji per agent', () => {
|
|
25
|
+
const values = Object.values(AGENT_EMOJI);
|
|
26
|
+
expect(new Set(values).size).toBe(values.length);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('iconText', () => {
|
|
31
|
+
it('returns glyph for known agent', () => {
|
|
32
|
+
expect(iconText('fog')).toBe('≋');
|
|
33
|
+
expect(iconText('fair')).toBe('☼');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns name as fallback for unknown agent', () => {
|
|
37
|
+
expect(iconText('unknown')).toBe('unknown');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('svgPath', () => {
|
|
42
|
+
it('returns a path ending with .svg', () => {
|
|
43
|
+
expect(svgPath('fog')).toContain('icons');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { visualWidth } from "../src/cli/tui";
|
|
3
|
+
import {
|
|
4
|
+
cutVisual, padAnsi, wrapPlain, Screen, mountainRow, overlay, circled,
|
|
5
|
+
LoomUI, OrchState,
|
|
6
|
+
} from "../src/cli/loom";
|
|
7
|
+
|
|
8
|
+
const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
9
|
+
|
|
10
|
+
describe("ANSI-aware helpers", () => {
|
|
11
|
+
it("cutVisual truncates by visual width, keeping ANSI", () => {
|
|
12
|
+
expect(strip(cutVisual("hello", 3))).toBe("hel");
|
|
13
|
+
expect(strip(cutVisual("雾雨霜", 4))).toBe("雾雨");
|
|
14
|
+
expect(strip(cutVisual("雾雨霜", 5))).toBe("雾雨"); // can't split a wide glyph
|
|
15
|
+
const styled = "\x1b[36m雾雨\x1b[39m";
|
|
16
|
+
expect(visualWidth(cutVisual(styled, 2))).toBe(2);
|
|
17
|
+
expect(cutVisual(styled, 2)).toContain("\x1b[36m");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("padAnsi pads to exact visual width", () => {
|
|
21
|
+
expect(visualWidth(padAnsi("雾", 6))).toBe(6);
|
|
22
|
+
expect(visualWidth(padAnsi("雾雨霜雪露晴", 4))).toBe(4); // truncates
|
|
23
|
+
expect(visualWidth(padAnsi("\x1b[31mab\x1b[0m", 5))).toBe(5);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("wrapPlain wraps CJK per glyph and latin per word", () => {
|
|
27
|
+
expect(wrapPlain("雾雨霜雪", 4)).toEqual(["雾雨", "霜雪"]);
|
|
28
|
+
expect(wrapPlain("alpha beta", 6)).toEqual(["alpha", "beta"]);
|
|
29
|
+
expect(wrapPlain("", 10)).toEqual([""]);
|
|
30
|
+
for (const ln of wrapPlain("混合 mixed 文本 with 中英 words", 10)) {
|
|
31
|
+
expect(visualWidth(ln)).toBeLessThanOrEqual(10);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("wrapPlain hard-breaks unbroken monster tokens", () => {
|
|
36
|
+
const lines = wrapPlain("a".repeat(25), 10);
|
|
37
|
+
expect(lines.every((l) => visualWidth(l) <= 10)).toBe(true);
|
|
38
|
+
expect(lines.join("")).toBe("a".repeat(25));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("overlay puts top glyphs over base, fixed width", () => {
|
|
42
|
+
const res = overlay("▁▁▁▁▁▁", " ≋ ", 6);
|
|
43
|
+
const plain = strip(res);
|
|
44
|
+
expect(plain).toContain("≋");
|
|
45
|
+
expect(plain).toContain("▁");
|
|
46
|
+
expect(visualWidth(res)).toBe(6);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("circled maps indices to ①②…", () => {
|
|
50
|
+
expect(circled(0)).toBe("①");
|
|
51
|
+
expect(circled(5)).toBe("⑥");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("Screen diff renderer", () => {
|
|
56
|
+
it("repaints only changed rows", () => {
|
|
57
|
+
let buf = "";
|
|
58
|
+
const out = { write: (s: string) => { buf += s; return true; } };
|
|
59
|
+
const sc = new Screen(out);
|
|
60
|
+
sc.flush(["aaa", "bbb", "ccc"], null);
|
|
61
|
+
buf = "";
|
|
62
|
+
sc.flush(["aaa", "BBB", "ccc"], null);
|
|
63
|
+
expect(buf).toContain("BBB");
|
|
64
|
+
expect(buf).not.toContain("aaa");
|
|
65
|
+
expect(buf).not.toContain("ccc");
|
|
66
|
+
expect(buf).toContain("\x1b[2;1H"); // row 2 repainted in place
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("clears rows that disappear when the frame shrinks", () => {
|
|
70
|
+
let buf = "";
|
|
71
|
+
const out = { write: (s: string) => { buf += s; return true; } };
|
|
72
|
+
const sc = new Screen(out);
|
|
73
|
+
sc.flush(["a", "b", "c"], null);
|
|
74
|
+
buf = "";
|
|
75
|
+
sc.flush(["a"], null);
|
|
76
|
+
expect(buf).toContain("\x1b[2;1H\x1b[2K");
|
|
77
|
+
expect(buf).toContain("\x1b[3;1H\x1b[2K");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("mountainRow", () => {
|
|
82
|
+
it("is deterministic and fills the width", () => {
|
|
83
|
+
const a = mountainRow(40, 5);
|
|
84
|
+
const b = mountainRow(40, 5);
|
|
85
|
+
expect(a).toBe(b);
|
|
86
|
+
expect(visualWidth(a)).toBe(40);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("grows with the session", () => {
|
|
90
|
+
const young = strip(mountainRow(60, 0));
|
|
91
|
+
const old = strip(mountainRow(60, 30));
|
|
92
|
+
const mass = (s: string) => [...s].reduce((n, c) => n + " ▁▂▃▄▅".indexOf(c), 0);
|
|
93
|
+
expect(mass(old)).toBeGreaterThan(mass(young));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("OrchState (multi-agent dynamics)", () => {
|
|
98
|
+
const plan = [
|
|
99
|
+
{ id: "t1", assignedTo: "fog", description: "调研", allDeps: [] },
|
|
100
|
+
{ id: "t2", assignedTo: "rain", description: "起草", allDeps: ["t1"] },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
it("tracks plan → start → done with per-agent tallies", () => {
|
|
104
|
+
const o = new OrchState();
|
|
105
|
+
o.plan(plan);
|
|
106
|
+
expect(o.progress()).toEqual({ done: 0, total: 2 });
|
|
107
|
+
o.start("t1");
|
|
108
|
+
expect(o.runningAgents()).toEqual(["fog"]);
|
|
109
|
+
expect(o.tally("fog").run).toBe(true);
|
|
110
|
+
o.done("t1", true);
|
|
111
|
+
expect(o.tally("fog")).toEqual({ ok: 1, fail: 0, run: false });
|
|
112
|
+
o.start("t2");
|
|
113
|
+
o.done("t2", false);
|
|
114
|
+
expect(o.tally("rain").fail).toBe(1);
|
|
115
|
+
expect(o.progress()).toEqual({ done: 2, total: 2 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("moves shuttles only while an agent is weaving", () => {
|
|
119
|
+
const o = new OrchState();
|
|
120
|
+
o.plan(plan);
|
|
121
|
+
o.start("t1");
|
|
122
|
+
expect(o.shuttleX.has("fog")).toBe(true);
|
|
123
|
+
o.done("t1", true);
|
|
124
|
+
expect(o.shuttleX.has("fog")).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/** Headless LoomUI on a fake 80×24 terminal. */
|
|
129
|
+
function makeUI(cols = 80, rows = 24) {
|
|
130
|
+
const out = { columns: cols, rows, isTTY: false, write: (_: string) => true };
|
|
131
|
+
const ui = new LoomUI({ out, inp: null, headless: true });
|
|
132
|
+
ui.start();
|
|
133
|
+
return ui;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("LoomUI frame composition", () => {
|
|
137
|
+
it("every row is exactly terminal width; frame is exactly terminal height", () => {
|
|
138
|
+
const ui = makeUI(80, 24);
|
|
139
|
+
ui.text("你好,世界。Hello world.");
|
|
140
|
+
ui.line("a styled line");
|
|
141
|
+
const frame = ui.paint();
|
|
142
|
+
expect(frame.length).toBe(24);
|
|
143
|
+
for (const row of frame) expect(visualWidth(row)).toBe(80);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("streams into the open block and shows the agent header", () => {
|
|
147
|
+
const ui = makeUI();
|
|
148
|
+
ui.beginStream("rain");
|
|
149
|
+
ui.streamWrite("江南可采莲,");
|
|
150
|
+
ui.streamWrite("莲叶何田田。");
|
|
151
|
+
const frame = ui.paint().map(strip).join("\n");
|
|
152
|
+
expect(frame).toContain("雨");
|
|
153
|
+
expect(frame).toContain("江南可采莲,莲叶何田田。");
|
|
154
|
+
ui.endStream();
|
|
155
|
+
for (const row of ui.paint()) expect(visualWidth(row)).toBe(80);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("renders the weave chart and rail badges during orchestration", () => {
|
|
159
|
+
const ui = makeUI(100, 30);
|
|
160
|
+
ui.orch.plan([
|
|
161
|
+
{ id: "t1", assignedTo: "fog", description: "调研竞品", allDeps: [] },
|
|
162
|
+
{ id: "t2", assignedTo: "frost", description: "审校", allDeps: ["t1"] },
|
|
163
|
+
]);
|
|
164
|
+
ui.orch.start("t1");
|
|
165
|
+
ui.line(" ≋ ① 霧 调研竞品", "task-t1");
|
|
166
|
+
ui.line(" · ② 霜 审校 ←①", "task-t2");
|
|
167
|
+
const frame = ui.paint();
|
|
168
|
+
expect(frame.length).toBe(30);
|
|
169
|
+
for (const row of frame) expect(visualWidth(row)).toBe(100);
|
|
170
|
+
const text = frame.map(strip).join("\n");
|
|
171
|
+
expect(text).toContain("调研竞品");
|
|
172
|
+
expect(text).toContain("①");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("updates line blocks in place by id", () => {
|
|
176
|
+
const ui = makeUI();
|
|
177
|
+
ui.line("· task running", "task-x");
|
|
178
|
+
ui.update("task-x", "✓ task done");
|
|
179
|
+
const text = ui.paint().map(strip).join("\n");
|
|
180
|
+
expect(text).toContain("✓ task done");
|
|
181
|
+
expect(text).not.toContain("task running");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("falls back to a notice on tiny terminals", () => {
|
|
185
|
+
const ui = makeUI(40, 8);
|
|
186
|
+
const frame = ui.paint();
|
|
187
|
+
expect(strip(frame[0])).toContain("窗口太小");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("survives many turns and long text without width drift", () => {
|
|
191
|
+
const ui = makeUI(72, 20);
|
|
192
|
+
for (let i = 0; i < 50; i++) {
|
|
193
|
+
ui.text(`第 ${i} 轮:混排 latin text 和中文,外加长串 ${"x".repeat(90)}`);
|
|
194
|
+
ui.turns++;
|
|
195
|
+
}
|
|
196
|
+
for (const row of ui.paint()) expect(visualWidth(row)).toBe(72);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("palette ↑↓ navigation + Enter execution", () => {
|
|
201
|
+
function key(ui: any, name: string, opts: Record<string, any> = {}) {
|
|
202
|
+
ui.onKey(opts.str ?? "", { name, ...opts });
|
|
203
|
+
}
|
|
204
|
+
function type(ui: any, text: string) {
|
|
205
|
+
for (const ch of text) ui.onKey(ch, { name: ch });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
it("Enter runs the ↑↓-highlighted command", async () => {
|
|
209
|
+
const ui = makeUI() as any;
|
|
210
|
+
const p = ui.readInput();
|
|
211
|
+
type(ui, "/");
|
|
212
|
+
key(ui, "down"); // /fog → /rain
|
|
213
|
+
key(ui, "down"); // /rain → /frost
|
|
214
|
+
key(ui, "return");
|
|
215
|
+
expect(await p).toBe("/frost");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("selection can scroll past the visible window", () => {
|
|
219
|
+
const ui = makeUI() as any;
|
|
220
|
+
type(ui, "/");
|
|
221
|
+
const total = ui.paletteMatches().length;
|
|
222
|
+
for (let i = 0; i < total + 5; i++) key(ui, "down");
|
|
223
|
+
expect(ui.paletteIdx).toBe(total - 1); // clamped to last, beyond first 8
|
|
224
|
+
expect(total).toBeGreaterThan(8);
|
|
225
|
+
for (const row of ui.paint()) expect(visualWidth(row)).toBe(80);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("argument-taking commands fill the input instead of submitting", () => {
|
|
229
|
+
const ui = makeUI() as any;
|
|
230
|
+
let resolved: string | null = null;
|
|
231
|
+
ui.readInput().then((s: string) => { resolved = s; });
|
|
232
|
+
type(ui, "/task");
|
|
233
|
+
key(ui, "return");
|
|
234
|
+
expect(ui.inputGlyphs.join("")).toBe("/task ");
|
|
235
|
+
expect(resolved).toBeNull(); // not submitted — waiting for arguments
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("typing resets the selection; Esc closes the palette", () => {
|
|
239
|
+
const ui = makeUI() as any;
|
|
240
|
+
type(ui, "/");
|
|
241
|
+
key(ui, "down");
|
|
242
|
+
expect(ui.paletteIdx).toBe(1);
|
|
243
|
+
type(ui, "c"); // filter change
|
|
244
|
+
expect(ui.paletteIdx).toBe(0);
|
|
245
|
+
key(ui, "escape");
|
|
246
|
+
expect(ui.inputGlyphs.length).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|