skyloom 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +3 -0
- package/dist/cli/main.js +2 -22
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/tui.d.ts.map +1 -1
- package/dist/cli/tui.js +0 -6
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/diff.d.ts.map +1 -1
- package/dist/core/diff.js +0 -1
- package/dist/core/diff.js.map +1 -1
- package/dist/core/evolve.d.ts.map +1 -1
- package/dist/core/evolve.js +0 -9
- package/dist/core/evolve.js.map +1 -1
- package/dist/core/graph.d.ts +1 -1
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +1 -1
- package/dist/core/graph.js.map +1 -1
- package/dist/core/llm.d.ts +3 -0
- package/dist/core/llm.d.ts.map +1 -1
- package/dist/core/llm.js +4 -27
- package/dist/core/llm.js.map +1 -1
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +0 -2
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/schemas.d.ts +1 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +1 -23
- package/dist/core/schemas.js.map +1 -1
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +0 -1
- package/dist/core/skill.js.map +1 -1
- package/dist/gateway/qr.d.ts.map +1 -1
- package/dist/gateway/qr.js +0 -1
- package/dist/gateway/qr.js.map +1 -1
- package/dist/tools/computer.d.ts.map +1 -1
- package/dist/tools/computer.js.map +1 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +0 -2
- package/dist/web/server.js.map +1 -1
- package/eslint.config.js +62 -0
- package/package.json +1 -1
- package/src/cli/main.ts +3 -22
- package/src/cli/tui.ts +0 -2
- package/src/core/diff.ts +0 -1
- package/src/core/evolve.ts +0 -6
- package/src/core/graph.ts +1 -1
- package/src/core/llm.ts +4 -33
- package/src/core/sandbox.ts +1 -4
- package/src/core/schemas.ts +1 -25
- package/src/core/skill.ts +0 -1
- package/src/gateway/qr.ts +0 -1
- package/src/tools/computer.ts +2 -2
- package/src/web/server.ts +0 -3
- package/tests/factory.test.ts +56 -0
- package/tests/pipelines.test.ts +118 -0
package/eslint.config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ESLint 9 flat config. Replaces the missing config that made `npm run lint` a
|
|
2
|
+
// silent no-op. Scope: src/ TypeScript. We run WITHOUT type-aware linting (no
|
|
3
|
+
// parserOptions.project) so it's fast and doesn't need a second TS program;
|
|
4
|
+
// the strict tsconfig already does the type checking.
|
|
5
|
+
//
|
|
6
|
+
// Philosophy: errors catch real bugs (unused vars, unsafe patterns); the large
|
|
7
|
+
// existing `any` surface and a few stylistic rules are warnings so the gate is
|
|
8
|
+
// honest without forcing a mass rewrite to go green. Tighten over time.
|
|
9
|
+
|
|
10
|
+
const tsParser = require('@typescript-eslint/parser');
|
|
11
|
+
const tsPlugin = require('@typescript-eslint/eslint-plugin');
|
|
12
|
+
|
|
13
|
+
module.exports = [
|
|
14
|
+
{
|
|
15
|
+
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '**/*.d.ts'],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
files: ['src/**/*.ts'],
|
|
19
|
+
languageOptions: {
|
|
20
|
+
parser: tsParser,
|
|
21
|
+
ecmaVersion: 2022,
|
|
22
|
+
sourceType: 'module',
|
|
23
|
+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
24
|
+
globals: {
|
|
25
|
+
process: 'readonly', console: 'readonly', Buffer: 'readonly',
|
|
26
|
+
__dirname: 'readonly', __filename: 'readonly', module: 'writable',
|
|
27
|
+
require: 'readonly', exports: 'writable', global: 'readonly',
|
|
28
|
+
setTimeout: 'readonly', clearTimeout: 'readonly',
|
|
29
|
+
setInterval: 'readonly', clearInterval: 'readonly',
|
|
30
|
+
URL: 'readonly', URLSearchParams: 'readonly', fetch: 'readonly',
|
|
31
|
+
AbortController: 'readonly', AbortSignal: 'readonly',
|
|
32
|
+
FormData: 'readonly', Blob: 'readonly', TextEncoder: 'readonly', TextDecoder: 'readonly',
|
|
33
|
+
performance: 'readonly', NodeJS: 'readonly', BufferEncoding: 'readonly',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
plugins: { '@typescript-eslint': tsPlugin },
|
|
37
|
+
rules: {
|
|
38
|
+
// ── Real-bug errors ──
|
|
39
|
+
'no-unused-vars': 'off', // superseded by the TS-aware version below
|
|
40
|
+
'@typescript-eslint/no-unused-vars': ['error', {
|
|
41
|
+
argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none',
|
|
42
|
+
ignoreRestSiblings: true,
|
|
43
|
+
}],
|
|
44
|
+
'no-undef': 'error',
|
|
45
|
+
'no-dupe-keys': 'error',
|
|
46
|
+
'no-unreachable': 'error',
|
|
47
|
+
'no-constant-condition': ['error', { checkLoops: false }],
|
|
48
|
+
'no-fallthrough': 'error',
|
|
49
|
+
'no-self-assign': 'error',
|
|
50
|
+
'no-unsafe-negation': 'error',
|
|
51
|
+
'use-isnan': 'error',
|
|
52
|
+
'valid-typeof': 'error',
|
|
53
|
+
'no-cond-assign': ['error', 'except-parens'],
|
|
54
|
+
|
|
55
|
+
// ── Debt warnings (honest, not blocking) ──
|
|
56
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
57
|
+
'no-empty': ['warn', { allowEmptyCatch: true }],
|
|
58
|
+
'prefer-const': 'warn',
|
|
59
|
+
'no-var': 'warn',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
];
|
package/package.json
CHANGED
package/src/cli/main.ts
CHANGED
|
@@ -10,9 +10,8 @@ import { createSystemContext, orchestrateTask } from "../core/factory";
|
|
|
10
10
|
import { loadConfig, USER_CONFIG_DIR } from "../core/config";
|
|
11
11
|
import { listProviders, modelsFor, providerLabel, validateModel } from "../core/catalog";
|
|
12
12
|
import { agentTheme } from "../core/theme";
|
|
13
|
-
import { classify } from "../core/router";
|
|
14
13
|
import { InteractiveMode, ModeController } from "./mode";
|
|
15
|
-
import { readLine, renderPalette, StreamRenderer
|
|
14
|
+
import { readLine, renderPalette, StreamRenderer } from "./tui";
|
|
16
15
|
import { loomChat } from "./loom_chat";
|
|
17
16
|
|
|
18
17
|
const MODE = new ModeController();
|
|
@@ -73,18 +72,6 @@ function welcome(agent: any) {
|
|
|
73
72
|
process.stdout.write(chalk.dim(" /help for commands · /quit to exit\n\n"));
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
function statusBar(agent: any, ctx: any): string {
|
|
77
|
-
try {
|
|
78
|
-
const cu = agent.contextUsage();
|
|
79
|
-
const pct = cu.pct || 0;
|
|
80
|
-
const bar = pct < 50 ? chalk.green : pct < 80 ? chalk.yellow : chalk.red;
|
|
81
|
-
const filled = Math.round(pct / 10);
|
|
82
|
-
const ctxBar = `${bar("█".repeat(filled) + "░".repeat(10 - filled))} ${pct}%`;
|
|
83
|
-
const cost = formatCost(ctx.llm.getTotalCost());
|
|
84
|
-
return chalk.dim(`${ctxBar} · ${cost} · ${cu.model || "?"}`);
|
|
85
|
-
} catch { return ""; }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
75
|
function formatCost(c: number): string {
|
|
89
76
|
if (c >= 1) return chalk.yellow(`$${c.toFixed(2)}`);
|
|
90
77
|
if (c >= 0.01) return chalk.yellow(`$${c.toFixed(4)}`);
|
|
@@ -119,11 +106,6 @@ async function streamResponse(agent: any, input: string): Promise<void> {
|
|
|
119
106
|
const header = () => { if (!headerShown) { out.write("\n " + chalk.bold.hex(theme.hex)(`${theme.symbol} ${theme.kanji}`) + chalk.hex(theme.hex)(` ${theme.name}`) + "\n\n"); headerShown = true; } };
|
|
120
107
|
const endBlock = () => { if (renderer) { renderer.flush(); renderer = null; out.write("\n"); } };
|
|
121
108
|
|
|
122
|
-
// All content passes through this cleaner before display
|
|
123
|
-
const writeClean = (text: string) => {
|
|
124
|
-
if (renderer) renderer.write(text);
|
|
125
|
-
};
|
|
126
|
-
|
|
127
109
|
// ── Ctrl-C interrupts this turn (keeps partial output); a 2nd Ctrl-C exits. ──
|
|
128
110
|
const controller = new AbortController();
|
|
129
111
|
let interrupted = false;
|
|
@@ -374,7 +356,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
374
356
|
|
|
375
357
|
// Wire up security approval — prompt user for HIGH/CRITICAL operations
|
|
376
358
|
try {
|
|
377
|
-
const { getSecurity,
|
|
359
|
+
const { getSecurity, PERMISSION_MODE_ALIASES } = require("../core/security");
|
|
378
360
|
const sec = getSecurity();
|
|
379
361
|
// Honor a configured permission mode (config.yaml cli.approvalMode), mapped
|
|
380
362
|
// through the same aliases as /perm.
|
|
@@ -405,7 +387,6 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
405
387
|
return; // loomChat exits the process itself
|
|
406
388
|
}
|
|
407
389
|
|
|
408
|
-
// eslint-disable-next-line prefer-const
|
|
409
390
|
let currentAgent = agent; // mutable for agent switching
|
|
410
391
|
let lastSessions: any[] = []; // index→session map for /resume <n>
|
|
411
392
|
welcome(agent);
|
|
@@ -599,7 +580,7 @@ async function chat(agentName: string, modelOverride?: string, classic?: boolean
|
|
|
599
580
|
continue;
|
|
600
581
|
}
|
|
601
582
|
if (cmdL === "/models" || cmdL.startsWith("/models ")) {
|
|
602
|
-
const { listProviders, modelsFor, providerLabel
|
|
583
|
+
const { listProviders, modelsFor, providerLabel } = require("../core/catalog");
|
|
603
584
|
const args = inp.split(/\s+/).slice(1);
|
|
604
585
|
const filter = args[0]?.toLowerCase() || "";
|
|
605
586
|
process.stdout.write(chalk.bold("\n ✦ 模型目录 · Model Catalog\n"));
|
package/src/cli/tui.ts
CHANGED
|
@@ -14,8 +14,6 @@ import chalk from "chalk";
|
|
|
14
14
|
import { agentTheme, PALETTE } from "../core/theme";
|
|
15
15
|
import { registry } from "../core/commands";
|
|
16
16
|
|
|
17
|
-
const TUI_VERSION = (() => { try { return require("../../package.json").version; } catch { return ""; } })();
|
|
18
|
-
|
|
19
17
|
export interface TUIContext {
|
|
20
18
|
agent: any;
|
|
21
19
|
agents: Map<string, any>;
|
package/src/core/diff.ts
CHANGED
|
@@ -60,7 +60,6 @@ export function unifiedDiff(oldStr: string, newStr: string, opts: DiffOptions =
|
|
|
60
60
|
// Context window bounds.
|
|
61
61
|
const ctxStart = Math.max(0, pre - context);
|
|
62
62
|
const oldCtxAfterStart = oldLines.length - suf;
|
|
63
|
-
const newCtxAfterStart = newLines.length - suf;
|
|
64
63
|
const oldCtxAfter = oldLines.slice(oldCtxAfterStart, oldCtxAfterStart + context);
|
|
65
64
|
const leading = oldLines.slice(ctxStart, pre);
|
|
66
65
|
|
package/src/core/evolve.ts
CHANGED
|
@@ -121,12 +121,6 @@ export function analyzeFailures(
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// General rule: if failure rate > 20%, suggest self-review
|
|
125
|
-
const recentExperiences = recent.filter(e => {
|
|
126
|
-
try { return new Date(e.lastSeen).getTime() > Date.now() - 3 * 86400000; }
|
|
127
|
-
catch { return false; }
|
|
128
|
-
});
|
|
129
|
-
|
|
130
124
|
// Deduplicate suggestions
|
|
131
125
|
const seen = new Set<string>();
|
|
132
126
|
const uniqueDiffs = suggestedDiffs.filter(d => {
|
package/src/core/graph.ts
CHANGED
|
@@ -140,7 +140,7 @@ const RELATION_PATTERNS: Array<[RegExp, string]> = [
|
|
|
140
140
|
[/(\w+) (?:file|path|文件|路径) (?:在|为|at) (.+?)(?:[。,,.\n]|$)/gi, "located_at"],
|
|
141
141
|
];
|
|
142
142
|
|
|
143
|
-
export function extractFacts(text: string,
|
|
143
|
+
export function extractFacts(text: string, _agent: string): Array<[string, string, string]> {
|
|
144
144
|
const facts: Array<[string, string, string]> = [];
|
|
145
145
|
for (const [pattern, pred] of RELATION_PATTERNS) {
|
|
146
146
|
let match;
|
package/src/core/llm.ts
CHANGED
|
@@ -355,37 +355,6 @@ const FALLBACK_CHAINS: Map<string, string[]> = new Map([
|
|
|
355
355
|
/**
|
|
356
356
|
* HTTP status codes that are considered transient errors (worth retrying).
|
|
357
357
|
*/
|
|
358
|
-
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Check if an exception is worth retrying.
|
|
362
|
-
*/
|
|
363
|
-
function isTransientError(err: unknown): boolean {
|
|
364
|
-
if (err instanceof Error) {
|
|
365
|
-
const status =
|
|
366
|
-
(err as any).status_code || (err as any).http_status || 0;
|
|
367
|
-
if (status && RETRYABLE_STATUSES.has(status)) {
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
if (err.name === "TimeoutError") {
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const errName = err.constructor.name.toLowerCase();
|
|
376
|
-
return [
|
|
377
|
-
"ratelimiterror",
|
|
378
|
-
"apitimeouterror",
|
|
379
|
-
"apiconnectionerror",
|
|
380
|
-
"serviceunavailableerror",
|
|
381
|
-
"internalservererror",
|
|
382
|
-
"timeout",
|
|
383
|
-
].includes(errName);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return false;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
358
|
/**
|
|
390
359
|
* Estimate cost for LLM API call.
|
|
391
360
|
*/
|
|
@@ -704,7 +673,7 @@ export class LLMClient {
|
|
|
704
673
|
messages: Record<string, unknown>[],
|
|
705
674
|
agentName?: string,
|
|
706
675
|
tools?: string[],
|
|
707
|
-
|
|
676
|
+
_stream: boolean = false,
|
|
708
677
|
overrides?: Record<string, unknown>
|
|
709
678
|
): Promise<LLMResponse> {
|
|
710
679
|
const temperature = (overrides?.temperature as number) ?? 0.7;
|
|
@@ -867,7 +836,9 @@ export class LLMClient {
|
|
|
867
836
|
private async *callOpenAIStream(
|
|
868
837
|
m: string, messages: Record<string, unknown>[], tools?: string[], temp?: number, maxTok?: number, signal?: AbortSignal, agentName?: string
|
|
869
838
|
): AsyncGenerator<StreamEvent> {
|
|
870
|
-
|
|
839
|
+
// Honor a per-agent API key override (agents.<name>.api_key) on the
|
|
840
|
+
// streaming path too — previously only the non-streaming path passed it.
|
|
841
|
+
const apiKey = this.getApiKey(m, agentName);
|
|
871
842
|
const baseUrl = this.getBaseUrl(m);
|
|
872
843
|
const body: Record<string, unknown> = {
|
|
873
844
|
model: m, messages, temperature: temp ?? 0.7, max_tokens: maxTok ?? 4096,
|
package/src/core/sandbox.ts
CHANGED
|
@@ -6,15 +6,12 @@
|
|
|
6
6
|
* limits, and dangerous command detection BEFORE execution.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { execSync
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
10
|
import * as fs from "fs";
|
|
11
11
|
import * as path from "path";
|
|
12
12
|
import * as os from "os";
|
|
13
|
-
import { getLogger } from "./logger";
|
|
14
13
|
import { REDLINE_PATTERNS, REDLINE_COMMANDS } from "./security";
|
|
15
14
|
|
|
16
|
-
const log = getLogger("sandbox");
|
|
17
|
-
|
|
18
15
|
/* ═══════════════════════════════════════
|
|
19
16
|
Configuration
|
|
20
17
|
═══════════════════════════════════════ */
|
package/src/core/schemas.ts
CHANGED
|
@@ -78,30 +78,6 @@ export interface MessageSchema {
|
|
|
78
78
|
tool_call_id?: string;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
/**
|
|
82
|
-
* Coerce a value to a target type with best-effort conversion
|
|
83
|
-
*/
|
|
84
|
-
function coerceType(value: unknown, targetType: string): unknown {
|
|
85
|
-
if (value === null || value === undefined) {
|
|
86
|
-
return value;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
switch (targetType) {
|
|
90
|
-
case "string":
|
|
91
|
-
return String(value);
|
|
92
|
-
case "number":
|
|
93
|
-
return Number(value);
|
|
94
|
-
case "boolean":
|
|
95
|
-
return Boolean(value);
|
|
96
|
-
case "array":
|
|
97
|
-
return Array.isArray(value) ? value : [];
|
|
98
|
-
case "object":
|
|
99
|
-
return typeof value === "object" ? value : {};
|
|
100
|
-
default:
|
|
101
|
-
return value;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
81
|
/**
|
|
106
82
|
* Extract JSON object/array from a potentially malformed string
|
|
107
83
|
*/
|
|
@@ -170,7 +146,7 @@ function repairJSON(text: string): string {
|
|
|
170
146
|
*/
|
|
171
147
|
export function parseSchema<T extends Record<string, unknown>>(
|
|
172
148
|
raw: string,
|
|
173
|
-
|
|
149
|
+
_schemaType?: new () => T
|
|
174
150
|
): T {
|
|
175
151
|
if (!raw || !raw.trim()) {
|
|
176
152
|
throw new SchemaValidationError("empty response", raw);
|
package/src/core/skill.ts
CHANGED
|
@@ -245,7 +245,6 @@ function extractSkillHead(body: string, maxChars: number): string {
|
|
|
245
245
|
// Patterns for auto-deriving triggers
|
|
246
246
|
const TRIGGER_QUOTED = /["'"'""]([^"'""\n]{1,40})["'""']/g;
|
|
247
247
|
const TRIGGER_EXT = /(?<![A-Za-z0-9])\.[A-Za-z0-9]{2,6}\b/g;
|
|
248
|
-
const TRIGGER_STRIP = " \t,.;:!?,。、;:!?、。";
|
|
249
248
|
|
|
250
249
|
/**
|
|
251
250
|
* Pull candidate trigger phrases out of a skill description.
|
package/src/gateway/qr.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// qrcode-terminal ships no types; declare the tiny surface we use.
|
|
8
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
8
|
const qrcode: { generate: (text: string, opts: { small?: boolean }, cb: (s: string) => void) => void } =
|
|
10
9
|
require('qrcode-terminal');
|
|
11
10
|
|
package/src/tools/computer.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* processes and services, and install/uninstall software.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { execSync, execFileSync
|
|
8
|
+
import { execSync, execFileSync } from 'child_process';
|
|
9
9
|
import * as os from 'os';
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
|
-
import type { ToolRegistry
|
|
12
|
+
import type { ToolRegistry } from '../core/tool';
|
|
13
13
|
|
|
14
14
|
const MAX_OUT = 8000;
|
|
15
15
|
|
package/src/web/server.ts
CHANGED
|
@@ -8,11 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { createServer, IncomingMessage, ServerResponse } from "http";
|
|
10
10
|
import { createSystemContext } from "../core/factory";
|
|
11
|
-
import { getLogger } from "../core/logger";
|
|
12
11
|
import { renderInkWashUI, SKYLOOM_FAVICON_SVG } from "./ui";
|
|
13
12
|
|
|
14
|
-
const log = getLogger("web-server");
|
|
15
|
-
|
|
16
13
|
/* ──────────────────────────────────────────────
|
|
17
14
|
Server
|
|
18
15
|
────────────────────────────────────────────── */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { SystemContext, TaskExecutionResult } from "../src/core/factory";
|
|
3
|
+
import { MessageBus } from "../src/core/bus";
|
|
4
|
+
|
|
5
|
+
describe("factory · TaskExecutionResult", () => {
|
|
6
|
+
it("holds the provided fields", () => {
|
|
7
|
+
const r = new TaskExecutionResult({ id: "t1", agent: "fog", description: "do x", success: true, content: "done" });
|
|
8
|
+
expect(r).toMatchObject({ id: "t1", agent: "fog", success: true, content: "done" });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("factory · SystemContext", () => {
|
|
13
|
+
function ctx(overrides: Partial<ConstructorParameters<typeof SystemContext>[0]> = {}) {
|
|
14
|
+
return new SystemContext({
|
|
15
|
+
config: { agents: {} } as any,
|
|
16
|
+
bus: new MessageBus(),
|
|
17
|
+
llm: {} as any,
|
|
18
|
+
agentMap: new Map(),
|
|
19
|
+
toolRegistry: {} as any,
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it("stores constructor options with sensible defaults", () => {
|
|
25
|
+
const c = ctx({ workspacePath: "/ws" });
|
|
26
|
+
expect(c.workspacePath).toBe("/ws");
|
|
27
|
+
expect(c.mcp).toBeNull();
|
|
28
|
+
expect(c.mcpStatus).toEqual([]);
|
|
29
|
+
expect(c.agentMap.size).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("initAll fires the plugin init hook before agents come up", async () => {
|
|
33
|
+
const emit = vi.fn().mockResolvedValue(undefined);
|
|
34
|
+
const c = ctx({ plugins: { emit } as any });
|
|
35
|
+
await c.initAll();
|
|
36
|
+
expect(emit).toHaveBeenCalledWith("init", expect.objectContaining({ config: expect.anything() }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("closeAll closes every agent and tolerates a missing mcp", async () => {
|
|
40
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
41
|
+
const agents = new Map<string, any>([
|
|
42
|
+
["fog", { close }],
|
|
43
|
+
["rain", { close }],
|
|
44
|
+
]);
|
|
45
|
+
const c = ctx({ agentMap: agents as any });
|
|
46
|
+
await c.closeAll();
|
|
47
|
+
expect(close).toHaveBeenCalledTimes(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("closeAll also closes mcp when present", async () => {
|
|
51
|
+
const closeAll = vi.fn().mockResolvedValue(undefined);
|
|
52
|
+
const c = ctx({ mcp: { closeAll } as any });
|
|
53
|
+
await c.closeAll();
|
|
54
|
+
expect(closeAll).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
matchPipeline,
|
|
4
|
+
matchAllPipelines,
|
|
5
|
+
buildTasksFromPipeline,
|
|
6
|
+
listPipelines,
|
|
7
|
+
getPipelineByName,
|
|
8
|
+
validateDAG,
|
|
9
|
+
topologicalSort,
|
|
10
|
+
type Task,
|
|
11
|
+
} from "../src/core/pipelines";
|
|
12
|
+
|
|
13
|
+
function task(id: string, dependsOn: string[] = []): Task {
|
|
14
|
+
return { id, description: id, assignedTo: "fog", parentId: dependsOn[0] ?? null, dependsOn };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("pipelines · matching", () => {
|
|
18
|
+
it("matches a code-review goal to the code_review pipeline", () => {
|
|
19
|
+
const p = matchPipeline("帮我审查代码");
|
|
20
|
+
expect(p?.name).toBe("code_review");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("matches an English review goal", () => {
|
|
24
|
+
expect(matchPipeline("please review my code")?.name).toBe("code_review");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns null for an unmatched / empty goal", () => {
|
|
28
|
+
expect(matchPipeline("今天天气怎么样")).toBeNull();
|
|
29
|
+
expect(matchPipeline("")).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("matchAllPipelines returns an array (possibly empty)", () => {
|
|
33
|
+
expect(Array.isArray(matchAllPipelines("审查代码"))).toBe(true);
|
|
34
|
+
expect(matchAllPipelines("xyzzy nonsense")).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("pipelines · introspection", () => {
|
|
39
|
+
it("lists pipelines with names, triggers and steps", () => {
|
|
40
|
+
const list = listPipelines();
|
|
41
|
+
expect(list.length).toBeGreaterThan(0);
|
|
42
|
+
for (const p of list) {
|
|
43
|
+
expect(typeof p.name).toBe("string");
|
|
44
|
+
expect(Array.isArray(p.triggers)).toBe(true);
|
|
45
|
+
expect(Array.isArray(p.steps)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("getPipelineByName round-trips a listed name; unknown → null", () => {
|
|
50
|
+
const name = listPipelines()[0].name as string;
|
|
51
|
+
expect(getPipelineByName(name)?.name).toBe(name);
|
|
52
|
+
expect(getPipelineByName("___nope___")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("pipelines · materialization", () => {
|
|
57
|
+
it("builds tasks from a pipeline, substituting {goal}", () => {
|
|
58
|
+
const p = getPipelineByName("code_review")!;
|
|
59
|
+
const tasks = buildTasksFromPipeline(p, "登录模块");
|
|
60
|
+
expect(tasks.length).toBe(p.steps.length);
|
|
61
|
+
expect(tasks[0].description).toContain("登录模块");
|
|
62
|
+
expect(tasks[0].metadata?.goal).toBe("登录模块");
|
|
63
|
+
expect(tasks[0].metadata?.pipeline).toBe("code_review");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("a materialized pipeline is a valid DAG", () => {
|
|
67
|
+
for (const meta of listPipelines()) {
|
|
68
|
+
const p = getPipelineByName(meta.name as string)!;
|
|
69
|
+
const tasks = buildTasksFromPipeline(p, "x");
|
|
70
|
+
const v = validateDAG(tasks);
|
|
71
|
+
expect(v.valid, `${meta.name}: ${v.errors.join("; ")}`).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("pipelines · validateDAG", () => {
|
|
77
|
+
it("accepts a linear chain", () => {
|
|
78
|
+
const v = validateDAG([task("1"), task("2", ["1"]), task("3", ["2"])]);
|
|
79
|
+
expect(v.valid).toBe(true);
|
|
80
|
+
expect(v.errors).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("flags a missing dependency", () => {
|
|
84
|
+
const v = validateDAG([task("2", ["1"])]); // 1 doesn't exist
|
|
85
|
+
expect(v.valid).toBe(false);
|
|
86
|
+
expect(v.errors.join(" ")).toMatch(/non-existent/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("detects a cycle", () => {
|
|
90
|
+
const v = validateDAG([task("a", ["b"]), task("b", ["a"])]);
|
|
91
|
+
expect(v.valid).toBe(false);
|
|
92
|
+
expect(v.errors.join(" ")).toMatch(/[Cc]ycle/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("pipelines · topologicalSort", () => {
|
|
97
|
+
it("orders dependencies before dependents", () => {
|
|
98
|
+
const sorted = topologicalSort([task("3", ["2"]), task("1"), task("2", ["1"])]);
|
|
99
|
+
const order = sorted.map((t) => t.id);
|
|
100
|
+
expect(order.indexOf("1")).toBeLessThan(order.indexOf("2"));
|
|
101
|
+
expect(order.indexOf("2")).toBeLessThan(order.indexOf("3"));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles independent tasks (all in-degree 0)", () => {
|
|
105
|
+
const sorted = topologicalSort([task("a"), task("b"), task("c")]);
|
|
106
|
+
expect(sorted.map((t) => t.id).sort()).toEqual(["a", "b", "c"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("a diamond DAG keeps the root first and the join last", () => {
|
|
110
|
+
// a → b, a → c, b → d, c → d
|
|
111
|
+
const sorted = topologicalSort([
|
|
112
|
+
task("d", ["b", "c"]), task("b", ["a"]), task("c", ["a"]), task("a"),
|
|
113
|
+
]);
|
|
114
|
+
const order = sorted.map((t) => t.id);
|
|
115
|
+
expect(order[0]).toBe("a");
|
|
116
|
+
expect(order[order.length - 1]).toBe("d");
|
|
117
|
+
});
|
|
118
|
+
});
|