skyloom 1.12.0 → 1.13.1

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 (137) hide show
  1. package/.github/workflows/ci.yml +36 -36
  2. package/README.md +137 -46
  3. package/config/default.yaml +43 -47
  4. package/config/models.yaml +155 -155
  5. package/config/providers.yaml +39 -39
  6. package/config/skills/api_integrator/SKILL.md +15 -15
  7. package/config/skills/arch_designer/SKILL.md +13 -13
  8. package/config/skills/ci_cd_manager/SKILL.md +14 -14
  9. package/config/skills/code_analysis/SKILL.md +13 -13
  10. package/config/skills/code_generator/SKILL.md +12 -12
  11. package/config/skills/code_reviewer/SKILL.md +13 -13
  12. package/config/skills/content_writer/SKILL.md +14 -14
  13. package/config/skills/data_transformer/SKILL.md +15 -15
  14. package/config/skills/document_analysis/SKILL.md +13 -13
  15. package/config/skills/emotional_companion/SKILL.md +15 -15
  16. package/config/skills/performance_checker/SKILL.md +14 -14
  17. package/config/skills/security_auditor/SKILL.md +14 -14
  18. package/config/skills/self_evolve/SKILL.md +13 -13
  19. package/config/skills/sys_operator/SKILL.md +15 -15
  20. package/config/skills/task_planner/SKILL.md +14 -14
  21. package/config/skills/web_research/SKILL.md +14 -14
  22. package/config/skills/workflow_designer/SKILL.md +13 -13
  23. package/dist/agents/dew.js +52 -52
  24. package/dist/agents/fair.js +84 -84
  25. package/dist/agents/fog.js +30 -30
  26. package/dist/agents/frost.js +32 -32
  27. package/dist/agents/rain.js +32 -32
  28. package/dist/agents/snow.js +68 -68
  29. package/dist/cli/main.js +127 -74
  30. package/dist/cli/main.js.map +1 -1
  31. package/dist/cli/tui.d.ts +52 -19
  32. package/dist/cli/tui.d.ts.map +1 -1
  33. package/dist/cli/tui.js +198 -265
  34. package/dist/cli/tui.js.map +1 -1
  35. package/dist/core/agent/task.d.ts +58 -0
  36. package/dist/core/agent/task.d.ts.map +1 -0
  37. package/dist/core/agent/task.js +83 -0
  38. package/dist/core/agent/task.js.map +1 -0
  39. package/dist/core/agent.d.ts +2 -45
  40. package/dist/core/agent.d.ts.map +1 -1
  41. package/dist/core/agent.js +61 -145
  42. package/dist/core/agent.js.map +1 -1
  43. package/dist/core/agent_helpers.d.ts +10 -0
  44. package/dist/core/agent_helpers.d.ts.map +1 -1
  45. package/dist/core/agent_helpers.js +39 -0
  46. package/dist/core/agent_helpers.js.map +1 -1
  47. package/dist/core/catalog.d.ts +71 -0
  48. package/dist/core/catalog.d.ts.map +1 -0
  49. package/dist/core/catalog.js +176 -0
  50. package/dist/core/catalog.js.map +1 -0
  51. package/dist/core/config.d.ts +8 -0
  52. package/dist/core/config.d.ts.map +1 -1
  53. package/dist/core/config.js +12 -4
  54. package/dist/core/config.js.map +1 -1
  55. package/dist/core/factory.js +16 -16
  56. package/dist/core/llm.d.ts +7 -0
  57. package/dist/core/llm.d.ts.map +1 -1
  58. package/dist/core/llm.js +139 -7
  59. package/dist/core/llm.js.map +1 -1
  60. package/dist/core/longdoc.js +5 -5
  61. package/dist/core/memory.d.ts.map +1 -1
  62. package/dist/core/memory.js +69 -62
  63. package/dist/core/memory.js.map +1 -1
  64. package/dist/core/theme.d.ts +46 -0
  65. package/dist/core/theme.d.ts.map +1 -0
  66. package/dist/core/theme.js +42 -0
  67. package/dist/core/theme.js.map +1 -0
  68. package/dist/web/server.js +542 -519
  69. package/dist/web/server.js.map +1 -1
  70. package/docs/AESTHETIC_DESIGN.md +144 -0
  71. package/docs/OPTIMIZATION_PLAN.md +178 -0
  72. package/package.json +60 -60
  73. package/scripts/install.js +48 -48
  74. package/scripts/link.js +10 -10
  75. package/setup.bat +79 -79
  76. package/skill-test-ty2fOA/test.md +10 -10
  77. package/src/agents/dew.ts +70 -70
  78. package/src/agents/fair.ts +102 -102
  79. package/src/agents/fog.ts +48 -48
  80. package/src/agents/frost.ts +50 -50
  81. package/src/agents/rain.ts +50 -50
  82. package/src/agents/snow.ts +239 -239
  83. package/src/cli/main.ts +417 -372
  84. package/src/cli/mode.ts +58 -58
  85. package/src/cli/tui.ts +174 -223
  86. package/src/core/agent/task.ts +100 -0
  87. package/src/core/agent.ts +1446 -1549
  88. package/src/core/agent_helpers.ts +496 -461
  89. package/src/core/arbitrate.ts +162 -162
  90. package/src/core/catalog.ts +178 -0
  91. package/src/core/checkpoint.ts +94 -94
  92. package/src/core/config.ts +20 -4
  93. package/src/core/estimate.ts +104 -104
  94. package/src/core/evolve.ts +191 -191
  95. package/src/core/factory.ts +627 -627
  96. package/src/core/filter.ts +103 -103
  97. package/src/core/graph.ts +156 -156
  98. package/src/core/icons.ts +53 -53
  99. package/src/core/index.ts +37 -37
  100. package/src/core/learn.ts +146 -146
  101. package/src/core/llm.ts +108 -5
  102. package/src/core/longdoc.ts +155 -155
  103. package/src/core/mcp_server.ts +176 -176
  104. package/src/core/memory.ts +1178 -1171
  105. package/src/core/profile.ts +255 -255
  106. package/src/core/router.ts +124 -124
  107. package/src/core/sandbox.ts +142 -142
  108. package/src/core/security.ts +243 -243
  109. package/src/core/skill.ts +342 -342
  110. package/src/core/theme.ts +65 -0
  111. package/src/core/tool_router.ts +193 -193
  112. package/src/core/vector.ts +152 -152
  113. package/src/core/workspace.ts +150 -150
  114. package/src/plugins/loader.ts +66 -66
  115. package/src/skills/loader.ts +46 -46
  116. package/src/sql.js.d.ts +29 -29
  117. package/src/tools/builtin.ts +380 -380
  118. package/src/tools/computer.ts +269 -269
  119. package/src/tools/delegate.ts +49 -49
  120. package/src/web/server.ts +660 -634
  121. package/src/web/tts.ts +93 -93
  122. package/tests/agent_helpers.test.ts +48 -0
  123. package/tests/bus.test.ts +121 -121
  124. package/tests/catalog.test.ts +86 -0
  125. package/tests/config.test.ts +41 -0
  126. package/tests/icons.test.ts +45 -45
  127. package/tests/memory.test.ts +147 -0
  128. package/tests/router.test.ts +86 -86
  129. package/tests/schemas.test.ts +51 -51
  130. package/tests/semantic.test.ts +83 -83
  131. package/tests/setup.ts +10 -10
  132. package/tests/skill.test.ts +172 -172
  133. package/tests/task.test.ts +60 -0
  134. package/tests/tool.test.ts +108 -108
  135. package/tests/tool_router.test.ts +71 -71
  136. package/tests/tui.test.ts +67 -0
  137. package/vitest.config.ts +17 -17
