kevlar-4u 1.0.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/README.md +357 -0
- package/config/mcp-config.json +9 -0
- package/dist/__tests__/configureWizard.test.d.ts +2 -0
- package/dist/__tests__/configureWizard.test.d.ts.map +1 -0
- package/dist/__tests__/configureWizard.test.js +89 -0
- package/dist/__tests__/configureWizard.test.js.map +1 -0
- package/dist/__tests__/createPersonaTool.test.d.ts +2 -0
- package/dist/__tests__/createPersonaTool.test.d.ts.map +1 -0
- package/dist/__tests__/createPersonaTool.test.js +292 -0
- package/dist/__tests__/createPersonaTool.test.js.map +1 -0
- package/dist/__tests__/createPersonaWizard.test.d.ts +2 -0
- package/dist/__tests__/createPersonaWizard.test.d.ts.map +1 -0
- package/dist/__tests__/createPersonaWizard.test.js +138 -0
- package/dist/__tests__/createPersonaWizard.test.js.map +1 -0
- package/dist/__tests__/deletePersonaWizard.test.d.ts +2 -0
- package/dist/__tests__/deletePersonaWizard.test.d.ts.map +1 -0
- package/dist/__tests__/deletePersonaWizard.test.js +78 -0
- package/dist/__tests__/deletePersonaWizard.test.js.map +1 -0
- package/dist/__tests__/e2e.test.d.ts +2 -0
- package/dist/__tests__/e2e.test.d.ts.map +1 -0
- package/dist/__tests__/e2e.test.js +121 -0
- package/dist/__tests__/e2e.test.js.map +1 -0
- package/dist/__tests__/errors.test.d.ts +2 -0
- package/dist/__tests__/errors.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test.js +86 -0
- package/dist/__tests__/errors.test.js.map +1 -0
- package/dist/__tests__/execution.test.d.ts +2 -0
- package/dist/__tests__/execution.test.d.ts.map +1 -0
- package/dist/__tests__/execution.test.js +792 -0
- package/dist/__tests__/execution.test.js.map +1 -0
- package/dist/__tests__/getModesTool.test.d.ts +2 -0
- package/dist/__tests__/getModesTool.test.d.ts.map +1 -0
- package/dist/__tests__/getModesTool.test.js +47 -0
- package/dist/__tests__/getModesTool.test.js.map +1 -0
- package/dist/__tests__/helpTool.test.d.ts +2 -0
- package/dist/__tests__/helpTool.test.d.ts.map +1 -0
- package/dist/__tests__/helpTool.test.js +18 -0
- package/dist/__tests__/helpTool.test.js.map +1 -0
- package/dist/__tests__/listPersonasTool.test.d.ts +2 -0
- package/dist/__tests__/listPersonasTool.test.d.ts.map +1 -0
- package/dist/__tests__/listPersonasTool.test.js +110 -0
- package/dist/__tests__/listPersonasTool.test.js.map +1 -0
- package/dist/__tests__/logger.test.d.ts +2 -0
- package/dist/__tests__/logger.test.d.ts.map +1 -0
- package/dist/__tests__/logger.test.js +56 -0
- package/dist/__tests__/logger.test.js.map +1 -0
- package/dist/__tests__/observability.test.d.ts +2 -0
- package/dist/__tests__/observability.test.d.ts.map +1 -0
- package/dist/__tests__/observability.test.js +60 -0
- package/dist/__tests__/observability.test.js.map +1 -0
- package/dist/__tests__/parser.test.d.ts +2 -0
- package/dist/__tests__/parser.test.d.ts.map +1 -0
- package/dist/__tests__/parser.test.js +259 -0
- package/dist/__tests__/parser.test.js.map +1 -0
- package/dist/__tests__/persona_creation_debug.test.d.ts +2 -0
- package/dist/__tests__/persona_creation_debug.test.d.ts.map +1 -0
- package/dist/__tests__/persona_creation_debug.test.js +56 -0
- package/dist/__tests__/persona_creation_debug.test.js.map +1 -0
- package/dist/__tests__/resetPersonasWizard.test.d.ts +2 -0
- package/dist/__tests__/resetPersonasWizard.test.d.ts.map +1 -0
- package/dist/__tests__/resetPersonasWizard.test.js +74 -0
- package/dist/__tests__/resetPersonasWizard.test.js.map +1 -0
- package/dist/__tests__/reviewContentWizard.test.d.ts +2 -0
- package/dist/__tests__/reviewContentWizard.test.d.ts.map +1 -0
- package/dist/__tests__/reviewContentWizard.test.js +148 -0
- package/dist/__tests__/reviewContentWizard.test.js.map +1 -0
- package/dist/__tests__/sanitize.test.d.ts +2 -0
- package/dist/__tests__/sanitize.test.d.ts.map +1 -0
- package/dist/__tests__/sanitize.test.js +138 -0
- package/dist/__tests__/sanitize.test.js.map +1 -0
- package/dist/__tests__/server.test.d.ts +2 -0
- package/dist/__tests__/server.test.d.ts.map +1 -0
- package/dist/__tests__/server.test.js +49 -0
- package/dist/__tests__/server.test.js.map +1 -0
- package/dist/execution/aggregator.d.ts +43 -0
- package/dist/execution/aggregator.d.ts.map +1 -0
- package/dist/execution/aggregator.js +132 -0
- package/dist/execution/aggregator.js.map +1 -0
- package/dist/execution/base.d.ts +62 -0
- package/dist/execution/base.d.ts.map +1 -0
- package/dist/execution/base.js +5 -0
- package/dist/execution/base.js.map +1 -0
- package/dist/execution/client.d.ts +9 -0
- package/dist/execution/client.d.ts.map +1 -0
- package/dist/execution/client.js +30 -0
- package/dist/execution/client.js.map +1 -0
- package/dist/execution/config.d.ts +30 -0
- package/dist/execution/config.d.ts.map +1 -0
- package/dist/execution/config.js +95 -0
- package/dist/execution/config.js.map +1 -0
- package/dist/execution/index.d.ts +19 -0
- package/dist/execution/index.d.ts.map +1 -0
- package/dist/execution/index.js +151 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/execution/limiter.d.ts +32 -0
- package/dist/execution/limiter.d.ts.map +1 -0
- package/dist/execution/limiter.js +147 -0
- package/dist/execution/limiter.js.map +1 -0
- package/dist/execution/lock.d.ts +17 -0
- package/dist/execution/lock.d.ts.map +1 -0
- package/dist/execution/lock.js +37 -0
- package/dist/execution/lock.js.map +1 -0
- package/dist/execution/modes/direct_api.d.ts +11 -0
- package/dist/execution/modes/direct_api.d.ts.map +1 -0
- package/dist/execution/modes/direct_api.js +213 -0
- package/dist/execution/modes/direct_api.js.map +1 -0
- package/dist/execution/modes/index.d.ts +7 -0
- package/dist/execution/modes/index.d.ts.map +1 -0
- package/dist/execution/modes/index.js +7 -0
- package/dist/execution/modes/index.js.map +1 -0
- package/dist/execution/modes/orchestration.d.ts +11 -0
- package/dist/execution/modes/orchestration.d.ts.map +1 -0
- package/dist/execution/modes/orchestration.js +110 -0
- package/dist/execution/modes/orchestration.js.map +1 -0
- package/dist/execution/modes/sampling.d.ts +9 -0
- package/dist/execution/modes/sampling.d.ts.map +1 -0
- package/dist/execution/modes/sampling.js +66 -0
- package/dist/execution/modes/sampling.js.map +1 -0
- package/dist/execution/parallel.d.ts +16 -0
- package/dist/execution/parallel.d.ts.map +1 -0
- package/dist/execution/parallel.js +90 -0
- package/dist/execution/parallel.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/reviewDispatcherPrompt.d.ts +2 -0
- package/dist/prompts/reviewDispatcherPrompt.d.ts.map +1 -0
- package/dist/prompts/reviewDispatcherPrompt.js +67 -0
- package/dist/prompts/reviewDispatcherPrompt.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +156 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/configureTool.d.ts +17 -0
- package/dist/tools/configureTool.d.ts.map +1 -0
- package/dist/tools/configureTool.js +104 -0
- package/dist/tools/configureTool.js.map +1 -0
- package/dist/tools/configureWizardTool.d.ts +11 -0
- package/dist/tools/configureWizardTool.d.ts.map +1 -0
- package/dist/tools/configureWizardTool.js +205 -0
- package/dist/tools/configureWizardTool.js.map +1 -0
- package/dist/tools/createPersonaTool.d.ts +37 -0
- package/dist/tools/createPersonaTool.d.ts.map +1 -0
- package/dist/tools/createPersonaTool.js +353 -0
- package/dist/tools/createPersonaTool.js.map +1 -0
- package/dist/tools/createPersonaWizardTool.d.ts +13 -0
- package/dist/tools/createPersonaWizardTool.d.ts.map +1 -0
- package/dist/tools/createPersonaWizardTool.js +713 -0
- package/dist/tools/createPersonaWizardTool.js.map +1 -0
- package/dist/tools/deletePersonaTool.d.ts +10 -0
- package/dist/tools/deletePersonaTool.d.ts.map +1 -0
- package/dist/tools/deletePersonaTool.js +75 -0
- package/dist/tools/deletePersonaTool.js.map +1 -0
- package/dist/tools/deletePersonaWizardTool.d.ts +11 -0
- package/dist/tools/deletePersonaWizardTool.d.ts.map +1 -0
- package/dist/tools/deletePersonaWizardTool.js +184 -0
- package/dist/tools/deletePersonaWizardTool.js.map +1 -0
- package/dist/tools/getModesTool.d.ts +12 -0
- package/dist/tools/getModesTool.d.ts.map +1 -0
- package/dist/tools/getModesTool.js +78 -0
- package/dist/tools/getModesTool.js.map +1 -0
- package/dist/tools/helpTool.d.ts +7 -0
- package/dist/tools/helpTool.d.ts.map +1 -0
- package/dist/tools/helpTool.js +65 -0
- package/dist/tools/helpTool.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/listPersonasTool.d.ts +7 -0
- package/dist/tools/listPersonasTool.d.ts.map +1 -0
- package/dist/tools/listPersonasTool.js +118 -0
- package/dist/tools/listPersonasTool.js.map +1 -0
- package/dist/tools/resetPersonasTool.d.ts +7 -0
- package/dist/tools/resetPersonasTool.d.ts.map +1 -0
- package/dist/tools/resetPersonasTool.js +447 -0
- package/dist/tools/resetPersonasTool.js.map +1 -0
- package/dist/tools/resetPersonasWizardTool.d.ts +9 -0
- package/dist/tools/resetPersonasWizardTool.d.ts.map +1 -0
- package/dist/tools/resetPersonasWizardTool.js +125 -0
- package/dist/tools/resetPersonasWizardTool.js.map +1 -0
- package/dist/tools/reviewContentWizardTool.d.ts +13 -0
- package/dist/tools/reviewContentWizardTool.d.ts.map +1 -0
- package/dist/tools/reviewContentWizardTool.js +411 -0
- package/dist/tools/reviewContentWizardTool.js.map +1 -0
- package/dist/tools/reviewTool.d.ts +10 -0
- package/dist/tools/reviewTool.d.ts.map +1 -0
- package/dist/tools/reviewTool.js +133 -0
- package/dist/tools/reviewTool.js.map +1 -0
- package/dist/tools/types.d.ts +14 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/utils/errors.d.ts +30 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +47 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +52 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/observability.d.ts +61 -0
- package/dist/utils/observability.d.ts.map +1 -0
- package/dist/utils/observability.js +69 -0
- package/dist/utils/observability.js.map +1 -0
- package/dist/utils/parser.d.ts +27 -0
- package/dist/utils/parser.d.ts.map +1 -0
- package/dist/utils/parser.js +178 -0
- package/dist/utils/parser.js.map +1 -0
- package/dist/utils/personaIdMaps.d.ts +7 -0
- package/dist/utils/personaIdMaps.d.ts.map +1 -0
- package/dist/utils/personaIdMaps.js +51 -0
- package/dist/utils/personaIdMaps.js.map +1 -0
- package/dist/utils/sanitize.d.ts +4 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +49 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/dist/utils/types.d.ts +8 -0
- package/dist/utils/types.d.ts.map +1 -0
- package/dist/utils/types.js +2 -0
- package/dist/utils/types.js.map +1 -0
- package/package.json +42 -0
- package/skills/_template.md +66 -0
- package/skills/tmp/wizard-create-cwzrrpim_draft.json +26 -0
- package/skills/tmp/wizard-create-cwzrrpim_wizard.json +26 -0
- package/skills/tmp/wizard-create-d81intme_draft.json +26 -0
- package/skills/tmp/wizard-create-d81intme_wizard.json +26 -0
- package/skills/tmp/wizard-create-g50jqzmh_draft.json +8 -0
- package/skills/tmp/wizard-create-g50jqzmh_wizard.json +8 -0
- package/skills/tmp/wizard-create-onupu9wb_draft.json +8 -0
- package/skills/tmp/wizard-create-onupu9wb_wizard.json +8 -0
- package/skills/tmp/wizard-review-fwbxe3d2_review_wizard.json +9 -0
- package/skills/tmp/wizard-review-sypg5e9d_review_wizard.json +9 -0
- package/skills/wechat_official/wechat_official.md +26 -0
- package/skills/wechat_official/wechat_official_1.md +31 -0
- package/skills/xiaohongshu/xiaohongshu.md +26 -0
- package/skills/xiaohongshu/xiaohongshu_1.md +29 -0
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
// ── Test subject imports ───────────────────────────────────────────────────────
|
|
7
|
+
import { orchestrationHandler, } from "../execution/modes/orchestration.js";
|
|
8
|
+
import { hasApiKey, maskApiKey } from "../execution/modes/direct_api.js";
|
|
9
|
+
import { setClientInfo, isSamplingSupported, getSamplingClientList } from "../execution/client.js";
|
|
10
|
+
import { setConfigPath, readConfig, updateConfig, isValidMode, isValidConcurrency } from "../execution/config.js";
|
|
11
|
+
import { RateLimiter, withRetry, isRetryableError } from "../execution/limiter.js";
|
|
12
|
+
import { ResultAggregator, generateAggregatedReport, estimateTokenCost, checkBudget } from "../execution/aggregator.js";
|
|
13
|
+
import { acquireReviewLock, releaseReviewLock, getReviewLock, isLocked } from "../execution/lock.js";
|
|
14
|
+
import { executeReview, loadPersonasForReview, validatePersonaFields } from "../execution/index.js";
|
|
15
|
+
import { executePersonasInParallel } from "../execution/parallel.js";
|
|
16
|
+
import { directApiHandler } from "../execution/modes/direct_api.js";
|
|
17
|
+
import { samplingHandler } from "../execution/modes/sampling.js";
|
|
18
|
+
let tmpDir;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "kevlar-exec-test-"));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
releaseReviewLock();
|
|
25
|
+
});
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
// Orchestration Mode Tests
|
|
28
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
describe("orchestrationHandler", () => {
|
|
30
|
+
it("always returns available", () => {
|
|
31
|
+
assert.ok(orchestrationHandler.canExecute());
|
|
32
|
+
});
|
|
33
|
+
it("has correct mode and priority", () => {
|
|
34
|
+
assert.equal(orchestrationHandler.mode, "orchestration");
|
|
35
|
+
assert.equal(orchestrationHandler.priority, 30);
|
|
36
|
+
});
|
|
37
|
+
it("generates report with personas", async () => {
|
|
38
|
+
const personas = [
|
|
39
|
+
{
|
|
40
|
+
meta: {
|
|
41
|
+
id: "test-1",
|
|
42
|
+
name: "测试人设",
|
|
43
|
+
name_en: "Test Persona",
|
|
44
|
+
version: "1.0.0",
|
|
45
|
+
author: "test",
|
|
46
|
+
tags: [],
|
|
47
|
+
description: "A test persona",
|
|
48
|
+
},
|
|
49
|
+
systemPrompt: "You are a test.",
|
|
50
|
+
filePath: "/test/persona.md",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
const result = await orchestrationHandler.execute({
|
|
54
|
+
skillsDir: tmpDir,
|
|
55
|
+
personas,
|
|
56
|
+
content: "测试内容",
|
|
57
|
+
});
|
|
58
|
+
assert.equal(result.mode, "orchestration");
|
|
59
|
+
assert.deepEqual(result.personas, ["test-1"]);
|
|
60
|
+
assert.ok(result.report.includes("测试人设"));
|
|
61
|
+
assert.ok(result.report.includes("测试内容"));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Direct API Key Tests
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
describe("hasApiKey", () => {
|
|
68
|
+
it("returns false when no API key is set", () => {
|
|
69
|
+
// Clear env vars for this test
|
|
70
|
+
delete process.env.KEVLAR_API_KEY;
|
|
71
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
72
|
+
delete process.env.OPENAI_API_KEY;
|
|
73
|
+
assert.ok(!hasApiKey());
|
|
74
|
+
});
|
|
75
|
+
it("returns true when KEVLAR_API_KEY is set", () => {
|
|
76
|
+
process.env.KEVLAR_API_KEY = "sk-test-key-12345";
|
|
77
|
+
assert.ok(hasApiKey());
|
|
78
|
+
delete process.env.KEVLAR_API_KEY;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("maskApiKey", () => {
|
|
82
|
+
it("masks long keys correctly", () => {
|
|
83
|
+
const masked = maskApiKey("sk-ant-api-key-12345", 4);
|
|
84
|
+
assert.ok(masked.startsWith("sk-a"));
|
|
85
|
+
assert.ok(masked.endsWith("2345"));
|
|
86
|
+
assert.ok(masked.includes("****"));
|
|
87
|
+
});
|
|
88
|
+
it("masks short keys entirely", () => {
|
|
89
|
+
const masked = maskApiKey("abc", 4);
|
|
90
|
+
assert.equal(masked, "***");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// Client Detection Tests
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
describe("isSamplingSupported", () => {
|
|
97
|
+
it("returns true for claude-ai", () => {
|
|
98
|
+
assert.ok(isSamplingSupported("claude-ai"));
|
|
99
|
+
assert.ok(isSamplingSupported("Claude-AI"));
|
|
100
|
+
});
|
|
101
|
+
it("returns false for unknown clients", () => {
|
|
102
|
+
assert.ok(!isSamplingSupported("unknown-client"));
|
|
103
|
+
});
|
|
104
|
+
it("returns false when no client info is set", () => {
|
|
105
|
+
setClientInfo("unknown");
|
|
106
|
+
assert.ok(!isSamplingSupported());
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe("getSamplingClientList", () => {
|
|
110
|
+
it("returns an array with claude-ai", () => {
|
|
111
|
+
const list = getSamplingClientList();
|
|
112
|
+
assert.ok(Array.isArray(list));
|
|
113
|
+
assert.ok(list.includes("claude-ai"));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
// Config Tests
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
describe("config", () => {
|
|
120
|
+
it("returns default config when path not set", () => {
|
|
121
|
+
const config = readConfig();
|
|
122
|
+
assert.equal(config.mode, "auto");
|
|
123
|
+
assert.ok(config.multiAgent);
|
|
124
|
+
assert.equal(config.multiAgent.maxConcurrency, 3);
|
|
125
|
+
});
|
|
126
|
+
it("isValidMode accepts valid modes", () => {
|
|
127
|
+
assert.ok(isValidMode("auto"));
|
|
128
|
+
assert.ok(isValidMode("orchestration"));
|
|
129
|
+
assert.ok(isValidMode("mcp_sampling"));
|
|
130
|
+
assert.ok(isValidMode("direct_api"));
|
|
131
|
+
});
|
|
132
|
+
it("isValidMode rejects invalid modes", () => {
|
|
133
|
+
assert.ok(!isValidMode("invalid"));
|
|
134
|
+
assert.ok(!isValidMode(""));
|
|
135
|
+
});
|
|
136
|
+
it("isValidConcurrency validates range", () => {
|
|
137
|
+
assert.ok(isValidConcurrency(1));
|
|
138
|
+
assert.ok(isValidConcurrency(5));
|
|
139
|
+
assert.ok(isValidConcurrency(10));
|
|
140
|
+
assert.ok(!isValidConcurrency(0));
|
|
141
|
+
assert.ok(!isValidConcurrency(11));
|
|
142
|
+
assert.ok(!isValidConcurrency(-1));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// Config Persistence Tests
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
describe("config persistence", () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
// Set config path to temp directory
|
|
151
|
+
setConfigPath(tmpDir);
|
|
152
|
+
});
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
// Clean up config file if exists
|
|
155
|
+
const configPath = path.join(tmpDir, "kevlar-config.json");
|
|
156
|
+
if (fs.existsSync(configPath)) {
|
|
157
|
+
fs.unlinkSync(configPath);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
it("writes and reads config", async () => {
|
|
161
|
+
const updated = await updateConfig({
|
|
162
|
+
mode: "mcp_sampling",
|
|
163
|
+
maxConcurrency: 5,
|
|
164
|
+
});
|
|
165
|
+
assert.equal(updated.mode, "mcp_sampling");
|
|
166
|
+
assert.equal(updated.multiAgent.maxConcurrency, 5);
|
|
167
|
+
const read = readConfig();
|
|
168
|
+
assert.equal(read.mode, "mcp_sampling");
|
|
169
|
+
assert.equal(read.multiAgent.maxConcurrency, 5);
|
|
170
|
+
});
|
|
171
|
+
it("preserves existing config when partially updating", async () => {
|
|
172
|
+
// First update
|
|
173
|
+
await updateConfig({ mode: "direct_api" });
|
|
174
|
+
// Partial update
|
|
175
|
+
const updated = await updateConfig({ maxConcurrency: 8 });
|
|
176
|
+
assert.equal(updated.mode, "direct_api"); // Preserved
|
|
177
|
+
assert.equal(updated.multiAgent.maxConcurrency, 8); // Updated
|
|
178
|
+
});
|
|
179
|
+
it("returns defaults when config file missing", () => {
|
|
180
|
+
const config = readConfig();
|
|
181
|
+
assert.equal(config.mode, "auto");
|
|
182
|
+
assert.equal(config.multiAgent.maxConcurrency, 3);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
// Rate Limiter Tests
|
|
187
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
188
|
+
describe("RateLimiter", () => {
|
|
189
|
+
it("acquires and releases permits", async () => {
|
|
190
|
+
const limiter = new RateLimiter({ maxConcurrent: 2, minDelayMs: 0 });
|
|
191
|
+
await limiter.acquire();
|
|
192
|
+
await limiter.acquire();
|
|
193
|
+
limiter.release();
|
|
194
|
+
limiter.release();
|
|
195
|
+
// Should not throw
|
|
196
|
+
await limiter.acquire();
|
|
197
|
+
limiter.release();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe("isRetryableError", () => {
|
|
201
|
+
it("identifies retryable errors", () => {
|
|
202
|
+
assert.ok(isRetryableError("rate_limit_exceeded"));
|
|
203
|
+
assert.ok(isRetryableError("service_unavailable"));
|
|
204
|
+
assert.ok(isRetryableError("timeout"));
|
|
205
|
+
assert.ok(isRetryableError("network_error"));
|
|
206
|
+
});
|
|
207
|
+
it("rejects non-retryable errors", () => {
|
|
208
|
+
assert.ok(!isRetryableError("invalid_api_key"));
|
|
209
|
+
assert.ok(!isRetryableError("unknown"));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe("withRetry", () => {
|
|
213
|
+
it("succeeds without retries", async () => {
|
|
214
|
+
let callCount = 0;
|
|
215
|
+
const result = await withRetry(async () => {
|
|
216
|
+
callCount++;
|
|
217
|
+
return "success";
|
|
218
|
+
});
|
|
219
|
+
assert.equal(result, "success");
|
|
220
|
+
assert.equal(callCount, 1);
|
|
221
|
+
});
|
|
222
|
+
it("retries on failure and succeeds", async () => {
|
|
223
|
+
let callCount = 0;
|
|
224
|
+
const result = await withRetry(async () => {
|
|
225
|
+
callCount++;
|
|
226
|
+
if (callCount < 3) {
|
|
227
|
+
throw new Error("rate limit exceeded");
|
|
228
|
+
}
|
|
229
|
+
return "success";
|
|
230
|
+
}, { maxRetries: 3 });
|
|
231
|
+
assert.equal(result, "success");
|
|
232
|
+
assert.equal(callCount, 3);
|
|
233
|
+
});
|
|
234
|
+
it("throws after max retries", async () => {
|
|
235
|
+
let callCount = 0;
|
|
236
|
+
await assert.rejects(async () => {
|
|
237
|
+
await withRetry(async () => {
|
|
238
|
+
callCount++;
|
|
239
|
+
throw new Error("rate limit exceeded");
|
|
240
|
+
}, { maxRetries: 2 });
|
|
241
|
+
}, /rate limit exceeded/);
|
|
242
|
+
assert.equal(callCount, 3); // Initial + 2 retries
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
// Result Aggregator Tests
|
|
247
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
248
|
+
describe("ResultAggregator", () => {
|
|
249
|
+
it("collects successful results", () => {
|
|
250
|
+
const aggregator = new ResultAggregator();
|
|
251
|
+
aggregator.addSuccess({
|
|
252
|
+
personaId: "p1",
|
|
253
|
+
personaName: "人设1",
|
|
254
|
+
review: "很好",
|
|
255
|
+
});
|
|
256
|
+
const successful = aggregator.getSuccessful();
|
|
257
|
+
assert.equal(successful.length, 1);
|
|
258
|
+
assert.equal(successful[0].personaId, "p1");
|
|
259
|
+
assert.ok(!successful[0].error);
|
|
260
|
+
});
|
|
261
|
+
it("collects failed results", () => {
|
|
262
|
+
const aggregator = new ResultAggregator();
|
|
263
|
+
aggregator.addFailure("p1", "人设1", "Network error");
|
|
264
|
+
const failed = aggregator.getFailed();
|
|
265
|
+
assert.equal(failed.length, 1);
|
|
266
|
+
assert.equal(failed[0].personaId, "p1");
|
|
267
|
+
assert.equal(failed[0].error, "Network error");
|
|
268
|
+
});
|
|
269
|
+
it("calculates success rate", () => {
|
|
270
|
+
const aggregator = new ResultAggregator();
|
|
271
|
+
aggregator.addSuccess({ personaId: "p1", personaName: "A", review: "OK" });
|
|
272
|
+
aggregator.addSuccess({ personaId: "p2", personaName: "B", review: "OK" });
|
|
273
|
+
aggregator.addFailure("p3", "C", "Error");
|
|
274
|
+
const partial = aggregator.getPartialResult();
|
|
275
|
+
assert.equal(partial.successRate, 2 / 3);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe("generateAggregatedReport", () => {
|
|
279
|
+
it("generates report with mode label", () => {
|
|
280
|
+
const report = generateAggregatedReport({
|
|
281
|
+
mode: "mcp_sampling",
|
|
282
|
+
contentSummary: "测试内容摘要",
|
|
283
|
+
personas: [
|
|
284
|
+
{
|
|
285
|
+
personaId: "p1",
|
|
286
|
+
personaName: "人设1",
|
|
287
|
+
review: "评论内容",
|
|
288
|
+
completedAt: new Date(),
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
assert.ok(report.includes("MCP 采样模式"));
|
|
293
|
+
assert.ok(report.includes("测试内容摘要"));
|
|
294
|
+
assert.ok(report.includes("人设1"));
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
298
|
+
// Token Budget Tests
|
|
299
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
describe("estimateTokenCost", () => {
|
|
301
|
+
it("estimates based on content and persona count", () => {
|
|
302
|
+
const cost = estimateTokenCost(3, 1000);
|
|
303
|
+
// (1000/3) + 3*10000 = 333 + 30000 = 30333
|
|
304
|
+
assert.equal(cost, 30333);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
describe("checkBudget", () => {
|
|
308
|
+
it("does not throw when under budget", () => {
|
|
309
|
+
// Clear env var
|
|
310
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
311
|
+
assert.doesNotThrow(() => checkBudget(1, 1000));
|
|
312
|
+
});
|
|
313
|
+
it("throws when over budget", () => {
|
|
314
|
+
process.env.KEVLAR_TOKEN_BUDGET_PER_TASK = "100";
|
|
315
|
+
assert.throws(() => checkBudget(5, 10000), /超出预算/);
|
|
316
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
// Review Lock Tests
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
describe("Review Lock", () => {
|
|
323
|
+
it("acquires lock when not locked", () => {
|
|
324
|
+
assert.ok(!isLocked());
|
|
325
|
+
assert.ok(acquireReviewLock("mcp_sampling"));
|
|
326
|
+
assert.ok(isLocked());
|
|
327
|
+
});
|
|
328
|
+
it("fails to acquire when already locked", () => {
|
|
329
|
+
acquireReviewLock("mcp_sampling");
|
|
330
|
+
assert.ok(!acquireReviewLock("direct_api"));
|
|
331
|
+
});
|
|
332
|
+
it("releases lock correctly", () => {
|
|
333
|
+
acquireReviewLock("mcp_sampling");
|
|
334
|
+
releaseReviewLock();
|
|
335
|
+
assert.ok(!isLocked());
|
|
336
|
+
assert.equal(getReviewLock(), null);
|
|
337
|
+
});
|
|
338
|
+
it("returns lock info when locked", () => {
|
|
339
|
+
acquireReviewLock("test_mode");
|
|
340
|
+
const lock = getReviewLock();
|
|
341
|
+
assert.ok(lock);
|
|
342
|
+
assert.equal(lock.mode, "test_mode");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
346
|
+
// executeReview Tests (Mode Resolution)
|
|
347
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
348
|
+
describe("executeReview", () => {
|
|
349
|
+
// Create a mock sampling function for testing
|
|
350
|
+
const mockSamplingFn = async (params) => {
|
|
351
|
+
return { content: "Mock response", stopReason: "endTurn" };
|
|
352
|
+
};
|
|
353
|
+
const testPersonas = [
|
|
354
|
+
{
|
|
355
|
+
meta: {
|
|
356
|
+
id: "test-1",
|
|
357
|
+
name: "测试人设",
|
|
358
|
+
name_en: "Test Persona",
|
|
359
|
+
version: "1.0.0",
|
|
360
|
+
author: "test",
|
|
361
|
+
tags: [],
|
|
362
|
+
description: "A test persona",
|
|
363
|
+
},
|
|
364
|
+
systemPrompt: "You are a test.",
|
|
365
|
+
filePath: "/test/persona.md",
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
it("executes with orchestration mode", async () => {
|
|
369
|
+
const result = await executeReview("orchestration", {
|
|
370
|
+
skillsDir: tmpDir,
|
|
371
|
+
personas: testPersonas,
|
|
372
|
+
content: "测试内容",
|
|
373
|
+
});
|
|
374
|
+
assert.equal(result.mode, "orchestration");
|
|
375
|
+
assert.deepEqual(result.personas, ["test-1"]);
|
|
376
|
+
assert.ok(result.report.includes("宿主辅助兜底模式"));
|
|
377
|
+
});
|
|
378
|
+
it("throws for unknown mode", async () => {
|
|
379
|
+
await assert.rejects(async () => {
|
|
380
|
+
await executeReview("unknown_mode", {
|
|
381
|
+
skillsDir: tmpDir,
|
|
382
|
+
personas: testPersonas,
|
|
383
|
+
content: "测试",
|
|
384
|
+
});
|
|
385
|
+
}, /未知执行模式/);
|
|
386
|
+
});
|
|
387
|
+
describe("mcp_sampling mode", () => {
|
|
388
|
+
beforeEach(() => {
|
|
389
|
+
// Set client to claude-ai so isSamplingSupported() returns true
|
|
390
|
+
setClientInfo("claude-ai");
|
|
391
|
+
});
|
|
392
|
+
it("throws when samplingFn not provided", async () => {
|
|
393
|
+
await assert.rejects(async () => {
|
|
394
|
+
await executeReview("mcp_sampling", {
|
|
395
|
+
skillsDir: tmpDir,
|
|
396
|
+
personas: testPersonas,
|
|
397
|
+
content: "测试",
|
|
398
|
+
});
|
|
399
|
+
}, /MCP Sampling 模式需要 samplingFn/);
|
|
400
|
+
});
|
|
401
|
+
it("executes with samplingFn", async () => {
|
|
402
|
+
const result = await executeReview("mcp_sampling", {
|
|
403
|
+
skillsDir: tmpDir,
|
|
404
|
+
personas: testPersonas,
|
|
405
|
+
content: "测试内容",
|
|
406
|
+
samplingFn: mockSamplingFn,
|
|
407
|
+
});
|
|
408
|
+
assert.equal(result.mode, "mcp_sampling");
|
|
409
|
+
assert.ok(result.report.includes("MCP 采样模式"));
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
it("throws when mode not available (client not supported)", async () => {
|
|
413
|
+
// Set client to unknown
|
|
414
|
+
setClientInfo("unknown-client");
|
|
415
|
+
await assert.rejects(async () => {
|
|
416
|
+
await executeReview("mcp_sampling", {
|
|
417
|
+
skillsDir: tmpDir,
|
|
418
|
+
personas: testPersonas,
|
|
419
|
+
content: "测试",
|
|
420
|
+
samplingFn: mockSamplingFn,
|
|
421
|
+
});
|
|
422
|
+
}, /mcp_sampling 模式当前不可用/);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
426
|
+
// validatePersonaFields Tests
|
|
427
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
428
|
+
describe("validatePersonaFields", () => {
|
|
429
|
+
it("succeeds for valid custom persona", () => {
|
|
430
|
+
const validCustom = {
|
|
431
|
+
meta: { id: "custom", name: "Custom", name_en: "", version: "1.0", author: "user", tags: ["通用"], description: "活跃于平台" },
|
|
432
|
+
systemPrompt: "性格特质:温和。盲区:无。",
|
|
433
|
+
filePath: "",
|
|
434
|
+
};
|
|
435
|
+
assert.doesNotThrow(() => validatePersonaFields(validCustom));
|
|
436
|
+
});
|
|
437
|
+
it("throws for custom persona missing platform", () => {
|
|
438
|
+
const invalid = {
|
|
439
|
+
meta: { id: "custom", name: "Custom", name_en: "", version: "1.0", author: "user", tags: [], description: "有详尽的性格描述", blindSpot: "无" },
|
|
440
|
+
systemPrompt: "性格特质:温和。盲区:无。",
|
|
441
|
+
filePath: "",
|
|
442
|
+
};
|
|
443
|
+
assert.throws(() => validatePersonaFields(invalid), /缺少平台/);
|
|
444
|
+
});
|
|
445
|
+
it("throws for custom persona missing traits", () => {
|
|
446
|
+
const invalid = {
|
|
447
|
+
meta: { id: "custom", name: "Custom", name_en: "", version: "1.0", author: "user", tags: ["通用"], description: "A", blindSpot: "无" },
|
|
448
|
+
systemPrompt: "盲区:无。",
|
|
449
|
+
filePath: "",
|
|
450
|
+
};
|
|
451
|
+
assert.throws(() => validatePersonaFields(invalid), /缺少性格描述/);
|
|
452
|
+
});
|
|
453
|
+
it("throws for custom persona missing blind spot", () => {
|
|
454
|
+
const invalid = {
|
|
455
|
+
meta: { id: "custom", name: "Custom", name_en: "", version: "1.0", author: "user", tags: ["通用"], description: "性格温和的评论员" },
|
|
456
|
+
systemPrompt: "性格特质:温和。",
|
|
457
|
+
filePath: "",
|
|
458
|
+
};
|
|
459
|
+
assert.throws(() => validatePersonaFields(invalid), /缺少盲区/);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// loadPersonasForReview Tests
|
|
464
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
465
|
+
describe("loadPersonasForReview", () => {
|
|
466
|
+
beforeEach(async () => {
|
|
467
|
+
// Create a test persona file in tmpDir
|
|
468
|
+
const personaPath = path.join(tmpDir, "test_persona.md");
|
|
469
|
+
fs.writeFileSync(personaPath, [
|
|
470
|
+
"---",
|
|
471
|
+
"id: test_persona",
|
|
472
|
+
"name: 测试人设",
|
|
473
|
+
"name_en: Test",
|
|
474
|
+
"version: 1.0.0",
|
|
475
|
+
"author: test",
|
|
476
|
+
"tags:",
|
|
477
|
+
" - test",
|
|
478
|
+
"description: A test persona",
|
|
479
|
+
"blindSpot: none",
|
|
480
|
+
"---",
|
|
481
|
+
"常用平台:通用",
|
|
482
|
+
"性格特质:温和",
|
|
483
|
+
"盲区:无",
|
|
484
|
+
].join("\n"), "utf-8");
|
|
485
|
+
});
|
|
486
|
+
it("returns empty personas when directory empty", async () => {
|
|
487
|
+
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "kevlar-empty-"));
|
|
488
|
+
try {
|
|
489
|
+
const { personas } = await loadPersonasForReview(emptyDir);
|
|
490
|
+
assert.equal(personas.length, 0);
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
fs.rmSync(emptyDir, { recursive: true, force: true });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
it("loads all personas when no ids specified", async () => {
|
|
497
|
+
const { personas, missingIds } = await loadPersonasForReview(tmpDir);
|
|
498
|
+
assert.equal(personas.length, 1);
|
|
499
|
+
assert.equal(personas[0].meta.id, "test_persona");
|
|
500
|
+
assert.equal(missingIds, undefined);
|
|
501
|
+
});
|
|
502
|
+
it("loads specific personas by ids", async () => {
|
|
503
|
+
const { personas, missingIds } = await loadPersonasForReview(tmpDir, ["test_persona"]);
|
|
504
|
+
assert.equal(personas.length, 1);
|
|
505
|
+
assert.ok(!missingIds);
|
|
506
|
+
});
|
|
507
|
+
it("reports missing ids", async () => {
|
|
508
|
+
const { personas, missingIds } = await loadPersonasForReview(tmpDir, ["nonexistent"]);
|
|
509
|
+
assert.equal(personas.length, 0);
|
|
510
|
+
assert.deepEqual(missingIds, ["nonexistent"]);
|
|
511
|
+
});
|
|
512
|
+
it("partially loads with missing ids", async () => {
|
|
513
|
+
const { personas, missingIds } = await loadPersonasForReview(tmpDir, ["test_persona", "ghost"]);
|
|
514
|
+
assert.equal(personas.length, 1);
|
|
515
|
+
assert.equal(personas[0].meta.id, "test_persona");
|
|
516
|
+
assert.deepEqual(missingIds, ["ghost"]);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
520
|
+
// executePersonasInParallel Tests
|
|
521
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
522
|
+
describe("executePersonasInParallel", () => {
|
|
523
|
+
const makePersona = (id, name, sp = "You are a critic.") => ({
|
|
524
|
+
meta: { id, name, name_en: "", version: "1.0", author: "test", tags: [], description: `Persona ${name}` },
|
|
525
|
+
systemPrompt: sp,
|
|
526
|
+
filePath: `/test/${id}.md`,
|
|
527
|
+
});
|
|
528
|
+
it("executes all personas successfully", async () => {
|
|
529
|
+
const personas = [makePersona("p1", "A"), makePersona("p2", "B")];
|
|
530
|
+
const result = await executePersonasInParallel(personas, "Test content", { mode: "mcp_sampling", retryEventName: "test" }, async (p) => `Review by ${p.meta.name}`);
|
|
531
|
+
assert.equal(result.mode, "mcp_sampling");
|
|
532
|
+
assert.equal(result.personas.length, 2);
|
|
533
|
+
assert.ok(result.personas.includes("p1"));
|
|
534
|
+
assert.ok(result.personas.includes("p2"));
|
|
535
|
+
assert.ok(!result.partialFailures || result.partialFailures.length === 0);
|
|
536
|
+
assert.ok(result.report.includes("Review by A"));
|
|
537
|
+
assert.ok(result.report.includes("Review by B"));
|
|
538
|
+
});
|
|
539
|
+
it("collects partial failures", async () => {
|
|
540
|
+
const personas = [makePersona("p1", "Good"), makePersona("p2", "Bad")];
|
|
541
|
+
const result = await executePersonasInParallel(personas, "Test", { mode: "direct_api", retryEventName: "test" }, async (p) => {
|
|
542
|
+
if (p.meta.id === "p2")
|
|
543
|
+
throw new Error("Intentional failure");
|
|
544
|
+
return `OK from ${p.meta.name}`;
|
|
545
|
+
});
|
|
546
|
+
assert.ok(result.personas.includes("p1"));
|
|
547
|
+
assert.ok(!result.personas.includes("p2"));
|
|
548
|
+
assert.ok(result.partialFailures);
|
|
549
|
+
assert.equal(result.partialFailures.length, 1);
|
|
550
|
+
assert.equal(result.partialFailures[0].personaId, "p2");
|
|
551
|
+
});
|
|
552
|
+
it("respects maxConcurrency from config", async () => {
|
|
553
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
554
|
+
let maxConcurrent = 0;
|
|
555
|
+
let current = 0;
|
|
556
|
+
const personas = [makePersona("p1", "A"), makePersona("p2", "B"), makePersona("p3", "C")];
|
|
557
|
+
await executePersonasInParallel(personas, "Test", { mode: "mcp_sampling", retryEventName: "test" }, async () => {
|
|
558
|
+
current++;
|
|
559
|
+
maxConcurrent = Math.max(maxConcurrent, current);
|
|
560
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
561
|
+
current--;
|
|
562
|
+
return "done";
|
|
563
|
+
});
|
|
564
|
+
// With 3 personas and default concurrency 3, should never exceed that
|
|
565
|
+
assert.ok(maxConcurrent <= 3);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
569
|
+
// Direct API Handler Tests
|
|
570
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
571
|
+
describe("directApiHandler", () => {
|
|
572
|
+
const makePersona = (id, name, sp = "You are a critic.") => ({
|
|
573
|
+
meta: { id, name, name_en: "", version: "1.0", author: "test", tags: [], description: `Persona ${name}` },
|
|
574
|
+
systemPrompt: sp,
|
|
575
|
+
filePath: `/test/${id}.md`,
|
|
576
|
+
});
|
|
577
|
+
let previousKevlarKey;
|
|
578
|
+
let previousAnthropicKey;
|
|
579
|
+
let previousOpenAiKey;
|
|
580
|
+
beforeEach(() => {
|
|
581
|
+
previousKevlarKey = process.env.KEVLAR_API_KEY;
|
|
582
|
+
previousAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
583
|
+
previousOpenAiKey = process.env.OPENAI_API_KEY;
|
|
584
|
+
delete process.env.KEVLAR_API_KEY;
|
|
585
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
586
|
+
delete process.env.OPENAI_API_KEY;
|
|
587
|
+
});
|
|
588
|
+
afterEach(() => {
|
|
589
|
+
if (previousKevlarKey === undefined)
|
|
590
|
+
delete process.env.KEVLAR_API_KEY;
|
|
591
|
+
else
|
|
592
|
+
process.env.KEVLAR_API_KEY = previousKevlarKey;
|
|
593
|
+
if (previousAnthropicKey === undefined)
|
|
594
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
595
|
+
else
|
|
596
|
+
process.env.ANTHROPIC_API_KEY = previousAnthropicKey;
|
|
597
|
+
if (previousOpenAiKey === undefined)
|
|
598
|
+
delete process.env.OPENAI_API_KEY;
|
|
599
|
+
else
|
|
600
|
+
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
|
601
|
+
});
|
|
602
|
+
it("canExecute returns false when no API key set", () => {
|
|
603
|
+
assert.ok(!directApiHandler.canExecute());
|
|
604
|
+
});
|
|
605
|
+
it("canExecute returns true when KEVLAR_API_KEY is set", () => {
|
|
606
|
+
process.env.KEVLAR_API_KEY = "sk-ant-test-key-12345";
|
|
607
|
+
assert.ok(directApiHandler.canExecute());
|
|
608
|
+
});
|
|
609
|
+
it("has correct mode and priority", () => {
|
|
610
|
+
assert.equal(directApiHandler.mode, "direct_api");
|
|
611
|
+
assert.equal(directApiHandler.priority, 20);
|
|
612
|
+
});
|
|
613
|
+
it("throws when no API key available on execute", async () => {
|
|
614
|
+
await assert.rejects(() => directApiHandler.execute({
|
|
615
|
+
skillsDir: "/tmp",
|
|
616
|
+
personas: [makePersona("p1", "A")],
|
|
617
|
+
content: "test",
|
|
618
|
+
}), /API key not configured/);
|
|
619
|
+
});
|
|
620
|
+
it("executes with Anthropic provider and mocked fetch", async () => {
|
|
621
|
+
process.env.KEVLAR_API_KEY = "sk-ant-test-key-anthropic-12345";
|
|
622
|
+
process.env.KEVLAR_TOKEN_BUDGET_PER_TASK = "100000";
|
|
623
|
+
const mockResponse = {
|
|
624
|
+
content: [{ type: "text", text: "Anthropic review result" }],
|
|
625
|
+
usage: { input_tokens: 50, output_tokens: 100 },
|
|
626
|
+
stop_reason: "end_turn",
|
|
627
|
+
};
|
|
628
|
+
const origFetch = globalThis.fetch;
|
|
629
|
+
globalThis.fetch = async (url, init) => {
|
|
630
|
+
assert.ok(url.toString().includes("anthropic.com"));
|
|
631
|
+
const body = JSON.parse(init?.body);
|
|
632
|
+
assert.ok(body.model);
|
|
633
|
+
assert.ok(body.system);
|
|
634
|
+
assert.equal(body.temperature, 0.7);
|
|
635
|
+
return new Response(JSON.stringify(mockResponse), { status: 200 });
|
|
636
|
+
};
|
|
637
|
+
try {
|
|
638
|
+
const result = await directApiHandler.execute({
|
|
639
|
+
skillsDir: "/tmp",
|
|
640
|
+
personas: [makePersona("p1", "Critic")],
|
|
641
|
+
content: "test content",
|
|
642
|
+
});
|
|
643
|
+
assert.equal(result.mode, "direct_api");
|
|
644
|
+
assert.equal(result.personas.length, 1);
|
|
645
|
+
assert.ok(result.report.includes("Anthropic review result"));
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
globalThis.fetch = origFetch;
|
|
649
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
it("executes with OpenAI provider and mocked fetch", async () => {
|
|
653
|
+
process.env.KEVLAR_API_KEY = "sk-openai-key-1234567890abcdef";
|
|
654
|
+
process.env.KEVLAR_TOKEN_BUDGET_PER_TASK = "100000";
|
|
655
|
+
const mockResponse = {
|
|
656
|
+
choices: [{ message: { content: "OpenAI review result" } }],
|
|
657
|
+
usage: { prompt_tokens: 50, completion_tokens: 100 },
|
|
658
|
+
};
|
|
659
|
+
const origFetch = globalThis.fetch;
|
|
660
|
+
globalThis.fetch = async (url) => {
|
|
661
|
+
assert.ok(url.toString().includes("openai.com"));
|
|
662
|
+
return new Response(JSON.stringify(mockResponse), { status: 200 });
|
|
663
|
+
};
|
|
664
|
+
try {
|
|
665
|
+
const result = await directApiHandler.execute({
|
|
666
|
+
skillsDir: "/tmp",
|
|
667
|
+
personas: [makePersona("p1", "Critic")],
|
|
668
|
+
content: "test openai",
|
|
669
|
+
});
|
|
670
|
+
assert.equal(result.mode, "direct_api");
|
|
671
|
+
assert.ok(result.report.includes("OpenAI review result"));
|
|
672
|
+
}
|
|
673
|
+
finally {
|
|
674
|
+
globalThis.fetch = origFetch;
|
|
675
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
it("executes with Ollama provider and mocked fetch", async () => {
|
|
679
|
+
process.env.KEVLAR_API_KEY = "ollama-local-key";
|
|
680
|
+
process.env.OLLAMA_BASE_URL = "http://localhost:11434";
|
|
681
|
+
process.env.KEVLAR_MODEL = "llama3";
|
|
682
|
+
process.env.KEVLAR_TOKEN_BUDGET_PER_TASK = "100000";
|
|
683
|
+
const mockResponse = {
|
|
684
|
+
message: { content: "Ollama review result" },
|
|
685
|
+
};
|
|
686
|
+
const origFetch = globalThis.fetch;
|
|
687
|
+
globalThis.fetch = async (url) => {
|
|
688
|
+
assert.ok(url.toString().includes("localhost:11434"));
|
|
689
|
+
return new Response(JSON.stringify(mockResponse), { status: 200 });
|
|
690
|
+
};
|
|
691
|
+
try {
|
|
692
|
+
const result = await directApiHandler.execute({
|
|
693
|
+
skillsDir: "/tmp",
|
|
694
|
+
personas: [makePersona("p1", "Critic")],
|
|
695
|
+
content: "test ollama",
|
|
696
|
+
});
|
|
697
|
+
assert.equal(result.mode, "direct_api");
|
|
698
|
+
assert.ok(result.report.includes("Ollama review result"));
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
globalThis.fetch = origFetch;
|
|
702
|
+
delete process.env.OLLAMA_BASE_URL;
|
|
703
|
+
delete process.env.KEVLAR_MODEL;
|
|
704
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
it("reports partial failures for persona errors", async () => {
|
|
708
|
+
process.env.KEVLAR_API_KEY = "sk-ant-fail-key";
|
|
709
|
+
process.env.KEVLAR_TOKEN_BUDGET_PER_TASK = "100000";
|
|
710
|
+
const origFetch = globalThis.fetch;
|
|
711
|
+
globalThis.fetch = async () => {
|
|
712
|
+
return new Response(JSON.stringify({
|
|
713
|
+
content: [{ type: "text", text: "ok" }],
|
|
714
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
715
|
+
stop_reason: "end_turn",
|
|
716
|
+
}), { status: 200 });
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const personas = [makePersona("p1", "Good"), makePersona("p2", "Bad")];
|
|
720
|
+
const result = await directApiHandler.execute({
|
|
721
|
+
skillsDir: "/tmp",
|
|
722
|
+
personas,
|
|
723
|
+
content: "test",
|
|
724
|
+
});
|
|
725
|
+
assert.ok(result.personas.includes("p1"));
|
|
726
|
+
assert.equal(result.mode, "direct_api");
|
|
727
|
+
}
|
|
728
|
+
finally {
|
|
729
|
+
globalThis.fetch = origFetch;
|
|
730
|
+
delete process.env.KEVLAR_TOKEN_BUDGET_PER_TASK;
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
735
|
+
// Sampling Handler Tests
|
|
736
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
describe("samplingHandler", () => {
|
|
738
|
+
const makePersona = (id, name, sp = "You are a critic.") => ({
|
|
739
|
+
meta: { id, name, name_en: "", version: "1.0", author: "test", tags: [], description: `Persona ${name}` },
|
|
740
|
+
systemPrompt: sp,
|
|
741
|
+
filePath: `/test/${id}.md`,
|
|
742
|
+
});
|
|
743
|
+
let previousEnv;
|
|
744
|
+
beforeEach(() => {
|
|
745
|
+
previousEnv = process.env.KEVLAR_ENABLE_SAMPLING;
|
|
746
|
+
});
|
|
747
|
+
afterEach(() => {
|
|
748
|
+
setClientInfo("unknown");
|
|
749
|
+
if (previousEnv === undefined)
|
|
750
|
+
delete process.env.KEVLAR_ENABLE_SAMPLING;
|
|
751
|
+
else
|
|
752
|
+
process.env.KEVLAR_ENABLE_SAMPLING = previousEnv;
|
|
753
|
+
});
|
|
754
|
+
it("has correct mode and priority", () => {
|
|
755
|
+
assert.equal(samplingHandler.mode, "mcp_sampling");
|
|
756
|
+
assert.equal(samplingHandler.priority, 10);
|
|
757
|
+
});
|
|
758
|
+
it("canExecute returns true when client supports sampling", () => {
|
|
759
|
+
setClientInfo("claude-ai");
|
|
760
|
+
assert.ok(samplingHandler.canExecute());
|
|
761
|
+
});
|
|
762
|
+
it("canExecute returns false when client does not support sampling", () => {
|
|
763
|
+
setClientInfo("unknown-client");
|
|
764
|
+
assert.ok(!samplingHandler.canExecute());
|
|
765
|
+
});
|
|
766
|
+
it("canExecute returns true when KEVLAR_ENABLE_SAMPLING=true", () => {
|
|
767
|
+
setClientInfo("unknown-client");
|
|
768
|
+
process.env.KEVLAR_ENABLE_SAMPLING = "true";
|
|
769
|
+
assert.ok(samplingHandler.canExecute());
|
|
770
|
+
});
|
|
771
|
+
it("throws when no samplingFn provided on execute", async () => {
|
|
772
|
+
setClientInfo("claude-ai");
|
|
773
|
+
await assert.rejects(() => samplingHandler.execute({
|
|
774
|
+
skillsDir: "/tmp",
|
|
775
|
+
personas: [makePersona("p1", "A")],
|
|
776
|
+
content: "test",
|
|
777
|
+
}), /MCP Sampling 模式需要 samplingFn/);
|
|
778
|
+
});
|
|
779
|
+
it("executes with samplingFn", async () => {
|
|
780
|
+
setClientInfo("claude-ai");
|
|
781
|
+
const result = await samplingHandler.execute({
|
|
782
|
+
skillsDir: "/tmp",
|
|
783
|
+
personas: [makePersona("p1", "A"), makePersona("p2", "B")],
|
|
784
|
+
content: "test content",
|
|
785
|
+
samplingFn: async () => ({ content: "mock review", stopReason: "endTurn" }),
|
|
786
|
+
});
|
|
787
|
+
assert.equal(result.mode, "mcp_sampling");
|
|
788
|
+
assert.equal(result.personas.length, 2);
|
|
789
|
+
assert.ok(result.report.includes("mock review"));
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
//# sourceMappingURL=execution.test.js.map
|