package/src/web/tts.ts CHANGED
@@ -1,93 +1,93 @@
1
- /**
2
- * Text-to-Speech integration using Volcano Engine (Doubao) TTS API.
3
- * Uses POST to https://openspeech.bytedance.com/api/v3/tts/unidirectional.
4
- */
5
-
6
- const HTTP_ENDPOINT = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
7
-
8
- export interface VoiceOption {
9
- key: string;
10
- name: string;
11
- desc: string;
12
- voiceType: string;
13
- }
14
-
15
- export const VOICE_CATALOG: VoiceOption[] = [
16
- { key: 'xiaohe', name: '小河', desc: '温柔自然女声', voiceType: 'zh_female_xiaohe_uranus_bigtts' },
17
- { key: 'qingxinnvsheng', name: '清新女声', desc: '清澈自然女声', voiceType: 'zh_female_qingxinnvsheng_uranus_bigtts' },
18
- { key: 'cancan', name: '灿灿', desc: '活力甜美少女音', voiceType: 'zh_female_cancan_uranus_bigtts' },
19
- { key: 'sajiaoxuemei', name: '撒娇雪梅', desc: '甜美撒娇少女音', voiceType: 'zh_female_sajiaoxuemei_uranus_bigtts' },
20
- { key: 'meilinvyou', name: '魅力女游', desc: '温柔魅力女声', voiceType: 'zh_female_meilinvyou_uranus_bigtts' },
21
- { key: 'xiaoshan', name: '小杉', desc: '温暖磁性男声', voiceType: 'zh_male_xiaoshan_uranus_bigtts' },
22
- ];
23
-
24
- export interface TTSOptions {
25
- text: string;
26
- voice?: string;
27
- speed?: number;
28
- pitch?: number;
29
- apiKey?: string;
30
- }
31
-
32
- export interface TTSResult {
33
- success: boolean;
34
- audioBase64?: string;
35
- format?: string;
36
- error?: string;
37
- }
38
-
39
- /**
40
- * Convert text to speech using Volcano Engine TTS API.
41
- */
42
- export async function textToSpeech(options: TTSOptions): Promise<TTSResult> {
43
- const { text, voice = 'zh_female_xiaohe_uranus_bigtts', speed = 1.0, pitch = 1.0 } = options;
44
-
45
- if (!text || !text.trim()) {
46
- return { success: false, error: 'Text is required' };
47
- }
48
-
49
- const apiKey = options.apiKey || process.env.VOLC_ACCESS_TOKEN;
50
- if (!apiKey) {
51
- return { success: false, error: 'API key not configured. Set VOLC_ACCESS_TOKEN or pass apiKey.' };
52
- }
53
-
54
- const payload = {
55
- app: { appid: process.env.VOLC_APP_ID || '' },
56
- user: { uid: 'skyloom' },
57
- request: {
58
- reqid: Math.random().toString(36).slice(2, 14),
59
- text,
60
- text_type: 'plain',
61
- operation: 'query',
62
- frontend_type: 'unitTson',
63
- voice: { voice_type: voice, speed_rate: speed, pitch_rate: pitch },
64
- audio: { audio_type: 'mp3' },
65
- },
66
- };
67
-
68
- try {
69
- const response = await fetch(HTTP_ENDPOINT, {
70
- method: 'POST',
71
- headers: {
72
- 'Content-Type': 'application/json',
73
- 'Authorization': `Bearer ${apiKey}`,
74
- },
75
- body: JSON.stringify(payload),
76
- });
77
-
78
- const data: any = await response.json();
79
- if (data.code !== 3000) {
80
- return { success: false, error: `API error: ${data.code} - ${data.message || 'unknown'}` };
81
- }
82
- return { success: true, audioBase64: data.data, format: 'mp3' };
83
- } catch (e: any) {
84
- return { success: false, error: `TTS request failed: ${e.message || e}` };
85
- }
86
- }
87
-
88
- /**
89
- * List available TTS voices.
90
- */
91
- export function listVoices(): VoiceOption[] {
92
- return [...VOICE_CATALOG];
93
- }
1
+ /**
2
+ * Text-to-Speech integration using Volcano Engine (Doubao) TTS API.
3
+ * Uses POST to https://openspeech.bytedance.com/api/v3/tts/unidirectional.
4
+ */
5
+
6
+ const HTTP_ENDPOINT = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
7
+
8
+ export interface VoiceOption {
9
+ key: string;
10
+ name: string;
11
+ desc: string;
12
+ voiceType: string;
13
+ }
14
+
15
+ export const VOICE_CATALOG: VoiceOption[] = [
16
+ { key: 'xiaohe', name: '小河', desc: '温柔自然女声', voiceType: 'zh_female_xiaohe_uranus_bigtts' },
17
+ { key: 'qingxinnvsheng', name: '清新女声', desc: '清澈自然女声', voiceType: 'zh_female_qingxinnvsheng_uranus_bigtts' },
18
+ { key: 'cancan', name: '灿灿', desc: '活力甜美少女音', voiceType: 'zh_female_cancan_uranus_bigtts' },
19
+ { key: 'sajiaoxuemei', name: '撒娇雪梅', desc: '甜美撒娇少女音', voiceType: 'zh_female_sajiaoxuemei_uranus_bigtts' },
20
+ { key: 'meilinvyou', name: '魅力女游', desc: '温柔魅力女声', voiceType: 'zh_female_meilinvyou_uranus_bigtts' },
21
+ { key: 'xiaoshan', name: '小杉', desc: '温暖磁性男声', voiceType: 'zh_male_xiaoshan_uranus_bigtts' },
22
+ ];
23
+
24
+ export interface TTSOptions {
25
+ text: string;
26
+ voice?: string;
27
+ speed?: number;
28
+ pitch?: number;
29
+ apiKey?: string;
30
+ }
31
+
32
+ export interface TTSResult {
33
+ success: boolean;
34
+ audioBase64?: string;
35
+ format?: string;
36
+ error?: string;
37
+ }
38
+
39
+ /**
40
+ * Convert text to speech using Volcano Engine TTS API.
41
+ */
42
+ export async function textToSpeech(options: TTSOptions): Promise<TTSResult> {
43
+ const { text, voice = 'zh_female_xiaohe_uranus_bigtts', speed = 1.0, pitch = 1.0 } = options;
44
+
45
+ if (!text || !text.trim()) {
46
+ return { success: false, error: 'Text is required' };
47
+ }
48
+
49
+ const apiKey = options.apiKey || process.env.VOLC_ACCESS_TOKEN;
50
+ if (!apiKey) {
51
+ return { success: false, error: 'API key not configured. Set VOLC_ACCESS_TOKEN or pass apiKey.' };
52
+ }
53
+
54
+ const payload = {
55
+ app: { appid: process.env.VOLC_APP_ID || '' },
56
+ user: { uid: 'skyloom' },
57
+ request: {
58
+ reqid: Math.random().toString(36).slice(2, 14),
59
+ text,
60
+ text_type: 'plain',
61
+ operation: 'query',
62
+ frontend_type: 'unitTson',
63
+ voice: { voice_type: voice, speed_rate: speed, pitch_rate: pitch },
64
+ audio: { audio_type: 'mp3' },
65
+ },
66
+ };
67
+
68
+ try {
69
+ const response = await fetch(HTTP_ENDPOINT, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'Authorization': `Bearer ${apiKey}`,
74
+ },
75
+ body: JSON.stringify(payload),
76
+ });
77
+
78
+ const data: any = await response.json();
79
+ if (data.code !== 3000) {
80
+ return { success: false, error: `API error: ${data.code} - ${data.message || 'unknown'}` };
81
+ }
82
+ return { success: true, audioBase64: data.data, format: 'mp3' };
83
+ } catch (e: any) {
84
+ return { success: false, error: `TTS request failed: ${e.message || e}` };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * List available TTS voices.
90
+ */
91
+ export function listVoices(): VoiceOption[] {
92
+ return [...VOICE_CATALOG];
93
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseExtractedFacts, synthesizeDelegationSummary } from "../src/core/agent_helpers";
3
+
4
+ describe("parseExtractedFacts", () => {
5
+ it("parses a raw JSON array", () => {
6
+ const out = parseExtractedFacts('[{"key":"lang","value":"ts"}]');
7
+ expect(out).toEqual([{ key: "lang", value: "ts" }]);
8
+ });
9
+
10
+ it("parses JSON inside a markdown fence", () => {
11
+ const out = parseExtractedFacts('```json\n[{"key":"a","value":1}]\n```');
12
+ expect(out).toEqual([{ key: "a", value: 1 }]);
13
+ });
14
+
15
+ it("extracts a JSON array embedded in prose", () => {
16
+ const out = parseExtractedFacts('Sure! Here are the facts: [{"key":"x","value":true}] done.');
17
+ expect(out).toEqual([{ key: "x", value: true }]);
18
+ });
19
+
20
+ it("returns [] for empty or non-JSON input", () => {
21
+ expect(parseExtractedFacts("")).toEqual([]);
22
+ expect(parseExtractedFacts("no json here")).toEqual([]);
23
+ expect(parseExtractedFacts(" ")).toEqual([]);
24
+ });
25
+
26
+ it("returns [] when JSON is an object, not an array", () => {
27
+ expect(parseExtractedFacts('{"key":"x"}')).toEqual([]);
28
+ });
29
+
30
+ it("filters out non-object array members", () => {
31
+ const out = parseExtractedFacts('[{"key":"a","value":1}, "junk", 42, null]');
32
+ // null is typeof 'object' in JS, so it survives the filter — assert the real members
33
+ expect(out).toContainEqual({ key: "a", value: 1 });
34
+ expect(out).not.toContain("junk");
35
+ expect(out).not.toContain(42);
36
+ });
37
+ });
38
+
39
+ describe("synthesizeDelegationSummary", () => {
40
+ it("summarizes successes and failures", () => {
41
+ expect(synthesizeDelegationSummary([["fog", true], ["rain", false]])).toBe(
42
+ "[Delegated: fog | Failed: rain]"
43
+ );
44
+ });
45
+ it("returns empty string when no delegations", () => {
46
+ expect(synthesizeDelegationSummary([])).toBe("");
47
+ });
48
+ });
package/tests/bus.test.ts CHANGED
@@ -1,121 +1,121 @@
1
- /**
2
- * Tests for the event bus.
3
- */
4
- import { describe, it, expect, vi } from 'vitest';
5
- import { MessageBus, Event, EventType } from '../src/core/bus';
6
-
7
- describe('MessageBus', () => {
8
- it('subscribe and publish broadcasts to subscribers', async () => {
9
- const bus = new MessageBus();
10
- const received: Event[] = [];
11
-
12
- async function handler(event: Event) {
13
- received.push(event);
14
- }
15
-
16
- bus.subscribe('test_agent', handler);
17
- const event = new Event(EventType.SYSTEM_EVENT, 'system', null, { msg: 'hello' });
18
- await bus.publish(event);
19
-
20
- expect(received).toHaveLength(1);
21
- expect(received[0].type).toBe(EventType.SYSTEM_EVENT);
22
- expect(received[0].data).toEqual({ msg: 'hello' });
23
- });
24
-
25
- it('direct message goes to target agent only', async () => {
26
- const bus = new MessageBus();
27
- const received: Event[] = [];
28
-
29
- async function handler(event: Event) {
30
- received.push(event);
31
- }
32
-
33
- bus.subscribe('target_agent', handler);
34
- const event = new Event(EventType.TASK_ASSIGNED, 'snow', 'target_agent', { task: 'test' });
35
- await bus.publish(event);
36
-
37
- expect(received).toHaveLength(1);
38
- });
39
-
40
- it('unsubscribe removes handler', async () => {
41
- const bus = new MessageBus();
42
- const received: Event[] = [];
43
-
44
- async function handler(event: Event) {
45
- received.push(event);
46
- }
47
-
48
- bus.subscribe('agent', handler);
49
- bus.unsubscribe('agent');
50
- await bus.publish(new Event(EventType.SYSTEM_EVENT, 'system'));
51
- expect(received).toHaveLength(0);
52
- });
53
-
54
- it('state listener receives state change events', async () => {
55
- const bus = new MessageBus();
56
- const received: Event[] = [];
57
-
58
- async function listener(event: Event) {
59
- received.push(event);
60
- }
61
-
62
- bus.onStateChange(listener);
63
- await bus.notifyStateChange(
64
- new Event(EventType.STATE_CHANGE, 'fog', null, { old_state: 'idle', new_state: 'thinking' })
65
- );
66
-
67
- expect(received).toHaveLength(1);
68
- expect(received[0].type).toBe(EventType.STATE_CHANGE);
69
- expect(received[0].source).toBe('fog');
70
- });
71
-
72
- it('state listener ignores non-state events', async () => {
73
- const bus = new MessageBus();
74
- const received: Event[] = [];
75
-
76
- async function listener(event: Event) {
77
- received.push(event);
78
- }
79
-
80
- bus.onStateChange(listener);
81
- await bus.notifyStateChange(new Event(EventType.TASK_ASSIGNED, 'snow'));
82
- expect(received).toHaveLength(0);
83
- });
84
-
85
- it('history tracks published events', async () => {
86
- const bus = new MessageBus();
87
- for (let i = 0; i < 5; i++) {
88
- await bus.publish(new Event(EventType.SYSTEM_EVENT, 'system'));
89
- }
90
- expect(bus.getHistory()).toHaveLength(5);
91
- expect(bus.getHistory(undefined, undefined, 2)).toHaveLength(2);
92
- });
93
-
94
- it('broadcast skips source agent', async () => {
95
- const bus = new MessageBus();
96
- const called: string[] = [];
97
-
98
- async function fogHandler(_event: Event) { called.push('fog'); }
99
- async function rainHandler(_event: Event) { called.push('rain'); }
100
-
101
- bus.subscribe('fog', fogHandler);
102
- bus.subscribe('rain', rainHandler);
103
- await bus.publish(new Event(EventType.SYSTEM_EVENT, 'fog'));
104
-
105
- expect(called).not.toContain('fog');
106
- expect(called).toContain('rain');
107
- });
108
-
109
- it('handler exception does not block other handlers', async () => {
110
- const bus = new MessageBus();
111
- const good: Event[] = [];
112
-
113
- async function failing(_event: Event) { throw new Error('failed'); }
114
- async function ok(event: Event) { good.push(event); }
115
-
116
- bus.subscribe('rain', failing);
117
- bus.subscribe('dew', ok);
118
- await bus.publish(new Event(EventType.SYSTEM_EVENT, 'fog'));
119
- expect(good).toHaveLength(1);
120
- });
121
- });
1
+ /**
2
+ * Tests for the event bus.
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest';
5
+ import { MessageBus, Event, EventType } from '../src/core/bus';
6
+
7
+ describe('MessageBus', () => {
8
+ it('subscribe and publish broadcasts to subscribers', async () => {
9
+ const bus = new MessageBus();
10
+ const received: Event[] = [];
11
+
12
+ async function handler(event: Event) {
13
+ received.push(event);
14
+ }
15
+
16
+ bus.subscribe('test_agent', handler);
17
+ const event = new Event(EventType.SYSTEM_EVENT, 'system', null, { msg: 'hello' });
18
+ await bus.publish(event);
19
+
20
+ expect(received).toHaveLength(1);
21
+ expect(received[0].type).toBe(EventType.SYSTEM_EVENT);
22
+ expect(received[0].data).toEqual({ msg: 'hello' });
23
+ });
24
+
25
+ it('direct message goes to target agent only', async () => {
26
+ const bus = new MessageBus();
27
+ const received: Event[] = [];
28
+
29
+ async function handler(event: Event) {
30
+ received.push(event);
31
+ }
32
+
33
+ bus.subscribe('target_agent', handler);
34
+ const event = new Event(EventType.TASK_ASSIGNED, 'snow', 'target_agent', { task: 'test' });
35
+ await bus.publish(event);
36
+
37
+ expect(received).toHaveLength(1);
38
+ });
39
+
40
+ it('unsubscribe removes handler', async () => {
41
+ const bus = new MessageBus();
42
+ const received: Event[] = [];
43
+
44
+ async function handler(event: Event) {
45
+ received.push(event);
46
+ }
47
+
48
+ bus.subscribe('agent', handler);
49
+ bus.unsubscribe('agent');
50
+ await bus.publish(new Event(EventType.SYSTEM_EVENT, 'system'));
51
+ expect(received).toHaveLength(0);
52
+ });
53
+
54
+ it('state listener receives state change events', async () => {
55
+ const bus = new MessageBus();
56
+ const received: Event[] = [];
57
+
58
+ async function listener(event: Event) {
59
+ received.push(event);
60
+ }
61
+
62
+ bus.onStateChange(listener);
63
+ await bus.notifyStateChange(
64
+ new Event(EventType.STATE_CHANGE, 'fog', null, { old_state: 'idle', new_state: 'thinking' })
65
+ );
66
+
67
+ expect(received).toHaveLength(1);
68
+ expect(received[0].type).toBe(EventType.STATE_CHANGE);
69
+ expect(received[0].source).toBe('fog');
70
+ });
71
+
72
+ it('state listener ignores non-state events', async () => {
73
+ const bus = new MessageBus();
74
+ const received: Event[] = [];
75
+
76
+ async function listener(event: Event) {
77
+ received.push(event);
78
+ }
79
+
80
+ bus.onStateChange(listener);
81
+ await bus.notifyStateChange(new Event(EventType.TASK_ASSIGNED, 'snow'));
82
+ expect(received).toHaveLength(0);
83
+ });
84
+
85
+ it('history tracks published events', async () => {
86
+ const bus = new MessageBus();
87
+ for (let i = 0; i < 5; i++) {
88
+ await bus.publish(new Event(EventType.SYSTEM_EVENT, 'system'));
89
+ }
90
+ expect(bus.getHistory()).toHaveLength(5);
91
+ expect(bus.getHistory(undefined, undefined, 2)).toHaveLength(2);
92
+ });
93
+
94
+ it('broadcast skips source agent', async () => {
95
+ const bus = new MessageBus();
96
+ const called: string[] = [];
97
+
98
+ async function fogHandler(_event: Event) { called.push('fog'); }
99
+ async function rainHandler(_event: Event) { called.push('rain'); }
100
+
101
+ bus.subscribe('fog', fogHandler);
102
+ bus.subscribe('rain', rainHandler);
103
+ await bus.publish(new Event(EventType.SYSTEM_EVENT, 'fog'));
104
+
105
+ expect(called).not.toContain('fog');
106
+ expect(called).toContain('rain');
107
+ });
108
+
109
+ it('handler exception does not block other handlers', async () => {
110
+ const bus = new MessageBus();
111
+ const good: Event[] = [];
112
+
113
+ async function failing(_event: Event) { throw new Error('failed'); }
114
+ async function ok(event: Event) { good.push(event); }
115
+
116
+ bus.subscribe('rain', failing);
117
+ bus.subscribe('dew', ok);
118
+ await bus.publish(new Event(EventType.SYSTEM_EVENT, 'fog'));
119
+ expect(good).toHaveLength(1);
120
+ });
121
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ loadCatalog,
4
+ listProviders,
5
+ modelsFor,
6
+ allModels,
7
+ getModelInfo,
8
+ isKnownModel,
9
+ providerLabel,
10
+ validateModel,
11
+ resetCatalogCache,
12
+ PROVIDER_META,
13
+ } from "../src/core/catalog";
14
+
15
+ describe("catalog", () => {
16
+ beforeEach(() => resetCatalogCache());
17
+
18
+ it("loads providers from models.yaml", () => {
19
+ const providers = listProviders();
20
+ expect(providers.length).toBeGreaterThan(0);
21
+ expect(providers).toContain("openai");
22
+ expect(providers).toContain("deepseek");
23
+ });
24
+
25
+ it("orders providers by wizard order", () => {
26
+ const providers = listProviders();
27
+ const orders = providers.map((p) => PROVIDER_META[p]?.order ?? 99);
28
+ const sorted = [...orders].sort((a, b) => a - b);
29
+ expect(orders).toEqual(sorted);
30
+ });
31
+
32
+ it("parses real model fields (context + cost)", () => {
33
+ const gpt4o = getModelInfo("gpt-4o");
34
+ expect(gpt4o).not.toBeNull();
35
+ expect(gpt4o!.provider).toBe("openai");
36
+ expect(gpt4o!.context).toBeGreaterThan(0);
37
+ expect(gpt4o!.costIn).toBeGreaterThan(0);
38
+ });
39
+
40
+ it("contains all real, API-verified deepseek models", () => {
41
+ // Verified live against https://api.deepseek.com/v1/models
42
+ expect(isKnownModel("deepseek-chat")).toBe(true);
43
+ expect(isKnownModel("deepseek-reasoner")).toBe(true);
44
+ expect(isKnownModel("deepseek-v4-flash")).toBe(true);
45
+ expect(isKnownModel("deepseek-v4-pro")).toBe(true);
46
+ });
47
+
48
+ it("still rejects genuinely unknown model ids", () => {
49
+ expect(isKnownModel("deepseek-v9-ultra")).toBe(false);
50
+ expect(isKnownModel("gpt-5-imaginary")).toBe(false);
51
+ });
52
+
53
+ it("marks ollama / zero-cost models as local", () => {
54
+ const local = allModels().filter((m) => m.local);
55
+ expect(local.every((m) => m.costIn === 0 && m.costOut === 0)).toBe(true);
56
+ });
57
+
58
+ it("tolerates provider/ prefix when resolving", () => {
59
+ // openrouter lists "openai/gpt-4.1"; bare "gpt-4.1" should also resolve
60
+ const direct = getModelInfo("gpt-4.1");
61
+ expect(direct).not.toBeNull();
62
+ });
63
+
64
+ it("validateModel passes for a real model and fails with suggestions otherwise", () => {
65
+ expect(validateModel("gpt-4o").ok).toBe(true);
66
+ const bad = validateModel("totally-made-up-model");
67
+ expect(bad.ok).toBe(false);
68
+ expect(bad.suggestions.length).toBeGreaterThan(0);
69
+ expect(bad.suggestions.every((s) => isKnownModel(s))).toBe(true);
70
+ });
71
+
72
+ it("provides display labels", () => {
73
+ expect(providerLabel("openai")).toBe("OpenAI");
74
+ expect(providerLabel("unknown-xyz")).toBe("unknown-xyz");
75
+ });
76
+
77
+ it("modelsFor returns empty for unknown provider", () => {
78
+ expect(modelsFor("nope")).toEqual([]);
79
+ });
80
+
81
+ it("caches the catalog across calls", () => {
82
+ const a = loadCatalog();
83
+ const b = loadCatalog();
84
+ expect(a).toBe(b);
85
+ });
86
+ });
@@ -0,0 +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
+ });